← back to Beverlyhillsvideos
social/poster/post-daily.mjs
125 lines
#!/usr/bin/env node
// post-daily.mjs — post one restaurant film to Instagram (Graph API).
// Safe by default: DRY-RUN unless BHV_IG_LIVE=1 AND a token+user-id exist.
//
// node social/poster/post-daily.mjs # dry-run (prints what it WOULD post)
// BHV_IG_LIVE=1 node social/poster/post-daily.mjs # live (needs token in env/secrets)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cfg, canGoLive } from './env.mjs';
import { buildCaption } from './caption.mjs';
import { postReel } from './ig-graph.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '../..');
// KIND = which daily slot this run serves. 'restaurant' (default) reads films.json;
// 'store' reads store-films.json — each with its own ledger so the two slots rotate
// independently. Restaurants stay restaurants-only regardless of store films existing.
const KIND = process.env.BHV_IG_KIND || 'restaurant';
const FILMS = path.join(ROOT, KIND === 'restaurant' ? 'data/films.json' : `data/${KIND}-films.json`);
const LEDGER = path.join(HERE, KIND === 'restaurant' ? 'ledger.jsonl' : `ledger-${KIND}.jsonl`);
function readLedger() {
try {
return fs.readFileSync(LEDGER, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
} catch {
return [];
}
}
function appendLedger(entry) {
fs.appendFileSync(LEDGER, JSON.stringify(entry) + '\n');
}
// Order films least-recently-posted first so the library cycles without repeats.
function rankByLRU(films, ledger) {
const lastPosted = new Map(); // slug -> ts of most recent REAL (published) post
for (const e of ledger) {
// Only a SUCCESSFULLY PUBLISHED post (has a mediaId) consumes a film's rotation
// slot. Dry-run entries (no mediaId) must not influence rotation, or a dry-tested
// film gets skipped on its real turn. A FAILED live post also writes a !dryRun
// entry (with `error`, no mediaId) — it must NOT count either, or a transient API
// failure silently benches that film for a full pool cycle (weeks) instead of
// retrying it next slot.
if (e.slug && e.mediaId) lastPosted.set(e.slug, e.ts);
}
return films
.map((f) => ({ film: f, last: lastPosted.get(f.slug) || '' }))
.sort((a, b) => (a.last < b.last ? -1 : a.last > b.last ? 1 : 0)) // never-posted ('') first
.map((x) => x.film);
}
// Restaurant films are landscape with a vertical counterpart under /video/vertical/;
// store cards are authored vertical already (src points straight at the 9:16 file).
const verticalUrl = (film) =>
KIND === 'store' || film.src.includes('/vertical/') || film.src.includes('/store/')
? `${cfg.siteBase}${film.src}`
: `${cfg.siteBase}${film.src.replace('/video/', '/video/vertical/')}`;
async function reachable(url) {
// 5s abort so a hung (non-refusing) server can't freeze the whole posting slot.
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 5000);
try {
const res = await fetch(url, { method: 'HEAD', signal: ac.signal });
return res.ok && (res.headers.get('content-type') || '').includes('video');
} catch {
return false;
} finally {
clearTimeout(t);
}
}
// Walk the LRU order to the first film whose vertical asset actually serves.
async function pickNextFilm(films, ledger) {
for (const film of rankByLRU(films, ledger)) {
if (await reachable(verticalUrl(film))) return film;
console.warn(`[bhv-ig] skip ${film.slug}: no reachable vertical at ${verticalUrl(film)}`);
}
return null;
}
async function main() {
if (!fs.existsSync(FILMS)) {
console.log(`[bhv-ig] no ${KIND} film library yet (${path.basename(FILMS)}) — nothing to post.`);
return;
}
// Legacy restaurant entries predate the `type` field, so treat missing type as restaurant.
const films = JSON.parse(fs.readFileSync(FILMS, 'utf8')).filter((f) => (f.type || 'restaurant') === KIND);
const ledger = readLedger();
const film = await pickNextFilm(films, ledger);
if (!film) {
console.error('[bhv-ig] no film with a reachable vertical asset — nothing to post.');
process.exitCode = 1;
return;
}
const seed = ledger.length;
const caption = buildCaption(film, seed);
const videoUrl = verticalUrl(film);
const ts = new Date().toISOString();
console.log(`[bhv-ig] picked: ${film.name} (${film.slug})`);
console.log(`[bhv-ig] video : ${videoUrl}`);
console.log(`[bhv-ig] caption:\n${caption}\n`);
if (!canGoLive()) {
const why = !cfg.live ? 'BHV_IG_LIVE!=1' : !cfg.igUserId ? 'no BHV_IG_USER_ID' : 'no BHV_IG_ACCESS_TOKEN';
console.log(`[bhv-ig] DRY-RUN (${why}) — nothing posted.`);
appendLedger({ ts, slug: film.slug, name: film.name, videoUrl, dryRun: true, reason: why });
return;
}
try {
const { containerId, mediaId } = await postReel({ videoUrl, caption });
console.log(`[bhv-ig] POSTED ✓ media=${mediaId} container=${containerId}`);
appendLedger({ ts, slug: film.slug, name: film.name, videoUrl, dryRun: false, containerId, mediaId });
} catch (err) {
console.error(`[bhv-ig] POST FAILED: ${err.message}`);
appendLedger({ ts, slug: film.slug, name: film.name, videoUrl, dryRun: false, error: err.message });
process.exitCode = 1;
}
}
main();