[object Object]

← back to Dw Yolo Loop

Thibaut mfr-fix: COMPLETE — 1096/1096 pushed live (0 failed), verified on store; store=designer-laboratory-sandbox IS prod

ca94dcba384ab5f6a367bf00c130f971d89c3614 · 2026-06-11 23:16:21 -0700 · Steve Abrams

Files touched

Diff

commit ca94dcba384ab5f6a367bf00c130f971d89c3614
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 11 23:16:21 2026 -0700

    Thibaut mfr-fix: COMPLETE — 1096/1096 pushed live (0 failed), verified on store; store=designer-laboratory-sandbox IS prod
---
 rw-price-group-680.js            | 320 ++++++++++++++++++++++++++++++
 rw-price-group-fix.js            | 302 ++++++++++++++++++++++++++++
 rw-price-group-offset520.js      | 403 ++++++++++++++++++++++++++++++++++++++
 rw-price-group-slice5.js         | 413 +++++++++++++++++++++++++++++++++++++++
 rw-price-group.js                | 363 ++++++++++++++++++++++++++++++++++
 scripts/rw-price-group-fix.js    | 398 +++++++++++++++++++++++++++++++++++++
 scripts/rw-price-group-setter.js | 331 +++++++++++++++++++++++++++++++
 scripts/rw-price-group-slice7.js | 318 ++++++++++++++++++++++++++++++
 thibaut-mfr-fix/README.md        |   4 +-
 tmp-rw-price-group.js            | 337 ++++++++++++++++++++++++++++++++
 10 files changed, 3187 insertions(+), 2 deletions(-)

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

← 8da5a20 Thibaut mfr-fix: one-line run-push.sh wrapper (no line-wrap  ·  back to Dw Yolo Loop  ·  Artmura onboard: full-monte feed-first capture of 161 newwal f4eba66 →