← back to Wolfgordon Crawl
backfill-enrichment.js
263 lines
#!/usr/bin/env node
// ============================================================================
// Wolf Gordon ENRICHMENT BACKFILL (LIVE store: designer-laboratory-sandbox)
//
// Fixes the bare-shell cohort that today's push-shopify.js created: ACTIVE WG
// products with NO description and only a single `custom.width` metafield.
//
// Source of truth = local dw_unified.wolf_gordon_catalog (PostgreSQL BEFORE
// Shopify). This writes ONLY metafields + descriptionHtml + (optionally) images
// + the activation gate. It NEVER creates products, NEVER creates variants,
// NEVER touches price. CAP-FREE (no new variants).
//
// Idempotent: skips a metafield it can already see on the product; skips desc if
// the product already has a non-trivial description; image-ADD-only (diff).
//
// Activation gate (Steve's hard rule): a product stays/goes ACTIVE only with an
// image AND a width metafield; otherwise -> DRAFT + Needs-Image / Needs-Width.
//
// Usage:
// node backfill-enrichment.js # DRY-RUN, whole bare-shell cohort
// node backfill-enrichment.js --canary # DRY-RUN, the 3 canary SKUs
// node backfill-enrichment.js --canary --apply
// node backfill-enrichment.js --apply # LIVE, whole cohort
// node backfill-enrichment.js --skus A,B,C [--apply]
// node backfill-enrichment.js --limit 50 [--apply]
// ============================================================================
const fs = require('fs'); const os = require('os');
const { pool } = require('./scraper-utils');
function loadToken() {
if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
const p = os.homedir() + '/Projects/secrets-manager/.env';
const line = fs.readFileSync(p, 'utf8').split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
return line.slice('SHOPIFY_ADMIN_TOKEN='.length).replace(/^["']|["']$/g, '').trim();
}
const TOKEN = loadToken();
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const GQL = `https://${SHOP}/admin/api/${API}/graphql.json`;
const THROTTLE_MS = 450;
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const CANARY = argv.includes('--canary');
const SKUS_ARG = (() => { const i = argv.indexOf('--skus'); return i !== -1 && argv[i + 1] ? argv[i + 1].split(',').map(s => s.trim()) : null; })();
const LIMIT = (() => { const i = argv.indexOf('--limit'); return i !== -1 && argv[i + 1] ? parseInt(argv[i + 1], 10) : null; })();
const CANARY_SKUS = ['DWWG-533181', 'DWWG-533204', 'DWWG-533203'];
const wait = ms => new Promise(r => setTimeout(r, ms));
async function gql(q, v) {
await wait(THROTTLE_MS);
const res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
const j = await res.json();
if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors));
return j.data;
}
function banWallpaper(s) { return String(s || '').replace(/wallpapers/gi, 'Wallcoverings').replace(/wallpaper/gi, 'Wallcovering'); }
function titleCase(s) { return String(s || '').toLowerCase().replace(/\b([a-z])/g, (m, c) => c.toUpperCase()); }
// --- build the descriptionHtml from crawled ai_description (never AI-invent) ---
function buildDescription(row) {
let d = (row.ai_description || '').trim();
if (!d || d.length < 20) return null;
d = banWallpaper(d);
return `<p>${d.replace(/</g, '<').replace(/>/g, '>')}</p>`;
}
function widthInches(row) {
if (row.width_inches != null) return String(row.width_inches);
const m = String(row.width || '').match(/([\d.]+)/);
return m ? m[1] : null;
}
// --- build the full metafield set from crawled catalog data ---
function buildMetafields(row) {
const mf = [];
const add = (namespace, key, type, value) => {
if (value == null) return;
const v = String(value).trim();
if (!v || /^unknown$/i.test(v)) return;
mf.push({ namespace, key, type, value: banWallpaper(v) });
};
const widthDisp = row.width ? String(row.width).trim() : (widthInches(row) ? `${widthInches(row)}"` : null);
// global namespace (store convention for WG)
add('global', 'width', 'single_line_text_field', widthDisp);
add('global', 'material', 'single_line_text_field', row.material);
add('global', 'color', 'single_line_text_field', row.color_name);
add('global', 'Collection', 'single_line_text_field', row.collection);
add('global', 'finish', 'single_line_text_field', row.finish);
if (row.repeat_v) add('global', 'repeat', 'single_line_text_field', row.repeat_v);
add('global', 'title_tag', 'single_line_text_field',
`${titleCase(row.pattern_name || '')} - ${titleCase(row.color_name || '')} Wallcovering by Wolf Gordon Wallcoverings`.trim());
add('global', 'description_tag', 'single_line_text_field',
`Authorized Dealer of ${row.mfr_sku || row.dw_sku} by WOLF GORDON WALLCOVERINGS at Designer Wallcoverings`);
// custom namespace
add('custom', 'width', 'single_line_text_field', widthInches(row) ? `${widthInches(row)} Inches` : widthDisp);
add('custom', 'pattern_name', 'single_line_text_field', row.pattern_name);
add('custom', 'manufacturer_sku', 'single_line_text_field', row.mfr_sku);
add('custom', 'material', 'multi_line_text_field', row.material);
add('custom', 'collection_name', 'single_line_text_field', row.collection);
add('custom', 'color', 'single_line_text_field', row.color_name);
add('custom', 'fire_rating', 'single_line_text_field', row.fire_rating);
add('custom', 'color_hex', 'single_line_text_field', row.color_hex || row.dominant_color_hex);
// dwc namespace (DW standing rule: manufacturer_sku backup + real vendor + pattern/color)
add('dwc', 'manufacturer_sku', 'single_line_text_field', row.mfr_sku);
add('dwc', 'pattern_name', 'single_line_text_field', row.pattern_name);
add('dwc', 'color', 'single_line_text_field', row.color_name);
add('dwc', 'brand', 'single_line_text_field', 'Wolf Gordon');
// specs namespace (style/pattern from ai_styles/ai_patterns when present)
const firstOf = (j) => { try { const a = typeof j === 'string' ? JSON.parse(j) : j; return Array.isArray(a) && a.length ? String(a[0]) : null; } catch { return null; } };
add('specs', 'style', 'single_line_text_field', firstOf(row.ai_styles));
add('specs', 'pattern', 'single_line_text_field', firstOf(row.ai_patterns) || row.design);
// match type (commercial)
if (row.match_type) add('custom', 'pattern_repeat', 'single_line_text_field', row.match_type);
return mf;
}
async function loadLiveProduct(pid) {
const d = await gql(`{ node(id:"gid://shopify/Product/${pid}"){ ... on Product {
id status descriptionHtml
mediaCount{ count }
media(first:30){ edges{ node{ ... on MediaImage { image{ url } } } } }
metafields(first:80){ edges{ node{ namespace key } } }
variants(first:3){ edges{ node{ sku } } }
} } }`);
return d.node;
}
async function setMetafields(pid, mfs) {
// productSet metafields would replace — instead use metafieldsSet (upsert).
const inputs = mfs.map(m => ({ ownerId: `gid://shopify/Product/${pid}`, namespace: m.namespace, key: m.key, type: m.type, value: m.value }));
// metafieldsSet caps at 25 per call
for (let i = 0; i < inputs.length; i += 25) {
const chunk = inputs.slice(i, i + 25);
const d = await gql(`mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`, { mf: chunk });
const ue = d.metafieldsSet.userErrors;
if (ue && ue.length) throw new Error('metafieldsSet: ' + JSON.stringify(ue));
}
}
async function setDescription(pid, html) {
const d = await gql(`mutation($id:ID!,$d:String!){ productUpdate(input:{id:$id, descriptionHtml:$d}){ userErrors{ message } } }`,
{ id: `gid://shopify/Product/${pid}`, d: html });
const ue = d.productUpdate.userErrors; if (ue && ue.length) throw new Error('descUpdate: ' + JSON.stringify(ue));
}
async function setStatus(pid, status) {
const d = await gql(`mutation($id:ID!,$s:ProductStatus!){ productUpdate(input:{id:$id, status:$s}){ product{status} userErrors{ message } } }`,
{ id: `gid://shopify/Product/${pid}`, s: status });
const ue = d.productUpdate.userErrors; if (ue && ue.length) throw new Error('statusUpdate: ' + JSON.stringify(ue));
}
async function addTags(pid, tags) {
const d = await gql(`mutation($id:ID!,$t:[String!]!){ tagsAdd(id:$id, tags:$t){ userErrors{ message } } }`,
{ id: `gid://shopify/Product/${pid}`, t: tags });
const ue = d.tagsAdd.userErrors; if (ue && ue.length) throw new Error('tagsAdd: ' + JSON.stringify(ue));
}
async function addImages(pid, urls) {
const d = await gql(`mutation($id:ID!,$m:[CreateMediaInput!]!){ productCreateMedia(productId:$id, media:$m){ mediaUserErrors{ message } } }`,
{ id: `gid://shopify/Product/${pid}`, m: urls.map(u => ({ originalSource: u, mediaContentType: 'IMAGE' })) });
const ue = d.productCreateMedia.mediaUserErrors; if (ue && ue.length) console.error(' image-add warn:', JSON.stringify(ue));
}
function catalogImages(row) {
const imgs = [];
if (row.image_url) imgs.push(row.image_url);
if (row.all_images) {
try { const a = typeof row.all_images === 'string' ? JSON.parse(row.all_images) : row.all_images; if (Array.isArray(a)) for (const u of a) if (u && !imgs.includes(u)) imgs.push(u); } catch {}
}
return imgs;
}
async function main() {
console.log('='.repeat(74));
console.log(` WG ENRICHMENT BACKFILL mode=${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'}${CANARY ? ' [CANARY]' : ''}${LIMIT ? ` [limit ${LIMIT}]` : ''}`);
console.log(` store=${SHOP} api=${API} token=...${TOKEN.slice(-4)}`);
console.log('='.repeat(74));
// target = bare-shell cohort: catalog rows w/ a recorded pid. Restrict to canary/skus/limit.
let where = `shopify_product_id IS NOT NULL AND shopify_product_id<>''`;
let params = [];
if (CANARY) { where += ` AND dw_sku = ANY($1)`; params = [CANARY_SKUS]; }
else if (SKUS_ARG) { where += ` AND dw_sku = ANY($1)`; params = [SKUS_ARG]; }
const rows = (await pool.query(
`SELECT id, mfr_sku, dw_sku, pattern_name, color_name, collection, product_type, material,
width, width_inches, repeat_v, repeat_h, match_type, fire_rating, finish, design,
image_url, all_images, color_hex, dominant_color_hex, ai_description, ai_styles, ai_patterns,
shopify_product_id
FROM wolf_gordon_catalog WHERE ${where} ORDER BY updated_at DESC ${LIMIT && !CANARY && !SKUS_ARG ? `LIMIT ${LIMIT}` : ''}`,
params)).rows;
console.log(` candidate rows: ${rows.length}`);
const summary = { processed: 0, descSet: 0, mfSet: 0, imgAdded: 0, drafted: 0, tagged: 0, skippedComplete: 0, errors: 0 };
for (const row of rows) {
const pid = row.shopify_product_id;
try {
const live = await loadLiveProduct(pid);
if (!live) { console.log(` [MISS] ${row.dw_sku} pid=${pid} not found`); continue; }
summary.processed++;
const have = new Set(live.metafields.edges.map(m => `${m.node.namespace}.${m.node.key}`));
const wantMf = buildMetafields(row).filter(m => !have.has(`${m.namespace}.${m.key}`));
const curDesc = (live.descriptionHtml || '').replace(/<[^>]+>/g, '').trim();
const wantDesc = curDesc.length < 20 ? buildDescription(row) : null;
// Dedup by image BASENAME, not full URL: the catalog swatch
// (cdn2-optimize.wolfgordon.com/.../y47665sv.jpg) is re-hosted by Shopify
// as cdn.shopify.com/.../y47665sv.jpg — same file, different host. Comparing
// full URLs would re-upload a duplicate. basename() collapses both.
const basename = (u) => { try { return decodeURIComponent(u.split('?')[0].split('/').pop() || '').toLowerCase(); } catch { return (u.split('?')[0].split('/').pop() || '').toLowerCase(); } };
const liveImgs = new Set((live.media.edges || []).map(m => m.node?.image?.url).filter(Boolean).map(basename));
const wantImgs = catalogImages(row).filter(u => u && !liveImgs.has(basename(u)));
const liveImgCount = live.mediaCount?.count || 0;
const willHaveImg = liveImgCount > 0 || wantImgs.length > 0;
// width metafield presence after backfill
const widthDisp = row.width || (row.width_inches ? `${row.width_inches}"` : null);
const willHaveWidth = have.has('global.width') || have.has('custom.width') ||
wantMf.some(m => m.key === 'width') || !!widthDisp;
const needsImageTag = !willHaveImg;
const needsWidthTag = !willHaveWidth;
const shouldDraft = (needsImageTag || needsWidthTag);
if (!wantMf.length && !wantDesc && !wantImgs.length && !shouldDraft) { summary.skippedComplete++; continue; }
console.log(` [FIX] ${row.dw_sku} ${row.pattern_name} ${row.color_name} | +${wantMf.length}mf desc=${wantDesc ? 'Y' : '-'} +${wantImgs.length}img${shouldDraft ? ' DRAFT' : ''}`);
if (APPLY) {
if (wantImgs.length) { await addImages(pid, wantImgs); summary.imgAdded += wantImgs.length; }
if (wantMf.length) { await setMetafields(pid, wantMf); summary.mfSet += wantMf.length; }
if (wantDesc) { await setDescription(pid, wantDesc); summary.descSet++; }
if (shouldDraft) {
if (live.status === 'ACTIVE') { await setStatus(pid, 'DRAFT'); summary.drafted++; }
const tags = []; if (needsImageTag) tags.push('Needs-Image'); if (needsWidthTag) tags.push('Needs-Width');
if (tags.length) { await addTags(pid, tags); summary.tagged++; }
}
} else {
if (wantImgs.length) summary.imgAdded += wantImgs.length;
if (wantMf.length) summary.mfSet += wantMf.length;
if (wantDesc) summary.descSet++;
if (shouldDraft) summary.drafted++;
}
} catch (e) {
summary.errors++;
console.error(` [ERR] ${row.dw_sku} pid=${pid}: ${e.message}`);
}
}
console.log('-'.repeat(74));
console.log((APPLY ? ' APPLIED. ' : ' DRY-RUN. ') + JSON.stringify(summary));
await pool.end();
}
main().catch(e => { console.error('FATAL:', e); process.exit(1); });