← back to Letsbegin

glitter_full_monty.js

619 lines

#!/usr/bin/env node
/**
 * Glitter Walls Full Monty (NO room settings)
 * ~410 products, vendor "Glitter Walls"
 *
 * Pipeline:
 *   1. Migrate global.* → custom.* metafields (width, fire_rating, material, repeat, match_type, etc.)
 *   2. AI color analysis via Gemini (dominant color name, hex, color_details JSON)
 *   3. Body rewrite to exactly 2 paragraphs (editorial + AI description)
 *   4. Title update: add color if missing, format "Pattern - Color | Glitter Walls"
 *   5. Set custom.full_monty_date = "2026-04-06"
 *   6. Update alt tags on images: "{SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
 *   7. NO room settings generation
 */

const https = require('https');
const http = require('http');
const fs = require('fs');

const STORE = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
const GEMINI_KEY = process.env.GEMINI_API_KEY;
if (!GEMINI_KEY) {
  throw new Error('GEMINI_API_KEY environment variable is required');
}
if (!TOKEN) {
  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
}
const FULL_MONTY_DATE = '2026-04-06';
const LOG_FILE = '/tmp/glitter_monty.log';

// Initialize log
fs.writeFileSync(LOG_FILE, `=== Glitter Walls Full Monty ===\nStarted: ${new Date().toISOString()}\n\n`);

function log(msg) {
  const line = `[${new Date().toISOString()}] ${msg}`;
  console.log(line);
  fs.appendFileSync(LOG_FILE, line + '\n');
}

function gqlRaw(body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request({
      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } }); });
    req.on('error', reject);
    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
    req.write(data); req.end();
  });
}

async function gql(body, retries = 3) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const result = await gqlRaw(body);
      // Check for throttling
      if (result?.errors?.[0]?.message?.includes('Throttled')) {
        log(`  GQL throttled, waiting 3s (attempt ${attempt}/${retries})`);
        await sleep(3000);
        continue;
      }
      return result;
    } catch (e) {
      if (attempt < retries) {
        log(`  GQL error: ${e.message}, retrying in ${attempt * 2}s (attempt ${attempt}/${retries})`);
        await sleep(attempt * 2000);
        continue;
      }
      throw e;
    }
  }
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

function downloadImage(url, dest) {
  return new Promise((resolve, reject) => {
    const mod = url.startsWith('https') ? https : http;
    const file = fs.createWriteStream(dest);
    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        file.close(); return downloadImage(res.headers.location, dest).then(resolve).catch(reject);
      }
      if (res.statusCode !== 200) {
        file.close(); return reject(new Error(`HTTP ${res.statusCode}`));
      }
      res.pipe(file); file.on('finish', () => { file.close(); resolve(); });
    }).on('error', e => { file.close(); reject(e); });
  });
}

