← back to Atomic50 Onboard
cadence/atomic50-cadence.mjs
210 lines
#!/usr/bin/env node
/**
* atomic50-cadence.mjs — MONTHLY Atomic 50 Ceilings line maintenance.
* PROPOSE-ONLY. NEVER writes Shopify, never publishes. Modeled on york-cadence.mjs.
*
* Each run (15th @ 08:00 PT):
* a. Best-effort re-scrape / refresh atomic50_catalog (try/catch — a headless failure
* must NOT crash the cadence). The atomic50-scraper-manager skill ships no runnable
* script entrypoint today, so this pass currently SKIPS the re-scrape and notes it;
* when an entrypoint lands, point RESCRAPE_CMD at it and it will run best-effort.
* b. Diff catalog vs live Shopify (vendor "Atomic 50 Ceilings", keyed by sec/dwc mfr_sku):
* - onboard-ready : real catalog SKU (not LBI-BOYD) not yet on Shopify
* - disco : catalog row flagged discontinued but still live ACTIVE
* - image-drift : live product carrying an Atomic 50 mfr_sku with NO image
* Since all 58 real SKUs are currently mapped, the go-forward signal is NEW or REMOVED.
* c. If anything is actionable -> ONE dated pending-approval memo + ONE CNCP parking-lot
* card. If nothing changed -> write nothing (silent, like the canaries).
* d. NEVER auto-write Shopify. Propose only.
* e. Bump vendor_registry next_scheduled_crawl +1 month, last_product_update=now().
* f. Append a run-log line to cadence/data/cadence.log.
*
* Cost: $0 — local PG (host=/tmp socket) + read-only Shopify Admin GraphQL (rate-limited).
*/
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { Client } = require('pg');
const ROOT = process.env.HOME + '/Projects/atomic50-onboard';
const DATA = ROOT + '/cadence/data';
const SHOP = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
const URL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
const VENDOR = 'Atomic 50 Ceilings';
// Runnable feed-first scraper entrypoint (sitemap + ?format=json, $0 plain-fetch).
// Wired 2026-07-14. Best-effort: a failure here does not crash the cadence.
const RESCRAPE_CMD = `/opt/homebrew/bin/node ${process.env.HOME}/Projects/atomic50-onboard/scraper/scrape-atomic50.mjs`;
// ---- token (parse .env directly; do NOT shell-source — unquoted-value bug) ----
function readToken() {
const env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
for (const line of env.split('\n')) {
const m = line.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/);
if (m) return m[1].trim().replace(/^["']|["']$/g, '');
}
throw new Error('SHOPIFY_ADMIN_TOKEN not found');
}
const TOKEN = readToken();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const norm = (s) => String(s || '').toUpperCase().trim();
const stamp = () => new Date().toISOString();
const gql = async (query, v) => {
for (let a = 0; a < 8; a++) {
let j;
try {
const r = await fetch(URL, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: v }),
});
j = await r.json();
} catch (e) { await sleep(1500 * (a + 1)); continue; }
if (j.errors) {
if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
throw new Error(JSON.stringify(j.errors));
}
const t = j.extensions?.cost?.throttleStatus;
if (t && t.currentlyAvailable < 300) await sleep(1500);
return j.data;
}
throw new Error('gql retries exhausted');
};
// ---- a. best-effort re-scrape ------------------------------------------------
function rescrape() {
if (!RESCRAPE_CMD) return { ran: false, note: 'no scraper entrypoint wired — re-scrape skipped this pass' };
try {
execSync(RESCRAPE_CMD, { stdio: 'ignore', timeout: 20 * 60 * 1000 });
return { ran: true, note: 'atomic50 re-scrape completed' };
} catch (e) {
return { ran: false, note: `re-scrape failed (continuing on existing catalog): ${String(e.message || e).slice(0, 200)}` };
}
}
async function main() {
fs.mkdirSync(DATA, { recursive: true });
const ts = stamp();
const rescr = rescrape();
const c = new Client({ host: '/tmp', database: 'dw_unified' });
await c.connect();
// ---- catalog snapshot (exclude the LBI-BOYD resource row) ----
const { rows: cat } = await c.query(`
SELECT mfr_sku, dw_sku, product_type, shopify_product_id, on_shopify,
discontinued, image_url
FROM atomic50_catalog
WHERE mfr_sku <> 'LBI-BOYD'`);
const catByMfr = new Map();
for (const r of cat) catByMfr.set(norm(r.mfr_sku), r);
// ---- live Shopify: vendor "Atomic 50 Ceilings", keyed by sec/dwc mfr_sku ----
const liveMfr = new Set();
const liveNoImage = [];
const liveActiveByMfr = new Map();
let cur = null;
for (let pg = 0; pg < 60; pg++) {
const d = (await gql(
`query($c:String){products(first:60,after:$c,query:"vendor:'Atomic 50 Ceilings'"){pageInfo{hasNextPage endCursor} nodes{id status title featuredImage{url} sec:metafield(namespace:"sec",key:"mfr_sku"){value} dwc:metafield(namespace:"dwc",key:"manufacturer_sku"){value} cust:metafield(namespace:"custom",key:"manufacturer_sku"){value}}}}`,
{ c: cur }
))?.products;
if (!d) break;
for (const p of d.nodes) {
const mfr = norm(p.sec?.value || p.dwc?.value || p.cust?.value);
if (!mfr) continue;
liveMfr.add(mfr);
if (p.status === 'ACTIVE') liveActiveByMfr.set(mfr, p);
if (!p.featuredImage?.url) liveNoImage.push({ mfr, title: p.title, status: p.status });
}
if (!d.pageInfo.hasNextPage) break;
cur = d.pageInfo.endCursor;
}
// ---- b. diff ----
// onboard-ready: real catalog SKU not present on live Shopify (NEW on the site since onboard)
const onboard = cat
.filter((r) => !liveMfr.has(norm(r.mfr_sku)))
.map((r) => ({ mfr: r.mfr_sku, dw_sku: r.dw_sku, type: r.product_type }));
// disco: catalog row flagged discontinued but still live ACTIVE (REMOVED from the site)
const disco = [];
for (const [mfr, p] of liveActiveByMfr) {
const row = catByMfr.get(mfr);
if (row && row.discontinued) disco.push({ mfr, title: p.title });
}
// image-drift: live Atomic 50 product with no image (kept as draft per DW rule, worth flagging)
const imageDrift = liveNoImage;
const actionable = onboard.length + disco.length + imageDrift.length;
const out = {
ts,
rescrape: rescr,
catalog_real: cat.length,
live_shopify: liveMfr.size,
onboard_ready: onboard.length,
disco: disco.length,
image_drift: imageDrift.length,
onboard_sample: onboard.slice(0, 12),
disco_sample: disco.slice(0, 10),
image_drift_sample: imageDrift.slice(0, 10),
};
fs.writeFileSync(DATA + '/latest.json', JSON.stringify(out, null, 2));
// ---- c. propose only on change (silent otherwise) ----
if (actionable > 0) {
const day = ts.slice(0, 10);
const memo =
`# Atomic 50 cadence — ${actionable} actionable (${day})
Monthly propose-only diff of atomic50_catalog vs live Shopify (vendor "${VENDOR}").
All customer-facing writes stay GATED — this is a surface, not an auto-apply.
- **Onboard-ready**: ${onboard.length} real catalog SKU(s) not yet on Shopify → run push-shopify.js (creates DRAFT, quote-only/sample-only). Sample: ${onboard.slice(0, 8).map((o) => o.mfr).join(', ') || '—'}
- **Disco**: ${disco.length} live ACTIVE now flagged discontinued in catalog → ARCHIVE (never delete). Sample: ${disco.slice(0, 8).map((d) => d.mfr).join(', ') || '—'}
- **Image-drift**: ${imageDrift.length} live Atomic 50 product(s) with NO image → keep DRAFT + tag Needs-Image until backfilled. Sample: ${imageDrift.slice(0, 8).map((i) => i.mfr).join(', ') || '—'}
Re-scrape this pass: ${rescr.ran ? 'ran' : 'skipped'} — ${rescr.note}
Data: cadence/data/latest.json
## Decision
- [ ] **APPROVE** — which action to run (onboard DRAFT / archive disco / backfill images)
- [ ] **REVISE** — adjust scope
- [ ] **BLOCK** — do nothing this cycle
`;
fs.writeFileSync(process.env.HOME + '/.claude/yolo-queue/pending-approval/atomic50-cadence-actions.md', memo);
try {
execSync(
`curl -s -X POST http://127.0.0.1:3333/api/parking-lot -H 'Content-Type: application/json' -d ${JSON.stringify(
JSON.stringify({
project: 'atomic50-onboard',
title: `Atomic 50 cadence: ${actionable} actionable`,
note: `onboard ${onboard.length} / disco ${disco.length} / image-drift ${imageDrift.length}`,
})
)}`,
{ stdio: 'ignore' }
);
} catch (e) { /* CNCP down — memo still written */ }
}
// ---- e. bump the schedule (+1 month) and stamp last_product_update ----
await c.query(`
UPDATE vendor_registry
SET next_scheduled_crawl = next_scheduled_crawl + interval '1 month',
last_product_update = now()
WHERE vendor_name = $1`, [VENDOR]);
await c.end();
// ---- f. run-log line ----
const logLine = `${ts} | catalog ${cat.length} | live ${liveMfr.size} | onboard-ready ${onboard.length} | disco ${disco.length} | image-drift ${imageDrift.length} | actionable ${actionable} | rescrape=${rescr.ran ? 'ran' : 'skipped'}\n`;
fs.appendFileSync(DATA + '/cadence.log', logLine);
console.log('atomic50-cadence ' + logLine.trim());
}
main().catch((e) => { console.error('atomic50-cadence FAILED:', e); process.exit(1); });