← back to Sister Parish Onboarding
scripts/crawl_pdps.js
107 lines
#!/usr/bin/env node
/**
* Crawl Sister Parish PDPs to extract DW-spec metafields the /products.json
* endpoint doesn't expose: item width, roll length (sold by), repeat (H+V),
* country of origin, paint pairings, plus anything else in the two <dl> blocks.
*
* Writes results to dw_unified.sisterparish_catalog.specs JSONB.
* Rate-limited: ~1 req/sec to be polite.
*/
require('dotenv').config({ path: require('path').join(__dirname,'..','.env') });
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const DELAY_MS = 1100;
const OUT = path.join(__dirname,'..','output','pdp_specs.json');
async function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
function parseSpecs(html) {
const specs = {};
// Both <dl> blocks
const dls = [...html.matchAll(/<dl[^>]*>([\s\S]*?)<\/dl>/g)];
for (const dl of dls) {
const pairs = [...dl[1].matchAll(/<dt[^>]*>([\s\S]*?)<\/dt>\s*<dd[^>]*>([\s\S]*?)<\/dd>/g)];
for (const p of pairs) {
const k = p[1].replace(/<[^>]+>/g,' ').replace(/[:\s]+$/,'').replace(/\s+/g,' ').trim();
const v = p[2].replace(/<[^>]+>/g,' ').replace(/\s+/g,' ').trim();
if (k && v) specs[k] = v;
}
}
// Pattern title's body sometimes mentions a Sister Parish-told story — capture description block too
const descMatch = html.match(/<div[^>]*product__description[^>]*>([\s\S]*?)<\/div>\s*<\/div>/);
if (descMatch) {
const txt = descMatch[1].replace(/<[^>]+>/g,' ').replace(/\s+/g,' ').trim();
if (txt && txt.length > 30) specs.__description_long = txt.slice(0, 2000);
}
return specs;
}
// Normalize loose keys to canonical DW spec keys for downstream metafields
function canonicalize(raw) {
const norm = {};
for (const [k, v] of Object.entries(raw)) {
const lk = k.toLowerCase();
if (lk.includes('item width') || lk === 'width') norm.width = v;
else if (lk.includes('sold by')) norm.sold_by = v;
else if (lk.includes('horizontal repeat')) norm.horizontal_repeat = v;
else if (lk.includes('vertical repeat')) norm.vertical_repeat = v;
else if (lk.includes('repeat') && !norm.repeat) norm.repeat = v;
else if (lk.includes('country of origin') || lk === 'origin') norm.country_of_origin = v;
else if (lk.includes('match')) norm.match = v;
else if (lk.includes('fire')) norm.fire_rating = v;
else if (lk.includes('paste') || lk.includes('hang')) norm.paste = v;
else if (lk.includes('paint pair')) norm.paint_pairings = v;
else if (lk === 'sku') {} // skip; already have
else norm[`extra__${k.replace(/[^a-z0-9]+/gi,'_').toLowerCase()}`] = v;
}
return norm;
}
(async () => {
const data = JSON.parse(fs.readFileSync(path.join(__dirname,'..','output','normalized.json'),'utf8'));
const results = [];
for (let i = 0; i < data.length; i++) {
const p = data[i];
const url = p.url;
const stamp = `[${String(i+1).padStart(3,'0')}/${data.length}]`;
try {
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (DW-onboarding/1.0)' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
const raw = parseSpecs(html);
const canon = canonicalize(raw);
const filled = Object.keys(canon).filter(k => !k.startsWith('extra__') && !k.startsWith('__')).length;
results.push({ sp_product_id: p.sp_product_id, handle: p.handle, raw, canon });
console.log(`${stamp} ✓ ${p.title.slice(0,50).padEnd(50)} keys=${Object.keys(raw).length} canon=${filled}`);
} catch (e) {
results.push({ sp_product_id: p.sp_product_id, handle: p.handle, error: e.message });
console.log(`${stamp} ✗ ${p.title}: ${e.message}`);
}
await sleep(DELAY_MS);
}
fs.writeFileSync(OUT, JSON.stringify(results, null, 2));
console.log(`\nWrote ${results.length} PDP specs to ${OUT}`);
// Push specs into PG
const sql = `
ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS specs JSONB;
ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS specs_raw JSONB;
ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS specs_synced_at TIMESTAMPTZ;
`;
execSync(`psql dw_unified -c ${JSON.stringify(sql)}`, { stdio: 'inherit' });
for (const r of results) {
if (r.error) continue;
const canonStr = JSON.stringify(r.canon).replace(/'/g, "''");
const rawStr = JSON.stringify(r.raw).replace(/'/g, "''");
const stmt = `UPDATE sisterparish_catalog SET specs='${canonStr}'::jsonb, specs_raw='${rawStr}'::jsonb, specs_synced_at=NOW() WHERE sp_product_id=${r.sp_product_id};`;
fs.appendFileSync('/tmp/sp_specs_update.sql', stmt + '\n');
}
execSync(`psql dw_unified -f /tmp/sp_specs_update.sql`, { stdio: 'inherit' });
fs.unlinkSync('/tmp/sp_specs_update.sql');
// Spot-check
execSync(`psql dw_unified -c "SELECT title, specs->>'width' AS width, specs->>'sold_by' AS sold_by, specs->>'horizontal_repeat' AS h_repeat, specs->>'vertical_repeat' AS v_repeat, specs->>'country_of_origin' AS coo FROM sisterparish_catalog WHERE specs IS NOT NULL ORDER BY random() LIMIT 8;"`, { stdio: 'inherit' });
})();