← back to Greenland Onboard
scripts/scrape-full.mjs
117 lines
#!/usr/bin/env node
// Authoritative full-catalog scrape (feed-first, $0). Two passes:
// (1) Build joinKey -> material map from the "Wallcovering > Category" branch leaves
// (the real material taxonomy) via the per-material /product/page listing. The
// trailing [A-Z]{2}\d+ token (TF3492, NQ8248, DP1172, LA3001...) is the physical
// colorway key that is stable across a collection's G0### code and a material
// series' S### code.
// (2) Walk detail /product/<id> for the whole catalog, explode skus[] colorways,
// classify each by joinKey->material (fallbacks: detail wallClass, then unknown),
// dedupe colorways by joinKey to unique physical SKUs.
// Emits data/full-catalog-skus.ndjson (one line per unique colorway) + prints
// per-material unique counts. No pricing, no names yet — pure inventory truth.
import { writeFileSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const API = 'https://api.greenlandwallcoverings.com/api';
const KEY_RE = /([A-Z]{2}\d+)\s*$/i;
const joinKey = (code) => { const m = (code || '').match(KEY_RE); return m ? m[1].toUpperCase() : null; };
// ---- category tree -> leaves with full path
const tree = (await (await fetch(`${API}/category/index`)).json()).data;
const leaves = [];
(function walk(ns, path) { for (const n of ns) { const p = [...path, n.label]; if (n.children && n.children.length) walk(n.children, p); else leaves.push({ id: n.id, label: n.label, path: p }); } })(tree, []);
// real material leaves = under "Wallcovering > Category ..." (+ Engineering wallpaper for XPE)
const matLeaves = leaves.filter(l => (l.path[0] === 'Wallcovering' && l.path[1] === 'Category') || l.path[0] === 'Engineering wallpaper');
// ---- pass 1: joinKey -> material
const key2mat = new Map();
async function pageAll(catId) {
const rows = [];
for (let pg = 1; pg <= 30; pg++) {
const j = await (await fetch(`${API}/product/page?categoryId=1&subCategoryIds=${catId}&pageSize=200&pageNum=${pg}`)).json();
const list = j?.data?.list || [];
rows.push(...list);
if (rows.length >= (j?.data?.total || 0) || list.length === 0) break;
}
return rows;
}
for (const lf of matLeaves) {
const rows = await pageAll(lf.id);
for (const r of rows) {
const k = joinKey(r.code);
if (k && !key2mat.has(k)) key2mat.set(k, lf.label);
}
}
console.log(`pass1: ${key2mat.size} colorway keys classified across ${matLeaves.length} material leaves`);
// ---- pass 2: walk detail, explode, dedupe by joinKey
// multi-signal material classifier (subcat VALUES + install-manual filename + name + code)
const MAT_KW = [
[/paper\s*weave/i, 'Paper Weave'], [/water\s*hyacinth/i, 'Water hyacinth'], [/cotton/i, 'Cotton Yarn'],
[/arrowroot/i, 'Arrowroot'], [/grass\s*cloth|grasscloth|\bgrass\b/i, 'Grass'], [/jacquard/i, 'Jacquard'],
[/\bsilk\b/i, 'Silk'], [/suede/i, 'Suede'], [/velvet/i, 'Velvet'], [/raffia/i, 'Raffia'], [/hemp/i, 'Hemp'],
[/\bcork\b/i, 'Cork'], [/sisal/i, 'Sisal'], [/abaca/i, 'Abaca'], [/linen/i, 'Linen'], [/\bjute\b/i, 'Jute'],
[/\bwool\b/i, 'Wool'], [/wood\s*bark|\bwood\b/i, 'Wood'], [/mica/i, 'Mica'],
[/\bxpe\b/i, 'XPE Functional Wallcoverings'], [/pe\s*weav|pe\s*woven/i, 'PE Weaving / PE Woven Material'],
[/vinyl/i, 'Vinyl'],
];
const kw = (s) => { for (const [re, m] of MAT_KW) if (re.test(s || '')) return m; return null; };
const classifyDetail = (d) => {
for (const s of (d.subCategories || [])) { const m = kw(s.category_name); if (m) return m; }
const man = kw((d.installationManual || '').replace(/^.*downloadFile\?fileName=/, ''));
if (man) return man;
return kw(d.name) || (/^S\d/.test(d.code) ? 'Silk' : /^V\d/.test(d.code) ? 'Vinyl' : /^P\d/.test(d.code) ? 'Paper Weave' : null);
};
async function fetchId(id) {
try { const r = await fetch(`${API}/product/${id}`, { signal: AbortSignal.timeout(20000) }); if (!r.ok) return null; const j = await r.json(); return (j.code === 0 && j.data && j.data.id) ? j.data : null; } catch { return null; }
}
const uniq = new Map(); // joinKey -> record
let id = 1, HI = 340; const CONC = 8;
async function worker() {
while (id <= HI) {
const d = await fetchId(id++);
if (!d) continue;
const detailMat = classifyDetail(d);
const specs = {
use: (d.use || '').trim(), width: (d.width || '').trim(), minimumOrder: (d.minimumOrder || '').trim(),
repeat: (d.repeat || '').trim(), fireRating: (d.fireRating || '').trim(), backing: (d.backing || '').trim(),
weight: (d.weight || '').toString().trim(),
installUrl: d.installationManual || '', catalogUrl: d.catalog || '',
};
for (const s of (d.skus || [])) {
const k = joinKey(s.model);
if (!k) continue;
// priority: category-branch map (authoritative) > detail multi-signal > Unknown
const material = key2mat.get(k) || detailMat || 'Unknown';
if (!uniq.has(k)) {
uniq.set(k, {
joinKey: k, mfrModel: (s.model || '').trim(), color: (s.colorDescription || '').trim(),
material, collectionId: d.id, collectionCode: d.code, collectionName: (d.name || '').trim(),
thumb: s.thumbnail, coverImg: d.coverImg, seenIn: [d.id], ...specs,
});
} else { uniq.get(k).seenIn.push(d.id); }
}
}
}
await Promise.all(Array.from({ length: CONC }, worker));
const rows = [...uniq.values()];
writeFileSync(join(ROOT, 'data', 'full-catalog-skus.ndjson'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
// per-material counts
const prefixMap = JSON.parse(readFileSync(join(ROOT, 'data', 'material-prefix-map.json'), 'utf8'));
const byMat = {};
for (const r of rows) { byMat[r.material] = (byMat[r.material] || 0) + 1; }
console.log(`\npass2: ${rows.length} UNIQUE physical colorways (deduped by joinKey)\n`);
console.log('MATERIAL PREFIX UNIQUE SKUS');
console.log('------------------------------------- ------- -----------');
for (const [m, c] of Object.entries(byMat).sort((a, b) => b[1] - a[1])) {
const px = prefixMap[m] || '???';
console.log(`${m.padEnd(37)} ${px.padEnd(7)} ${String(c).padStart(11)}`);
}
console.log(`\nwrote data/full-catalog-skus.ndjson (${rows.length} rows)`);