← back to Dw Vendor Scrapers Local
recrawl-versa_ds.js
243 lines
#!/usr/bin/env node
// ============================================================================
// Versa Designed Surfaces ADDITIVE all_images re-crawl — full-page pass
// ============================================================================
// WHY: the bulk versa_ds catalog (1,745 rows in versa_catalog / vendor_catalog
// vendor_code='versa_ds') was listing-scraped and captured only a single
// /Thumbnails/ hero per SKU — NO gallery, NO per-colorway swatches, NO specs.
// 1,619 rows have empty all_images. Many color_name values are a HEX string
// (e.g. "#d1cfcb") instead of the real colour, which is actually encoded in the
// thumbnail filename (…-038-Earl_Grey.jpg → "Earl Grey"). Steve's standing rule
// "always pull all data and images" requires every product to carry its FULL
// image set + real colour name + specs before it is import-ready.
//
// PRIVATE LABEL: versa_ds is the DW private label "DW Commercial Surfaces"
// (NOT Hollywood). The source brand "Versa" / "Versa Designed Surfaces" must be
// scrubbed from every customer-facing field at PUSH time via the PL scrub
// (hollywood-import/scrub-momentum.js leak-check passes 0 on versa, but its
// vendor default of "Hollywood Wallcoverings" is WRONG for versa — see the
// pending-approval note before any Shopify push).
//
// SAFETY / ADDITIVE-ONLY:
// * NEVER inserts new rows. Only UPDATEs all_images / specs / color_name on
// rows that already exist (matched by mfr_sku carried out of the DB).
// * Only touches rows whose all_images is empty.
// * Does NOT push to Shopify, does NOT publish, does NOT change SKUs.
//
// TWO image sources, additive:
// 1. PAGE CRAWL (Playwright) of the product detail page when product_url is
// present — yields the full gallery + sibling colorways + specs. Versa is
// a JS-rendered TYPO3 site, hence Playwright (matches the proven
// DW-Programming/versa-evo-pvcfree-scraper.js approach).
// 2. URL-DERIVED FALLBACK (zero-risk, no crawl) from the existing thumbnail
// image_url: the full-size image (strip /Thumbnails/) + the REAL colour
// name parsed from the filename. Applied to EVERY eligible row so even
// product_url-less rows get a real colour name + full-res hero.
//
// MODES:
// --canary N process only the first N rows (default 5) — run FIRST to
// confirm the gallery selector matches live HTML.
// --all process every eligible row (full ~1,619-row pass).
// --net-new restrict to rows whose mfr_sku is NOT already in
// shopify_products (skip the 827 already-live cohort; ~792 rows).
// --no-crawl URL-derive only (color_name + full-size hero), skip Playwright.
//
// Mirrors the harness of recrawl-coordonne.js / recrawl-osborne.js.
// Deploy target: Kamatera /root/DW-Agents/vendor-scrapers/. Authored from the
// local mirror; FIRST run on Kamatera MUST be --canary to validate selectors.
// ============================================================================
require('dotenv').config({ path: '/root/DW-Agents/dw-price-stock/.env' });
const { pool } = require('/root/DW-Agents/vendor-scrapers/scraper-utils');
const TABLE = 'versa_catalog';
const SITE_BASE = 'https://www.versadesignedsurfaces.com';
const argv = process.argv.slice(2);
const FULL = argv.includes('--all');
const NET_NEW_ONLY = argv.includes('--net-new');
const NO_CRAWL = argv.includes('--no-crawl');
const canaryIdx = argv.indexOf('--canary');
const CANARY_N = canaryIdx >= 0 ? (parseInt(argv[canaryIdx + 1], 10) || 5) : (FULL ? 0 : 5);
const sleep = (ms) => new Promise(r => setTimeout(r, ms + Math.floor(Math.random() * 250)));
// Junk filters — never store logos, icons, sprites, payment badges as images.
const JUNK = /(logo|icon|favicon|placeholder|sprite|payment|visa|mastercard|paypal|flag|avatar|loader|spinner)/i;
// Versa product imagery lives under /fileadmin/products/. Accept those + TYPO3
// processed images (csm_ / _processed_). Reject everything else.
function isProductImg(u) {
if (!u) return false;
if (!/\.(jpe?g|png|webp)(\?.*)?$/i.test(u)) return false;
if (JUNK.test(u)) return false;
return /\/fileadmin\/|csm_|_processed_/i.test(u);
}
function absUrl(u) {
if (!u) return '';
u = u.trim().replace(/&/g, '&');
if (/^data:/i.test(u)) return ''; // inline base64 placeholder, not a real image
if (u.startsWith('//')) return 'https:' + u;
if (/^https?:\/\//i.test(u)) return u;
return SITE_BASE + (u.startsWith('/') ? '' : '/') + u;
}
// Turn a /Thumbnails/ thumbnail URL into its full-size sibling (TYPO3 keeps the
// full image in the parent product dir, same filename without /Thumbnails/).
function fullSizeFromThumb(thumbUrl) {
if (!thumbUrl) return '';
return thumbUrl.replace(/\/Thumbnails\//i, '/');
}
// The REAL colour name is the trailing token of the thumbnail filename:
// Brio_A119-038-Earl_Grey.jpg -> "Earl Grey"
// Bellagio_A122-505-Barley.jpg -> "Barley"
// Parse it off the filename when color_name is missing or a hex string.
function colorFromImageUrl(imageUrl, mfrSku) {
if (!imageUrl) return '';
if (/^data:/i.test(imageUrl)) return ''; // base64 placeholder — no parseable filename (AVF/Evo rows)
const file = imageUrl.split('/').pop().replace(/\.(jpe?g|png|webp)$/i, '');
// Strip the leading <Pattern>_<family>-<num> and keep the trailing colour.
// The SKU (e.g. A119-038) appears in the filename; split on it.
const skuTail = (mfrSku || '').split('-').pop(); // "038"
let tail = '';
if (skuTail && file.includes('-' + skuTail + '-')) {
tail = file.split('-' + skuTail + '-').pop();
} else {
// fallback: take everything after the last numeric-only segment
const parts = file.split('-');
const lastNumIdx = parts.map(p => /^\d+$/.test(p)).lastIndexOf(true);
tail = lastNumIdx >= 0 ? parts.slice(lastNumIdx + 1).join('-') : parts.slice(-1)[0];
}
const color = tail.replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
// Sanity: a real colour name has letters, no '=' (base64), 3..40 chars.
if (!/[a-z]/i.test(color) || /=/.test(color) || color.length < 3 || color.length > 40) return '';
return color;
}
// Crawl one product detail page with Playwright → { images[], specs{} }.
async function crawlPage(page, url) {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500); // TYPO3 JS render
return await page.evaluate(() => {
const out = { images: [], specs: {} };
document.querySelectorAll('img').forEach(img => {
const s = img.getAttribute('src') || img.getAttribute('data-src') || '';
if (s) out.images.push(s);
});
document.querySelectorAll('a[href]').forEach(a => {
const h = a.getAttribute('href') || '';
if (/\.(jpe?g|png|webp)$/i.test(h)) out.images.push(h);
});
// specs: any <dl>/<table>/label:value rows on the product page
document.querySelectorAll('table tr, dl').forEach(() => {});
const rows = document.querySelectorAll('table tr');
rows.forEach(tr => {
const cells = tr.querySelectorAll('th,td');
if (cells.length === 2) {
const k = cells[0].textContent.trim().replace(/\s+/g, ' ');
const v = cells[1].textContent.trim().replace(/\s+/g, ' ');
if (k && v && k.length < 60) out.specs[k] = v;
}
});
return out;
});
}
(async () => {
// Eligible rows: missing all_images.
let sql = `
SELECT mfr_sku,
split_part(product_url,'?',1) AS url,
image_url,
color_name
FROM ${TABLE}
WHERE (all_images IS NULL OR all_images = '' OR all_images = '[]' OR all_images = '""')
`;
if (NET_NEW_ONLY) {
sql += ` AND NOT EXISTS (SELECT 1 FROM shopify_products s WHERE upper(s.mfr_sku)=upper(${TABLE}.mfr_sku))`;
}
sql += ` ORDER BY mfr_sku`;
if (CANARY_N > 0) sql += ` LIMIT ${CANARY_N}`;
// Ensure target columns exist (idempotent — no-op if already present).
await pool.query(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS all_images TEXT`);
await pool.query(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS specs JSONB`);
const { rows } = await pool.query(sql);
console.log(`Versa additive all_images re-crawl — ${rows.length} rows${CANARY_N > 0 ? ` (CANARY/limit ${CANARY_N})` : ' (FULL)'}${NET_NEW_ONLY ? ' [net-new only]' : ''}${NO_CRAWL ? ' [url-derive only]' : ''}`);
// Lazy Playwright launch (only if we're going to crawl).
let browser = null, page = null;
if (!NO_CRAWL) {
const { chromium } = require('playwright');
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9' });
}
let n = 0, updated = 0, withImgs = 0, withSpecs = 0, colorFixed = 0, crawlMiss = 0, errs = 0;
for (const r of rows) {
n++;
try {
// (2) URL-derived baseline — zero-risk, always available.
const heroFull = fullSizeFromThumb(absUrl(r.image_url));
const derivedColor = colorFromImageUrl(r.image_url, r.mfr_sku);
const isHexColor = r.color_name && /^#?[0-9a-f]{3,8}$/i.test(String(r.color_name).trim());
const newColor = (derivedColor && (!r.color_name || isHexColor)) ? derivedColor : null;
let images = [];
if (heroFull) images.push(heroFull);
if (r.image_url) images.push(absUrl(r.image_url)); // keep thumbnail too
let specs = {};
// (1) Page crawl when we have a real product_url.
if (!NO_CRAWL && r.url && /versadesignedsurfaces\.com/i.test(r.url)) {
try {
const pageData = await crawlPage(page, r.url);
for (const raw of pageData.images) {
const u = absUrl(raw);
if (isProductImg(u) && !images.includes(u)) images.push(u);
}
specs = pageData.specs || {};
await sleep(600);
} catch (e) {
crawlMiss++;
if (CANARY_N > 0) console.error(` crawl-miss ${r.mfr_sku}: ${e.message}`);
}
}
// Dedupe + keep only real product images.
images = [...new Set(images.filter(isProductImg))];
if (!images.length) { if (CANARY_N > 0) console.log(` ${r.mfr_sku}: no images`); continue; }
await pool.query(
`UPDATE ${TABLE}
SET all_images = $1,
image_url = COALESCE(NULLIF(image_url,''), $2),
color_name = COALESCE($3, color_name),
specs = COALESCE(specs, '{}'::jsonb) || $4::jsonb
WHERE mfr_sku = $5`,
[JSON.stringify(images), images[0], newColor, JSON.stringify(specs), r.mfr_sku]
);
updated++; withImgs++;
if (Object.keys(specs).length) withSpecs++;
if (newColor) colorFixed++;
if (CANARY_N > 0) {
console.log(` ${r.mfr_sku}: ${images.length} imgs${newColor ? `, color "${newColor}"` : ''}, ${Object.keys(specs).length} specs`);
console.log(` ${images.slice(0, 3).join('\n ')}${images.length > 3 ? '\n …' : ''}`);
} else if (n % 50 === 0) {
console.log(` ${n}/${rows.length} | updated ${updated} | w/imgs ${withImgs} | w/specs ${withSpecs} | colorFixed ${colorFixed} | crawlMiss ${crawlMiss} | errs ${errs}`);
}
} catch (e) {
errs++;
if (errs % 10 === 0 || CANARY_N > 0) console.error(` err ${r.mfr_sku}: ${e.message}`);
await sleep(500);
}
}
if (browser) await browser.close();
console.log(`\nDONE. rows=${n} updated=${updated} with_imgs=${withImgs} with_specs=${withSpecs} color_fixed=${colorFixed} crawl_miss=${crawlMiss} errors=${errs}`);
await pool.end();
})().catch(e => { console.error('FATAL', e); pool.end().catch(() => {}); process.exit(1); });