← back to Wolfgordon Crawl
rescrape-room-images.js
139 lines
#!/usr/bin/env node
// ============================================================================
// Wolf Gordon — TARGETED room-image re-scrape ($0, plain HTTP, resumable).
//
// The crawler fix (scrape-wolfgordon.js, commit 24bc927) now captures the
// room/installation shot into all_images, but the 5,483 catalog rows still
// hold swatch-only all_images. This re-fetches each product page over HTTP and
// updates ONLY all_images (= [swatch, ...room/installation shots]) in
// dw_unified.wolf_gordon_catalog. image_url (the primary swatch) is left
// untouched. NEVER touches price, never creates rows, never deletes.
//
// Priority: the LIVE cohort (rows with a shopify_product_id) first — those are
// the products customers see. --all widens to the whole catalog.
//
// Resumable: a row is SKIPPED if its all_images already contains an
// installation shot (idempotent re-run safe). Polite: concurrency 3, jittered
// pauses, honors the shared kill-switch ~/.dw-fixer-stop.
//
// Usage:
// node rescrape-room-images.js # DRY-RUN, live cohort
// node rescrape-room-images.js --apply # LIVE cohort, write
// node rescrape-room-images.js --apply --all # whole catalog, write
// node rescrape-room-images.js --apply --limit 50
// ============================================================================
const https = require('https');
const os = require('os');
const fs = require('fs');
const { pool, wait } = require('./scraper-utils');
const { parseProductPage } = require('./scrape-wolfgordon.js');
const TABLE = 'wolf_gordon_catalog';
const STOP = os.homedir() + '/.dw-fixer-stop';
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const ALL = argv.includes('--all');
const LIMIT = (() => { const i = argv.indexOf('--limit'); return i !== -1 && argv[i + 1] ? parseInt(argv[i + 1], 10) : null; })();
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
function fetchPage(url, maxRedirects = 5) {
return new Promise((resolve, reject) => {
https.get(url, { timeout: 25000, headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' } }, (res) => {
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location && maxRedirects > 0) {
let loc = res.headers.location;
if (loc.startsWith('/')) loc = `https://www.wolfgordon.com${loc}`;
return fetchPage(loc, maxRedirects - 1).then(resolve).catch(reject);
}
let data = '';
res.on('data', c => data += c);
res.on('end', () => resolve({ html: data, status: res.statusCode }));
res.on('error', reject);
}).on('error', reject).on('timeout', function () { this.destroy(); reject(new Error('timeout')); });
});
}
const nk = s => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
const hasRoom = (j) => { try { const a = typeof j === 'string' ? JSON.parse(j) : j; return Array.isArray(a) && a.some(u => /installation/.test(String(u))); } catch { return false; } };
async function main() {
console.log('='.repeat(74));
console.log(` WG ROOM-IMAGE RE-SCRAPE mode=${APPLY ? 'APPLY (write)' : 'DRY-RUN'} scope=${ALL ? 'ALL' : 'LIVE-COHORT'}${LIMIT ? ` limit=${LIMIT}` : ''}`);
console.log('='.repeat(74));
// Source-of-truth rows. Live cohort = those with a shopify_product_id.
let where = `product_url IS NOT NULL`;
if (!ALL) where += ` AND shopify_product_id IS NOT NULL`;
const rows = (await pool.query(
`SELECT id, mfr_sku, product_url, all_images, shopify_product_id
FROM ${TABLE} WHERE ${where}
ORDER BY (shopify_product_id IS NOT NULL) DESC, updated_at DESC
${LIMIT ? `LIMIT ${LIMIT}` : ''}`)).rows;
console.log(` candidate rows: ${rows.length}`);
// Group rows by product PAGE (one page yields many colorways). We fetch each
// distinct page once, then match parsed colorways back to rows by SKU.
const pages = new Map(); // url -> [row,...]
let alreadyHasRoom = 0;
for (const r of rows) {
if (hasRoom(r.all_images)) { alreadyHasRoom++; continue; } // resume skip
if (!pages.has(r.product_url)) pages.set(r.product_url, []);
pages.get(r.product_url).push(r);
}
// Also index every candidate row by SKU so a page that lists sibling colorways
// can update them too (the room shot is shared per pattern).
const bySku = new Map();
for (const r of rows) bySku.set(nk(r.mfr_sku), r);
const pageList = [...pages.keys()];
console.log(` rows already have a room shot (skipped): ${alreadyHasRoom}`);
console.log(` distinct product pages to fetch: ${pageList.length}`);
const summary = { pagesFetched: 0, rowsUpdated: 0, pagesNoRoom: 0, pages404: 0, errors: 0 };
const concurrency = 3;
let idx = 0;
async function worker() {
while (idx < pageList.length) {
if (fs.existsSync(STOP)) { console.log(' KILL-SWITCH present — stopping.'); return; }
const i = idx++;
const url = pageList[i];
try {
const { html, status } = await fetchPage(url);
if (status !== 200 || html.length < 2000) { summary.pages404++; await wait(400, 200); continue; }
summary.pagesFetched++;
const parsed = parseProductPage(html, url);
// build SKU -> all_images (with room shots) from this page
for (const p of parsed) {
let imgs; try { imgs = JSON.parse(p.all_images); } catch { continue; }
if (!Array.isArray(imgs) || !imgs.some(u => /installation/.test(String(u)))) continue;
const row = bySku.get(nk(p.mfr_sku));
if (!row) continue;
if (hasRoom(row.all_images)) continue; // already done in a prior page
row.all_images = JSON.stringify(imgs); // mark done in-memory (dedup siblings)
if (APPLY) {
await pool.query(`UPDATE ${TABLE} SET all_images=$1, updated_at=NOW(), last_scraped=NOW() WHERE id=$2`, [JSON.stringify(imgs), row.id]);
}
summary.rowsUpdated++;
}
// did THIS page yield any room shot at all?
if (!parsed.some(p => /installation/.test(String(p.all_images)))) summary.pagesNoRoom++;
if ((i + 1) % 25 === 0) console.log(` progress ${i + 1}/${pageList.length} pages | ${summary.rowsUpdated} rows updated`);
await wait(450, 250);
} catch (e) {
summary.errors++;
if (summary.errors <= 5) console.error(` err ${url}: ${e.message}`);
await wait(900, 400);
}
}
}
const ws = [];
for (let w = 0; w < concurrency; w++) ws.push(worker());
await Promise.all(ws);
console.log('-'.repeat(74));
console.log((APPLY ? ' APPLIED. ' : ' DRY-RUN. ') + JSON.stringify(summary));
await pool.end();
}
main().catch(e => { console.error('FATAL:', e); process.exit(1); });