diff --git a/apps/web/src/app/api/following/feed/[format]/route.js b/apps/web/src/app/api/following/feed/[format]/route.js
index a4ca17f..b06cc0a 100644
--- a/apps/web/src/app/api/following/feed/[format]/route.js
+++ b/apps/web/src/app/api/following/feed/[format]/route.js
@@ -1,7 +1,13 @@
import { accounts } from '@rssamplifier/db';
-import { SYNDICATION_FORMATS, buildSyndication } from '@rssamplifier/feed';
+import {
+ SYNDICATION_FORMATS,
+ adSlotsFor,
+ buildSyndication,
+ interleaveAds,
+} from '@rssamplifier/feed';
import { db, siteUrl } from '../../../../../lib/db.js';
+import { fetchFeedAds } from '../../../../../lib/feedAds.js';
import { RIVER_LIMIT, following, followingFeedUrl } from '../../../../../lib/following.js';
export const dynamic = 'force-dynamic';
@@ -58,6 +64,23 @@ export async function GET(req, { params }) {
const origin = siteUrl();
+ const rows = items.map((row) => ({
+ ...row,
+ // The publisher's guid, the same identity every other feed on the site
+ // uses, so a re-crawl that renumbers our rows does not make a reader show
+ // the same post twice.
+ id: String(row.guid ?? row.url ?? ''),
+ }));
+
+ // Sponsored items at the same one-in-ten rate as the topic feeds. The ad is
+ // not personalised and carries nothing about this account: the request to the
+ // ad network names the slot and nothing else, and the surface tag is what
+ // distinguishes this river from a topic's in the advertiser's own analytics.
+ // That matters here in a way it does not elsewhere — this is the one feed on
+ // the site that belongs to a particular person.
+ const wanted = adSlotsFor(rows.length);
+ const ads = wanted > 0 ? await fetchFeedAds(wanted, { src: 'following' }) : [];
+
const body = buildSyndication(
format,
{
@@ -69,13 +92,7 @@ export async function GET(req, { params }) {
link: `${origin}/following`,
selfUrl: followingFeedUrl(origin, token, format),
},
- items.map((row) => ({
- ...row,
- // The publisher's guid, the same identity every other feed on the site
- // uses, so a re-crawl that renumbers our rows does not make a reader show
- // the same post twice.
- id: String(row.guid ?? row.url ?? ''),
- })),
+ interleaveAds(rows, ads),
);
return new Response(body, {
diff --git a/apps/web/src/lib/ads.js b/apps/web/src/lib/ads.js
index be2535b..4fdedc4 100644
--- a/apps/web/src/lib/ads.js
+++ b/apps/web/src/lib/ads.js
@@ -19,10 +19,21 @@
* text/plain to CLI clients. ad.js has no size for it and cannot render it in a
* browser, so it is deliberately unused here.
*
+ * The syndicated feeds are monetised too, but nothing in this file does it:
+ * a feed has no DOM for ad.js to fill, so the ad has to be *in* the document
+ * and is fetched while it is built. See ./feedAds.js and `interleaveAds` in the
+ * feed package.
+ *
* Deliberately *not* monetised: /llms.txt, /opml, /api/* and the rest of the
* machine-readable surface (the clean copy for agents is the product's whole
* pitch), the framed reader (someone else's article — see the reader page), and
* /offline (no network, so the request could not succeed anyway).
+ *
+ * The line between "a feed carries ads" and "/api/* does not" is who the
+ * document is for. A feed is a subscription a person reads in a reader, and it
+ * is the same river the ad-carrying web pages show. /api/* and /llms.txt are
+ * the machine-readable copy an agent consumes, where an ad is noise in a data
+ * structure rather than a placement anybody sees.
*/
export const AD_SLOT = '2768fe0d-c51c-4629-8d86-0efba3d9ec1f';
diff --git a/apps/web/src/lib/feedAds.js b/apps/web/src/lib/feedAds.js
new file mode 100644
index 0000000..a84ec19
--- /dev/null
+++ b/apps/web/src/lib/feedAds.js
@@ -0,0 +1,174 @@
+/*
+ * Sponsored items for the syndicated feeds.
+ *
+ * The site already carries CrawlProof's web units (see ./ads.js), but a feed is
+ * not a page: nothing here runs `ad.js`, there is no DOM to fill, and the
+ * reader is a piece of software that will keep the document for weeks. So the
+ * ad has to be *in* the document, fetched while we build it.
+ *
+ * Two decisions are worth stating, because both are easy to get wrong later.
+ *
+ * **We take `as=fields`, not `as=rss`.** CrawlProof will happily hand back a
+ * ready-made ``, and splicing that string into our XML would be less
+ * code. It would also mean two different pieces of software decide how a title
+ * gets escaped inside one document, and the day their idea of escaping differs
+ * from ours is the day every subscriber's reader reports a parse error on the
+ * whole feed. Taking the raw fields and rendering them through `buildRss` /
+ * `buildAtom` / `buildJsonFeed` keeps that decision in exactly one place — the
+ * same place it is made for the other fifty items.
+ *
+ * **Failure is silent and total.** Every path out of here returns `[]`. A feed
+ * is the product; an ad is revenue on top of it. A slow ad server, an expired
+ * slot, a network blip — none of those may cost a reader their subscription, so
+ * there is no retry, no error surfaced upward, and a hard timeout well under
+ * the time a reader would wait.
+ */
+
+import { AD_SLOT } from './ads.js';
+
+/** Where the ad network lives. */
+const CRAWLPROOF = 'https://crawlproof.com';
+
+/**
+ * How long to wait for an ad before giving up on it.
+ *
+ * Deliberately short. The feed query has already run by the time we get here,
+ * so this is time added directly to a response the reader is waiting on, and an
+ * unsold slot costs nothing while a slow one costs everybody.
+ */
+const TIMEOUT_MS = 2000;
+
+/**
+ * How long a fetched ad is reused.
+ *
+ * The feeds are served with `max-age=300`, and CrawlProof's default identity
+ * rotation is daily — so refetching per request would burn an impression for
+ * every cache miss while returning an item carrying the same guid, which no
+ * reader would show twice anyway. Matching the feed's own cache window keeps
+ * the impression count honest about how often the ad was actually published.
+ */
+const CACHE_MS = 300_000;
+
+/** @type {Map} */
+const cache = new Map();
+
+/**
+ * Is feed advertising on?
+ *
+ * Read through a non-literal property access: Next inlines `process.env.FOO` at
+ * build time, which would bake the build-time value into the image and ignore
+ * whatever Railway injects at runtime. Same reason `siteUrl()` does it.
+ *
+ * Defaults to on. Set `FEED_ADS=0` to turn every sponsored item off without a
+ * deploy — the kill switch matters more than the toggle, because the thing it
+ * switches off is written into documents other people keep.
+ *
+ * @returns {boolean}
+ */
+export function feedAdsEnabled() {
+ const env = process.env;
+ return String(env['FEED_ADS'] ?? '1') !== '0';
+}
+
+/**
+ * Fetch sponsored items, already in the shape `buildSyndication` renders.
+ *
+ * @param {number} count how many to ask for (CrawlProof caps at 5)
+ * @param {{ src?: string }} [opts] surface tag, so one slot can tell its
+ * surfaces apart in the advertiser's analytics
+ * @returns {PromiseInjected',
+ content_html: 'ends with ]]> a terminator',
+ };
+ const xml = buildRss(channel, interleaveAds(posts(20), [hostile]));
+
+ assert.equal(XMLValidator.validate(xml), true);
+ const parsed = parser.parse(xml);
+ // 20 posts + 1 ad. An injected would make it 22.
+ assert.equal(parsed.rss.channel.item.length, 21);
+ const found = parsed.rss.channel.item.find((i) => i.category === 'Sponsored');
+ assert.equal(found.description, 'ends with ]]> a terminator');
+});
+
+test('a real post still renders exactly as it did', () => {
+ // content_html and sponsored are opt-in; a crawled post carries neither, and
+ // its body must stay the plain-text summary it has always been.
+ const xml = buildRss(channel, posts(2));
+ const parsed = parser.parse(xml);
+
+ assert.equal(parsed.rss.channel.item[0].description, 'A real post from a real blog.');
+ assert.equal(parsed.rss.channel.item[0].category, undefined);
+ assert.equal(XMLValidator.validate(xml), true);
+});
+
+test('playlists carry no ads, because an ad is not playable', () => {
+ // The call sites do not splice into media formats at all, but if one ever
+ // did, a sponsored line has no enclosure and must be skipped rather than
+ // handed to a player as a file it cannot open.
+ const mixed = interleaveAds(
+ posts(20).map((p) => ({ ...p, audio_url: 'https://example.com/a.mp3' })),
+ [ad],
+ );
+
+ const m3u = buildM3u(channel, mixed);
+ const pls = buildPls(channel, mixed);
+
+ assert.ok(!m3u.includes('Sponsored'));
+ assert.ok(!pls.includes('Sponsored'));
+ assert.match(pls, /NumberOfEntries=20/);
+});