function geminiCall(reqBody) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(reqBody);
    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`);
    const req = https.request({
      hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
    }, res => {
      const chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch { resolve(null); } });
    });
    req.on('error', () => resolve(null));
    req.setTimeout(120000, () => { req.destroy(); resolve(null); });
    req.write(data); req.end();
  });
}

// ---- Cleaners for global → custom migration ----

function cleanWidth(v) {
  if (!v) return null;
  const m = v.match(/([\d.]+)/);
  return m ? `${m[1]} Inches` : null;
}

function cleanRepeat(v) {
  if (!v || v === 'N/A' || v === '0' || v.toLowerCase() === 'random' || v.toLowerCase() === 'none') return null;
  const m = v.match(/([\d.]+)/);
  return m ? `${m[1]} Inches` : null;
}

function cleanFireRating(v) {
  if (!v) return null;
  let f = v.replace(/["']/g, '').trim();
  if (f.length > 100) return null;
  if (/class\s*a/i.test(f) && !f.includes('ASTM')) f = 'ASTM E-84 Class A';
  return f || null;
}

function cleanMaterial(v) {
  if (!v) return null;
  return v.replace(/\bWallpaper\b/gi, 'Wallcovering').replace(/\bWallpapers\b/gi, 'Wallcoverings').trim() || null;
}

// Global → Custom field mapping
const FIELD_MAP = {
  'width': { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
  'Width': { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
  'repeat': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'Vert-Rpt': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'Vert-Repeat': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'fire_rating': { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
  'FLAMMABILITY': { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
  'Contents': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
  'Content': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
  'Construction': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
  'Collection': { key: 'collection_name', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Brand': { key: 'brand', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'MATCH': { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Match': { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Finish': { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'FINISH': { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Cleaning': { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Clean-Code': { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'application': { key: 'application', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Substrate': { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'substrate': { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'length': { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Length': { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'packaged': { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Packaged': { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'unit_of_measure': { key: 'unit_of_measure', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Weight': { key: 'product_weight', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Country': { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Country-of-Origin': { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'color': { key: 'color', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Color-Way': { key: 'color', clean: v => v?.trim() || null, type: 'single_line_text_field' },
};

// ---- Title parsing helpers ----

function extractPatternAndColor(title) {
  // Try to extract pattern name and color from title
  // Formats seen:
  //   "Venezia Glass Beads Wallcovering - Gold"
  //   "Venezia Glass Beads - Red Rock"
  //   "Venezia(tm) Glass Beads - Silver"
  //   "Sequin - Eel Skin White Gold Wallcovering"
  //   "Pattern - Color | Glitter Walls" (already processed)

  // First, strip "| Glitter Walls" suffix if present (already processed product)
  let cleaned = title.replace(/\s*\|\s*Glitter\s*Walls\s*$/i, '').trim();
  // Remove (tm), wallcovering(s), wallpaper(s)
  cleaned = cleaned.replace(/\(tm\)/gi, '').replace(/\bwallcoverings?\b/gi, '').replace(/\bwall\s*coverings?\b/gi, '').replace(/\bwall\s*papers?\b/gi, '').replace(/\s+/g, ' ').trim();

  // Try dash split — use the LAST dash to handle patterns like "Sequin - Eel Skin - White Gold"
  const lastDash = cleaned.lastIndexOf(' - ');
  if (lastDash > 0) {
    // But handle case where there's a dash in the middle like "Sexy Sequin- Miami Beach"
    const dashMatch = cleaned.match(/^(.+?)\s*[-–]\s*(.+)$/);
    if (dashMatch) {
      return { pattern: dashMatch[1].trim(), color: dashMatch[2].trim() };
    }
  }

  // Try pipe
  const pipeMatch = cleaned.match(/^(.+?)\s*\|\s*(.+)$/);
  if (pipeMatch) {
    return { pattern: pipeMatch[1].trim(), color: null };
  }
  return { pattern: cleaned.trim(), color: null };
}

function toTitleCase(s) {
  if (!s) return '';
  return s.replace(/\b\w+/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
}

function buildTitle(pattern, color) {
  // Format: "Pattern - Color | Glitter Walls"
  // Ensure color is Title Case
  const tc = toTitleCase(color);
  if (tc) {
    return `${pattern} - ${tc} | Glitter Walls`;
  }
  return `${pattern} | Glitter Walls`;
}

// ---- Main ----

async function fetchAllProducts() {
  log('Fetching all Glitter Walls products...');
  const allProducts = [];
  let cursor = null;

  while (true) {
    const after = cursor ? `, after: "${cursor}"` : '';
    const query = `{
      products(first: 25, query: "vendor:\\"Glitter Walls\\" AND status:active"${after}) {
        edges {
          cursor
          node {
            id
            title
            bodyHtml
            vendor
            tags
            images(first: 10) {
              edges { node { id url altText } }
            }
            media(first: 10) {
              edges { node { ... on MediaImage { id image { url altText } } } }
            }
            variants(first: 5) {
              edges { node { id sku title } }
            }
            metafields(first: 50) {
              edges { node { namespace key value type } }
            }
          }
        }
        pageInfo { hasNextPage }
      }
    }`;

    const r = await gql({ query });
    const edges = r?.data?.products?.edges || [];

    if (edges.length === 0) break;

    for (const edge of edges) {
      allProducts.push(edge.node);
      cursor = edge.cursor;
    }

    log(`  Fetched ${allProducts.length} products so far...`);

    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
    await sleep(600);
  }

  log(`Total products fetched: ${allProducts.length}\n`);
  return allProducts;
}

async function processProduct(p, idx, total) {
  const pid = p.id;
  const origTitle = p.title;
  log(`\n[${idx + 1}/${total}] ${origTitle}`);

  // Collect metafields
  const globals = {};
  const customs = {};
  for (const m of (p.metafields?.edges || [])) {
    const { namespace, key, value } = m.node;
    if (namespace === 'global' && value) globals[key] = value;
    if (namespace === 'custom' && value) customs[key] = value;
  }

  // Get SKU from first variant
  const firstVariant = p.variants?.edges?.[0]?.node;
  const sku = firstVariant?.sku || '';
  // Clean SKU: strip "-sample" suffix
  const cleanSku = sku.replace(/-sample$/i, '').trim();

  const result = { specs: 0, color: false, body: false, title: false, montyDate: false, alt: 0, errors: [] };

  // ============================================================
  // PHASE 1: Migrate global.* → custom.* metafields
  // ============================================================
  const mfToSet = [];
  const seen = new Set();

  for (const [gKey, mapping] of Object.entries(FIELD_MAP)) {
    if (!globals[gKey]) continue;
    if (customs[mapping.key]) continue;
    if (seen.has(mapping.key)) continue;

    const cleaned = mapping.clean(globals[gKey]);
    if (!cleaned) continue;

    mfToSet.push({
      ownerId: pid, namespace: 'custom', key: mapping.key,
      value: cleaned, type: mapping.type
    });
    seen.add(mapping.key);
  }

  if (mfToSet.length > 0) {
    try {
      const r = await gql({
        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
        variables: { m: mfToSet }
      });
      const errs = r?.data?.metafieldsSet?.userErrors || [];
      if (errs.length > 0) {
        result.errors.push(`Specs: ${errs[0].message}`);
      } else {
        result.specs = mfToSet.length;
        log(`  Phase 1 Specs: ${mfToSet.length} fields migrated`);
      }
    } catch (e) {
      result.errors.push(`Specs: ${e.message}`);
    }
    await sleep(400);
  } else {
    log(`  Phase 1 Specs: already complete`);
  }

  // ============================================================
  // PHASE 2: AI Color Analysis via Gemini
  // ============================================================
  const imgUrl = p.images?.edges?.[0]?.node?.url;
  let colorName = customs['color_name'] || globals['color'] || null;
  let colorHex = customs['color_hex'] || null;
  let aiDesc = '';

  if (imgUrl && !customs['color_details']) {
    try {
      const tmpImg = '/tmp/glitter_img.jpg';
      await downloadImage(imgUrl, tmpImg);
      const imgB64 = fs.readFileSync(tmpImg).toString('base64');

      const colorResult = await geminiCall({
        contents: [{ parts: [
          { text: 'Analyze this wallcovering swatch image. Return ONLY valid JSON (no markdown fencing): {"dominantColor":"ColorName","dominantHex":"#XXXXXX","backgroundColor":"ColorName","backgroundHex":"#XXXXXX","colors":[{"name":"Color Name","hex":"#XXXXXX","percentage":40}]}. Use 3-6 colors summing to 100%. Title Case color names. Be specific with color names (e.g., "Champagne Gold" not just "Gold").' },
          { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
        ]}],
        generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
      });

      const text = colorResult?.candidates?.[0]?.content?.parts?.[0]?.text || '';
      const clean = text.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
      const cd = JSON.parse(clean);

      colorName = cd.dominantColor || colorName;
      colorHex = cd.dominantHex || colorHex;

      const colorMf = [
        { ownerId: pid, namespace: 'custom', key: 'color_hex', value: cd.dominantHex, type: 'single_line_text_field' },
        { ownerId: pid, namespace: 'custom', key: 'background_color', value: cd.backgroundColor, type: 'single_line_text_field' },
        { ownerId: pid, namespace: 'custom', key: 'color_details', value: JSON.stringify(cd.colors), type: 'json' },
        { ownerId: pid, namespace: 'custom', key: 'color_name', value: cd.dominantColor, type: 'single_line_text_field' },
      ];
      await gql({
        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
        variables: { m: colorMf }
      });
      result.color = true;
      log(`  Phase 2 Color: ${cd.dominantColor} (${cd.dominantHex})`);

      // Also get AI description while we have the image
      const descResult = await geminiCall({
        contents: [{ parts: [
          { text: `Describe this wallcovering called "${origTitle}" by Glitter Walls in 2-3 sentences for a design-trade buyer. Mention texture, visual effect, and recommended use (commercial/hospitality/residential). Do NOT use the word "wallpaper" — always say "wallcovering". Do NOT start with the product name.` },
          { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
        ]}],
        generationConfig: { temperature: 0.3, maxOutputTokens: 250 }
      });
      aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/wallpaper/gi, 'wallcovering').trim();

      await sleep(1200);
    } catch (e) {
      result.errors.push(`Color: ${e.message?.slice(0, 60)}`);
      log(`  Phase 2 Color: ERROR ${e.message?.slice(0, 60)}`);
    }
  } else if (customs['color_details']) {
    log(`  Phase 2 Color: already done`);
    result.color = true;
    // If we have color_details but no color_name, try to extract it
    if (!customs['color_name'] && customs['color_details']) {
      try {
        const details = JSON.parse(customs['color_details']);
        if (Array.isArray(details) && details.length > 0) {
          colorName = details[0].name || colorName;
        }
      } catch {}
    }

    // Still need AI description for body rewrite - fetch image
    if (imgUrl) {
      try {
        const tmpImg = '/tmp/glitter_img.jpg';
        await downloadImage(imgUrl, tmpImg);
        const imgB64 = fs.readFileSync(tmpImg).toString('base64');
        const descResult = await geminiCall({
          contents: [{ parts: [
            { text: `Describe this wallcovering called "${origTitle}" by Glitter Walls in 2-3 sentences for a design-trade buyer. Mention texture, visual effect, and recommended use (commercial/hospitality/residential). Do NOT use the word "wallpaper" — always say "wallcovering". Do NOT start with the product name.` },
            { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
          ]}],
          generationConfig: { temperature: 0.3, maxOutputTokens: 250 }
        });
        aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/wallpaper/gi, 'wallcovering').trim();
        await sleep(1200);
      } catch (e) {
        log(`  AI Desc: ERROR ${e.message?.slice(0, 40)}`);
      }
    }
  } else {
    log(`  Phase 2 Color: no image available`);
  }

  // If we still don't have a colorName, try extracting from the title
  if (!colorName) {
    const parsed = extractPatternAndColor(origTitle);
    colorName = parsed.color;
  }

  // ============================================================
  // PHASE 3: Body rewrite — exactly 2 paragraphs
  // ============================================================
  const { pattern, color: parsedColor } = extractPatternAndColor(origTitle);
  // For TITLE: prefer global metafield color (original vendor data) > parsed title color > AI color_name
  // Global metafield 'color' is the original vendor color name, never modified by our scripts
  // Filter out generic/useless color values
  const badColorValues = ['random', 'n/a', 'none', 'varies', 'n a', ''];
  const globalColor = (globals['color'] || globals['Color-Way'] || '').trim();
  const useGlobalColor = globalColor && !badColorValues.includes(globalColor.toLowerCase()) ? globalColor : null;
  const titleColor = useGlobalColor || parsedColor || colorName || '';
  const displayColor = titleColor;

  const editorial = `${pattern}${displayColor ? ' - ' + displayColor : ''} is a premium wallcovering by Glitter Walls, designed to bring luxurious shimmer and texture to any interior. This decorative surface material is ideal for creating striking feature walls in hospitality, retail, and residential spaces.`;
  const aiParagraph = aiDesc || 'Crafted with meticulous attention to detail, this wallcovering delivers a captivating visual presence that transforms ordinary walls into stunning focal points. Perfect for designers seeking to add depth and sophistication to commercial lobbies, boutique hotels, upscale restaurants, and elegant living spaces.';

  const newBody = `<p>${editorial}</p>\n<p>${aiParagraph}</p>`;

  try {
    await gql({
      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
      variables: { i: { id: pid, bodyHtml: newBody } }
    });
    result.body = true;
    log(`  Phase 3 Body: 2 paragraphs set`);
  } catch (e) {
    result.errors.push(`Body: ${e.message?.slice(0, 40)}`);
  }
  await sleep(400);

  // ============================================================
  // PHASE 4: Title update — "Pattern - Color | Glitter Walls"
  // ============================================================
  const newTitle = buildTitle(pattern, titleColor);

  // Only update if title actually changed
  if (newTitle !== origTitle) {
    try {
      await gql({
        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
        variables: { i: { id: pid, title: newTitle } }
      });
      result.title = true;
      log(`  Phase 4 Title: "${origTitle}" → "${newTitle}"`);
    } catch (e) {
      result.errors.push(`Title: ${e.message?.slice(0, 40)}`);
    }
    await sleep(400);
  } else {
    log(`  Phase 4 Title: already correct`);
    result.title = true;
  }

  // ============================================================
  // PHASE 5: Set custom.full_monty_date
  // ============================================================
  if (customs['full_monty_date'] !== FULL_MONTY_DATE) {
    try {
      await gql({
        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
        variables: { m: [
          { ownerId: pid, namespace: 'custom', key: 'full_monty_date', value: FULL_MONTY_DATE, type: 'single_line_text_field' }
        ] }
      });
      result.montyDate = true;
      log(`  Phase 5 Monty date: ${FULL_MONTY_DATE}`);
    } catch (e) {
      result.errors.push(`MontyDate: ${e.message?.slice(0, 40)}`);
    }
    await sleep(300);
  } else {
    result.montyDate = true;
    log(`  Phase 5 Monty date: already set`);
  }

  // ============================================================
  // PHASE 6: Update alt tags on all images
  // ============================================================
  const mediaEdges = p.media?.edges || [];
  const altBase = `${cleanSku || sku} ${pattern} ${titleColor} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();

  if (mediaEdges.length > 0) {
    const mediaUpdates = [];
    for (let mi = 0; mi < mediaEdges.length; mi++) {
      const mediaNode = mediaEdges[mi].node;
      if (!mediaNode?.id) continue;
      const altTag = mi === 0 ? altBase : `${altBase} ${mi + 1}`;
      mediaUpdates.push({ id: mediaNode.id, alt: altTag });
    }

    if (mediaUpdates.length > 0) {
      try {
        // Shopify limit: up to 10 media at a time
        const mediaJson = mediaUpdates.map(m => `{id: "${m.id}", alt: "${m.alt.replace(/"/g, '\\"')}"}`).join(', ');
        await gql({
          query: `mutation { productUpdateMedia(productId: "${pid}", media: [${mediaJson}]) { media { ... on MediaImage { id } } mediaUserErrors { message } } }`
        });
        result.alt = mediaUpdates.length;
        log(`  Phase 6 Alt tags: ${mediaUpdates.length} images updated`);
      } catch (e) {
        result.errors.push(`Alt: ${e.message?.slice(0, 40)}`);
      }
      await sleep(400);
    }
  } else {
    log(`  Phase 6 Alt tags: no media found`);
  }

  if (result.errors.length > 0) {
    log(`  ERRORS: ${result.errors.join(' | ')}`);
  }

  return result;
}

