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