Skip to content

Commit abb2d47

Browse files
ralyodioclaude
andcommitted
feat(social): Reddit lives at /r/, X lives at /x/, and X gets providers
Two namespaces, and one package that answers the same question for both: what is the canonical identity of a thing on a platform? Reddit needed only a name. It publishes real RSS, so a subreddit resolved down the ordinary path and landed as an untyped row at a slug of its own — which is how 50,099 of them ended up filed among the blogs, 41% of the crawl queue. They are not moved or renamed: the migration gives them an identity, /r/programming answers, and /{slug} keeps working for every link already pointing at it. X needed the rest. It publishes nothing, so posts are collected through a provider stack — RSSHub, then Teapot, then the official API — with session rotation, cooldowns, failover and spend caps. The provider appears in no public URL, so the collection method can be replaced under a live subscriber without their reader noticing. The design decision worth stating: a social source is an ordinary row in `feeds`, not the parallel `sources`/`items` pair the PRD sketches. That pair already exists here, carrying dedupe, backoff, interval learning, keyword extraction, FTS, alerts, sitemaps and five syndication formats — so an X post reaches /topics/*.rss beside a blog post with no code in the topic path that knows X exists. - packages/social: canonicalisation for both platforms; X providers, session pool, registry with failover, post normalisation - migration: social_network/social_ref/social_config on feeds, provider and session health tables (no credentials: those stay in the environment), and a deterministic backfill of the existing subreddits - crawl: a third ingestion path beside fetch and scrape, on a five-minute floor rather than the directory's hourly one - web: /r, /r/<sub>, /r/u/<user>, /x, /x/<handle>, /x/<handle>/replies, /x/<handle>/media, /x/list/<id>, /x/search, /x/status — each with .rss/.atom/.json/.md, canonical tags, and an offer to add what is not here yet - rivers now answer If-None-Match with a 304, computed before the ad fetch so an unchanged feed never meters an impression it did not deliver Not built: the /admin/x buttons of §34. There is no notion of an administrator in this codebase to guard them with, and shipping a kill switch anyone can pull is worse than not shipping one. X_ENABLED and X_PRIMARY_PROVIDER cover it without a code deploy; /x/status is the read-only half. 64 new tests, all 12 packages green under Node 22 and 24. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q6QEgpuS4MLamogXtr2ZX6
1 parent 1c4c3ab commit abb2d47

60 files changed

Lines changed: 6432 additions & 28 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,44 @@ CARD_BACKFILL_SECONDS=20
4646
# Nonessential maintenance can be paused while a large first-crawl backlog owns
4747
# the database write path. The work is resumable when these are switched on.
4848
CLUSTER_BACKFILL=1
49+
50+
# --------------------------------------------------------------------- X / Twitter
51+
# X publishes no feeds, so posts are collected through a provider and mirrored
52+
# at /x/<handle>. Off by default: with this unset nothing is collected, existing
53+
# X feeds keep serving what they hold, and no source is marked unhealthy for it.
54+
X_ENABLED=false
55+
56+
# Failover order. The provider never appears in a public URL, so this can change
57+
# under a live subscriber without their reader noticing.
58+
X_PRIMARY_PROVIDER=rsshub
59+
X_FALLBACK_PROVIDERS=teapot,official
60+
61+
# Self-hosted, alongside the app, and not exposed publicly. A provider whose
62+
# base URL is unset is skipped rather than guessed at.
63+
RSSHUB_BASE_URL=
64+
RSSHUB_ACCESS_KEY=
65+
TEAPOT_BASE_URL=
66+
67+
# The official API: the only provider that costs money per request, hence the
68+
# caps. 0 means unlimited.
69+
X_API_BEARER_TOKEN=
70+
X_API_DAILY_READS=0
71+
X_API_MONTHLY_READS=0
72+
X_API_MAX_RPM=0
73+
74+
# Logged-in X sessions for the unofficial providers, as JSON:
75+
# [{"id":"x-1","authToken":"...","ct0":"..."}]
76+
#
77+
# These are a full login to an X account - whoever holds them can post as it and
78+
# change its password. They live here rather than in a table so that a leaked
79+
# database dump carries none of them; x_sessions holds health state only. Use
80+
# dedicated accounts, and separate ones for production and development.
81+
#
82+
# The positional pair X_AUTH_TOKENS / X_CT0_TOKENS also works and is a trap:
83+
# the lists are matched by index, so removing one dead account from the middle
84+
# of the first and forgetting the second pairs every later token with the wrong
85+
# cookie. Prefer the JSON form, which cannot express that.
86+
X_SESSIONS=
87+
88+
X_FETCH_TIMEOUT_MS=15000
89+
X_SESSION_COOLDOWN_SECONDS=900

‎README.md‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ packages/feed/ Feed discovery, RSS/Atom/JSON Feed parsing, OPML, SSRF guards
2323
packages/ingest/ Submit + crawl orchestration
2424
packages/db/ Turso/libSQL client, migrations and every query
2525
packages/notify/ Alerts — web push, email digests and webhooks
26+
packages/social/ X and Reddit: canonical identity, and X's provider adapters
2627
```
2728

2829
Everything outside the Next app is plain ESM with JSDoc types — no build step, so Docker stays
@@ -56,6 +57,98 @@ pnpm --filter @rssamplifier/db migrate
5657
| `/discoveries/{id}` | Progress of one keyword run: what was added, and why the rest was not |
5758
| `/crawlstats` | Crawler and discovery queues, live (`/crawlstatus` redirects here) |
5859
| `/random` | Redirect to a random blog — the toolbar's ✦ |
60+
| `/r` | Every subreddit and Reddit user in the directory |
61+
| `/r/<subreddit>` | One community: `/r/programming`, `.rss` `.atom` `.json` `.md` |
62+
| `/r/u/<user>` | One Reddit user, under the same prefix |
63+
| `/x` | Every X account, search and list in the directory |
64+
| `/x/<handle>` | One timeline: `/x/OpenAI`, plus `/replies` and `/media` |
65+
| `/x/list/<id>` | One X list |
66+
| `/x/search?q=` | An X search as a feed — X's own operators pass through |
67+
| `/x/status` | Which provider is collecting X, and how it is doing |
68+
69+
### The two social namespaces
70+
71+
Reddit and X both live under a prefix of their own, and for the same reason from
72+
opposite directions. Reddit publishes real RSS, so a subreddit resolves down the
73+
ordinary path and lands as an untyped row at a slug of its own — which is how
74+
50,099 of them ended up filed among the blogs. X publishes nothing at all, so
75+
without a provider it is not submittable in the first place.
76+
77+
`packages/social` answers one question for both: **what is the canonical identity
78+
of this thing?** `@OpenAI`, `x.com/OpenAI` and `https://twitter.com/openai/` are
79+
one source (`x:user:openai`, at `/x/OpenAI`); `/r/programming`, `/r/Programming/`
80+
and `/r/programming/new/.rss` are one community (`r:sub:programming`, at
81+
`/r/programming`). One identity means one row, which means **one polling job no
82+
matter how many people subscribe** — the thing that matters most when the
83+
upstream rate-limits per account.
84+
85+
A social source is an ordinary row in `feeds`, not a parallel `sources` table.
86+
That is what lets it inherit dedupe, backoff, interval learning, keyword
87+
extraction, full-text search, alerts, sitemaps and all five syndication formats
88+
without a line of X-specific code in any of them — and it is why an X post can
89+
appear in `/topics/artificial-intelligence.rss` beside a blog post with nothing
90+
to tell them apart.
91+
92+
`/{slug}` still answers for both, and always will: it is the permanent identity
93+
of a row and links already point at it. The `/r/` and `/x/` address is the
94+
canonical one, which is what search engines are told.
95+
96+
## Collecting X
97+
98+
X has no feeds, so posts are collected through a provider and mirrored here.
99+
**Which provider never appears in a public URL** — that is the whole design.
100+
A reader subscribed to `/x/OpenAI.rss` through RSSHub is still subscribed when
101+
the collection method is replaced under them.
102+
103+
```
104+
RSSHub (primary) → Teapot (fallback) → official X API (paid) → cached items
105+
```
106+
107+
Failover is per attempt and the order is fixed; three failures in a row set a
108+
provider aside for a few minutes and one success clears it. Nothing here can
109+
empty a feed: items are only ever written on success, so an outage leaves
110+
yesterday's posts exactly where they were and the public route goes on serving
111+
them.
112+
113+
| Variable | Default | What |
114+
| --- | --- | --- |
115+
| `X_ENABLED` | `false` | The kill switch. Off means no collection; existing feeds keep serving. |
116+
| `X_PRIMARY_PROVIDER` | `rsshub` | First provider tried. |
117+
| `X_FALLBACK_PROVIDERS` | `teapot,official` | Tried in order after it. |
118+
| `RSSHUB_BASE_URL` | — | A self-hosted RSSHub. Unset means the provider is skipped. |
119+
| `RSSHUB_ACCESS_KEY` | — | If that instance requires one. |
120+
| `TEAPOT_BASE_URL` | — | A Teapot or Nitter-shaped instance. |
121+
| `X_API_BEARER_TOKEN` | — | Official API. Unset means that provider is skipped. |
122+
| `X_API_DAILY_READS` | `0` | Spend cap, `0` for unlimited. Also `X_API_MONTHLY_READS`, `X_API_MAX_RPM`. |
123+
| `X_SESSIONS` | — | JSON: `[{"id":"x-1","authToken":"…","ct0":"…"}]` |
124+
| `X_FETCH_TIMEOUT_MS` | `15000` | Upstream deadline. |
125+
| `X_SESSION_COOLDOWN_SECONDS` | `900` | How long a rate-limited session rests. |
126+
127+
**Session credentials live in the environment and never in a table.**
128+
`auth_token` and `ct0` are a full login to an X account — whoever holds them can
129+
post as it and change its password. `x_sessions` and `x_provider_state` hold
130+
health state only, so a leaked database dump carries no credentials, and nothing
131+
renderable about a session is one. Use dedicated accounts, and separate ones for
132+
production and development.
133+
134+
§47 of the PRD also allows the positional pair `X_AUTH_TOKENS` / `X_CT0_TOKENS`.
135+
It works, and it is a trap worth naming: the lists are matched by position, so
136+
deleting one dead account from the middle of the first and forgetting the second
137+
silently pairs every later token with the wrong cookie — a pool of sessions that
138+
all authenticate as nobody. `X_SESSIONS` cannot express that state.
139+
140+
Both unofficial providers keep their own logged-in sessions by default, so the
141+
pool has nothing to hand them unless the deployment exposes a per-request
142+
parameter (`RSSHUB_SESSION_PARAM`, `TEAPOT_SESSION_HEADER`). Nothing here guesses
143+
at those names: guessing would mean putting a live cookie on a query string
144+
somebody else's instance might log.
145+
146+
**Not built:** the `/admin/x` buttons of §34 — disable a provider, clear a
147+
cooldown, force a refresh. This codebase has no notion of an administrator to
148+
guard them with, and shipping the levers before the lock is how a kill switch
149+
becomes a way for anyone to turn collection off. `X_ENABLED` and
150+
`X_PRIMARY_PROVIDER` cover the two that matter without a code deploy.
151+
`/x/status` is the read-only half, and is where those buttons go.
59152

60153
## Agent endpoints
61154

‎apps/poller/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"@rssamplifier/discover": "workspace:*",
1313
"@rssamplifier/feed": "workspace:*",
1414
"@rssamplifier/ingest": "workspace:*",
15-
"@rssamplifier/notify": "workspace:*"
15+
"@rssamplifier/notify": "workspace:*",
16+
"@rssamplifier/social": "workspace:*"
1617
}
1718
}

‎apps/poller/src/index.js‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
q,
66
accounts,
77
alerts,
8+
social,
89
takeWriteTally,
910
warmStatsCache,
1011
warmDirectoryCache,
@@ -24,6 +25,7 @@ import {
2425
import { runDueSources, discoverFromOwnTopics } from '@rssamplifier/discover';
2526
import { findFeedCard } from '@rssamplifier/feed';
2627
import { deliverAlerts, vapidConfig } from '@rssamplifier/notify';
28+
import { createXRuntime, xEnabled } from '@rssamplifier/social';
2729

2830
import { createRecorder, toEntry, writeFailure } from './log.js';
2931

@@ -294,7 +296,12 @@ async function tick() {
294296
batchSize,
295297
concurrency,
296298
publishLog ? recorder.record : null,
297-
{ perHost },
299+
// `crawl` is handed to crawlFeed as-is. The X runtime travels here rather
300+
// than being built per feed because it is the thing that *remembers*:
301+
// which provider is in cooldown, which session is resting. Rebuilt per
302+
// crawl it would be a system with no memory, rediscovering the same
303+
// outage on every source in the batch.
304+
{ perHost, crawl: { xRuntime } },
298305
);
299306
if (crawled || failed) {
300307
// The backlog is the number worth watching: crawled/failed only say the
@@ -578,6 +585,42 @@ try {
578585
process.exit(1);
579586
}
580587

588+
/**
589+
* The X collection runtime, built once and shared by every crawl in this process.
590+
*
591+
* Built after the migration, because `hydrate()` reads `x_provider_state` and
592+
* `x_sessions` — the tables that migration creates — to restore cooldowns
593+
* across a redeploy. Without that a service that redeploys ten times in a day
594+
* forgets ten outages and re-walks into each of them.
595+
*
596+
* `null` when X is switched off, and that is a first-class state rather than a
597+
* failure: `crawlFeed` sees no runtime and reschedules its X sources without
598+
* touching their health, so existing feeds keep serving what they hold and
599+
* nothing is retired while the integration is off (§42's kill switch).
600+
*
601+
* Logged either way. "The X sources stopped updating" is the kind of thing that
602+
* goes unnoticed for a week, and a boot line saying `x=false` is what turns
603+
* that into a five-second answer — the same reason the push half of the alerts
604+
* stack prints `push=true` here.
605+
*/
606+
const xRuntime = xEnabled(env)
607+
? await createXRuntime({
608+
env,
609+
providerStore: social.providerStore(db),
610+
sessionStore: social.sessionStore(db),
611+
// Straight onto the same live log the crawl writes to, so a failover or a
612+
// session cooldown appears on /crawlstats beside the crawl it affected
613+
// rather than in a stream nobody has open (§35).
614+
onEvent: publishLog ? (event, fields) => recorder.record(toEntry(event, fields)) : null,
615+
})
616+
: null;
617+
618+
log('x-runtime', {
619+
enabled: Boolean(xRuntime),
620+
providers: xRuntime ? xRuntime.registry.candidates().map((p) => p.name) : [],
621+
sessions: xRuntime ? xRuntime.sessions.size : 0,
622+
});
623+
581624
/**
582625
* Key the items stored before the grouping column existed.
583626
*

‎apps/web/next.config.mjs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,43 @@ const nextConfig = {
206206
// The directory itself: what was added to it, newest first.
207207
{ source: '/feed.:format(rss|atom|json|xml|md)', destination: '/api/directory/feed/:format' },
208208

209+
// The two social namespaces.
210+
//
211+
// No playlist spellings, for the same reason /following has none: a
212+
// timeline and a subreddit carry no enclosures, so an `.m3u` of one
213+
// would be an empty file with a confident name.
214+
//
215+
// Ordered narrowest-first within each prefix, because `:username` and
216+
// `:subreddit` match anything: every fixed address under /x has to be
217+
// named before /x/:username, and /r/u before /r/:subreddit. A rewrite
218+
// parameter never spans a slash, so the two-segment rules cannot be
219+
// shadowed by the one-segment ones — but the fixed segments can be, and
220+
// silently.
221+
{
222+
source: '/x/search.:format(rss|atom|json|xml|md)',
223+
destination: '/api/x/search/feed/:format',
224+
},
225+
{
226+
source: '/x/list/:listId.:format(rss|atom|json|xml|md)',
227+
destination: '/api/x/list/:listId/feed/:format',
228+
},
229+
{
230+
source: '/x/:username/:mode(replies|media).:format(rss|atom|json|xml|md)',
231+
destination: '/api/x/:username/:mode/feed/:format',
232+
},
233+
{
234+
source: '/x/:username.:format(rss|atom|json|xml|md)',
235+
destination: '/api/x/:username/feed/:format',
236+
},
237+
{
238+
source: '/r/u/:username.:format(rss|atom|json|xml|md)',
239+
destination: '/api/r/u/:username/feed/:format',
240+
},
241+
{
242+
source: '/r/:subreddit.:format(rss|atom|json|xml|md)',
243+
destination: '/api/r/:subreddit/feed/:format',
244+
},
245+
209246
// One category of it. The segments are the category pages' own paths,
210247
// duplicated from CATEGORIES in apps/web/src/lib/categories.js — this
211248
// file is evaluated before the workspace resolves, so it cannot import

‎apps/web/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@rssamplifier/mail": "workspace:*",
2020
"@rssamplifier/notify": "workspace:*",
2121
"@rssamplifier/search": "workspace:*",
22+
"@rssamplifier/social": "workspace:*",
2223
"@rssamplifier/translate": "workspace:*",
2324
"@simplewebauthn/browser": "^13.0.0",
2425
"@swc/helpers": "^0.5.23",
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { siteUrl } from '../lib/db.js';
2+
3+
/**
4+
* What `/r/somewhere` or `/x/somebody` shows when nobody has added it yet.
5+
*
6+
* A 404 would be the easy answer and the wrong one. The address is well formed,
7+
* the thing at the other end almost certainly exists, and the visitor has
8+
* already told us exactly what they want by typing it — so the page offers to
9+
* add it rather than telling them they were wrong to ask.
10+
*
11+
* A plain `<form method="post">` to `/api/submit`, like every other control on
12+
* this site: it works with JavaScript off, and the endpoint answers an HTML
13+
* caller with a 303 back to the source's own page. Nothing is fetched from X or
14+
* Reddit while the visitor waits — the row is written, the poller collects on
15+
* its next tick, and this page is replaced by the real one within the minute
16+
* (§17, §37).
17+
*
18+
* @param {{ network: 'x'|'reddit', label: string, input: string, canonical: string }} props
19+
* `input` is what gets submitted — the canonical upstream URL, not what was
20+
* typed, so the source that gets created is the one this page is about.
21+
*/
22+
export default function AddSocialSource({ network, label, input, canonical }) {
23+
const platform = network === 'x' ? 'X' : 'Reddit';
24+
25+
return (
26+
<main className="prose">
27+
<h1>{label}</h1>
28+
29+
<p>
30+
Nobody has added this {platform} source to the directory yet. Add it and RSS Amplifier
31+
will start collecting it — usually within a minute.
32+
</p>
33+
34+
<form method="post" action="/api/submit">
35+
<input type="hidden" name="input" value={input} />
36+
<button type="submit">Add {label} to the directory</button>
37+
</form>
38+
39+
<p>
40+
Once it is here, it will be at{' '}
41+
<code>
42+
{siteUrl()}
43+
{canonical}
44+
</code>{' '}
45+
in every format this site publishes:{' '}
46+
<code>.rss</code>, <code>.atom</code>, <code>.json</code> and <code>.md</code>. That
47+
address does not change, whatever we have to do behind it to keep collecting.
48+
</p>
49+
50+
{network === 'x' ? (
51+
<p>
52+
X publishes no feeds of its own, so this is collected on your behalf and mirrored here.
53+
Protected accounts are not collected, and posts arrive as fast as we can read them
54+
rather than in real time.
55+
</p>
56+
) : (
57+
<p>
58+
Reddit publishes its own feed for this, and we read it on a schedule and keep a copy —
59+
so the address above works whether or not Reddit is answering right now.
60+
</p>
61+
)}
62+
63+
<p>
64+
<a href={network === 'x' ? '/x' : '/r'}>Browse what is already here</a>
65+
</p>
66+
</main>
67+
);
68+
}

0 commit comments

Comments
 (0)