← back to Greenland Onboard
scripts/expand-discover.mjs
143 lines
#!/usr/bin/env node
// EXPAND DISCOVERY: scrape BOTH cork leaves (40 Handmade Cork + 292 Top100 Cork&Wood),
// explode detail.skus[] colorways, capture wallClass, FILTER to Cork only (exclude Wood),
// dedup by mfr_sku, then DIFF against greenland_catalog. $0 open API. NO DB WRITE.
// Emits data/expand-new.ndjson (cork rows NOT yet in DB) + data/expand-live-all.ndjson.
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 = process.env.GL_API || 'https://api.greenlandwallcoverings.com/api';
const LEAVES = [{ cat: 1, sub: 40 }, { cat: 1, sub: 292 }];
const PAGE_SIZE = 200;
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function j(url, tries = 4) {
for (let i = 0; i < tries; i++) {
try {
const r = await fetch(url, { headers: { accept: 'application/json' } });
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
} catch (e) { if (i === tries - 1) throw e; await sleep(600 * (i + 1)); }
}
}
const SKU_RE = /^[A-Z]{1,4}\d{2,}[A-Z0-9-]*$/;
function splitNameCode(name, code) {
const n = (name || '').trim(), c = (code || '').trim();
const nSku = SKU_RE.test(n.replace(/\s+/g, '')), cSku = SKU_RE.test(c.replace(/\s+/g, ''));
if (nSku && !cSku) return { sku: n, label: c };
if (cSku && !nSku) return { sku: c, label: n };
return { sku: n || c, label: (n && c && n !== c) ? c : '' };
}
function widthInches(w) {
if (!w) return null;
let m = w.match(/([\d.]+)\s*(?:''|"|in\b|inch)/i); if (m) return parseFloat(m[1]);
m = w.match(/([\d.]+)\s*cm/i); if (m) return +(parseFloat(m[1]) / 2.54).toFixed(2);
return null;
}
const norm = s => (s || '').trim().toUpperCase().replace(/\s+/g, '');
async function collectLeaf(cat, sub) {
const first = await j(`${API}/product/page?categoryId=${cat}&subCategoryIds=${sub}&page=1&pageSize=${PAGE_SIZE}`);
const total = first.data.total; let all = [...(first.data.list || [])];
for (let p = 2; all.length < total; p++) {
const d = await j(`${API}/product/page?categoryId=${cat}&subCategoryIds=${sub}&page=${p}&pageSize=${PAGE_SIZE}`);
const l = d.data.list || []; if (!l.length) break; all.push(...l);
}
console.log(` leaf ${sub}: ${all.length} groups (total=${total})`);
return all;
}
async function main() {
const existRaw = readFileSync('/tmp/gl_existing_skus.txt', 'utf8').split('\n').map(x => x.trim()).filter(Boolean);
const existNorm = new Set(existRaw.map(norm));
console.log(`existing DB mfr_skus: ${existRaw.length} (${existNorm.size} normalized)`);
// gather groups across both leaves, dedup by group ID
const groupsById = new Map();
for (const { cat, sub } of LEAVES) {
const gs = await collectLeaf(cat, sub);
for (const g of gs) if (!groupsById.has(g.ID)) groupsById.set(g.ID, g);
}
const all = [...groupsById.values()];
console.log(`unique groups across cork leaves: ${all.length}`);
const rows = []; const seen = new Set();
let parents = 0, exploded = 0, woodSkipped = 0;
for (let i = 0; i < all.length; i++) {
const li = all[i];
let det = {};
try { det = (await j(`${API}/product/${li.ID}`)).data || {}; }
catch (e) { console.warn(` ! detail ${li.ID} failed: ${e.message}`); }
const subs = (det.subCategories || []).filter(s => s.category_name && s.category_name !== '是');
const wallClass = subs.find(s => s.category_type === 'wallClass')?.category_name || null;
// CORK-ONLY GATE: exclude Wood (and anything not Cork) — leaf 292 mixes Cork & Wood
if (wallClass && wallClass.toLowerCase() !== 'cork') { woodSkipped++; continue; }
const { sku: parentSku, label: parentLabel } = splitNameCode(det.name ?? li.name, det.code ?? li.code);
const patternName = parentLabel || parentSku;
const collection = subs.find(s => s.category_type === 'wallType')?.category_name || null;
const subcats = subs.map(s => s.category_name).join(', ') || null;
const compName = (det.compositions || []).map(c => c.compositionName).filter(Boolean).join(', ');
const compPct = (det.compositions?.[0]?.percentage) || null;
const material = compName || 'Cork';
const composition = compName ? [compName, compPct && `${compPct}%`].filter(Boolean).join(' ')
: (compPct ? `Cork ${compPct}%` : 'Cork');
const shared = {
collection, category_name: det.categoryName || li.categoryName || 'Wallcovering',
subcategory_names: subcats, product_type: 'Wallcovering', use: det.use || null,
width: det.width || null, width_inches: widthInches(det.width),
minimum_order: det.minimumOrder || null, pattern_repeat: det.repeat || null,
fire_rating_us: det.fireRating || null, backing: det.backing || null,
weight: det.weight != null ? String(det.weight) : null,
material, composition,
is_publish: det.isPublish ?? li.isPublish ?? 0,
specs: { width: det.width, minimumOrder: det.minimumOrder, fireRating: det.fireRating, backing: det.backing, weight: det.weight, use: det.use, unitPriceCode: det.unitPrice, wallClass },
};
const push = (r) => { if (r.mfr_sku && !seen.has(norm(r.mfr_sku))) { seen.add(norm(r.mfr_sku)); rows.push(r); } };
const kids = Array.isArray(det.skus) ? det.skus.filter(k => k && k.model) : [];
if (kids.length) {
exploded++;
for (const k of kids) {
const img = k.thumbnail || det.coverImg || li.coverImg || null;
push({
gl_id: 10000000 + (k.id || 0), mfr_sku: k.model, pattern_name: patternName,
color_name: k.colorDescription || null, ...shared, image_url: img,
all_images: [img].filter(Boolean).join('|') || null,
product_url: `https://m.greenlandwallcoverings.com/#/pages/detail/index?id=${li.ID}`,
raw: { parent_id: li.ID, parent_sku: parentSku, sku: k },
});
}
} else {
parents++;
push({
gl_id: li.ID, mfr_sku: parentSku, pattern_name: patternName, color_name: null, ...shared,
image_url: det.coverImg || li.coverImg || null,
all_images: [det.coverImg || li.coverImg, det.rotograph].filter(Boolean).join('|') || null,
product_url: `https://m.greenlandwallcoverings.com/#/pages/detail/index?id=${li.ID}`,
raw: det && det.id ? det : li,
});
}
if ((i + 1) % 25 === 0) console.log(` …${i + 1}/${all.length} groups (${rows.length} cork SKUs, ${woodSkipped} wood-skipped)`);
await sleep(100);
}
console.log(`cork scrape: ${parents} leaf + ${exploded} groups exploded → ${rows.length} cork colorways (${woodSkipped} non-cork groups skipped)`);
const newRows = rows.filter(r => !existNorm.has(norm(r.mfr_sku)));
console.log(`\n>>> NEW cork (not in DB): ${newRows.length}`);
console.log(`>>> already in DB : ${rows.length - newRows.length}`);
const liveNorm = new Set(rows.map(r => norm(r.mfr_sku)));
const goneFromLive = existRaw.filter(s => !liveNorm.has(norm(s)));
console.log(`>>> in DB but NOT in live cork scrape: ${goneFromLive.length} (kept, not deleted)`);
if (goneFromLive.length) console.log(' ', goneFromLive.slice(0, 25).join(', '), goneFromLive.length > 25 ? '…' : '');
writeFileSync(join(ROOT, 'data', 'expand-new.ndjson'), newRows.map(r => JSON.stringify(r)).join('\n') + (newRows.length ? '\n' : ''));
writeFileSync(join(ROOT, 'data', 'expand-live-all.ndjson'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
if (newRows.length) {
console.log('\nNEW sample:');
for (const r of newRows.slice(0, 20)) console.log(` ${r.mfr_sku} | ${r.pattern_name} | color=${r.color_name || '—'} | ${r.collection || '—'} | wc=${r.specs.wallClass}`);
}
console.log(`\nwrote data/expand-new.ndjson (${newRows.length}) + data/expand-live-all.ndjson (${rows.length})`);
}
main().catch(e => { console.error(e); process.exit(1); });