← back to Greenland China
scripts/scrape.js
178 lines
#!/usr/bin/env node
/**
* Greenland Wallcoverings feed-first scraper (LOCAL ONLY, read-only).
*
* Discovered API (Vue/uni-app SPA backend, no anti-bot on the API itself):
* BASE = https://api.greenlandwallcoverings.com/api
* GET /category/0 -> full category tree
* GET /product/page?page&pageSize&categoryId&subCategoryIds&colors -> paginated list {list,total,totalPage}
* GET /product/{id} -> full detail (width, repeat, fireRating, backing, weight,
* compositions, subCategories, skus[colorways], rotograph[banners])
* images: /downloadFile?fileName=... -> JPEG bytes
*
* Enumerates ALL products via /product/page, then enriches each with /product/{id}.
* Captures FULL page data + ALL images per Steve's hard rule.
* Writes one product/line to ../staging/greenland.jsonl
*/
const fs = require('fs');
const path = require('path');
const BASE = 'https://api.greenlandwallcoverings.com/api';
const OUT = path.join(__dirname, '..', 'staging', 'greenland.jsonl');
const CATS_OUT = path.join(__dirname, '..', 'staging', 'greenland-categories.json');
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getJson(url, tries = 4) {
for (let i = 0; i < tries; i++) {
try {
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), 30000);
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: ctrl.signal });
clearTimeout(to);
if (!res.ok) throw new Error('HTTP ' + res.status);
const j = await res.json();
return j;
} catch (e) {
if (i === tries - 1) throw e;
await sleep(800 * (i + 1));
}
}
}
const imgUrl = (fileName) => fileName ? `${BASE}/downloadFile?fileName=${encodeURIComponent(fileName)}` : null;
// downloadFile URLs come back already-formed in coverImg; raw filenames appear inside skus/rotograph
const asImage = (v) => {
if (!v) return null;
if (typeof v === 'string') return v.startsWith('http') ? v : imgUrl(v);
return null;
};
async function main() {
// 1. category tree (for labels + the "type" top-level buckets)
const cats = await getJson(`${BASE}/category/0`);
fs.writeFileSync(CATS_OUT, JSON.stringify(cats.data, null, 2));
const topCats = (cats.data || []).map((c) => ({ id: c.id, label: c.label }));
console.log('Top categories:', topCats.map((c) => `${c.id}:${c.label}`).join(', '));
// 2. enumerate the FULL list (categoryId=0 / omitted returns all)
const pageSize = 100;
let page = 1;
const listRows = [];
while (true) {
const j = await getJson(`${BASE}/product/page?page=${page}&pageSize=${pageSize}&categoryId=0`);
const d = j.data || {};
const list = d.list || [];
listRows.push(...list);
process.stdout.write(`\rlist page ${page}/${d.totalPage} (${listRows.length}/${d.total}) `);
if (page >= (d.totalPage || 1) || list.length === 0) break;
page++;
await sleep(150);
}
console.log(`\nlisted ${listRows.length} products. enriching with detail...`);
// 3. enrich each with /product/{id} (full specs + all images + colorways)
const out = fs.createWriteStream(OUT);
const catCount = {};
let done = 0;
for (const lr of listRows) {
const id = lr.ID;
let detail = null;
try {
const dj = await getJson(`${BASE}/product/${id}`);
detail = dj.data;
} catch (e) {
console.error(`\n detail fail id=${id}: ${e.message}`);
}
const d = detail || {};
// collect ALL images: cover + rotograph banners + per-sku colorway images
const images = [];
const cover = lr.coverImg || d.coverImg || null;
if (cover) images.push(cover);
for (const r of (Array.isArray(d.rotograph) ? d.rotograph : [])) {
const u = asImage(r.img || r.image || r.url || r.fileName || r);
if (u) images.push(u);
}
// colorway objects use keys {color(int), colorDescription, model, thumbnail, imgs}
const colorways = [];
for (const s of (Array.isArray(d.skus) ? d.skus : [])) {
const thumb = asImage(s.thumbnail || s.img || s.image || s.coverImg || s.fileName);
if (thumb) images.push(thumb);
for (const im of (Array.isArray(s.imgs) ? s.imgs : [])) { const u = asImage(im); if (u) images.push(u); }
colorways.push({ id: s.id, model: s.model || null, color: s.colorDescription || s.colorName || null, image: thumb });
}
const uniqImages = [...new Set(images.filter(Boolean))];
// subcategory breakdown by type (wallClass / wallType / newClass / news)
const subCats = (Array.isArray(d.subCategories) ? d.subCategories : [])
.filter((s) => s.category_name)
.map((s) => ({ id: s.category_id, name: s.category_name, type: s.category_type }));
const wallClass = subCats.filter((s) => s.type === 'wallClass').map((s) => s.name);
const wallType = subCats.filter((s) => s.type === 'wallType').map((s) => s.name);
const composition = (Array.isArray(d.compositions) ? d.compositions : [])
.filter((c) => c.compositionName)
.map((c) => `${c.compositionName}${c.percentage ? ' ' + c.percentage + '%' : ''}`)
.join(', ') || null;
// derive a real SKU: the vendor's `code` is sometimes a color ("Warm White") or a collection
// name ("JUNGLE PULSE") instead of a style code, and occasionally the style code lands in `name`.
// Prefer a code-looking `name`, else the first colorway model, else a code-looking `code`, else GL-<id>.
const codeRe = /^[A-Z]{1,4}-?\d/i;
const rawName = lr.name || d.name || null;
const rawCode = lr.code || d.code || null;
const firstModel = colorways.map((c) => c.model).find((m) => m && codeRe.test(m));
let sku = null, pattern = null, collection = null, colorName = null;
if (codeRe.test(rawName || '')) { sku = rawName; pattern = rawName; colorName = rawCode; }
else if (firstModel) { sku = firstModel; pattern = rawName; collection = (rawCode && !codeRe.test(rawCode)) ? rawCode : null; }
else if (codeRe.test(rawCode || '')) { sku = rawCode; pattern = rawName; }
else { sku = 'GL-' + id; pattern = rawName; collection = rawCode; }
const catName = lr.categoryName || d.categoryName || null;
catCount[catName || 'Uncategorized'] = (catCount[catName || 'Uncategorized'] || 0) + 1;
const row = {
source: 'greenland',
id,
// NOTE: vendor stores pattern in `name`, color/code in `code` (sometimes swapped) — keep both raw
name: rawName,
code: rawCode,
sku, // derived (see codeRe logic above)
pattern,
collection, // vendor collection name, when `code` held one
color: colorName, // real color name, when `code` held one
category_id: lr.categoryId || d.categoryId || null,
category: catName,
wall_class: wallClass.length ? wallClass : null, // Cork / Natural / Textile etc.
wall_type: wallType.length ? wallType : null, // e.g. "Top 100 Cork& Wood"
sub_categories: subCats.length ? subCats : null,
use: d.use || null,
width: d.width || null,
repeat: d.repeat || null,
minimum_order: d.minimumOrder || null,
fire_rating: d.fireRating || null,
backing: d.backing || null,
weight: d.weight || null,
composition,
unit_price: d.unitPrice != null ? d.unitPrice : null,
is_publish: (d.isPublish != null ? d.isPublish : lr.isPublish),
cover_image: cover,
images: uniqImages,
image_count: uniqImages.length,
colorways: colorways.length ? colorways : null,
url: `http://m.greenlandwallcoverings.com/#/pages/detail/index?id=${id}`,
detail_ok: !!detail,
};
out.write(JSON.stringify(row) + '\n');
done++;
if (done % 25 === 0) process.stdout.write(`\renriched ${done}/${listRows.length} `);
await sleep(120);
}
out.end();
console.log(`\nDONE. wrote ${done} products -> ${OUT}`);
console.log('Per-category counts:', JSON.stringify(catCount, null, 2));
}
main().catch((e) => { console.error('FATAL', e); process.exit(1); });