← back to Letsbegin

retro_full_monty.js

618 lines

#!/usr/bin/env node
/**
 * Retro Walls Full Monty (NO room settings)
 * ~1,052 products — vendor:"Retro Walls"
 *
 * Pipeline per product:
 *   1. Migrate global.* / dwc.* metafields → custom.* (width, fire_rating, material, repeat, match_type, etc.)
 *   2. AI color analysis via Gemini 2.0 Flash → color_hex, color_details, background_color, color_name
 *   3. Body rewrite to exactly 2 paragraphs (no specs, no tables, no hashtags)
 *   4. Title update: "Pattern - Color | Retro Walls"
 *   5. Set custom.full_monty_date = "2026-04-06"
 *   6. Image alt tags: "{SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
 *   7. NO room settings
 *
 * Resume-safe: tracks progress in /tmp/retro_monty_progress.json
 * Logs to /tmp/retro_monty.log
 */

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 LOG_FILE = '/tmp/retro_monty.log';
const PROGRESS_FILE = '/tmp/retro_monty_progress.json';
const FULL_MONTY_DATE = '2026-04-06';

// ---------- Logging ----------
function log(msg) {
  const ts = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' });
  const line = `[${ts}] ${msg}`;
  console.log(line);
  fs.appendFileSync(LOG_FILE, line + '\n');
}

// ---------- Progress tracking (resume-safe) ----------
function loadProgress() {
  try { return JSON.parse(fs.readFileSync(PROGRESS_FILE, 'utf8')); }
  catch { return { done: {}, cursor: null, page: 0, stats: { specs: 0, colors: 0, bodies: 0, titles: 0, alts: 0, fmDate: 0, errors: 0, skipped: 0 } }; }
}
function saveProgress(p) { fs.writeFileSync(PROGRESS_FILE, JSON.stringify(p, null, 2)); }

// ---------- Shopify GraphQL ----------
function gql(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 {
          const parsed = JSON.parse(c);
          // Check for throttle
          const available = parsed?.extensions?.cost?.throttleStatus?.currentlyAvailable;
          if (available !== undefined && available < 200) {
            // Throttled — wait and retry
            log(`  THROTTLED (available: ${available}) — waiting 5s`);
            setTimeout(() => gql(body).then(resolve).catch(reject), 5000);
            return;
          }
          resolve(parsed);
        } catch { resolve({ error: c.slice(0, 300) }); }
      });
    });
    req.on('error', reject);
    req.setTimeout(60000, () => { req.destroy(); reject(new Error('gql timeout')); });
    req.write(data); req.end();
  });
}

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

// ---------- Image download ----------
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 >= 400) {
        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); });
  });
}

// ---------- Gemini API ----------
function geminiCall(reqBody, retries = 2) {
  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 {
          const parsed = JSON.parse(Buffer.concat(chunks).toString());
          if (parsed?.error && retries > 0) {
            log(`  Gemini error: ${parsed.error.message?.slice(0, 80)} — retrying in 5s`);
            setTimeout(() => geminiCall(reqBody, retries - 1).then(resolve).catch(reject), 5000);
            return;
          }
          resolve(parsed);
        } catch { resolve(null); }
      });
    });
    req.on('error', e => {
      if (retries > 0) {
        setTimeout(() => geminiCall(reqBody, retries - 1).then(resolve).catch(reject), 3000);
      } else resolve(null);
    });
    req.setTimeout(60000, () => {
      req.destroy();
      if (retries > 0) {
        setTimeout(() => geminiCall(reqBody, retries - 1).then(resolve).catch(reject), 3000);
      } else resolve(null);
    });
    req.write(data); req.end();
  });
}

