← back to Dw Material Filter

classify.mjs

93 lines

#!/usr/bin/env node
// Derive a normalized `custom.material` for every ACTIVE DW product from the
// signals we already have (structured "Material:" tag → keyword scan over
// tags+title). Priority order matters: decorative/specialty surfaces and
// specific natural fibers rank ABOVE generic "Vinyl" (which is almost always
// the substrate, not the look). Read-only: emits a distribution + a write-plan
// JSONL (shopify_id → material). $0 (local, rules-based — no AI).
import { execFileSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';

// pull active products from the local mirror as TSV (id, type, title, tags, existing)
const SQL = `
copy (
  select shopify_id,
         translate(coalesce(product_type,''), E'\\t\\n\\r', '   '),
         translate(coalesce(title,''),        E'\\t\\n\\r', '   '),
         translate(coalesce(tags,''),         E'\\t\\n\\r', '   '),
         translate(coalesce(metafields->'custom'->'material_category'->>'value',''), E'\\t\\n\\r', '   ')
  from shopify_products
  where lower(status)='active' and shopify_id is not null
) to stdout`;   // default TEXT format: tab-delimited, empty string stays empty (no "" quoting)
const raw = execFileSync('psql', ['host=/tmp dbname=dw_unified', '-tA', '-c', SQL], { maxBuffer: 1 << 30 }).toString();

// canonical taxonomy, HIGHEST priority first. Each: [label, /regex/].
// Vinyl is LAST — only if nothing else matched (it's the backing, not the face).
const RULES = [
  ['Glass Bead',  /glass[\s-]*bead|beaded/],
  ['Mica',        /\bmica\b/],
  ['Cork',        /\bcork\b/],
  ['Grasscloth',  /grass[\s-]*cloth/],
  ['Sisal',       /\bsisal\b/],
  ['Seagrass',    /sea[\s-]*grass/],
  ['Jute',        /\bjute\b/],
  ['Hemp',        /\bhemp\b/],
  ['Raffia',      /\braffia\b/],
  ['Paperweave',  /paper[\s-]*weave|arrowroot/],
  ['Leather',     /\bleather\b/],
  ['Suede',       /\bsuede\b|ultrasuede/],
  ['Silk',        /\bsilk\b/],
  ['Linen',       /\blinen\b/],
  ['Wool',        /\bwool\b|\bfelt\b/],
  ['Cotton',      /\bcotton\b/],
  ['Flock',       /\bflock(ed)?\b/],
  ['Velvet',      /\bvelvet\b/],
  ['Mylar',       /\bmylar\b/],
  ['Metallic',    /\bmetallic\b|\bfoil\b|gold leaf|silver leaf|\bgilt\b/],
  ['Wood',        /\bwood\b|\bveneer\b|\bbamboo\b/],
  ['Vinyl',       /\bvinyl\b/],
];
// explicit "Material: X" structured tag → canonical (highest confidence)
const STRUCT = { linen:'Linen', cotton:'Cotton', silk:'Silk', velvet:'Velvet',
  metallic:'Metallic', wool:'Wool', leather:'Leather', suede:'Suede', cork:'Cork',
  grasscloth:'Grasscloth', vinyl:'Vinyl' };

function classify(type, title, tags, existing) {
  const ex = (existing || '').replace(/^"+|"+$/g, '').trim();   // strip any stray CSV quoting
  if (ex) return { m: ex, src: 'existing' };
  const hay = (title + ' ' + tags).toLowerCase();
  // 1) structured Material: tag
  const st = tags.match(/Material:\s*([A-Za-z][A-Za-z \-]*)/i);
  if (st) { const k = st[1].trim().toLowerCase().split(/[\s-]/)[0]; if (STRUCT[k]) return { m: STRUCT[k], src: 'struct-tag' }; }
  // 2) priority keyword scan
  for (const [label, re] of RULES) if (re.test(hay)) return { m: label, src: 'keyword' };
  return null; // unclassified
}

const dist = {}, bySrc = {}, plan = [], examples = {};
let total = 0, classified = 0;
for (const line of raw.split('\n')) {
  if (!line) continue;
  total++;
  const c = line.split('\t');
  const [sid, type, title, tags, existing] = [c[0]||'', c[1]||'', c[2]||'', c[3]||'', c[4]||''];
  const r = classify(type, title, tags, existing);
  if (!r) continue;
  classified++;
  dist[r.m] = (dist[r.m] || 0) + 1;
  bySrc[r.src] = (bySrc[r.src] || 0) + 1;
  (examples[r.m] = examples[r.m] || []).length < 3 && examples[r.m].push(`${title.slice(0,52)}  [${r.src}]`);
  if (r.src !== 'existing') plan.push({ shopify_id: sid, material: r.m }); // material_category value to write
}

writeFileSync('data/material-plan.jsonl', plan.map(p => JSON.stringify(p)).join('\n') + '\n');
console.log(`active total          : ${total}`);
console.log(`classified            : ${classified} (${(classified/total*100).toFixed(1)}%)`);
console.log(`unclassified (no write): ${total - classified}`);
console.log(`to WRITE material_category: ${plan.length}  (already-set kept: ${bySrc.existing||0})`);
console.log('by source             :', bySrc);
console.log('\nmaterial_category distribution + sample titles:');
for (const [k, v] of Object.entries(dist).sort((a, b) => b[1] - a[1])) {
  console.log(`  ${k.padEnd(12)} ${String(v).padStart(6)}   e.g. ${(examples[k]||[]).slice(0,2).join('  |  ')}`);
}