← 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
A social/poster/README.mdA social/poster/caption.mjsA social/poster/com.steve.bhv-ig-daily.plistA social/poster/env.mjsA social/poster/ig-graph.mjsA social/poster/post-daily.mjsA social/poster/run.sh
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 →