// ---------- Metafield migration mappings ----------
const GLOBAL_TO_CUSTOM = {
  'width':              { key: 'width',          type: 'single_line_text_field', clean: cleanWidth },
  'Width':              { key: 'width',          type: 'single_line_text_field', clean: cleanWidth },
  'repeat':             { key: 'pattern_repeat', type: 'single_line_text_field', clean: cleanRepeat },
  'Vert-Rpt':           { key: 'pattern_repeat', type: 'single_line_text_field', clean: cleanRepeat },
  'Vert-Repeat':        { key: 'pattern_repeat', type: 'single_line_text_field', clean: cleanRepeat },
  'fire_rating':        { key: 'fire_rating',    type: 'single_line_text_field', clean: cleanFire },
  'FLAMMABILITY':       { key: 'fire_rating',    type: 'single_line_text_field', clean: cleanFire },
  'Contents':           { key: 'material',       type: 'multi_line_text_field',  clean: cleanMaterial },
  'Content':            { key: 'material',       type: 'multi_line_text_field',  clean: cleanMaterial },
  'Construction':       { key: 'material',       type: 'multi_line_text_field',  clean: cleanMaterial },
  'Collection':         { key: 'collection_name', type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Brand':              { key: 'brand',          type: 'single_line_text_field', clean: v => v?.trim() || null },
  'MATCH':              { key: 'match_type',     type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Match':              { key: 'match_type',     type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Finish':             { key: 'finish',         type: 'single_line_text_field', clean: v => v?.trim() || null },
  'FINISH':             { key: 'finish',         type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Cleaning':           { key: 'care',           type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Clean-Code':         { key: 'care',           type: 'single_line_text_field', clean: v => v?.trim() || null },
  'application':        { key: 'application',    type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Substrate':          { key: 'backing',        type: 'single_line_text_field', clean: v => v?.trim() || null },
  'substrate':          { key: 'backing',        type: 'single_line_text_field', clean: v => v?.trim() || null },
  'length':             { key: 'length',         type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Length':             { key: 'length',         type: 'single_line_text_field', clean: v => v?.trim() || null },
  'packaged':           { key: 'packaging',      type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Packaged':           { key: 'packaging',      type: 'single_line_text_field', clean: v => v?.trim() || null },
  'unit_of_measure':    { key: 'unit_of_measure', type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Weight':             { key: 'product_weight', type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Country':            { key: 'origin',         type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Country-of-Origin':  { key: 'origin',         type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Style':              { key: 'style',          type: 'single_line_text_field', clean: v => v?.trim() || null },
  // NOTE: custom.color is product_reference type in Shopify — do NOT write strings to it
  // Color names go to custom.color_name instead
  'color':              { key: 'color_name',     type: 'single_line_text_field', clean: v => v?.trim() || null },
  'Color-Way':          { key: 'color_name',     type: 'single_line_text_field', clean: v => v?.trim() || null },
};

// Also migrate dwc.* → custom.*
const DWC_TO_CUSTOM = {
  'pattern_name':  { key: 'pattern_name',  type: 'single_line_text_field', clean: v => v?.trim() || null },
  'brand':         { key: 'brand',         type: 'single_line_text_field', clean: v => v?.trim() || null },
  'collection':    { key: 'collection_name', type: 'single_line_text_field', clean: v => v?.trim() || null },
  'width':         { key: 'width',         type: 'single_line_text_field', clean: cleanWidth },
  'contents':      { key: 'material',      type: 'multi_line_text_field',  clean: cleanMaterial },
  'fire_rating':   { key: 'fire_rating',   type: 'single_line_text_field', clean: cleanFire },
};

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') return null;
  const m = v.match(/([\d.]+)/);
  return m ? `${m[1]} Inches` : null;
}
function cleanFire(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;
}

// ---------- Title helpers ----------
function extractPatternAndColor(title) {
  // Existing titles are varied:
  //   "Black and White Paisley Wallcovering"
  //   "Tropical Kitsch - 01 Pastel"
  //   "Abstract Jars by ATA Designs- Ver.ATAD1028"
  //   "Zepellin Zebras Grey, Black Traditional Wallcovering"
  // Try to extract pattern & color from title or metafields
  let clean = (title || '').trim();
  // Remove trailing type words
  clean = clean.replace(/\s*(Wallcovering|Wallpaper|Wall Paper|Wall Covering)\s*$/i, '').trim();
  // Remove vendor attributions
  clean = clean.replace(/\s*by\s+.*$/i, '').trim();
  // Remove "DW " prefix if present
  clean = clean.replace(/^DW\s+/i, '').trim();
  // Remove " DW" suffix
  clean = clean.replace(/\s+DW$/i, '').trim();
  return clean;
}

// ---------- Fetch all products with pagination ----------
async function fetchAllProducts(startCursor) {
  const products = [];
  let cursor = startCursor;
  let page = 0;

  while (true) {
    page++;
    const after = cursor ? `, after: "${cursor}"` : '';
    const r = await gql({
      query: `{ products(first: 25, query: "vendor:\\"Retro Walls\\""${after}, sortKey: CREATED_AT) {
        edges { cursor node {
          id title bodyHtml vendor productType tags
          images(first: 10) { edges { node { id url altText } } }
          media(first: 10) { edges { node { id alt mediaContentType ... on MediaImage { image { url } } } } }
          variants(first: 5) { edges { node { id sku barcode title } } }
          metafields(first: 50) { edges { node { namespace key value type } } }
        } }
        pageInfo { hasNextPage }
      } }`
    });

    const edges = r?.data?.products?.edges || [];
    if (edges.length === 0) break;

    for (const edge of edges) {
      products.push({ ...edge.node, _cursor: edge.cursor });
    }

    cursor = edges[edges.length - 1].cursor;

    if (page % 5 === 0) {
      log(`  Fetched ${products.length} products (page ${page})...`);
    }

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

  return products;
}

// ---------- Main processing ----------
async function processProduct(p, progress, idx, total) {
  const pid = p.id;
  const numericId = pid.replace('gid://shopify/Product/', '');

  // Skip if already done
  if (progress.done[numericId]) {
    progress.stats.skipped++;
    return;
  }

  log(`\n[${idx}/${total}] ${p.title}`);

  // Collect existing metafields
  const globals = {};
  const customs = {};
  const dwcs = {};
  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;
    if (namespace === 'dwc' && value) dwcs[key] = value;
  }

  const sku = p.variants?.edges?.[0]?.node?.sku?.replace(/-sample$/i, '') || '';
  const imgUrl = p.images?.edges?.[0]?.node?.url;
  const allMfUpdates = [];

  // ====== PHASE 1: Migrate global.* → custom.* ======
  const seen = new Set(Object.keys(customs));

  // global.* migrations
  for (const [gKey, mapping] of Object.entries(GLOBAL_TO_CUSTOM)) {
    if (!globals[gKey]) continue;
    if (seen.has(mapping.key)) continue;
    const cleaned = mapping.clean(globals[gKey]);
    if (!cleaned) continue;
    allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: mapping.key, value: cleaned, type: mapping.type });
    seen.add(mapping.key);
  }

  // dwc.* migrations
  for (const [dKey, mapping] of Object.entries(DWC_TO_CUSTOM)) {
    if (!dwcs[dKey]) continue;
    if (seen.has(mapping.key)) continue;
    const cleaned = mapping.clean(dwcs[dKey]);
    if (!cleaned) continue;
    allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: mapping.key, value: cleaned, type: mapping.type });
    seen.add(mapping.key);
  }

  if (allMfUpdates.length > 0) {
    log(`  Phase 1 Specs: ${allMfUpdates.length} fields to migrate`);
    progress.stats.specs++;
  }

  // ====== PHASE 2: AI Color Analysis via Gemini ======
  let colorName = customs['color_name'] || customs['color'] || '';
  let colorHex = customs['color_hex'] || '';
  let colorData = null;

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

      const colorResult = await geminiCall({
        contents: [{ parts: [
          { text: 'Analyze this wallcovering image. Return ONLY valid JSON (no markdown, no code fences): {"dominantColor":"Color Name","dominantHex":"#XXXXXX","backgroundColor":"Color Name","backgroundHex":"#XXXXXX","colors":[{"name":"Color Name","hex":"#XXXXXX","percentage":40}]}. Use 3-6 colors summing to 100%. Use Title Case color names like "Navy Blue", "Warm Beige", "Forest Green".' },
          { 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();
      colorData = JSON.parse(clean);

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

      allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'color_hex', value: colorData.dominantHex, type: 'single_line_text_field' });
      allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'background_color', value: colorData.backgroundColor, type: 'single_line_text_field' });
      allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'color_details', value: JSON.stringify(colorData.colors), type: 'json' });
      allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'color_name', value: colorData.dominantColor, type: 'single_line_text_field' });

      progress.stats.colors++;
      log(`  Phase 2 Color: ${colorData.dominantColor} (${colorData.dominantHex})`);
    } catch (e) {
      log(`  Phase 2 Color: ERROR — ${e.message?.slice(0, 80)}`);
      progress.stats.errors++;
    }
    await sleep(1200); // Gemini rate limit
  } else if (customs['color_details']) {
    log(`  Phase 2 Color: already done`);
    // Extract existing color name if available
    try {
      const existing = JSON.parse(customs['color_details']);
      if (Array.isArray(existing) && existing[0]?.name) {
        colorName = colorName || existing[0].name;
      }
    } catch {}
  } else {
    log(`  Phase 2 Color: no image — skipping`);
  }

  // ====== PHASE 3: Body rewrite (2 paragraphs, no specs, no hashtags) ======
  const cleanTitle = extractPatternAndColor(p.title);
  let aiDesc = '';

  if (imgUrl) {
    try {
      const tmpImg2 = `/tmp/retro_img_${numericId}.jpg`;
      if (!fs.existsSync(tmpImg2)) {
        await downloadImage(imgUrl, tmpImg2);
      }
      const imgB64 = fs.readFileSync(tmpImg2).toString('base64');

      const descResult = await geminiCall({
        contents: [{ parts: [
          { text: `Write exactly 2 sentences describing this wallcovering called "${cleanTitle}" by Retro Walls for a design-trade buyer. Sentence 1: What the design looks like (pattern, texture, colors). Sentence 2: Recommended setting/use (residential, hospitality, commercial). Do NOT use "wallpaper" — say "wallcovering". Do NOT use hashtags. Do NOT mention specs (width, material, fire rating). Keep it professional and concise.` },
          { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
        ]}],
        generationConfig: { temperature: 0.3, maxOutputTokens: 200 }
      });

      aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '')
        .replace(/wallpaper/gi, 'wallcovering')
        .replace(/#\w+/g, '')  // strip hashtags
        .trim();

      // Clean up temp file
      try { fs.unlinkSync(tmpImg2); } catch {}
    } catch (e) {
      log(`  Phase 3 Body AI desc error: ${e.message?.slice(0, 50)}`);
    }
    await sleep(1200); // Gemini rate limit
  }

  const colorInfo = colorName ? ` in ${colorName}` : '';
  const para1 = `${cleanTitle} is a vintage-inspired wallcovering${colorInfo} from Retro Walls, curated from an exclusive private collection of over 500 wallcovering books and rolls.`;
  const para2 = aiDesc || `This distinctive design is perfect for feature walls in residential, hospitality, and commercial spaces, offering a timeless aesthetic that appeals to interior designers and restoration specialists alike.`;
  const newBody = `<p>${para1}</p>\n<p>${para2}</p>`;

  progress.stats.bodies++;
  log(`  Phase 3 Body: 2 paragraphs written`);

  // ====== PHASE 4: Title update — "Pattern - Color | Retro Walls" ======
  let newTitle = p.title;
  // Only update if not already in correct format
  if (!p.title.includes('| Retro Walls')) {
    const patternName = customs['pattern_name'] || cleanTitle;
    // Use color from AI, existing metafield, or extract from title
    const titleColor = colorName || '';
    if (titleColor && !patternName.toLowerCase().includes(titleColor.toLowerCase())) {
      newTitle = `${patternName} - ${titleColor} | Retro Walls`;
    } else {
      newTitle = `${patternName} | Retro Walls`;
    }
    // Clean up "Wallcovering" from pattern name in title since it's redundant
    newTitle = newTitle.replace(/\s*Wallcovering\s*/g, ' ').replace(/\s+/g, ' ').trim();
    progress.stats.titles++;
    log(`  Phase 4 Title: ${newTitle}`);
  } else {
    log(`  Phase 4 Title: already formatted`);
  }

  // ====== PHASE 5: Set full_monty_date ======
  allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'full_monty_date', value: FULL_MONTY_DATE, type: 'single_line_text_field' });
  progress.stats.fmDate++;

  // ====== PUSH: Metafields (with retry on type errors) ======
  if (allMfUpdates.length > 0) {
    // Filter out known problematic fields (custom.color is product_reference, not text)
    const safeMfUpdates = allMfUpdates.filter(m => !(m.namespace === 'custom' && m.key === 'color'));

    try {
      const r = await gql({
        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }',
        variables: { m: safeMfUpdates }
      });
      const errs = r?.data?.metafieldsSet?.userErrors || [];
      if (errs.length > 0) {
        // If type error, retry without the problematic fields
        const typeErrors = errs.filter(e => e.message?.includes('type'));
        if (typeErrors.length > 0 && safeMfUpdates.length > 1) {
          log(`  Metafield type error — retrying critical fields only...`);
          // Retry with just the essential fields (color data + full_monty_date)
          const criticalKeys = new Set(['color_hex', 'background_color', 'color_details', 'color_name', 'full_monty_date']);
          const criticalMf = safeMfUpdates.filter(m => criticalKeys.has(m.key));
          if (criticalMf.length > 0) {
            const r2 = await gql({
              query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
              variables: { m: criticalMf }
            });
            const errs2 = r2?.data?.metafieldsSet?.userErrors || [];
            if (errs2.length > 0) {
              log(`  Critical metafield errors: ${errs2.map(e => e.message).join('; ')}`);
              progress.stats.errors++;
            } else {
              log(`  Critical metafields saved OK (${criticalMf.length} fields)`);
            }
          }
        } else {
          log(`  Metafield errors: ${errs.map(e => e.message).join('; ')}`);
          progress.stats.errors++;
        }
      }
    } catch (e) {
      log(`  Metafield push error: ${e.message?.slice(0, 60)}`);
      progress.stats.errors++;
    }
    await sleep(500);
  }

  // ====== PUSH: Product update (title + body) ======
  try {
    const r = await gql({
      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
      variables: { i: { id: pid, title: newTitle, descriptionHtml: newBody } }
    });
    const errs = r?.data?.productUpdate?.userErrors || [];
    if (errs.length > 0) {
      log(`  Product update errors: ${errs.map(e => e.message).join('; ')}`);
      progress.stats.errors++;
    }
  } catch (e) {
    log(`  Product update error: ${e.message?.slice(0, 60)}`);
    progress.stats.errors++;
  }
  await sleep(500);

  // ====== PHASE 6: Image alt tags (using media IDs) ======
  const mediaItems = p.media?.edges || [];
  if (mediaItems.length > 0) {
    const patName = customs['pattern_name'] || cleanTitle;
    const altColor = colorName || '';
    const altBase = `${sku} ${patName} ${altColor} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();

    // Build media update list
    const mediaUpdates = [];
    let viewIdx = 0;
    for (const mediaEdge of mediaItems) {
      const media = mediaEdge.node;
      if (media.mediaContentType !== 'IMAGE') continue;
      // Skip spin GIFs (check URL from nested image)
      const mediaUrl = media.image?.url || '';
      if (mediaUrl.includes('spin.gif') || mediaUrl.includes('swatch-spin')) continue;

      const suffix = viewIdx > 0 ? ` view-${viewIdx + 1}` : '';
      const newAlt = `${altBase}${suffix}`;
      viewIdx++;

      // Skip if alt is already correct
      if (media.alt === newAlt) continue;

      mediaUpdates.push({ id: media.id, alt: newAlt });
    }

    if (mediaUpdates.length > 0) {
      try {
        // Batch all media alt updates in one call
        const mediaPayload = mediaUpdates.map(m => `{id: "${m.id}", alt: "${m.alt.replace(/"/g, '\\"')}"}`).join(', ');
        await gql({
          query: `mutation { productUpdateMedia(productId: "${pid}", media: [${mediaPayload}]) { media { id } mediaUserErrors { message } } }`
        });
        progress.stats.alts++;
        log(`  Phase 6 Alts: ${mediaUpdates.length} images updated`);
      } catch (e) {
        log(`  Phase 6 Alts: ERROR — ${e.message?.slice(0, 50)}`);
      }
      await sleep(500);
    }
  }

  // Mark done
  progress.done[numericId] = true;
  saveProgress(progress);
}

