← back to Wolfgordon Crawl
price-backfill.js
76 lines
#!/usr/bin/env node
// ============================================================================
// Wolf Gordon price backfill.
// WG publishes a per-linear-yard price on each public product page
// ("$31.25 per linear yard"). That price is the COST input to the DW formula:
// price_trade = cost (scraped per-yard price)
// price_retail = round(cost / 0.65 / 0.85, 2) [Steve, 2026-06-19]
// Price is per-PATTERN (identical across colorways), so we fetch one
// representative page per pattern and apply to every colorway of that pattern.
// Local dw_unified only. $0 (public HTTP + local DB).
// ============================================================================
const https = require('https');
const { pool, wait, closePool } = require('./scraper-utils');
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, redirects = 5) {
return new Promise((resolve, reject) => {
https.get(url, { timeout: 25000, headers: { 'User-Agent': UA, 'Accept': 'text/html' } }, (res) => {
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location && redirects > 0) {
let loc = res.headers.location;
if (loc.startsWith('/')) loc = 'https://www.wolfgordon.com' + loc;
return fetchPage(loc, redirects - 1).then(resolve).catch(reject);
}
let d = ''; res.on('data', c => d += c); res.on('end', () => resolve({ html: d, status: res.statusCode }));
}).on('error', reject).on('timeout', function () { this.destroy(); reject(new Error('timeout')); });
});
}
function parseCost(html) {
const m = html.match(/\$\s*([0-9]+(?:\.[0-9]{2})?)\s*per\s*linear\s*yard/i);
return m ? parseFloat(m[1]) : null;
}
const retailFrom = cost => Math.round((cost / 0.65 / 0.85) * 100) / 100;
async function main() {
// one representative product_url per pattern
const { rows } = await pool.query(`
SELECT pattern_name, MIN(product_url) AS url, COUNT(*) AS colorways
FROM wolf_gordon_catalog
WHERE pattern_name IS NOT NULL AND product_url IS NOT NULL
GROUP BY pattern_name ORDER BY pattern_name`);
console.log(`Patterns to price: ${rows.length}`);
let priced = 0, rowsUpdated = 0, noPrice = 0, errors = 0, i = 0;
const concurrency = 4;
async function worker() {
while (i < rows.length) {
const r = rows[i++];
try {
const { html, status } = await fetchPage(r.url);
if (status !== 200) { noPrice++; continue; }
const cost = parseCost(html);
if (!cost) { noPrice++; continue; }
const retail = retailFrom(cost);
const upd = await pool.query(
`UPDATE wolf_gordon_catalog SET price_trade=$1, price_retail=$2, updated_at=NOW()
WHERE pattern_name=$3`, [cost, retail, r.pattern_name]);
priced++; rowsUpdated += upd.rowCount;
if (priced <= 10) console.log(` ${r.pattern_name.padEnd(24)} cost $${cost} -> retail $${retail} (${upd.rowCount} colorways)`);
if (priced % 50 === 0) console.log(` ...${priced}/${rows.length} patterns priced (${rowsUpdated} rows)`);
await wait(250, 150);
} catch (e) { errors++; if (errors <= 5) console.error(` err ${r.pattern_name}: ${e.message}`); await wait(500, 300); }
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
const cov = await pool.query(`SELECT count(*) total, count(*) FILTER(WHERE price_retail IS NOT NULL) withprice FROM wolf_gordon_catalog`);
console.log('\n=== PRICE BACKFILL DONE ===');
console.log(` patterns priced: ${priced} | no-price: ${noPrice} | errors: ${errors}`);
console.log(` catalog rows with price: ${cov.rows[0].withprice}/${cov.rows[0].total}`);
await closePool();
}
main().catch(e => { console.error('FATAL', e); closePool().then(() => process.exit(1)); });