← back to Dw Five Field Step0

scripts/scan-live.js

117 lines

#!/usr/bin/env node
/**
 * Step-0 live scanner (DTD verdict B, 2026-06-21).
 *
 * READ-ONLY. Scans every ACTIVE Shopify product, reads each variant's live
 * title + price + sku + the product descriptionHtml, classifies the 5-field
 * structure per SKU, and persists ONE row per active product into
 * dw_unified.active_five_field_status (truncate+reload, idempotent).
 *
 * Ground truth for has_sample / has_product (the mirror -Sample-suffix
 * heuristic misclassifies ~5k SKUs — Koroseal $4.25-leak, non-suffixed
 * samples). We classify a variant as a SAMPLE if its title says "sample" OR
 * its sku ends -sample OR (single-variant product whose only variant is
 * priced exactly 4.25 — the leak pattern). has_product = a non-sample variant.
 *
 * Cost: $0 (free Shopify GraphQL reads). No writes to Shopify.
 */
const fs = require('fs');
const { Pool } = require('pg');

const env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.match(/SHOPIFY_ADMIN_TOKEN=(\S+)/) || [])[1];
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
const URL = `https://${DOMAIN}/admin/api/${VER}/graphql.json`;
// Connect over the unix socket (peer auth, no password) exactly like the psql
// CLI does — TCP to 127.0.0.1 demands a password the local trust setup rejects.
delete process.env.PGPASSWORD;
delete process.env.DATABASE_URL;
const pool = new Pool({ host: '/tmp', database: 'dw_unified' });

const Q = `query($cursor:String){products(first:120,after:$cursor,query:"status:active"){pageInfo{hasNextPage endCursor} edges{node{id vendor descriptionHtml variants(first:30){edges{node{title sku price}}}}}}}`;

const isSampleByLabel = x => /sample/i.test(x.title || '') || /-sample$/i.test(x.sku || '');

async function gql(cursor) {
  for (let a = 0; a < 8; a++) {
    const r = await fetch(URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN }, body: JSON.stringify({ query: Q, variables: { cursor } }) });
    const j = await r.json();
    if (j.errors && JSON.stringify(j.errors).includes('Throttled')) { await new Promise(s => setTimeout(s, 3000)); continue; }
    return j;
  }
  throw new Error('throttled-exhausted');
}

(async () => {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS active_five_field_status (
      shopify_id text PRIMARY KEY,
      vendor text,
      bare_sku text,        -- the non-sample variant sku (or stripped), our dw_sku join key
      has_sample boolean,
      has_product boolean,
      any_zero boolean,     -- any variant price 0/null
      product_price numeric, -- price of the (first) non-sample variant, if any
      sample_price numeric,
      variant_count int,
      no_desc boolean,
      scanned_at timestamptz default now()
    );`);
  await pool.query('TRUNCATE active_five_field_status;');

  let cursor = null, n = 0, pages = 0, batch = [];
  const flush = async () => {
    if (!batch.length) return;
    const vals = [], ph = [];
    batch.forEach((r, i) => {
      const b = i * 10;
      ph.push(`($${b+1},$${b+2},$${b+3},$${b+4},$${b+5},$${b+6},$${b+7},$${b+8},$${b+9},$${b+10})`);
      vals.push(r.shopify_id, r.vendor, r.bare_sku, r.has_sample, r.has_product, r.any_zero, r.product_price, r.sample_price, r.variant_count, r.no_desc);
    });
    await pool.query(`INSERT INTO active_five_field_status
      (shopify_id,vendor,bare_sku,has_sample,has_product,any_zero,product_price,sample_price,variant_count,no_desc)
      VALUES ${ph.join(',')} ON CONFLICT (shopify_id) DO NOTHING`, vals);
    batch = [];
  };

  while (true) {
    const j = await gql(cursor);
    if (j.errors) { console.error('ERR', JSON.stringify(j.errors).slice(0, 300)); break; }
    for (const { node: p } of j.data.products.edges) {
      n++;
      const vs = p.variants.edges.map(e => e.node);
      const prices = vs.map(x => (x.price == null ? null : parseFloat(x.price)));
      const labelSample = vs.map(isSampleByLabel);
      // Leak detection: single variant, not labeled sample, priced exactly 4.25 -> it's a leaked sample (no real product).
      const leak = vs.length === 1 && !labelSample[0] && prices[0] === 4.25;
      const isSample = vs.map((x, i) => labelSample[i] || (leak && i === 0));
      const hasSample = isSample.some(Boolean);
      const hasProduct = vs.some((x, i) => !isSample[i]);
      const nonSampleIdx = vs.findIndex((x, i) => !isSample[i]);
      const sampleIdx = isSample.indexOf(true);
      const anyZero = prices.some(pp => pp == null || pp === 0);
      const txt = (p.descriptionHtml || '').replace(/<[^>]*>/g, '').trim();
      const rawSku = (nonSampleIdx >= 0 ? vs[nonSampleIdx].sku : vs[0]?.sku) || '';
      const bare = rawSku.replace(/-Sample$/i, '');
      batch.push({
        shopify_id: p.id, vendor: p.vendor, bare_sku: bare,
        has_sample: hasSample, has_product: hasProduct, any_zero: anyZero,
        product_price: nonSampleIdx >= 0 ? prices[nonSampleIdx] : null,
        sample_price: sampleIdx >= 0 ? prices[sampleIdx] : null,
        variant_count: vs.length, no_desc: txt.length < 5,
      });
    }
    if (batch.length >= 400) await flush();
    pages++;
    const pi = j.data.products.pageInfo;
    cursor = pi.endCursor;
    if (pages % 25 === 0) console.error(`...${n} scanned`);
    if (!pi.hasNextPage) break;
    await new Promise(s => setTimeout(s, 220));
  }
  await flush();
  const c = await pool.query('SELECT count(*) FROM active_five_field_status');
  console.log(`DONE scanned=${n} persisted=${c.rows[0].count}`);
  await pool.end();
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });