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