Automatic posting of two Superior Whiskey pages a day to the Facebook Page (facebook.com/SuperiorWhiskey). Scope: publishing these posts, nothing else — no advertising, no messaging, no replying to comments, no Page settings.
Modelled on PartingPoint's lib/facebookSpotlights.ts (branch
feat/facebook-spotlights), which has been delivering since 2026-09-12. Same
doctrine, adapted to a static site.
The website is Eleventy on Cloudflare Pages, and Pages has no scheduled handler — Cloudflare staff, on the Pages-cron question: "Unfortunately not, no. You will need to use Workers for that today." So delivery lives in its own Worker with its own Cron Triggers and its own D1 database. The site's build and deploy pipeline is untouched; the Worker only reads what the site publishes.
That separation also gives a better liveness check than the reference got: the Worker is on a different hostname from the site, so it can fetch the page itself before posting instead of delegating that to CI.
| Slot | Chicago window | Pool |
|---|---|---|
| AM | 09:00–11:00 | scored bottle reviews (63) |
| PM | 17:00–19:00 | distillery, type and country hubs, journal pieces, bottle guides, and /about/ /how-we-score/ /reviews/ /for-distilleries/ (64) |
Contains affiliate links; commissions never influence a score.) is added only for pages that actually carry
rel="sponsored" links — all 65 review and guide pages; no hub or journal
piece.description and the next run
picks it up.Two posts a day for 90 days needs 180 distinct pages. The site has 127
postable ones. The cooldown is therefore 60 days, decided with Art on
2026-09-13. As the review count grows the cooldown can rise: it is
COOLDOWN_DAYS in src/catalog.js, and the safe ceiling is the smaller slot
pool (currently 63). Raise it past that and the feed goes quiet.
Cron Trigger every 30 min
↓ reconcile any `unknown` rows against the Page feed first
↓ paused? connected? → stop
↓ a due `pending` post (retry) → deliver that, nothing new
↓ inside an AM/PM window? → pick that slot's next page
↓ slot free today? 6h since the last post? off cooldown? copy legal?
↓ GET the page and require its own <link rel="canonical"> → confirmed live
↓ INSERT the row (UNIQUE per slot per day, and per page per day)
↓ conditional UPDATE … state='pending' — `changes` is the lock
↓ POST graph.facebook.com/v25.0/{page-id}/feed
A 200 from the site alone proves nothing — Cloudflare Pages answers unknown
paths with the HTML fallback, so the page must declare the exact canonical
URL being posted. This was found the hard way on this site: a deleted image
path still returned 200 text/html.
| state | meaning | next |
|---|---|---|
| pending | queued, or waiting for next_attempt_at |
delivered by the next run (max 5 attempts) |
| delivering | a run holds the row | becomes unknown after 10 minutes if the run died |
| published | Facebook returned a post id | post_id, permalink, published_at recorded |
| unknown | ambiguous answer (timeout, 5xx, codes 1/2) | Page feed read back and matched on exact message; found → published; absent after 15 min → pending |
| failed | Facebook refused, or attempts exhausted | operator alert; Retry on the operator page |
| canceled | manual | Retry re-queues it |
Rate-limit codes (4, 17, 32, 613, 80001) retry in an hour. Token codes
(190, 102) flip the channel to needs_reconnect, alert once, and hold the
post as pending until the Page is reconnected.
Deliberate difference from PartingPoint: a refused post holds its slot for the day. PartingPoint moves on to the next item; here a second attempt with a different page risks two posts in one slot when the refusal was ambiguous, and never double-posting outranks always posting twice.
src/index.js Worker entry: scheduled() + routes (operator page, /job, OAuth return)
src/facebook.js the state machine — eligibility, queue, delivery, reconciliation
src/connect.js the OAuth dance and the Page token
src/catalog.js what may be posted; slot pools; cooldown; candidate ordering
src/compose.js the copy rules
src/crypto.js AES-GCM token sealing, signed OAuth state and session (WebCrypto only)
src/time.js Chicago calendar, the two windows, the six-hour gap
src/ui.js the operator page
test/ 34 tests: node --test "test/*.test.mjs"
The catalog itself is built by the website: eleventyConfig.addCollection("socialItems", …)
in ../eleventy.config.js, emitted as /social-items.json by ../social-items.njk.
A page is postable exactly when this deploy shipped it.
The Worker's own URL. Sign in with OPERATOR_PASSPHRASE. Shows the channel
status, what each slot would post next, anything held for review, and every
post with its exact text, state and permalink. Buttons: Connect / Reconnect
Page, Pause / Resume, Check token, Run now, Retry, Remove token.
There is no Node on the owner's Mac, so this deploys from the Cloudflare
dashboard via Workers Builds — Cloudflare clones the repo and runs the build
itself. Every push to main redeploys the Worker, the same shape as the
website's own pipeline.
superiorwhiskey-social. Copy its
database_id (the UUID in the database's URL) into wrangler.toml and
push — a Git-deployed Worker has no dashboard substitute for that value.artiomk/superiorwhiskey-site.
superiorwhiskey-social — Cloudflare
fails the build if it does not match name in wrangler.toml.social-workernpx wrangler deploy
(the default)wrangler is pinned in
social-worker/package.json.[triggers] crons and the D1 binding come from wrangler.toml on deploy —
nothing to re-enter in the dashboard.FACEBOOK_APP_ID, FACEBOOK_APP_SECRET, FACEBOOK_PAGE_ID,
FACEBOOK_LOGIN_CONFIG_ID (optional), SOCIAL_JOB_SECRET (>= 32 random
characters), OPERATOR_PASSPHRASE, ALERT_WEBHOOK_URL (optional).
Runtime secrets, not build variables — build variables are not visible at
run time.The OAuth redirect URI is derived from the hostname the operator page is
served on, so WORKER_ORIGIN only needs setting if the Worker is ever put
behind a custom domain.
Meta side: the app must be Live (which requires a public privacy-policy
URL) or posts are visible only to app-role users. pages_manage_posts and
pages_read_engagement must be added to the use case before the Login for
Business configuration can list them. Set the Page's minimum age to 21+ (US)
— Meta's alcohol policy.
Then open the operator page → Connect Facebook Page → approve the dialog with an account that can create content on the Page.
curl -H "Authorization: Bearer $SOCIAL_JOB_SECRET" <worker>/jobnpx wrangler tail does the same.SELECT id,post_date,slot,state,attempts,title,permalink FROM social_post ORDER BY id DESC LIMIT 20;/me/accounts
even after the dialog grants it. The connector also reads the pinned Page
directly by id, so keep FACEBOOK_PAGE_ID set.tasks field on a Page node — it is an edge field of
/me/accounts and asking for it fails the whole read.