[object Object]

← 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 →