← back to Norma
agents/instagram-agent/daily-cadence.js
261 lines
#!/usr/bin/env node
/**
* daily-cadence.js — "3 posts/day per account, spaced during business hours, 6 days/week" driver.
*
* Derives today's coverage from the post-ledger, picks the next batch of accounts
* that still need today's post, auto-sources a guard-passing (no private-label leak)
* on-brand product per the account's theme, and posts a PACED batch via post-to.js.
*
* node daily-cadence.js --dry # preview the next batch, no posting
* node daily-cadence.js --batch 4 # post the next 4 accounts (default 4)
* node daily-cadence.js --status # today's coverage report only
*
* Rest day: Sundays (getDay()===0) are skipped. Sensitive (scenic/botanical) accounts
* are NOT auto-posted — they're reported as "needs Cody" for a gated visual pass.
* Restricted / settlement-skip accounts are excluded entirely.
*/
const fs = require('fs');
const path = require('path');
const cp = require('child_process');
const HERE = __dirname;
const args = process.argv.slice(2);
const flag = (n) => args.includes('--' + n);
const val = (n, d) => { const i = args.indexOf('--' + n); return i >= 0 ? args[i + 1] : d; };
const DRY = flag('dry');
const BATCH = parseInt(val('batch', '4'), 10);
const STORE = 'https://designerwallcoverings.com';
const reg = JSON.parse(fs.readFileSync(path.join(HERE, 'accounts.json'), 'utf8')).accounts;
const themes = JSON.parse(fs.readFileSync(path.join(HERE, 'account-themes.json'), 'utf8')).overrides;
const content = require('./content');
const HANDLES = Object.keys(reg);
// ---- Publisher-side enrollment HOLD enforcement (TK-11383) ------------------
// This is the point of the IRREVERSIBLE action: whatever is in HANDLES gets
// posted PUBLICLY. build-registry.js honours enrollment-hold.json when it WRITES
// accounts.json, but that guard is defeated by a hand-edit, an --include-held
// build that gets committed, or an older checkout. So the publisher enforces the
// SAME hold list independently, right where the harm would happen. A held handle
// that somehow reached accounts.json is dropped here and cannot post.
// Corrupt hold file => fail CLOSED (abort the whole run): an untrustworthy
// denylist must not be treated as empty. Absent file => warn loudly + continue
// (documented fresh state; the written registry already excludes held handles).
(() => {
let hold = {};
try {
hold = JSON.parse(fs.readFileSync(path.join(HERE, 'enrollment-hold.json'), 'utf8')).hold || {};
} catch (e) {
if (e.code !== 'ENOENT') {
console.error(`ABORT: enrollment-hold.json is unreadable (${e.message}). Refusing to post — an untrustworthy hold list must not be read as "no holds".`);
process.exit(1);
}
console.error('WARNING: enrollment-hold.json not present; proceeding with no enrollment holds.');
}
// Match the hold the SAME three ways build-registry.js does -- registry key,
// the record's own `handle` field, and ig_user_id -- and normalise every key.
// This side is the one that matters: build-registry only WRITES a file, whereas
// the post below is IRREVERSIBLE and PUBLIC, so the publisher must never be the
// weaker of the two guards. Each narrower match is a real fail-open:
// - case/whitespace: one capital in hand-typed JSON matched nothing, silently;
// - registry key only: the unlisted merge keys an account by PAGE_ID whenever
// the Graph edge returns username:null, which a handle lookup never matches;
// - handle only: a hold stops matching the moment the owner RENAMES the account,
// while ig_user_id is immutable and is literally what post-to.js publishes to
// (POST /{ig-user-id}/media_publish).
const holdByHandle = {};
for (const [k, v] of Object.entries(hold)) holdByHandle[String(k).trim().toLowerCase()] = v;
const holdByIgId = {};
for (const [k, v] of Object.entries(holdByHandle)) if (v && v.ig_user_id) holdByIgId[String(v.ig_user_id)] = k;
const fired = new Set();
for (const key of Object.keys(reg)) {
const acct = reg[key] || {};
const k = String(key).trim().toLowerCase();
const byKey = holdByHandle[k];
const byHandle = acct.handle ? holdByHandle[String(acct.handle).trim().toLowerCase()] : null;
const igName = acct.ig_user_id ? holdByIgId[String(acct.ig_user_id)] : null;
const entry = byKey || byHandle || (igName ? holdByHandle[igName] : null);
if (!entry) continue;
const how = byKey ? 'registry key' : (byHandle ? 'handle field' : 'ig_user_id');
fired.add(byKey ? k : (byHandle ? String(acct.handle).trim().toLowerCase() : igName));
delete reg[key];
const i = HANDLES.indexOf(key);
if (i >= 0) HANDLES.splice(i, 1);
console.error(`HOLD ENFORCED: @${acct.handle || key} is on the enrollment hold list (${entry.ticket || 'no ticket'}, matched by ${how}) but was present in accounts.json — dropped from this run so it will NOT post publicly. ${entry.reason || ''}`);
}
// A hold entry that matches NOTHING is reported, never silent. An unmatched entry
// is the signature of a typo'd handle, and a denylist that cannot say "I matched
// nothing" is indistinguishable from one that is working.
// Computed from what actually FIRED, not by re-scanning `reg` -- the held
// accounts have already been deleted from it by this point, so a re-scan would
// report every entry that just fired as "matched nothing". A guard must not
// misreport its own successful match.
const unmatched = Object.keys(holdByHandle).filter((k) => !fired.has(k));
if (unmatched.length) {
console.error(`HOLD: ${unmatched.length} hold entr(ies) matched no account in this registry — expected if never enrolled, but a typo'd handle looks identical: ${unmatched.join(', ')}`);
}
})();
const now = new Date();
const isRestDay = now.getDay() === 0; // Sunday
const today = now.toISOString().slice(0, 10);
// Cadence target: 3 posts/day per account, spaced during business hours (Steve 2026-08-14).
const TARGET_PER_DAY = parseInt(val('target', '3'), 10);
const MIN_GAP_MS = 3.3 * 3600 * 1000; // ~3.3h between an account's posts → 3 fit in the 08:00–20:00 window
// per-account today: how many posts + when the most recent one was (for the spacing gate)
const led = path.join(HERE, 'data', 'post-ledger.jsonl');
const rows = fs.existsSync(led) ? fs.readFileSync(led, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean) : [];
const todayRows = rows.filter((r) => (r.ts || '').slice(0, 10) === today);
const countToday = {}; const lastTs = {};
for (const r of todayRows) { countToday[r.handle] = (countToday[r.handle] || 0) + 1; if (!lastTs[r.handle] || r.ts > lastTs[r.handle]) lastTs[r.handle] = r.ts; }
const nowMs = now.getTime();
// An account still needs a post if it's under today's target AND its last post was >= the gap ago
// (last=0 when it hasn't posted today, so the gap check passes immediately).
function needsPost(h) {
if ((countToday[h] || 0) >= TARGET_PER_DAY) return false;
const last = lastTs[h] ? Date.parse(lastTs[h]) : 0;
return (nowMs - last) >= MIN_GAP_MS;
}
const everPosted = new Set(rows.map((r) => r.product_handle)); // avoid reusing a product
// Also dedup by IMAGE identity (filename), not just handle — several handles can share one image
// (e.g. the "Quick Ship Durable Walls" variants), which was silently creating same-image duplicates.
const imgKey = (u) => String(u || '').split('/').pop().split('?')[0].toLowerCase();
// Build from ALL published slides (carousel room + swatch), not just the swatch — else the room
// photo repeats across accounts, unguarded (Cody gate, /yoloforever cycle 1).
const everPostedImg = new Set();
for (const r of rows) {
everPostedImg.add(imgKey(r.image_url));
String(r.images || '').split(',').forEach((u) => everPostedImg.add(imgKey(u)));
}
everPostedImg.delete('');
function themeFor(h) {
const o = themes[h] || {};
if (o.skip) return { skip: o.skip };
const keyword = o.keyword || h.replace(/wallpaper|wallcoverings|walls|designs?/gi, '').replace(/[_-]+/g, ' ').trim() || 'wallcovering';
return { keyword, sensitive: !!o.sensitive };
}
// Fresh-sourcing fix (TK-10588, DTD 7/9 C + Cody-scoped 2026-08-15): the old source was
// suggest.json capped at 10 candidates/keyword, so narrow-keyword pools exhausted as everPosted
// grew ("no clean product … all reused"). We now page the Boost SF-filter search API (50/keyword/page,
// thousands available) for the SAME keyword. Every candidate STILL passes the identical leak/no-image
// guard below — this WIDENS the guard-gated pool, it does NOT loosen the private-label guard.
const BOOST_SHOP = 'designer-laboratory-sandbox.myshopify.com';
const CANDIDATE_PAGES = 3; // up to 150 guard-gated candidates before giving up
// ── Novasuede account guard (TK-11367; implements Steve's TK-11086 order 2026-09-01:
// "kill any posts of novasuede to designerwallcoverings websites"). Norma is a THIRD posting
// pipeline that TK-11086's patch never touched — it only fixed ~/Projects/dw-marketing-reels —
// so Norma kept fanning the Novasuede line across the DW network for six more days (35 posts,
// 09-02..09-07). Guarded HERE, at the account/product seam, because the block is account-scoped.
// Matches on TEXT (title/caption), NOT on a `DWNS-` sku prefix: the leaked posts carried DWCC-*
// skus, which is precisely why TK-11086's sku-prefix filter did not catch them.
// @suedewallpaper is deliberately NOT blocked — it is the suede line's own account and TK-10866
// kept it as a valid Novasuede target. Steve confirmed this exact scope 2026-09-10.
const NOVASUEDE_RE = /nova\s*suede/i;
const NOVASUEDE_BLOCKED = new Set(['designerwallcoverings', 'hospitalitywallcoverings', 'wallpaperinstallers']);
const novasuedeBlocked = (acct, ...texts) =>
NOVASUEDE_BLOCKED.has(String(acct || '').toLowerCase()) && texts.some((t) => NOVASUEDE_RE.test(String(t || '')));
async function tryCandidates(list, acct) {
for (const p of list) {
if (!p.handle || everPosted.has(p.handle)) continue; // don't repost a product
if (novasuedeBlocked(acct, p.title, p.handle)) continue; // cheap pre-filter, before the fetch
try {
const r = await content.resolveProduct(p.handle); // throws on leak/no-image (guard) — UNCHANGED
// Authoritative re-check on the real product payload (the search list's title can be stale/absent).
if (novasuedeBlocked(acct, r.title, r.caption)) continue;
// Skip if ANY slide (room OR swatch) was already posted — not just the swatch.
if ((r.images || [r.image_url]).some((u) => everPostedImg.has(imgKey(u)))) continue;
return { handle: p.handle, title: r.title, image_url: r.image_url };
} catch { /* leak or imageless — next */ }
}
return null;
}
async function findProduct(keyword, acct) {
for (let page = 1; page <= CANDIDATE_PAGES; page++) {
let list = [];
try {
const u = `https://services.mybcapps.com/bc-sf-filter/search?q=${encodeURIComponent(keyword)}&shop=${BOOST_SHOP}&limit=50&page=${page}`;
// 8s timeout: 3rd-party endpoint on a machine-run cron — a hung fetch would stack zombie
// launchd runs every 35min. On abort, catch->break falls through to the suggest.json fallback.
const j = await (await fetch(u, { signal: AbortSignal.timeout(8000) })).json();
list = j.products || [];
} catch { break; }
if (!list.length) break;
const hit = await tryCandidates(list, acct);
if (hit) return hit;
}
// Fallback: original 10-capped suggest.json if the Boost API is unreachable.
try {
const u = `${STORE}/search/suggest.json?q=${encodeURIComponent(keyword)}&resources%5Btype%5D=product&resources%5Blimit%5D=10`;
const j = await (await fetch(u)).json();
return await tryCandidates((j.resources?.results?.products) || [], acct);
} catch { return null; }
}
(async () => {
if (isRestDay) { console.log('REST DAY (Sunday) — no posting today.'); return; }
const hour = now.getHours();
if (!flag('force') && !flag('dry') && !flag('status') && (hour < 8 || hour >= 20)) {
console.log(`Outside posting hours (${hour}:00; window 08:00–20:00). Skipping. Use --force to override.`);
return;
}
const eligible = HANDLES.filter((h) => !themeFor(h).skip);
const remaining = eligible.filter((h) => needsPost(h)); // under target AND past the spacing gap
const skipped = HANDLES.filter((h) => themeFor(h).skip).map((h) => `${h} (${themeFor(h).skip})`);
if (flag('status')) {
const totalSlots = eligible.length * TARGET_PER_DAY;
const doneSlots = eligible.reduce((a, h) => a + Math.min(countToday[h] || 0, TARGET_PER_DAY), 0);
const waiting = eligible.filter((h) => (countToday[h] || 0) < TARGET_PER_DAY && !needsPost(h)).length;
console.log(`COVERAGE ${today}: ${doneSlots}/${totalSlots} post-slots filled (target ${TARGET_PER_DAY}/account across ${eligible.length} accounts).`);
console.log(`Eligible right now: ${remaining.length} · Waiting on spacing gap (~3.3h): ${waiting}`);
console.log(`Excluded: ${skipped.join(', ') || 'none'}`);
console.log(`Eligible now: ${remaining.join(', ') || '(none — all at target or within gap)'}`);
return;
}
if (!remaining.length) { console.log(`No accounts eligible this run for ${today} (all at ${TARGET_PER_DAY}/day target or within the ~3.3h spacing gap).`); return; }
// next batch, sensitive accounts deferred to Cody (reported, not auto-posted)
const batch = [];
const needsCody = [];
for (const h of remaining) {
if (batch.length >= BATCH) break;
const t = themeFor(h);
if (t.sensitive) { needsCody.push(h); continue; }
const prod = await findProduct(t.keyword, h);
if (!prod) { console.log(` ⚠ @${h} — no clean product for "${t.keyword}" (all leaked/reused); will retry`); continue; }
batch.push({ account: h, product: prod.handle, title: prod.title });
}
const filled = eligible.reduce((a, h) => a + Math.min(countToday[h] || 0, TARGET_PER_DAY), 0);
console.log(`Cadence ${today} — ${filled}/${eligible.length * TARGET_PER_DAY} slots filled (${TARGET_PER_DAY}/acct), posting next ${batch.length}${DRY ? ' (DRY)' : ''}:`);
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
for (let i = 0; i < batch.length; i++) {
const b = batch[i];
if (DRY) { console.log(` @${b.account} ← ${b.title} [${b.product}]`); continue; }
let out = '';
try { out = cp.execFileSync(process.execPath, [path.join(HERE, 'post-to.js'), b.account, '--product', b.product, '--confirm'], { encoding: 'utf8' }); }
catch (e) { out = String(e.stdout || '') + String(e.message || ''); }
if (/user access is restricted/i.test(out)) {
// VELOCITY TRIPWIRE — a restricted account means the token/IP may be flagging.
console.log(` ✗ @${b.account} — RESTRICTED. STOPPING batch (velocity tripwire).`);
console.log('TRIPWIRE: pausing daily cadence — surface to Steve before more posting.');
break;
}
console.log(/posted/i.test(out) ? ` ✓ @${b.account} ← ${b.title}` : ` ⚠ @${b.account} — ${out.trim().split('\n').pop()}`);
if (i < batch.length - 1) await sleep(3000); // pace within batch
}
if (needsCody.length) console.log(`Deferred to Cody (settlement-sensitive, gated visual pass): ${needsCody.join(', ')}`);
console.log(`Remaining after this batch: ${remaining.length - batch.length}`);
})();