// ---------- MAIN ----------
async function main() {
  log('=== RETRO WALLS FULL MONTY (NO ROOM SETTINGS) ===');
  log(`Date: ${FULL_MONTY_DATE} | Target: ~1,052 products`);
  log(`Pipeline: specs migrate → AI colors → body rewrite → title → full_monty_date → alt tags\n`);

  const progress = loadProgress();
  const alreadyDone = Object.keys(progress.done).length;
  if (alreadyDone > 0) {
    log(`RESUMING: ${alreadyDone} products already processed`);
  }

  // Fetch all products
  log('Fetching all Retro Walls products...');
  const products = await fetchAllProducts(null);
  log(`Fetched ${products.length} total products\n`);

  const total = products.length;
  const startTime = Date.now();

  for (let i = 0; i < products.length; i++) {
    try {
      await processProduct(products[i], progress, i + 1, total);
    } catch (e) {
      log(`  FATAL ERROR on product: ${e.message}`);
      progress.stats.errors++;
      saveProgress(progress);
    }

    // Progress report every 50 products
    if ((i + 1) % 50 === 0) {
      const elapsed = ((Date.now() - startTime) / 1000 / 60).toFixed(1);
      const done = Object.keys(progress.done).length;
      const rate = (done / (elapsed || 1)).toFixed(1);
      log(`\n--- PROGRESS: ${done}/${total} done (${elapsed}min, ${rate}/min) ---`);
      log(`    Specs: ${progress.stats.specs} | Colors: ${progress.stats.colors} | Bodies: ${progress.stats.bodies}`);
      log(`    Titles: ${progress.stats.titles} | Alts: ${progress.stats.alts} | Errors: ${progress.stats.errors}\n`);
    }
  }

  const elapsed = ((Date.now() - startTime) / 1000 / 60).toFixed(1);
  log('\n=== RETRO WALLS FULL MONTY COMPLETE ===');
  log(`Total products: ${total}`);
  log(`Already done (skipped): ${progress.stats.skipped}`);
  log(`Specs migrated: ${progress.stats.specs}`);
  log(`Colors analyzed: ${progress.stats.colors}`);
  log(`Bodies rewritten: ${progress.stats.bodies}`);
  log(`Titles updated: ${progress.stats.titles}`);
  log(`Alt tags set: ${progress.stats.alts}`);
  log(`Full monty date set: ${progress.stats.fmDate}`);
  log(`Errors: ${progress.stats.errors}`);
  log(`Duration: ${elapsed} minutes`);

  // Gemini cost estimate
  const geminiCalls = progress.stats.colors + progress.stats.bodies;
  const estCost = (geminiCalls * 0.0002).toFixed(2); // ~$0.0002 per flash call
  log(`Gemini cost estimate: ~$${estCost} (${geminiCalls} calls @ ~$0.0002/call)`);
}

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