Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,23 @@ X_SESSION_COOLDOWN_SECONDS=900
IG_COOKIE=

# -------------------------------------------------------------------- Facebook
# Facebook publishes no public feed, serves no page without a login, and has no
# bridge. The only route in is Meta's Graph API, which returns a Page's posts
# only to somebody who ADMINISTERS that Page - so /fb/ takes no open
# submissions, and a Page appears only once its operator connects it here.
# Facebook publishes no feed and shows nothing without a login, so a Page is
# read with a logged-in session off mbasic.facebook.com - the same bargain as
# the X and Instagram cookies above, not a different one.
#
# FB_PAGE_TOKENS=[{"page":"MyPage","token":"EAA..."}]
# A normal browser session, copied from devtools. c_user and xs are the two that
# matter; extra cookies are harmless.
#
# FB_COOKIE='c_user=...; xs=...'
#
# A Page Access Token can post as the Page. Treat it exactly like the X session
# cookies above: vault, not a service env, and never a database column.
# This is a login to somebody's Facebook account. Vault, not a service env, and
# never a database column. Use a dedicated account: expect it to be challenged,
# and expect /fb/ to be the least dependable namespace on the site.
FB_COOKIE=

# Optional, and almost never applicable. Meta's Graph API returns a Page's posts
# only to somebody who ADMINISTERS that Page; where a token exists it is used in
# preference to scraping, because a supported API beats guessing at markup.
#
# FB_PAGE_TOKENS=[{"page":"MyPage","token":"EAA..."}]
FB_PAGE_TOKENS=
55 changes: 37 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,35 +101,54 @@ to tell them apart.
of a row and links already point at it. The `/r/` and `/x/` address is the
canonical one, which is what search engines are told.

## Facebook, and what `/fb/` can honestly be
## Facebook, and what `/fb/` is honestly worth

**There is no way to read an arbitrary public Facebook Page.** Three doors, all
measured on 2026-08-29 rather than assumed:
Facebook publishes nothing readable without a login. Three doors, measured on
2026-08-29 rather than assumed:

- the old `facebook.com/feeds/page.php?format=rss20` endpoint answers **404** —
removed, not deprecated
- `mbasic.facebook.com/<page>` answers 200 with a **login wall**
- `mbasic.facebook.com/<page>` answers 200 and **redirects to `login.php`**
- RSSHub, which carries a thousand namespaces and maintains Twitter and
Instagram, has **no Facebook namespace at all**

The one remaining door is Meta's Graph API, and it only opens for Pages the
caller **administers**. Reading somebody else's public Page needs the
`Page Public Content Access` feature, which requires App Review plus business
verification and is granted rarely.

So `/fb/` is the one namespace here that does not take open submissions: a Page
appears when its operator connects it, by putting a Page Access Token in
`FB_PAGE_TOKENS`. A Page nobody has connected is not "not crawled yet", it is
not collectable, and the page says exactly that rather than offering a button
that would quietly do nothing.
So a Page is read the way X and Instagram are read: with a logged-in session,
off the HTML that `mbasic.facebook.com` still renders server-side.

```bash
FB_PAGE_TOKENS='[{"page":"MyPage","token":"EAA..."}]'
FB_COOKIE='c_user=...; xs=...' # a normal browser session, not an app
FB_PAGE_TOKENS= # optional; see below
```

What is deliberately absent: anything that drives a logged-in Facebook session
against that login wall. It breaks constantly, it is against Meta's terms, and
it would risk an account of ours to serve a directory nobody pays for.
Where a Page Access Token happens to exist for a Page, Meta's Graph API is used
instead — a supported API beats guessing at markup. It is never required and
almost never present, because Graph only returns a Page's posts to somebody who
**administers** that Page.

**What that third bullet is telling you.** RSSHub maintains Twitter and
Instagram and not this, and that is a verdict rather than an oversight: Facebook
is the most hostile of the three to being read this way. Expect the markup to
change, expect the reading account to be challenged, and expect `/fb/` to be the
least dependable namespace on the site.

Three things follow from that, and they are in the code rather than in this
paragraph:

- **Every selector is in one place.** `SELECTORS` in
`packages/social/src/facebook/scrape.js`. When it breaks, fetch a page with a
live cookie, look at the HTML, and edit that object — nothing else.
- **A checkpoint retires the session, never the Page.** A login wall is
classified as an auth failure, which reschedules and leaves every health
column alone. Ten of those in a row would otherwise mark every Facebook Page
in the directory dead, since a feed is retired after ten failures.
- **It is polled hourly, not every five minutes.** Each platform has its own
floor (`FLOOR_MINUTES`): X 5, Instagram 30, Facebook 60. The cost of asking
too often here is not a 429, it is a lock on the account and every Facebook
source going dark at once. A Page that posts twice a week loses nothing to an
hourly floor.

