← back to Wolfgordon Crawl
repair-images.js
158 lines
#!/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); });