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