From 3696662b58e1574ea5e93b757450dbdf85a88ec1 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Sat, 29 Aug 2026 13:41:16 +0000
Subject: [PATCH] feat(social): Instagram at /ig/, Facebook at /fb/, and one
failure rule
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two more namespaces, and they cost very different amounts — which is the
useful part of this change rather than an aside.
Instagram is X's shape: no feeds, collected through the RSSHub daemon
already running in the poller, mirrored here. It was a canonical.js, a
fetch.js and a route, because the namespace machinery already existed.
Facebook is not. Three doors, measured rather than assumed:
- facebook.com/feeds/page.php?format=rss20 answers 404 (removed)
- mbasic.facebook.com/ answers 200 with a login wall
- RSSHub carries a thousand namespaces and no Facebook one
The only remaining route is Meta's Graph API, which returns a Page's
posts only to somebody who ADMINISTERS that Page — reading a stranger's
public Page needs a permission Meta grants rarely and only after review.
So /fb/ takes no open submissions: a Page appears once its operator
connects it via FB_PAGE_TOKENS, and an unconnected Page is told plainly
that it is not collectable rather than shown a button that does nothing.
Nothing here drives a logged-in session against that login wall.
The rule from #157 is now shared rather than per-platform. src/failure.js
owns "only a source that does not exist may count against the source",
and X was refactored onto it rather than Instagram and Facebook being
written against a copy. Its 60 tests passed unchanged, which is the
evidence the refactor was behaviour-identical.
Two bugs the tests caught, both mine:
- identify.js said in a comment that X is tried before Instagram and
had Instagram first, so a bare @handle resolved to Instagram
- the Facebook collector took its display name from the ref, which is
lowercased because it is an identity — so every item title read
"somepage:" instead of "SomePage:". Display now comes from feed_url,
which kept the original casing; identity still comes from the ref
Also: an uncrawled social row now renders its canonical name instead of
whatever the OPML import called it. 50,026 subreddits are imported and a
few hundred crawled, so /r/programming was headed "reddit.com". A render
decision, not a rewrite of stored titles — the crawler owns those.
1,345 tests across 12 packages, all green.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Q6QEgpuS4MLamogXtr2ZX6
---
.env.example | 18 +
README.md | 57 ++-
apps/web/next.config.mjs | 12 +
apps/web/src/app/AddSocialSource.jsx | 115 ++++--
apps/web/src/app/SocialIndex.jsx | 95 +++--
.../app/api/fb/[page]/feed/[format]/route.js | 39 ++
.../api/ig/[username]/feed/[format]/route.js | 34 ++
.../api/ig/tag/[tag]/feed/[format]/route.js | 34 ++
apps/web/src/app/fb/[page]/page.jsx | 56 +++
apps/web/src/app/fb/page.jsx | 27 ++
apps/web/src/app/ig/[username]/page.jsx | 55 +++
apps/web/src/app/ig/page.jsx | 27 ++
apps/web/src/app/ig/tag/[tag]/page.jsx | 50 +++
apps/web/src/app/layout.jsx | 3 +-
apps/web/src/lib/sitemap.js | 4 +
apps/web/src/lib/socialPage.js | 11 +-
apps/web/src/lib/socialRiver.js | 45 ++-
packages/db/src/social.js | 16 +-
packages/feed/src/slug.js | 6 +
packages/ingest/src/crawl.js | 20 +-
packages/ingest/test/social-crawl.test.js | 4 +-
packages/social/index.js | 29 ++
packages/social/src/collect.js | 54 +++
packages/social/src/display.js | 68 ++++
packages/social/src/facebook/canonical.js | 201 ++++++++++
packages/social/src/facebook/fetch.js | 263 +++++++++++++
packages/social/src/failure.js | 69 ++++
packages/social/src/identify.js | 119 +++---
packages/social/src/instagram/canonical.js | 205 ++++++++++
packages/social/src/instagram/fetch.js | 125 ++++++
packages/social/src/x/fetch.js | 75 +---
packages/social/test/platforms.test.js | 369 ++++++++++++++++++
32 files changed, 2108 insertions(+), 197 deletions(-)
create mode 100644 apps/web/src/app/api/fb/[page]/feed/[format]/route.js
create mode 100644 apps/web/src/app/api/ig/[username]/feed/[format]/route.js
create mode 100644 apps/web/src/app/api/ig/tag/[tag]/feed/[format]/route.js
create mode 100644 apps/web/src/app/fb/[page]/page.jsx
create mode 100644 apps/web/src/app/fb/page.jsx
create mode 100644 apps/web/src/app/ig/[username]/page.jsx
create mode 100644 apps/web/src/app/ig/page.jsx
create mode 100644 apps/web/src/app/ig/tag/[tag]/page.jsx
create mode 100644 packages/social/src/collect.js
create mode 100644 packages/social/src/display.js
create mode 100644 packages/social/src/facebook/canonical.js
create mode 100644 packages/social/src/facebook/fetch.js
create mode 100644 packages/social/src/failure.js
create mode 100644 packages/social/src/instagram/canonical.js
create mode 100644 packages/social/src/instagram/fetch.js
create mode 100644 packages/social/test/platforms.test.js
diff --git a/.env.example b/.env.example
index a72eade..21491ea 100644
--- a/.env.example
+++ b/.env.example
@@ -87,3 +87,21 @@ X_SESSIONS=
X_FETCH_TIMEOUT_MS=15000
X_SESSION_COOLDOWN_SECONDS=900
+
+# ------------------------------------------------------------------- Instagram
+# Collected through the same embedded RSSHub daemon as X, so it needs no base
+# URL of its own — only a logged-in Instagram cookie on the RSSHub side.
+# Accounts and hashtags only; stories expire and are not collected.
+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.
+#
+# FB_PAGE_TOKENS=[{"page":"MyPage","token":"EAA..."}]
+#
+# 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.
+FB_PAGE_TOKENS=
diff --git a/README.md b/README.md
index 783c24f..a342f2e 100644
--- a/README.md
+++ b/README.md
@@ -65,8 +65,12 @@ pnpm --filter @rssamplifier/db migrate
| `/x/list/` | One X list |
| `/x/search?q=` | An X search as a feed — X's own operators pass through |
| `/x/status` | Which provider is collecting X, and how it is doing |
+| `/ig` | Every Instagram account and hashtag in the directory |
+| `/ig/` | One account: `/ig/nasa`, and `/ig/tag/` for a hashtag |
+| `/fb` | Facebook Pages whose operators have connected them |
+| `/fb/` | One connected Page |
-### The two social namespaces
+### The four social namespaces
Reddit and X both live under a prefix of their own, and for the same reason from
opposite directions. Reddit publishes real RSS, so a subreddit resolves down the
@@ -74,8 +78,12 @@ ordinary path and lands as an untyped row at a slug of its own — which is how
50,099 of them ended up filed among the blogs. X publishes nothing at all, so
without a provider it is not submittable in the first place.
-`packages/social` answers one question for both: **what is the canonical identity
-of this thing?** `@OpenAI`, `x.com/OpenAI` and `https://twitter.com/openai/` are
+Instagram is X's shape again — no feeds, collected through RSSHub — and cost
+almost nothing to add, which is the point of having built the namespace once.
+Facebook is its own thing entirely; see below.
+
+`packages/social` answers one question for all four: **what is the canonical
+identity of this thing?** `@OpenAI`, `x.com/OpenAI` and `https://twitter.com/openai/` are
one source (`x:user:openai`, at `/x/OpenAI`); `/r/programming`, `/r/Programming/`
and `/r/programming/new/.rss` are one community (`r:sub:programming`, at
`/r/programming`). One identity means one row, which means **one polling job no
@@ -93,6 +101,49 @@ 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
+
+**There is no way to read an arbitrary public Facebook Page.** Three doors, all
+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/` answers 200 with a **login wall**
+- 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.
+
+```bash
+FB_PAGE_TOKENS='[{"page":"MyPage","token":"EAA..."}]'
+```
+
+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.
+
+## Collecting Instagram
+
+Same shape as X, through the same RSSHub daemon — the `/instagram/2/…` web-api
+routes, which authenticate with a cookie rather than the private-api routes,
+which want a username and password.
+
+```bash
+IG_COOKIE= # on the RSSHub side; see apps/poller/src/rsshub.js
+```
+
+Accounts and hashtags only. Stories expire, and a feed of things that have
+already gone is worse than no feed.
+
## Collecting X
X has no feeds, so posts are collected through a provider and mirrored here.
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
index a7f31c7..cc0276f 100644
--- a/apps/web/next.config.mjs
+++ b/apps/web/next.config.mjs
@@ -242,6 +242,18 @@ const nextConfig = {
source: '/r/:subreddit.:format(rss|atom|json|xml|md)',
destination: '/api/r/:subreddit/feed/:format',
},
+ {
+ source: '/ig/tag/:tag.:format(rss|atom|json|xml|md)',
+ destination: '/api/ig/tag/:tag/feed/:format',
+ },
+ {
+ source: '/ig/:username.:format(rss|atom|json|xml|md)',
+ destination: '/api/ig/:username/feed/:format',
+ },
+ {
+ source: '/fb/:page.:format(rss|atom|json|xml|md)',
+ destination: '/api/fb/:page/feed/:format',
+ },
// One category of it. The segments are the category pages' own paths,
// duplicated from CATEGORIES in apps/web/src/lib/categories.js — this
diff --git a/apps/web/src/app/AddSocialSource.jsx b/apps/web/src/app/AddSocialSource.jsx
index e634e12..7a31016 100644
--- a/apps/web/src/app/AddSocialSource.jsx
+++ b/apps/web/src/app/AddSocialSource.jsx
@@ -1,33 +1,82 @@
import { siteUrl } from '../lib/db.js';
/**
- * What `/r/somewhere` or `/x/somebody` shows when nobody has added it yet.
+ * What `/r/somewhere`, `/x/somebody`, `/ig/somebody` or `/fb/SomePage` shows
+ * when it is not in the directory yet.
*
- * A 404 would be the easy answer and the wrong one. The address is well formed,
- * the thing at the other end almost certainly exists, and the visitor has
- * already told us exactly what they want by typing it — so the page offers to
- * add it rather than telling them they were wrong to ask.
+ * A 404 would be the easy answer and the wrong one for three of the four. The
+ * address is well formed, the thing at the other end almost certainly exists,
+ * and the visitor has already said exactly what they want by typing it — so the
+ * page offers to add it rather than telling them they were wrong to ask.
*
* A plain `
-
- Once it is here, it will be at{' '}
-
- {siteUrl()}
- {canonical}
- {' '}
- in every format this site publishes:{' '}
- .rss, .atom, .json and .md. That
- address does not change, whatever we have to do behind it to keep collecting.
-
+
Once it is here, it will be at {address}. That address does not change, whatever we have
+ to do behind it to keep collecting.
- {network === 'x' ? (
-
- X publishes no feeds of its own, so this is collected on your behalf and mirrored here.
- Protected accounts are not collected, and posts arrive as fast as we can read them
- rather than in real time.
-
- ) : (
-
- Reddit publishes its own feed for this, and we read it on a schedule and keep a copy —
- so the address above works whether or not Reddit is answering right now.
-
);
}
+
+/** What to call each platform, and the one thing worth saying about it. */
+const PLATFORMS = {
+ x: {
+ name: 'X',
+ index: '/x',
+ note: 'X publishes no feeds of its own, so this is collected on your behalf and mirrored here. Protected accounts are not collected, and posts arrive as fast as we can read them rather than in real time.',
+ },
+ reddit: {
+ name: 'Reddit',
+ index: '/r',
+ note: 'Reddit publishes its own feed for this, and we read it on a schedule and keep a copy — so the address above works whether or not Reddit is answering right now.',
+ },
+ instagram: {
+ name: 'Instagram',
+ 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.',
+ },
+};
diff --git a/apps/web/src/app/SocialIndex.jsx b/apps/web/src/app/SocialIndex.jsx
index bed0d1f..721ce87 100644
--- a/apps/web/src/app/SocialIndex.jsx
+++ b/apps/web/src/app/SocialIndex.jsx
@@ -1,5 +1,5 @@
import { social } from '@rssamplifier/db';
-import { socialPathFor } from '@rssamplifier/social';
+import { socialDisplayTitle, socialPathFor } from '@rssamplifier/social';
import { db, siteUrl } from '../lib/db.js';
import ListFilter from './ListFilter.jsx';
@@ -24,6 +24,51 @@ import { FILTER_FROM } from '../lib/listFilter.js';
/** How many sources a page of this listing holds. */
const PER_PAGE = 100;
+/**
+ * What each namespace calls itself, and the one paragraph it owes a reader.
+ *
+ * A table rather than a chain of ternaries, which is what this was when there
+ * were two platforms and what stopped scaling at three.
+ */
+const LOOKS = {
+ reddit: {
+ platform: 'Reddit',
+ noun: 'communities and users',
+ base: '/r',
+ placeholder: 'r/programming',
+ addLabel: 'Add a subreddit or Reddit user',
+ blurb:
+ 'Reddit publishes a feed for every community. We read them on a schedule and keep a copy, so these addresses work whether or not Reddit is answering right now.',
+ },
+ x: {
+ platform: 'X',
+ noun: 'accounts, searches and lists',
+ base: '/x',
+ placeholder: '@OpenAI',
+ addLabel: 'Add an X account, list or search',
+ blurb:
+ 'X publishes no feeds, so these are collected on your behalf and mirrored here — the posts you read come out of this directory, never out of X.',
+ },
+ instagram: {
+ platform: 'Instagram',
+ noun: 'accounts and hashtags',
+ base: '/ig',
+ placeholder: 'ig/nasa',
+ addLabel: 'Add an Instagram account or hashtag',
+ blurb:
+ 'Instagram publishes no feeds either, so these are collected and mirrored the same way X is. Private accounts are not collected, and neither are stories — they expire, and a feed of things that have already gone is worse than no feed.',
+ },
+ facebook: {
+ platform: 'Facebook',
+ noun: 'connected Pages',
+ base: '/fb',
+ placeholder: '',
+ addLabel: '',
+ 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.',
+ },
+};
+
/**
* @param {{ network: 'x'|'reddit', page?: number }} props
*/
@@ -36,9 +81,7 @@ export default async function SocialIndex({ network, page = 1 }) {
social.countSocialFeeds(client, network),
]);
- const platform = network === 'x' ? 'X' : 'Reddit';
- const noun = network === 'x' ? 'accounts, searches and lists' : 'communities and users';
- const base = network === 'x' ? '/x' : '/r';
+ const { platform, noun, base, blurb, placeholder, addLabel } = LOOKS[network];
return (
@@ -50,38 +93,23 @@ export default async function SocialIndex({ network, page = 1 }) {
page here and a feed in four formats, at an address that does not change.
- {network === 'x' ? (
-
- X publishes no feeds, so these are collected on your behalf and mirrored here — the
- posts you read come out of this directory, never out of X.{' '}
- Turn a search into a feed, or{' '}
- see how collection is going.
-
- ) : (
-
- Reddit publishes a feed for every community. We read them on a schedule and keep a
- copy, so these addresses work whether or not Reddit is answering right now.
-
- )}
+
{blurb}
-
+ {/* 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 : (
+
+ )}
{rows.length >= FILTER_FROM ? (
{rows.map((row) => {
const href = socialPathFor(row);
+ // The canonical name where the imported title says nothing — most
+ // of the catalogue is uncrawled and titled with the bare host.
+ const name = socialDisplayTitle(row, href.replace(/^\//, ''));
return (
Machine-readable: MCP server · CLI ·{' '}
diff --git a/apps/web/src/lib/sitemap.js b/apps/web/src/lib/sitemap.js
index 284cb68..8ae6082 100644
--- a/apps/web/src/lib/sitemap.js
+++ b/apps/web/src/lib/sitemap.js
@@ -48,6 +48,10 @@ export const STATIC_PAGES = [
// addresses to keep, and it does not need the sitemap's help to do it.
{ path: '/r', changefreq: 'daily', priority: '0.8' },
{ path: '/x', changefreq: 'daily', priority: '0.8' },
+ { path: '/ig', changefreq: 'daily', priority: '0.8' },
+ // Weekly rather than daily: a namespace that only ever holds Pages somebody
+ // connected by hand changes on a very different clock from the other three.
+ { path: '/fb', changefreq: 'weekly', priority: '0.6' },
{ path: '/search', changefreq: 'daily', priority: '0.8' },
{ path: '/submit', changefreq: 'weekly', priority: '0.7' },
{ path: '/signup', changefreq: 'monthly', priority: '0.6' },
diff --git a/apps/web/src/lib/socialPage.js b/apps/web/src/lib/socialPage.js
index 8fc788c..23fdc67 100644
--- a/apps/web/src/lib/socialPage.js
+++ b/apps/web/src/lib/socialPage.js
@@ -1,4 +1,5 @@
import { social } from '@rssamplifier/db';
+import { socialDisplayTitle } from '@rssamplifier/social';
import { db, siteUrl } from './db.js';
import { feedAlternates } from './subscribe.js';
@@ -47,17 +48,17 @@ export function socialMetadata({ feed, canonical, label, network }) {
};
}
+ const name = socialDisplayTitle(feed, label);
+
return {
- title: String(feed.title ?? label),
- description: String(
- feed.description ?? `${label}, mirrored by the RSS Amplifier directory.`,
- ),
+ title: name,
+ description: String(feed.description ?? `${name}, mirrored by the RSS Amplifier directory.`),
alternates: {
canonical: url,
// The same four formats the rewrites serve. No playlists: a timeline and
// a subreddit carry no enclosures, so announcing an `.m3u` would be
// advertising an empty file.
- types: feedAlternates(url, String(feed.title ?? label)),
+ types: feedAlternates(url, name),
},
other: { 'x-social-network': network },
};
diff --git a/apps/web/src/lib/socialRiver.js b/apps/web/src/lib/socialRiver.js
index 95b5057..323eaa3 100644
--- a/apps/web/src/lib/socialRiver.js
+++ b/apps/web/src/lib/socialRiver.js
@@ -1,5 +1,11 @@
import { q, social } from '@rssamplifier/db';
-import { redditSource, xSource } from '@rssamplifier/social';
+import {
+ facebookSource,
+ instagramSource,
+ redditSource,
+ socialDisplayTitle,
+ xSource,
+} from '@rssamplifier/social';
import { db, siteUrl } from './db.js';
import {
@@ -87,7 +93,10 @@ export async function socialRiver({
const rows = await q.itemsForFeed(client, String(feed.id), riverLimit(rawLimit));
const channel = {
- title: String(feed.title ?? label ?? ref),
+ // The canonical name when the stored title says nothing — most of the
+ // imported subreddits have not been crawled yet and carry the bare host as
+ // their title. See socialDisplayTitle.
+ title: socialDisplayTitle(feed, label ?? ref),
description: String(
feed.description ?? `${label ?? ref}, mirrored by the RSS Amplifier directory.`,
),
@@ -155,3 +164,35 @@ export function xTarget(params) {
const [canonical, query = null] = source.path.split('?');
return { ref: source.ref, canonical, label: source.title, query };
}
+
+/**
+ * The same for `/ig/…`, across both modes.
+ *
+ * `ig/` rather than a bare handle, because the parser is being asked
+ * "is this Instagram?" and a bare handle is ambiguous with X — see the ordering
+ * note in @rssamplifier/social's identify.js. The route already knows which
+ * platform it is holding, so it says so.
+ *
+ * @param {{ username?: string, tag?: string }} params
+ * @returns {{ ref: string, canonical: string, label: string }|null}
+ */
+export function instagramTarget(params) {
+ const source = params.tag
+ ? instagramSource(`https://www.instagram.com/explore/tags/${params.tag}/`)
+ : instagramSource(`ig/${params.username}`);
+
+ if (!source) return null;
+ return { ref: source.ref, canonical: source.path, label: source.title };
+}
+
+/**
+ * And for `/fb/`.
+ *
+ * @param {{ page?: string }} params
+ * @returns {{ ref: string, canonical: string, label: string }|null}
+ */
+export function facebookTarget(params) {
+ const source = facebookSource(`fb/${params.page}`);
+ if (!source) return null;
+ return { ref: source.ref, canonical: source.path, label: source.title };
+}
diff --git a/packages/db/src/social.js b/packages/db/src/social.js
index 2894662..34bb612 100644
--- a/packages/db/src/social.js
+++ b/packages/db/src/social.js
@@ -137,11 +137,17 @@ export async function upsertSocialSource(db, source) {
source.feedUrl,
source.siteUrl ?? null,
source.title,
- // X sources are polled more often than the 60-minute default, because a
- // timeline is the one thing in this directory where an hour old is
- // visibly stale. §17's "active source: 5 minutes" is the ceiling the
- // crawler's own interval learning then works down from.
- source.network === 'x' ? 5 : 60,
+ // Provider-collected sources start faster than the 60-minute default,
+ // because a timeline is the one thing in this directory where an hour old
+ // is visibly stale. §17's "active source: 5 minutes" is the starting
+ // point, not the resting one: the crawler's interval learning backs a
+ // quiet Page or account off on its own, which is why a rarely-posting
+ // Facebook Page costs nothing to start fast.
+ //
+ // 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,
now,
now,
now,
diff --git a/packages/feed/src/slug.js b/packages/feed/src/slug.js
index 41eedef..3976865 100644
--- a/packages/feed/src/slug.js
+++ b/packages/feed/src/slug.js
@@ -34,6 +34,12 @@ const RESERVED = new Set([
// above are listed for.
'r',
'x',
+ 'ig',
+ 'fb',
+ // Reserved as well as the short forms, so a feed cannot take the name we
+ // would use if either namespace is ever spelled out.
+ 'instagram',
+ 'facebook',
// The people index, for the same reason as the categories above: a feed
// slugged 'authors' would still be served, but only Next's static segment
// would answer and the blog's own page would be unreachable.
diff --git a/packages/ingest/src/crawl.js b/packages/ingest/src/crawl.js
index eee2210..c1f7651 100644
--- a/packages/ingest/src/crawl.js
+++ b/packages/ingest/src/crawl.js
@@ -1,6 +1,6 @@
import { resolveFeed, scrapeFeed, feedTopics } from '@rssamplifier/feed';
import { q, authors } from '@rssamplifier/db';
-import { fetchXSource } from '@rssamplifier/social';
+import { fetchSocialSource, isCollected } from '@rssamplifier/social';
import { prepareCredits } from './enrich.js';
import {
@@ -202,10 +202,10 @@ export function topicsFrom(feed = {}, storedItems = []) {
async function collectSocial(feed, opts) {
const runtime = opts.xRuntime ?? null;
if (!runtime) {
- return { ok: false, throttled: true, retryAfter: 3600, error: 'x-runtime-unavailable' };
+ return { ok: false, throttled: true, retryAfter: 3600, error: 'social-runtime-unavailable' };
}
- return (opts.x ?? fetchXSource)(feed, { runtime });
+ return (opts.x ?? fetchSocialSource)(feed, { runtime });
}
export async function crawlFeed(db, feed, opts = {}) {
@@ -215,10 +215,16 @@ export async function crawlFeed(db, feed, opts = {}) {
// The third way in. A feed is fetched, a scraped source is read off a page,
// and a social source is collected through a provider — three methods, one
// return shape, and everything past this point is identical for all three.
- // That is what keeps X out of the rest of the pipeline entirely: dedupe,
- // interval learning, keyword extraction, credits, FTS and syndication never
- // learn that it exists (§30, AC-8).
- const social = feed.social_network === 'x' ? 'x' : null;
+ // That is what keeps the platforms out of the rest of the pipeline entirely:
+ // dedupe, interval learning, keyword extraction, credits, FTS and syndication
+ // never learn that any of them exists (§30, AC-8).
+ //
+ // `isCollected` rather than a list of network names, because the two
+ // questions differ: Reddit *is* a social network and is *not* collected — it
+ // publishes real RSS and is fetched like any blog, and `/r/` is about naming
+ // it rather than about reading it. @rssamplifier/social owns that
+ // 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
diff --git a/packages/ingest/test/social-crawl.test.js b/packages/ingest/test/social-crawl.test.js
index b9e0259..c242c10 100644
--- a/packages/ingest/test/social-crawl.test.js
+++ b/packages/ingest/test/social-crawl.test.js
@@ -178,14 +178,14 @@ test('a rate limit moves the schedule and touches no health column (§16)', asyn
assert.equal(items.length, 2);
});
-test('no X runtime is a reschedule, not a verdict on the source', async () => {
+test('no social runtime is a reschedule, not a verdict on the source', async () => {
const feed = await seedXSource();
const result = await crawlFeed(db, feed, {});
assert.equal(result.ok, false);
assert.equal(result.throttled, true);
- assert.equal(result.error, 'x-runtime-unavailable');
+ assert.equal(result.error, 'social-runtime-unavailable');
const after = (await db.execute({ sql: 'select * from feeds where id = ?', args: [feed.id] }))
.rows[0];
diff --git a/packages/social/index.js b/packages/social/index.js
index d0fdc22..b0575b3 100644
--- a/packages/social/index.js
+++ b/packages/social/index.js
@@ -53,4 +53,33 @@ export {
redditSpecFromRef,
} from './src/reddit/canonical.js';
+export {
+ parseInstagramInput,
+ instagramRef,
+ instagramUrl,
+ instagramPath,
+ instagramSlug,
+ instagramTitle,
+ instagramSource,
+ instagramSpecFromRef,
+ INSTAGRAM_MODES,
+} from './src/instagram/canonical.js';
+export { fetchInstagramSource } from './src/instagram/fetch.js';
+
+export {
+ parseFacebookInput,
+ facebookRef,
+ facebookUrl,
+ facebookPath,
+ facebookSlug,
+ facebookTitle,
+ facebookSource,
+ facebookSpecFromRef,
+} from './src/facebook/canonical.js';
+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 { socialSourceFrom, socialPathFor, SOCIAL_NETWORKS } from './src/identify.js';
+export { socialDisplayTitle } from './src/display.js';
diff --git a/packages/social/src/collect.js b/packages/social/src/collect.js
new file mode 100644
index 0000000..07baae6
--- /dev/null
+++ b/packages/social/src/collect.js
@@ -0,0 +1,54 @@
+/**
+ * One entry point for the crawler, whatever the platform is.
+ *
+ * `crawlFeed` should not grow a branch per network — it already holds three
+ * ingestion methods apart (fetch, scrape, collect) and that is the right number.
+ * So the choice of collector lives here, and the crawler asks one question:
+ * is this row collected rather than fetched?
+ *
+ * Reddit is deliberately absent. It publishes real RSS, so it is *fetched* like
+ * any blog and needs no collector at all — the `/r/` namespace is about naming
+ * it, not about reading it. That asymmetry is the whole reason `social_network`
+ * and "needs a collector" are two different questions.
+ */
+
+import { fetchXSource } from './x/fetch.js';
+import { fetchInstagramSource } from './instagram/fetch.js';
+import { fetchFacebookSource } from './facebook/fetch.js';
+
+/**
+ * The networks that cannot simply be fetched, and what collects them.
+ *
+ * Adding a platform is an entry here plus a `canonical.js` and a `fetch.js`.
+ * It is deliberately not a registry with lifecycle hooks: three collectors that
+ * share a return shape are easier to read than a framework that abstracts over
+ * two of them.
+ */
+const COLLECTORS = {
+ x: fetchXSource,
+ instagram: fetchInstagramSource,
+ facebook: fetchFacebookSource,
+};
+
+/**
+ * Is this row collected through a provider rather than fetched from a document?
+ *
+ * @param {{ social_network?: string|null }} feed
+ * @returns {boolean}
+ */
+export function isCollected(feed) {
+ return Boolean(COLLECTORS[String(feed?.social_network ?? '')]);
+}
+
+/**
+ * Collect one social source, in the shape `crawlFeed` expects.
+ *
+ * @param {object} feed the row
+ * @param {{ runtime: object, limit?: number, signal?: AbortSignal }} opts
+ * @returns {Promise