← back to Interiordesignershowroom

scripts/verify-images.js

121 lines

#!/usr/bin/env node
// TK-10112 — Dead-image guard. The storefront + Room Builder should only show
// products whose image actually loads. CJ/affiliate feeds go stale: an item is
// dropped or its CDN path rotates and the image_url starts 404ing, leaving a
// broken card. This script checks every product's image_url and flips
// in_stock=FALSE for the dead ones so they stop rendering. We NEVER delete —
// affiliate URLs recover (item comes back / CDN path returns), and the nightly
// ingest re-UPSERTs stock, so a recovered image naturally comes back in_stock.
//
// Live check: HEAD first (cheap), fall back to a ranged GET (Range: bytes=0-0)
// for hosts that 405/403 a HEAD or don't set content-type on HEAD. A URL is
// GOOD iff we get a 2xx AND the content-type is an image/*. Anything else
// (network error, timeout, 4xx/5xx, non-image body) is DEAD.
//
// Safe/reversible: only writes products.in_stock. Concurrency-capped, 6s
// per-request timeout. Products with working images end up in_stock=TRUE.
//
// Usage: node scripts/verify-images.js [--limit=N] [--dry-run]

try { require('dotenv').config(); } catch (_) { /* env from shell */ }
const db = require('../lib/db');

const CONCURRENCY = 12;
const FETCH_TIMEOUT_MS = 6000;
const PROGRESS_EVERY = 250;

function arg(name, def) {
  const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
  return hit ? hit.split('=')[1] : def;
}
const DRY_RUN = process.argv.includes('--dry-run');

// One request with a hard timeout. Returns { ok, contentType } or null on error.
async function req(url, method, extraHeaders) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
  try {
    const res = await fetch(url, {
      method,
      redirect: 'follow',
      signal: ctrl.signal,
      headers: {
        'User-Agent': 'Mozilla/5.0 (idshowroom image-verify/1.0)',
        Accept: 'image/*,*/*;q=0.8',
        ...(extraHeaders || {}),
      },
    });
    return { ok: res.ok, status: res.status, contentType: res.headers.get('content-type') || '' };
  } catch {
    return null; // timeout / DNS / connection reset → treat as dead
  } finally {
    clearTimeout(t);
  }
}

function isImage(ct) {
  return typeof ct === 'string' && /^image\//i.test(ct.trim());
}

// GOOD iff a 2xx with an image content-type. HEAD first; if HEAD is unusable
// (non-2xx, or 2xx but no/blank/non-image content-type) fall back to a ranged
// GET which many CDNs answer more completely.
async function imageAlive(url) {
  const head = await req(url, 'HEAD');
  if (head && head.ok && isImage(head.contentType)) return true;
  // Fallback: ranged GET (only pulls the first byte).
  const get = await req(url, 'GET', { Range: 'bytes=0-0' });
  if (get && get.ok && isImage(get.contentType)) return true;
  return false;
}

async function main() {
  const limit = parseInt(arg('limit', '0'), 10);
  await db.query('SELECT 1');

  const { rows } = await db.query(
    `SELECT id, image_url FROM products
     WHERE image_url IS NOT NULL AND image_url <> ''
     ORDER BY id ${limit > 0 ? `LIMIT ${limit}` : ''}`
  );
  console.log(`[verify-images] checking ${rows.length} products (concurrency ${CONCURRENCY}, ${DRY_RUN ? 'DRY-RUN' : 'LIVE'})`);

  let checked = 0, dead = 0, good = 0;
  const deadIds = [];
  const goodIds = [];

  let idx = 0;
  async function worker() {
    while (idx < rows.length) {
      const row = rows[idx++];
      let alive = false;
      try { alive = await imageAlive(row.image_url); } catch { alive = false; }
      if (alive) { good++; goodIds.push(row.id); }
      else { dead++; deadIds.push(row.id); }
      checked++;
      if (checked % PROGRESS_EVERY === 0) {
        console.log(`  ${checked}/${rows.length}  good=${good} dead=${dead}`);
      }
    }
  }
  await Promise.all(Array.from({ length: CONCURRENCY }, worker));

  if (!DRY_RUN) {
    // Dead images → hide. Working images → make sure they're shown (recovery).
    // Batch the UPDATEs via = ANY($1::bigint[]).
    if (deadIds.length) {
      await db.query('UPDATE products SET in_stock = FALSE, updated_at = now() WHERE id = ANY($1::bigint[])', [deadIds]);
    }
    if (goodIds.length) {
      await db.query('UPDATE products SET in_stock = TRUE, updated_at = now() WHERE id = ANY($1::bigint[]) AND in_stock IS DISTINCT FROM TRUE', [goodIds]);
    }
  }

  console.log('[verify-images] ------------------------------------');
  console.log(`[verify-images] checked=${checked}  good=${good}  dead(flagged in_stock=FALSE)=${dead}`);
  if (DRY_RUN) console.log('[verify-images] DRY-RUN — no rows written.');
  await db.pool.end();
}

main().catch((e) => { console.error(e); process.exit(1); });