← back to Designer Wallcoverings
audits/designtex-uom-fix/fill-specs-live-308.mjs
129 lines
#!/usr/bin/env node
// Designtex Step 3 — write structured spec metafields (global.* + specs.*, DTD 2/2 verdict C)
// to the LIVE DWDX Designtex products from canonical dw_unified, and fix "single roll" prose
// in body_html (body backed up FIRST). DWDX ONLY — never touches the 9 DWAH "Rocket" products.
// Source = /tmp/designtex-specs-export.json (real vendor crawl, NO AI). $0 (Shopify Admin API).
// Idempotent: a per-product ledger records done SKUs so a re-run skips them. ≥90s batch gaps.
import fs from 'fs'; import os from 'os'; import path from 'path';
const EXECUTE = process.argv.includes('--execute');
const HERE = path.dirname(new URL(import.meta.url).pathname);
const TOK = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8')
.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].trim();
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
async function gql(query, variables) {
for (let a = 0; a < 6; a++) {
const r = await fetch(`https://${DOMAIN}/admin/api/${VER}/graphql.json`, {
method: 'POST', headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const j = await r.json();
if (j.errors && /throttl/i.test(JSON.stringify(j.errors))) { await new Promise(x => setTimeout(x, 3000)); continue; }
return j;
}
return {};
}
// canonical spec source keyed by dw_sku
const specs = JSON.parse(fs.readFileSync('/tmp/designtex-specs-export.json', 'utf8'));
const byDwSku = {}; for (const s of specs) byDwSku[s.dw_sku] = s;
// idempotent ledger
const LEDGER = path.join(HERE, 'fill-specs-ledger.json');
const ledger = fs.existsSync(LEDGER) ? JSON.parse(fs.readFileSync(LEDGER, 'utf8')) : {};
// body_html backup file (append-only, keyed by product id)
const BODYBAK = path.join(HERE, 'body-html-backup.json');
const bodyBak = fs.existsSync(BODYBAK) ? JSON.parse(fs.readFileSync(BODYBAK, 'utf8')) : {};
// 1. collect every LIVE DWDX Designtex product
const pq = `query($c:String){
products(first:50, after:$c, query:"vendor:Designtex"){
pageInfo{ hasNextPage endCursor }
edges{ node{
id title status descriptionHtml
variants(first:4){ edges{ node{ sku } } }
} }
}
}`;
const targets = []; let after = null;
while (true) {
const j = await gql(pq, { c: after }); const pr = j.data && j.data.products;
if (!pr) { console.error('fetch err', JSON.stringify(j).slice(0, 200)); break; }
for (const e of pr.edges) {
const p = e.node;
const skus = (p.variants?.edges || []).map(v => v.node.sku).filter(Boolean);
const dw = skus.map(s => s.replace(/-(Sample|Roll|Yard)$/i, '')).find(s => /^DWDX-/i.test(s));
if (!dw) continue; // DWDX only — excludes DWAH Rocket
targets.push({ id: p.id, dw, title: p.title, status: p.status, body: p.descriptionHtml || '' });
}
if (!pr.pageInfo.hasNextPage) break; after = pr.pageInfo.endCursor; await new Promise(x => setTimeout(x, 300));
}
const matched = targets.filter(t => byDwSku[t.dw]);
console.log(`Mode: ${EXECUTE ? 'EXECUTE' : 'DRY RUN'} | live DWDX=${targets.length} matched-to-canonical=${matched.length} unmatched=${targets.length - matched.length} already-done=${Object.keys(ledger).length}`);
if (targets.length - matched.length) console.log(' unmatched dw_skus:', targets.filter(t => !byDwSku[t.dw]).map(t => t.dw).slice(0, 20));
if (!EXECUTE) { console.log('Sample writes for first 2:'); matched.slice(0,2).forEach(t=>{ const s=byDwSku[t.dw]; console.log(' ',t.dw, {repeat:s.pattern_repeat, content:s.material, fire:s.fire_rating, care:s.maintenance, finish:s.finish, width:s.width}); }); console.log('Re-run with --execute.'); process.exit(0); }
const mfMut = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`;
const bodyMut = `mutation($id:ID!,$body:String!){ productUpdate(input:{id:$id, descriptionHtml:$body}){ userErrors{ message } } }`;
const T = 'single_line_text_field';
const fixProse = (html) => html
.replace(/Priced\s+per\s+single\s+roll/gi, 'Priced per yard')
.replace(/priced\s+per\s+single\s+roll/gi, 'priced per yard')
.replace(/\bper\s+single\s+roll\b/gi, 'per yard')
.replace(/\bsingle\s+roll\b/gi, 'yard');
const results = []; const BATCH = 50;
for (let i = 0; i < matched.length; i += BATCH) {
for (const t of matched.slice(i, i + BATCH)) {
if (ledger[t.dw]) { results.push({ dw: t.dw, ok: true, skipped: true }); continue; }
const s = byDwSku[t.dw]; const errs = [];
const mfs = [];
const add = (ns, key, val) => { if (val != null && String(val).trim() !== '' && !/^N\/A$/i.test(String(val))) mfs.push({ ownerId: t.id, namespace: ns, key, type: T, value: String(val) }); };
// global.* (primary PDP snippet: repeat, material, length, Contents, width)
add('global', 'repeat', s.pattern_repeat);
add('global', 'material', s.material);
add('global', 'Contents', s.material);
add('global', 'length', s.length || s.roll_length);
add('global', 'width', s.width);
add('global', 'Cleaning-Code', s.maintenance);
add('global', 'FLAMMABILITY', s.fire_rating);
add('global', 'Finish', s.finish);
add('global', 'Country-of-Origin', 'USA');
// specs.* (spec-sheet page snippet)
add('specs', 'repeat', s.pattern_repeat);
add('specs', 'material_type', s.material);
add('specs', 'finish', s.finish);
add('specs', 'fire_rating', s.fire_rating);
add('specs', 'care_instructions', s.maintenance);
add('specs', 'width', s.width);
add('specs', 'color', s.color_name);
add('specs', 'origin', 'USA');
// chunk metafieldsSet at 25/call
for (let k = 0; k < mfs.length; k += 25) {
const m = await gql(mfMut, { mf: mfs.slice(k, k + 25) });
(m.data?.metafieldsSet?.userErrors || []).forEach(e => errs.push('mf:' + e.message));
}
// body prose fix (backup first)
if (/single\s+roll/i.test(t.body)) {
if (!(t.id in bodyBak)) { bodyBak[t.id] = t.body; fs.writeFileSync(BODYBAK, JSON.stringify(bodyBak, null, 2)); }
const fixed = fixProse(t.body);
if (fixed !== t.body) {
const b = await gql(bodyMut, { id: t.id, body: fixed });
(b.data?.productUpdate?.userErrors || []).forEach(e => errs.push('body:' + e.message));
}
}
const ok = errs.length === 0;
if (ok) { ledger[t.dw] = { id: t.id, at: new Date().toISOString(), mfCount: mfs.length }; fs.writeFileSync(LEDGER, JSON.stringify(ledger, null, 2)); }
else console.error('ERR', t.dw, errs.join('; '));
results.push({ dw: t.dw, ok, errs });
await new Promise(x => setTimeout(x, 350));
}
console.log(`Batch ${i / BATCH + 1} done (${Math.min(i + BATCH, matched.length)}/${matched.length}).`);
if (i + BATCH < matched.length) { console.log('Sleeping 90s...'); await new Promise(x => setTimeout(x, 90000)); }
}
const failed = results.filter(r => !r.ok);
const skipped = results.filter(r => r.skipped).length;
console.log(`DONE. ${results.length - failed.length - skipped} written, ${skipped} skipped(done), ${failed.length} failed.`);
fs.writeFileSync(path.join(HERE, 'fill-specs-results.json'), JSON.stringify(results, null, 2));