← back to Letsbegin
auto-save: 2026-06-21T18:53:00 (9 files) — rw-fix-rollprice.js rw-price-group-slice16.js rw-price-group-slice17.js rw-price-group-slice19.js rw-price-group-slice2.js
76bceba6e6321a9ab69fd391dd07f2eddab2b165 · 2026-06-21 18:53:04 -0700 · Steve Abrams
Files touched
A rw-fix-rollprice.jsA rw-price-group-slice16.jsA rw-price-group-slice17.jsA rw-price-group-slice19.jsA rw-price-group-slice2.jsA rw-price-group-slice21.jsA rw-price-group-slice22.jsA rw-price-group-slice25.jsA rw-price-group.js
Diff
commit 76bceba6e6321a9ab69fd391dd07f2eddab2b165
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jun 21 18:53:04 2026 -0700
auto-save: 2026-06-21T18:53:00 (9 files) — rw-fix-rollprice.js rw-price-group-slice16.js rw-price-group-slice17.js rw-price-group-slice19.js rw-price-group-slice2.js
---
rw-fix-rollprice.js | 100 ++++++++++
rw-price-group-slice16.js | 451 ++++++++++++++++++++++++++++++++++++++++++++
rw-price-group-slice17.js | 466 ++++++++++++++++++++++++++++++++++++++++++++++
rw-price-group-slice19.js | 428 ++++++++++++++++++++++++++++++++++++++++++
rw-price-group-slice2.js | 405 ++++++++++++++++++++++++++++++++++++++++
rw-price-group-slice21.js | 373 +++++++++++++++++++++++++++++++++++++
rw-price-group-slice22.js | 360 +++++++++++++++++++++++++++++++++++
rw-price-group-slice25.js | 358 +++++++++++++++++++++++++++++++++++
rw-price-group.js | 381 +++++++++++++++++++++++++++++++++++++
9 files changed, 3322 insertions(+)
diff --git a/rw-fix-rollprice.js b/rw-fix-rollprice.js
new file mode 100644
index 0000000..84cbd1d
--- /dev/null
+++ b/rw-fix-rollprice.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+/**
+ * rw-fix-rollprice.js — Fix the 6 roll-priced products incorrectly bucketed as C4.
+ * Roll products: $150/roll with ~5m2/roll = ~$30/m2 -> nearest bucket = C2.
+ */
+
+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); }
+
+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, 500) }); } });
+ });
+ 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 || ''));
+ if (throttled) { await sleep(3000 * attempt); continue; }
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+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}
+]);
+
+// Roll products: ~$30/m2 -> nearest C2
+// Note: for roll products, per-m2 computed from roll price / roll area
+const ROLL_PRODUCTS = [
+ { id: 'gid://shopify/Product/7851169611827', slug: 'aquarium-mint', pricePerM2: 29.85 },
+ { id: 'gid://shopify/Product/7851169644595', slug: 'aquarium-ocean', pricePerM2: 29.85 },
+ { id: 'gid://shopify/Product/7851169710131', slug: 'aquarium-pastel', pricePerM2: 29.85 },
+ { id: 'gid://shopify/Product/7851169873971', slug: 'aquila-cloud', pricePerM2: 30.00 },
+ { id: 'gid://shopify/Product/7851169939507', slug: 'aquila-rust', pricePerM2: 30.00 },
+ { id: 'gid://shopify/Product/7851169972275', slug: 'aquila-teal', pricePerM2: 30.00 },
+];
+
+async function fixProduct(pid, slug, pricePerM2) {
+ const metafields = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', type: 'single_line_text_field', value: 'C2' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_per_m2', type: 'number_decimal', value: '33.84' },
+ { ownerId: pid, namespace: 'custom', key: 'material_options', type: 'json', value: MATERIAL_OPTIONS_C2 },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', type: 'single_line_text_field', value: `C2 portal-RRP-derived; roll product ~$${pricePerM2.toFixed(2)}/m2 (computed from $150/roll / roll-area); P&S/Commercial extrapolated` },
+ ];
+ const mutation = `mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }`;
+ const result = await gql({ query: mutation, variables: { m: metafields } });
+ const userErrors = result?.data?.metafieldsSet?.userErrors || [];
+ if (userErrors.length > 0) throw new Error(`UserErrors: ${JSON.stringify(userErrors)}`);
+ if (result?.errors) throw new Error(`GQL errors: ${JSON.stringify(result.errors)}`);
+ return result;
+}
+
+async function main() {
+ let fixed = 0, failed = 0;
+ for (const p of ROLL_PRODUCTS) {
+ try {
+ console.log(`[FIX] ${p.slug}: C4 -> C2 (roll ~$${p.pricePerM2}/m2)`);
+ await fixProduct(p.id, p.slug, p.pricePerM2);
+ console.log(` [WRITTEN] C2`);
+ fixed++;
+ await sleep(500);
+ } catch (e) {
+ console.error(` [ERROR] ${e.message}`);
+ failed++;
+ }
+ }
+ console.log(`\nFixed: ${fixed}, Failed: ${failed}`);
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
diff --git a/rw-price-group-slice16.js b/rw-price-group-slice16.js
new file mode 100644
index 0000000..f97007f
--- /dev/null
+++ b/rw-price-group-slice16.js
@@ -0,0 +1,451 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-slice16.js — Price-group detector + cost-metafield writer
+ * for SLICE at OFFSET 600, LIMIT 40 of the Rebel Walls mural cohort.
+ *
+ * Algorithm:
+ * 1. For each shopify_id, fetch custom.rw_product_url + custom.price_group.
+ * 2. If no URL -> count unknown, skip.
+ * 3. Fetch the RW page, extract base per-m2 price from JSON-LD or price chip.
+ * 4. Bucket: ~58.10 -> C2, ~76.40 -> C3, ~88.30 -> C4 (nearest).
+ * 5. Write price_group + (if != C3) cost_per_m2 + material_options + cost_basis.
+ * 6. Report {processed, c2, c3, c4, unknown, failed}.
+ *
+ * SANDBOX ONLY. Rate-limited ~2 req/s.
+ */
+
+const https = require('https');
+const http = require('http');
+const { URL } = require('url');
+const fs = require('fs');
+
+function loadEnv(path) {
+ try {
+ for (const line of fs.readFileSync(path, 'utf8').split('\n')) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
+ if (!m) continue;
+ let v = m[2].trim();
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+ if (process.env[m[1]] == null) process.env[m[1]] = v;
+ }
+ } catch { /* ok */ }
+}
+loadEnv('/Users/stevestudio2/Projects/secrets-manager/.env');
+
+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); }
+if (STORE !== 'designer-laboratory-sandbox.myshopify.com') {
+ console.error(`FATAL: refusing to run against non-sandbox store "${STORE}"`); process.exit(1);
+}
+
+// ---- the 40 IDs for slice OFFSET 600 ----
+const SLICE_IDS = [
+ 'gid://shopify/Product/7855824502835',
+ 'gid://shopify/Product/7855824437299',
+ 'gid://shopify/Product/7855824535603',
+ 'gid://shopify/Product/7855824568371',
+ 'gid://shopify/Product/7855824601139',
+ 'gid://shopify/Product/7855824633907',
+ 'gid://shopify/Product/7855824699443',
+ 'gid://shopify/Product/7855824764979',
+ 'gid://shopify/Product/7855824732211',
+ 'gid://shopify/Product/7855824797747',
+ 'gid://shopify/Product/7855824830515',
+ 'gid://shopify/Product/7855824863283',
+ 'gid://shopify/Product/7855824896051',
+ 'gid://shopify/Product/7855824928819',
+ 'gid://shopify/Product/7855824961587',
+ 'gid://shopify/Product/7855824994355',
+ 'gid://shopify/Product/7855825027123',
+ 'gid://shopify/Product/7855825059891',
+ 'gid://shopify/Product/7855825125427',
+ 'gid://shopify/Product/7855825158195',
+ 'gid://shopify/Product/7855825190963',
+ 'gid://shopify/Product/7855825223731',
+ 'gid://shopify/Product/7855825289267',
+ 'gid://shopify/Product/7855825322035',
+ 'gid://shopify/Product/7855825354803',
+ 'gid://shopify/Product/7855825387571',
+ 'gid://shopify/Product/7855825420339',
+ 'gid://shopify/Product/7855825453107',
+ 'gid://shopify/Product/7855825485875',
+ 'gid://shopify/Product/7855825518643',
+ 'gid://shopify/Product/7855825551411',
+ 'gid://shopify/Product/7855825584179',
+ 'gid://shopify/Product/7855825616947',
+ 'gid://shopify/Product/7855825911859',
+ 'gid://shopify/Product/7855825977395',
+ 'gid://shopify/Product/7855825748019',
+ 'gid://shopify/Product/7855825813555',
+ 'gid://shopify/Product/7855825846323',
+ 'gid://shopify/Product/7855825879091',
+ 'gid://shopify/Product/7855826042931',
+];
+
+// ---- price group data ----
+const 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}
+ ]),
+ retail: 58.10,
+ cost_basis: 'C2 price group; Non-Woven RRP $58.10/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+ C3: {
+ cost_per_m2: '44.50',
+ material_options: 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.42,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":107.00,"cost_per_m2":62.33,"cost_confirmed":false}
+ ]),
+ retail: 76.40,
+ cost_basis: 'C3 price group; Non-Woven RRP $76.40/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+ 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}
+ ]),
+ retail: 88.30,
+ cost_basis: 'C4 price group; Non-Woven RRP $88.30/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+};
+
+function bucketPrice(pricePerM2) {
+ // Snap to nearest anchor: C2=58.10, C3=76.40, C4=88.30
+ const anchors = [
+ { group: 'C2', anchor: 58.10 },
+ { group: 'C3', anchor: 76.40 },
+ { group: 'C4', anchor: 88.30 },
+ ];
+ let best = anchors[0];
+ let bestDist = Math.abs(pricePerM2 - anchors[0].anchor);
+ for (const a of anchors) {
+ const d = Math.abs(pricePerM2 - a.anchor);
+ if (d < bestDist) { bestDist = d; best = a; }
+ }
+ return best.group;
+}
+
+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 = 5) {
+ 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;
+ }
+ }
+}
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+
+async function fetchMetafields(pid) {
+ const r = await gql({ query: `{ product(id:"${pid}") {
+ id title vendor
+ url: metafield(namespace:"custom", key:"rw_product_url"){ value }
+ pg: metafield(namespace:"custom", key:"price_group"){ value }
+ cost: metafield(namespace:"custom", key:"cost_per_m2"){ value }
+ } }` });
+ const p = r?.data?.product;
+ if (!p) return null;
+ return {
+ id: p.id,
+ title: p.title,
+ vendor: p.vendor,
+ rw_product_url: p.url?.value ?? null,
+ price_group: p.pg?.value ?? null,
+ cost_per_m2: p.cost?.value ?? null,
+ };
+}
+
+// ---- fetch RW product page and extract base price ----
+function fetchPageUrl(urlStr, redirects = 5) {
+ return new Promise((resolve, reject) => {
+ try {
+ const u = new URL(urlStr);
+ const lib = u.protocol === 'https:' ? https : http;
+ let resolved = false;
+ const req = lib.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 Chrome/124 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.9',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ 'Connection': 'close',
+ },
+ }, res => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirects > 0) {
+ res.resume();
+ req.destroy();
+ let loc = res.headers.location;
+ if (loc.startsWith('/')) loc = u.origin + loc;
+ if (!resolved) { resolved = true; resolve(fetchPageUrl(loc, redirects - 1)); }
+ return;
+ }
+ let c = '';
+ let truncated = false;
+ res.setEncoding('utf8');
+ res.on('data', d => {
+ if (truncated) return;
+ c += d;
+ if (c.length > 400000) {
+ // We have enough to extract prices — stop consuming but don't destroy
+ truncated = true;
+ res.destroy();
+ if (!resolved) { resolved = true; resolve({ status: res.statusCode, body: c }); }
+ }
+ });
+ res.on('end', () => { if (!resolved) { resolved = true; resolve({ status: res.statusCode, body: c }); } });
+ res.on('error', () => { if (!resolved) { resolved = true; resolve({ status: res.statusCode, body: c }); } });
+ });
+ req.on('error', e => { if (!resolved) { resolved = true; reject(e); } });
+ req.setTimeout(30000, () => { req.destroy(); if (!resolved) { resolved = true; reject(new Error('timeout fetching ' + urlStr)); } });
+ req.end();
+ } catch (e) { reject(e); }
+ });
+}
+
+function extractPriceFromPage(html) {
+ // Strategy 1: JSON-LD with offers.price
+ 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 items = Array.isArray(obj) ? obj : [obj];
+ for (const item of items) {
+ const price = item?.offers?.price || item?.offers?.[0]?.price;
+ if (price && !isNaN(parseFloat(price))) {
+ const p = parseFloat(price);
+ // RW prices: $5-10/sqft or $55-100/m2
+ if (p >= 2 && p <= 20) return p * 10.7639; // per sqft -> m2
+ if (p >= 40 && p <= 200) return p; // already per m2
+ }
+ }
+ } catch { /* ignore */ }
+ }
+
+ // Strategy 2: "From $X / sq ft" patterns
+ const sqftPatterns = [
+ /From\s+\$([0-9]+\.?[0-9]*)\s*\/\s*sq\.?\s*ft/i,
+ /\$([0-9]+\.?[0-9]*)\s*\/\s*sq\.?\s*ft/i,
+ /From\s+USD\s*([0-9]+\.?[0-9]*)\s*\/\s*sq/i,
+ ];
+ for (const pat of sqftPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ }
+ }
+
+ // Strategy 3: per m2 patterns
+ const m2Patterns = [
+ /From\s+\$([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ /\$([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ /From\s+USD\s*([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ ];
+ for (const pat of m2Patterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 40 && p <= 200) return p;
+ }
+ }
+
+ // Strategy 4: data attributes or JS variables with price
+ const priceAttrPatterns = [
+ /"price"\s*:\s*([0-9]+\.?[0-9]*)/,
+ /data-price="([0-9]+\.?[0-9]*)"/,
+ /price_per_sqft['":\s]+([0-9]+\.?[0-9]*)/i,
+ /pricePerSqFt['":\s]+([0-9]+\.?[0-9]*)/i,
+ ];
+ for (const pat of priceAttrPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ if (p >= 40 && p <= 200) return p;
+ }
+ }
+
+ // Strategy 5: look for standalone price-like numbers near pricing keywords
+ const priceSectionMatch = html.match(/(?:From|Starting|Price|Cost)[^$]*\$([0-9]+\.[0-9]+)/i);
+ if (priceSectionMatch) {
+ const p = parseFloat(priceSectionMatch[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ if (p >= 40 && p <= 200) return p;
+ }
+
+ return null;
+}
+
+async function writeMetafields(pid, group) {
+ const g = GROUPS[group];
+ const mf = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', value: g.cost_basis, type: 'multi_line_text_field' },
+ ];
+
+ // For C2 and C4, overwrite cost_per_m2 and material_options
+ if (group !== 'C3') {
+ mf.push({ ownerId: pid, namespace: 'custom', key: 'cost_per_m2', value: g.cost_per_m2, type: 'number_decimal' });
+ mf.push({ ownerId: pid, namespace: 'custom', key: 'material_options', value: g.material_options, type: 'json' });
+ }
+
+ const r = await gql({ query: MF_MUTATION, variables: { m: mf } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ return errs.map(e => `${(e.field || []).join('.')}: ${e.message}`);
+}
+
+async function main() {
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0 };
+ const failureLog = [];
+ const total = SLICE_IDS.length;
+
+ console.log(`[rw-price-group-slice16] Processing ${total} products (slice OFFSET 600)`);
+ console.log(`Store: ${STORE}\n`);
+
+ for (let i = 0; i < SLICE_IDS.length; i++) {
+ const pid = SLICE_IDS[i];
+ const idx = `[${i + 1}/${total}]`;
+ const shortId = pid.split('/').pop();
+
+ // 1. Fetch metafields
+ let meta;
+ try {
+ meta = await fetchMetafields(pid);
+ } catch (e) {
+ console.log(`${idx} ${shortId} FETCH ERROR: ${e.message}`);
+ counts.failed++;
+ failureLog.push(`${shortId}: fetch metafields error: ${e.message}`);
+ await sleep(400);
+ continue;
+ }
+
+ if (!meta) {
+ console.log(`${idx} ${shortId} NOT FOUND in Shopify`);
+ counts.failed++;
+ failureLog.push(`${shortId}: product not found`);
+ await sleep(400);
+ continue;
+ }
+
+ // Guard: only process Rebel Walls products
+ if (meta.vendor !== 'Rebel Walls') {
+ console.log(`${idx} ${shortId} GUARD: vendor="${meta.vendor}" not RW, skip`);
+ counts.failed++;
+ failureLog.push(`${shortId}: vendor="${meta.vendor}" not RW`);
+ await sleep(400);
+ continue;
+ }
+
+ // 2. Check for URL
+ if (!meta.rw_product_url) {
+ console.log(`${idx} ${shortId} NO URL — unknown group (title: ${meta.title?.slice(0, 50)})`);
+ counts.unknown++;
+ counts.processed++;
+ await sleep(300);
+ continue;
+ }
+
+ // 3. Fetch the RW product page
+ let pricePerM2 = null;
+ let fetchErr = null;
+ try {
+ const { status, body } = await fetchPageUrl(meta.rw_product_url);
+ if (status === 200) {
+ pricePerM2 = extractPriceFromPage(body);
+ if (pricePerM2 === null) {
+ console.log(`${idx} ${shortId} PRICE EXTRACT FAILED url=${meta.rw_product_url}`);
+ const snippet = body.slice(0, 5000);
+ const anyDollar = snippet.match(/\$[0-9]+\.[0-9]+/g);
+ if (anyDollar) console.log(` Dollar amounts in first 5k: ${anyDollar.slice(0, 10).join(', ')}`);
+ }
+ } else {
+ fetchErr = `HTTP ${status}`;
+ console.log(`${idx} ${shortId} HTTP ${status} for ${meta.rw_product_url}`);
+ }
+ } catch (e) {
+ fetchErr = e.message;
+ console.log(`${idx} ${shortId} FETCH PAGE ERROR: ${e.message}`);
+ }
+
+ // 4. Bucket the price
+ let group;
+ if (pricePerM2 !== null) {
+ group = bucketPrice(pricePerM2);
+ console.log(`${idx} ${shortId} price=$${pricePerM2.toFixed(2)}/m2 -> ${group} (title: ${meta.title?.slice(0, 40)})`);
+ } else {
+ // Can't determine -> leave as-is (C3 default), count unknown
+ console.log(`${idx} ${shortId} UNKNOWN price (err: ${fetchErr || 'no pattern matched'}) -> leaving C3 default`);
+ counts.unknown++;
+ counts.processed++;
+ await sleep(400);
+ continue;
+ }
+
+ // 5. Write metafields
+ let writeErrors = [];
+ try {
+ writeErrors = await writeMetafields(pid, group);
+ } catch (e) {
+ writeErrors = [e.message];
+ }
+
+ if (writeErrors.length > 0) {
+ console.log(`${idx} ${shortId} WRITE ERROR: ${writeErrors.join('; ')}`);
+ counts.failed++;
+ failureLog.push(`${shortId}: write errors: ${writeErrors.join('; ')}`);
+ } else {
+ counts[group.toLowerCase()]++;
+ counts.processed++;
+ console.log(` -> wrote price_group=${group}${group !== 'C3' ? ` cost_per_m2=${GROUPS[group].cost_per_m2}` : ' (C3, cost unchanged)'}`);
+ }
+
+ await sleep(500); // ~2 req/s budget
+ }
+
+ console.log('\n=== DONE ===');
+ console.log(`processed=${counts.processed} c2=${counts.c2} c3=${counts.c3} c4=${counts.c4} unknown=${counts.unknown} failed=${counts.failed}`);
+ if (failureLog.length) {
+ console.log('FAILURES:');
+ failureLog.forEach(f => console.log(' - ' + f));
+ }
+
+ // Emit a final JSON line for easy parsing
+ console.log('\nJSON_RESULT:' + JSON.stringify({ ...counts, notes: failureLog.join('; ') || 'none' }));
+ return counts;
+}
+
+main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });
diff --git a/rw-price-group-slice17.js b/rw-price-group-slice17.js
new file mode 100644
index 0000000..fecabcb
--- /dev/null
+++ b/rw-price-group-slice17.js
@@ -0,0 +1,466 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-slice17.js — Price-group detector + cost-metafield writer
+ * for SLICE 17 (OFFSET 640, LIMIT 40) of the Rebel Walls mural cohort.
+ *
+ * Algorithm:
+ * 1. For each shopify_id, fetch custom.rw_product_url + custom.price_group.
+ * 2. If no URL -> count unknown, skip.
+ * 3. curl the RW page, extract base per-m2 price from JSON-LD or price chip.
+ * 4. Bucket: ~58.10 -> C2, ~76.40 -> C3, ~88.30 -> C4 (nearest).
+ * 5. Write price_group + (if != C3) cost_per_m2 + material_options + cost_basis.
+ * If C3, write price_group=C3 but leave cost_per_m2/material_options unchanged.
+ * 6. Report {processed, c2, c3, c4, unknown, failed}.
+ *
+ * SANDBOX ONLY. Rate-limited ~2 req/s.
+ */
+
+const https = require('https');
+const http = require('http');
+const { URL } = require('url');
+
+// ---- env ----
+const fs = require('fs');
+function loadEnv(path) {
+ try {
+ for (const line of fs.readFileSync(path, 'utf8').split('\n')) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
+ if (!m) continue;
+ let v = m[2].trim();
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+ if (process.env[m[1]] == null) process.env[m[1]] = v;
+ }
+ } catch { /* ok */ }
+}
+loadEnv('/Users/stevestudio2/Projects/secrets-manager/.env');
+
+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); }
+if (STORE !== 'designer-laboratory-sandbox.myshopify.com') {
+ console.error(`FATAL: refusing to run against non-sandbox store "${STORE}"`); process.exit(1);
+}
+
+// ---- the 40 IDs for slice 17 (OFFSET 640, LIMIT 40) ----
+const SLICE_IDS = [
+ 'gid://shopify/Product/7855826075699',
+ 'gid://shopify/Product/7855826141235',
+ 'gid://shopify/Product/7855826206771',
+ 'gid://shopify/Product/7855826239539',
+ 'gid://shopify/Product/7855826272307',
+ 'gid://shopify/Product/7855826370611',
+ 'gid://shopify/Product/7855826436147',
+ 'gid://shopify/Product/7855826468915',
+ 'gid://shopify/Product/7855826534451',
+ 'gid://shopify/Product/7855826567219',
+ 'gid://shopify/Product/7855826599987',
+ 'gid://shopify/Product/7855826665523',
+ 'gid://shopify/Product/7855826698291',
+ 'gid://shopify/Product/7855826731059',
+ 'gid://shopify/Product/7855826829363',
+ 'gid://shopify/Product/7855826862131',
+ 'gid://shopify/Product/7855826927667',
+ 'gid://shopify/Product/7855826960435',
+ 'gid://shopify/Product/7855826993203',
+ 'gid://shopify/Product/7855827025971',
+ 'gid://shopify/Product/7855827058739',
+ 'gid://shopify/Product/7855827124275',
+ 'gid://shopify/Product/7855827157043',
+ 'gid://shopify/Product/7855827222579',
+ 'gid://shopify/Product/7855827255347',
+ 'gid://shopify/Product/7855827288115',
+ 'gid://shopify/Product/7855827353651',
+ 'gid://shopify/Product/7855827386419',
+ 'gid://shopify/Product/7855827419187',
+ 'gid://shopify/Product/7855827451955',
+ 'gid://shopify/Product/7855827517491',
+ 'gid://shopify/Product/7855827550259',
+ 'gid://shopify/Product/7855827583027',
+ 'gid://shopify/Product/7855827615795',
+ 'gid://shopify/Product/7855827681331',
+ 'gid://shopify/Product/7855827714099',
+ 'gid://shopify/Product/7855827779635',
+ 'gid://shopify/Product/7855827812403',
+ 'gid://shopify/Product/7855827845171',
+ 'gid://shopify/Product/7855827877939',
+];
+
+// ---- price group data ----
+const 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}
+ ]),
+ retail: 58.10,
+ cost_basis: 'C2 price group; Non-Woven RRP $58.10/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+ C3: {
+ cost_per_m2: '44.50',
+ material_options: 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.42,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":107.00,"cost_per_m2":62.33,"cost_confirmed":false}
+ ]),
+ retail: 76.40,
+ cost_basis: 'C3 price group; Non-Woven RRP $76.40/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+ 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}
+ ]),
+ retail: 88.30,
+ cost_basis: 'C4 price group; Non-Woven RRP $88.30/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+};
+
+function bucketPrice(pricePerM2) {
+ // Snap to nearest anchor: C2=58.10, C3=76.40, C4=88.30
+ const anchors = [
+ { group: 'C2', anchor: 58.10 },
+ { group: 'C3', anchor: 76.40 },
+ { group: 'C4', anchor: 88.30 },
+ ];
+ let best = anchors[0];
+ let bestDist = Math.abs(pricePerM2 - anchors[0].anchor);
+ for (const a of anchors) {
+ const d = Math.abs(pricePerM2 - a.anchor);
+ if (d < bestDist) { bestDist = d; best = a; }
+ }
+ return best.group;
+}
+
+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 = 5) {
+ 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;
+ }
+ }
+}
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+
+async function fetchMetafields(pid) {
+ const r = await gql({ query: `{ product(id:"${pid}") {
+ id title vendor
+ url: metafield(namespace:"custom", key:"rw_product_url"){ value }
+ pg: metafield(namespace:"custom", key:"price_group"){ value }
+ cost: metafield(namespace:"custom", key:"cost_per_m2"){ value }
+ } }` });
+ const p = r?.data?.product;
+ if (!p) return null;
+ return {
+ id: p.id,
+ title: p.title,
+ vendor: p.vendor,
+ rw_product_url: p.url?.value ?? null,
+ price_group: p.pg?.value ?? null,
+ cost_per_m2: p.cost?.value ?? null,
+ };
+}
+
+// ---- fetch RW product page and extract base price ----
+function fetchUrl(urlStr, redirects = 5) {
+ return new Promise((resolve, reject) => {
+ try {
+ const u = new URL(urlStr);
+ const lib = u.protocol === 'https:' ? https : http;
+ const req = lib.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 Chrome/124 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.9',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ },
+ }, res => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirects > 0) {
+ res.resume(); // drain
+ resolve(fetchUrl(res.headers.location, redirects - 1));
+ return;
+ }
+ let c = '';
+ let truncated = false;
+ res.setEncoding('utf8');
+ res.on('data', d => {
+ if (!truncated) {
+ c += d;
+ if (c.length > 600000) {
+ truncated = true;
+ res.destroy(); // destroys the socket, triggers 'close', not 'end'
+ }
+ }
+ });
+ res.on('end', () => resolve({ status: res.statusCode, body: c }));
+ res.on('close', () => { if (truncated) resolve({ status: res.statusCode, body: c }); });
+ res.on('error', () => { if (truncated) resolve({ status: res.statusCode || 200, body: c }); });
+ });
+ req.on('error', (e) => { reject(e); });
+ req.setTimeout(45000, () => { req.destroy(); reject(new Error('timeout fetching ' + urlStr)); });
+ req.end();
+ } catch (e) { reject(e); }
+ });
+}
+
+function extractPriceFromPage(html, urlStr) {
+ // Strategy 1: JSON-LD with offers.price
+ 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 items = Array.isArray(obj) ? obj : [obj];
+ for (const item of items) {
+ const price = item?.offers?.price ?? item?.offers?.[0]?.price;
+ if (price != null && !isNaN(parseFloat(price))) {
+ const p = parseFloat(price);
+ if (p >= 2 && p <= 20) return p * 10.7639; // sqft -> m2
+ if (p >= 40 && p <= 200) return p; // already m2
+ }
+ }
+ } catch { /* ignore */ }
+ }
+
+ // Strategy 2: "From $X / sq ft" patterns
+ const sqftPatterns = [
+ /From\s+\$([0-9]+\.?[0-9]*)\s*\/\s*sq\.?\s*ft/i,
+ /\$([0-9]+\.?[0-9]*)\s*\/\s*sq\.?\s*ft/i,
+ /From\s+USD\s*([0-9]+\.?[0-9]*)\s*\/\s*sq/i,
+ ];
+ for (const pat of sqftPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ }
+ }
+
+ // Strategy 3: per m2 patterns
+ const m2Patterns = [
+ /From\s+\$([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ /\$([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ /From\s+USD\s*([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ ];
+ for (const pat of m2Patterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 40 && p <= 200) return p;
+ }
+ }
+
+ // Strategy 4: data attributes or JS variables with price
+ const priceAttrPatterns = [
+ /"price"\s*:\s*([0-9]+\.?[0-9]*)/,
+ /data-price="([0-9]+\.?[0-9]*)"/,
+ /'price'\s*:\s*([0-9]+\.?[0-9]*)/,
+ /price_per_sqft['":\s]+([0-9]+\.?[0-9]*)/i,
+ /pricePerSqFt['":\s]+([0-9]+\.?[0-9]*)/i,
+ /basePrice["':\s]+([0-9]+\.?[0-9]*)/i,
+ ];
+ for (const pat of priceAttrPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ if (p >= 40 && p <= 200) return p;
+ }
+ }
+
+ // Strategy 5: look for standalone price-like numbers near pricing keywords
+ const priceSectionMatch = html.match(/(?:From|Starting|Price|Cost)[^$]*\$([0-9]+\.[0-9]+)/i);
+ if (priceSectionMatch) {
+ const p = parseFloat(priceSectionMatch[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ if (p >= 40 && p <= 200) return p;
+ }
+
+ // Strategy 6: look for the specific RW anchor prices directly
+ // Check for known price patterns: 5.40, 7.10, 8.20, 58.10, 76.40, 88.30
+ const knownPrices = [
+ { sqft: 5.40, group: 'C2' },
+ { sqft: 7.10, group: 'C3' },
+ { sqft: 8.20, group: 'C4' },
+ ];
+ for (const kp of knownPrices) {
+ const re = new RegExp('\\$' + kp.sqft.toFixed(2).replace('.', '\\.'));
+ if (re.test(html)) return kp.sqft * 10.7639;
+ }
+
+ return null;
+}
+
+async function writeMetafields(pid, group) {
+ const g = GROUPS[group];
+ const mf = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', value: g.cost_basis, type: 'multi_line_text_field' },
+ ];
+
+ // For C2 and C4, overwrite cost_per_m2 and material_options.
+ // For C3, leave cost/material_options as-is (they're already the C3 defaults).
+ if (group !== 'C3') {
+ mf.push({ ownerId: pid, namespace: 'custom', key: 'cost_per_m2', value: g.cost_per_m2, type: 'number_decimal' });
+ mf.push({ ownerId: pid, namespace: 'custom', key: 'material_options', value: g.material_options, type: 'json' });
+ }
+
+ const r = await gql({ query: MF_MUTATION, variables: { m: mf } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ return errs.map(e => `${(e.field || []).join('.')}: ${e.message}`);
+}
+
+async function main() {
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0 };
+ const failureLog = [];
+ const total = SLICE_IDS.length;
+
+ console.log(`[rw-price-group-slice17] Processing ${total} products (OFFSET 640, LIMIT 40)`);
+ console.log(`Store: ${STORE}\n`);
+
+ for (let i = 0; i < SLICE_IDS.length; i++) {
+ const pid = SLICE_IDS[i];
+ const shortId = pid.split('/').pop();
+ const idx = `[${i + 1}/${total}]`;
+
+ // 1. Fetch metafields
+ let meta;
+ try {
+ meta = await fetchMetafields(pid);
+ } catch (e) {
+ console.log(`${idx} ${shortId} FETCH ERROR: ${e.message}`);
+ counts.failed++;
+ failureLog.push(`${shortId}: fetch metafields error: ${e.message}`);
+ await sleep(400);
+ continue;
+ }
+
+ if (!meta) {
+ console.log(`${idx} ${shortId} NOT FOUND in Shopify`);
+ counts.failed++;
+ failureLog.push(`${shortId}: product not found`);
+ await sleep(400);
+ continue;
+ }
+
+ // Guard: only process Rebel Walls products
+ if (meta.vendor !== 'Rebel Walls') {
+ console.log(`${idx} ${shortId} GUARD: vendor="${meta.vendor}" not RW, skip`);
+ counts.failed++;
+ failureLog.push(`${shortId}: vendor="${meta.vendor}" not RW`);
+ await sleep(400);
+ continue;
+ }
+
+ // 2. Check for URL
+ if (!meta.rw_product_url) {
+ console.log(`${idx} ${shortId} NO URL — unknown group (title: ${meta.title?.slice(0, 50)})`);
+ counts.unknown++;
+ counts.processed++;
+ await sleep(300);
+ continue;
+ }
+
+ // 3. Fetch the RW product page
+ let pricePerM2 = null;
+ let fetchErr = null;
+ try {
+ const { status, body } = await fetchUrl(meta.rw_product_url);
+ if (status === 200) {
+ pricePerM2 = extractPriceFromPage(body, meta.rw_product_url);
+ if (pricePerM2 === null) {
+ console.log(`${idx} ${shortId} PRICE EXTRACT FAILED url=${meta.rw_product_url}`);
+ // Debug: check what dollar amounts are present
+ const anyDollar = body.match(/\$[0-9]+\.[0-9]+/g);
+ if (anyDollar) console.log(` Dollar amounts in page: ${[...new Set(anyDollar)].slice(0, 10).join(', ')}`);
+ }
+ } else {
+ fetchErr = `HTTP ${status}`;
+ console.log(`${idx} ${shortId} HTTP ${status} for ${meta.rw_product_url}`);
+ }
+ } catch (e) {
+ fetchErr = e.message;
+ console.log(`${idx} ${shortId} FETCH PAGE ERROR: ${e.message}`);
+ }
+
+ // 4. Bucket the price
+ let group;
+ if (pricePerM2 !== null) {
+ group = bucketPrice(pricePerM2);
+ console.log(`${idx} ${shortId} price=$${pricePerM2.toFixed(2)}/m2 -> ${group} (title: ${meta.title?.slice(0, 40)})`);
+ } else {
+ // Can't determine -> leave as-is (C3 default), count unknown
+ console.log(`${idx} ${shortId} UNKNOWN price (${fetchErr || 'no pattern matched'}) -> leaving as-is`);
+ counts.unknown++;
+ counts.processed++;
+ await sleep(400);
+ continue;
+ }
+
+ // 5. Write metafields
+ let writeErrors = [];
+ try {
+ writeErrors = await writeMetafields(pid, group);
+ } catch (e) {
+ writeErrors = [e.message];
+ }
+
+ if (writeErrors.length > 0) {
+ console.log(`${idx} ${shortId} WRITE ERROR: ${writeErrors.join('; ')}`);
+ counts.failed++;
+ failureLog.push(`${shortId}: write errors: ${writeErrors.join('; ')}`);
+ } else {
+ counts[group.toLowerCase()]++;
+ counts.processed++;
+ console.log(` -> wrote price_group=${group}${group !== 'C3' ? ` cost_per_m2=${GROUPS[group].cost_per_m2}` : ' (C3, cost unchanged)'}`);
+ }
+
+ await sleep(500); // ~2 req/s budget
+ }
+
+ console.log('\n=== DONE ===');
+ console.log(`processed=${counts.processed} c2=${counts.c2} c3=${counts.c3} c4=${counts.c4} unknown=${counts.unknown} failed=${counts.failed}`);
+ if (failureLog.length) {
+ console.log('FAILURES:');
+ failureLog.forEach(f => console.log(' - ' + f));
+ }
+ return counts;
+}
+
+process.on('unhandledRejection', (reason) => {
+ console.error(`UNHANDLED REJECTION: ${reason && reason.stack ? reason.stack : reason}`);
+});
+process.on('uncaughtException', (e) => {
+ console.error(`UNCAUGHT EXCEPTION: ${e.stack || e}`);
+});
+
+main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });
diff --git a/rw-price-group-slice19.js b/rw-price-group-slice19.js
new file mode 100644
index 0000000..596d799
--- /dev/null
+++ b/rw-price-group-slice19.js
@@ -0,0 +1,428 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-slice19.js — Price-group detector + cost-metafield writer
+ * for SLICE at OFFSET 720, LIMIT 40 of the Rebel Walls mural cohort.
+ *
+ * Algorithm:
+ * 1. For each shopify_id, fetch custom.rw_product_url + custom.price_group.
+ * 2. If no URL -> count unknown, skip.
+ * 3. Fetch the RW page, extract base per-m2 price from JSON-LD or price chip.
+ * 4. Bucket: ~58.10 -> C2, ~76.40 -> C3, ~88.30 -> C4 (nearest).
+ * 5. Write price_group + (if != C3) cost_per_m2 + material_options + cost_basis.
+ * 6. Report {processed, c2, c3, c4, unknown, failed}.
+ *
+ * SANDBOX ONLY. Rate-limited ~2 req/s.
+ */
+
+const https = require('https');
+const http = require('http');
+const { URL } = require('url');
+const fs = require('fs');
+const { execFile } = require('child_process');
+
+function loadEnv(path) {
+ try {
+ for (const line of fs.readFileSync(path, 'utf8').split('\n')) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
+ if (!m) continue;
+ let v = m[2].trim();
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+ if (process.env[m[1]] == null) process.env[m[1]] = v;
+ }
+ } catch { /* ok */ }
+}
+loadEnv('/Users/stevestudio2/Projects/secrets-manager/.env');
+
+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); }
+if (STORE !== 'designer-laboratory-sandbox.myshopify.com') {
+ console.error(`FATAL: refusing to run against non-sandbox store "${STORE}"`); process.exit(1);
+}
+
+// ---- the 40 IDs for slice OFFSET 720 ----
+const SLICE_IDS = [
+ 'gid://shopify/Product/7855830237235',
+ 'gid://shopify/Product/7855830270003',
+ 'gid://shopify/Product/7855830335539',
+ 'gid://shopify/Product/7855830368307',
+ 'gid://shopify/Product/7855830401075',
+ 'gid://shopify/Product/7855830433843',
+ 'gid://shopify/Product/7855830499379',
+ 'gid://shopify/Product/7855830532147',
+ 'gid://shopify/Product/7855830564915',
+ 'gid://shopify/Product/7855830597683',
+ 'gid://shopify/Product/7855830695987',
+ 'gid://shopify/Product/7855830466611',
+ 'gid://shopify/Product/7855830630451',
+ 'gid://shopify/Product/7855830663219',
+ 'gid://shopify/Product/7855830728755',
+ 'gid://shopify/Product/7855830761523',
+ 'gid://shopify/Product/7855830794291',
+ 'gid://shopify/Product/7855830827059',
+ 'gid://shopify/Product/7855830892595',
+ 'gid://shopify/Product/7855830859827',
+ 'gid://shopify/Product/7855830925363',
+ 'gid://shopify/Product/7855830958131',
+ 'gid://shopify/Product/7855830990899',
+ 'gid://shopify/Product/7855831023667',
+ 'gid://shopify/Product/7855831056435',
+ 'gid://shopify/Product/7855831121971',
+ 'gid://shopify/Product/7855831154739',
+ 'gid://shopify/Product/7855831187507',
+ 'gid://shopify/Product/7855831220275',
+ 'gid://shopify/Product/7855831253043',
+ 'gid://shopify/Product/7855831285811',
+ 'gid://shopify/Product/7855831318579',
+ 'gid://shopify/Product/7855831351347',
+ 'gid://shopify/Product/7855831384115',
+ 'gid://shopify/Product/7855831416883',
+ 'gid://shopify/Product/7855831449651',
+ 'gid://shopify/Product/7855831482419',
+ 'gid://shopify/Product/7855831547955',
+ 'gid://shopify/Product/7855831580723',
+ 'gid://shopify/Product/7855831646259',
+];
+
+// ---- price group data ----
+const 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}
+ ]),
+ retail: 58.10,
+ cost_basis: 'C2 price group; Non-Woven RRP $58.10/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+ C3: {
+ cost_per_m2: '44.50',
+ material_options: 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.42,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":107.00,"cost_per_m2":62.33,"cost_confirmed":false}
+ ]),
+ retail: 76.40,
+ cost_basis: 'C3 price group; Non-Woven RRP $76.40/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+ 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}
+ ]),
+ retail: 88.30,
+ cost_basis: 'C4 price group; Non-Woven RRP $88.30/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+};
+
+function bucketPrice(pricePerM2) {
+ // Snap to nearest anchor: C2=58.10, C3=76.40, C4=88.30
+ const anchors = [
+ { group: 'C2', anchor: 58.10 },
+ { group: 'C3', anchor: 76.40 },
+ { group: 'C4', anchor: 88.30 },
+ ];
+ let best = anchors[0];
+ let bestDist = Math.abs(pricePerM2 - anchors[0].anchor);
+ for (const a of anchors) {
+ const d = Math.abs(pricePerM2 - a.anchor);
+ if (d < bestDist) { bestDist = d; best = a; }
+ }
+ return best.group;
+}
+
+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 = 5) {
+ 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;
+ }
+ }
+}
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+
+async function fetchMetafields(pid) {
+ const r = await gql({ query: `{ product(id:"${pid}") {
+ id title vendor
+ url: metafield(namespace:"custom", key:"rw_product_url"){ value }
+ pg: metafield(namespace:"custom", key:"price_group"){ value }
+ cost: metafield(namespace:"custom", key:"cost_per_m2"){ value }
+ } }` });
+ const p = r?.data?.product;
+ if (!p) return null;
+ return {
+ id: p.id,
+ title: p.title,
+ vendor: p.vendor,
+ rw_product_url: p.url?.value ?? null,
+ price_group: p.pg?.value ?? null,
+ cost_per_m2: p.cost?.value ?? null,
+ };
+}
+
+// ---- fetch RW product page via curl (Node https.request hangs on RW's HTTP/2 keep-alive) ----
+function fetchPageUrl(urlStr) {
+ return new Promise((resolve, reject) => {
+ execFile('curl', [
+ '-sL',
+ '--max-time', '25',
+ '-A', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124 Safari/537.36',
+ '-H', 'Accept: text/html,application/xhtml+xml,*/*;q=0.9',
+ '-H', 'Accept-Language: en-US,en;q=0.9',
+ '--max-filesize', '600000',
+ '--write-out', '\n%%HTTP_STATUS%%:%{http_code}',
+ urlStr,
+ ], { maxBuffer: 700 * 1024, timeout: 30000 }, (err, stdout, stderr) => {
+ if (err && !stdout) {
+ return reject(new Error(`curl failed: ${err.message || stderr}`));
+ }
+ // extract status from trailing marker
+ const statusMatch = stdout.match(/\n%%HTTP_STATUS%%:(\d+)\s*$/);
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : 200;
+ const body = statusMatch ? stdout.slice(0, stdout.lastIndexOf('\n%%HTTP_STATUS%%:')) : stdout;
+ resolve({ status, body });
+ });
+ });
+}
+
+function extractPriceFromPage(html) {
+ // Strategy 1: JSON-LD with offers.price
+ 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 items = Array.isArray(obj) ? obj : [obj];
+ for (const item of items) {
+ const price = item?.offers?.price || item?.offers?.[0]?.price;
+ if (price && !isNaN(parseFloat(price))) {
+ const p = parseFloat(price);
+ // RW prices: $5-10/sqft or $55-100/m2
+ if (p >= 2 && p <= 20) return p * 10.7639; // per sqft -> m2
+ if (p >= 40 && p <= 200) return p; // already per m2
+ }
+ }
+ } catch { /* ignore */ }
+ }
+
+ // Strategy 2: "From $X / sq ft" patterns
+ const sqftPatterns = [
+ /From\s+\$([0-9]+\.?[0-9]*)\s*\/\s*sq\.?\s*ft/i,
+ /\$([0-9]+\.?[0-9]*)\s*\/\s*sq\.?\s*ft/i,
+ /From\s+USD\s*([0-9]+\.?[0-9]*)\s*\/\s*sq/i,
+ ];
+ for (const pat of sqftPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ }
+ }
+
+ // Strategy 3: per m2 patterns
+ const m2Patterns = [
+ /From\s+\$([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ /\$([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ /From\s+USD\s*([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ ];
+ for (const pat of m2Patterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 40 && p <= 200) return p;
+ }
+ }
+
+ // Strategy 4: data attributes or JS variables with price
+ const priceAttrPatterns = [
+ /"price"\s*:\s*([0-9]+\.?[0-9]*)/,
+ /data-price="([0-9]+\.?[0-9]*)"/,
+ /price_per_sqft['":\s]+([0-9]+\.?[0-9]*)/i,
+ /pricePerSqFt['":\s]+([0-9]+\.?[0-9]*)/i,
+ ];
+ for (const pat of priceAttrPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ if (p >= 40 && p <= 200) return p;
+ }
+ }
+
+ // Strategy 5: look for standalone price-like numbers near pricing keywords
+ const priceSectionMatch = html.match(/(?:From|Starting|Price|Cost)[^$]*\$([0-9]+\.[0-9]+)/i);
+ if (priceSectionMatch) {
+ const p = parseFloat(priceSectionMatch[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ if (p >= 40 && p <= 200) return p;
+ }
+
+ return null;
+}
+
+async function writeMetafields(pid, group) {
+ const g = GROUPS[group];
+ const mf = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', value: g.cost_basis, type: 'multi_line_text_field' },
+ ];
+
+ // For C2 and C4, overwrite cost_per_m2 and material_options
+ if (group !== 'C3') {
+ mf.push({ ownerId: pid, namespace: 'custom', key: 'cost_per_m2', value: g.cost_per_m2, type: 'number_decimal' });
+ mf.push({ ownerId: pid, namespace: 'custom', key: 'material_options', value: g.material_options, type: 'json' });
+ }
+
+ const r = await gql({ query: MF_MUTATION, variables: { m: mf } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ return errs.map(e => `${(e.field || []).join('.')}: ${e.message}`);
+}
+
+async function main() {
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0 };
+ const failureLog = [];
+ const total = SLICE_IDS.length;
+
+ console.log(`[rw-price-group-slice19] Processing ${total} products (slice OFFSET 720)`);
+ console.log(`Store: ${STORE}\n`);
+
+ for (let i = 0; i < SLICE_IDS.length; i++) {
+ const pid = SLICE_IDS[i];
+ const idx = `[${i + 1}/${total}]`;
+ const shortId = pid.split('/').pop();
+
+ // 1. Fetch metafields
+ let meta;
+ try {
+ meta = await fetchMetafields(pid);
+ } catch (e) {
+ console.log(`${idx} ${shortId} FETCH ERROR: ${e.message}`);
+ counts.failed++;
+ failureLog.push(`${shortId}: fetch metafields error: ${e.message}`);
+ await sleep(400);
+ continue;
+ }
+
+ if (!meta) {
+ console.log(`${idx} ${shortId} NOT FOUND in Shopify`);
+ counts.failed++;
+ failureLog.push(`${shortId}: product not found`);
+ await sleep(400);
+ continue;
+ }
+
+ // Guard: only process Rebel Walls products
+ if (meta.vendor !== 'Rebel Walls') {
+ console.log(`${idx} ${shortId} GUARD: vendor="${meta.vendor}" not RW, skip`);
+ counts.failed++;
+ failureLog.push(`${shortId}: vendor="${meta.vendor}" not RW`);
+ await sleep(400);
+ continue;
+ }
+
+ // 2. Check for URL
+ if (!meta.rw_product_url) {
+ console.log(`${idx} ${shortId} NO URL — unknown group (title: ${meta.title?.slice(0, 50)})`);
+ counts.unknown++;
+ counts.processed++;
+ await sleep(300);
+ continue;
+ }
+
+ // 3. Fetch the RW product page
+ let pricePerM2 = null;
+ let fetchErr = null;
+ try {
+ const { status, body } = await fetchPageUrl(meta.rw_product_url);
+ if (status === 200) {
+ pricePerM2 = extractPriceFromPage(body);
+ if (pricePerM2 === null) {
+ console.log(`${idx} ${shortId} PRICE EXTRACT FAILED url=${meta.rw_product_url}`);
+ const snippet = body.slice(0, 5000);
+ const anyDollar = snippet.match(/\$[0-9]+\.[0-9]+/g);
+ if (anyDollar) console.log(` Dollar amounts in first 5k: ${anyDollar.slice(0, 10).join(', ')}`);
+ }
+ } else {
+ fetchErr = `HTTP ${status}`;
+ console.log(`${idx} ${shortId} HTTP ${status} for ${meta.rw_product_url}`);
+ }
+ } catch (e) {
+ fetchErr = e.message;
+ console.log(`${idx} ${shortId} FETCH PAGE ERROR: ${e.message}`);
+ }
+
+ // 4. Bucket the price
+ let group;
+ if (pricePerM2 !== null) {
+ group = bucketPrice(pricePerM2);
+ console.log(`${idx} ${shortId} price=$${pricePerM2.toFixed(2)}/m2 -> ${group} (title: ${meta.title?.slice(0, 40)})`);
+ } else {
+ // Can't determine -> leave as-is (C3 default), count unknown
+ console.log(`${idx} ${shortId} UNKNOWN price (err: ${fetchErr || 'no pattern matched'}) -> leaving C3 default`);
+ counts.unknown++;
+ counts.processed++;
+ await sleep(400);
+ continue;
+ }
+
+ // 5. Write metafields
+ let writeErrors = [];
+ try {
+ writeErrors = await writeMetafields(pid, group);
+ } catch (e) {
+ writeErrors = [e.message];
+ }
+
+ if (writeErrors.length > 0) {
+ console.log(`${idx} ${shortId} WRITE ERROR: ${writeErrors.join('; ')}`);
+ counts.failed++;
+ failureLog.push(`${shortId}: write errors: ${writeErrors.join('; ')}`);
+ } else {
+ counts[group.toLowerCase()]++;
+ counts.processed++;
+ console.log(` -> wrote price_group=${group}${group !== 'C3' ? ` cost_per_m2=${GROUPS[group].cost_per_m2}` : ' (C3, cost unchanged)'}`);
+ }
+
+ await sleep(500); // ~2 req/s budget
+ }
+
+ console.log('\n=== DONE ===');
+ console.log(`processed=${counts.processed} c2=${counts.c2} c3=${counts.c3} c4=${counts.c4} unknown=${counts.unknown} failed=${counts.failed}`);
+ if (failureLog.length) {
+ console.log('FAILURES:');
+ failureLog.forEach(f => console.log(' - ' + f));
+ }
+
+ // Emit a final JSON line for easy parsing
+ console.log('\nJSON_RESULT:' + JSON.stringify({ ...counts, notes: failureLog.join('; ') || 'none' }));
+ return counts;
+}
+
+main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });
diff --git a/rw-price-group-slice2.js b/rw-price-group-slice2.js
new file mode 100644
index 0000000..7c8eb08
--- /dev/null
+++ b/rw-price-group-slice2.js
@@ -0,0 +1,405 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-slice2.js — Price-group detector + cost-metafield writer
+ * for SLICE 2 (OFFSET 40, LIMIT 40) of the Rebel Walls mural cohort.
+ *
+ * Algorithm:
+ * 1. For each shopify_id, fetch custom.rw_product_url + custom.price_group.
+ * 2. If no URL -> count unknown, skip.
+ * 3. curl the RW page, extract base per-m2 price from JSON-LD or price chip.
+ * 4. Bucket: ~58.10 -> C2, ~76.40 -> C3, ~88.30 -> C4 (nearest).
+ * 5. Write price_group + (if != C3) cost_per_m2 + material_options + cost_basis.
+ * 6. Report {processed, c2, c3, c4, unknown, failed}.
+ *
+ * SANDBOX ONLY. Rate-limited ~2 req/s.
+ */
+
+const https = require('https');
+
+// ---- env ----
+const fs = require('fs');
+function loadEnv(path) {
+ try {
+ for (const line of fs.readFileSync(path, 'utf8').split('\n')) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
+ if (!m) continue;
+ let v = m[2].trim();
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+ if (process.env[m[1]] == null) process.env[m[1]] = v;
+ }
+ } catch { /* ok */ }
+}
+loadEnv('/Users/stevestudio2/Projects/secrets-manager/.env');
+
+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); }
+if (STORE !== 'designer-laboratory-sandbox.myshopify.com') {
+ console.error(`FATAL: refusing to run against non-sandbox store "${STORE}"`); process.exit(1);
+}
+
+// ---- the 40 IDs for slice 2 ----
+const SLICE_IDS = [
+ 'gid://shopify/Product/7851165712435',
+ 'gid://shopify/Product/7851165745203',
+ 'gid://shopify/Product/7851165777971',
+ 'gid://shopify/Product/7851165810739',
+ 'gid://shopify/Product/7851165843507',
+ 'gid://shopify/Product/7851165876275',
+ 'gid://shopify/Product/7851165909043',
+ 'gid://shopify/Product/7851165941811',
+ 'gid://shopify/Product/7851165974579',
+ 'gid://shopify/Product/7851166007347',
+ 'gid://shopify/Product/7851166040115',
+ 'gid://shopify/Product/7851166072883',
+ 'gid://shopify/Product/7851164762163',
+ 'gid://shopify/Product/7851166105651',
+ 'gid://shopify/Product/7851166138419',
+ 'gid://shopify/Product/7851166171187',
+ 'gid://shopify/Product/7851166203955',
+ 'gid://shopify/Product/7851166236723',
+ 'gid://shopify/Product/7851166269491',
+ 'gid://shopify/Product/7851166302259',
+ 'gid://shopify/Product/7851166335027',
+ 'gid://shopify/Product/7851166367795',
+ 'gid://shopify/Product/7851166400563',
+ 'gid://shopify/Product/7851166433331',
+ 'gid://shopify/Product/7851166466099',
+ 'gid://shopify/Product/7851166498867',
+ 'gid://shopify/Product/7851166531635',
+ 'gid://shopify/Product/7851166564403',
+ 'gid://shopify/Product/7851166597171',
+ 'gid://shopify/Product/7851166629939',
+ 'gid://shopify/Product/7851166662707',
+ 'gid://shopify/Product/7851166695475',
+ 'gid://shopify/Product/7851166728243',
+ 'gid://shopify/Product/7851166761011',
+ 'gid://shopify/Product/7851166793779',
+ 'gid://shopify/Product/7851166826547',
+ 'gid://shopify/Product/7851166859315',
+ 'gid://shopify/Product/7851166892083',
+ 'gid://shopify/Product/7851166924851',
+ 'gid://shopify/Product/7851166957619',
+];
+
+// ---- price group data ----
+const 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}
+ ]),
+ retail: 58.10,
+ cost_basis: 'C2 price group; Non-Woven RRP $58.10/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+ C3: {
+ cost_per_m2: '44.50',
+ material_options: 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.42,"cost_confirmed":false},
+ {"material":"Commercial Grade","retail_per_m2":107.00,"cost_per_m2":62.33,"cost_confirmed":false}
+ ]),
+ retail: 76.40,
+ cost_basis: 'C3 price group; Non-Woven RRP $76.40/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+ 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}
+ ]),
+ retail: 88.30,
+ cost_basis: 'C4 price group; Non-Woven RRP $88.30/m2; cost = RRP x 0.5825; P&S/Commercial extrapolated. Sourced from live RW public product page.',
+ },
+};
+
+function bucketPrice(pricePerM2) {
+ // Snap to nearest anchor: C2=58.10, C3=76.40, C4=88.30
+ const anchors = [
+ { group: 'C2', anchor: 58.10 },
+ { group: 'C3', anchor: 76.40 },
+ { group: 'C4', anchor: 88.30 },
+ ];
+ let best = anchors[0];
+ let bestDist = Math.abs(pricePerM2 - anchors[0].anchor);
+ for (const a of anchors) {
+ const d = Math.abs(pricePerM2 - a.anchor);
+ if (d < bestDist) { bestDist = d; best = a; }
+ }
+ return best.group;
+}
+
+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 = 5) {
+ 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;
+ }
+ }
+}
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+
+async function fetchMetafields(pid) {
+ const r = await gql({ query: `{ product(id:"${pid}") {
+ id title vendor
+ url: metafield(namespace:"custom", key:"rw_product_url"){ value }
+ pg: metafield(namespace:"custom", key:"price_group"){ value }
+ cost: metafield(namespace:"custom", key:"cost_per_m2"){ value }
+ } }` });
+ const p = r?.data?.product;
+ if (!p) return null;
+ return {
+ id: p.id,
+ title: p.title,
+ vendor: p.vendor,
+ rw_product_url: p.url?.value ?? null,
+ price_group: p.pg?.value ?? null,
+ cost_per_m2: p.cost?.value ?? null,
+ };
+}
+
+// ---- fetch RW product page and extract base price ----
+// Use curl subprocess — Node https.request hangs on rebelwalls.com due to HTTPS/Keep-Alive behavior
+const { execFile } = require('child_process');
+function fetchUrl(urlStr) {
+ return new Promise((resolve, reject) => {
+ execFile('curl', [
+ '-s', '-L',
+ '--max-time', '15',
+ '-A', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124 Safari/537.36',
+ '-H', 'Accept: text/html,application/xhtml+xml,*/*;q=0.9',
+ '-H', 'Accept-Language: en-US,en;q=0.9',
+ urlStr,
+ ], { maxBuffer: 2 * 1024 * 1024 }, (err, stdout, stderr) => {
+ if (err) { reject(new Error(err.message || 'curl failed')); return; }
+ resolve({ status: 200, body: stdout });
+ });
+ });
+}
+
+function extractPriceFromPage(html) {
+ // Strategy 1: JSON-LD with offers.price (handles both number and string-encoded prices)
+ 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 items = Array.isArray(obj) ? obj : [obj];
+ for (const item of items) {
+ const offers = item?.offers;
+ if (!offers) continue;
+ const offerList = Array.isArray(offers) ? offers : [offers];
+ for (const o of offerList) {
+ const price = o?.price;
+ if (price == null) continue;
+ const raw = String(price).replace(/[^0-9.]/g, '');
+ if (!raw) continue;
+ const p = parseFloat(raw);
+ if (p >= 40 && p <= 200) return p; // USD per m2
+ if (p >= 2 && p <= 20) return p * 10.7639; // USD per sqft -> m2
+ }
+ }
+ } catch { /* ignore */ }
+ }
+
+ // Strategy 2: bare "price": "76.4" numeric-string in JSON on the page
+ const bareM = html.match(/"price"\s*:\s*"([0-9]+\.?[0-9]*)"/);
+ if (bareM) {
+ const p = parseFloat(bareM[1]);
+ if (p >= 40 && p <= 200) return p;
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ }
+
+ // Strategy 3: "$X / sq ft" patterns
+ const sqftPatterns = [
+ /From\s+\$([0-9]+\.?[0-9]*)\s*\/\s*sq\.?\s*ft/i,
+ /\$([0-9]+\.?[0-9]*)\s*\/\s*sq\.?\s*ft/i,
+ ];
+ for (const pat of sqftPatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 2 && p <= 20) return p * 10.7639;
+ }
+ }
+
+ // Strategy 4: "$X / m2" patterns
+ const m2Patterns = [
+ /From\s+\$([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ /\$([0-9]+\.?[0-9]*)\s*\/\s*m[²2]/i,
+ ];
+ for (const pat of m2Patterns) {
+ const m = html.match(pat);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (p >= 40 && p <= 200) return p;
+ }
+ }
+
+ return null;
+}
+
+async function writeMetafields(pid, group) {
+ const g = GROUPS[group];
+ const mf = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', value: group, type: 'single_line_text_field' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', value: g.cost_basis, type: 'multi_line_text_field' },
+ ];
+
+ // For C2 and C4, overwrite cost_per_m2 and material_options
+ if (group !== 'C3') {
+ mf.push({ ownerId: pid, namespace: 'custom', key: 'cost_per_m2', value: g.cost_per_m2, type: 'number_decimal' });
+ mf.push({ ownerId: pid, namespace: 'custom', key: 'material_options', value: g.material_options, type: 'json' });
+ }
+
+ const r = await gql({ query: MF_MUTATION, variables: { m: mf } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ return errs.map(e => `${(e.field || []).join('.')}: ${e.message}`);
+}
+
+async function main() {
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0 };
+ const failureLog = [];
+ const total = SLICE_IDS.length;
+
+ console.log(`[rw-price-group-slice2] Processing ${total} products (slice OFFSET 40)`);
+ console.log(`Store: ${STORE}\n`);
+
+ for (let i = 0; i < SLICE_IDS.length; i++) {
+ const pid = SLICE_IDS[i];
+ const idx = `[${i + 1}/${total}]`;
+
+ // 1. Fetch metafields
+ let meta;
+ try {
+ meta = await fetchMetafields(pid);
+ } catch (e) {
+ console.log(`${idx} ${pid} FETCH ERROR: ${e.message}`);
+ counts.failed++;
+ failureLog.push(`${pid}: fetch metafields error: ${e.message}`);
+ await sleep(400);
+ continue;
+ }
+
+ if (!meta) {
+ console.log(`${idx} ${pid} NOT FOUND in Shopify`);
+ counts.failed++;
+ failureLog.push(`${pid}: product not found`);
+ await sleep(400);
+ continue;
+ }
+
+ // Guard: only process Rebel Walls products
+ if (meta.vendor !== 'Rebel Walls') {
+ console.log(`${idx} ${pid} GUARD: vendor="${meta.vendor}" not RW, skip`);
+ counts.failed++;
+ failureLog.push(`${pid}: vendor="${meta.vendor}" not RW`);
+ await sleep(400);
+ continue;
+ }
+
+ // 2. Check for URL
+ if (!meta.rw_product_url) {
+ console.log(`${idx} ${pid} NO URL — unknown group (title: ${meta.title?.slice(0, 50)})`);
+ counts.unknown++;
+ counts.processed++;
+ await sleep(300);
+ continue;
+ }
+
+ // 3. Fetch the RW product page
+ let pricePerM2 = null;
+ let fetchErr = null;
+ try {
+ const { status, body } = await fetchUrl(meta.rw_product_url);
+ if (status === 200) {
+ pricePerM2 = extractPriceFromPage(body);
+ if (pricePerM2 === null) {
+ // Try harder: log a snippet for debugging
+ console.log(`${idx} ${pid} PRICE EXTRACT FAILED (status=${status}) url=${meta.rw_product_url}`);
+ // Check for any dollar amounts in the page near price context
+ const snippet = body.slice(0, 5000);
+ const anyDollar = snippet.match(/\$[0-9]+\.[0-9]+/g);
+ if (anyDollar) console.log(` Dollar amounts found in first 5k: ${anyDollar.slice(0, 10).join(', ')}`);
+ }
+ } else {
+ fetchErr = `HTTP ${status}`;
+ console.log(`${idx} ${pid} HTTP ${status} for ${meta.rw_product_url}`);
+ }
+ } catch (e) {
+ fetchErr = e.message;
+ console.log(`${idx} ${pid} FETCH PAGE ERROR: ${e.message}`);
+ }
+
+ // 4. Bucket the price
+ let group;
+ if (pricePerM2 !== null) {
+ group = bucketPrice(pricePerM2);
+ console.log(`${idx} ${pid} price=$${pricePerM2.toFixed(2)}/m2 -> ${group} (title: ${meta.title?.slice(0, 40)})`);
+ } else {
+ // Can't determine -> leave as-is (C3 default), count unknown
+ console.log(`${idx} ${pid} UNKNOWN price (err: ${fetchErr || 'no pattern matched'}) -> leaving C3 default`);
+ counts.unknown++;
+ counts.processed++;
+ await sleep(400);
+ continue;
+ }
+
+ // 5. Write metafields
+ let writeErrors = [];
+ try {
+ writeErrors = await writeMetafields(pid, group);
+ } catch (e) {
+ writeErrors = [e.message];
+ }
+
+ if (writeErrors.length > 0) {
+ console.log(`${idx} ${pid} WRITE ERROR: ${writeErrors.join('; ')}`);
+ counts.failed++;
+ failureLog.push(`${pid}: write errors: ${writeErrors.join('; ')}`);
+ } else {
+ counts[group.toLowerCase()]++;
+ counts.processed++;
+ console.log(` -> wrote price_group=${group}${group !== 'C3' ? ` cost_per_m2=${GROUPS[group].cost_per_m2}` : ' (C3, cost unchanged)'}`);
+ }
+
+ await sleep(500); // ~2 req/s budget (one fetch + one write per cycle)
+ }
+
+ console.log('\n=== DONE ===');
+ console.log(`processed=${counts.processed} c2=${counts.c2} c3=${counts.c3} c4=${counts.c4} unknown=${counts.unknown} failed=${counts.failed}`);
+ if (failureLog.length) {
+ console.log('FAILURES:');
+ failureLog.forEach(f => console.log(' - ' + f));
+ }
+ return counts;
+}
+
+main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });
diff --git a/rw-price-group-slice21.js b/rw-price-group-slice21.js
new file mode 100644
index 0000000..ab77e9c
--- /dev/null
+++ b/rw-price-group-slice21.js
@@ -0,0 +1,373 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-slice21.js — Determine RW mural price group (C2/C3/C4) from live
+ * per-m2 retail rate, then write cost metafields to Shopify sandbox.
+ *
+ * Slice: OFFSET 800, LIMIT 40
+ */
+
+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); }
+
+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, 500) }); } });
+ });
+ 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 * attempt); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+// ---------- HTTP GET for RW page ----------
+function httpGet(url, maxRedirects = 5) {
+ return new Promise((resolve, reject) => {
+ const lib = url.startsWith('https') ? require('https') : require('http');
+ const req = lib.get(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.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 => {
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && maxRedirects > 0) {
+ let loc = res.headers.location;
+ if (loc.startsWith('/')) {
+ const u = new URL(url);
+ loc = u.origin + loc;
+ }
+ res.resume();
+ return httpGet(loc, maxRedirects - 1).then(resolve).catch(reject);
+ }
+ 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('HTTP timeout')); });
+ });
+}
+
+// ---------- Price extraction ----------
+function extractPricePerM2(html) {
+ // Strategy 1: JSON-LD price
+ const jldMatches = [...html.matchAll(/<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi)];
+ for (const m of jldMatches) {
+ try {
+ const obj = JSON.parse(m[1]);
+ const items = Array.isArray(obj) ? obj : [obj];
+ for (const item of items) {
+ const offers = item.offers ? (Array.isArray(item.offers) ? item.offers : [item.offers]) : [];
+ for (const offer of offers) {
+ if (offer.price) {
+ const p = parseFloat(offer.price);
+ if (!isNaN(p) && p > 1) {
+ const currency = (offer.priceCurrency || '').toUpperCase();
+ if (currency === 'USD' || !currency) {
+ if (p < 30) {
+ return p * 10.7639;
+ } else {
+ return p;
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch (_) {}
+ }
+
+ // Strategy 2: Look for "From $X / sq ft" or "From $X/sqft" or "From $X / m²"
+ const sqftPatterns = [
+ /From\s+\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*(?:sq\.?\s*ft|sqft|sq\s+ft)/i,
+ /\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*(?:sq\.?\s*ft|sqft)/i,
+ /"price":\s*"?([\d.]+)"?,\s*"priceCurrency":\s*"USD"/i,
+ /price.*?From[^$]*\$([\d]+(?:\.[\d]+)?)/i,
+ ];
+ for (const re of sqftPatterns) {
+ const m = html.match(re);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (!isNaN(p) && p > 0 && p < 50) {
+ return p * 10.7639;
+ }
+ }
+ }
+
+ // m2 patterns
+ const m2Patterns = [
+ /From\s+\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*m[²2]/i,
+ /\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*m[²2]/i,
+ /([\d]+(?:\.[\d]+)?)\s*\/\s*m2/i,
+ ];
+ for (const re of m2Patterns) {
+ const m = html.match(re);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (!isNaN(p) && p > 10 && p < 300) {
+ return p;
+ }
+ }
+ }
+
+ // Strategy 3: "basePrice":5.4 or similar
+ const basePriceRe = /"basePrice"[^:]*:\s*([\d.]+)/i;
+ const bm = html.match(basePriceRe);
+ if (bm) {
+ const p = parseFloat(bm[1]);
+ if (!isNaN(p) && p > 0) {
+ if (p < 30) return p * 10.7639;
+ return p;
+ }
+ }
+
+ // Strategy 4: pricePerSquareFoot
+ const initStateRe = /pricePerSquareFoot['"]\s*:\s*([\d.]+)/i;
+ const im = html.match(initStateRe);
+ if (im) {
+ const p = parseFloat(im[1]);
+ if (!isNaN(p) && p > 0) return p * 10.7639;
+ }
+
+ // Strategy 5: any dollar amount near sq/m context — take smallest (base material)
+ const anyPriceContextRe = /\$([\d]+(?:\.[\d]+)?)\s*\/\s*(?:sq|m)/gi;
+ const allMatches = [...html.matchAll(anyPriceContextRe)];
+ if (allMatches.length > 0) {
+ const prices = allMatches.map(m => parseFloat(m[1])).filter(p => !isNaN(p) && p > 0);
+ if (prices.length > 0) {
+ const minP = Math.min(...prices);
+ if (minP < 30) return minP * 10.7639;
+ return minP;
+ }
+ }
+
+ return null;
+}
+
+// ---------- Bucket ----------
+function bucketPrice(pricePerM2) {
+ if (pricePerM2 === null) return null;
+ // C2 ~58.10, C3 ~76.40, C4 ~88.30
+ // Midpoints: C2<67.25, 67.25<=C3<82.35, C4>=82.35
+ if (pricePerM2 < 67.25) return 'C2';
+ if (pricePerM2 < 82.35) return 'C3';
+ return 'C4';
+}
+
+// ---------- 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: 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: null, C4: '51.44' };
+
+// ---------- Write metafields ----------
+async function writeMetafields(pid, group, pricePerM2) {
+ const metafields = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', type: 'single_line_text_field', value: group },
+ ];
+
+ if (group !== 'C3') {
+ metafields.push(
+ { ownerId: pid, namespace: 'custom', key: 'cost_per_m2', type: 'number_decimal', value: COST_PER_M2[group] },
+ { ownerId: pid, namespace: 'custom', key: 'material_options', type: 'json', value: MATERIAL_OPTIONS[group] },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', type: 'single_line_text_field', value: `${group} portal-RRP-derived; base rate ~$${pricePerM2 ? pricePerM2.toFixed(2) : '?'}/m2; P&S/Commercial extrapolated` },
+ );
+ }
+
+ const mutation = `mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }`;
+ const result = await gql({ query: mutation, variables: { m: metafields } });
+ const userErrors = result?.data?.metafieldsSet?.userErrors || [];
+ if (userErrors.length > 0) {
+ throw new Error(`UserErrors: ${JSON.stringify(userErrors)}`);
+ }
+ if (result?.errors) {
+ throw new Error(`GQL errors: ${JSON.stringify(result.errors)}`);
+ }
+ return result;
+}
+
+// ---------- Fetch product metafields ----------
+async function fetchProductMeta(pid) {
+ const query = `{
+ product(id:"${pid}") {
+ rwUrl: metafield(namespace:"custom", key:"rw_product_url") { value }
+ pg: metafield(namespace:"custom", key:"price_group") { value }
+ }
+ }`;
+ const result = await gql({ query });
+ return result?.data?.product || null;
+}
+
+// ---------- Main ----------
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7855834300467',
+ 'gid://shopify/Product/7855834333235',
+ 'gid://shopify/Product/7855834562611',
+ 'gid://shopify/Product/7855834824755',
+ 'gid://shopify/Product/7855834857523',
+ 'gid://shopify/Product/7855835021363',
+ 'gid://shopify/Product/7855835054131',
+ 'gid://shopify/Product/7855835119667',
+ 'gid://shopify/Product/7855835152435',
+ 'gid://shopify/Product/7855835185203',
+ 'gid://shopify/Product/7855835250739',
+ 'gid://shopify/Product/7855835283507',
+ 'gid://shopify/Product/7855835316275',
+ 'gid://shopify/Product/7855835349043',
+ 'gid://shopify/Product/7855835414579',
+ 'gid://shopify/Product/7855835480115',
+ 'gid://shopify/Product/7855835512883',
+ 'gid://shopify/Product/7855835611187',
+ 'gid://shopify/Product/7855835709491',
+ 'gid://shopify/Product/7855835742259',
+ 'gid://shopify/Product/7855835807795',
+ 'gid://shopify/Product/7855835840563',
+ 'gid://shopify/Product/7855835873331',
+ 'gid://shopify/Product/7855835906099',
+ 'gid://shopify/Product/7855835938867',
+ 'gid://shopify/Product/7855835971635',
+ 'gid://shopify/Product/7855836037171',
+ 'gid://shopify/Product/7855836135475',
+ 'gid://shopify/Product/7855836168243',
+ 'gid://shopify/Product/7855836201011',
+ 'gid://shopify/Product/7855836233779',
+ 'gid://shopify/Product/7855836332083',
+ 'gid://shopify/Product/7855836364851',
+ 'gid://shopify/Product/7855836397619',
+ 'gid://shopify/Product/7855836626995',
+ 'gid://shopify/Product/7855836758067',
+ 'gid://shopify/Product/7855836790835',
+ 'gid://shopify/Product/7855836823603',
+ 'gid://shopify/Product/7855836856371',
+ 'gid://shopify/Product/7855836889139',
+];
+
+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) {
+ const shortId = pid.split('/').pop();
+ try {
+ // Fetch metafields
+ const meta = await fetchProductMeta(pid);
+ if (!meta) {
+ console.log(`[SKIP] ${shortId}: product not found`);
+ counts.failed++;
+ continue;
+ }
+
+ const rwUrl = meta.rwUrl?.value || null;
+ const existingGroup = meta.pg?.value || null;
+
+ if (!rwUrl) {
+ console.log(`[SKIP] ${shortId}: no rw_product_url`);
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ console.log(`[FETCH] ${shortId}: ${rwUrl}`);
+
+ // Fetch RW page
+ let pageResult;
+ try {
+ pageResult = await httpGet(rwUrl);
+ } catch (e) {
+ console.log(`[FAIL] ${shortId}: HTTP error: ${e.message}`);
+ counts.failed++;
+ notes.push(`${shortId}: HTTP fetch failed: ${e.message}`);
+ continue;
+ }
+
+ if (pageResult.status !== 200) {
+ console.log(`[FAIL] ${shortId}: HTTP ${pageResult.status}`);
+ counts.failed++;
+ notes.push(`${shortId}: HTTP status ${pageResult.status}`);
+ continue;
+ }
+
+ const pricePerM2 = extractPricePerM2(pageResult.body);
+ const group = bucketPrice(pricePerM2);
+
+ console.log(` price/m2=${pricePerM2 ? pricePerM2.toFixed(2) : 'null'} => ${group || 'UNKNOWN'}`);
+
+ if (!group) {
+ console.log(` [UNKNOWN] can't determine price group for ${shortId}`);
+ // Still write C3 as default if none set
+ if (!existingGroup) {
+ await writeMetafields(pid, 'C3', null);
+ }
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ // Write metafields
+ await writeMetafields(pid, group, pricePerM2);
+ console.log(` [WRITTEN] ${group}`);
+
+ counts[group.toLowerCase()]++;
+ counts.processed++;
+
+ // Rate limit ~2 req/s
+ await sleep(500);
+
+ } catch (e) {
+ console.error(`[ERROR] ${shortId}: ${e.message}`);
+ counts.failed++;
+ notes.push(`${shortId}: ${e.message}`);
+ }
+ }
+
+ console.log('\n=== RESULTS ===');
+ console.log(JSON.stringify({ ...counts, notes: notes.join('; ') || 'none' }, null, 2));
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
diff --git a/rw-price-group-slice22.js b/rw-price-group-slice22.js
new file mode 100644
index 0000000..cb8493e
--- /dev/null
+++ b/rw-price-group-slice22.js
@@ -0,0 +1,360 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-slice22.js — Determine RW mural price group (C2/C3/C4) from live
+ * per-m2 retail rate, then write cost metafields to Shopify sandbox.
+ *
+ * Slice: OFFSET 840, LIMIT 40
+ */
+
+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); }
+
+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, 500) }); } });
+ });
+ 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 * attempt); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+// ---------- HTTP GET for RW page ----------
+function httpGet(url, maxRedirects = 5) {
+ return new Promise((resolve, reject) => {
+ const lib = url.startsWith('https') ? require('https') : require('http');
+ const req = lib.get(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.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 => {
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && maxRedirects > 0) {
+ let loc = res.headers.location;
+ if (loc.startsWith('/')) {
+ const u = new URL(url);
+ loc = u.origin + loc;
+ }
+ res.resume();
+ return httpGet(loc, maxRedirects - 1).then(resolve).catch(reject);
+ }
+ 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('HTTP timeout')); });
+ });
+}
+
+// ---------- Price extraction ----------
+function extractPricePerM2(html) {
+ // Strategy 1: JSON-LD price
+ const jldMatches = [...html.matchAll(/<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi)];
+ for (const m of jldMatches) {
+ try {
+ const obj = JSON.parse(m[1]);
+ const items = Array.isArray(obj) ? obj : [obj];
+ for (const item of items) {
+ const offers = item.offers ? (Array.isArray(item.offers) ? item.offers : [item.offers]) : [];
+ for (const offer of offers) {
+ if (offer.price) {
+ const p = parseFloat(offer.price);
+ if (!isNaN(p) && p > 1) {
+ const currency = (offer.priceCurrency || '').toUpperCase();
+ if (currency === 'USD' || !currency) {
+ if (p < 30) return p * 10.7639;
+ else return p;
+ }
+ }
+ }
+ }
+ }
+ } catch (_) {}
+ }
+
+ // Strategy 2: sq ft / m2 text patterns
+ const sqftPatterns = [
+ /From\s+\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*(?:sq\.?\s*ft|sqft|sq\s+ft)/i,
+ /\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*(?:sq\.?\s*ft|sqft)/i,
+ /"price":\s*"?([\d.]+)"?,\s*"priceCurrency":\s*"USD"/i,
+ /price.*?From[^$]*\$([\d]+(?:\.[\d]+)?)/i,
+ ];
+ for (const re of sqftPatterns) {
+ const m = html.match(re);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (!isNaN(p) && p > 0 && p < 50) return p * 10.7639;
+ }
+ }
+
+ const m2Patterns = [
+ /From\s+\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*m[²2]/i,
+ /\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*m[²2]/i,
+ /([\d]+(?:\.[\d]+)?)\s*\/\s*m2/i,
+ ];
+ for (const re of m2Patterns) {
+ const m = html.match(re);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (!isNaN(p) && p > 10 && p < 300) return p;
+ }
+ }
+
+ // Strategy 3: basePrice key in JS
+ const basePriceRe = /"basePrice"[^:]*:\s*([\d.]+)/i;
+ const bm = html.match(basePriceRe);
+ if (bm) {
+ const p = parseFloat(bm[1]);
+ if (!isNaN(p) && p > 0) {
+ if (p < 30) return p * 10.7639;
+ return p;
+ }
+ }
+
+ // Strategy 4: pricePerSquareFoot
+ const initStateRe = /pricePerSquareFoot['"]\s*:\s*([\d.]+)/i;
+ const im = html.match(initStateRe);
+ if (im) {
+ const p = parseFloat(im[1]);
+ if (!isNaN(p) && p > 0) return p * 10.7639;
+ }
+
+ // Strategy 5: smallest $X/sq or $X/m context
+ const anyPriceContextRe = /\$([\d]+(?:\.[\d]+)?)\s*\/\s*(?:sq|m)/gi;
+ const allMatches = [...html.matchAll(anyPriceContextRe)];
+ if (allMatches.length > 0) {
+ const prices = allMatches.map(m => parseFloat(m[1])).filter(p => !isNaN(p) && p > 0);
+ if (prices.length > 0) {
+ const minP = Math.min(...prices);
+ if (minP < 30) return minP * 10.7639;
+ return minP;
+ }
+ }
+
+ return null;
+}
+
+// ---------- Bucket ----------
+function bucketPrice(pricePerM2) {
+ if (pricePerM2 === null) return null;
+ // C2 ~58.10, C3 ~76.40, C4 ~88.30
+ // Midpoints: C2<67.25, 67.25<=C3<82.35, C4>=82.35
+ if (pricePerM2 < 67.25) return 'C2';
+ if (pricePerM2 < 82.35) return 'C3';
+ return 'C4';
+}
+
+// ---------- 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: 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: null, C4: '51.44' };
+
+// ---------- Write metafields ----------
+async function writeMetafields(pid, group, pricePerM2) {
+ const metafields = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', type: 'single_line_text_field', value: group },
+ ];
+
+ if (group !== 'C3') {
+ metafields.push(
+ { ownerId: pid, namespace: 'custom', key: 'cost_per_m2', type: 'number_decimal', value: COST_PER_M2[group] },
+ { ownerId: pid, namespace: 'custom', key: 'material_options', type: 'json', value: MATERIAL_OPTIONS[group] },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', type: 'single_line_text_field', value: `${group} portal-RRP-derived; base rate ~$${pricePerM2 ? pricePerM2.toFixed(2) : '?'}/m2; P&S/Commercial extrapolated` },
+ );
+ }
+
+ const mutation = `mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }`;
+ const result = await gql({ query: mutation, variables: { m: metafields } });
+ const userErrors = result?.data?.metafieldsSet?.userErrors || [];
+ if (userErrors.length > 0) {
+ throw new Error(`UserErrors: ${JSON.stringify(userErrors)}`);
+ }
+ if (result?.errors) {
+ throw new Error(`GQL errors: ${JSON.stringify(result.errors)}`);
+ }
+ return result;
+}
+
+// ---------- Fetch product metafields ----------
+async function fetchProductMeta(pid) {
+ const query = `{
+ product(id:"${pid}") {
+ rwUrl: metafield(namespace:"custom", key:"rw_product_url") { value }
+ pg: metafield(namespace:"custom", key:"price_group") { value }
+ }
+ }`;
+ const result = await gql({ query });
+ return result?.data?.product || null;
+}
+
+// ---------- Main ----------
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7855836921907',
+ 'gid://shopify/Product/7855836954675',
+ 'gid://shopify/Product/7855837020211',
+ 'gid://shopify/Product/7855837052979',
+ 'gid://shopify/Product/7855837085747',
+ 'gid://shopify/Product/7855837118515',
+ 'gid://shopify/Product/7855837216819',
+ 'gid://shopify/Product/7855837249587',
+ 'gid://shopify/Product/7855837282355',
+ 'gid://shopify/Product/7855837315123',
+ 'gid://shopify/Product/7855837380659',
+ 'gid://shopify/Product/7855837413427',
+ 'gid://shopify/Product/7855837478963',
+ 'gid://shopify/Product/7855837511731',
+ 'gid://shopify/Product/7855837544499',
+ 'gid://shopify/Product/7855837577267',
+ 'gid://shopify/Product/7855837610035',
+ 'gid://shopify/Product/7855837642803',
+ 'gid://shopify/Product/7855837675571',
+ 'gid://shopify/Product/7855837708339',
+ 'gid://shopify/Product/7855837741107',
+ 'gid://shopify/Product/7855837773875',
+ 'gid://shopify/Product/7855837839411',
+ 'gid://shopify/Product/7855837872179',
+ 'gid://shopify/Product/7855837937715',
+ 'gid://shopify/Product/7855838003251',
+ 'gid://shopify/Product/7855838036019',
+ 'gid://shopify/Product/7855838101555',
+ 'gid://shopify/Product/7855838134323',
+ 'gid://shopify/Product/7855838199859',
+ 'gid://shopify/Product/7855838232627',
+ 'gid://shopify/Product/7855838330931',
+ 'gid://shopify/Product/7855838494771',
+ 'gid://shopify/Product/7855838527539',
+ 'gid://shopify/Product/7855838560307',
+ 'gid://shopify/Product/7855838593075',
+ 'gid://shopify/Product/7855838625843',
+ 'gid://shopify/Product/7855838658611',
+ 'gid://shopify/Product/7855838691379',
+ 'gid://shopify/Product/7855838724147',
+];
+
+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) {
+ const shortId = pid.split('/').pop();
+ try {
+ const meta = await fetchProductMeta(pid);
+ if (!meta) {
+ console.log(`[SKIP] ${shortId}: product not found`);
+ counts.failed++;
+ continue;
+ }
+
+ const rwUrl = meta.rwUrl?.value || null;
+ const existingGroup = meta.pg?.value || null;
+
+ if (!rwUrl) {
+ console.log(`[SKIP] ${shortId}: no rw_product_url`);
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ console.log(`[FETCH] ${shortId}: ${rwUrl}`);
+
+ let pageResult;
+ try {
+ pageResult = await httpGet(rwUrl);
+ } catch (e) {
+ console.log(`[FAIL] ${shortId}: HTTP error: ${e.message}`);
+ counts.failed++;
+ notes.push(`${shortId}: HTTP fetch failed: ${e.message}`);
+ continue;
+ }
+
+ if (pageResult.status !== 200) {
+ console.log(`[FAIL] ${shortId}: HTTP ${pageResult.status}`);
+ counts.failed++;
+ notes.push(`${shortId}: HTTP status ${pageResult.status}`);
+ continue;
+ }
+
+ const pricePerM2 = extractPricePerM2(pageResult.body);
+ const group = bucketPrice(pricePerM2);
+
+ console.log(` price/m2=${pricePerM2 ? pricePerM2.toFixed(2) : 'null'} => ${group || 'UNKNOWN'}`);
+
+ if (!group) {
+ console.log(` [UNKNOWN] can't determine price group for ${shortId}`);
+ if (!existingGroup) {
+ await writeMetafields(pid, 'C3', null);
+ }
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ await writeMetafields(pid, group, pricePerM2);
+ console.log(` [WRITTEN] ${group}`);
+
+ counts[group.toLowerCase()]++;
+ counts.processed++;
+
+ await sleep(500);
+
+ } catch (e) {
+ console.error(`[ERROR] ${shortId}: ${e.message}`);
+ counts.failed++;
+ notes.push(`${shortId}: ${e.message}`);
+ }
+ }
+
+ console.log('\n=== RESULTS ===');
+ console.log(JSON.stringify({ ...counts, notes: notes.join('; ') || 'none' }, null, 2));
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
diff --git a/rw-price-group-slice25.js b/rw-price-group-slice25.js
new file mode 100644
index 0000000..9f7c8cf
--- /dev/null
+++ b/rw-price-group-slice25.js
@@ -0,0 +1,358 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group-slice25.js — Determine RW mural price group (C2/C3/C4) from live
+ * per-m2 retail rate, then write cost metafields to Shopify sandbox.
+ *
+ * Slice: OFFSET 960, LIMIT 40
+ */
+
+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); }
+
+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, 500) }); } });
+ });
+ 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 * attempt); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+// ---------- HTTP GET for RW page ----------
+function httpGet(url, maxRedirects = 5) {
+ return new Promise((resolve, reject) => {
+ const lib = url.startsWith('https') ? require('https') : require('http');
+ const req = lib.get(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.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 => {
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && maxRedirects > 0) {
+ let loc = res.headers.location;
+ if (loc.startsWith('/')) {
+ const u = new URL(url);
+ loc = u.origin + loc;
+ }
+ res.resume();
+ return httpGet(loc, maxRedirects - 1).then(resolve).catch(reject);
+ }
+ 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('HTTP timeout')); });
+ });
+}
+
+// ---------- Price extraction ----------
+function extractPricePerM2(html) {
+ // Strategy 1: JSON-LD price
+ const jldMatches = [...html.matchAll(/<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi)];
+ for (const m of jldMatches) {
+ try {
+ const obj = JSON.parse(m[1]);
+ const items = Array.isArray(obj) ? obj : [obj];
+ for (const item of items) {
+ const offers = item.offers ? (Array.isArray(item.offers) ? item.offers : [item.offers]) : [];
+ for (const offer of offers) {
+ if (offer.price) {
+ const p = parseFloat(offer.price);
+ if (!isNaN(p) && p > 1) {
+ const currency = (offer.priceCurrency || '').toUpperCase();
+ if (currency === 'USD' || !currency) {
+ if (p < 30) {
+ return p * 10.7639;
+ } else {
+ return p;
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch (_) {}
+ }
+
+ // Strategy 2: Look for "From $X / sq ft" or "From $X/sqft" or "From $X / m²"
+ const sqftPatterns = [
+ /[Ff]rom\s*\$\s*([\d.]+)\s*\/\s*sq\.?\s*ft/gi,
+ /\$\s*([\d.]+)\s*\/\s*sq\.?\s*ft/gi,
+ /[Ff]rom\s*\$\s*([\d.]+)\s*per\s*sq\.?\s*ft/gi,
+ ];
+ for (const pat of sqftPatterns) {
+ const matches = [...html.matchAll(pat)];
+ if (matches.length > 0) {
+ const prices = matches.map(m => parseFloat(m[1])).filter(p => !isNaN(p) && p > 1 && p < 50);
+ if (prices.length > 0) {
+ const minP = Math.min(...prices);
+ return minP * 10.7639;
+ }
+ }
+ }
+
+ // Strategy 3: per m2 patterns
+ const m2Patterns = [
+ /[Ff]rom\s*\$\s*([\d.]+)\s*\/\s*m[²2]/gi,
+ /\$\s*([\d.]+)\s*\/\s*m[²2]/gi,
+ /[Ff]rom\s*\$\s*([\d.]+)\s*per\s*m[²2]/gi,
+ ];
+ for (const pat of m2Patterns) {
+ const matches = [...html.matchAll(pat)];
+ if (matches.length > 0) {
+ const prices = matches.map(m => parseFloat(m[1])).filter(p => !isNaN(p) && p > 30 && p < 200);
+ if (prices.length > 0) {
+ return Math.min(...prices);
+ }
+ }
+ }
+
+ // Strategy 4: Generic price chips — look for price in known ranges
+ const genericPat = /\$\s*([\d]+(?:\.\d{1,2})?)/g;
+ const genericMatches = [...html.matchAll(genericPat)];
+ const prices = genericMatches
+ .map(m => parseFloat(m[1]))
+ .filter(p => !isNaN(p));
+
+ // Sqft range: 4-15; m2 range: 40-200
+ const sqftPrices = prices.filter(p => p >= 4 && p <= 15);
+ const m2Prices = prices.filter(p => p >= 40 && p <= 200);
+
+ if (sqftPrices.length > 0) {
+ const minP = Math.min(...sqftPrices);
+ return minP * 10.7639;
+ }
+ if (m2Prices.length > 0) {
+ return Math.min(...m2Prices);
+ }
+
+ return null;
+}
+
+// ---------- Bucket ----------
+function bucketPrice(pricePerM2) {
+ if (pricePerM2 === null) return null;
+ // C2 ~58.10, C3 ~76.40, C4 ~88.30
+ // Midpoints: C2<67.25, 67.25<=C3<82.35, C4>=82.35
+ if (pricePerM2 < 67.25) return 'C2';
+ if (pricePerM2 < 82.35) return 'C3';
+ return 'C4';
+}
+
+// ---------- 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: 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: null, C4: '51.44' };
+
+// ---------- Write metafields ----------
+async function writeMetafields(pid, group, pricePerM2) {
+ const metafields = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', type: 'single_line_text_field', value: group },
+ ];
+
+ if (group !== 'C3') {
+ metafields.push(
+ { ownerId: pid, namespace: 'custom', key: 'cost_per_m2', type: 'number_decimal', value: COST_PER_M2[group] },
+ { ownerId: pid, namespace: 'custom', key: 'material_options', type: 'json', value: MATERIAL_OPTIONS[group] },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', type: 'single_line_text_field', value: `${group} portal-RRP-derived; base rate ~$${pricePerM2 ? pricePerM2.toFixed(2) : '?'}/m2; P&S/Commercial extrapolated` },
+ );
+ }
+
+ const mutation = `mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }`;
+ const result = await gql({ query: mutation, variables: { m: metafields } });
+ const userErrors = result?.data?.metafieldsSet?.userErrors || [];
+ if (userErrors.length > 0) {
+ throw new Error(`UserErrors: ${JSON.stringify(userErrors)}`);
+ }
+ if (result?.errors) {
+ throw new Error(`GQL errors: ${JSON.stringify(result.errors)}`);
+ }
+ return result;
+}
+
+// ---------- Fetch product metafields ----------
+async function fetchProductMeta(pid) {
+ const query = `{
+ product(id:"${pid}") {
+ rwUrl: metafield(namespace:"custom", key:"rw_product_url") { value }
+ pg: metafield(namespace:"custom", key:"price_group") { value }
+ }
+ }`;
+ const result = await gql({ query });
+ return result?.data?.product || null;
+}
+
+// ---------- Main ----------
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7855843573811',
+ 'gid://shopify/Product/7855843672115',
+ 'gid://shopify/Product/7855843770419',
+ 'gid://shopify/Product/7855843934259',
+ 'gid://shopify/Product/7855844032563',
+ 'gid://shopify/Product/7855844098099',
+ 'gid://shopify/Product/7855844163635',
+ 'gid://shopify/Product/7855844229171',
+ 'gid://shopify/Product/7855844294707',
+ 'gid://shopify/Product/7855844360243',
+ 'gid://shopify/Product/7855844425779',
+ 'gid://shopify/Product/7855844491315',
+ 'gid://shopify/Product/7855844556851',
+ 'gid://shopify/Product/7855844687923',
+ 'gid://shopify/Product/7855844786227',
+ 'gid://shopify/Product/7855844884531',
+ 'gid://shopify/Product/7855844950067',
+ 'gid://shopify/Product/7855845015603',
+ 'gid://shopify/Product/7855845048371',
+ 'gid://shopify/Product/7855845081139',
+ 'gid://shopify/Product/7855845146675',
+ 'gid://shopify/Product/7855845212211',
+ 'gid://shopify/Product/7855845244979',
+ 'gid://shopify/Product/7855845310515',
+ 'gid://shopify/Product/7855845376051',
+ 'gid://shopify/Product/7855845408819',
+];
+
+async function main() {
+ const counts = { processed: 0, c2: 0, c3: 0, c4: 0, unknown: 0, failed: 0 };
+ const notes = [];
+
+ console.log(`Processing ${PRODUCT_IDS.length} products (slice OFFSET 960)`);
+
+ for (const pid of PRODUCT_IDS) {
+ const shortId = pid.split('/').pop();
+ try {
+ // Fetch metafields
+ const meta = await fetchProductMeta(pid);
+ if (!meta) {
+ console.log(`[SKIP] ${shortId}: product not found`);
+ counts.failed++;
+ continue;
+ }
+
+ const rwUrl = meta.rwUrl?.value || null;
+ const existingGroup = meta.pg?.value || null;
+
+ if (!rwUrl) {
+ console.log(`[SKIP] ${shortId}: no rw_product_url`);
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ // If already set to C2 or C4 by a prior run, skip (idempotent)
+ // C3 is the default baseline so always re-verify
+ if (existingGroup && existingGroup !== 'C3') {
+ console.log(`[SKIP-IDEMPOTENT] ${shortId}: already ${existingGroup}`);
+ counts[existingGroup.toLowerCase()]++;
+ counts.processed++;
+ continue;
+ }
+
+ console.log(`[FETCH] ${shortId}: ${rwUrl}`);
+
+ // Fetch RW page
+ let pageResult;
+ try {
+ pageResult = await httpGet(rwUrl);
+ } catch (e) {
+ console.log(`[FAIL] ${shortId}: HTTP error: ${e.message}`);
+ counts.failed++;
+ notes.push(`${shortId}: HTTP fetch failed: ${e.message}`);
+ continue;
+ }
+
+ if (pageResult.status !== 200) {
+ console.log(`[FAIL] ${shortId}: HTTP ${pageResult.status}`);
+ counts.failed++;
+ notes.push(`${shortId}: HTTP status ${pageResult.status}`);
+ continue;
+ }
+
+ const pricePerM2 = extractPricePerM2(pageResult.body);
+ const group = bucketPrice(pricePerM2);
+
+ console.log(` price/m2=${pricePerM2 ? pricePerM2.toFixed(2) : 'null'} => ${group || 'UNKNOWN'}`);
+
+ if (!group) {
+ console.log(` [UNKNOWN] can't determine price group for ${shortId}`);
+ // Still write C3 as default if none set
+ if (!existingGroup) {
+ await writeMetafields(pid, 'C3', null);
+ }
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ // Write metafields
+ await writeMetafields(pid, group, pricePerM2);
+ console.log(` [WRITTEN] ${group}`);
+
+ counts[group.toLowerCase()]++;
+ counts.processed++;
+
+ // Rate limit ~2 req/s
+ await sleep(500);
+
+ } catch (e) {
+ console.error(`[ERROR] ${shortId}: ${e.message}`);
+ counts.failed++;
+ notes.push(`${shortId}: ${e.message}`);
+ }
+ }
+
+ console.log('\n=== RESULTS ===');
+ console.log(JSON.stringify({ ...counts, notes: notes.join('; ') || 'none' }, null, 2));
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
diff --git a/rw-price-group.js b/rw-price-group.js
new file mode 100644
index 0000000..178a2e3
--- /dev/null
+++ b/rw-price-group.js
@@ -0,0 +1,381 @@
+#!/usr/bin/env node
+/**
+ * rw-price-group.js — Determine RW mural price group (C2/C3/C4) from live
+ * per-m2 retail rate, then write cost metafields to Shopify sandbox.
+ *
+ * Slice: OFFSET 120, LIMIT 40
+ */
+
+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); }
+
+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, 500) }); } });
+ });
+ 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 * attempt); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+// ---------- HTTP GET for RW page ----------
+function httpGet(url, maxRedirects = 5) {
+ return new Promise((resolve, reject) => {
+ const lib = url.startsWith('https') ? require('https') : require('http');
+ const req = lib.get(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.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 => {
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && maxRedirects > 0) {
+ let loc = res.headers.location;
+ if (loc.startsWith('/')) {
+ const u = new URL(url);
+ loc = u.origin + loc;
+ }
+ res.resume();
+ return httpGet(loc, maxRedirects - 1).then(resolve).catch(reject);
+ }
+ 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('HTTP timeout')); });
+ });
+}
+
+// ---------- Price extraction ----------
+function extractPricePerM2(html) {
+ // Strategy 1: JSON-LD price
+ const jldMatches = [...html.matchAll(/<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi)];
+ for (const m of jldMatches) {
+ try {
+ const obj = JSON.parse(m[1]);
+ const items = Array.isArray(obj) ? obj : [obj];
+ for (const item of items) {
+ // Look for offers
+ const offers = item.offers ? (Array.isArray(item.offers) ? item.offers : [item.offers]) : [];
+ for (const offer of offers) {
+ if (offer.price) {
+ const p = parseFloat(offer.price);
+ if (!isNaN(p) && p > 1) {
+ const currency = (offer.priceCurrency || '').toUpperCase();
+ if (currency === 'USD' || !currency) {
+ // Check if it could be per sqft or per m2
+ // RW prices are $5-10/sqft or $55-100/m2
+ if (p < 30) {
+ // likely per sqft, convert to m2
+ return p * 10.7639;
+ } else {
+ return p;
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch (_) {}
+ }
+
+ // Strategy 2: Look for "From $X / sq ft" or "From $X/sqft" or "From $X / m²"
+ // sq ft patterns
+ const sqftPatterns = [
+ /From\s+\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*(?:sq\.?\s*ft|sqft|sq\s+ft)/i,
+ /\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*(?:sq\.?\s*ft|sqft)/i,
+ /"price":\s*"?([\d.]+)"?,\s*"priceCurrency":\s*"USD"/i,
+ /price.*?From[^$]*\$([\d]+(?:\.[\d]+)?)/i,
+ ];
+ for (const re of sqftPatterns) {
+ const m = html.match(re);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (!isNaN(p) && p > 0 && p < 50) {
+ return p * 10.7639;
+ }
+ }
+ }
+
+ // m2 patterns
+ const m2Patterns = [
+ /From\s+\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*m[²2]/i,
+ /\$\s*([\d]+(?:\.[\d]+)?)\s*\/\s*m[²2]/i,
+ /([\d]+(?:\.[\d]+)?)\s*\/\s*m2/i,
+ ];
+ for (const re of m2Patterns) {
+ const m = html.match(re);
+ if (m) {
+ const p = parseFloat(m[1]);
+ if (!isNaN(p) && p > 10 && p < 300) {
+ return p;
+ }
+ }
+ }
+
+ // Strategy 3: Look for price in data attributes or script vars
+ // "basePrice":5.4 or similar
+ const basePriceRe = /"basePrice"[^:]*:\s*([\d.]+)/i;
+ const bm = html.match(basePriceRe);
+ if (bm) {
+ const p = parseFloat(bm[1]);
+ if (!isNaN(p) && p > 0) {
+ if (p < 30) return p * 10.7639;
+ return p;
+ }
+ }
+
+ // Strategy 4: Look for window.__INITIAL_STATE__ or similar
+ const initStateRe = /pricePerSquareFoot['"]\s*:\s*([\d.]+)/i;
+ const im = html.match(initStateRe);
+ if (im) {
+ const p = parseFloat(im[1]);
+ if (!isNaN(p) && p > 0) return p * 10.7639;
+ }
+
+ // Strategy 5: Look for any dollar amount that could be per sqft
+ // Find "5.40" or "7.10" or "8.20" near "sq ft" context
+ const anyPriceContextRe = /\$([\d]+(?:\.[\d]+)?)\s*\/\s*(?:sq|m)/gi;
+ const allMatches = [...html.matchAll(anyPriceContextRe)];
+ if (allMatches.length > 0) {
+ // Take the smallest (base material)
+ const prices = allMatches.map(m => parseFloat(m[1])).filter(p => !isNaN(p) && p > 0);
+ if (prices.length > 0) {
+ const minP = Math.min(...prices);
+ if (minP < 30) return minP * 10.7639;
+ return minP;
+ }
+ }
+
+ return null;
+}
+
+// ---------- Bucket ----------
+function bucketPrice(pricePerM2) {
+ if (pricePerM2 === null) return null;
+ // C2 ~58.10, C3 ~76.40, C4 ~88.30
+ // Midpoints: C2<67.25, 67.25<=C3<82.35, C4>=82.35
+ if (pricePerM2 < 67.25) return 'C2';
+ if (pricePerM2 < 82.35) return 'C3';
+ return 'C4';
+}
+
+// ---------- 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: 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: null, C4: '51.44' };
+
+// ---------- Write metafields ----------
+async function writeMetafields(pid, group, pricePerM2) {
+ const metafields = [
+ { ownerId: pid, namespace: 'custom', key: 'price_group', type: 'single_line_text_field', value: group },
+ ];
+
+ if (group !== 'C3') {
+ metafields.push(
+ { ownerId: pid, namespace: 'custom', key: 'cost_per_m2', type: 'number_decimal', value: COST_PER_M2[group] },
+ { ownerId: pid, namespace: 'custom', key: 'material_options', type: 'json', value: MATERIAL_OPTIONS[group] },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', type: 'single_line_text_field', value: `${group} portal-RRP-derived; base rate ~$${pricePerM2 ? pricePerM2.toFixed(2) : '?'}/m2; P&S/Commercial extrapolated` },
+ );
+ }
+
+ const mutation = `mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }`;
+ const result = await gql({ query: mutation, variables: { m: metafields } });
+ const userErrors = result?.data?.metafieldsSet?.userErrors || [];
+ if (userErrors.length > 0) {
+ throw new Error(`UserErrors: ${JSON.stringify(userErrors)}`);
+ }
+ if (result?.errors) {
+ throw new Error(`GQL errors: ${JSON.stringify(result.errors)}`);
+ }
+ return result;
+}
+
+// ---------- Fetch product metafields ----------
+async function fetchProductMeta(pid) {
+ const query = `{
+ product(id:"${pid}") {
+ rwUrl: metafield(namespace:"custom", key:"rw_product_url") { value }
+ pg: metafield(namespace:"custom", key:"price_group") { value }
+ }
+ }`;
+ const result = await gql({ query });
+ return result?.data?.product || null;
+}
+
+// ---------- Main ----------
+const PRODUCT_IDS = [
+ 'gid://shopify/Product/7851168563251',
+ 'gid://shopify/Product/7851168596019',
+ 'gid://shopify/Product/7851168628787',
+ 'gid://shopify/Product/7851168661555',
+ 'gid://shopify/Product/7851168694323',
+ 'gid://shopify/Product/7851168727091',
+ 'gid://shopify/Product/7851168759859',
+ 'gid://shopify/Product/7851168792627',
+ 'gid://shopify/Product/7851168825395',
+ 'gid://shopify/Product/7851168858163',
+ 'gid://shopify/Product/7851168890931',
+ 'gid://shopify/Product/7851168923699',
+ 'gid://shopify/Product/7851168956467',
+ 'gid://shopify/Product/7851168989235',
+ 'gid://shopify/Product/7851169022003',
+ 'gid://shopify/Product/7851169054771',
+ 'gid://shopify/Product/7851169087539',
+ 'gid://shopify/Product/7851169120307',
+ 'gid://shopify/Product/7851169153075',
+ 'gid://shopify/Product/7851169185843',
+ 'gid://shopify/Product/7851169218611',
+ 'gid://shopify/Product/7851169251379',
+ 'gid://shopify/Product/7851169284147',
+ 'gid://shopify/Product/7851169316915',
+ 'gid://shopify/Product/7851169382451',
+ 'gid://shopify/Product/7851169415219',
+ 'gid://shopify/Product/7851169447987',
+ 'gid://shopify/Product/7851169480755',
+ 'gid://shopify/Product/7851169513523',
+ 'gid://shopify/Product/7851169546291',
+ 'gid://shopify/Product/7851169579059',
+ 'gid://shopify/Product/7851169611827',
+ 'gid://shopify/Product/7851169644595',
+ 'gid://shopify/Product/7851169710131',
+ 'gid://shopify/Product/7851169775667',
+ 'gid://shopify/Product/7851169808435',
+ 'gid://shopify/Product/7851169841203',
+ 'gid://shopify/Product/7851169873971',
+ 'gid://shopify/Product/7851169939507',
+ 'gid://shopify/Product/7851169972275',
+];
+
+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) {
+ const shortId = pid.split('/').pop();
+ try {
+ // Fetch metafields
+ const meta = await fetchProductMeta(pid);
+ if (!meta) {
+ console.log(`[SKIP] ${shortId}: product not found`);
+ counts.failed++;
+ continue;
+ }
+
+ const rwUrl = meta.rwUrl?.value || null;
+ const existingGroup = meta.pg?.value || null;
+
+ if (!rwUrl) {
+ console.log(`[SKIP] ${shortId}: no rw_product_url`);
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ console.log(`[FETCH] ${shortId}: ${rwUrl}`);
+
+ // Fetch RW page
+ let pageResult;
+ try {
+ pageResult = await httpGet(rwUrl);
+ } catch (e) {
+ console.log(`[FAIL] ${shortId}: HTTP error: ${e.message}`);
+ counts.failed++;
+ notes.push(`${shortId}: HTTP fetch failed: ${e.message}`);
+ continue;
+ }
+
+ if (pageResult.status !== 200) {
+ console.log(`[FAIL] ${shortId}: HTTP ${pageResult.status}`);
+ counts.failed++;
+ notes.push(`${shortId}: HTTP status ${pageResult.status}`);
+ continue;
+ }
+
+ const pricePerM2 = extractPricePerM2(pageResult.body);
+ const group = bucketPrice(pricePerM2);
+
+ console.log(` price/m2=${pricePerM2 ? pricePerM2.toFixed(2) : 'null'} => ${group || 'UNKNOWN'}`);
+
+ if (!group) {
+ console.log(` [UNKNOWN] can't determine price group for ${shortId}`);
+ // Still write C3 as default if none set
+ if (!existingGroup) {
+ await writeMetafields(pid, 'C3', null);
+ }
+ counts.unknown++;
+ counts.processed++;
+ continue;
+ }
+
+ // Write metafields
+ await writeMetafields(pid, group, pricePerM2);
+ console.log(` [WRITTEN] ${group}`);
+
+ counts[group.toLowerCase()]++;
+ counts.processed++;
+
+ // Rate limit ~2 req/s (we do 2 per iteration: fetch meta + write)
+ await sleep(500);
+
+ } catch (e) {
+ console.error(`[ERROR] ${shortId}: ${e.message}`);
+ counts.failed++;
+ notes.push(`${shortId}: ${e.message}`);
+ }
+ }
+
+ console.log('\n=== RESULTS ===');
+ console.log(JSON.stringify({ ...counts, notes: notes.join('; ') || 'none' }, null, 2));
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
← e1f7121 schumacher gallery dedup tooling: visual (md5+phash) audit +
·
back to Letsbegin
·
chore: macstudio3 migration — reconcile from mac2 + repoint 2c41f83 →