async function main() {
  log('=== Glitter Walls Full Monty (NO Room Settings) ===');
  log(`Date: ${FULL_MONTY_DATE}`);
  log(`Store: ${STORE}\n`);

  const products = await fetchAllProducts();

  const stats = {
    total: products.length,
    specsFieldsMigrated: 0,
    colorsAnalyzed: 0,
    bodiesRewritten: 0,
    titlesUpdated: 0,
    montyDatesSet: 0,
    altTagsUpdated: 0,
    errors: 0,
    skipped: 0,
  };

  for (let i = 0; i < products.length; i++) {
    try {
      const result = await processProduct(products[i], i, products.length);
      stats.specsFieldsMigrated += result.specs;
      if (result.color) stats.colorsAnalyzed++;
      if (result.body) stats.bodiesRewritten++;
      if (result.title) stats.titlesUpdated++;
      if (result.montyDate) stats.montyDatesSet++;
      stats.altTagsUpdated += result.alt;
      stats.errors += result.errors.length;
    } catch (e) {
      log(`  FATAL ERROR on product ${i + 1}: ${e.message}`);
      stats.errors++;
    }

    // Brief pause between products to avoid rate limits
    await sleep(200);

    // Progress every 25
    if ((i + 1) % 25 === 0) {
      log(`\n--- PROGRESS: ${i + 1}/${products.length} (${((i + 1) / products.length * 100).toFixed(1)}%) ---`);
      log(`  Specs fields: ${stats.specsFieldsMigrated} | Colors: ${stats.colorsAnalyzed} | Bodies: ${stats.bodiesRewritten} | Titles: ${stats.titlesUpdated} | Alt tags: ${stats.altTagsUpdated} | Errors: ${stats.errors}\n`);
    }
  }

  const summary = `
=== COMPLETE ===
Total products: ${stats.total}
Spec fields migrated: ${stats.specsFieldsMigrated}
Colors analyzed: ${stats.colorsAnalyzed}
Bodies rewritten: ${stats.bodiesRewritten}
Titles updated: ${stats.titlesUpdated}
Monty dates set: ${stats.montyDatesSet}
Alt tags updated: ${stats.altTagsUpdated}
Errors: ${stats.errors}
Finished: ${new Date().toISOString()}
`;

  log(summary);
}

main().catch(e => { log(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });