← back to Wolfgordon Crawl
Add Wolf Gordon image-repair script (idempotent, image-add only)
a43744515f70d5a4cd175b52bffe99900b5db591 · 2026-06-19 11:33:33 -0700 · Steve
Files touched
Diff
commit a43744515f70d5a4cd175b52bffe99900b5db591
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 19 11:33:33 2026 -0700
Add Wolf Gordon image-repair script (idempotent, image-add only)
---
repair-images.js | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 157 insertions(+)
diff --git a/repair-images.js b/repair-images.js
new file mode 100644
index 0000000..9cf40aa
--- /dev/null
+++ b/repair-images.js
@@ -0,0 +1,157 @@
+#!/usr/bin/env node
+// ============================================================================
+// Wolf Gordon image REPAIR (LIVE store: designer-laboratory-sandbox)
+//
+// Bounded one-shot: the ~480 WG products created earlier today (before an
+// image-extraction bug was fixed) have 0 images. The catalog image_url is now
+// corrected (signed/200). For each catalog row with a shopify_product_id and a
+// signed image_url, GET the product's current images; if the catalog image is
+// missing, POST it as a product image. Idempotent (skip if already present).
+//
+// IMAGE-ADD ONLY. Does NOT touch price, SKU, variants, status, metafields.
+// Image adds do NOT consume the daily variant-create cap.
+//
+// node repair-images.js # DRY-RUN (default) — reports plan, no writes
+// node repair-images.js --apply # LIVE image adds
+// node repair-images.js --apply --limit 50
+//
+// Target rows: shopify_product_id IS NOT NULL AND image_url LIKE '%s=%'
+// Throttle ~2 req/s. If token fails or product 404s, log + continue. If the
+// running error rate exceeds 20%, STOP and report.
+// ============================================================================
+
+const { pool } = require('./scraper-utils');
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const REST = `https://${SHOP}/admin/api/${API}`;
+const THROTTLE_MS = 500; // ~2 req/s
+
+const argv = process.argv.slice(2);
+const APPLY = argv.includes('--apply');
+const LIMIT = (() => {
+ const i = argv.indexOf('--limit');
+ return i !== -1 && argv[i + 1] ? parseInt(argv[i + 1], 10) : null;
+})();
+
+// ---------- token (master .env via secrets manager) ----------
+function loadToken() {
+ if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+ const fs = require('fs');
+ const p = require('os').homedir() + '/Projects/secrets-manager/.env';
+ const line = fs.readFileSync(p, 'utf8').split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
+ if (!line) throw new Error('SHOPIFY_ADMIN_TOKEN not found in secrets .env');
+ return line.slice('SHOPIFY_ADMIN_TOKEN='.length).replace(/^["']|["']$/g, '').trim();
+}
+const TOKEN = loadToken();
+
+const wait = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function rest(method, path, body) {
+ await wait(THROTTLE_MS);
+ const res = await fetch(REST + path, {
+ method,
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const txt = await res.text();
+ let j; try { j = JSON.parse(txt); } catch { j = { _raw: txt }; }
+ if (!res.ok) {
+ const err = new Error(`REST ${method} ${path} -> ${res.status}: ${txt.slice(0, 200)}`);
+ err.status = res.status;
+ throw err;
+ }
+ return j;
+}
+
+// Strip the query string so a signed-url re-add isn't seen as "new" if Shopify
+// already ingested the same source. Shopify rehosts to its own CDN, so we match
+// on whether the product has ANY image at all, plus a best-effort src basename.
+function basename(u) {
+ try { return (u || '').split('?')[0].split('/').pop().toLowerCase(); }
+ catch { return ''; }
+}
+
+async function main() {
+ console.log('='.repeat(74));
+ console.log(` WOLF GORDON IMAGE REPAIR mode=${APPLY ? 'APPLY (LIVE WRITES)' : 'DRY-RUN'}` +
+ `${LIMIT ? ` [limit ${LIMIT}]` : ''}`);
+ console.log(` store=${SHOP} api=${API} token=...${TOKEN.slice(-4)}`);
+ console.log('='.repeat(74));
+
+ // token sanity
+ const shop = await rest('GET', '/shop.json');
+ console.log(` shop: ${shop.shop.name} (${shop.shop.myshopify_domain})`);
+
+ const rows = (await pool.query(`
+ SELECT shopify_product_id, dw_sku, mfr_sku, image_url
+ FROM wolf_gordon_catalog
+ WHERE shopify_product_id IS NOT NULL AND image_url LIKE '%s=%'
+ ORDER BY shopify_product_id`)).rows;
+
+ const work = LIMIT ? rows.slice(0, LIMIT) : rows;
+ console.log(` target rows: ${rows.length}${LIMIT ? ` (limited to ${work.length})` : ''}`);
+ console.log('-'.repeat(74));
+
+ let checked = 0, added = 0, skipped = 0, errors = 0;
+ const errSamples = [];
+ const addedSamples = [];
+
+ for (const r of work) {
+ checked++;
+ const pid = String(r.shopify_product_id);
+ try {
+ const got = await rest('GET', `/products/${pid}/images.json`);
+ const imgs = got.images || [];
+ const wantBase = basename(r.image_url);
+
+ // Has the image already? Treat "any image present whose basename matches
+ // the catalog source" OR "Shopify already has >=1 image" carefully:
+ // the bug left these at 0 images, so the precise rule is — if the product
+ // already has the catalog source (by basename) skip; if it has 0 images
+ // add the catalog image; if it has some other image already, also skip
+ // (don't second-guess a product that already has imagery).
+ const hasMatch = imgs.some(i => basename(i.src) === wantBase);
+ if (hasMatch || imgs.length > 0) {
+ skipped++;
+ if (skipped <= 3) console.log(` [SKIP] ${r.dw_sku} pid=${pid} (${imgs.length} img already)`);
+ continue;
+ }
+
+ // 0 images -> add the catalog image.
+ console.log(` [ADD] ${r.dw_sku} pid=${pid} ${wantBase}`);
+ if (APPLY) {
+ await rest('POST', `/products/${pid}/images.json`, { image: { src: r.image_url } });
+ added++;
+ if (addedSamples.length < 2) addedSamples.push({ dw_sku: r.dw_sku, pid });
+ } else {
+ added++; // would-add count in dry-run
+ }
+ } catch (e) {
+ errors++;
+ const msg = `${r.dw_sku} pid=${pid}: ${e.message}`;
+ console.error(` [ERR] ${msg}`);
+ if (errSamples.length < 10) errSamples.push(msg);
+ // 20% error-rate circuit breaker (only meaningful after a few checks).
+ if (checked >= 20 && errors / checked > 0.20) {
+ console.error('-'.repeat(74));
+ console.error(` STOP — error rate ${((errors / checked) * 100).toFixed(1)}% exceeds 20% (${errors}/${checked}).`);
+ break;
+ }
+ }
+ }
+
+ console.log('-'.repeat(74));
+ console.log(` ${APPLY ? 'APPLIED' : 'DRY-RUN'} summary:`);
+ console.log(` checked: ${checked}`);
+ console.log(` images ${APPLY ? 'added' : 'would-add'}: ${added}`);
+ console.log(` skipped (already had image): ${skipped}`);
+ console.log(` errors: ${errors}`);
+ if (errSamples.length) console.log(` error samples:\n - ${errSamples.join('\n - ')}`);
+ if (addedSamples.length) console.log(` added samples: ${JSON.stringify(addedSamples)}`);
+ if (!APPLY) console.log(' DRY-RUN — nothing written. Re-run with --apply to execute.');
+
+ await pool.end();
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
← 71763fa Add per-row image gap-fill (fetch each unsigned row's own pa
·
back to Wolfgordon Crawl
·
WG data-quality: add live-state audits + enrichment backfill 02b4906 →