← back to Letsbegin

color-family-harvest.js

84 lines

#!/usr/bin/env node
/**
 * color-family-harvest.js — READ-ONLY. Pulls live Shopify title + custom.dominant_hex
 * for every product in the last-20-day cohort, parses the VENDOR color word out of the
 * live title (segment between the last " - " and " | "), and emits a JSONL of:
 *   { id, title, vendorColor, hex }
 * Used to (a) build the controlled vocabulary, (b) drive the writer. No writes.
 */
const https = require('https');
const fs = require('fs');
const { execSync } = require('child_process');

const STORE = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = (() => {
  const line = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8')
    .split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
  return line.split('=').slice(1).join('=').trim().replace(/^"|"$/g, '');
})();
const API = '/admin/api/2024-10/graphql.json';
const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';

function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
function gqlRaw(body){
  return new Promise((resolve,reject)=>{
    const data=JSON.stringify(body);
    const req=https.request({hostname:STORE,path:API,method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)}},
      res=>{let c='';res.on('data',d=>c+=d);res.on('end',()=>{try{resolve(JSON.parse(c));}catch{resolve({error:c.slice(0,300)});}});});
    req.on('error',reject); req.setTimeout(60000,()=>{req.destroy();reject(new Error('timeout'));});
    req.write(data); req.end();
  });
}
async function gql(body,retries=5){
  for(let a=1;a<=retries;a++){
    try{
      const r=await gqlRaw(body);
      if(r?.errors?.some(e=>/Throttled/i.test(e.message||''))){await sleep(3000);continue;}
      if((r?.extensions?.cost?.throttleStatus?.currentlyAvailable||9999)<200) await sleep(1500);
      return r;
    }catch(e){ if(a<retries){await sleep(a*2000);continue;} throw e; }
  }
}

// Parse the vendor color word from a live title "Pattern - Color | Vendor".
// Handles: dash delimiter (canonical), comma fallback, and "X Wallcoverings" suffix.
function parseVendorColor(title){
  if(!title) return null;
  let t = title;
  const pipe = t.indexOf('|');
  if(pipe>=0) t = t.slice(0,pipe);
  t = t.trim();
  // strip trailing "Wallcoverings"/"Wallpaper" noise word
  t = t.replace(/\s+wall\s*coverings?$/i,'').replace(/\s+wall\s*papers?$/i,'').trim();
  // last " - " split (dash). en-dash too.
  let idx = -1, m, re=/\s[-–]\s/g;
  while((m=re.exec(t))) idx=m.index;
  if(idx>=0) return t.slice(idx).replace(/^\s[-–]\s/,'').trim();
  // comma fallback (old DB-style "Pattern, Color")
  let ci=-1, r2=/,\s/g;
  while((m=r2.exec(t))) ci=m.index;
  if(ci>=0) return t.slice(ci+1).trim();
  return null; // no color segment (pattern-only title)
}

async function main(){
  const ids = execSync(`${PSQL} -U stevestudio2 -d dw_unified -t -A -c "SELECT shopify_id FROM shopify_products WHERE created_at_shopify >= now()-interval '20 days' ORDER BY created_at_shopify DESC;"`)
    .toString().trim().split('\n').map(s=>s.trim()).filter(Boolean);
  console.error(`cohort: ${ids.length} ids`);
  const out = fs.createWriteStream('/Users/macstudio3/Projects/Letsbegin/color-family-cohort.jsonl');
  let n=0;
  for(const gid of ids){
    const r = await gql({query:`{ product(id:"${gid}"){ title metafields(first:50){edges{node{namespace key value}}} } }`});
    const p = r?.data?.product;
    if(!p){ console.error(`  MISSING ${gid}`); out.write(JSON.stringify({id:gid,title:null,vendorColor:null,hex:null,missing:true})+'\n'); continue; }
    const mf={}; for(const e of (p.metafields?.edges||[])) if(e.node.namespace==='custom') mf[e.node.key]=e.node.value;
    const rec = { id:gid, title:p.title, vendorColor:parseVendorColor(p.title), hex:mf.dominant_hex||null, color_family:mf.color_family||null };
    out.write(JSON.stringify(rec)+'\n');
    if(++n%100===0) console.error(`  ${n}/${ids.length}`);
    await sleep(120);
  }
  out.end();
  console.error(`done: ${n}`);
}
main().catch(e=>{console.error('FATAL',e);process.exit(1);});