← back to Letsbegin

rw-price-group-slice25.js

359 lines

#!/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); });