← back to Dw Yolo Loop
chore: remove dead rw-price-group one-off cluster (9 unreferenced Rebel Walls price-group experiment forks, ~2700 LOC)
fd37d081b7e38e7dc689990de1cc99244d81fb80 · 2026-06-15 23:00:31 -0700 · Steve Abrams
All 9 confirmed zero external references (no plist/shell/import). Reversible via git history. Council Eng#1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
D rw-price-group-680.jsD rw-price-group-fix.jsD rw-price-group-offset520.jsD rw-price-group-slice5.jsD rw-price-group.jsD scripts/rw-price-group-fix.jsD scripts/rw-price-group-setter.jsD scripts/rw-price-group-slice7.jsD tmp-rw-price-group.js
Diff
commit fd37d081b7e38e7dc689990de1cc99244d81fb80
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 15 23:00:31 2026 -0700
chore: remove dead rw-price-group one-off cluster (9 unreferenced Rebel Walls price-group experiment forks, ~2700 LOC)
All 9 confirmed zero external references (no plist/shell/import). Reversible via git history. Council Eng#1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
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 ------------------------------
tmp-rw-price-group.js | 337 --------------------------------
9 files changed, 3185 deletions(-)
diff --git a/rw-price-group-680.js b/rw-price-group-680.js
deleted file mode 100644
index ebc4f8f..0000000
--- a/rw-price-group-680.js
+++ /dev/null
@@ -1,320 +0,0 @@
-#!/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
deleted file mode 100644
index c443de7..0000000
--- a/rw-price-group-fix.js
+++ /dev/null
@@ -1,302 +0,0 @@
-#!/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
deleted file mode 100644
index 8f4c62a..0000000
--- a/rw-price-group-offset520.js
+++ /dev/null
@@ -1,403 +0,0 @@
-#!/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
deleted file mode 100644
index ec022ec..0000000
--- a/rw-price-group-slice5.js
+++ /dev/null
@@ -1,413 +0,0 @@
-#!/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
deleted file mode 100644
index f6f7265..0000000
--- a/rw-price-group.js
+++ /dev/null
@@ -1,363 +0,0 @@
-#!/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
deleted file mode 100644
index aec33fa..0000000
--- a/scripts/rw-price-group-fix.js
+++ /dev/null
@@ -1,398 +0,0 @@
-#!/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
deleted file mode 100644
index 1c2a35e..0000000
--- a/scripts/rw-price-group-setter.js
+++ /dev/null
@@ -1,331 +0,0 @@
-#!/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
deleted file mode 100644
index ed5696c..0000000
--- a/scripts/rw-price-group-slice7.js
+++ /dev/null
@@ -1,318 +0,0 @@
-#!/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/tmp-rw-price-group.js b/tmp-rw-price-group.js
deleted file mode 100644
index 38e12a1..0000000
--- a/tmp-rw-price-group.js
+++ /dev/null
@@ -1,337 +0,0 @@
-#!/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); });
← 7debeb6 Stage GDrive cost-mine 2026-06-15: 772 rows (DW Exclusives 5
·
back to Dw Yolo Loop
·
chore: gitignore runtime artifacts + un-track gmc-425-result f5311b1 →