← 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); });