← 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); });