← back to Dw Photo Capture

schu-publish.mjs

172 lines

// Republish settlement-clean Schumacher active-but-unpublished products to the Online Store channel ONLY.
// DRY-RUN by default; --apply to write. Publish-to-channel ONLY (never touches price/title/tags/status).
// Per-product gate: live re-verify (active + published_at NULL + >=1 image + sellable price>0),
// then settlement pre-screen (bird/palm/leaf/frond/tropical/butterfly/banana/grape/animal/leopard -> HOLD).
import fs from 'node:fs';

const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const STORE = (env.match(/^SHOPIFY_STORE=(.+)$/m) || [])[1].trim();
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
const GQL = `https://${STORE}/admin/api/2024-10/graphql.json`;
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const APPLY = process.argv.includes('--apply');
const AUDIT = '/Users/macstudio3/Projects/dw-photo-capture/schu-publish-audit.jsonl';
const ONLINE_STORE_PUB = 'gid://shopify/Publication/22208643184';

const sleep = ms => new Promise(r => setTimeout(r, ms));

// Settlement pre-screen: HOLD on any motif in the settlement's prohibited/tropical space.
// Conservative — when a token matches, HOLD. Word-boundaried to avoid false hits (e.g. "believe").
const HOLD_RE = new RegExp(
  '\\b(' + [
    'bird','birds','avian','parrot','parakeet','macaw','toucan','cockatoo','peacock','peafowl',
    'crane','heron','egret','flamingo','swallow','dove','sparrow','finch','pheasant','partridge','quail',
    'palm','palms','frond','fronds','tropical','tropic','jungle','rainforest','banana','bananas','plantain',
    'grape','grapes','grapevine','vine','vines','leaf','leaves','foliage','foliate','frondescence',
    'butterfly','butterflies','moth','moths','leopard','cheetah','tiger','zebra','jaguar','panther',
    'monkey','monkeys','flora','fauna','paradise','aviary','birdcage','ornithology','feather','feathers',
    'palmier','palmette','fern','ferns','banana-leaf','bananaleaf',
    'floral','florals','acanthus','ivy','forest'
  ].join('|') + ')\\b', 'i');

function settlementHold(title, handle, tags) {
  const hay = `${title} ${String(handle).replace(/-/g, ' ')} ${tags}`;
  const m = hay.match(HOLD_RE);
  return m ? m[1].toLowerCase() : null;
}

async function gql(query, variables) {
  for (let a = 0; a < 5; a++) {
    let r;
    try {
      const ctrl = new AbortController();
      const t = setTimeout(() => ctrl.abort(), 20000); // 20s hard timeout per request
      r = await fetch(GQL, { method: 'POST', headers: H, body: JSON.stringify({ query, variables }), signal: ctrl.signal });
      clearTimeout(t);
    } catch (e) {
      if (a === 4) throw new Error('fetch-failed:' + (e.name || e.message));
      await sleep(1000 * (a + 1)); continue;
    }
    if (r.status === 429 || r.status >= 500) { await sleep(1200 * (a + 1)); continue; }
    const j = await r.json();
    if (j.errors) { if (a === 4) throw new Error(JSON.stringify(j.errors)); await sleep(1200 * (a + 1)); continue; }
    // throttle backoff on cost
    const cost = j.extensions?.cost?.throttleStatus;
    if (cost && cost.currentlyAvailable < 100) await sleep(700);
    return j.data;
  }
  throw new Error('gql retries exhausted');
}

const Q = `query($id:ID!){
  product(id:$id){
    id title handle status vendor
    publishedOnCurrentPublication
    publishedAt: publishedInContext
    featuredImage{ url }
    images(first:1){ edges{ node{ url } } }
    resourcePublicationOnCurrentPublication{ isPublished }
    variants(first:20){ edges{ node{ id price sku } } }
  }
}`;
// simpler/robust query — some fields above may not exist; use a minimal reliable one
const Q2 = `query($id:ID!){
  product(id:$id){
    id title handle status vendor
    publishedAt
    images(first:1){ edges{ node{ url } } }
    variants(first:30){ edges{ node{ id price sku } } }
  }
}`;

const M_PUB = `mutation($id:ID!,$pubs:[PublicationInput!]!){publishablePublish(id:$id,input:$pubs){publishable{publicationCount}userErrors{field message}}}`;

const rows = fs.readFileSync('/Users/macstudio3/Projects/dw-photo-capture/schu-candidates.tsv', 'utf8')
  .trim().split('\n').filter(Boolean).map(l => {
    const [shopify_id, handle, title, tags] = l.split('\t');
    return { shopify_id, handle, title, tags: tags || '' };
  });

const counts = { published: 0, hold_settlement: 0, hold_noprice: 0, already_published: 0,
  hold_noimage: 0, other_skip: 0, not_active: 0, error: 0 };
