← back to Rebel Walls Scraper
initial scaffold: rebel-walls scraper + skill
2142acaa88d924cabf1ba823e161c8d5df2ea8ab · 2026-06-03 09:01:39 -0700 · Steve
Files touched
A .gitignoreA README.mdA scripts/scrape.js
Diff
commit 2142acaa88d924cabf1ba823e161c8d5df2ea8ab
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jun 3 09:01:39 2026 -0700
initial scaffold: rebel-walls scraper + skill
---
.gitignore | 8 ++
README.md | 12 +++
scripts/scrape.js | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 294 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/README.md b/README.md
new file mode 100644
index 0000000..dd83fed
--- /dev/null
+++ b/README.md
@@ -0,0 +1,12 @@
+# Rebel Walls Scraper
+
+Public-storefront scraper for rebelwalls.com (designer wall murals). No auth/trade portal.
+Custom-dimension murals priced per m² (DTD Option A — see skill SKILL.md).
+
+## Usage
+ node scripts/scrape.js enumerate # sitemap -> data/product-urls.json
+ node scripts/scrape.js pilot 25 # scrape 25 -> output/pilot-<date>.json
+ node scripts/scrape.js fullproduct output/pilot-<date>.json # quality gate
+
+Pilot only. NO import to dw_unified/Shopify (gated — see ~/.claude/yolo-queue/pending-approval/).
+Skill: ~/.claude/skills/rebel-walls-scraper-manager/
diff --git a/scripts/scrape.js b/scripts/scrape.js
new file mode 100644
index 0000000..2c80e59
--- /dev/null
+++ b/scripts/scrape.js
@@ -0,0 +1,274 @@
+#!/usr/bin/env node
+/**
+ * Rebel Walls scraper — public storefront, no auth.
+ * Modes:
+ * node scripts/scrape.js enumerate -> data/product-urls.json
+ * node scripts/scrape.js pilot <N> [offset] -> output/pilot-<date>.json
+ * node scripts/scrape.js fullproduct <json> -> data-quality scorecard
+ *
+ * Zero-dependency: uses global fetch (Node 18+). Polite: 1.5s delay, single-threaded.
+ * Pricing model = DTD Option A (per-m² base rate; see SKILL.md).
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..');
+const DATA = path.join(ROOT, 'data');
+const OUT = path.join(ROOT, 'output');
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36';
+const BASE = 'https://rebelwalls.com';
+const DELAY_MS = 1500;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const today = () => new Date().toISOString().slice(0, 10);
+
+async function get(url) {
+ const res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept-Language': 'en-US,en' } });
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
+ return res.text();
+}
+
+/* ---- enumerate: sitemap -> product slugs (drop categories/blog) ---- */
+// Category/collection/blog pages we must NOT treat as products.
+const NON_PRODUCT_SUFFIX = /-wallpaper$|-collection$|-murals$|-guide$|-info$/;
+const NON_PRODUCT_KEYWORDS = /(\bblog\b|how-to|ways-of|making-your|ideas$|inspiration|about|contact|returns|faq|trade|sample|wallpaper-for|-tips|guide-to)/i;
+
+async function enumerate() {
+ console.error('Fetching sitemap…');
+ const xml = await get(`${BASE}/sitemap.xml`);
+ const locs = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim());
+ // Only root-level single-segment slugs are candidate products.
+ const candidates = locs.filter((u) => {
+ if (!u.startsWith(BASE + '/')) return false;
+ const slug = u.slice(BASE.length + 1);
+ if (!slug || slug.includes('/')) return false; // skip locale/nested
+ if (NON_PRODUCT_SUFFIX.test(slug)) return false;
+ if (NON_PRODUCT_KEYWORDS.test(slug)) return false;
+ return true;
+ });
+ const uniq = [...new Set(candidates)];
+ fs.mkdirSync(DATA, { recursive: true });
+ fs.writeFileSync(path.join(DATA, 'product-urls.json'), JSON.stringify(uniq, null, 2));
+ console.error(`Enumerated ${uniq.length} candidate product URLs (from ${locs.length} sitemap locs) -> data/product-urls.json`);
+ return uniq;
+}
+
+/* ---- parse a single product page ---- */
+function decode(s) {
+ if (s == null) return s;
+ // unescape \/ and \uXXXX then strip HTML tags + html entities
+ let out = s.replace(/\\\//g, '/');
+ out = out.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
+ out = out.replace(/<[^>]+>/g, '');
+ out = out.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ');
+ return out.trim();
+}
+
+function jsonLdProduct(html) {
+ for (const m of html.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)) {
+ try {
+ const d = JSON.parse(m[1]);
+ if (d['@type'] === 'Product') return d;
+ } catch (_) {}
+ }
+ return null;
+}
+
+function specValue(html, label) {
+ // {"label":"Collection","value":"Shutterstock"}
+ const re = new RegExp('\\{"label":"' + label.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + '","value":"((?:[^"\\\\]|\\\\.)*)"\\}');
+ const m = html.match(re);
+ return m ? decode(m[1]) : '';
+}
+
+function specItem(html, header) {
+ // {"header":"Roll Width","materials":{"std":"<p>19.7 in / 0.5 m</p>",...
+ const re = new RegExp('"header":"' + header.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + '","materials":\\{"std":"((?:[^"\\\\]|\\\\.)*)"');
+ const m = html.match(re);
+ return m ? decode(m[1]) : '';
+}
+
+function priceTier(html, key) {
+ // "description":"non-woven-text","price":"$7.10 / sq ft","defaultPrice":"$7.10"
+ const re = new RegExp('"description":"' + key + '-text","price":"([^"]+)"');
+ const m = html.match(re);
+ if (!m) return null;
+ const num = (m[1].match(/([0-9]+(?:\.[0-9]+)?)/) || [])[1];
+ return num ? parseFloat(num) : null;
+}
+
+function hiResImage(url) {
+ if (!url) return url;
+ // strip the watermark layer + bump width; keep auto format/quality.
+ let u = url.replace(/l_common:rw_watermark[^/]*\//, '');
+ u = u.replace(/[?,]?w_\d+/g, '').replace(/,h_\d+/g, '');
+ // ensure a clean wide transform
+ u = u.replace('/image/upload/', '/image/upload/c_limit,w_1600,f_auto,q_auto/');
+ // collapse accidental double-transform
+ u = u.replace(/c_limit,w_1600,f_auto,q_auto\/c_[^/]+\//, 'c_limit,w_1600,f_auto,q_auto/');
+ return u;
+}
+
+function parseColorways(html) {
+ const m = html.match(/"colorways":(\[.*?\}\])/s);
+ if (!m) return [];
+ try {
+ const arr = JSON.parse(m[1].replace(/\\\//g, '/'));
+ return arr.map((c) => ({ no: c.no, name: decode(c.name), link: c.link }));
+ } catch (_) { return []; }
+}
+
+function splitName(name) {
+ // "Abstract Blossoms, Brown" -> pattern + color (split on LAST comma)
+ const i = name.lastIndexOf(',');
+ if (i === -1) return { pattern: name.trim(), color: '' };
+ return { pattern: name.slice(0, i).trim(), color: name.slice(i + 1).trim() };
+}
+
+async function parseProduct(url) {
+ const html = await get(url);
+ const ld = jsonLdProduct(html);
+ if (!ld) throw new Error(`no JSON-LD Product at ${url}`);
+ const { pattern, color } = splitName(ld.name || '');
+ const basePerM2 = ld.offers && ld.offers.price ? parseFloat(ld.offers.price) : null;
+ const sqftNonwoven = priceTier(html, 'non-woven');
+ const sqftPeel = priceTier(html, 'peel-and-stick');
+ const sqftComm = priceTier(html, 'commercial-grade');
+
+ // gather interior/room images from inline state
+ const slug = url.slice(BASE.length);
+ const mfr = ld.sku || '';
+ const imgs = [...new Set([...html.matchAll(/https:\\?\/\\?\/res\.rebelwalls\.com\/[^"\\]+/g)]
+ .map((m) => m[0].replace(/\\\//g, '/'))
+ .filter((u) => mfr && u.includes(mfr)))];
+ const interior = imgs.filter((u) => /_interior\d*\.jpg/i.test(u)).map(hiResImage);
+ const primary = hiResImage(ld.image || interior[0] || imgs[0] || '');
+
+ return {
+ mfr_sku: mfr,
+ pattern_name: pattern,
+ color_name: color,
+ full_name: ld.name || '',
+ collection: specValue(html, 'Collection'),
+ base_unit: specValue(html, 'Base Unit'),
+ product_type: 'Mural',
+ material: 'Non-woven',
+ roll_width: specItem(html, 'Roll Width'),
+ grammage: specItem(html, 'Grammage'),
+ fire_rating: specItem(html, 'Fire Rating'),
+ light_fastness: specItem(html, 'Light Fastness'),
+ cleanability: specItem(html, 'Cleanability'),
+ sustainability: specItem(html, 'Sustainability'),
+ paper_quality: specItem(html, 'Paper Quality'),
+ manufacturer: specItem(html, 'Manufacturer'),
+ repeat_h: specValue(html, 'Horizontal Repeat'),
+ repeat_v: specValue(html, 'Vertical Repeat'),
+ match_type: specValue(html, 'Pattern Repeat'),
+ // ---- Pricing: DTD Option A ----
+ price_retail: basePerM2, // per-m² base (non-woven)
+ price_unit: 'Priced Per Square Meter',
+ display_dimension: '1 m² (≈10.76 sq ft)',
+ price_per_sqft_nonwoven: sqftNonwoven,
+ price_per_sqft_peelstick: sqftPeel,
+ price_per_sqft_commercial: sqftComm,
+ currency: (ld.offers && ld.offers.priceCurrency) || 'USD',
+ in_stock: !ld.offers || /InStock/i.test(ld.offers.availability || ''),
+ image_url: primary,
+ all_images: imgs.map(hiResImage),
+ room_setting_images: interior,
+ colorways: parseColorways(html),
+ description: decode(ld.description || ''),
+ product_url: url,
+ last_scraped: new Date().toISOString(),
+ };
+}
+
+/* ---- pilot ---- */
+async function pilot(n, offset) {
+ const urlsPath = path.join(DATA, 'product-urls.json');
+ let urls;
+ if (fs.existsSync(urlsPath)) urls = JSON.parse(fs.readFileSync(urlsPath, 'utf8'));
+ else urls = await enumerate();
+ const slice = urls.slice(offset || 0, (offset || 0) + n);
+ const results = [];
+ for (let i = 0; i < slice.length; i++) {
+ const u = slice[i];
+ process.stderr.write(`[${i + 1}/${slice.length}] ${u} … `);
+ try {
+ const p = await parseProduct(u);
+ results.push(p);
+ process.stderr.write(`OK ${p.mfr_sku} $${p.price_retail}/m²\n`);
+ } catch (e) {
+ results.push({ product_url: u, error: String(e.message || e) });
+ process.stderr.write(`ERR ${e.message}\n`);
+ }
+ if (i < slice.length - 1) await sleep(DELAY_MS);
+ }
+ fs.mkdirSync(OUT, { recursive: true });
+ const outFile = path.join(OUT, `pilot-${today()}.json`);
+ fs.writeFileSync(outFile, JSON.stringify(results, null, 2));
+ console.error(`\nWrote ${results.length} products -> ${outFile}`);
+ return outFile;
+}
+
+/* ---- fullproduct gate ---- */
+function fullproduct(jsonPath) {
+ const rows = JSON.parse(fs.readFileSync(jsonPath, 'utf8')).filter((r) => !r.error);
+ const total = rows.length;
+ const nonEmpty = (v) => v != null && String(v).trim() !== '' && !(Array.isArray(v) && v.length === 0);
+ const pct = (n) => `${n}/${total} (${total ? Math.round((n / total) * 100) : 0}%)`;
+ const tier1 = {
+ image_url: rows.filter((r) => nonEmpty(r.image_url)).length,
+ 'roll_width(width)': rows.filter((r) => nonEmpty(r.roll_width)).length,
+ mfr_sku: rows.filter((r) => nonEmpty(r.mfr_sku)).length,
+ pattern_name: rows.filter((r) => nonEmpty(r.pattern_name)).length,
+ price_retail: rows.filter((r) => r.price_retail > 0).length,
+ };
+ const tier2 = {
+ all_images: rows.filter((r) => nonEmpty(r.all_images)).length,
+ room_setting_images: rows.filter((r) => nonEmpty(r.room_setting_images)).length,
+ material: rows.filter((r) => nonEmpty(r.material)).length,
+ color_name: rows.filter((r) => nonEmpty(r.color_name)).length,
+ collection: rows.filter((r) => nonEmpty(r.collection)).length,
+ product_url: rows.filter((r) => nonEmpty(r.product_url)).length,
+ fire_rating: rows.filter((r) => nonEmpty(r.fire_rating)).length,
+ grammage: rows.filter((r) => nonEmpty(r.grammage)).length,
+ };
+ const tier1Pass = Object.values(tier1).every((v) => v === total);
+ const lines = [];
+ lines.push(`FULLPRODUCT Validation: Rebel Walls (pilot)`);
+ lines.push(`═══════════════════════════════`);
+ lines.push(`Total products: ${total}`);
+ lines.push('');
+ lines.push('TIER 1 (Required — import blocked if any fail):');
+ for (const [k, v] of Object.entries(tier1)) lines.push(` ${v === total ? '✅' : '❌'} ${k}: ${pct(v)}`);
+ lines.push('');
+ lines.push('TIER 2 (Recommended — warn if <80%):');
+ for (const [k, v] of Object.entries(tier2)) lines.push(` ${v >= total * 0.8 ? '✅' : '⚠️'} ${k}: ${pct(v)}`);
+ lines.push('');
+ lines.push(`VERDICT: ${tier1Pass ? '✅ READY FOR IMPORT (Tier 1 complete)' : '❌ NOT READY (Tier 1 gap)'}`);
+ const report = lines.join('\n');
+ console.log(report);
+ return { report, tier1, tier2, total, tier1Pass };
+}
+
+/* ---- main ---- */
+(async () => {
+ const [, , mode, a, b] = process.argv;
+ try {
+ if (mode === 'enumerate') await enumerate();
+ else if (mode === 'pilot') await pilot(parseInt(a || '25', 10), parseInt(b || '0', 10));
+ else if (mode === 'fullproduct') fullproduct(a);
+ else {
+ console.error('Usage: scrape.js enumerate | pilot <N> [offset] | fullproduct <json>');
+ process.exit(1);
+ }
+ } catch (e) {
+ console.error('FATAL:', e.message || e);
+ process.exit(1);
+ }
+})();
+
+module.exports = { parseProduct, hiResImage, splitName };
(oldest)
·
back to Rebel Walls Scraper
·
Rebel Walls scraper: enumerate 4309 + 25-SKU pilot (fullprod f8fa280 →