← back to Vendor Recrawl Dispatcher
lib/generic-url-refresh.js
132 lines
'use strict';
// GENERIC product_url refresher (DTD Option C). For each existing SKU with a
// product_url, fetch the page (free), extract price/specs via the LOCAL enrich-router
// (exo -> Ollama, $0, no paid API), and UPDATE dw_unified staging. NO publish.
//
// Covers any vendor catalog that has a product_url column populated — hundreds of
// vendors — instead of hand-wiring each. Hand-wired <code>-refresh scripts remain the
// fallback for vendors with no product_url / anti-bot walls / JS-rendered pages.
//
// Safe: --dry-run fetches + extracts but writes NOTHING. Live writes only price/spec
// columns that exist on the table, only when extraction is confident.
const https = require('https');
const { pool } = require('./db');
const enrich = require('./enrich-router');
const cfg = require('./config');
function fetchHtml(url, timeoutMs = 15000, hops = 0) {
return new Promise((resolve) => {
if (hops > 4) return resolve({ ok: false, status: 'too-many-redirects' });
let done = false;
const finish = (v) => { if (done) return; done = true; try { req.destroy(); } catch {} resolve(v); };
// GUARANTEED hard deadline — one tarpitting/anti-bot URL can never stall the batch.
const hard = setTimeout(() => finish({ ok: false, status: 'timeout' }), timeoutMs);
let req;
try {
req = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh) DW-recrawl/1.0' } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume(); clearTimeout(hard); if (done) return; done = true;
return resolve(fetchHtml(new URL(res.headers.location, url).href, timeoutMs, hops + 1));
}
if (res.statusCode !== 200) { res.resume(); clearTimeout(hard); return finish({ ok: false, status: res.statusCode }); }
let body = ''; res.setEncoding('utf8');
res.on('data', d => { body += d; if (body.length > 800_000) { res.destroy(); } });
res.on('end', () => { clearTimeout(hard); finish({ ok: true, html: body }); });
});
req.on('error', () => { clearTimeout(hard); finish({ ok: false, status: 'error' }); });
} catch { clearTimeout(hard); finish({ ok: false, status: 'bad-url' }); }
});
}
// Strip HTML to visible-ish text, trimmed to a model-friendly size.
function htmlToText(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/ /g, ' ').replace(/&/g, '&')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 6000);
}
// Ask the LOCAL model to extract structured fields from the page text. Returns object or null.
async function extractFields(vendorName, sku, text) {
const prompt =
`You are extracting wallcovering product data from a vendor product page.\n` +
`Vendor: ${vendorName}. SKU: ${sku}.\n` +
`From the PAGE TEXT below, return ONLY a compact JSON object with any of these keys you can find ` +
`(omit keys you can't find, no prose): {"price": number, "width_in": number, "repeat_in": number, ` +
`"material": string, "pattern_name": string, "discontinued": boolean}.\n\nPAGE TEXT:\n${text}`;
const res = await enrich.chat([{ role: 'user', content: prompt }]);
if (!res || res.skipped || !res.text) return null;
const m = res.text.match(/\{[\s\S]*\}/);
if (!m) return null;
try { return { provider: res.provider, fields: JSON.parse(m[0]) }; } catch { return null; }
}
// Which price/spec columns actually exist on this table (only update real columns).
async function tableColumns(table) {
const { rows } = await pool.query(
`SELECT column_name FROM information_schema.columns WHERE table_name = $1`, [table]);
return new Set(rows.map(r => r.column_name));
}
// Refresh up to `limit` stale SKUs (with product_url) for one vendor catalog.
// opts: { table, vendorName, skuCol, limit, dryRun }
async function refreshVendor({ table, vendorName, skuCol = 'mfr_sku', limit = 25, dryRun = true }) {
const cols = await tableColumns(table);
if (!cols.has('product_url')) return { table, skipped: 'no product_url column' };
const orderStale = cols.has('updated_at') ? 'ORDER BY updated_at ASC NULLS FIRST' : '';
const { rows } = await pool.query(
`SELECT ${skuCol} AS sku, product_url FROM ${table}
WHERE product_url IS NOT NULL AND product_url <> '' ${orderStale} LIMIT $1`, [limit]);
let fetched = 0, extracted = 0, updated = 0, failed = 0;
const sample = [];
for (const r of rows) {
const page = await fetchHtml(r.product_url);
if (!page.ok) { failed++; continue; }
fetched++;
const ex = await extractFields(vendorName, r.sku, htmlToText(page.html));
if (!ex || !ex.fields || !Object.keys(ex.fields).length) { continue; }
extracted++;
// build an UPDATE over only real, price/spec columns the model returned
const set = [], vals = [];
const map = { price: 'price', width_in: 'width_in', repeat_in: 'repeat_in',
material: 'material', pattern_name: 'pattern_name', discontinued: 'discontinued' };
for (const [k, col] of Object.entries(map)) {
if (ex.fields[k] != null && cols.has(col)) { vals.push(ex.fields[k]); set.push(`${col} = $${vals.length}`); }
}
if (!set.length) continue;
if (sample.length < 5) sample.push({ sku: r.sku, via: ex.provider, fields: ex.fields });
if (!dryRun) {
if (cols.has('updated_at')) set.push(`updated_at = now()`);
vals.push(r.sku);
await pool.query(`UPDATE ${table} SET ${set.join(', ')} WHERE ${skuCol} = $${vals.length}`, vals);
updated++;
}
await new Promise(s => setTimeout(s, 1200 + Math.floor((Date.now() % 800)))); // politeness
}
return { table, vendorName, rows: rows.length, fetched, extracted, updated, failed, dryRun, sample };
}
module.exports = { refreshVendor, fetchHtml, htmlToText, extractFields };
// CLI: node lib/generic-url-refresh.js <table> <vendorName> [limit] [--run]
// default = DRY-RUN (writes nothing). --run performs live UPDATEs to local staging (no publish).
if (require.main === module) {
(async () => {
const argv = process.argv.slice(2);
const flags = argv.filter(a => a.startsWith('--'));
const pos = argv.filter(a => !a.startsWith('--'));
const [table = 'thibaut_catalog', vendorName = 'Thibaut', limit = '3'] = pos;
const dryRun = !flags.includes('--run');
const backend = await enrich.resolveProvider();
console.log(` local backend: ${backend} | table=${table} | ${dryRun ? 'DRY-RUN (writes nothing)' : 'LIVE (updates local staging, no publish)'} | $0 local`);
const r = await refreshVendor({ table, vendorName, limit: parseInt(limit, 10), dryRun });
console.log(JSON.stringify(r, null, 2));
await pool.end().catch(() => {});
})();
}