A personal profile cannot be read by any of this and is rejected rather than
half-supported.

## Collecting Instagram

Expand Down
46 changes: 9 additions & 37 deletions apps/web/src/app/AddSocialSource.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import { siteUrl } from '../lib/db.js';
* its next tick, and this page is replaced by the real one within the minute
* (§17, §37).
*
* **Facebook is the exception, and says so.** There is no public feed, no
* unauthenticated HTML and no provider; the only way in is a Page Access Token
* from whoever administers the Page. Offering an "add" button there would be a
* button that quietly does nothing, so it gets an explanation instead.
* Facebook was the exception here for one revision, on the grounds that only a
* Page's own administrator could connect it. That is no longer true: it is read
* with a session like X and Instagram are, so it takes open submissions like
* they do — see facebook/scrape.js for what that costs in reliability.
*
* @param {{ network: 'x'|'reddit'|'instagram'|'facebook', label: string, input: string, canonical: string }} props
* `input` is what gets submitted — the canonical upstream URL, not what was
Expand All @@ -38,39 +38,6 @@ export default function AddSocialSource({ network, label, input, canonical }) {
</>
);

if (network === 'facebook') {
return (
<main className="prose">
<h1>{label}</h1>

<p>
This Facebook Page is not connected, and unlike the rest of the directory it cannot be
added by anyone who happens to want it.
</p>

<p>
Facebook publishes no feed for a Page, serves no page without a login, and has no
third-party bridge we can use. The only remaining route is Meta&rsquo;s own Graph API,
and it will only return a Page&rsquo;s posts to somebody who <strong>administers that
Page</strong> — reading a stranger&rsquo;s public Page needs a permission Meta grants
rarely and only after review.
</p>

<p>
So if this is your Page, it can be connected: an administrator supplies a Page Access
Token and it appears here at {address}, collected on the same schedule as everything
else. If it is not your Page, there is nothing we can honestly offer — and we would
rather say that than mirror a scraper that breaks every few weeks.
</p>

<p>
<a href="/fb">What is connected</a> · <a href="/x">X</a> · <a href="/ig">Instagram</a> ·{' '}
<a href="/r">Reddit</a>
</p>
</main>
);
}

return (
<main className="prose">
<h1>{label}</h1>
Expand Down Expand Up @@ -114,4 +81,9 @@ const PLATFORMS = {
index: '/ig',
note: 'Instagram publishes no feeds, so this is collected on your behalf and mirrored here. Private accounts are not collected, and stories are not either — they expire, and a feed of things that have already gone is worse than no feed.',
},
facebook: {
name: 'Facebook',
index: '/fb',
note: 'Facebook publishes no feeds and shows nothing without a login, so this is read on your behalf and mirrored here. It is the least reliable of the four by some distance — Facebook changes its markup often and without warning — and it is checked hourly rather than every few minutes, because asking faster is how the reading account gets locked.',
},
};
23 changes: 9 additions & 14 deletions apps/web/src/app/SocialIndex.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ const LOOKS = {
},
facebook: {
platform: 'Facebook',
noun: 'connected Pages',
noun: 'Pages',
base: '/fb',
placeholder: '',
addLabel: '',
placeholder: 'fb/SomePage',
addLabel: 'Add a Facebook Page',
blurb:
'Facebook is the one platform here that cannot be added by whoever wants it. There is no public feed, no page without a login, and no bridge — only Meta’s Graph API, which returns a Page’s posts to somebody who administers that Page. So these are Pages whose operators connected them, and nothing else can be.',
'Facebook publishes no feeds and shows nothing without a login, so these are read on your behalf and mirrored here. Expect them to be the least dependable part of this directory: Facebook changes its markup without warning and works harder than the others to stop anyone reading it this way, so a Page here goes quiet from time to time. Pages only — a personal profile cannot be read at all.',
},
};

