[object Object]

← back to Greenland China

scaffold greenland-china: feed-first scraper + Sangetsu-pattern viewer

fc9618a477eeaf0d561811ceba2c207e17e77d4c · 2026-06-30 09:27:16 -0700 · Steve Abrams

API discovered via SPA JS bundle reverse-engineering:
base=https://api.greenlandwallcoverings.com/api
/product/page (paginated, total 880), /product/{id} (full specs),
/category/0 (tree), /downloadFile (images). No anti-bot on the API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit fc9618a477eeaf0d561811ceba2c207e17e77d4c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 30 09:27:16 2026 -0700

    scaffold greenland-china: feed-first scraper + Sangetsu-pattern viewer
    
    API discovered via SPA JS bundle reverse-engineering:
    base=https://api.greenlandwallcoverings.com/api
    /product/page (paginated, total 880), /product/{id} (full specs),
    /category/0 (tree), /downloadFile (images). No anti-bot on the API.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore               |   8 +++
 scripts/scrape.js        | 160 +++++++++++++++++++++++++++++++++++++++++++++++
 viewer/public/index.html | 143 ++++++++++++++++++++++++++++++++++++++++++
 viewer/server.js         | 120 +++++++++++++++++++++++++++++++++++
 4 files changed, 431 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/scripts/scrape.js b/scripts/scrape.js
