← back to Dw Vendor Scrapers Local
recrawl-hollyhunt.js
206 lines
#!/usr/bin/env node
// ============================================================================
// Holly Hunt FULL-PAGE re-crawl — all_images completeness pass
// ============================================================================
// WHY: scrape-hollyhunt.js is Algolia-feed-based and captures only the single
// grid thumbnail per SKU (image_url). Steve's standing rule "always pull all
// data and images" requires every product carry ALL its images (all angles +
// per-colorway swatches) in all_images before it is import-ready. Holly Hunt
// detail pages (https://www.hollyhunt.com/sku/<pattern>/<color>) carry the
// full set (HERO / Install / DET / colorway shots) as Drupal /dam/files assets,
// served in many /styles/NNN_w/ sizes plus the un-styled full-res original.
//
// This driver crawls each net-new holly_hunt product_url, harvests the distinct
// FULL-RES originals (strips the /styles/NNN_w/public/ resize segment + ?itok
// query), dedupes, and ADDITIVELY writes all_images (JSON array) onto BOTH
// holly_hunt_catalog AND the unified vendor_catalog (the import-readiness gate
// table), keyed by mfr_sku. Nothing is wiped — image_url is only backfilled if
// null. No Shopify publish. Idempotent: re-running only fills rows still empty.
//
// Deploy target: /root/DW-Agents/vendor-scrapers/recrawl-hollyhunt.js
// Run: node recrawl-hollyhunt.js # net-new rows missing all_images
// node recrawl-hollyhunt.js --all # every holly_hunt row missing all_images
// ============================================================================
require('dotenv').config({ path: '/root/DW-Agents/dw-price-stock/.env' });
const https = require('https');
const { pool } = require('/root/DW-Agents/vendor-scrapers/scraper-utils');
const SITE_BASE = 'https://www.hollyhunt.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 ALL = process.argv.includes('--all');
const CONCURRENCY = 3;
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')); });
});
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms + Math.floor(Math.random() * 250)));
// Canonicalize a Drupal image ref to the full-res original:
// /sites/default/files/styles/1200_w/public/dam/files/2021-06/LX1218_..._HERO.jpg?itok=X
// -> https://www.hollyhunt.com/sites/default/files/dam/files/2021-06/LX1218_..._HERO.jpg
function canonicalImage(ref) {
if (!ref) return null;
let u = ref.split('?')[0].replace(/\\\//g, '/');
u = u.replace(/\/styles\/[^/]+\/public\//, '/'); // drop the resize derivative segment
if (!/\.(jpe?g|png|webp)$/i.test(u)) return null;
if (u.startsWith('http')) return u;
if (u.startsWith('/')) return SITE_BASE + u;
return null;
}
const norm = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
// Extract the distinct full-res product images RELEVANT TO THIS product.
// Holly Hunt PDPs also render "you may also like" / cross-pattern recommendation
// thumbnails under the same /dam/files/ path, so a raw harvest pulls in foreign
// products (e.g. a Pampa sectional, sibling patterns). We keep only images whose
// filename references THIS product's pattern name or mfr_sku — which retains the
// HERO / Install / DET shots AND every per-colorway swatch of the pattern (the
// "all angles + per-colorway" the rule wants) while dropping recommendations.
function extractAllImages(html, patternName, mfrSku) {
const all = new Set();
const re = /\/sites\/default\/files\/[^"'\s)]*\/dam\/files\/[^"'\s)]+\.(?:jpe?g|png|webp)/gi;
let m;
while ((m = re.exec(html)) !== null) {
const c = canonicalImage(m[0]);
if (c) all.add(c);
}
const pn = norm(patternName);
const sk = norm(mfrSku);
const fileOf = (u) => norm(u.split('/').pop());
let kept = [...all].filter((u) => {
const f = fileOf(u);
return (pn.length >= 3 && f.includes(pn)) || (sk.length >= 3 && f.includes(sk));
});
// Fallbacks: never strand a row imageless. Pattern-name miss → sku-only; still
// empty → accept the raw set (better a slightly noisy gallery than no images).
if (!kept.length && sk.length >= 3) kept = [...all].filter((u) => fileOf(u).includes(sk));
if (!kept.length) kept = [...all];
// Stable order: HERO → Install → colorway WM → DET; deterministic by sort.
return kept.sort((a, b) => {
const score = (s) => (/_HERO\./i.test(s) ? 0 : /_Install/i.test(s) ? 1 : /_DET\./i.test(s) ? 3 : 2);
const sa = score(a), sb = score(b);
return sa !== sb ? sa - sb : a.localeCompare(b);
});
}
// Light spec harvest (best-effort, additive). Holly Hunt PDPs expose a spec
// table; we grab a few labelled rows if present. Never overwrites non-null.
function extractSpecs(html) {
const specs = {};
const grab = (label) => {
const re = new RegExp(label + '\\s*<\\/[^>]+>\\s*<[^>]+>\\s*([^<]{1,80})', 'i');
const m = html.match(re);
return m ? m[1].trim() : null;
};
for (const [key, label] of [['width', 'Width'], ['repeat_v', 'Repeat'], ['material', 'Content'], ['composition', 'Composition']]) {
const v = grab(label);
if (v) specs[key] = v;
}
return specs;
}
async function loadWorklist() {
// Source from the gate table (vendor_catalog). product_url drives the crawl.
const where = ALL
? `vendor_code='holly_hunt'`
: `vendor_code='holly_hunt' AND on_shopify IS NOT TRUE`;
const { rows } = await pool.query(
`SELECT mfr_sku, pattern_name, split_part(product_url,'?',1) AS url
FROM vendor_catalog
WHERE ${where}
AND (all_images IS NULL OR all_images IN ('','[]','null'))
AND product_url IS NOT NULL AND product_url <> ''
ORDER BY mfr_sku`
);
return rows;
}
async function writeImages(mfrSku, allImages, specs) {
const json = JSON.stringify(allImages);
const first = allImages[0] || null;
// Gate table — additive: set all_images, backfill image_url only if empty.
await pool.query(
`UPDATE vendor_catalog
SET all_images = $1,
image_url = COALESCE(NULLIF(image_url,''), $2),
last_scraped_at = NOW()
WHERE vendor_code='holly_hunt' AND mfr_sku=$3`,
[json, first, mfrSku]
);
// Per-vendor catalog — keep consistent if the row exists.
await pool.query(
`UPDATE holly_hunt_catalog
SET all_images = $1,
image_url = COALESCE(NULLIF(image_url,''), $2),
updated_at = NOW()
WHERE mfr_sku=$3`,
[json, first, mfrSku]
).catch(() => {});
// Best-effort specs backfill (only columns that exist & are currently null).
if (specs && Object.keys(specs).length) {
const sets = [], vals = []; let i = 1;
for (const [k, v] of Object.entries(specs)) {
const col = k === 'repeat_v' ? 'pattern_repeat' : k;
sets.push(`${col} = COALESCE(NULLIF(${col},''), $${i++})`);
vals.push(v);
}
vals.push(mfrSku);
await pool.query(
`UPDATE vendor_catalog SET ${sets.join(', ')} WHERE vendor_code='holly_hunt' AND mfr_sku=$${i}`,
vals
).catch(() => {});
}
}
(async () => {
const rows = await loadWorklist();
console.log(`Holly Hunt full-page re-crawl: ${rows.length} ${ALL ? '' : 'net-new '}rows missing all_images`);
let pages = 0, updated = 0, multiImg = 0, noImg = 0, errs = 0;
let idx = 0;
async function worker() {
while (idx < rows.length) {
const i = idx++;
const { mfr_sku, pattern_name, url } = rows[i];
try {
const html = await httpGet(url);
const imgs = extractAllImages(html, pattern_name, mfr_sku);
if (imgs.length) {
const specs = extractSpecs(html);
await writeImages(mfr_sku, imgs, specs);
updated++;
if (imgs.length > 1) multiImg++;
} else {
noImg++;
}
pages++;
if (pages % 50 === 0) console.log(` ${pages}/${rows.length} pages | ${updated} updated | ${multiImg} multi-img | ${noImg} no-img | ${errs} errs`);
await sleep(350);
} catch (e) {
errs++;
if (errs % 20 === 0) console.error(` err at ${url}: ${e.message}`);
await sleep(900);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
console.log(`\nDONE. pages=${pages} updated=${updated} multi_img=${multiImg} no_img=${noImg} errors=${errs}`);
await pool.end();
})().catch(e => { console.error('FATAL', e); pool.end().catch(() => {}); process.exit(1); });