← back to Dw Yolo Loop
Thibaut mfr-fix: COMPLETE — 1096/1096 pushed live (0 failed), verified on store; store=designer-laboratory-sandbox IS prod
ca94dcba384ab5f6a367bf00c130f971d89c3614 · 2026-06-11 23:16:21 -0700 · Steve Abrams
Files touched
A rw-price-group-680.jsA rw-price-group-fix.jsA rw-price-group-offset520.jsA rw-price-group-slice5.jsA rw-price-group.jsA scripts/rw-price-group-fix.jsA scripts/rw-price-group-setter.jsA scripts/rw-price-group-slice7.jsM thibaut-mfr-fix/README.mdA tmp-rw-price-group.js
Diff
commit ca94dcba384ab5f6a367bf00c130f971d89c3614
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jun 11 23:16:21 2026 -0700
Thibaut mfr-fix: COMPLETE — 1096/1096 pushed live (0 failed), verified on store; store=designer-laboratory-sandbox IS prod
---
rw-price-group-680.js | 320 ++++++++++++++++++++++++++++++
rw-price-group-fix.js | 302 ++++++++++++++++++++++++++++
rw-price-group-offset520.js | 403 ++++++++++++++++++++++++++++++++++++++
rw-price-group-slice5.js | 413 +++++++++++++++++++++++++++++++++++++++
rw-price-group.js | 363 ++++++++++++++++++++++++++++++++++
scripts/rw-price-group-fix.js | 398 +++++++++++++++++++++++++++++++++++++
scripts/rw-price-group-setter.js | 331 +++++++++++++++++++++++++++++++
scripts/rw-price-group-slice7.js | 318 ++++++++++++++++++++++++++++++
thibaut-mfr-fix/README.md | 4 +-
tmp-rw-price-group.js | 337 ++++++++++++++++++++++++++++++++
10 files changed, 3187 insertions(+), 2 deletions(-)
diff --git a/rw-price-group-680.js b/rw-price-group-680.js
new file mode 100644
index 0000000..ebc4f8f
--- /dev/null
+++ b/rw-price-group-680.js
@@ -0,0 +1,320 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-680.js
+ * Determine Rebel Walls price group (C2/C3/C4) from live RW product page per-m2 rate.
+ * Slice: LIMIT 40 OFFSET 680
+ * Sandbox only.
+ */
+
+const https = require('https');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
+
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7855827910707',
+ 'gid://shopify/Product/7855827943475',
+ 'gid://shopify/Product/7855827976243',
+ 'gid://shopify/Product/7855828009011',
+ 'gid://shopify/Product/7855828074547',
+ 'gid://shopify/Product/7855828140083',
+ 'gid://shopify/Product/7855828172851',
+ 'gid://shopify/Product/7855828238387',
+ 'gid://shopify/Product/7855828271155',
+ 'gid://shopify/Product/7855828303923',
+ 'gid://shopify/Product/7855828336691',
+ 'gid://shopify/Product/7855828402227',
+ 'gid://shopify/Product/7855828434995',
+ 'gid://shopify/Product/7855828467763',
+ 'gid://shopify/Product/7855828533299',
+ 'gid://shopify/Product/7855828566067',
+ 'gid://shopify/Product/7855828598835',
+ 'gid://shopify/Product/7855828631603',
+ 'gid://shopify/Product/7855828664371',
+ 'gid://shopify/Product/7855828729907',
+ 'gid://shopify/Product/7855828860979',
+ 'gid://shopify/Product/7855829024819',
+ 'gid://shopify/Product/7855829221427',
+ 'gid://shopify/Product/7855829483571',
+ 'gid://shopify/Product/7855829647411',
+ 'gid://shopify/Product/7855829712947',
+ 'gid://shopify/Product/7855829745715',
+ 'gid://shopify/Product/7855829778483',
+ 'gid://shopify/Product/7855829876787',
+ 'gid://shopify/Product/7855829909555',
+ 'gid://shopify/Product/7855829942323',
+ 'gid://shopify/Product/7855829975091',
+ 'gid://shopify/Product/7855830007859',
+ 'gid://shopify/Product/7855830040627',
+ 'gid://shopify/Product/7855829811251',
+ 'gid://shopify/Product/7855830073395',
+ 'gid://shopify/Product/7855830106163',
+ 'gid://shopify/Product/7855830138931',
+ 'gid://shopify/Product/7855830171699',
+ 'gid://shopify/Product/7855830204467',
+];
+
+// Price group buckets (base/cheapest material per-m2)
+// C2 ~58.10, C3 ~76.40, C4 ~88.30
+const C2_MATERIALS = [
+ {"material":"Non-Woven (Standard)","retail_per_m2":58.10,"cost_per_m2":33.84,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":69.70,"cost_per_m2":40.60,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":81.36,"cost_per_m2":47.39,"cost_confirmed":false}
+];
+const C4_MATERIALS = [
+ {"material":"Non-Woven (Standard)","retail_per_m2":88.30,"cost_per_m2":51.44,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":106.00,"cost_per_m2":61.75,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":123.66,"cost_per_m2":72.03,"cost_confirmed":false}
+];
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function gqlRaw(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data)
+ },
+ }, res => {
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } });
+ });
+ req.on('error', reject);
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data); req.end();
+ });
+}
+
+async function gql(body, retries = 4) {
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
+ const result = await gqlRaw(body);
+ const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+ const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable || 9999) < 200;
+ if (throttled) { await sleep(3000); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+function httpsGet(url, redirects = 5) {
+ return new Promise((resolve, reject) => {
+ const parsedUrl = new URL(url);
+ const options = {
+ hostname: parsedUrl.hostname,
+ path: parsedUrl.pathname + parsedUrl.search,
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ }
+ };
+ const req = https.request(options, res => {
+ if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location && redirects > 0) {
+ const next = res.headers.location.startsWith('http') ? res.headers.location : `https://${parsedUrl.hostname}${res.headers.location}`;
+ res.resume();
+ return resolve(httpsGet(next, redirects - 1));
+ }
+ let body = '';
+ res.on('data', d => body += d);
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('error', reject);
+ req.setTimeout(20000, () => { req.destroy(); reject(new Error('fetch timeout')); });
+ req.end();
+ });
+}
+
+/**
+ * Extract base per-m2 price from Rebel Walls product page HTML.
+ * RW uses JSON-LD Product schema with offers.price in USD/m2.
+ * Returns a number (USD/m2) or null.
+ */
+function extractRWPrice(html) {
+ // Primary: JSON-LD Product schema — offers.price is base material price per m2
+ const jsonLdBlocks = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi) || [];
+ for (const block of jsonLdBlocks) {
+ const inner = block.replace(/<script[^>]*>/, '').replace(/<\/script>/, '');
+ try {
+ const obj = JSON.parse(inner);
+ if (obj['@type'] === 'Product' && obj.offers?.price) {
+ const p = parseFloat(obj.offers.price);
+ if (!isNaN(p) && p > 10) return p; // sanity: RW prices are 58-120 range
+ }
+ } catch {}
+ }
+
+ // Fallback: any "price": "XX.X" in JSON-LD in the valid range
+ const allPrices = [];
+ const priceRe = /"price"\s*:\s*"?([\d.,]+)"?/g;
+ let m;
+ while ((m = priceRe.exec(html)) !== null) {
+ const val = parseFloat(m[1].replace(',', ''));
+ if (!isNaN(val) && val >= 40 && val <= 200) allPrices.push(val);
+ }
+ if (allPrices.length > 0) {
+ // Return the minimum (base material tier)
+ return Math.min(...allPrices);
+ }
+
+ return null;
+}
+
+/**
+ * Bucket a per-m2 price into C2/C3/C4
+ * C2 ~58.10, C3 ~76.40, C4 ~88.30
+ */
+function bucketPrice(pricePerM2) {
+ if (pricePerM2 === null || pricePerM2 === undefined) return null;
+ // Use midpoint thresholds
+ // C2 < 67.25 (midpoint of 58.10 and 76.40)
+ // C3 between 67.25 and 82.35 (midpoint of 76.40 and 88.30)
+ // C4 >= 82.35
+ if (pricePerM2 < 67.25) return 'C2';
+ if (pricePerM2 < 82.35) return 'C3';
+ return 'C4';
+}
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+
+async function writeMetafields(pid, group, currentGroup) {
+ const mfs = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ ];
+
+ if (group !== 'C3') {
+ const costPerM2 = group === 'C2' ? '33.84' : '51.44';
+ const materials = group === 'C2' ? C2_MATERIALS : C4_MATERIALS;
+ const costBasis = `${group} portal-RRP-derived; Non-Woven retail $${group === 'C2' ? '58.10' : '88.30'}/m2 (x0.5825 net); P&S/Commercial extrapolated`;
+ mfs.push(
+ { ownerId: pid, namespace: 'custom', key: 'cost_per_m2', value: costPerM2, type: 'number_decimal' },
+ { ownerId: pid, namespace: 'custom', key: 'material_options', value: JSON.stringify(materials), type: 'json' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', value: costBasis, type: 'single_line_text_field' },
+ );
+ }
+
+ const r = await gql({ query: MF_MUTATION, variables: { m: mfs } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ if (errs.length) {
+ throw new Error(`metafieldsSet errors: ${errs.map(e => e.message).join('; ')}`);
+ }
+ return mfs.length;
+}
+
+async function processProduct(pid) {
+ // 1. Fetch rw_product_url + price_group
+ const q = `{ product(id:"${pid}") {
+ title
+ metafields(first:50){edges{node{namespace key value}}}
+ }}`;
+ const fr = await gql({ query: q });
+ const p = fr?.data?.product;
+ if (!p) throw new Error(`product not found: ${pid}`);
+
+ const mfMap = {};
+ for (const e of (p.metafields?.edges || [])) {
+ const { namespace, key, value } = e.node;
+ if (namespace === 'custom') mfMap[key] = value;
+ }
+
+ const rwUrl = mfMap['rw_product_url'];
+ const currentGroup = mfMap['price_group'];
+
+ if (!rwUrl) {
+ return { status: 'skip', reason: 'no rw_product_url' };
+ }
+
+ // 2. Fetch RW product page
+ let html;
+ try {
+ const resp = await httpsGet(rwUrl);
+ if (resp.status !== 200) {
+ return { status: 'unknown', reason: `HTTP ${resp.status} from ${rwUrl}` };
+ }
+ html = resp.body;
+ } catch (e) {
+ return { status: 'unknown', reason: `fetch error: ${e.message}` };
+ }
+
+ // 3. Extract price
+ const priceM2 = extractRWPrice(html);
+ if (priceM2 === null) {
+ return { status: 'unknown', reason: `could not extract price from ${rwUrl}`, html_snippet: html.slice(0, 500) };
+ }
+
+ // 4. Bucket
+ const group = bucketPrice(priceM2);
+
+ // 5. Write metafields
+ await writeMetafields(pid, group, currentGroup);
+ await sleep(500);
+
+ return { status: 'ok', group, priceM2, previousGroup: currentGroup, title: p.title };
+}
+
+async function main() {
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0, skip: 0 };
+ const notes = [];
+ const results = [];
+
+ for (let i = 0; i < PRODUCT_IDS.length; i++) {
+ const pid = PRODUCT_IDS[i];
+ const short = pid.replace('gid://shopify/Product/', '');
+ try {
+ const res = await processProduct(pid);
+ results.push({ pid: short, ...res });
+ counts.processed++;
+
+ if (res.status === 'skip') {
+ counts.skip++;
+ console.log(`[${i+1}/40] ${short} SKIP: ${res.reason}`);
+ } else if (res.status === 'unknown') {
+ counts.unknown++;
+ console.log(`[${i+1}/40] ${short} UNKNOWN: ${res.reason}`);
+ } else {
+ if (res.group === 'C2') counts.c2++;
+ else if (res.group === 'C3') counts.c3++;
+ else if (res.group === 'C4') counts.c4++;
+ const changed = res.previousGroup !== res.group ? ` (was ${res.previousGroup})` : '';
+ console.log(`[${i+1}/40] ${short} OK: ${res.group}${changed} @ $${res.priceM2}/m2 — ${res.title}`);
+ }
+ } catch (e) {
+ counts.failed++;
+ results.push({ pid: short, status: 'failed', error: e.message });
+ console.error(`[${i+1}/40] ${short} FAILED: ${e.message}`);
+ notes.push(`${short}: ${e.message.slice(0, 80)}`);
+ }
+
+ // Rate limit: ~2 req/s for Shopify, plus RW fetch
+ if (i < PRODUCT_IDS.length - 1) await sleep(500);
+ }
+
+ const summary = {
+ processed: counts.processed,
+ c2: counts.c2,
+ c3: counts.c3,
+ c4: counts.c4,
+ unknown: counts.unknown,
+ failed: counts.failed,
+ skip: counts.skip,
+ notes: notes.slice(0, 10).join(' | ')
+ };
+ console.log('\n=== FINAL SUMMARY ===');
+ console.log(JSON.stringify(summary, null, 2));
+ return summary;
+}
+
+main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
diff --git a/rw-price-group-fix.js b/rw-price-group-fix.js
new file mode 100644
index 0000000..c443de7
--- /dev/null
+++ b/rw-price-group-fix.js
@@ -0,0 +1,302 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-fix.js
+ * Determine Rebel Walls mural price group (C2/C3/C4) from live per-m2 retail rate
+ * and correct cost metafields in Shopify sandbox.
+ */
+
+const https = require('https');
+const http = require('http');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
+
+// Price group buckets (per m2)
+// C2 ~58.10, C3 ~76.40, C4 ~88.30
+const PRICE_GROUPS = [
+ { name: 'C2', target: 58.10 },
+ { name: 'C3', target: 76.40 },
+ { name: 'C4', target: 88.30 },
+];
+
+// Pre-computed cost metafields per group
+const GROUP_DATA = {
+ C2: {
+ cost_per_m2: '33.84',
+ material_options: JSON.stringify([
+ { material: 'Non-Woven (Standard)', retail_per_m2: 58.10, cost_per_m2: 33.84, cost_confirmed: false },
+ { material: 'Peel & Stick', retail_per_m2: 69.70, cost_per_m2: 40.60, cost_confirmed: false },
+ { material: 'Commercial Grade', retail_per_m2: 81.36, cost_per_m2: 47.39, cost_confirmed: false },
+ ]),
+ cost_basis: 'C2 standard; portal-RRP-derived; P&S/Commercial extrapolated from RRP ratio',
+ },
+ C3: null, // leave as-is
+ C4: {
+ cost_per_m2: '51.44',
+ material_options: JSON.stringify([
+ { material: 'Non-Woven (Standard)', retail_per_m2: 88.30, cost_per_m2: 51.44, cost_confirmed: false },
+ { material: 'Peel & Stick', retail_per_m2: 106.00, cost_per_m2: 61.75, cost_confirmed: false },
+ { material: 'Commercial Grade', retail_per_m2: 123.66, cost_per_m2: 72.03, cost_confirmed: false },
+ ]),
+ cost_basis: 'C4 standard; portal-RRP-derived; P&S/Commercial extrapolated from RRP ratio',
+ },
+};
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// Shopify GraphQL
+function gqlRaw(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data),
+ },
+ }, res => {
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } });
+ });
+ req.on('error', reject);
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data); req.end();
+ });
+}
+
+async function gql(body, retries = 4) {
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
+ const result = await gqlRaw(body);
+ const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+ const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable || 9999) < 200;
+ if (throttled) { await sleep(3000); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+// Fetch a URL and return body text
+function fetchUrl(url) {
+ return new Promise((resolve, reject) => {
+ const urlObj = new URL(url);
+ const lib = urlObj.protocol === 'https:' ? https : http;
+ const req = lib.request({
+ hostname: urlObj.hostname,
+ path: urlObj.pathname + urlObj.search,
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ },
+ }, res => {
+ let c = '';
+ // Handle redirects
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ const redirectUrl = res.headers.location.startsWith('http')
+ ? res.headers.location
+ : `${urlObj.protocol}//${urlObj.hostname}${res.headers.location}`;
+ fetchUrl(redirectUrl).then(resolve).catch(reject);
+ return;
+ }
+ res.on('data', d => c += d);
+ res.on('end', () => resolve({ statusCode: res.statusCode, body: c }));
+ });
+ req.on('error', reject);
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error(`timeout fetching ${url}`)); });
+ req.end();
+ });
+}
+
+// Extract per-m2 price from RW product page
+// RW product pages embed the per-sqft price in JSON strings as: "$7.10 \\/ sq ft"
+// (double-encoded: backslash + forward slash). In the raw HTML this appears as
+// "$7.10 \\\\\\/ sq ft" (multiple backslash escapes). We match with [\\]*\/ to
+// handle any level of encoding.
+// JSON-LD "price":"N" is per-PANEL for large murals (e.g. $150), NOT per-m2.
+// Strategy: prefer sqft pattern (convert x10.7639); JSON-LD only if in 40-100 range.
+function extractPricePerM2(html) {
+ // Strategy 1 (PREFERRED): dollar amount followed by any backslashes then / then sq
+ // Catches: "$7.10 \/ sq", "$7.10 \\\/ sq", "$5.40 / sq ft", etc.
+ const re = /\$([\d.]+)\s*[\\]*\/\s*sq/gi;
+ for (const m of html.matchAll(re)) {
+ const v = parseFloat(m[1]);
+ // Sqft price for RW murals is ~3-15 range
+ if (v >= 3 && v <= 15) return v * 10.7639;
+ }
+
+ // Strategy 2: JSON-LD "price":"N","priceCurrency":"USD" — only if in per-m2 range (40-100)
+ // Values > 100 are per-panel prices for large murals (e.g. $150), not per-m2
+ const jsonLdM = html.match(/"price":\s*"([\d.]+)",\s*"priceCurrency":\s*"USD"/);
+ if (jsonLdM) {
+ const price = parseFloat(jsonLdM[1]);
+ if (price >= 40 && price <= 100) return price;
+ }
+
+ // Strategy 3: any "price" value in the 40-100 m2 range (fallback only)
+ for (const a of html.matchAll(/"price":\s*["']?([\d.]+)["']?/g)) {
+ const v = parseFloat(a[1]);
+ if (v >= 40 && v <= 100) return v;
+ }
+
+ // Strategy 4: explicit m2 patterns
+ const m2Patterns = [
+ /\$\s*([\d,]+\.?\d*)\s*\/\s*m[²2]/i,
+ /from\s+\$\s*([\d,]+\.?\d*)\s*\/\s*m[²2]/i,
+ ];
+ for (const pat of m2Patterns) {
+ const mm = html.match(pat);
+ if (mm) {
+ const price = parseFloat(mm[1].replace(',', ''));
+ if (price >= 40 && price <= 100) return price;
+ }
+ }
+
+ return null;
+}
+
+// Bucket price to group
+function bucketGroup(pricePerM2) {
+ if (!pricePerM2 || pricePerM2 <= 0) return null;
+ let best = null;
+ let bestDist = Infinity;
+ for (const g of PRICE_GROUPS) {
+ const dist = Math.abs(pricePerM2 - g.target);
+ if (dist < bestDist) { bestDist = dist; best = g.name; }
+ }
+ // Sanity check: if price is way off from all buckets (>25% from nearest), flag as unknown
+ const nearestTarget = PRICE_GROUPS.find(g => g.name === best)?.target || 76.40;
+ if (Math.abs(pricePerM2 - nearestTarget) / nearestTarget > 0.30) {
+ console.log(` WARNING: price ${pricePerM2.toFixed(2)}/m² is >30% from nearest bucket (${best}=${nearestTarget}); using unknown`);
+ return null;
+ }
+ return best;
+}
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+
+async function processProduct(pid) {
+ // 1. Fetch rw_product_url and existing price_group
+ const r = await gql({ query: `{ product(id:"${pid}") {
+ rw_product_url: metafield(namespace:"custom", key:"rw_product_url") { value }
+ pg: metafield(namespace:"custom", key:"price_group") { value }
+ }}` });
+
+ const prod = r?.data?.product;
+ if (!prod) throw new Error(`product not found`);
+
+ const rwUrl = prod.rw_product_url?.value;
+ const existingGroup = prod.pg?.value;
+
+ if (!rwUrl) {
+ console.log(` ${pid}: no rw_product_url -> skip (unknown)`);
+ return { result: 'unknown', group: null };
+ }
+
+ // 2. Fetch the RW product page
+ let pricePerM2 = null;
+ try {
+ const { statusCode, body } = await fetchUrl(rwUrl);
+ if (statusCode !== 200) {
+ console.log(` ${pid}: HTTP ${statusCode} fetching ${rwUrl} -> unknown`);
+ return { result: 'unknown', group: null };
+ }
+ pricePerM2 = extractPricePerM2(body);
+ if (pricePerM2) {
+ console.log(` ${pid}: fetched ${rwUrl} -> ${pricePerM2.toFixed(2)}/m²`);
+ } else {
+ console.log(` ${pid}: could not extract price from ${rwUrl}`);
+ // Debug: show first 2000 chars of relevant parts
+ const pricePart = body.match(/price[\s\S]{0,200}/i);
+ if (pricePart) console.log(` DEBUG price context: ${pricePart[0].slice(0, 200).replace(/\n/g, ' ')}`);
+ }
+ } catch (e) {
+ console.log(` ${pid}: fetch error ${e.message} -> unknown`);
+ return { result: 'unknown', group: null };
+ }
+
+ const group = bucketGroup(pricePerM2);
+ if (!group) {
+ console.log(` ${pid}: price unknown (extracted: ${pricePerM2?.toFixed(2) || 'null'}) -> keeping ${existingGroup || 'C3'}`);
+ return { result: 'unknown', group: null };
+ }
+
+ console.log(` ${pid}: group=${group} (price=${pricePerM2.toFixed(2)}/m², existing=${existingGroup || 'none'})`);
+
+ // 3. Write price_group metafield
+ const mfs = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ ];
+
+ // 4. If not C3, overwrite cost metafields
+ if (group !== 'C3') {
+ const gd = GROUP_DATA[group];
+ mfs.push(
+ { ownerId: pid, namespace: 'custom', key: 'cost_per_m2', value: gd.cost_per_m2, type: 'number_decimal' },
+ { ownerId: pid, namespace: 'custom', key: 'material_options', value: gd.material_options, type: 'json' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', value: gd.cost_basis, type: 'single_line_text_field' },
+ );
+ }
+
+ const wr = await gql({ query: MF_MUTATION, variables: { m: mfs } });
+ const errs = wr?.data?.metafieldsSet?.userErrors || [];
+ if (errs.length) {
+ throw new Error(`metafieldsSet errors: ${errs.map(e => e.message).join('; ')}`);
+ }
+ console.log(` ${pid}: wrote price_group=${group}${group !== 'C3' ? ` + cost metafields` : ''} OK`);
+
+ return { result: 'ok', group };
+}
+
+// Run 2: only the 5 products that were "unknown" in run 1 (all others already written)
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7851168006195', // alpaca-rebel-pink -> $150 per panel, sqft=$7.10 -> C3
+ 'gid://shopify/Product/7851168038963', // alpaca-rebel-rainbow
+ 'gid://shopify/Product/7851168071731', // alpaca-rebel-sky
+ 'gid://shopify/Product/7851168137267', // amazon-fern
+ 'gid://shopify/Product/7851168268339', // anglessy-plum
+];
+
+async function main() {
+ let processed = 0, c2 = 0, c3 = 0, c4 = 0, unknown = 0, failed = 0;
+ const notes = [];
+
+ for (const pid of PRODUCT_IDS) {
+ try {
+ const { result, group } = await processProduct(pid);
+ processed++;
+ if (result === 'unknown') {
+ unknown++;
+ } else if (group === 'C2') {
+ c2++;
+ } else if (group === 'C3') {
+ c3++;
+ } else if (group === 'C4') {
+ c4++;
+ }
+ } catch (e) {
+ failed++;
+ processed++;
+ console.log(` FAILED ${pid}: ${e.message}`);
+ notes.push(`FAIL ${pid}: ${e.message}`);
+ }
+ await sleep(500); // ~2 req/s
+ }
+
+ const summary = { processed, c2, c3, c4, unknown, failed, notes: notes.join(' | ') || 'none' };
+ console.log('\n=== FINAL RESULT ===');
+ console.log(JSON.stringify(summary, null, 2));
+ return summary;
+}
+
+main().catch(e => { console.error(`FATAL: ${e.message}`); process.exit(1); });
diff --git a/rw-price-group-offset520.js b/rw-price-group-offset520.js
new file mode 100644
index 0000000..8f4c62a
--- /dev/null
+++ b/rw-price-group-offset520.js
@@ -0,0 +1,403 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-offset520.js
+ * Determine Rebel Walls price group (C2/C3/C4) from live RW product page,
+ * then write price_group + cost metafields to sandbox Shopify.
+ * Slice: LIMIT 40 OFFSET 520
+ */
+
+const https = require('https');
+const http = require('http');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
+
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7855821652019',
+ 'gid://shopify/Product/7855821684787',
+ 'gid://shopify/Product/7855821717555',
+ 'gid://shopify/Product/7855821750323',
+ 'gid://shopify/Product/7855821783091',
+ 'gid://shopify/Product/7855821815859',
+ 'gid://shopify/Product/7855821848627',
+ 'gid://shopify/Product/7855821881395',
+ 'gid://shopify/Product/7855821914163',
+ 'gid://shopify/Product/7855821946931',
+ 'gid://shopify/Product/7855821979699',
+ 'gid://shopify/Product/7855822012467',
+ 'gid://shopify/Product/7855822045235',
+ 'gid://shopify/Product/7855822078003',
+ 'gid://shopify/Product/7855822110771',
+ 'gid://shopify/Product/7855822143539',
+ 'gid://shopify/Product/7855822176307',
+ 'gid://shopify/Product/7855822209075',
+ 'gid://shopify/Product/7855822241843',
+ 'gid://shopify/Product/7855822274611',
+ 'gid://shopify/Product/7855822307379',
+ 'gid://shopify/Product/7855822340147',
+ 'gid://shopify/Product/7855822405683',
+ 'gid://shopify/Product/7855822438451',
+ 'gid://shopify/Product/7855822471219',
+ 'gid://shopify/Product/7855822503987',
+ 'gid://shopify/Product/7855822536755',
+ 'gid://shopify/Product/7855822569523',
+ 'gid://shopify/Product/7855822602291',
+ 'gid://shopify/Product/7855822635059',
+ 'gid://shopify/Product/7855822667827',
+ 'gid://shopify/Product/7855822700595',
+ 'gid://shopify/Product/7855822766131',
+ 'gid://shopify/Product/7855822798899',
+ 'gid://shopify/Product/7855822864435',
+ 'gid://shopify/Product/7855822929971',
+ 'gid://shopify/Product/7855822962739',
+ 'gid://shopify/Product/7855822995507',
+ 'gid://shopify/Product/7855823028275',
+ 'gid://shopify/Product/7855823061043',
+];
+
+// Price group data
+const PRICE_GROUPS = {
+ C2: {
+ cost_per_m2: '33.84',
+ material_options: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":58.10,"cost_per_m2":33.84,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":69.70,"cost_per_m2":40.60,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":81.36,"cost_per_m2":47.39,"cost_confirmed":false}
+ ]),
+ cost_basis: 'C2 group; portal-RRP-derived; P&S/Commercial extrapolated'
+ },
+ C3: null, // leave as-is
+ C4: {
+ cost_per_m2: '51.44',
+ material_options: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":88.30,"cost_per_m2":51.44,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":106.00,"cost_per_m2":61.75,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":123.66,"cost_per_m2":72.03,"cost_confirmed":false}
+ ]),
+ cost_basis: 'C4 group; portal-RRP-derived; P&S/Commercial extrapolated'
+ }
+};
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function gql(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data)
+ }
+ }, res => {
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } });
+ });
+ req.on('error', reject);
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data); req.end();
+ });
+}
+
+function fetchUrl(url, redirectCount = 0) {
+ return new Promise((resolve, reject) => {
+ if (redirectCount > 5) return reject(new Error('too many redirects'));
+ const parsed = new URL(url);
+ const lib = parsed.protocol === 'https:' ? https : http;
+ const req = lib.request({
+ hostname: parsed.hostname,
+ path: parsed.pathname + parsed.search,
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ }
+ }, res => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ const redir = res.headers.location.startsWith('http')
+ ? res.headers.location
+ : `${parsed.protocol}//${parsed.hostname}${res.headers.location}`;
+ res.resume();
+ return resolve(fetchUrl(redir, redirectCount + 1));
+ }
+ let body = '';
+ res.on('data', d => body += d);
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('error', reject);
+ req.setTimeout(20000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.end();
+ });
+}
+
+function extractPricePerM2(html, url) {
+ // Try JSON-LD first
+ const jsonLdMatches = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi) || [];
+ for (const block of jsonLdMatches) {
+ const inner = block.replace(/<script[^>]*>/i, '').replace(/<\/script>/i, '').trim();
+ try {
+ const obj = JSON.parse(inner);
+ const offers = obj.offers || (obj['@graph'] && obj['@graph'].find(o => o.offers))?.offers;
+ if (offers) {
+ const price = offers.price || offers.lowPrice;
+ if (price) {
+ // Check if it has unit info
+ const pricePer = offers.priceCurrency === 'USD' ? parseFloat(price) : null;
+ if (pricePer && pricePer > 0) {
+ // JSON-LD price might be per roll, per panel, or per sqft/m2
+ // We'll check if this looks like a per-sqft rate
+ if (pricePer >= 4 && pricePer <= 15) {
+ // Likely per sqft, convert to m2
+ return pricePer * 10.7639;
+ }
+ if (pricePer >= 40 && pricePer <= 150) {
+ // Likely per m2
+ return pricePer;
+ }
+ }
+ }
+ }
+ } catch {}
+ }
+
+ // Try to find price in HTML text patterns
+ // RW shows "From $X.XX / sq ft" or "From $X.XX / m²"
+
+ // Pattern: per sq ft
+ const sqftPatterns = [
+ /\$\s*([\d]+\.[\d]{2})\s*\/\s*sq\.?\s*ft/gi,
+ /from\s+\$\s*([\d]+\.[\d]{2})\s*\/\s*sq/gi,
+ /([\d]+\.[\d]{2})\s*USD\s*\/\s*sq\.?\s*ft/gi,
+ /price[^>]*>\s*\$\s*([\d]+\.[\d]{2})\s*<[^>]*>\s*\/\s*sq/gi,
+ ];
+
+ for (const pat of sqftPatterns) {
+ const m = pat.exec(html);
+ if (m) {
+ const sqftPrice = parseFloat(m[1]);
+ if (sqftPrice >= 3 && sqftPrice <= 20) {
+ return sqftPrice * 10.7639;
+ }
+ }
+ }
+
+ // Pattern: per m²
+ const m2Patterns = [
+ /\$\s*([\d]+\.[\d]{2})\s*\/\s*m[²2]/gi,
+ /from\s+\$\s*([\d]+\.[\d]{2})\s*\/\s*m[²2]/gi,
+ /([\d]+\.[\d]{2})\s*USD\s*\/\s*m[²2]/gi,
+ ];
+
+ for (const pat of m2Patterns) {
+ const m = pat.exec(html);
+ if (m) {
+ const m2Price = parseFloat(m[1]);
+ if (m2Price >= 40 && m2Price <= 200) {
+ return m2Price;
+ }
+ }
+ }
+
+ // Try to find any price near "From" keyword
+ // Look for price chips in the page
+ const fromPrices = [];
+ const fromPatterns = [
+ /from[\s\S]{0,50}\$([\d]+\.[\d]{2})/gi,
+ /\$([\d]+\.[\d]{2})[\s\S]{0,30}\/\s*(sq\.?\s*ft|m[²2])/gi,
+ /"price"\s*:\s*"([\d]+\.[\d]{2})"/gi,
+ /"lowPrice"\s*:\s*"([\d]+\.[\d]{2})"/gi,
+ ];
+
+ for (const pat of fromPatterns) {
+ let m;
+ pat.lastIndex = 0;
+ while ((m = pat.exec(html)) !== null) {
+ const v = parseFloat(m[1]);
+ fromPrices.push(v);
+ }
+ }
+
+ // Filter to reasonable per-sqft range and take minimum (base price)
+ const sqftCandidates = fromPrices.filter(v => v >= 4 && v <= 20);
+ if (sqftCandidates.length > 0) {
+ const minSqft = Math.min(...sqftCandidates);
+ return minSqft * 10.7639;
+ }
+
+ // Filter to reasonable per-m2 range
+ const m2Candidates = fromPrices.filter(v => v >= 40 && v <= 200);
+ if (m2Candidates.length > 0) {
+ return Math.min(...m2Candidates);
+ }
+
+ return null;
+}
+
+function bucketPriceGroup(pricePerM2) {
+ if (pricePerM2 === null) return null;
+ // C2: ~58.10, C3: ~76.40, C4: ~88.30
+ // Midpoints: C2/C3 = 67.25, C3/C4 = 82.35
+ if (pricePerM2 <= 67.25) return 'C2';
+ if (pricePerM2 <= 82.35) return 'C3';
+ return 'C4';
+}
+
+async function fetchMetafields(productId) {
+ const res = await gql({
+ query: `query GetMeta($id: ID!) {
+ product(id: $id) {
+ rw_url: metafield(namespace: "custom", key: "rw_product_url") { value }
+ price_group: metafield(namespace: "custom", key: "price_group") { value }
+ }
+ }`,
+ variables: { id: productId }
+ });
+
+ if (res.errors || !res.data || !res.data.product) {
+ throw new Error(`GQL fetch failed: ${JSON.stringify(res.errors || res)}`);
+ }
+
+ return {
+ rw_url: res.data.product.rw_url?.value || null,
+ price_group: res.data.product.price_group?.value || null,
+ };
+}
+
+async function writeMetafields(productId, group) {
+ const pg = PRICE_GROUPS[group];
+ if (!pg) return; // C3, skip writes
+
+ const muts = [
+ { ownerId: productId, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ { ownerId: productId, namespace: 'custom', key: 'cost_per_m2', value: pg.cost_per_m2, type: 'number_decimal' },
+ { ownerId: productId, namespace: 'custom', key: 'material_options', value: pg.material_options, type: 'json' },
+ { ownerId: productId, namespace: 'custom', key: 'cost_basis', value: pg.cost_basis, type: 'multi_line_text_field' },
+ ];
+
+ const res = await gql({
+ query: `mutation SetMeta($metas: [MetafieldsSetInput!]!) {
+ metafieldsSet(metafields: $metas) {
+ metafields { key value }
+ userErrors { field message }
+ }
+ }`,
+ variables: { metas: muts }
+ });
+
+ const errs = res.data?.metafieldsSet?.userErrors || [];
+ if (errs.length > 0) throw new Error(`userErrors: ${JSON.stringify(errs)}`);
+ if (res.errors) throw new Error(`GQL errors: ${JSON.stringify(res.errors)}`);
+
+ return res.data?.metafieldsSet?.metafields?.length || 0;
+}
+
+async function writePriceGroupOnly(productId, group) {
+ // For C3 or when we only need to set price_group
+ const res = await gql({
+ query: `mutation SetMeta($metas: [MetafieldsSetInput!]!) {
+ metafieldsSet(metafields: $metas) {
+ metafields { key value }
+ userErrors { field message }
+ }
+ }`,
+ variables: {
+ metas: [{ ownerId: productId, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' }]
+ }
+ });
+
+ const errs = res.data?.metafieldsSet?.userErrors || [];
+ if (errs.length > 0) throw new Error(`userErrors: ${JSON.stringify(errs)}`);
+}
+
+async function processProduct(productId, idx) {
+ const shortId = productId.replace('gid://shopify/Product/', '');
+
+ try {
+ // Step 1: fetch existing metafields
+ const meta = await fetchMetafields(productId);
+
+ if (!meta.rw_url) {
+ console.log(`[${idx}] ${shortId} — no rw_product_url → SKIP`);
+ return { result: 'skip_no_url' };
+ }
+
+ // Step 2: fetch the RW product page
+ let html;
+ try {
+ const resp = await fetchUrl(meta.rw_url);
+ if (resp.status !== 200) {
+ console.log(`[${idx}] ${shortId} — HTTP ${resp.status} for ${meta.rw_url} → unknown`);
+ return { result: 'unknown', reason: `HTTP ${resp.status}` };
+ }
+ html = resp.body;
+ } catch (e) {
+ console.log(`[${idx}] ${shortId} — fetch error: ${e.message} → unknown`);
+ return { result: 'unknown', reason: e.message };
+ }
+
+ // Step 3: extract price
+ const pricePerM2 = extractPricePerM2(html, meta.rw_url);
+ const group = bucketPriceGroup(pricePerM2);
+
+ if (!group) {
+ console.log(`[${idx}] ${shortId} — could not extract price from ${meta.rw_url} → unknown (existing pg: ${meta.price_group || 'none'})`);
+ return { result: 'unknown', reason: 'no price extracted' };
+ }
+
+ console.log(`[${idx}] ${shortId} — $${pricePerM2?.toFixed(2)}/m2 → ${group} (was: ${meta.price_group || 'none'})`);
+
+ // Step 4: write metafields
+ if (group !== 'C3') {
+ await writeMetafields(productId, group);
+ } else {
+ // Write price_group=C3 but leave cost fields as-is
+ await writePriceGroupOnly(productId, 'C3');
+ }
+
+ return { result: 'ok', group };
+
+ } catch (e) {
+ console.error(`[${idx}] ${shortId} — FAILED: ${e.message}`);
+ return { result: 'failed', reason: e.message };
+ }
+}
+
+async function main() {
+ const stats = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0, skip: 0 };
+ const notes = [];
+
+ for (let i = 0; i < PRODUCT_IDS.length; i++) {
+ const pid = PRODUCT_IDS[i];
+ const res = await processProduct(pid, i + 1);
+
+ stats.processed++;
+
+ if (res.result === 'ok') {
+ if (res.group === 'C2') stats.c2++;
+ else if (res.group === 'C3') stats.c3++;
+ else if (res.group === 'C4') stats.c4++;
+ } else if (res.result === 'skip_no_url') {
+ stats.skip++;
+ } else if (res.result === 'unknown') {
+ stats.unknown++;
+ notes.push(`unknown: ${pid.slice(-13)}: ${res.reason}`);
+ } else if (res.result === 'failed') {
+ stats.failed++;
+ notes.push(`failed: ${pid.slice(-13)}: ${res.reason}`);
+ }
+
+ // ~2 req/s (fetch + write = ~2 calls per product, so 500ms gap)
+ if (i < PRODUCT_IDS.length - 1) await sleep(500);
+ }
+
+ console.log('\n=== FINAL RESULTS ===');
+ console.log(JSON.stringify({ ...stats, notes: notes.slice(0, 10) }, null, 2));
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
diff --git a/rw-price-group-slice5.js b/rw-price-group-slice5.js
new file mode 100644
index 0000000..ec022ec
--- /dev/null
+++ b/rw-price-group-slice5.js
@@ -0,0 +1,413 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-slice5.js
+ * Slice: OFFSET 200, LIMIT 40 — Rebel Walls price-group bucketing
+ * Reads rw_product_url metafield, fetches RW page, extracts base per-m2 price,
+ * buckets C2/C3/C4, writes price_group + cost metafields to sandbox.
+ */
+
+const https = require('https');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
+
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7851171479603',
+ 'gid://shopify/Product/7851171512371',
+ 'gid://shopify/Product/7851171545139',
+ 'gid://shopify/Product/7851171643443',
+ 'gid://shopify/Product/7851171577907',
+ 'gid://shopify/Product/7851171610675',
+ 'gid://shopify/Product/7851171676211',
+ 'gid://shopify/Product/7851171708979',
+ 'gid://shopify/Product/7851171741747',
+ 'gid://shopify/Product/7851171774515',
+ 'gid://shopify/Product/7851171807283',
+ 'gid://shopify/Product/7851171840051',
+ 'gid://shopify/Product/7851171872819',
+ 'gid://shopify/Product/7851171905587',
+ 'gid://shopify/Product/7851171938355',
+ 'gid://shopify/Product/7851171971123',
+ 'gid://shopify/Product/7851172003891',
+ 'gid://shopify/Product/7851172036659',
+ 'gid://shopify/Product/7851172069427',
+ 'gid://shopify/Product/7851172102195',
+ 'gid://shopify/Product/7851172134963',
+ 'gid://shopify/Product/7851172167731',
+ 'gid://shopify/Product/7851172200499',
+ 'gid://shopify/Product/7851172233267',
+ 'gid://shopify/Product/7851172266035',
+ 'gid://shopify/Product/7851172298803',
+ 'gid://shopify/Product/7851172331571',
+ 'gid://shopify/Product/7851172364339',
+ 'gid://shopify/Product/7851172397107',
+ 'gid://shopify/Product/7851172429875',
+ 'gid://shopify/Product/7851172462643',
+ 'gid://shopify/Product/7851172495411',
+ 'gid://shopify/Product/7851172528179',
+ 'gid://shopify/Product/7851172560947',
+ 'gid://shopify/Product/7851172593715',
+ 'gid://shopify/Product/7851172626483',
+ 'gid://shopify/Product/7851172659251',
+ 'gid://shopify/Product/7851172724787',
+ 'gid://shopify/Product/7851172757555',
+ 'gid://shopify/Product/7851172790323',
+];
+
+// Pre-computed material_options per group
+const MATERIAL_OPTIONS = {
+ C2: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":58.10,"cost_per_m2":33.84,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":69.70,"cost_per_m2":40.60,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":81.36,"cost_per_m2":47.39,"cost_confirmed":false}
+ ]),
+ C3: null, // don't rewrite
+ C4: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":88.30,"cost_per_m2":51.44,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":106.00,"cost_per_m2":61.75,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":123.66,"cost_per_m2":72.03,"cost_confirmed":false}
+ ]),
+};
+
+const COST_PER_M2 = { C2: '33.84', C3: '44.50', C4: '51.44' };
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function gqlRaw(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data),
+ },
+ }, res => {
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => {
+ try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); }
+ });
+ });
+ req.on('error', reject);
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data); req.end();
+ });
+}
+
+async function gql(body, retries = 4) {
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
+ const result = await gqlRaw(body);
+ const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+ const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable ?? 9999) < 200;
+ if (throttled) { await sleep(3000); continue; }
+ if (lowBudget) await sleep(1200);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+function fetchUrl(urlStr, retries = 3) {
+ return new Promise((resolve, reject) => {
+ const tryFetch = (url, attempt) => {
+ try {
+ const u = new URL(url);
+ const mod = u.protocol === 'https:' ? https : require('http');
+ const req = mod.request({
+ hostname: u.hostname,
+ path: u.pathname + u.search,
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ },
+ }, res => {
+ // Follow redirects
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ const loc = res.headers.location;
+ const nextUrl = loc.startsWith('http') ? loc : `${u.protocol}//${u.hostname}${loc}`;
+ res.destroy();
+ if (attempt < retries + 3) tryFetch(nextUrl, attempt + 1);
+ else reject(new Error(`Too many redirects from ${url}`));
+ return;
+ }
+ let body = '';
+ res.on('data', d => body += d);
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('error', e => {
+ if (attempt < retries) { sleep(attempt * 1000).then(() => tryFetch(url, attempt + 1)); }
+ else reject(e);
+ });
+ req.setTimeout(20000, () => { req.destroy(); reject(new Error(`fetch timeout: ${url}`)); });
+ req.end();
+ } catch (e) { reject(e); }
+ };
+ tryFetch(urlStr, 1);
+ });
+}
+
+/**
+ * Extract the base (cheapest) per-m2 price from a Rebel Walls product page.
+ * RW shows prices in USD. The base Non-Woven price is what we bucket on.
+ * Returns a number (USD per m2) or null if not found.
+ */
+function extractPricePerM2(html, url) {
+ // Strategy 1: JSON-LD structured data
+ const jsonLdMatches = html.match(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi) || [];
+ for (const block of jsonLdMatches) {
+ const inner = block.replace(/<script[^>]*>/i, '').replace(/<\/script>/i, '');
+ try {
+ const parsed = JSON.parse(inner);
+ const items = Array.isArray(parsed) ? parsed : [parsed];
+ for (const item of items) {
+ const offers = item.offers || (item['@graph'] || []).flatMap(n => n.offers || []);
+ const offerArr = Array.isArray(offers) ? offers : [offers];
+ for (const offer of offerArr) {
+ if (offer && offer.price) {
+ const p = parseFloat(String(offer.price).replace(/[^0-9.]/g, ''));
+ if (!isNaN(p) && p > 0) {
+ // Determine if it's per sqft or per m2
+ const unit = String(offer.unitCode || offer.unitText || '').toLowerCase();
+ if (unit.includes('sqft') || unit.includes('sq ft') || unit.includes('ft')) {
+ return Math.round(p * 10.7639 * 100) / 100;
+ }
+ // Assume per m2 if in the range we expect (50-200)
+ if (p >= 30 && p <= 300) return p;
+ }
+ }
+ }
+ }
+ } catch { /* skip */ }
+ }
+
+ // Strategy 2: Look for "From $X / sq ft" or "From $X/m²" patterns
+ // RW US site shows prices in USD per sq ft
+ const sqftPatterns = [
+ /[Ff]rom\s*\$\s*([\d,.]+)\s*\/\s*sq\.?\s*ft/i,
+ /\$\s*([\d,.]+)\s*\/\s*sq\.?\s*ft/i,
+ /[Ff]rom\s*\$\s*([\d,.]+)\s*per\s*sq\.?\s*ft/i,
+ /price[^<]*\$\s*([\d,.]+)\s*\/\s*sq/i,
+ ];
+ for (const pat of sqftPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1].replace(/,/g, ''));
+ if (!isNaN(p) && p > 0) {
+ return Math.round(p * 10.7639 * 100) / 100;
+ }
+ }
+ }
+
+ // Strategy 3: per-m2 patterns
+ const m2Patterns = [
+ /[Ff]rom\s*\$\s*([\d,.]+)\s*\/\s*m[²2]/i,
+ /\$\s*([\d,.]+)\s*\/\s*m[²2]/i,
+ /[Ff]rom\s*\$\s*([\d,.]+)\s*per\s*m[²2]/i,
+ ];
+ for (const pat of m2Patterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1].replace(/,/g, ''));
+ if (!isNaN(p) && p > 0) return p;
+ }
+ }
+
+ // Strategy 4: Look for price in page data / React state / window.__INITIAL_STATE__
+ // RW often embeds pricing in a script tag
+ const priceInScript = html.match(/['"](price|basePrice|pricePerSqFt|pricePerM2)['"]\s*:\s*["']?([\d.]+)/gi) || [];
+ for (const match of priceInScript) {
+ const numM = match.match(/([\d.]+)$/);
+ if (numM) {
+ const p = parseFloat(numM[1]);
+ if (!isNaN(p) && p > 0 && p < 1000) {
+ // Heuristic: values < 30 are likely per sqft
+ if (p < 30) return Math.round(p * 10.7639 * 100) / 100;
+ if (p >= 30 && p <= 300) return p;
+ }
+ }
+ }
+
+ // Strategy 5: Look for any dollar amount near "sqft" text within 100 chars
+ const sqftNearby = [...html.matchAll(/\$([\d,.]+)[^<]{0,60}sq\s*ft/gi)];
+ for (const m of sqftNearby) {
+ const p = parseFloat(m[1].replace(/,/g, ''));
+ if (!isNaN(p) && p > 0 && p < 50) {
+ return Math.round(p * 10.7639 * 100) / 100;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Bucket a USD/m2 price into C2/C3/C4.
+ * Thresholds: C2 ~$58.10, C3 ~$76.40, C4 ~$88.30
+ * Use midpoints between groups: <67.25 => C2, 67.25-82.35 => C3, >82.35 => C4
+ */
+function bucketPrice(pricePerM2) {
+ if (pricePerM2 === null) return null;
+ if (pricePerM2 < 67.25) return 'C2';
+ if (pricePerM2 < 82.35) return 'C3';
+ return 'C4';
+}
+
+async function fetchProductMetafields(productId) {
+ const q = {
+ query: `query($id: ID!) {
+ product(id: $id) {
+ title
+ url: metafield(namespace: "custom", key: "rw_product_url") { value }
+ pg: metafield(namespace: "custom", key: "price_group") { value }
+ }
+ }`,
+ variables: { id: productId },
+ };
+ const res = await gql(q);
+ const p = res?.data?.product;
+ if (!p) return null;
+ return {
+ title: p.title,
+ url: p.url?.value || null,
+ price_group: p.pg?.value || null,
+ };
+}
+
+async function writeMetafields(productId, group) {
+ // Build metafield array
+ const metafields = [
+ { ownerId: productId, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ { ownerId: productId, namespace: 'custom', key: 'cost_basis', value: `${group} — portal-RRP-derived, P&S/Commercial extrapolated`, type: 'single_line_text_field' },
+ ];
+
+ if (group !== 'C3') {
+ metafields.push({
+ ownerId: productId, namespace: 'custom', key: 'cost_per_m2',
+ value: COST_PER_M2[group], type: 'number_decimal',
+ });
+ metafields.push({
+ ownerId: productId, namespace: 'custom', key: 'material_options',
+ value: MATERIAL_OPTIONS[group], type: 'json',
+ });
+ }
+
+ const mutation = {
+ query: `mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
+ metafieldsSet(metafields: $metafields) {
+ metafields { key value }
+ userErrors { field message }
+ }
+ }`,
+ variables: { metafields },
+ };
+
+ const res = await gql(mutation);
+ const userErrors = res?.data?.metafieldsSet?.userErrors || [];
+ if (res?.errors?.length) throw new Error(JSON.stringify(res.errors));
+ if (userErrors.length) throw new Error(JSON.stringify(userErrors));
+ return res?.data?.metafieldsSet?.metafields?.length || 0;
+}
+
+async function processProduct(productId, idx, total) {
+ const shortId = productId.split('/').pop();
+ const prefix = `[${idx+1}/${total} id:${shortId}]`;
+
+ try {
+ // Step 1: fetch metafields
+ const meta = await fetchProductMetafields(productId);
+ if (!meta) {
+ console.log(`${prefix} SKIP: product not found`);
+ return { result: 'skip', group: null };
+ }
+
+ if (!meta.url) {
+ console.log(`${prefix} SKIP: no rw_product_url — "${meta.title}"`);
+ return { result: 'skip_no_url', group: null };
+ }
+
+ console.log(`${prefix} url=${meta.url} existing_pg=${meta.price_group || 'none'}`);
+
+ // Step 2: fetch RW page
+ let html;
+ try {
+ const resp = await fetchUrl(meta.url);
+ if (resp.status !== 200) {
+ console.log(`${prefix} WARN: HTTP ${resp.status} from ${meta.url}`);
+ if (resp.status === 404 || resp.status === 410) {
+ return { result: 'unknown', group: null };
+ }
+ }
+ html = resp.body;
+ } catch (e) {
+ console.log(`${prefix} WARN: fetch failed: ${e.message}`);
+ return { result: 'unknown', group: null };
+ }
+
+ // Step 3: extract price
+ const priceM2 = extractPricePerM2(html, meta.url);
+ console.log(`${prefix} extracted price/m2=${priceM2}`);
+
+ const group = bucketPrice(priceM2);
+ if (!group) {
+ console.log(`${prefix} UNKNOWN: could not extract price from ${meta.url}`);
+ return { result: 'unknown', group: null };
+ }
+
+ console.log(`${prefix} bucket=${group}`);
+
+ // Step 4: write metafields
+ await writeMetafields(productId, group);
+ console.log(`${prefix} WRITTEN group=${group}`);
+
+ return { result: 'ok', group };
+ } catch (e) {
+ console.error(`${prefix} FAILED: ${e.message}`);
+ return { result: 'failed', group: null };
+ }
+}
+
+async function main() {
+ console.log(`Starting Rebel Walls price-group bucketing — slice 5 (offset 200, limit 40)`);
+ console.log(`Products: ${PRODUCT_IDS.length}`);
+
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0, skip: 0 };
+
+ for (let i = 0; i < PRODUCT_IDS.length; i++) {
+ const res = await processProduct(PRODUCT_IDS[i], i, PRODUCT_IDS.length);
+ counts.processed++;
+ if (res.result === 'ok') {
+ if (res.group === 'C2') counts.c2++;
+ else if (res.group === 'C3') counts.c3++;
+ else if (res.group === 'C4') counts.c4++;
+ } else if (res.result === 'unknown') {
+ counts.unknown++;
+ } else if (res.result === 'failed') {
+ counts.failed++;
+ } else {
+ counts.skip++;
+ counts.processed--; // don't count skips in processed
+ }
+ // ~2 req/s pacing
+ if (i < PRODUCT_IDS.length - 1) await sleep(500);
+ }
+
+ console.log('\n=== FINAL RESULTS ===');
+ console.log(JSON.stringify(counts, null, 2));
+ return counts;
+}
+
+main().then(counts => {
+ process.exit(0);
+}).catch(e => {
+ console.error('FATAL:', e);
+ process.exit(1);
+});
diff --git a/rw-price-group.js b/rw-price-group.js
new file mode 100644
index 0000000..f6f7265
--- /dev/null
+++ b/rw-price-group.js
@@ -0,0 +1,363 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group.js — Determine Rebel Walls price group (C2/C3/C4) from live RW product page
+ * and update cost metafields on Shopify sandbox store.
+ *
+ * Buckets:
+ * ~58.10/m2 => C2 (Non-Woven Standard cheaper tier)
+ * ~76.40/m2 => C3 (baseline default)
+ * ~88.30/m2 => C4 (premium)
+ *
+ * Reads SHOPIFY_ADMIN_TOKEN from env.
+ * REPLACED by new implementation below.
+ * SANDBOX only. ~2 req/s.
+ */
+
+const https = require('https');
+const http = require('http');
+const { URL } = require('url');
+
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_PRODUCT_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
+
+// Product IDs from query (OFFSET 320 LIMIT 40)
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7851176132659',
+ 'gid://shopify/Product/7851176165427',
+ 'gid://shopify/Product/7851176198195',
+ 'gid://shopify/Product/7851176263731',
+ 'gid://shopify/Product/7851176296499',
+ 'gid://shopify/Product/7851176362035',
+ 'gid://shopify/Product/7851176394803',
+ 'gid://shopify/Product/7851176427571',
+ 'gid://shopify/Product/7851176460339',
+ 'gid://shopify/Product/7851176525875',
+ 'gid://shopify/Product/7851176493107',
+ 'gid://shopify/Product/7851176558643',
+ 'gid://shopify/Product/7851176624179',
+ 'gid://shopify/Product/7851176591411',
+ 'gid://shopify/Product/7851176722483',
+ 'gid://shopify/Product/7851176755251',
+ 'gid://shopify/Product/7851176853555',
+ 'gid://shopify/Product/7851177082931',
+ 'gid://shopify/Product/7851177115699',
+ 'gid://shopify/Product/7851176689715',
+ 'gid://shopify/Product/7851176788019',
+ 'gid://shopify/Product/7851176820787',
+ 'gid://shopify/Product/7851176886323',
+ 'gid://shopify/Product/7851176919091',
+ 'gid://shopify/Product/7851176951859',
+ 'gid://shopify/Product/7851176984627',
+ 'gid://shopify/Product/7851177017395',
+ 'gid://shopify/Product/7851177050163',
+ 'gid://shopify/Product/7851177148467',
+ 'gid://shopify/Product/7851177181235',
+ 'gid://shopify/Product/7851177214003',
+ 'gid://shopify/Product/7851177246771',
+ 'gid://shopify/Product/7851177279539',
+ 'gid://shopify/Product/7851177312307',
+ 'gid://shopify/Product/7851177345075',
+ 'gid://shopify/Product/7851177377843',
+ 'gid://shopify/Product/7851177410611',
+ 'gid://shopify/Product/7851177443379',
+ 'gid://shopify/Product/7851177476147',
+ 'gid://shopify/Product/7851177508915',
+];
+
+// Per-group material_options pre-computed
+const MATERIAL_OPTIONS = {
+ C2: [
+ {"material":"Non-Woven (Standard)","retail_per_m2":58.10,"cost_per_m2":33.84,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":69.70,"cost_per_m2":40.60,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":81.36,"cost_per_m2":47.39,"cost_confirmed":false}
+ ],
+ C3: [
+ {"material":"Non-Woven (Standard)","retail_per_m2":76.40,"cost_per_m2":44.50,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":91.68,"cost_per_m2":53.40,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":107.00,"cost_per_m2":62.33,"cost_confirmed":false}
+ ],
+ C4: [
+ {"material":"Non-Woven (Standard)","retail_per_m2":88.30,"cost_per_m2":51.44,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":106.00,"cost_per_m2":61.75,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":123.66,"cost_per_m2":72.03,"cost_confirmed":false}
+ ]
+};
+
+const GROUP_BASE_RETAIL = { C2: 33.84, C3: 44.50, C4: 51.44 }; // cost_per_m2
+const GROUP_RETAIL = { C2: 58.10, C3: 76.40, C4: 88.30 };
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// ---------- Shopify GraphQL ----------
+function gqlRaw(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data)
+ },
+ }, res => {
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } });
+ });
+ req.on('error', reject);
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data); req.end();
+ });
+}
+
+async function gql(body, retries = 4) {
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
+ const result = await gqlRaw(body);
+ const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+ const avail = result?.extensions?.cost?.throttleStatus?.currentlyAvailable;
+ if (throttled) { await sleep(3000); continue; }
+ if (avail != null && avail < 200) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+// ---------- HTTP fetch with redirect following ----------
+function fetchUrl(rawUrl, redirectCount = 0) {
+ return new Promise((resolve, reject) => {
+ if (redirectCount > 5) return reject(new Error('too many redirects'));
+ let parsed;
+ try { parsed = new URL(rawUrl); } catch { return reject(new Error(`bad URL: ${rawUrl}`)); }
+ const mod = parsed.protocol === 'https:' ? https : http;
+ const req = mod.request({
+ hostname: parsed.hostname,
+ path: parsed.pathname + (parsed.search || ''),
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120',
+ 'Accept': 'text/html,application/xhtml+xml,*/*',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ }
+ }, res => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ const next = res.headers.location.startsWith('http')
+ ? res.headers.location
+ : `${parsed.protocol}//${parsed.hostname}${res.headers.location}`;
+ res.resume();
+ return resolve(fetchUrl(next, redirectCount + 1));
+ }
+ let body = '';
+ res.on('data', d => body += d);
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('error', reject);
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.end();
+ });
+}
+
+// ---------- Price extraction from RW page ----------
+// RW shows prices like "From $5.40 / sq ft" or "From $58 / m²"
+// Also check JSON-LD for offers
+function extractPricePerM2(html) {
+ // Try JSON-LD first
+ const jldMatches = html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi) || [];
+ for (const block of jldMatches) {
+ const inner = block.replace(/<script[^>]*>/, '').replace(/<\/script>/, '');
+ try {
+ const data = JSON.parse(inner);
+ const offers = Array.isArray(data) ? data.flatMap(d => d.offers || []) : (data.offers || []);
+ const arr = Array.isArray(offers) ? offers : [offers];
+ for (const o of arr) {
+ const price = parseFloat(o.price || o.lowPrice || 0);
+ if (price > 0) {
+ // Check if unit is sq ft or m2
+ const unit = (o.unitCode || o.description || JSON.stringify(o)).toLowerCase();
+ if (unit.includes('sqft') || unit.includes('sq ft') || unit.includes('square foot') || unit.includes('square feet') || unit.includes('sq_ft')) {
+ return price * 10.7639; // convert to m2
+ }
+ // assume m2 if price is in range 50-200
+ if (price >= 40 && price <= 250) return price;
+ }
+ }
+ } catch {}
+ }
+
+ // Pattern: "From $X / sq ft" or "From $X/sq ft"
+ const sqftMatch = html.match(/[Ff]rom\s+\$?([\d,.]+)\s*\/\s*sq\.?\s*ft/i);
+ if (sqftMatch) {
+ const val = parseFloat(sqftMatch[1].replace(/,/g, ''));
+ if (val > 0) return val * 10.7639;
+ }
+
+ // Pattern: "From $X / m²" or "From $X/m2"
+ const m2Match = html.match(/[Ff]rom\s+\$?([\d,.]+)\s*\/\s*m[²2]/i);
+ if (m2Match) {
+ const val = parseFloat(m2Match[1].replace(/,/g, ''));
+ if (val > 0) return val;
+ }
+
+ // Look for price chips: "$5.40" near "sq ft"
+ const priceChips = [...html.matchAll(/\$([\d]+\.[\d]{2})/g)];
+ for (const m of priceChips) {
+ const val = parseFloat(m[1]);
+ // sq ft range: $4-$12; m2 range: $43-$130
+ if (val >= 4 && val <= 15) {
+ // check nearby context for sq ft
+ const idx = m.index;
+ const context = html.slice(Math.max(0, idx - 100), idx + 100).toLowerCase();
+ if (context.includes('sq ft') || context.includes('sqft') || context.includes('square')) {
+ return val * 10.7639;
+ }
+ }
+ if (val >= 40 && val <= 150) {
+ const idx = m.index;
+ const context = html.slice(Math.max(0, idx - 100), idx + 100).toLowerCase();
+ if (context.includes('/m') || context.includes('m²') || context.includes('per m')) {
+ return val;
+ }
+ }
+ }
+
+ // Try wider patterns: any price-like value in "price" context
+ // RW typically shows "5.40" near "sq ft" in their product page
+ const genericSqft = html.match(/(\d+\.\d+)\s*(?:USD\s*)?\/\s*sq\.?\s*ft/i);
+ if (genericSqft) {
+ const val = parseFloat(genericSqft[1]);
+ if (val > 0) return val * 10.7639;
+ }
+
+ return null;
+}
+
+// Bucket by per-m2 retail rate
+// C2 ~58.10, C3 ~76.40, C4 ~88.30
+function bucketGroup(ratePerM2) {
+ // Breakpoints: <67 => C2, 67-82 => C3, >82 => C4
+ if (ratePerM2 < 67) return 'C2';
+ if (ratePerM2 <= 82) return 'C3';
+ return 'C4';
+}
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+
+async function processProduct(gid) {
+ const shortId = gid.replace('gid://shopify/Product/', '');
+
+ // Step 1: Fetch rw_product_url and current price_group
+ const fetchQ = {
+ query: `{ product(id:"${gid}") {
+ title
+ metafield_url: metafield(namespace:"custom", key:"rw_product_url") { value }
+ metafield_pg: metafield(namespace:"custom", key:"price_group") { value }
+ } }`
+ };
+ const fr = await gql(fetchQ);
+ const prod = fr?.data?.product;
+ if (!prod) {
+ console.log(` [${shortId}] SKIP — not found on Shopify`);
+ return { status: 'failed', group: null };
+ }
+
+ const rwUrl = prod.metafield_url?.value || null;
+ const existingPg = prod.metafield_pg?.value || null;
+
+ if (!rwUrl) {
+ console.log(` [${shortId}] SKIP — no rw_product_url`);
+ return { status: 'unknown', group: null };
+ }
+
+ console.log(` [${shortId}] "${prod.title?.slice(0,50)}" url=${rwUrl.slice(0,60)} existing_pg=${existingPg || 'none'}`);
+
+ // Step 2: Scrape RW page
+ await sleep(500); // be polite
+ let pricePerM2 = null;
+ try {
+ const res = await fetchUrl(rwUrl);
+ if (res.status === 200) {
+ pricePerM2 = extractPricePerM2(res.body);
+ } else {
+ console.log(` HTTP ${res.status} for ${rwUrl}`);
+ }
+ } catch (e) {
+ console.log(` fetch error: ${e.message}`);
+ }
+
+ if (pricePerM2 == null) {
+ console.log(` Could not extract price — leaving as-is (C3 default)`);
+ return { status: 'unknown', group: existingPg || 'C3' };
+ }
+
+ const group = bucketGroup(pricePerM2);
+ console.log(` price/m2=$${pricePerM2.toFixed(2)} => ${group} (existing: ${existingPg || 'none'})`);
+
+ // Step 3: Write metafields
+ const metafields = [
+ { ownerId: gid, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ { ownerId: gid, namespace: 'custom', key: 'cost_basis', value: `${group}: portal RRP-derived, P&S/Commercial extrapolated`, type: 'single_line_text_field' },
+ ];
+
+ // Only overwrite cost metafields for C2 and C4 (C3 stays as-is)
+ if (group !== 'C3') {
+ const costPerM2 = group === 'C2' ? 33.84 : 51.44;
+ metafields.push(
+ { ownerId: gid, namespace: 'custom', key: 'cost_per_m2', value: String(costPerM2), type: 'number_decimal' },
+ { ownerId: gid, namespace: 'custom', key: 'material_options', value: JSON.stringify(MATERIAL_OPTIONS[group]), type: 'json' }
+ );
+ }
+
+ try {
+ const wr = await gql({ query: MF_MUTATION, variables: { m: metafields } });
+ const errs = wr?.data?.metafieldsSet?.userErrors || [];
+ if (errs.length) {
+ console.log(` WRITE ERRORS: ${errs.map(e => e.message).join('; ')}`);
+ return { status: 'failed', group };
+ }
+ console.log(` wrote ${metafields.length} metafields for ${group}`);
+ return { status: 'ok', group };
+ } catch (e) {
+ console.log(` write exception: ${e.message}`);
+ return { status: 'failed', group };
+ }
+}
+
+async function main() {
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0 };
+ const notes = [];
+
+ for (const gid of PRODUCT_IDS) {
+ try {
+ const result = await processProduct(gid);
+ counts.processed++;
+ if (result.status === 'ok') {
+ if (result.group === 'C2') counts.c2++;
+ else if (result.group === 'C3') counts.c3++;
+ else if (result.group === 'C4') counts.c4++;
+ } else if (result.status === 'unknown') {
+ counts.unknown++;
+ } else if (result.status === 'failed') {
+ counts.failed++;
+ } else if (result.status === 'skip_no_url') {
+ counts.unknown++;
+ }
+ } catch (e) {
+ console.log(` [${gid}] EXCEPTION: ${e.message}`);
+ counts.failed++;
+ counts.processed++;
+ }
+ await sleep(500); // ~2 req/s
+ }
+
+ console.log('\n=== FINAL COUNTS ===');
+ console.log(JSON.stringify(counts, null, 2));
+ return counts;
+}
+
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/scripts/rw-price-group-fix.js b/scripts/rw-price-group-fix.js
new file mode 100644
index 0000000..aec33fa
--- /dev/null
+++ b/scripts/rw-price-group-fix.js
@@ -0,0 +1,398 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-fix.js
+ * Determine Rebel Walls price group (C2/C3/C4) from live per-m2 retail rate,
+ * then write price_group + cost metafields to the sandbox Shopify store.
+ *
+ * Usage: node rw-price-group-fix.js
+ */
+
+const https = require('https');
+
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
+
+// Shopify IDs from the DB slice
+const SHOPIFY_IDS = [
+ 'gid://shopify/Product/7855823093811',
+ 'gid://shopify/Product/7855823126579',
+ 'gid://shopify/Product/7855823159347',
+ 'gid://shopify/Product/7855823192115',
+ 'gid://shopify/Product/7855823224883',
+ 'gid://shopify/Product/7855823257651',
+ 'gid://shopify/Product/7855823290419',
+ 'gid://shopify/Product/7855823323187',
+ 'gid://shopify/Product/7855823355955',
+ 'gid://shopify/Product/7855823388723',
+ 'gid://shopify/Product/7855823421491',
+ 'gid://shopify/Product/7855823454259',
+ 'gid://shopify/Product/7855823487027',
+ 'gid://shopify/Product/7855823552563',
+ 'gid://shopify/Product/7855823618099',
+ 'gid://shopify/Product/7855823650867',
+ 'gid://shopify/Product/7855823683635',
+ 'gid://shopify/Product/7855823716403',
+ 'gid://shopify/Product/7855823749171',
+ 'gid://shopify/Product/7855823781939',
+ 'gid://shopify/Product/7855823585331',
+ 'gid://shopify/Product/7855823814707',
+ 'gid://shopify/Product/7855823847475',
+ 'gid://shopify/Product/7855823880243',
+ 'gid://shopify/Product/7855823913011',
+ 'gid://shopify/Product/7855823945779',
+ 'gid://shopify/Product/7855823978547',
+ 'gid://shopify/Product/7855824011315',
+ 'gid://shopify/Product/7855824044083',
+ 'gid://shopify/Product/7855824076851',
+ 'gid://shopify/Product/7855824109619',
+ 'gid://shopify/Product/7855824175155',
+ 'gid://shopify/Product/7855824207923',
+ 'gid://shopify/Product/7855824240691',
+ 'gid://shopify/Product/7855824273459',
+ 'gid://shopify/Product/7855824306227',
+ 'gid://shopify/Product/7855824338995',
+ 'gid://shopify/Product/7855824371763',
+ 'gid://shopify/Product/7855824404531',
+ 'gid://shopify/Product/7855824470067',
+];
+
+// Pre-computed material_options per group
+const MATERIAL_OPTIONS = {
+ C2: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":58.10,"cost_per_m2":33.84,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":69.70,"cost_per_m2":40.60,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":81.36,"cost_per_m2":47.39,"cost_confirmed":false}
+ ]),
+ C3: null, // leave as-is
+ C4: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":88.30,"cost_per_m2":51.44,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":106.00,"cost_per_m2":61.75,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":123.66,"cost_per_m2":72.03,"cost_confirmed":false}
+ ]),
+};
+
+const COST_PER_M2 = { C2: '33.84', C3: '44.50', C4: '51.44' };
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function gql(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data)
+ },
+ }, res => {
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ _raw: c.slice(0, 300) }); } });
+ });
+ req.on('error', reject);
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data);
+ req.end();
+ });
+}
+
+function fetchUrl(url) {
+ return new Promise((resolve, reject) => {
+ const parsed = new URL(url);
+ const mod = parsed.protocol === 'https:' ? https : require('http');
+ const options = {
+ hostname: parsed.hostname,
+ path: parsed.pathname + parsed.search,
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ }
+ };
+ const req = mod.request(options, res => {
+ // Handle redirects
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ resolve(fetchUrl(res.headers.location));
+ return;
+ }
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => resolve({ status: res.statusCode, body: c }));
+ });
+ req.on('error', reject);
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.end();
+ });
+}
+
+/**
+ * Extract per-m2 price from Rebel Walls product page HTML.
+ * RW shows prices in USD per sq ft or per m2 depending on locale.
+ * We look for JSON-LD and also the price chip patterns.
+ */
+function extractPricePerM2(html) {
+ // Try JSON-LD first
+ const jsonLdMatches = [...html.matchAll(/<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi)];
+ for (const m of jsonLdMatches) {
+ try {
+ const obj = JSON.parse(m[1]);
+ const offers = obj.offers || (Array.isArray(obj) ? obj.flatMap(x => x.offers || []) : []);
+ const offerArr = Array.isArray(offers) ? offers : [offers];
+ for (const o of offerArr) {
+ if (o && o.price) {
+ const price = parseFloat(o.price);
+ if (price > 0) {
+ const currency = (o.priceCurrency || '').toUpperCase();
+ const unitText = JSON.stringify(o).toLowerCase();
+ // If it's per sq ft (small number, typically 3-15 USD), convert to m2
+ if (price < 20 && (unitText.includes('sq') || unitText.includes('sqft') || unitText.includes('sq_ft') || unitText.includes('square'))) {
+ return { pricePerM2: price * 10.7639, source: 'json-ld-sqft' };
+ }
+ // If it's per m2 (typically 40-150), use directly
+ if (price >= 30 && price <= 200) {
+ return { pricePerM2: price, source: 'json-ld-m2' };
+ }
+ }
+ }
+ }
+ } catch {}
+ }
+
+ // Try price patterns in HTML
+ // Look for "From $X / sq ft" or "From $X/sqft"
+ const sqftPatterns = [
+ /\$\s*([\d.]+)\s*\/\s*sq\s*ft/i,
+ /\$\s*([\d.]+)\s*\/\s*sqft/i,
+ /\$\s*([\d.]+)\s*per\s*sq\s*ft/i,
+ /"price":\s*"?([\d.]+)"?[^}]*"sq/i,
+ /From\s+\$\s*([\d.]+)\s*\/\s*sq/i,
+ ];
+ for (const pat of sqftPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const price = parseFloat(m[1]);
+ if (price > 0 && price < 30) {
+ return { pricePerM2: price * 10.7639, source: 'html-sqft' };
+ }
+ }
+ }
+
+ // Look for per-m2 patterns
+ const m2Patterns = [
+ /\$\s*([\d.]+)\s*\/\s*m[²2]/i,
+ /\$\s*([\d.]+)\s*per\s*m[²2]/i,
+ /From\s+\$\s*([\d.]+)\s*\/\s*m/i,
+ /"price":\s*"?([\d.]+)"?[^}]*"m2/i,
+ /"price":\s*"?([\d.]+)"?[^}]*\/m²/i,
+ ];
+ for (const pat of m2Patterns) {
+ const m = html.match(pat);
+ if (m) {
+ const price = parseFloat(m[1]);
+ if (price >= 30 && price <= 250) {
+ return { pricePerM2: price, source: 'html-m2' };
+ }
+ }
+ }
+
+ // Look for data-price attributes or price in JS
+ const dataPatterns = [
+ /data-price[^>]*>([\d.]+)</i,
+ /"base_price":\s*([\d.]+)/i,
+ /"basePrice":\s*([\d.]+)/i,
+ /pricePerSqFt[^:]*:\s*([\d.]+)/i,
+ /price_per_sqft[^:]*:\s*([\d.]+)/i,
+ ];
+ for (const pat of dataPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const price = parseFloat(m[1]);
+ if (price > 0 && price < 30) {
+ return { pricePerM2: price * 10.7639, source: 'html-data-sqft' };
+ }
+ if (price >= 30 && price <= 250) {
+ return { pricePerM2: price, source: 'html-data-m2' };
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Bucket a per-m2 price into C2/C3/C4.
+ * C2 ~58.10, C3 ~76.40, C4 ~88.30
+ * Pick nearest bucket.
+ */
+function bucketPrice(pricePerM2) {
+ const buckets = [
+ { group: 'C2', center: 58.10 },
+ { group: 'C3', center: 76.40 },
+ { group: 'C4', center: 88.30 },
+ ];
+ let best = null, bestDist = Infinity;
+ for (const b of buckets) {
+ const dist = Math.abs(pricePerM2 - b.center);
+ if (dist < bestDist) { bestDist = dist; best = b.group; }
+ }
+ return best;
+}
+
+async function fetchProductMetafields(pid) {
+ const r = await gql({
+ query: `{
+ product(id:"${pid}") {
+ rw_product_url: metafield(namespace:"custom", key:"rw_product_url") { value }
+ pg: metafield(namespace:"custom", key:"price_group") { value }
+ }
+ }`
+ });
+ return r?.data?.product;
+}
+
+const MF_MUTATION = `mutation metafieldsSet($m: [MetafieldsSetInput!]!) {
+ metafieldsSet(metafields: $m) {
+ metafields { key value }
+ userErrors { message field }
+ }
+}`;
+
+async function writeMetafields(pid, group) {
+ const mfs = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ {
+ ownerId: pid, namespace: 'custom', key: 'cost_basis',
+ value: `${group} — portal-RRP-derived; P&S and Commercial extrapolated`,
+ type: 'single_line_text_field'
+ },
+ ];
+
+ if (group !== 'C3') {
+ mfs.push({
+ ownerId: pid, namespace: 'custom', key: 'cost_per_m2',
+ value: COST_PER_M2[group],
+ type: 'number_decimal'
+ });
+ mfs.push({
+ ownerId: pid, namespace: 'custom', key: 'material_options',
+ value: MATERIAL_OPTIONS[group],
+ type: 'json'
+ });
+ }
+
+ const r = await gql({ query: MF_MUTATION, variables: { m: mfs } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ return { ok: errs.length === 0, errors: errs };
+}
+
+async function processOne(pid) {
+ // Step 1: fetch metafields
+ let meta;
+ try {
+ meta = await fetchProductMetafields(pid);
+ } catch (e) {
+ return { status: 'failed', reason: `fetch metafields: ${e.message}` };
+ }
+
+ if (!meta) return { status: 'failed', reason: 'product not found' };
+
+ const rwUrl = meta.rw_product_url?.value;
+ if (!rwUrl) return { status: 'skipped', reason: 'no rw_product_url' };
+
+ // Step 2: fetch RW page and extract price
+ let priceResult = null;
+ try {
+ const resp = await fetchUrl(rwUrl);
+ if (resp.status === 200) {
+ priceResult = extractPricePerM2(resp.body);
+ } else {
+ return { status: 'unknown', reason: `HTTP ${resp.status} for ${rwUrl}` };
+ }
+ } catch (e) {
+ return { status: 'unknown', reason: `fetch URL failed: ${e.message}` };
+ }
+
+ let group;
+ let priceNote = '';
+ if (priceResult) {
+ group = bucketPrice(priceResult.pricePerM2);
+ priceNote = `$${priceResult.pricePerM2.toFixed(2)}/m2 (${priceResult.source}) => ${group}`;
+ } else {
+ // Can't determine price — leave as C3 default
+ return { status: 'unknown', reason: `could not extract price from ${rwUrl}` };
+ }
+
+ // Step 3: write metafields
+ let writeResult;
+ try {
+ writeResult = await writeMetafields(pid, group);
+ } catch (e) {
+ return { status: 'failed', reason: `write metafields: ${e.message}` };
+ }
+
+ if (!writeResult.ok) {
+ return { status: 'failed', reason: `userErrors: ${writeResult.errors.map(e => e.message).join('; ')}` };
+ }
+
+ return { status: 'ok', group, priceNote };
+}
+
+async function main() {
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0 };
+ const notes = [];
+
+ console.log(`Processing ${SHOPIFY_IDS.length} products...`);
+
+ for (let i = 0; i < SHOPIFY_IDS.length; i++) {
+ const pid = SHOPIFY_IDS[i];
+ const shortId = pid.replace('gid://shopify/Product/', '');
+ process.stdout.write(`[${i+1}/${SHOPIFY_IDS.length}] ${shortId} ... `);
+
+ let result;
+ try {
+ result = await processOne(pid);
+ } catch (e) {
+ result = { status: 'failed', reason: e.message };
+ }
+
+ counts.processed++;
+
+ if (result.status === 'ok') {
+ const g = result.group.toLowerCase();
+ counts[g]++;
+ console.log(`OK ${result.group} — ${result.priceNote}`);
+ } else if (result.status === 'skipped') {
+ console.log(`SKIP — ${result.reason}`);
+ // skipped = no URL, treat as unknown
+ counts.unknown++;
+ notes.push(`${shortId}: SKIP — ${result.reason}`);
+ } else if (result.status === 'unknown') {
+ counts.unknown++;
+ console.log(`UNKNOWN — ${result.reason}`);
+ notes.push(`${shortId}: UNKNOWN — ${result.reason}`);
+ } else {
+ counts.failed++;
+ console.log(`FAILED — ${result.reason}`);
+ notes.push(`${shortId}: FAILED — ${result.reason}`);
+ }
+
+ // ~2 req/s (500ms between products = 2 Shopify calls + 1 RW fetch per product)
+ if (i < SHOPIFY_IDS.length - 1) await sleep(500);
+ }
+
+ console.log('\n=== RESULTS ===');
+ console.log(JSON.stringify(counts, null, 2));
+ console.log('\nNotes:');
+ notes.forEach(n => console.log(' ', n));
+
+ // Output final JSON for the orchestrator
+ console.log('\n__FINAL__');
+ console.log(JSON.stringify({ ...counts, notes: notes.join(' | ') }));
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
diff --git a/scripts/rw-price-group-setter.js b/scripts/rw-price-group-setter.js
new file mode 100644
index 0000000..1c2a35e
--- /dev/null
+++ b/scripts/rw-price-group-setter.js
@@ -0,0 +1,331 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-setter.js
+ * Determine each Rebel Walls mural's PRICE GROUP (C2/C3/C4) from its live
+ * per-m2 retail rate on rebelwalls.com, then write price_group + cost metafields.
+ *
+ * Usage: node rw-price-group-setter.js
+ * Reads SHOPIFY_ADMIN_TOKEN from env.
+ */
+
+'use strict';
+const https = require('https');
+const http = require('http');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API_PATH = '/admin/api/2024-10/graphql.json';
+
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
+
+// Product IDs from the slice (OFFSET 440 LIMIT 40)
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7851180261427',
+ 'gid://shopify/Product/7851180294195',
+ 'gid://shopify/Product/7851180326963',
+ 'gid://shopify/Product/7851180359731',
+ 'gid://shopify/Product/7851180392499',
+ 'gid://shopify/Product/7851180458035',
+ 'gid://shopify/Product/7851180490803',
+ 'gid://shopify/Product/7851180523571',
+ 'gid://shopify/Product/7851180556339',
+ 'gid://shopify/Product/7851180589107',
+ 'gid://shopify/Product/7851180621875',
+ 'gid://shopify/Product/7851180654643',
+ 'gid://shopify/Product/7851180425267',
+ 'gid://shopify/Product/7851180720179',
+ 'gid://shopify/Product/7851180752947',
+ 'gid://shopify/Product/7851180785715',
+ 'gid://shopify/Product/7851180818483',
+ 'gid://shopify/Product/7851180851251',
+ 'gid://shopify/Product/7851180884019',
+ 'gid://shopify/Product/7851180916787',
+ 'gid://shopify/Product/7851180949555',
+ 'gid://shopify/Product/7851180982323',
+ 'gid://shopify/Product/7851181015091',
+ 'gid://shopify/Product/7851181047859',
+ 'gid://shopify/Product/7851181080627',
+ 'gid://shopify/Product/7851181113395',
+ 'gid://shopify/Product/7851181146163',
+ 'gid://shopify/Product/7851181178931',
+ 'gid://shopify/Product/7851181211699',
+ 'gid://shopify/Product/7851181244467',
+ 'gid://shopify/Product/7851181277235',
+ 'gid://shopify/Product/7851181310003',
+ 'gid://shopify/Product/7851181375539',
+ 'gid://shopify/Product/7851181342771',
+ 'gid://shopify/Product/7851181408307',
+ 'gid://shopify/Product/7851181441075',
+ 'gid://shopify/Product/7851181473843',
+ 'gid://shopify/Product/7851181506611',
+ 'gid://shopify/Product/7851181539379',
+ 'gid://shopify/Product/7851181572147',
+];
+
+// Pre-computed per-group material_options JSON
+const MATERIAL_OPTIONS = {
+ C2: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":58.10,"cost_per_m2":33.84,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":69.70,"cost_per_m2":40.60,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":81.36,"cost_per_m2":47.39,"cost_confirmed":false}
+ ]),
+ C3: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":76.40,"cost_per_m2":44.50,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":91.70,"cost_per_m2":53.37,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":107.00,"cost_per_m2":62.32,"cost_confirmed":false}
+ ]),
+ C4: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":88.30,"cost_per_m2":51.44,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":106.00,"cost_per_m2":61.75,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":123.66,"cost_per_m2":72.03,"cost_confirmed":false}
+ ]),
+};
+
+const COST_PER_M2 = { C2: '33.84', C3: '44.50', C4: '51.44' };
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// ---- Shopify GraphQL ----
+function gqlRaw(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API_PATH, method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data)
+ },
+ }, res => {
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } });
+ });
+ req.on('error', reject);
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('gql timeout')); });
+ req.write(data);
+ req.end();
+ });
+}
+
+async function gql(body, retries = 4) {
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
+ const result = await gqlRaw(body);
+ const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+ const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable || 9999) < 300;
+ if (throttled) { await sleep(3000); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+// ---- Fetch RW product page ----
+function fetchUrl(url) {
+ return new Promise((resolve, reject) => {
+ const mod = url.startsWith('https') ? https : http;
+ const req = mod.get(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ }
+ }, res => {
+ // Handle redirect
+ if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) {
+ const loc = res.headers.location;
+ if (loc) {
+ const newUrl = loc.startsWith('http') ? loc : `https://rebelwalls.com${loc}`;
+ return fetchUrl(newUrl).then(resolve).catch(reject);
+ }
+ }
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => resolve({ body: c, status: res.statusCode }));
+ });
+ req.on('error', reject);
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('fetch timeout: ' + url)); });
+ });
+}
+
+// ---- Extract base price per m2 from RW page ----
+function extractBasePriceM2(html) {
+ // Best: "minimum_product_price":"$XX.XX" from the embedded JSON data
+ // This is the base material (Non-Woven) minimum per-m2 price
+ const m = html.match(/"minimum_product_price":"?\$?([0-9]+\.?[0-9]*)"?/);
+ if (m) {
+ const val = parseFloat(m[1]);
+ if (!isNaN(val) && val > 0) return val;
+ }
+
+ // Fallback: productMaterials std type minimumProductPrice
+ const matsM = html.match(/"productMaterials":\s*(\[[\s\S]*?\])/);
+ if (matsM) {
+ try {
+ const mats = JSON.parse(matsM[1]);
+ const std = mats.find(m => m.type === 'std');
+ if (std) {
+ const p = String(std.minimumProductPrice || '').replace(/[^0-9.]/g, '');
+ const v = parseFloat(p);
+ if (!isNaN(v) && v > 0) return v;
+ // Try defaultPrice and convert sq ft -> m2
+ const dp = String(std.defaultPrice || '').replace(/[^0-9.]/g, '');
+ const dv = parseFloat(dp);
+ if (!isNaN(dv) && dv > 0) return dv * 10.7639;
+ }
+ } catch {}
+ }
+
+ // Last resort: find the smallest dollar amount that's in range 50-100
+ const allPrices = [...html.matchAll(/\$([0-9]+\.[0-9]+)/g)]
+ .map(m => parseFloat(m[1]))
+ .filter(v => v >= 50 && v <= 120);
+ if (allPrices.length > 0) return Math.min(...allPrices);
+
+ return null;
+}
+
+// ---- Bucket price to group ----
+function priceGroup(priceM2) {
+ // C2 ~58.10, C3 ~76.40, C4 ~88.30
+ // Midpoints: (58.10+76.40)/2 = 67.25, (76.40+88.30)/2 = 82.35
+ if (priceM2 <= 67.25) return 'C2';
+ if (priceM2 <= 82.35) return 'C3';
+ return 'C4';
+}
+
+// ---- Write metafields ----
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+
+async function writeMetafields(ownerId, group) {
+ const mfs = [
+ { ownerId, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ ];
+
+ // Always write cost_basis note
+ mfs.push({
+ ownerId, namespace: 'custom', key: 'cost_basis',
+ value: `${group} portal-RRP-derived; P&S/Commercial extrapolated`,
+ type: 'single_line_text_field'
+ });
+
+ if (group !== 'C3') {
+ // Overwrite cost_per_m2 and material_options for C2/C4
+ mfs.push({
+ ownerId, namespace: 'custom', key: 'cost_per_m2',
+ value: COST_PER_M2[group],
+ type: 'number_decimal'
+ });
+ mfs.push({
+ ownerId, namespace: 'custom', key: 'material_options',
+ value: MATERIAL_OPTIONS[group],
+ type: 'json'
+ });
+ }
+
+ const r = await gql({ query: MF_MUTATION, variables: { m: mfs } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ if (errs.length) {
+ throw new Error(`metafieldsSet errors: ${errs.map(e => e.message).join('; ')}`);
+ }
+ return mfs.length;
+}
+
+// ---- Main ----
+async function main() {
+ const stats = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0, notes: [] };
+
+ console.log(`Processing ${PRODUCT_IDS.length} products...`);
+
+ for (let i = 0; i < PRODUCT_IDS.length; i++) {
+ const pid = PRODUCT_IDS[i];
+ const shortId = pid.replace('gid://shopify/Product/', '');
+
+ try {
+ // 1. Fetch rw_product_url + existing price_group
+ const fetchR = await gql({
+ query: `{ product(id:"${pid}") {
+ title
+ metafield(namespace:"custom",key:"rw_product_url"){ value }
+ pg:metafield(namespace:"custom",key:"price_group"){ value }
+ }}`
+ });
+ const prod = fetchR?.data?.product;
+ if (!prod) {
+ console.log(`[${i+1}/${PRODUCT_IDS.length}] ${shortId} — SKIP: not found on Shopify`);
+ stats.failed++;
+ continue;
+ }
+ const rwUrl = prod.metafield?.value;
+ const existingGroup = prod.pg?.value;
+ const title = prod.title || '(no title)';
+
+ if (!rwUrl) {
+ console.log(`[${i+1}/${PRODUCT_IDS.length}] ${shortId} "${title}" — SKIP: no rw_product_url`);
+ stats.unknown++;
+ continue;
+ }
+
+ // 2. Fetch the RW page and extract base price
+ await sleep(400); // rate limit for RW
+ let priceM2 = null;
+ let group = 'C3'; // default
+ let groupSource = 'default';
+
+ try {
+ const { body, status } = await fetchUrl(rwUrl);
+ if (status === 200) {
+ priceM2 = extractBasePriceM2(body);
+ if (priceM2 !== null) {
+ group = priceGroup(priceM2);
+ groupSource = `m2=$${priceM2.toFixed(2)}`;
+ } else {
+ console.log(` WARNING: could not extract price from ${rwUrl}`);
+ stats.notes.push(`${shortId}: no price extracted from ${rwUrl}`);
+ stats.unknown++;
+ stats.processed++;
+ continue;
+ }
+ } else {
+ console.log(` WARNING: ${rwUrl} returned HTTP ${status}`);
+ stats.notes.push(`${shortId}: HTTP ${status} from ${rwUrl}`);
+ stats.unknown++;
+ stats.processed++;
+ continue;
+ }
+ } catch (fetchErr) {
+ console.log(` WARNING: fetch failed for ${rwUrl}: ${fetchErr.message}`);
+ stats.notes.push(`${shortId}: fetch error: ${fetchErr.message}`);
+ stats.unknown++;
+ stats.processed++;
+ continue;
+ }
+
+ // 3. Write metafields
+ await sleep(400);
+ const mfCount = await writeMetafields(pid, group);
+
+ stats.processed++;
+ stats[group.toLowerCase()]++;
+ console.log(`[${i+1}/${PRODUCT_IDS.length}] ${shortId} "${title}" — ${group} (${groupSource}, was:${existingGroup||'none'}) — wrote ${mfCount} metafields`);
+
+ } catch (err) {
+ console.error(`[${i+1}/${PRODUCT_IDS.length}] ${shortId} — FAILED: ${err.message}`);
+ stats.failed++;
+ stats.notes.push(`${shortId}: ${err.message}`);
+ }
+
+ // Rate limiting: ~2 req/s (Shopify) + RW page fetches
+ await sleep(500);
+ }
+
+ console.log('\n=== RESULTS ===');
+ console.log(JSON.stringify(stats, null, 2));
+ return stats;
+}
+
+main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
diff --git a/scripts/rw-price-group-slice7.js b/scripts/rw-price-group-slice7.js
new file mode 100644
index 0000000..ed5696c
--- /dev/null
+++ b/scripts/rw-price-group-slice7.js
@@ -0,0 +1,318 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-slice7.js
+ * Process OFFSET 280, LIMIT 40 of Rebel Walls products.
+ * For each: fetch rw_product_url metafield, curl the RW page, extract base price,
+ * bucket into C2/C3/C4, write price_group + cost metafields to sandbox Shopify.
+ */
+
+'use strict';
+const https = require('https');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
+
+// Pre-computed per-group material_options
+const MATERIAL_OPTIONS = {
+ C2: JSON.stringify([
+ { material: 'Non-Woven (Standard)', retail_per_m2: 58.10, cost_per_m2: 33.84, cost_confirmed: false },
+ { material: 'Peel & Stick', retail_per_m2: 69.70, cost_per_m2: 40.60, cost_confirmed: false },
+ { material: 'Commercial Grade', retail_per_m2: 81.36, cost_per_m2: 47.39, cost_confirmed: false }
+ ]),
+ C3: JSON.stringify([
+ { material: 'Non-Woven (Standard)', retail_per_m2: 76.40, cost_per_m2: 44.50, cost_confirmed: false },
+ { material: 'Peel & Stick', retail_per_m2: 91.68, cost_per_m2: 53.40, cost_confirmed: false },
+ { material: 'Commercial Grade', retail_per_m2: 107.00, cost_per_m2: 62.33, cost_confirmed: false }
+ ]),
+ C4: JSON.stringify([
+ { material: 'Non-Woven (Standard)', retail_per_m2: 88.30, cost_per_m2: 51.44, cost_confirmed: false },
+ { material: 'Peel & Stick', retail_per_m2: 106.00, cost_per_m2: 61.75, cost_confirmed: false },
+ { material: 'Commercial Grade', retail_per_m2: 123.66, cost_per_m2: 72.03, cost_confirmed: false }
+ ])
+};
+
+const COST_PER_M2 = { C2: '33.84', C3: '44.50', C4: '51.44' };
+
+// Buckets: base per-m2 retail
+function bucketPrice(price) {
+ // C2 ~58.10, C3 ~76.40, C4 ~88.30
+ // Midpoints: C2/C3 = (58.10+76.40)/2 = 67.25, C3/C4 = (76.40+88.30)/2 = 82.35
+ if (price < 67.25) return 'C2';
+ if (price < 82.35) return 'C3';
+ return 'C4';
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function gqlRaw(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data)
+ }
+ }, res => {
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } });
+ });
+ req.on('error', reject);
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data); req.end();
+ });
+}
+
+async function gql(body, retries = 4) {
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
+ const result = await gqlRaw(body);
+ const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+ const avail = result?.extensions?.cost?.throttleStatus?.currentlyAvailable ?? 9999;
+ if (throttled) { await sleep(3000); continue; }
+ if (avail < 300) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+function fetchUrl(urlStr) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(urlStr);
+ const options = {
+ hostname: url.hostname,
+ path: url.pathname + (url.search || ''),
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.9'
+ }
+ };
+ const mod = require(url.protocol === 'https:' ? 'https' : 'http');
+ let redirectCount = 0;
+ function doRequest(opts) {
+ const req = mod.request(opts, res => {
+ if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location && redirectCount < 5) {
+ redirectCount++;
+ const redir = new URL(res.headers.location, `${opts.hostname}`);
+ const newMod = require(redir.protocol === 'https:' ? 'https' : 'http');
+ const newOpts = { ...opts, hostname: redir.hostname, path: redir.pathname + (redir.search || '') };
+ res.resume();
+ doRequest(newOpts);
+ return;
+ }
+ let body = '';
+ res.on('data', d => body += d);
+ res.on('end', () => resolve(body));
+ });
+ req.on('error', reject);
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('fetch timeout')); });
+ req.end();
+ }
+ doRequest(options);
+ });
+}
+
+function extractPrice(html) {
+ // Primary: JSON-LD Product with priceCurrency USD
+ const ldMatches = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi) || [];
+ for (const tag of ldMatches) {
+ const inner = tag.replace(/<script[^>]*>/, '').replace(/<\/script>/, '').trim();
+ try {
+ const obj = JSON.parse(inner);
+ if (obj['@type'] === 'Product' && obj.offers) {
+ const offer = Array.isArray(obj.offers) ? obj.offers[0] : obj.offers;
+ if (offer && offer.priceCurrency === 'USD' && offer.price) {
+ const p = parseFloat(offer.price);
+ if (!isNaN(p) && p > 0) return p;
+ }
+ }
+ } catch { /* ignore */ }
+ }
+
+ // Fallback: data-price attribute or "price":"XX.XX" near USD
+ const priceUsd = html.match(/"price"\s*:\s*"?([\d.]+)"?\s*[^}]*"priceCurrency"\s*:\s*"USD"/);
+ if (priceUsd) {
+ const p = parseFloat(priceUsd[1]);
+ if (!isNaN(p) && p > 0) return p;
+ }
+
+ // Second fallback: look for price chip text "$X.XX / sq ft" -> convert to m2
+ const sqft = html.match(/\$\s*([\d.]+)\s*\/\s*sq\.?\s*ft/i);
+ if (sqft) {
+ const perSqft = parseFloat(sqft[1]);
+ if (!isNaN(perSqft) && perSqft > 0) return perSqft * 10.7639;
+ }
+
+ return null;
+}
+
+async function writeMetafields(pid, group) {
+ const mfs = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ { ownerId: pid, namespace: 'custom', key: 'material_options', value: MATERIAL_OPTIONS[group], type: 'json' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', value: `${group} — portal-RRP-derived; P&S/Commercial extrapolated`, type: 'single_line_text_field' }
+ ];
+
+ // Only overwrite cost_per_m2 if not C3
+ if (group !== 'C3') {
+ mfs.push({ ownerId: pid, namespace: 'custom', key: 'cost_per_m2', value: COST_PER_M2[group], type: 'number_decimal' });
+ }
+
+ const r = await gql({
+ query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key value } userErrors { message field } } }',
+ variables: { m: mfs }
+ });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ if (errs.length) throw new Error(errs.map(e => e.message).join('; '));
+ return true;
+}
+
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7851174166579',
+ 'gid://shopify/Product/7851174199347',
+ 'gid://shopify/Product/7851174232115',
+ 'gid://shopify/Product/7851174264883',
+ 'gid://shopify/Product/7851174297651',
+ 'gid://shopify/Product/7851174330419',
+ 'gid://shopify/Product/7851174363187',
+ 'gid://shopify/Product/7851174395955',
+ 'gid://shopify/Product/7851174428723',
+ 'gid://shopify/Product/7851174461491',
+ 'gid://shopify/Product/7851174494259',
+ 'gid://shopify/Product/7851174527027',
+ 'gid://shopify/Product/7851174559795',
+ 'gid://shopify/Product/7851174592563',
+ 'gid://shopify/Product/7851174625331',
+ 'gid://shopify/Product/7851174658099',
+ 'gid://shopify/Product/7851174690867',
+ 'gid://shopify/Product/7851174723635',
+ 'gid://shopify/Product/7851174756403',
+ 'gid://shopify/Product/7851174789171',
+ 'gid://shopify/Product/7851174821939',
+ 'gid://shopify/Product/7851174854707',
+ 'gid://shopify/Product/7851174887475',
+ 'gid://shopify/Product/7851174985779',
+ 'gid://shopify/Product/7851175084083',
+ 'gid://shopify/Product/7851175182387',
+ 'gid://shopify/Product/7851175313459',
+ 'gid://shopify/Product/7851175346227',
+ 'gid://shopify/Product/7851175411763',
+ 'gid://shopify/Product/7851175444531',
+ 'gid://shopify/Product/7851175477299',
+ 'gid://shopify/Product/7851175542835',
+ 'gid://shopify/Product/7851175608371',
+ 'gid://shopify/Product/7851175673907',
+ 'gid://shopify/Product/7851175804979',
+ 'gid://shopify/Product/7851175837747',
+ 'gid://shopify/Product/7851175903283',
+ 'gid://shopify/Product/7851175510067',
+ 'gid://shopify/Product/7851176034355',
+ 'gid://shopify/Product/7851176067123'
+];
+
+async function main() {
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0 };
+ const notes = [];
+
+ for (const pid of PRODUCT_IDS) {
+ try {
+ // 1. Fetch metafields
+ const r = await gql({
+ query: `{ product(id:"${pid}") {
+ title
+ metafield(namespace:"custom",key:"rw_product_url"){ value }
+ pg: metafield(namespace:"custom",key:"price_group"){ value }
+ }}`
+ });
+ const prod = r?.data?.product;
+ if (!prod) {
+ console.log(`SKIP ${pid}: product not found`);
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ const rwUrl = prod.metafield?.value;
+ const existingPg = prod.pg?.value;
+ const title = prod.title || '';
+
+ if (!rwUrl) {
+ console.log(`SKIP ${pid} ("${title}"): no rw_product_url`);
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ // 2. Fetch product page
+ await sleep(500); // polite delay
+ let html;
+ try {
+ html = await fetchUrl(rwUrl);
+ } catch (e) {
+ console.log(`FAIL ${pid} ("${title}"): fetch error: ${e.message}`);
+ counts.failed++;
+ counts.processed++;
+ notes.push(`fetch-fail: ${pid} ${e.message}`);
+ continue;
+ }
+
+ // 3. Extract price
+ const price = extractPrice(html);
+ if (price === null) {
+ console.log(`UNKNOWN ${pid} ("${title}"): could not extract price from ${rwUrl}`);
+ counts.unknown++;
+ counts.processed++;
+ notes.push(`no-price: ${pid} ${rwUrl}`);
+ continue;
+ }
+
+ const group = bucketPrice(price);
+ console.log(`${pid} | "${title}" | price=${price.toFixed(2)}/m2 => ${group} (was: ${existingPg || 'unset'})`);
+
+ // 4. Write metafields
+ try {
+ await writeMetafields(pid, group);
+ console.log(` -> wrote price_group=${group}, cost_per_m2=${COST_PER_M2[group]}, material_options, cost_basis`);
+ counts[group.toLowerCase()]++;
+ counts.processed++;
+ } catch (e) {
+ console.log(` WRITE FAIL ${pid}: ${e.message}`);
+ counts.failed++;
+ counts.processed++;
+ notes.push(`write-fail: ${pid} ${e.message}`);
+ }
+
+ await sleep(500); // ~2 req/s
+ } catch (e) {
+ console.log(`EXCEPTION ${pid}: ${e.message}`);
+ counts.failed++;
+ counts.processed++;
+ notes.push(`exception: ${pid} ${e.message}`);
+ }
+ }
+
+ const result = {
+ processed: counts.processed,
+ c2: counts.c2,
+ c3: counts.c3,
+ c4: counts.c4,
+ unknown: counts.unknown,
+ failed: counts.failed,
+ notes: notes.join(' | ') || 'none'
+ };
+ console.log('\n=== FINAL RESULT ===');
+ console.log(JSON.stringify(result, null, 2));
+ return result;
+}
+
+main().catch(e => { console.error('FATAL:', e.message, e.stack); process.exit(1); });
diff --git a/thibaut-mfr-fix/README.md b/thibaut-mfr-fix/README.md
index 775fd1d..5262817 100644
--- a/thibaut-mfr-fix/README.md
+++ b/thibaut-mfr-fix/README.md
@@ -8,8 +8,8 @@ Scope audited: 3,612 Thibaut products (`vendor_prefix='DWTT'`).
| Task | Count | Status |
|---|---|---|
-| Tag backfill — add mfr SKU as a clean tag where missing (active) | 1,027 | **QUEUED** to `shopify_api_queue` (`source_agent='thibaut-mfr-tag-backfill'`, PUT, status `pending`) |
-| Title de-SKU — auto-fixable rewrites | 69 | **STAGED**, awaiting Steve eyeball → see `TITLES-FOR-REVIEW.md` |
+| Tag backfill — add mfr SKU as a clean tag where missing (active) | 1,027 | ✅ **PUSHED LIVE** 2026-06-11 (direct Admin REST, 0 failed) |
+| Title de-SKU — auto-fixable rewrites | 69 | ✅ **PUSHED LIVE** 2026-06-11 (0 failed) |
| Title de-SKU — needs real pattern name | 10 | backlog → `BACKLOG.md` |
| Junk parenthetical tags `Pattern(SKU)` | ~160 | backlog → `BACKLOG.md` |
diff --git a/tmp-rw-price-group.js b/tmp-rw-price-group.js
new file mode 100644
index 0000000..38e12a1
--- /dev/null
+++ b/tmp-rw-price-group.js
@@ -0,0 +1,337 @@
+#!/usr/bin/env node
+/**
+ * Rebel Walls price-group bucketer — SANDBOX
+ * Fetches rw_product_url from each product, curls the RW page,
+ * extracts base per-m2 rate, buckets C2/C3/C4, writes metafields.
+ */
+
+const https = require('https');
+const http = require('http');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
+
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7855838789683',
+ 'gid://shopify/Product/7855838822451',
+ 'gid://shopify/Product/7855838887987',
+ 'gid://shopify/Product/7855838920755',
+ 'gid://shopify/Product/7855838986291',
+ 'gid://shopify/Product/7855839019059',
+ 'gid://shopify/Product/7855839084595',
+ 'gid://shopify/Product/7855839117363',
+ 'gid://shopify/Product/7855839150131',
+ 'gid://shopify/Product/7855839182899',
+ 'gid://shopify/Product/7855839215667',
+ 'gid://shopify/Product/7855839248435',
+ 'gid://shopify/Product/7855839313971',
+ 'gid://shopify/Product/7855839379507',
+ 'gid://shopify/Product/7855839412275',
+ 'gid://shopify/Product/7855839477811',
+ 'gid://shopify/Product/7855839510579',
+ 'gid://shopify/Product/7855839543347',
+ 'gid://shopify/Product/7855839576115',
+ 'gid://shopify/Product/7855839739955',
+ 'gid://shopify/Product/7855839805491',
+ 'gid://shopify/Product/7855839838259',
+ 'gid://shopify/Product/7855839608883',
+ 'gid://shopify/Product/7855839707187',
+ 'gid://shopify/Product/7855839936563',
+ 'gid://shopify/Product/7855839969331',
+ 'gid://shopify/Product/7855840002099',
+ 'gid://shopify/Product/7855840034867',
+ 'gid://shopify/Product/7855840067635',
+ 'gid://shopify/Product/7855840100403',
+ 'gid://shopify/Product/7855840165939',
+ 'gid://shopify/Product/7855840329779',
+ 'gid://shopify/Product/7855840362547',
+ 'gid://shopify/Product/7855840395315',
+ 'gid://shopify/Product/7855840460851',
+ 'gid://shopify/Product/7855840493619',
+ 'gid://shopify/Product/7855840526387',
+ 'gid://shopify/Product/7855840559155',
+ 'gid://shopify/Product/7855840624691',
+ 'gid://shopify/Product/7855840657459',
+];
+
+// Price group buckets (per m2 USD)
+const BUCKETS = [
+ { group: 'C2', center: 58.10 },
+ { group: 'C3', center: 76.40 },
+ { group: 'C4', center: 88.30 },
+];
+
+const MATERIAL_OPTIONS = {
+ C2: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":58.10,"cost_per_m2":33.84,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":69.70,"cost_per_m2":40.60,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":81.36,"cost_per_m2":47.39,"cost_confirmed":false}
+ ]),
+ C3: null,
+ C4: JSON.stringify([
+ {"material":"Non-Woven (Standard)","retail_per_m2":88.30,"cost_per_m2":51.44,"cost_confirmed":false},
+ {"material":"Peel & Stick","retail_per_m2":106.00,"cost_per_m2":61.75,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":123.66,"cost_per_m2":72.03,"cost_confirmed":false}
+ ]),
+};
+
+const COST_PER_M2 = { C2: '33.84', C3: '44.5', C4: '51.44' };
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function gql(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data),
+ },
+ }, res => {
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => {
+ try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); }
+ });
+ });
+ req.on('error', reject);
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data);
+ req.end();
+ });
+}
+
+function fetchUrl(url, redirectCount) {
+ redirectCount = redirectCount || 0;
+ if (redirectCount > 5) return Promise.reject(new Error('too many redirects'));
+ return new Promise((resolve, reject) => {
+ const parsed = new URL(url);
+ const mod = parsed.protocol === 'https:' ? https : http;
+ const options = {
+ hostname: parsed.hostname,
+ path: parsed.pathname + parsed.search,
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ },
+ };
+ const req = mod.request(options, res => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ const loc = res.headers.location;
+ const redirectUrl = loc.startsWith('http') ? loc : `${parsed.protocol}//${parsed.hostname}${loc}`;
+ res.resume();
+ return fetchUrl(redirectUrl, redirectCount + 1).then(resolve).catch(reject);
+ }
+ let c = '';
+ res.on('data', d => c += d);
+ res.on('end', () => resolve({ status: res.statusCode, body: c }));
+ });
+ req.on('error', reject);
+ req.setTimeout(20000, () => { req.destroy(); reject(new Error('fetch timeout')); });
+ req.end();
+ });
+}
+
+function bucketPrice(pricePerM2) {
+ let best = null;
+ let bestDist = Infinity;
+ for (const b of BUCKETS) {
+ const dist = Math.abs(pricePerM2 - b.center);
+ if (dist < bestDist) { bestDist = dist; best = b.group; }
+ }
+ return best;
+}
+
+function extractPriceFromHtml(html) {
+ // Strategy 1: JSON-LD Product offers
+ // RW encodes base per-m2 USD price as e.g. "price":"76.4" in the Product JSON-LD block
+ const jsonldMatches = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi) || [];
+ for (const block of jsonldMatches) {
+ const inner = block.replace(/<script[^>]*>/, '').replace(/<\/script>/, '');
+ try {
+ const obj = JSON.parse(inner);
+ const items = Array.isArray(obj) ? obj : [obj];
+ for (const item of items) {
+ if (item['@type'] !== 'Product') continue;
+ const offers = item.offers;
+ if (!offers) continue;
+ const offerList = Array.isArray(offers) ? offers : [offers];
+ for (const offer of offerList) {
+ if (!offer.price) continue;
+ const p = parseFloat(offer.price);
+ if (p >= 40 && p <= 200) {
+ return { price: p, source: 'jsonld-product-offer' };
+ }
+ }
+ }
+ } catch (e) {}
+ }
+
+ // Strategy 2: inline numeric "price":67.45 (no quotes on value) in JS config
+ const numericMatch = html.match(/"price"\s*:\s*(\d{2,3}\.?\d*)\s*[,}]/);
+ if (numericMatch) {
+ const p = parseFloat(numericMatch[1]);
+ if (p >= 40 && p <= 200) {
+ return { price: p, source: 'script-numeric-price' };
+ }
+ }
+
+ // Strategy 3: "$X / sq ft" -> convert to m2
+ const sqftMatch = html.match(/\$\s*([\d.]+)\s*\/\s*sq\.?\s*ft/i);
+ if (sqftMatch) {
+ const p = parseFloat(sqftMatch[1]);
+ if (p > 1 && p < 30) {
+ return { price: p * 10.7639, source: 'sqft-pattern' };
+ }
+ }
+
+ return null;
+}
+
+async function getProductMeta(gid) {
+ const query = `{
+ product(id: "${gid}") {
+ id
+ title
+ rw_url: metafield(namespace: "custom", key: "rw_product_url") { value }
+ price_group: metafield(namespace: "custom", key: "price_group") { value }
+ }
+ }`;
+ const res = await gql({ query });
+ if (res.errors) throw new Error(JSON.stringify(res.errors));
+ return res.data && res.data.product;
+}
+
+async function writeMetafields(gid, group) {
+ const metafields = [
+ {
+ ownerId: gid,
+ namespace: 'custom',
+ key: 'price_group',
+ value: group,
+ type: 'single_line_text_field',
+ },
+ {
+ ownerId: gid,
+ namespace: 'custom',
+ key: 'cost_basis',
+ value: `${group} — portal-RRP-derived; P&S/Commercial extrapolated`,
+ type: 'single_line_text_field',
+ },
+ ];
+
+ if (group !== 'C3') {
+ metafields.push({
+ ownerId: gid,
+ namespace: 'custom',
+ key: 'cost_per_m2',
+ value: COST_PER_M2[group],
+ type: 'number_decimal',
+ });
+ metafields.push({
+ ownerId: gid,
+ namespace: 'custom',
+ key: 'material_options',
+ value: MATERIAL_OPTIONS[group],
+ type: 'json',
+ });
+ }
+
+ const mutation = `
+ mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
+ metafieldsSet(metafields: $metafields) {
+ metafields { key namespace value }
+ userErrors { field message }
+ }
+ }
+ `;
+ const res = await gql({ query: mutation, variables: { metafields } });
+ if (res.errors) throw new Error(JSON.stringify(res.errors));
+ const errs = res.data && res.data.metafieldsSet && res.data.metafieldsSet.userErrors;
+ if (errs && errs.length > 0) throw new Error('userErrors: ' + JSON.stringify(errs));
+ return res.data.metafieldsSet.metafields;
+}
+
+async function processProduct(gid) {
+ const meta = await getProductMeta(gid);
+ if (!meta) return { status: 'skip', reason: 'no product' };
+
+ const rwUrl = meta.rw_url && meta.rw_url.value;
+ if (!rwUrl) return { status: 'skip', reason: 'no rw_product_url' };
+
+ let pageData;
+ try {
+ pageData = await fetchUrl(rwUrl);
+ } catch (e) {
+ return { status: 'failed', reason: `fetch error: ${e.message}`, url: rwUrl };
+ }
+
+ if (pageData.status !== 200) {
+ return { status: 'failed', reason: `HTTP ${pageData.status}`, url: rwUrl };
+ }
+
+ const priceResult = extractPriceFromHtml(pageData.body);
+ if (!priceResult) {
+ console.error(`[UNKNOWN] ${gid.split('/').pop()} — no price found. URL: ${rwUrl}`);
+ // Defaulting to C3 (existing baseline)
+ await writeMetafields(gid, 'C3');
+ return { status: 'unknown', group: 'C3', url: rwUrl };
+ }
+
+ const group = bucketPrice(priceResult.price);
+ console.log(`[OK] ${gid.split('/').pop()} | ${meta.title} | $${priceResult.price.toFixed(2)}/m2 (${priceResult.source}) => ${group}`);
+
+ await writeMetafields(gid, group);
+ return { status: 'ok', group, price: priceResult.price, source: priceResult.source };
+}
+
+async function main() {
+ const results = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0, notes: [] };
+
+ for (let i = 0; i < PRODUCT_IDS.length; i++) {
+ const gid = PRODUCT_IDS[i];
+ const shortId = gid.split('/').pop();
+ try {
+ const r = await processProduct(gid);
+ results.processed++;
+
+ if (r.status === 'skip') {
+ results.unknown++;
+ results.notes.push(`SKIP ${shortId}: ${r.reason}`);
+ console.log(`[SKIP] ${shortId}: ${r.reason}`);
+ } else if (r.status === 'unknown') {
+ results.unknown++;
+ results.notes.push(`UNKNOWN ${shortId}: no price on page, defaulted C3`);
+ } else if (r.status === 'failed') {
+ results.failed++;
+ results.notes.push(`FAIL ${shortId}: ${r.reason}`);
+ console.error(`[FAIL] ${shortId}: ${r.reason}`);
+ } else {
+ if (r.group === 'C2') results.c2++;
+ else if (r.group === 'C3') results.c3++;
+ else if (r.group === 'C4') results.c4++;
+ }
+ } catch (e) {
+ results.processed++;
+ results.failed++;
+ results.notes.push(`FAIL ${shortId}: ${e.message}`);
+ console.error(`[FAIL] ${shortId}: ${e.message}`);
+ }
+
+ // ~2 req/s
+ if (i < PRODUCT_IDS.length - 1) await sleep(500);
+ }
+
+ console.log('\n=== FINAL RESULTS ===');
+ console.log(JSON.stringify(results, null, 2));
+ return results;
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
← 8da5a20 Thibaut mfr-fix: one-line run-push.sh wrapper (no line-wrap
·
back to Dw Yolo Loop
·
Artmura onboard: full-monte feed-first capture of 161 newwal f4eba66 →