[object Object]

← back to Beverlyhillsvideos

social: BHV Instagram daily poster (Graph API, restaurants-only, DRY-RUN-safe)

12a0de58634c9949d7a6f730c4e080dfc037a0fb · 2026-08-06 14:12:22 -0700 · Steve Abrams

- social/poster: Graph API Reel flow (container->poll->publish), LRU film
  rotation over the 44-film dining library, reachability-guarded video URLs,
  on-brand caption+hashtag builder, append-only ledger.
- Safe by default: DRY-RUN unless BHV_IG_LIVE=1 + token+user-id present.
- Draft launchd job (11:30 AM PT) + runbook; go-live gated on Meta token setup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 12a0de58634c9949d7a6f730c4e080dfc037a0fb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 14:12:22 2026 -0700

    social: BHV Instagram daily poster (Graph API, restaurants-only, DRY-RUN-safe)
    
    - social/poster: Graph API Reel flow (container->poll->publish), LRU film
      rotation over the 44-film dining library, reachability-guarded video URLs,
      on-brand caption+hashtag builder, append-only ledger.
    - Safe by default: DRY-RUN unless BHV_IG_LIVE=1 + token+user-id present.
    - Draft launchd job (11:30 AM PT) + runbook; go-live gated on Meta token setup.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 social/poster/README.md                    | 56 +++++++++++++++++
 social/poster/caption.mjs                  | 38 ++++++++++++
 social/poster/com.steve.bhv-ig-daily.plist | 32 ++++++++++
 social/poster/env.mjs                      | 46 ++++++++++++++
 social/poster/ig-graph.mjs                 | 69 +++++++++++++++++++++
 social/poster/post-daily.mjs               | 99 ++++++++++++++++++++++++++++++
 social/poster/run.sh                       |  9 +++
 7 files changed, 349 insertions(+)

