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