Expand Down Expand Up @@ -95,16 +95,11 @@ export default async function SocialIndex({ network, page = 1 }) {

<p>{blurb}</p>

{/* Facebook has no add form, and that is not an oversight: a Page can
only be connected by somebody who administers it, so a box inviting
anyone to paste a Page would be a box that quietly does nothing. */}
{network === 'facebook' ? null : (
<form method="post" action="/api/submit" className="add-source">
<label htmlFor="social-input">{addLabel}</label>
<input id="social-input" name="input" type="text" placeholder={placeholder} required />
<button type="submit">Add</button>
</form>
)}
<form method="post" action="/api/submit" className="add-source">
<label htmlFor="social-input">{addLabel}</label>
<input id="social-input" name="input" type="text" placeholder={placeholder} required />
<button type="submit">Add</button>
</form>

{rows.length >= FILTER_FROM ? (
<ListFilter
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/app/api/fb/[page]/feed/[format]/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ export const dynamic = 'force-dynamic';
/**
* One Facebook Page, as a feed of ours. `/fb/SomePage.rss` rewrites here.
*
* A Page that nobody has connected a token for will 404 here rather than
* serving an empty feed, and that is the honest answer: it is not "not crawled
* yet", it is not collectable at all. See @rssamplifier/social's
* facebook/canonical.js for why Facebook cannot work the way the other three do.
* A Page not in the directory 404s here rather than serving an empty feed, the
* same as the other three namespaces. Facebook is read with a session against
* mbasic; see @rssamplifier/social's facebook/scrape.js for how fragile that is
* and where to fix it when it breaks.
*
* @param {Request} req
* @param {{ params: Promise<{ page: string, format: string }> }} ctx
Expand All @@ -24,7 +24,7 @@ export async function GET(req, { params }) {
format,
400,
`not a Facebook Page name: ${page}`,
'Page names are 5-60 characters of A-Z, 0-9 and dot.',
'Page names are 3-60 characters of A-Z, 0-9 and dot.',
);
}

Expand Down
9 changes: 4 additions & 5 deletions apps/web/src/app/fb/[page]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@ export const dynamic = 'force-dynamic';
/**
* One Facebook Page, at `/fb/SomePage`.
*
* The one namespace of the four where "not here yet" usually means "not
* possible" rather than "nobody has got round to it". Facebook has no public
* feed, no unauthenticated HTML and no provider — the only way in is a Page
* Access Token from whoever administers the Page — so the empty state explains
* that rather than offering a button that would quietly do nothing.
* Read with a session against mbasic, like X and Instagram are — so this takes
* open submissions like they do. It is the least reliable of the four by some
* distance; see @rssamplifier/social's facebook/scrape.js for why, and for the
* one place to look when it stops working.
*
* @param {{ params: Promise<{ page: string }> }} props
*/
Expand Down
11 changes: 9 additions & 2 deletions packages/db/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { newId, nowIso } from './client.js';
* @typedef {import('@libsql/client').Client} Client
*/

/** The interval a source starts on when its caller names none. */
const DEFAULT_START_MINUTES = 60;

/**
* The one source behind a canonical ref.
*
Expand Down Expand Up @@ -110,7 +113,11 @@ export async function countSocialFeeds(db, network) {
* @param {{
* network: string, ref: string, slug: string, title: string, feedUrl: string,
* siteUrl?: string|null, config?: object|null, priority?: number,
* }} source
* intervalMinutes?: number,
* }} source `intervalMinutes` is the platform's floor, supplied by the caller —
* this package deliberately does not import @rssamplifier/social to look it
* up, because the dependency would run the wrong way: social already reads
* nothing from db, and db has no other reason to know what a platform is.
* @returns {Promise<{ id: string, slug: string, created: boolean }>}
*/
export async function upsertSocialSource(db, source) {
Expand Down Expand Up @@ -147,7 +154,7 @@ export async function upsertSocialSource(db, source) {
// Reddit is excluded deliberately — it is a real feed on somebody else's
// server, and 50,026 of them at five minutes is how you get rate-limited
// off a platform. See markHostThrottled in queries.js.
source.network === 'reddit' ? 60 : 5,
Math.max(1, Number(source.intervalMinutes) || DEFAULT_START_MINUTES),
now,
now,
now,
Expand Down
14 changes: 8 additions & 6 deletions packages/ingest/src/crawl.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { resolveFeed, scrapeFeed, feedTopics } from '@rssamplifier/feed';
import { q, authors } from '@rssamplifier/db';
import { fetchSocialSource, isCollected } from '@rssamplifier/social';
import { fetchSocialSource, floorMinutesFor, isCollected } from '@rssamplifier/social';

import { prepareCredits } from './enrich.js';
import {
Expand Down Expand Up @@ -226,11 +226,13 @@ export async function crawlFeed(db, feed, opts = {}) {
// distinction, so adding a platform never edits this file.
const social = isCollected(feed);

// A provider-backed source polls on a five-minute floor rather than an hour's
// — see SOCIAL_MIN_INTERVAL. The floor is passed to every scheduling call
// below rather than read from a global, so this row's cadence is decided here
// and nowhere else.
const floor = social ? SOCIAL_MIN_INTERVAL : FLOOR_DEFAULT;
// How fast this row may be asked, which is a property of its platform rather
// than of this codebase — X through RSSHub tolerates five minutes, a personal
// Instagram or Facebook session does not, and asking too often there costs a
// checkpoint on the account rather than a 429. @rssamplifier/social owns the
// table; the floor is passed to every scheduling call below so this row's
// cadence is decided here and nowhere else.
const floor = social ? floorMinutesFor(feed) : FLOOR_DEFAULT;

// What the server told us last time, sent back so it can answer "still the
// same" without sending the document again. Scraped sources are excluded: what
Expand Down
6 changes: 5 additions & 1 deletion packages/ingest/src/submit.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { resolveFeed, scrapeFeed, normalizeUrl, parseOpml, uniqueSlug } from '@rssamplifier/feed';
import { q, social } from '@rssamplifier/db';
import { socialSourceFrom } from '@rssamplifier/social';
import { floorMinutesFor, socialSourceFrom } from '@rssamplifier/social';

import { queueFeeds } from './queue.js';
import { refreshFeedKeywords } from './crawl.js';
Expand Down Expand Up @@ -208,6 +208,10 @@ async function submitSocial(db, source) {
feedUrl: source.feedUrl,
siteUrl: source.siteUrl,
priority: 1,
// The platform's floor, so a new row starts at the fastest it will ever be
// asked and the crawler backs it off from there. A Facebook Page read with
// a personal session starts hourly; an X timeline starts at five minutes.
intervalMinutes: floorMinutesFor({ social_network: source.network }),
});

if (!stored.id) {
Expand Down
9 changes: 8 additions & 1 deletion packages/social/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,14 @@ export {
export { fetchFacebookSource, pageToken, connectedPages } from './src/facebook/fetch.js';

export { failureResult, retryAfterFor, ANOMALY_SECONDS, UNCONFIGURED_SECONDS } from './src/failure.js';
export { fetchSocialSource, isCollected } from './src/collect.js';
export {
fetchSocialSource,
isCollected,
floorMinutesFor,
FLOOR_MINUTES,
DEFAULT_FLOOR_MINUTES,
} from './src/collect.js';
export { scrapeFacebookPage } from './src/facebook/scrape.js';

export { socialSourceFrom, socialPathFor, SOCIAL_NETWORKS } from './src/identify.js';
export { socialDisplayTitle } from './src/display.js';
3 changes: 2 additions & 1 deletion packages/social/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test": "node --test test/*.test.js"
},
"dependencies": {
"@rssamplifier/feed": "workspace:*"
"@rssamplifier/feed": "workspace:*",
"linkedom": "^0.18.13"
}
}
34 changes: 34 additions & 0 deletions packages/social/src/collect.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,40 @@ const COLLECTORS = {
facebook: fetchFacebookSource,
};

/**
* The fastest each platform may be asked, in minutes.
*
* Not one number, because the three are not one risk. X goes through RSSHub,
* which is built to be polled and is reading a medium where an hour old is
* visibly stale — five minutes is the point of the exercise. Instagram and
* Facebook are read with a personal session against a platform that watches for
* exactly that, and the cost of asking too often is not a 429, it is a
* checkpoint on the account and every source on that platform going dark at
* once.
*
* So the slow ones are slow on purpose. A Facebook Page that posts twice a week
* loses nothing to an hourly floor, and the account survives to keep reading it.
*
* These are floors, not intervals: the crawler's cadence code still backs a
* quiet source further off on its own. Nothing here makes a source faster.
*/
export const FLOOR_MINUTES = {
x: 5,
instagram: 30,
facebook: 60,
};

/** The default for anything fetched rather than collected. */
export const DEFAULT_FLOOR_MINUTES = 60;

/**
* @param {{ social_network?: string|null }} feed
* @returns {number} minutes
*/
export function floorMinutesFor(feed) {
return FLOOR_MINUTES[String(feed?.social_network ?? '')] ?? DEFAULT_FLOOR_MINUTES;
}

/**
* Is this row collected through a provider rather than fetched from a document?
*
Expand Down
Loading
Loading