[object Object]

← back to Letsbegin

auto-save: 2026-06-21T18:53:00 (9 files) — rw-fix-rollprice.js rw-price-group-slice16.js rw-price-group-slice17.js rw-price-group-slice19.js rw-price-group-slice2.js

76bceba6e6321a9ab69fd391dd07f2eddab2b165 · 2026-06-21 18:53:04 -0700 · Steve Abrams

Files touched

Diff

commit 76bceba6e6321a9ab69fd391dd07f2eddab2b165
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jun 21 18:53:04 2026 -0700

    auto-save: 2026-06-21T18:53:00 (9 files) — rw-fix-rollprice.js rw-price-group-slice16.js rw-price-group-slice17.js rw-price-group-slice19.js rw-price-group-slice2.js
---
 rw-fix-rollprice.js       | 100 ++++++++++
 rw-price-group-slice16.js | 451 ++++++++++++++++++++++++++++++++++++++++++++
 rw-price-group-slice17.js | 466 ++++++++++++++++++++++++++++++++++++++++++++++
 rw-price-group-slice19.js | 428 ++++++++++++++++++++++++++++++++++++++++++
 rw-price-group-slice2.js  | 405 ++++++++++++++++++++++++++++++++++++++++
 rw-price-group-slice21.js | 373 +++++++++++++++++++++++++++++++++++++
 rw-price-group-slice22.js | 360 +++++++++++++++++++++++++++++++++++
 rw-price-group-slice25.js | 358 +++++++++++++++++++++++++++++++++++
 rw-price-group.js         | 381 +++++++++++++++++++++++++++++++++++++
 9 files changed, 3322 insertions(+)

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

← e1f7121 schumacher gallery dedup tooling: visual (md5+phash) audit +  ·  back to Letsbegin  ·  chore: macstudio3 migration — reconcile from mac2 + repoint 2c41f83 →