← back to Dw Cowtan Recrawl
reference/recrawl-graham_brown.js
196 lines
#!/usr/bin/env node
/*
* recrawl-graham_brown.js — additive full-page all_images re-crawl for Graham & Brown.
*
* WHY: 3117/3125 graham_brown_catalog rows carry only a primary image_url and an
* empty all_images, which fails Steve's "always pull all data and images"
* completeness gate (a row is import-ready only when the crawl captured the FULL
* product page incl. ALL images). This driver re-crawls each product page and
* fills all_images + room_setting_url WITHOUT touching anything else (additive).
*
* SOURCE: grahambrown.com is a BigCommerce Stencil store (store hash s-7psb023nit).
* Each PDP renders this product's gallery AND a "related products" carousel, both
* on the same cdn11.bigcommerce.com host — so a naive "all CDN images" grab would
* pollute all_images with other SKUs' thumbnails. The reliable discriminator: the
* THIS-product gallery images embed the product's mfr_sku in the filename
* (e.g. 120628_TILE_..., 120628_ROOMSET_...). We filter on that.
*
* SAFETY: additive only. We UPDATE all_images / room_setting_url ONLY on rows where
* all_images is currently empty. No inserts, no deletes, no Shopify writes, no
* column other than all_images / room_setting_url / updated_at touched.
*
* USAGE
* # local dry-run (no DB write needed; prints extracted images per URL):
* node recrawl-graham_brown.js --dry-run --limit 5
*
* # prod (Kamatera) — fills empty all_images for every missing row:
* DATABASE_URL=postgres://... node recrawl-graham_brown.js --all-images
*
* # prod, net-net-new only (the 240 rows not yet on Shopify) — fastest path to
* # making the genuinely new SKUs import-ready:
* DATABASE_URL=postgres://... node recrawl-graham_brown.js --net-new --all-images
*
* FLAGS
* --dry-run fetch + extract + print, never write DB (DATABASE_URL optional)
* --net-new restrict to rows with no shopify_product_id AND no shopify sku match
* --all-images (default behaviour) capture all_images + room images; kept explicit
* --limit N cap number of product pages processed
*/
'use strict';
const https = require('https');
// pg is required lazily inside main() so --dry-run works without the module installed.
const ARGS = process.argv.slice(2);
const DRY_RUN = ARGS.includes('--dry-run');
const NET_NEW = ARGS.includes('--net-new');
const LIMIT = (() => { const i = ARGS.indexOf('--limit'); return i >= 0 ? parseInt(ARGS[i + 1], 10) || 0 : 0; })();
const TABLE = 'graham_brown_catalog';
const STORE_HASH = 's-7psb023nit';
const SITE_BASE = 'https://www.grahambrown.com';
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const CONCURRENCY = 3;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms + Math.floor(Math.random() * 250)));
function httpGet(url, maxRedirects = 4) {
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 25000, headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml', 'Accept-Language': 'en-US,en;q=0.9' } }, (res) => {
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location && maxRedirects > 0) {
const loc = res.headers.location.startsWith('http') ? res.headers.location : SITE_BASE + res.headers.location;
res.resume();
return httpGet(loc, maxRedirects - 1).then(resolve).catch(reject);
}
if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
let d = ''; res.on('data', (c) => (d += c)); res.on('end', () => resolve(d)); res.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
});
}
/*
* Extract THIS product's gallery from a Graham & Brown BigCommerce PDP.
* Returns { all_images: string[], room_setting_url: string|null }.
* Strategy: collect every cdn11/<STORE_HASH>/.../products/.../<...>.jpg whose
* filename contains the product's mfr_sku, normalise each to the "original" size
* variant, dedupe by image path, and order TILE first (the swatch shoppers expect
* as primary) then the rest. room_setting_url = first ROOMSET/ROLLSHOT frame.
*/
function extractGrahamImages(html, mfrSku, productUrl) {
const sku = String(mfrSku || '').trim();
if (!sku) return { all_images: [], room_setting_url: null };
// Paint / multi-variant "master" pages live at .../<masterNum>-master/ where the
// URL number can differ from this row's mfr_sku. Accept either as the filename key.
const masterNum = (String(productUrl || '').match(/\/(\d+)-master\//) || [])[1] || '';
const keys = [sku, masterNum].filter(Boolean);
const matchesKey = (fname) => keys.some((k) => fname.includes(k));
const re = new RegExp(`https://cdn11\\.bigcommerce\\.com/${STORE_HASH}/images/stencil/[^"'\\\\ ]+`, 'g');
const seen = new Set();
const imgs = [];
let m;
while ((m = re.exec(html)) !== null) {
let url = m[0]
.replace(/\\\//g, '/') // un-escape JSON slashes
.replace(/\\/g, '') // drop any stray backslashes
.replace(/&/g, '&')
.replace(/[?].*$/, '');
if (!/\.(jpg|jpeg|png|webp)$/i.test(url)) continue; // skip truncated / non-image hits
// only THIS product's frames — filename carries the mfr_sku (or master number)
const fname = url.split('/').pop();
if (!matchesKey(fname)) continue;
// normalise size token (100w / 340w / 1200w / 1280w / original) -> original
url = url.replace(/\/stencil\/(?:\d+w|\d+x\d+|original)\//, '/stencil/original/');
const pathKey = url.replace(/__\d+\.\d+\.(jpg|jpeg|png|webp)$/i, ''); // strip BC cache-bust suffix for dedupe
if (seen.has(pathKey)) continue;
seen.add(pathKey);
imgs.push(url);
}
// order: TILE first, then ROLLSHOT/DIGITAL ROLL, then ROOMSET/FLATLAY
const rank = (u) => {
const f = u.toUpperCase();
if (f.includes('_TILE')) return 0;
if (f.includes('ROLLSHOT') || f.includes('DIGITAL')) return 1;
if (f.includes('FLATLAY')) return 2;
if (f.includes('ROOMSET') || f.includes('ROOM')) return 3;
return 4;
};
imgs.sort((a, b) => rank(a) - rank(b));
const room = imgs.find((u) => /ROOMSET|ROOM|ROLLSHOT/i.test(u)) || null;
return { all_images: imgs, room_setting_url: room };
}
async function main() {
const pool = (DATABASE_URL_PRESENT()) ? new (require('pg').Pool)({ connectionString: process.env.DATABASE_URL }) : null;
if (!pool && !DRY_RUN) {
console.error('FATAL: DATABASE_URL not set and not --dry-run. Refusing to run.');
process.exit(2);
}
// Build the worklist. Rows missing all_images that have a product_url to fetch.
let where = `(all_images IS NULL OR all_images = '' OR all_images = '[]') AND product_url IS NOT NULL AND product_url <> ''`;
if (NET_NEW) {
where += ` AND shopify_product_id IS NULL
AND NOT EXISTS (SELECT 1 FROM shopify_products s
WHERE s.sku = g.dw_sku OR s.sku = g.dw_sku || '-Sample' OR s.sku ILIKE g.dw_sku || '-%')`;
}
let rows;
if (pool) {
const q = `SELECT g.id, g.dw_sku, g.mfr_sku, split_part(g.product_url,'?',1) AS url
FROM ${TABLE} g WHERE ${where} ORDER BY g.dw_sku ${LIMIT ? 'LIMIT ' + LIMIT : ''}`;
({ rows } = await pool.query(q));
} else {
// dry-run with no DB: hit a couple of known sample URLs so the extractor is exercised.
rows = [
// real net-net-new worklist rows: one wallpaper, one paint "master" (exercises the URL master-number fallback)
{ id: 0, dw_sku: 'DWGB-200116', mfr_sku: '120628', url: 'https://www.grahambrown.com/us/product/wallflower-night-garden-wallpaper/120628-master/' },
{ id: 0, dw_sku: 'DWGB-200555', mfr_sku: '109732', url: 'https://www.grahambrown.com/us/product/g-b-white-paint/109729-master/' },
].slice(0, LIMIT || 2);
}
console.log(`Re-crawling ${rows.length} graham_brown product page(s)${NET_NEW ? ' (net-net-new only)' : ''}${DRY_RUN ? ' [DRY RUN]' : ''}...`);
let pages = 0, updated = 0, withImg = 0, multiImg = 0, errs = 0;
let idx = 0;
async function worker() {
while (idx < rows.length) {
const i = idx++;
const r = rows[i];
try {
const html = await httpGet(r.url);
const { all_images, room_setting_url } = extractGrahamImages(html, r.mfr_sku, r.url);
pages++;
if (all_images.length) withImg++;
if (all_images.length > 1) multiImg++;
if (DRY_RUN) {
console.log(` ${r.dw_sku} (${r.mfr_sku}) -> ${all_images.length} img${room_setting_url ? ' +room' : ''}`);
all_images.forEach((u) => console.log(` ${u}`));
} else if (all_images.length) {
// additive: only set all_images where it is still empty (guards concurrent fills)
const res = await pool.query(
`UPDATE ${TABLE}
SET all_images = $1,
room_setting_url = COALESCE(NULLIF(room_setting_url,''), $2),
updated_at = now()
WHERE id = $3 AND (all_images IS NULL OR all_images = '' OR all_images = '[]')`,
[JSON.stringify(all_images), room_setting_url, r.id]
);
if (res.rowCount) updated++;
}
if (pages % 25 === 0) console.log(` ${pages}/${rows.length} pages | ${updated} updated | ${withImg} w/img | ${multiImg} multi | ${errs} errs`);
await sleep(350);
} catch (e) {
errs++;
if (errs % 10 === 0) console.error(` err at ${r.url}: ${e.message}`);
await sleep(800);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
console.log(`\nDONE. pages=${pages} updated=${updated} with_img=${withImg} multi_img=${multiImg} errors=${errs}`);
if (pool) await pool.end();
}
function DATABASE_URL_PRESENT() { return !!process.env.DATABASE_URL; }
main().catch((e) => { console.error('FATAL', e); process.exit(1); });