new file mode 100644
index 0000000..694fd51
--- /dev/null
+++ b/scripts/scrape.js
@@ -0,0 +1,160 @@
+#!/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);
+    }
+    const colorways = [];
+    for (const s of (Array.isArray(d.skus) ? d.skus : [])) {
+      const u = asImage(s.img || s.image || s.coverImg || s.fileName);
+      if (u) images.push(u);
+      colorways.push({ id: s.id, color: s.color || s.colorName || s.name || null, code: s.code || null, image: u });
+    }
+    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;
+
+    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: lr.name || d.name || null,
+      code: lr.code || d.code || null,
+      sku: d.code || lr.code || lr.name || String(id),     // mfr code is the closest thing to a SKU
+      pattern: lr.name || d.name || null,
+      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); });
diff --git a/viewer/public/index.html b/viewer/public/index.html
new file mode 100644
index 0000000..0bea6d0
--- /dev/null
+++ b/viewer/public/index.html
@@ -0,0 +1,143 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Greenland Wallcoverings — Staged SKUs (offline preview)</title>
+<style>
+  :root { --cols: 6; --bg:#0d0f0d; --card:#161b16; --line:#2a302a; --txt:#e8eae8; --mut:#9aa29a; --accent:#7fb069; }
+  * { box-sizing: border-box; }
+  body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
+  header { position:sticky; top:0; z-index:10; background:rgba(13,15,13,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; }
+  h1 { margin:0 0 10px; font-size:18px; font-weight:600; letter-spacing:.02em; }
+  h1 small { color:var(--mut); font-weight:400; font-size:13px; margin-left:8px; }
+  .controls { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
+  select, input[type=search] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
+  input[type=search] { min-width:220px; }
+  .dens { display:flex; align-items:center; gap:8px; color:var(--mut); }
+  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:18px 20px 60px; }
+  .card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
+  .thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
+  .thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
+  .badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700; letter-spacing:.04em; padding:2px 7px; border-radius:20px; text-transform:uppercase; background:#2f7d4f; color:#fff; }
+  .badge.imgs { position:static; background:#2a302a; color:#9aa29a; font-size:9px; padding:1px 5px; margin-left:5px; border-radius:10px; vertical-align:middle; text-transform:none; }
+  .meta { padding:8px 10px; }
+  .sku { font-weight:700; font-size:13px; color:var(--accent); word-break:break-all; }
+  .ttl { color:var(--txt); font-size:12px; margin-top:3px; max-height:2.8em; overflow:hidden; }
+  .color { color:var(--mut); font-size:11px; margin-top:2px; }
+  .cat { margin-top:4px; }
+  .cat span { display:inline-block; background:#1e241e; color:#9aa29a; font-size:9.5px; padding:1px 6px; border-radius:10px; margin-right:4px; margin-top:2px; }
+  .spec { margin-top:5px; font-size:11px; color:#777; display:flex; gap:8px; flex-wrap:wrap; }
+  #sentinel { height:1px; }
+  #status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
+</style>
+</head>
+<body>
+<header>
+  <h1>Greenland Wallcoverings <small id="sub">staged offline · read-only preview</small></h1>
+  <div class="controls">
+    <label class="dens">Category
+      <select id="cat"><option value="all">All categories</option></select>
+    </label>
+    <input type="search" id="q" placeholder="Search SKU, pattern, color, material…">
+    <label class="dens">Sort
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="sku">SKU A→Z</option>
+        <option value="title">Title A→Z</option>
+        <option value="width">Width ↑</option>
+        <option value="weight">Weight ↑</option>
+        <option value="images">Most images</option>
+      </select>
+    </label>
+    <label class="dens">Density
+      <input type="range" id="dens" min="3" max="10" value="6">
+    </label>
+  </div>
+</header>
+<div class="grid" id="grid"></div>
+<div id="sentinel"></div>
+<div id="status">loading…</div>
+
+<script>
+const grid = document.getElementById('grid');
+const statusEl = document.getElementById('status');
+const subEl = document.getElementById('sub');
+const catSel = document.getElementById('cat');
+const LIMIT = 120;
+let state = {
+  category: localStorage.getItem('gl_cat') || 'all',
+  sort: localStorage.getItem('gl_sort') || 'newest',
+  q: '',
+  offset: 0, total: 0, loading: false, done: false, catsBuilt: false,
+};
+
+// density persists
+const dens = document.getElementById('dens');
+dens.value = localStorage.getItem('gl_cols') || 6;
+document.documentElement.style.setProperty('--cols', dens.value);
+dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('gl_cols', dens.value); };
+
+document.getElementById('sort').value = state.sort;
+
+function reset() { grid.innerHTML=''; state.offset=0; state.done=false; statusEl.textContent='loading…'; load(); }
+
+catSel.onchange = (e) => { state.category = e.target.value; localStorage.setItem('gl_cat', state.category); reset(); };
+document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('gl_sort', state.sort); reset(); };
+let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); reset(); }, 250); };
+
+// same-origin proxy fallback so the grid renders even if CDN hot-link-blocks
+function imgSrc(u) { return u ? ('/img?u=' + encodeURIComponent(u)) : null; }
+
+function card(r) {
+  const el = document.createElement('div'); el.className='card';
+  const imgBadge = r.image_count>1 ? `<span class="badge imgs">📷 ${r.image_count}</span>` : '';
+  const src = imgSrc(r.image);
+  const thumb = src
+    ? `<div class="thumb" style="background-image:url('${src}')"><span class="badge">Greenland</span></div>`
+    : `<div class="thumb empty"><span class="badge">Greenland</span>no image</div>`;
+  const cats = [r.category, r.wall_class, r.wall_type].filter(Boolean).map(s=>`<span>${s}</span>`).join('');
+  const specs = r.specs.map(s=>`<span>${s}</span>`).join('');
+  el.innerHTML = `${thumb}<div class="meta">
+    <div class="sku">${r.sku||'—'}${imgBadge}</div>
+    <div class="ttl" title="${(r.title||'').replace(/"/g,'&quot;')}">${r.title||''}</div>
+    ${r.color && r.color!==r.title?`<div class="color">${r.color}</div>`:''}
+    ${cats?`<div class="cat">${cats}</div>`:''}
+    <div class="spec">${specs}</div>
+  </div>`;
+  if (r.url) { el.style.cursor='pointer'; el.onclick=()=>window.open(r.url,'_blank'); }
+  return el;
+}
+
+function buildCats(categories) {
+  if (state.catsBuilt) return;
+  const entries = Object.entries(categories).sort((a,b)=>b[1]-a[1]);
+  for (const [name,n] of entries) {
+    const o = document.createElement('option'); o.value=name; o.textContent=`${name} (${n})`; catSel.appendChild(o);
+  }
+  catSel.value = state.category;
+  state.catsBuilt = true;
+}
+
+async function load() {
+  if (state.loading || state.done) return;
+  state.loading = true;
+  const p = new URLSearchParams({ category:state.category, sort:state.sort, q:state.q, offset:state.offset, limit:LIMIT });
+  const res = await fetch('/api/skus?'+p); const data = await res.json();
+  buildCats(data.categories);
+  state.total = data.total;
+  const frag = document.createDocumentFragment();
+  data.rows.forEach(r => frag.appendChild(card(r)));
+  grid.appendChild(frag);
+  state.offset += data.rows.length;
+  state.done = state.offset >= data.total;
+  state.loading = false;
+  subEl.textContent = `staged offline · ${data.all.toLocaleString()} SKUs total · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}`;
+  statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} SKUs —` : 'scroll for more…';
+}
+
+new IntersectionObserver((es)=>{ if (es[0].isIntersecting) load(); }, { rootMargin:'600px' }).observe(document.getElementById('sentinel'));
+load();
+</script>
+</body>
+</html>
diff --git a/viewer/server.js b/viewer/server.js
new file mode 100644
index 0000000..64f6c35
--- /dev/null
+++ b/viewer/server.js
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+/**
+ * Staging viewer for the Greenland Wallcoverings onboarding catalog (OFFLINE / read-only).
+ * Reads ../staging/greenland.jsonl, flattens to uniform SKU rows, and serves a paginated
+ * /api/skus endpoint behind a sort + density + infinite-scroll grid.  Mirrors the
+ * Sangetsu/Lilycolor viewer pattern.
+ *
+ * Images: Greenland's CDN (api.greenlandwallcoverings.com/api/downloadFile) is referenced
+ * directly; a /img proxy provides a same-origin fallback so the grid renders even if the
+ * remote CDN hot-link-blocks a browser.
+ *
+ * HARD: read-only. No Shopify, no dw_unified, no publish. Pure local preview of staged data.
+ *   node viewer/server.js [port]   (default 0 = OS-assigned free port)
+ */
+const http = require('http');
+const https = require('https');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = parseInt(process.argv[2] || '0', 10);
+const DIR = path.join(__dirname, '..', 'staging');
+const GREEN = path.join(DIR, 'greenland.jsonl');
+const PUBLIC = path.join(__dirname, 'public');
+
+const readJsonl = (f) => (fs.existsSync(f)
+  ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
+  : []);
+
+function buildRows() {
+  const rows = [];
+  for (const r of readJsonl(GREEN)) {
+    const specs = [r.width, r.repeat, r.fire_rating, r.backing, r.weight ? r.weight + ' g' : null].filter(Boolean);
+    rows.push({
+      source: 'greenland',
+      id: r.id,
+      sku: r.sku || r.code || String(r.id),
+      title: r.pattern || r.name || r.sku || String(r.id),
+      color: r.code || null,
+      category: r.category || null,
+      wall_class: (r.wall_class && r.wall_class.length) ? r.wall_class.join(', ') : null,
+      wall_type: (r.wall_type && r.wall_type.length) ? r.wall_type.join(', ') : null,
+      width: r.width || null,
+      repeat: r.repeat || null,
+      fire: r.fire_rating || null,
+      backing: r.backing || null,
+      weight: r.weight || null,
+      composition: r.composition || null,
+      min_order: r.minimum_order || null,
+      specs,
+      image: r.cover_image || (r.images && r.images[0]) || null,
+      image_count: r.image_count || (r.images ? r.images.length : 0),
+      colorway_count: r.colorways ? r.colorways.length : 0,
+      url: r.url || null,
+    });
+  }
+  return rows;
+}
+
+let ROWS = buildRows();
+fs.watchFile(GREEN, { interval: 5000 }, () => { ROWS = buildRows(); });
+
+const sorters = {
+  newest: null, // server natural order (list order from vendor)
+  sku: (a, b) => String(a.sku).localeCompare(String(b.sku)),
+  title: (a, b) => String(a.title).localeCompare(String(b.title)),
+  width: (a, b) => (parseFloat(a.width) || 0) - (parseFloat(b.width) || 0),
+  weight: (a, b) => (a.weight || 0) - (b.weight || 0),
+  images: (a, b) => (b.image_count || 0) - (a.image_count || 0),
+};
+
+const categoriesOf = (rows) => {
+  const m = {};
+  for (const r of rows) { const c = r.category || 'Uncategorized'; m[c] = (m[c] || 0) + 1; }
+  return m;
+};
+
+const server = http.createServer((req, res) => {
+  const u = new URL(req.url, `http://localhost`);
+
+  if (u.pathname === '/api/skus') {
+    const cat = u.searchParams.get('category') || 'all';
+    const q = (u.searchParams.get('q') || '').toLowerCase();
+    const sort = u.searchParams.get('sort') || 'newest';
+    const offset = parseInt(u.searchParams.get('offset') || '0', 10);
+    const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10), 500);
+    let rows = ROWS;
+    if (cat !== 'all') rows = rows.filter((r) => (r.category || 'Uncategorized') === cat);
+    if (q) rows = rows.filter((r) => (r.sku + ' ' + r.title + ' ' + (r.color || '') + ' ' + (r.wall_class || '')).toLowerCase().includes(q));
+    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+    const page = rows.slice(offset, offset + limit);
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ total: rows.length, all: ROWS.length, categories: categoriesOf(ROWS), offset, limit, rows: page }));
+    return;
+  }
+
+  // same-origin image proxy fallback (CDN hot-link safety)
+  if (u.pathname === '/img') {
+    const target = u.searchParams.get('u');
+    if (!target || !/^https:\/\/api\.greenlandwallcoverings\.com\//.test(target)) { res.writeHead(400); return res.end('bad'); }
+    https.get(target, { headers: { 'User-Agent': 'Mozilla/5.0', Referer: 'http://m.greenlandwallcoverings.com/' } }, (pr) => {
+      res.writeHead(pr.statusCode || 200, { 'Content-Type': pr.headers['content-type'] || 'image/jpeg', 'Cache-Control': 'max-age=86400' });
+      pr.pipe(res);
+    }).on('error', () => { res.writeHead(502); res.end('img err'); });
+    return;
+  }
+
+  // static
+  let p = u.pathname === '/' ? '/index.html' : u.pathname;
+  const file = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
+  if (!file.startsWith(PUBLIC) || !fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
+  const ext = path.extname(file);
+  const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }[ext] || 'text/plain';
+  res.writeHead(200, { 'Content-Type': mime });
+  res.end(fs.readFileSync(file));
+});
+
+server.listen(PORT, () => {
+  const addr = server.address();
+  console.log(`Greenland staging viewer → http://localhost:${addr.port}  (${ROWS.length} SKUs)`);
+});

(oldest)  ·  back to Greenland China  ·  auto-save: 2026-06-30T09:39:21 (1 files) — staging/ 69b68a9 →