diff --git a/social/poster/README.md b/social/poster/README.md
new file mode 100644
index 0000000..a3dd3b7
--- /dev/null
+++ b/social/poster/README.md
@@ -0,0 +1,56 @@
+# BHV Instagram — daily poster (Graph API)
+
+Posts **1 restaurant film per day** to [@beverlyhillsvideos](https://instagram.com/beverlyhillsvideos)
+via the official Instagram Graph API. Rotates least-recently-posted through the 44-film
+dining library (`data/films.json`), so it cycles ~every 6 weeks with no repeats.
+
+Stores are **not** wired yet — no store media exists (decision 2026-08-06). Restaurants only for now.
+
+## Files
+| file | role |
+|---|---|
+| `post-daily.mjs` | entry point — pick next film → build caption → post (or dry-run) → ledger |
+| `ig-graph.mjs` | Graph API Reel flow: create container → poll status → publish |
+| `caption.mjs` | on-brand caption + rotating hashtags (from `../LAUNCH-PACK.md`) |
+| `env.mjs` | resolves config from env, falling back to the secrets master `.env` |
+| `run.sh` | launchd/cron wrapper (logs to `post.log`) |
+| `com.steve.bhv-ig-daily.plist` | **draft** launchd job, 11:30 AM PT — not installed |
+| `ledger.jsonl` | append-only record of every pick (dry + live) |
+
+## Safety model
+Nothing posts for real unless **all three** are true:
+`BHV_IG_LIVE=1` **and** `BHV_IG_USER_ID` set **and** `BHV_IG_ACCESS_TOKEN` set.
+Otherwise it DRY-RUNs (prints exactly what it would post) and records a dry ledger entry.
+
+```sh
+node social/poster/post-daily.mjs            # dry-run, always safe
+BHV_IG_LIVE=1 node social/poster/post-daily.mjs   # live (needs token)
+```
+
+## One-time Meta setup (required before first live post) — Steve-gated
+The Graph API cannot use the account password. It needs a Business/Creator account + a
+Facebook Page + a long-lived token. Password (`BHV_INSTAGRAM_PASSWORD`) is stored only for
+account recovery / manual login — the poster never uses it.
+
+1. In the IG app: **Settings → Account type → switch to Business (or Creator)**.
+2. Create/link a **Facebook Page** for Beverly Hills Videos and connect it to the IG account.
+3. Reuse the existing Meta app (`META_APP_ID` / `META_APP_SECRET` in secrets) or make one.
+   Add the **Instagram Graph API** product; scopes: `instagram_basic`,
+   `instagram_content_publish`, `pages_show_list`, `business_management`.
+4. Generate a **long-lived** access token for the Page, and get the **IG Business user id**
+   (`GET /me/accounts` → page → `instagram_business_account`).
+5. Route both via the secrets manager (auto-fans to the master `.env`):
+   ```sh
+   node ~/Projects/secrets-manager/cli.js add BHV_IG_ACCESS_TOKEN <long-lived-token>
+   node ~/Projects/secrets-manager/cli.js add BHV_IG_USER_ID <ig-business-user-id>
+   ```
+   Long-lived tokens last ~60 days — a refresh canary should be added before go-live.
+6. Prove it live once: `BHV_IG_LIVE=1 node social/poster/post-daily.mjs`
+7. Install the daily job:
+   ```sh
+   cp social/poster/com.steve.bhv-ig-daily.plist ~/Library/LaunchAgents/
+   launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.bhv-ig-daily.plist
+   ```
+
+Steps 1–2 (and possibly 3–4 in the Meta portal) need Steve's interactive login; the
+`portal-driver` skill can drive the clicking once Steve is authenticated.
diff --git a/social/poster/caption.mjs b/social/poster/caption.mjs
new file mode 100644
index 0000000..0449b94
--- /dev/null
+++ b/social/poster/caption.mjs
@@ -0,0 +1,38 @@
+// caption.mjs — assemble an on-brand IG caption + rotating hashtags for a film.
+// Voice + hashtag pools lifted from social/LAUNCH-PACK.md and social/CAPTIONS.md.
+
+// Always-on core tags (broad reach + brand + place).
+const CORE = ['#beverlyhills', '#beverlyhillsvideos', '#90210', '#rodeodrive'];
+
+// Rotating pools — pick a spread so every post's tag set differs slightly.
+const DINING = [
+  '#finedining', '#beverlyhillsrestaurants', '#luxurydining', '#laeats',
+  '#losangelesfood', '#eaterla', '#californiacuisine', '#michelin',
+  '#wheretoeat', '#foodie', '#luxurylifestyle', '#visitbeverlyhills',
+  '#luxurytravel', '#cityguide', '#cinematic',
+];
+
+// Deterministic-ish rotation by index so consecutive days don't repeat sets.
+function rotate(pool, n, seed) {
+  const out = [];
+  const start = Math.abs(seed) % pool.length;
+  for (let i = 0; out.length < n && i < pool.length; i++) {
+    out.push(pool[(start + i) % pool.length]);
+  }
+  return out;
+}
+
+export function buildCaption(film, seedIndex = 0) {
+  const blurb = (film.blurb || '').trim().replace(/\s+/g, ' ');
+  const hook = blurb ? `${film.name}. ${blurb}` : `${film.name} — Beverly Hills, in film.`;
+  const tags = [...CORE, ...rotate(DINING, 11, seedIndex)];
+  const body = [
+    hook,
+    '',
+    'A short film from Beverly Hills Videos — the insider’s guide to the city’s finest dining. Full guide, menu & interactive map → link in bio.',
+    '',
+    tags.join(' '),
+  ].join('\n');
+  // IG hard cap is 2200 chars; our captions run ~500, but clamp defensively.
+  return body.slice(0, 2190);
+}
diff --git a/social/poster/com.steve.bhv-ig-daily.plist b/social/poster/com.steve.bhv-ig-daily.plist
new file mode 100644
index 0000000..4f01a60
--- /dev/null
+++ b/social/poster/com.steve.bhv-ig-daily.plist
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- DRAFT — not installed. Daily BHV Instagram post at 11:30 AM PT.
+     Install (Steve-gated go-live) with:
+       cp social/poster/com.steve.bhv-ig-daily.plist ~/Library/LaunchAgents/
+       launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.bhv-ig-daily.plist
+     Requires BHV_IG_USER_ID + BHV_IG_ACCESS_TOKEN in the secrets master .env. -->
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.bhv-ig-daily</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/zsh</string>
+    <string>/Users/macstudio3/Projects/beverlyhillsvideos/social/poster/run.sh</string>
+  </array>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>BHV_IG_LIVE</key>
+    <string>1</string>
+  </dict>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key><integer>11</integer>
+    <key>Minute</key><integer>30</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/macstudio3/Projects/beverlyhillsvideos/social/poster/launchd.out</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/macstudio3/Projects/beverlyhillsvideos/social/poster/launchd.err</string>
+</dict>
+</plist>
diff --git a/social/poster/env.mjs b/social/poster/env.mjs
new file mode 100644
index 0000000..3f021a9
--- /dev/null
+++ b/social/poster/env.mjs
@@ -0,0 +1,46 @@
+// env.mjs — resolve BHV Instagram config.
+// Reads from process.env first; falls back to the secrets-manager master .env
+// so the launchd/cron wrapper doesn't have to export every key by hand.
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+const SECRETS_ENV = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+
+function fromSecretsFile(key) {
+  try {
+    const txt = fs.readFileSync(SECRETS_ENV, 'utf8');
+    // last occurrence wins (matches how the secrets CLI appends)
+    let val = null;
+    for (const line of txt.split('\n')) {
+      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+      if (m && m[1] === key) val = m[2];
+    }
+    return val;
+  } catch {
+    return null;
+  }
+}
+
+function get(key, fallback = null) {
+  return process.env[key] ?? fromSecretsFile(key) ?? fallback;
+}
+
+export const cfg = {
+  // Graph API identity — populated once the account is Business/Creator + token issued.
+  igUserId: get('BHV_IG_USER_ID'),
+  accessToken: get('BHV_IG_ACCESS_TOKEN'),
+  graphVersion: get('BHV_IG_GRAPH_VERSION', 'v21.0'),
+
+  // Where Instagram fetches the video from (must be public HTTPS).
+  siteBase: get('BHV_SITE_BASE', 'https://beverlyhillsvideos.com'),
+
+  // Safety: nothing posts for real unless BHV_IG_LIVE=1 AND a token exists.
+  live: get('BHV_IG_LIVE', '0') === '1',
+
+  handle: get('BHV_INSTAGRAM_HANDLE', 'beverlyhillsvideos'),
+};
+
+export function canGoLive() {
+  return Boolean(cfg.live && cfg.igUserId && cfg.accessToken);
+}
diff --git a/social/poster/ig-graph.mjs b/social/poster/ig-graph.mjs
new file mode 100644
index 0000000..f3aa87f
--- /dev/null
+++ b/social/poster/ig-graph.mjs
@@ -0,0 +1,69 @@
+// ig-graph.mjs — Instagram Graph API Reel publishing (3-step async flow).
+// Docs: developers.facebook.com/docs/instagram-platform/content-publishing
+import { cfg } from './env.mjs';
+
+const base = () => `https://graph.facebook.com/${cfg.graphVersion}`;
+
+async function gpost(pathPart, params) {
+  const url = new URL(`${base()}/${pathPart}`);
+  const body = new URLSearchParams({ ...params, access_token: cfg.accessToken });
+  const res = await fetch(url, { method: 'POST', body });
+  const json = await res.json();
+  if (!res.ok || json.error) {
+    throw new Error(`Graph POST ${pathPart} failed: ${JSON.stringify(json.error || json)}`);
+  }
+  return json;
+}
+
+async function gget(pathPart, fields) {
+  const url = new URL(`${base()}/${pathPart}`);
+  url.searchParams.set('fields', fields);
+  url.searchParams.set('access_token', cfg.accessToken);
+  const res = await fetch(url);
+  const json = await res.json();
+  if (!res.ok || json.error) {
+    throw new Error(`Graph GET ${pathPart} failed: ${JSON.stringify(json.error || json)}`);
+  }
+  return json;
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// Step 1: create a REELS container pointing at the public video URL.
+export async function createReelContainer({ videoUrl, caption }) {
+  const json = await gpost(`${cfg.igUserId}/media`, {
+    media_type: 'REELS',
+    video_url: videoUrl,
+    caption,
+    share_to_feed: 'true',
+  });
+  return json.id; // container id
+}
+
+// Step 2: poll the container until Instagram finishes transcoding.
+export async function waitForContainer(containerId, { timeoutMs = 300000, everyMs = 8000 } = {}) {
+  const deadline = Date.now() + timeoutMs;
+  while (Date.now() < deadline) {
+    const { status_code, status } = await gget(containerId, 'status_code,status');
+    if (status_code === 'FINISHED') return true;
+    if (status_code === 'ERROR' || status_code === 'EXPIRED') {
+      throw new Error(`Container ${containerId} ${status_code}: ${status}`);
+    }
+    await sleep(everyMs);
+  }
+  throw new Error(`Container ${containerId} not FINISHED within ${timeoutMs}ms`);
+}
+
+// Step 3: publish the finished container to the feed.
+export async function publishContainer(containerId) {
+  const json = await gpost(`${cfg.igUserId}/media_publish`, { creation_id: containerId });
+  return json.id; // published media id
+}
+
+// Full flow.
+export async function postReel({ videoUrl, caption }) {
+  const containerId = await createReelContainer({ videoUrl, caption });
+  await waitForContainer(containerId);
+  const mediaId = await publishContainer(containerId);
+  return { containerId, mediaId };
+}
diff --git a/social/poster/post-daily.mjs b/social/poster/post-daily.mjs
new file mode 100644
index 0000000..47bf03f
--- /dev/null
+++ b/social/poster/post-daily.mjs
@@ -0,0 +1,99 @@
+#!/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, '../..');
+const FILMS = path.join(ROOT, 'data/films.json');
+const LEDGER = path.join(HERE, 'ledger.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 post (dry or live)
+  for (const e of ledger) {
+    if (e.slug) 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);
+}
+
+const verticalUrl = (film) => `${cfg.siteBase}${film.src.replace('/video/', '/video/vertical/')}`;
+
+async function reachable(url) {
+  try {
+    const res = await fetch(url, { method: 'HEAD' });
+    return res.ok && (res.headers.get('content-type') || '').includes('video');
+  } catch {
+    return false;
+  }
+}
+
+// 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() {
+  const films = JSON.parse(fs.readFileSync(FILMS, 'utf8'));
+  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();
diff --git a/social/poster/run.sh b/social/poster/run.sh
new file mode 100755
index 0000000..ab40c3d
--- /dev/null
+++ b/social/poster/run.sh
@@ -0,0 +1,9 @@
+#!/bin/zsh
+# run.sh — launchd/cron wrapper for the daily BHV Instagram post.
+# Token + user-id are read from the secrets master .env by env.mjs.
+# LIVE posting requires BHV_IG_LIVE=1 (set by the plist once Steve approves go-live).
+cd "$(dirname "$0")/../.." || exit 1
+LOG="social/poster/post.log"
+echo "----- $(date '+%Y-%m-%d %H:%M:%S %Z') -----" >> "$LOG"
+/opt/homebrew/bin/node social/poster/post-daily.mjs >> "$LOG" 2>&1
+echo "exit: $?" >> "$LOG"

← eb1f4f4 chore: lint, refactor, v1.2.0 (session close)  ·  back to Beverlyhillsvideos  ·  stores: Rodeo Drive store-film pipeline (typed stream, resta fbefcdb →