← back to Jf Fabrics Recrawl
recrawl-jffabrics.js
139 lines
#!/usr/bin/env node
// ============================================================================
// JF Fabrics re-crawl driver (mirror of recrawl-osborne.js)
// ----------------------------------------------------------------------------
// Pulls distinct jf_fabrics product_urls from the DB and re-runs the FIXED
// extractor (extract-jffabrics.js: all_images + full specs), then ADDITIVELY
// upserts via scraper-utils.upsertProduct (ON CONFLICT (mfr_sku) DO UPDATE — no
// new rows, no duplicates; only backfills the missing image/spec columns).
//
// DEPLOY TARGET (Kamatera): copy this file + extract-jffabrics.js into
// /root/DW-Agents/vendor-scrapers/ then run there (writes prod dw_unified).
//
// Flags:
// --net-new only rows not yet on Shopify (on_shopify=false / no shopify id)
// --all-images only rows whose all_images is empty (the completeness gap)
// --limit N cap the worklist (smoke test)
// --dry-run crawl + extract + log, but DO NOT write to the DB
// ============================================================================
require('dotenv').config({ path: '/root/DW-Agents/dw-price-stock/.env' });
const https = require('https');
const path = require('path');
// scraper-utils + the fixed extractor resolve from the vendor-scrapers dir when
// deployed there; fall back to local sibling for off-box dry tests.
function req(localName, deployedPath) {
try { return require(deployedPath); } catch (e) { return require(path.join(__dirname, localName)); }
}
const { upsertProduct, pool } = req('./_stub_scraper_utils.js', '/root/DW-Agents/vendor-scrapers/scraper-utils');
const { extractFromHtml } = req('./extract-jffabrics.js', '/root/DW-Agents/vendor-scrapers/extract-jffabrics.js');
const TABLE = 'jf_fabrics_catalog';
const SITE_BASE = 'https://www.jffabrics.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 MIN_BYTES = 30000; // a real JF product page is ~60KB; shorter = throttled/challenge
const args = process.argv.slice(2);
const NET_NEW = args.includes('--net-new');
const ALL_IMAGES_ONLY = args.includes('--all-images');
const DRY = args.includes('--dry-run');
const limIdx = args.indexOf('--limit');
const LIMIT = limIdx >= 0 ? parseInt(args[limIdx + 1], 10) : 0;
function httpGet(url, maxRedirects = 4) {
return new Promise((resolve, reject) => {
const req2 = 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();
const err = new Error('HTTP ' + res.statusCode);
err.statusCode = res.statusCode;
const ra = res.headers['retry-after'];
if (ra) { const s = parseInt(ra, 10); if (!isNaN(s)) err.retryAfterMs = Math.min(120000, s * 1000); }
return reject(err);
}
let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); res.on('error', reject);
});
req2.on('error', reject);
req2.on('timeout', () => { req2.destroy(); reject(new Error('timeout')); });
});
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms + Math.floor(Math.random() * 250)));
// Fetch with retry on transient short pages AND on HTTP 429 rate-limiting.
// JF Fabrics enforces an IP burst quota that depletes after ~25 quick hits, then
// returns 429 until it refills — so on 429 we back off (Retry-After if given,
// else escalating cooldown) and retry, letting the single worker self-pace to a
// sustainable rate rather than burning the whole worklist into errors.
async function fetchPage(url) {
for (let attempt = 1; attempt <= 6; attempt++) {
try {
const html = await httpGet(url);
if (html && html.length >= MIN_BYTES) { await sleep(0); return html; }
await sleep(1500 * attempt); // short page (throttle/challenge) — back off + retry
} catch (e) {
if (e.statusCode === 429) {
const wait = e.retryAfterMs || Math.min(90000, 4000 * attempt * attempt); // 4s,16s,36s,64s,90s,90s
await sleep(wait);
continue;
}
if (attempt >= 6) throw e;
await sleep(1000 * attempt);
}
}
throw new Error('exhausted retries (throttled)');
}
(async () => {
let where = "product_url IS NOT NULL AND product_url <> ''";
if (NET_NEW) where += " AND COALESCE(on_shopify,false)=false AND (shopify_product_id IS NULL OR shopify_product_id='')";
if (ALL_IMAGES_ONLY) where += " AND (all_images IS NULL OR btrim(all_images)='' OR all_images IN ('[]','null','{}'))";
let sql = `SELECT DISTINCT split_part(product_url,'?',1) AS url FROM ${TABLE} WHERE ${where} ORDER BY 1`;
if (LIMIT > 0) sql += ` LIMIT ${LIMIT}`;
const { rows } = await pool.query(sql);
console.log(`[recrawl-jffabrics] ${rows.length} pages | net_new=${NET_NEW} all_images_only=${ALL_IMAGES_ONLY} dry=${DRY}`);
let pages = 0, skus = 0, withImg = 0, withSpecs = 0, errs = 0;
let idx = 0;
// Single worker — JF rate-limits an IP burst; serial + backoff is the only
// way the full worklist completes instead of mostly 429-erroring.
const concurrency = 1;
async function worker() {
while (idx < rows.length) {
const i = idx++;
const url = rows[i].url;
try {
const html = await fetchPage(url);
const p = extractFromHtml(html, url);
if (p && p.mfr_sku) {
const hasImgs = p.all_images && p.all_images !== '[]';
const hasSpecs = p.fire_rating || p.material || p.width || p.match_type || p.features;
if (DRY) {
skus++; if (hasImgs) withImg++; if (hasSpecs) withSpecs++;
} else {
const id = await upsertProduct(TABLE, p);
if (id) { skus++; if (hasImgs) withImg++; if (hasSpecs) withSpecs++; }
}
}
pages++;
if (pages % 25 === 0) console.log(` ${pages}/${rows.length} | ${skus} skus | ${withImg} w/img | ${withSpecs} w/specs | ${errs} errs`);
await sleep(1100); // polite spacing to stay under the burst quota
} catch (e) {
errs++;
if (errs % 10 === 0) console.error(` err ${url}: ${e.message}`);
await sleep(900);
}
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
console.log(`\nDONE. pages=${pages} upserted=${skus} with_img=${withImg} with_specs=${withSpecs} errors=${errs}${DRY ? ' (DRY-RUN, no writes)' : ''}`);
await pool.end();
})().catch(e => { console.error('FATAL', e); try { pool.end(); } catch (_) {} process.exit(1); });