← 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);
});