← back to Letsbegin
nina_full_monty.js
755 lines
#!/usr/bin/env node
/**
* Nina Campbell Full Monty — ALL ~759 products
* NO image cropping. NO room settings. NO SKU changes.
*
* Pipeline per product:
* 1. MFR SKU — verify/extract from image filename if missing
* 2. Pattern + Color — match catalog OR Gemini color analysis
* 3. Title — "{Pattern} - {Color} Wallcovering | Nina Campbell"
* 4. Body HTML — exactly 2 paragraphs (editorial + AI description)
* 5. Metafields — brand, width, material, unit_of_measure, packaging, full_monty_date, pattern_name, color_name
* 6. Migrate global.* → custom.* (width, repeat, fire_rating, Collection, Brand, etc.)
* 7. Image alt tags — "{DW SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
* 8. Tags — remove "Color:" prefix, add "Class A Fire Rated"
* 9. NO image cropping. NO room settings. NO SKU changes.
*/
const https = require('https');
const http = require('http');
const fs = require('fs');
const { Client } = require('pg');
const STORE = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
const GEMINI_KEY = process.env.GEMINI_API_KEY;
const API_VER = '2024-10';
const FULL_MONTY_DATE = '2026-04-07';
const LOG_FILE = '/tmp/nina_monty.log';
const PG_URL = process.env.DATABASE_URL;
const PAGE_SIZE = 25;
const API_DELAY = 500; // ms between Shopify API calls
const GEMINI_DELAY = 1200; // ms between Gemini calls
if (!TOKEN || !GEMINI_KEY || !PG_URL) {
throw new Error('SHOPIFY_PRODUCT_TOKEN, GEMINI_API_KEY, and DATABASE_URL environment variables are required');
}
// Initialize log
fs.writeFileSync(LOG_FILE, `=== Nina Campbell 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 sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// ---- GraphQL helpers ----
function gqlRaw(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = https.request({
hostname: STORE,
path: `/admin/api/${API_VER}/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`);
await sleep(attempt * 2000);
continue;
}
throw e;
}
}
}
// ---- 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 !== 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); });
});
}
// ---- Gemini API ----
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();
});
}
// ---- Title/text helpers ----
function toTitleCase(s) {
if (!s) return '';
return s.replace(/\b\w+/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
}
// Clean pattern name: remove "(Wa3)" suffix, clean up collection prefix junk
function cleanPatternName(raw) {
if (!raw) return '';
let name = raw
.replace(/\s*\(Wa\d+\)\s*/gi, '') // Remove (Wa3) etc
.replace(/\s*Wallaper\b/gi, '') // Typo in catalog "Wallaper"
.replace(/\s*Wallpaper\b/gi, '')
.replace(/\s*Wallcovering\b/gi, '')
.trim();
// If pattern has collection prefix like "Les Reves Collioure Blue/Beige"
// and the color portion is after the last space-slash, we want to keep the whole pattern
// but strip color-like suffixes that belong in color_name
// e.g. "Fontibre Kershaw Plain Black/Ivory" -> pattern = "Fontibre Kershaw Plain"
// But we keep it all — the color_name field handles the color separately
// Just strip trailing color descriptions after the last known pattern word
// Actually: keep the full pattern_name as-is from catalog. Color comes from color_name field.
return name;
}
// Extract NCW/NCF code from image filename or URL
function extractMfrSkuFromUrl(url) {
if (!url) return null;
// Match NCW####-## or NCF####-## pattern
const match = url.match(/(NC[WF]\d{3,5}-\d{1,2})/i);
return match ? match[1].toUpperCase() : null;
}
// ---- Global → Custom field mapping ----
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;
}
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' },
'Horiz-Rpt': { key: 'horizontal_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
'Horiz-Repeat': { key: 'horizontal_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' },
};
// ---- Load catalog from PostgreSQL ----
async function loadCatalog() {
const client = new Client({ connectionString: PG_URL });
await client.connect();
const res = await client.query('SELECT mfr_sku, pattern_name, color_name, collection FROM nina_campbell_catalog');
await client.end();
const byMfr = {};
for (const row of res.rows) {
if (row.mfr_sku) {
byMfr[row.mfr_sku.toUpperCase()] = row;
}
}
log(`Loaded ${res.rows.length} catalog entries from nina_campbell_catalog`);
return byMfr;
}
// ---- Fetch all products ----
async function fetchAllProducts() {
log('Fetching all Nina Campbell active products...');
const allProducts = [];
let cursor = null;
while (true) {
const after = cursor ? `, after: "${cursor}"` : '';
const query = `{
products(first: ${PAGE_SIZE}, query: "vendor:\\"Nina Campbell\\" AND status:active"${after}) {
edges {
cursor
node {
id
title
descriptionHtml
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 });
if (r?.errors?.length) {
log(` GQL top-level error: ${JSON.stringify(r.errors[0]).slice(0, 200)}`);
}
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(API_DELAY);
}
log(`Total products fetched: ${allProducts.length}\n`);
return allProducts;
}
// ---- Process a single product ----
async function processProduct(p, idx, total, catalog) {
const pid = p.id;
const origTitle = p.title;
log(`\n[${idx + 1}/${total}] ${origTitle}`);
// Collect metafields by namespace
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 primary DW SKU from first non-sample variant
const variants = p.variants?.edges || [];
let dwSku = '';
for (const v of variants) {
const s = v.node.sku || '';
if (!s.toLowerCase().endsWith('-sample') && s) { dwSku = s; break; }
}
if (!dwSku && variants.length > 0) dwSku = variants[0].node.sku || '';
const result = { specs: 0, color: false, body: false, title: false, montyDate: false, alt: 0, tags: false, errors: [] };
// ============================================================
// STEP 1: MFR SKU — verify or extract from image filename
// ============================================================
let mfrSku = (customs['manufacturer_sku'] || '').toUpperCase().trim();
if (!mfrSku) {
// Try to extract NCW/NCF code from image URLs
for (const img of (p.images?.edges || [])) {
const extracted = extractMfrSkuFromUrl(img.node.url);
if (extracted) {
mfrSku = extracted;
log(` Step 1 MFR SKU: extracted from image filename: ${mfrSku}`);
break;
}
}
// Also try title
if (!mfrSku) {
const titleMatch = origTitle.match(/(NC[WF]\d{3,5}-\d{1,2})/i);
if (titleMatch) mfrSku = titleMatch[1].toUpperCase();
}
}
// ============================================================
// STEP 2: Pattern + Color — catalog match or Gemini
// ============================================================
let patternName = customs['pattern_name'] || '';
let colorName = customs['color_name'] || '';
let catalogMatch = false;
// Check catalog
const catEntry = mfrSku ? catalog[mfrSku] : null;
if (catEntry) {
const rawPattern = cleanPatternName(catEntry.pattern_name);
// Only update if current pattern is bad (like "Nina Campbell s") or empty
if (!patternName || patternName === 'Nina Campbell s' || patternName.length < 3) {
patternName = rawPattern;
}
if (!colorName || colorName.length < 2) {
colorName = toTitleCase(catEntry.color_name);
}
catalogMatch = true;
log(` Step 2 Catalog match: ${mfrSku} -> "${patternName}" / "${colorName}"`);
}
// If no color, use Gemini on the image
const imgUrl = p.images?.edges?.[0]?.node?.url;
let aiDesc = '';
let needGeminiColor = !colorName && imgUrl;
let needGeminiDesc = true; // Always get AI description for body
if (needGeminiColor || needGeminiDesc) {
if (imgUrl) {
try {
const tmpImg = '/tmp/nina_monty_img.jpg';
await downloadImage(imgUrl, tmpImg);
const imgB64 = fs.readFileSync(tmpImg).toString('base64');
// Combined call: get color (if needed) + description
if (needGeminiColor) {
const colorResult = await geminiCall({
contents: [{ parts: [
{ text: 'What is the dominant color of this wallcovering? Use designer terms. Reply 1-2 words only.' },
{ inlineData: { mimeType: 'image/jpeg', data: imgB64 } },
]}],
generationConfig: { temperature: 0.2, maxOutputTokens: 50 },
});
const colorText = (colorResult?.candidates?.[0]?.content?.parts?.[0]?.text || '').trim();
if (colorText && colorText.length < 30) {
colorName = toTitleCase(colorText.replace(/[.\n]/g, '').trim());
log(` Step 2 Gemini color: "${colorName}"`);
}
await sleep(GEMINI_DELAY);
}
// Get AI description (1 sentence about texture/pattern)
const displayName = patternName || origTitle.split('|')[0].replace(/wallcovering/gi, '').trim();
const descResult = await geminiCall({
contents: [{ parts: [
{ text: `Describe this wallcovering in exactly 1 sentence for a design professional. Focus on texture, pattern, and visual effect. Do NOT use the word "wallpaper" — always say "wallcovering". Do NOT start with the product name. Do NOT mention the brand. Maximum 30 words.` },
{ inlineData: { mimeType: 'image/jpeg', data: imgB64 } },
]}],
generationConfig: { temperature: 0.3, maxOutputTokens: 100 },
});
aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '')
.replace(/wallpaper/gi, 'wallcovering')
.replace(/\n/g, ' ')
.trim();
await sleep(GEMINI_DELAY);
// Clean up temp file
try { fs.unlinkSync(tmpImg); } catch {}
} catch (e) {
result.errors.push(`Gemini: ${(e.message || '').slice(0, 60)}`);
log(` Step 2 Gemini ERROR: ${(e.message || '').slice(0, 60)}`);
}
}
}
// If pattern still bad, try parsing from title
if (!patternName || patternName === 'Nina Campbell s' || patternName.length < 3) {
// Extract from title: "Something - Color Wallcovering | Nina Campbell"
let cleaned = origTitle.replace(/\s*\|\s*Nina Campbell\s*(Europe)?\s*$/i, '').trim();
cleaned = cleaned.replace(/\s*Wallcovering\s*$/i, '').trim();
const dashIdx = cleaned.lastIndexOf(' - ');
if (dashIdx > 0) {
patternName = cleaned.substring(0, dashIdx).trim();
} else {
patternName = cleaned;
}
}
// Fallback color
if (!colorName) {
colorName = 'Natural';
}
// ============================================================
// STEP 3: Title — "{Pattern} - {Color} Wallcovering | Nina Campbell"
// ============================================================
const newTitle = `${patternName} - ${colorName} Wallcovering | Nina Campbell`;
if (newTitle !== origTitle) {
try {
const r = await gql({
query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
variables: { i: { id: pid, title: newTitle } },
});
const errs = r?.data?.productUpdate?.userErrors || [];
if (errs.length > 0) {
result.errors.push(`Title: ${errs[0].message}`);
} else {
result.title = true;
log(` Step 3 Title: "${origTitle}" -> "${newTitle}"`);
}
} catch (e) {
result.errors.push(`Title: ${(e.message || '').slice(0, 40)}`);
}
await sleep(API_DELAY);
} else {
result.title = true;
log(` Step 3 Title: already correct`);
}
// ============================================================
// STEP 4: Body HTML — exactly 2 paragraphs
// ============================================================
const p1 = `${patternName} in ${colorName} is a refined wallcovering by Nina Campbell. This design brings elegant sophistication to residential and commercial interiors.`;
const p2 = aiDesc || 'Featuring a carefully curated palette and understated texture, this wallcovering delivers timeless appeal suitable for discerning design applications.';
const newBody = `<p>${p1}</p>\n<p>${p2}</p>`;
try {
const r = await gql({
query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
variables: { i: { id: pid, descriptionHtml: newBody } },
});
const errs = r?.data?.productUpdate?.userErrors || [];
if (errs.length > 0) {
result.errors.push(`Body: ${errs[0].message}`);
} else {
result.body = true;
log(` Step 4 Body: 2 paragraphs set`);
}
} catch (e) {
result.errors.push(`Body: ${(e.message || '').slice(0, 40)}`);
}
await sleep(API_DELAY);
// ============================================================
// STEP 5: Metafields — required fields + migrate global.* -> custom.*
// ============================================================
const mfToSet = [];
const seen = new Set();
// Required metafields (always set/overwrite)
const requiredMf = [
{ key: 'pattern_name', value: patternName, type: 'single_line_text_field' },
{ key: 'color_name', value: colorName, type: 'single_line_text_field' },
{ key: 'brand', value: 'Nina Campbell', type: 'single_line_text_field' },
{ key: 'width', value: '20.5 Inches', type: 'single_line_text_field' },
{ key: 'material', value: 'Wallcovering', type: 'multi_line_text_field' },
{ key: 'unit_of_measure', value: 'Priced Per Single Roll', type: 'single_line_text_field' },
{ key: 'packaging', value: 'Double Roll', type: 'single_line_text_field' },
{ key: 'full_monty_date', value: FULL_MONTY_DATE, type: 'single_line_text_field' },
];
// Set MFR SKU if we found one and it's not already set
if (mfrSku && !customs['manufacturer_sku']) {
requiredMf.push({ key: 'manufacturer_sku', value: mfrSku, type: 'single_line_text_field' });
}
for (const mf of requiredMf) {
mfToSet.push({
ownerId: pid, namespace: 'custom', key: mf.key,
value: mf.value, type: mf.type,
});
seen.add(mf.key);
}
// Migrate global.* -> custom.* for unmapped fields
for (const [gKey, mapping] of Object.entries(FIELD_MAP)) {
if (!globals[gKey]) continue;
if (seen.has(mapping.key)) continue; // Already set by required fields
if (customs[mapping.key]) continue; // Already has custom value
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(`Metafields: ${errs.map(e => e.message).join('; ').slice(0, 100)}`);
log(` Step 5 Metafields: ERRORS ${errs[0].message}`);
} else {
result.specs = mfToSet.length;
log(` Step 5 Metafields: ${mfToSet.length} fields set`);
}
} catch (e) {
result.errors.push(`Metafields: ${(e.message || '').slice(0, 40)}`);
}
await sleep(API_DELAY);
}
// ============================================================
// STEP 7: Image alt tags — "{DW SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
// ============================================================
const cleanSku = dwSku.replace(/-[Ss]ample$/, '').trim();
const altBase = `${cleanSku} ${patternName} ${colorName} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();
const mediaEdges = p.media?.edges || [];
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 {
const mediaJson = mediaUpdates
.map(m => `{id: "${m.id}", alt: "${m.alt.replace(/"/g, '\\"')}"}`)
.join(', ');
const r = await gql({
query: `mutation { productUpdateMedia(productId: "${pid}", media: [${mediaJson}]) { media { ... on MediaImage { id } } mediaUserErrors { message } } }`,
});
const errs = r?.data?.productUpdateMedia?.mediaUserErrors || [];
if (errs.length > 0) {
result.errors.push(`Alt: ${errs[0].message}`);
} else {
result.alt = mediaUpdates.length;
log(` Step 7 Alt tags: ${mediaUpdates.length} images updated`);
}
} catch (e) {
result.errors.push(`Alt: ${(e.message || '').slice(0, 40)}`);
}
await sleep(API_DELAY);
}
} else {
log(` Step 7 Alt tags: no media found`);
}
// ============================================================
// STEP 8: Tags — remove "Color:" prefix, add "Class A Fire Rated", add MFR SKU as tag
// ============================================================
const currentTags = p.tags || [];
let newTags = [...currentTags];
let tagsChanged = false;
// Remove "Color:" prefix from tags
for (let ti = 0; ti < newTags.length; ti++) {
if (newTags[ti].startsWith('Color:')) {
newTags[ti] = newTags[ti].replace(/^Color:\s*/, '').trim();
tagsChanged = true;
}
}
// Add "Class A Fire Rated" if not present
if (!newTags.some(t => t.toLowerCase() === 'class a fire rated')) {
newTags.push('Class A Fire Rated');
tagsChanged = true;
}
// Add MFR SKU as tag if not present
if (mfrSku && !newTags.some(t => t.toUpperCase() === mfrSku)) {
newTags.push(mfrSku);
tagsChanged = true;
}
// Remove duplicates (case-insensitive)
const tagSet = new Map();
for (const t of newTags) {
const lower = t.toLowerCase().trim();
if (lower && !tagSet.has(lower)) tagSet.set(lower, t.trim());
}
newTags = Array.from(tagSet.values());
if (tagsChanged) {
try {
const tagStr = newTags.join(', ');
const r = await gql({
query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
variables: { i: { id: pid, tags: newTags } },
});
const errs = r?.data?.productUpdate?.userErrors || [];
if (errs.length > 0) {
result.errors.push(`Tags: ${errs[0].message}`);
} else {
result.tags = true;
log(` Step 8 Tags: updated (${newTags.length} tags)`);
}
} catch (e) {
result.errors.push(`Tags: ${(e.message || '').slice(0, 40)}`);
}
await sleep(API_DELAY);
} else {
result.tags = true;
log(` Step 8 Tags: already correct`);
}
if (result.errors.length > 0) {
log(` ERRORS: ${result.errors.join(' | ')}`);
}
return result;
}
// ---- Main ----
async function main() {
log('=== Nina Campbell Full Monty (NO Room Settings, NO Image Cropping, NO SKU Changes) ===');
log(`Date: ${FULL_MONTY_DATE}`);
log(`Store: ${STORE}`);
log(`Page size: ${PAGE_SIZE}`);
log(`API delay: ${API_DELAY}ms | Gemini delay: ${GEMINI_DELAY}ms\n`);
// Load catalog from PostgreSQL
const catalog = await loadCatalog();
// Fetch all products
const products = await fetchAllProducts();
const stats = {
total: products.length,
catalogMatches: 0,
geminiColors: 0,
specsFieldsSet: 0,
bodiesRewritten: 0,
titlesUpdated: 0,
montyDatesSet: 0,
altTagsUpdated: 0,
tagsUpdated: 0,
errors: 0,
};
for (let i = 0; i < products.length; i++) {
try {
const result = await processProduct(products[i], i, products.length, catalog);
stats.specsFieldsSet += result.specs;
if (result.color) stats.geminiColors++;
if (result.body) stats.bodiesRewritten++;
if (result.title) stats.titlesUpdated++;
stats.altTagsUpdated += result.alt;
if (result.tags) stats.tagsUpdated++;
stats.errors += result.errors.length;
} catch (e) {
log(` FATAL ERROR on product ${i + 1}: ${e.message}`);
stats.errors++;
}
// Progress every 25
if ((i + 1) % 25 === 0 || i === products.length - 1) {
log(`\n--- PROGRESS: ${i + 1}/${products.length} (${((i + 1) / products.length * 100).toFixed(1)}%) ---`);
log(` Specs: ${stats.specsFieldsSet} | Bodies: ${stats.bodiesRewritten} | Titles: ${stats.titlesUpdated} | Alt: ${stats.altTagsUpdated} | Tags: ${stats.tagsUpdated} | Errors: ${stats.errors}\n`);
}
}
const summary = `
=== NINA CAMPBELL FULL MONTY COMPLETE ===
Total products: ${stats.total}
Specs/metafields set: ${stats.specsFieldsSet}
Bodies rewritten: ${stats.bodiesRewritten}
Titles updated: ${stats.titlesUpdated}
Alt tags updated: ${stats.altTagsUpdated}
Tags updated: ${stats.tagsUpdated}
Errors: ${stats.errors}
Finished: ${new Date().toISOString()}
`;
log(summary);
}
main().catch(e => {
log(`FATAL: ${e.message}\n${e.stack}`);
process.exit(1);
});