const settlementList = [];
const publishedSample = [];
const auditLines = [];

(async () => {
  let i = 0;
  for (const row of rows) {
    i++;
    const gid = String(row.shopify_id).startsWith('gid://') ? String(row.shopify_id) : `gid://shopify/Product/${row.shopify_id}`;
    let rec = { shopify_id: row.shopify_id, handle: row.handle, title: row.title };
    try {
      const d = await gql(Q2, { id: gid });
      const p = d.product;
      if (!p) { counts.other_skip++; rec.decision = 'skip'; rec.reason = 'product-not-found'; auditLines.push(rec); continue; }
      rec.live_title = p.title; rec.live_handle = p.handle; rec.vendor = p.vendor;
      rec.status = p.status; rec.publishedAt = p.publishedAt;

      // GUARD: vendor must be schumacher-ish, must NOT be apartmentwallpaper/surface-stick.
      if (!/schumacher|borastapeter|bor.?stapeter/i.test(p.vendor || '')) {
        counts.other_skip++; rec.decision = 'skip'; rec.reason = `vendor-mismatch:${p.vendor}`; auditLines.push(rec); continue;
      }
      // status must still be active
      if (String(p.status).toUpperCase() !== 'ACTIVE') {
        counts.not_active++; rec.decision = 'skip'; rec.reason = `status:${p.status}`; auditLines.push(rec); continue;
      }
      // skip if already published (publishedAt not null)
      if (p.publishedAt) {
        counts.already_published++; rec.decision = 'skip'; rec.reason = 'already-published'; auditLines.push(rec); continue;
      }
      // must have >=1 image
      const img = p.images?.edges?.[0]?.node?.url;
      if (!img) {
        counts.hold_noimage++; rec.decision = 'hold'; rec.reason = 'no-image'; rec.needs = 'image'; auditLines.push(rec); continue;
      }
      // sellable variant price > 0 (exclude sample variant)
      const variants = (p.variants?.edges || []).map(e => e.node);
      const sellable = variants.filter(v => !/-Sample$/i.test(v.sku || '') && !/sample/i.test(v.sku || ''));
      const prices = (sellable.length ? sellable : variants).map(v => parseFloat(v.price)).filter(n => Number.isFinite(n));
      const maxPrice = prices.length ? Math.max(...prices) : 0;
      rec.max_sellable_price = maxPrice;
      if (!(maxPrice > 0)) {
        counts.hold_noprice++; rec.decision = 'hold'; rec.reason = 'no-price'; auditLines.push(rec); continue;
      }
      // SETTLEMENT GATE (hard) — use LIVE title/handle + mirror tags
      const holdTok = settlementHold(p.title, p.handle, row.tags);
      if (holdTok) {
        counts.hold_settlement++; rec.decision = 'hold'; rec.reason = 'settlement'; rec.matched = holdTok;
        settlementList.push({ shopify_id: row.shopify_id, handle: p.handle, title: p.title, matched: holdTok });
        auditLines.push(rec); continue;
      }
      // PASSED BOTH GATES
      if (!APPLY) {
        counts.published++; rec.decision = 'would-publish'; auditLines.push(rec);
        if (publishedSample.length < 12) publishedSample.push({ handle: p.handle, title: p.title, price: maxPrice });
        continue;
      }
      const pub = await gql(M_PUB, { id: gid, pubs: [{ publicationId: ONLINE_STORE_PUB }] });
      const errs = pub.publishablePublish?.userErrors || [];
      if (errs.length) {
        counts.error++; rec.decision = 'error'; rec.reason = JSON.stringify(errs); auditLines.push(rec); continue;
      }
      counts.published++; rec.decision = 'published'; auditLines.push(rec);
      if (publishedSample.length < 12) publishedSample.push({ handle: p.handle, title: p.title, price: maxPrice });
      await sleep(250); // gentle pacing
    } catch (e) {
      counts.error++; rec.decision = 'error'; rec.reason = String(e.message || e); auditLines.push(rec);
      await sleep(800);
    }
    if (i % 10 === 0) {
      process.stderr.write(`...${i}/${rows.length} ${JSON.stringify(counts)}\n`);
      fs.writeFileSync(AUDIT, auditLines.map(o => JSON.stringify(o)).join('\n') + '\n');
    }
  }

  fs.writeFileSync(AUDIT, auditLines.map(o => JSON.stringify(o)).join('\n') + '\n');
  fs.writeFileSync('/Users/macstudio3/Projects/dw-photo-capture/schu-settlement-hold.json', JSON.stringify(settlementList, null, 2));
  console.log(JSON.stringify({ mode: APPLY ? 'APPLY' : 'DRY-RUN', total: rows.length, counts,
    publishedSample, settlementCount: settlementList.length,
    settlementList: settlementList.map(s => `${s.handle} [${s.matched}]`) }, null, 2));
})();