From f9b0de90d51ea2ee550437b91979c41a60378544 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 18 Aug 2026 15:28:11 +0000 Subject: [PATCH] Stop the ads stacking on top of the feed, and give them something to show Two problems, reported from /topics/rust.rss. **All three ads sat at the top.** Document order was right -- 10, 21, 32 -- and document order is the half that does not matter. Readers sort by date, and the ads were dated to the start of the current UTC day by crawlproof's default identity rotation, which is *newer than the river itself*: this is a directory of other people's blogs and the newest post is routinely a day or more old. So all three sorted above every real post, and because they shared one timestamp they arrived as a block of three adverts at the top of the feed. Precisely the thing nobody opens. The original reasoning was that dating an ad to the period start stops it jumping up the sort order on every rebuild. True, and beside the point: it parks the ad at the top permanently instead of intermittently. Each ad is now re-dated to one second behind the post it follows, so document order and reader order agree and the ads are spread through the river rather than stacked on it. A test sorts the finished document by date and asserts nothing moves. Re-dating is safe because identity is the guid, not the date -- a reader that already stored the item keeps the date it first saw. An undated neighbour leaves the ad's own date alone. Plenty of rows genuinely have no date, and inventing one for the neighbour would be worse. **They looked like spam.** These items sit between real blog posts that each have a headline, a picture and a few paragraphs; a bare sponsored line next to them does not read as restrained, it reads as broken. So the feeds now ask for style=card -- the advertiser's own artwork, headline, body and call to action -- and carry image_url through as well, because many readers render only a thumbnail in list view and the item with no picture is the one that looks like filler. Nothing here invents copy. The body stays whatever the advertiser wrote (76 characters on average); what changed is the layout around it. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/lib/feedAds.js | 16 +++++- packages/feed/src/syndicate.js | 43 ++++++++++++-- packages/feed/test/syndicate-ads.test.js | 71 ++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/feedAds.js b/apps/web/src/lib/feedAds.js index a84ec19..9311b09 100644 --- a/apps/web/src/lib/feedAds.js +++ b/apps/web/src/lib/feedAds.js @@ -86,9 +86,14 @@ export async function fetchFeedAds(count, { src = 'feed' } = {}) { const hit = cache.get(key); if (hit && Date.now() - hit.at < CACHE_MS) return hit.items; + // style=card, not the default single line. These items sit between real blog + // posts that each have a headline, a picture and a few paragraphs, and a bare + // sponsored line next to them does not read as restrained — it reads as + // broken, and gets scrolled past. The card carries the advertiser's own + // artwork, headline, body and call to action. const url = `${CRAWLPROOF}/api/ads/feed?slot=${encodeURIComponent(AD_SLOT)}` + - `&as=fields&n=${want}&src=${encodeURIComponent(src)}`; + `&as=fields&style=card&n=${want}&src=${encodeURIComponent(src)}`; let items = []; @@ -157,9 +162,16 @@ function toItem(ad) { title, summary: typeof ad.body === 'string' && ad.body ? ad.body : null, content_html: typeof ad.html === 'string' && ad.html ? ad.html : null, + // A starting date only. interleaveAds overwrites it with one derived from + // the post the ad ends up following, because readers order by date and this + // one would otherwise float the ad to the top of the river. published_at: isoOrNull(ad.publishedAt), author: null, - image_url: null, + // The advertiser's artwork, carried the same way a crawled post's picture + // is. It is already inside content_html, but a great many readers render + // only the thumbnail in a list view, and an item with no picture beside + // items that all have one is the one that looks like filler. + image_url: typeof ad.imageUrl === 'string' && ad.imageUrl ? ad.imageUrl : null, sponsored: true, }; } diff --git a/packages/feed/src/syndicate.js b/packages/feed/src/syndicate.js index 954581f..65c556b 100644 --- a/packages/feed/src/syndicate.js +++ b/packages/feed/src/syndicate.js @@ -144,9 +144,26 @@ export function adSlotsFor(total, { every = AD_EVERY, max = AD_MAX } = {}) { /** * Place sponsored items among real ones. * - * Pure and index-based rather than date-based on purpose: the caller has - * already sorted the river, and an ad carrying today's date would otherwise - * sort itself to the top of every feed regardless of where it was meant to sit. + * Position in the document is only half the job, and the half that does not + * matter. **Readers sort by date**, so where an ad *sits* in the XML is + * irrelevant next to what date it carries — and an ad dated "now" floats to the + * top of the river no matter which index it was written at. + * + * That is not hypothetical. The first version of this shipped ads dated to the + * start of the current UTC day, which is *newer than most of the feed*: the + * directory is a river of other people's blogs and the newest post is routinely + * a day or more old. All three ads therefore sorted above every real post, and + * because they shared one timestamp they arrived as a block of three + * advertisements at the top of the feed. Precisely the thing nobody opens. + * + * So each ad is re-dated to sit just behind the post it follows — one second + * older, which is enough to order it and small enough that it reads as + * contemporary with its neighbours. Document order and reader order then agree, + * and the ads are spread through the river instead of stacked on top of it. + * + * Re-dating is safe because identity is the guid, not the date: a reader that + * has already stored the item keeps whatever date it first saw, and one that + * has not gets an item that sorts where we intended. * * A list shorter than one full interval gets nothing. A six-post feed cannot * carry an ad without the ad becoming the feed, and the same rule already @@ -170,7 +187,7 @@ export function interleaveAds(items, ads, { every = AD_EVERY, max = AD_MAX } = { // having ended in an advertisement, and costs the slot nothing to skip. const boundary = (i + 1) % every === 0 && i + 1 < items.length; if (boundary && placed < max && placed < ads.length) { - out.push(ads[placed]); + out.push(datedAfter(ads[placed], items[i])); placed += 1; } } @@ -178,6 +195,24 @@ export function interleaveAds(items, ads, { every = AD_EVERY, max = AD_MAX } = { return out; } +/** + * An ad re-dated to sort immediately after the post it follows. + * + * Falls back to the ad's own date when the preceding post has none — a great + * many rows genuinely do not (an item parsed out of a playlist has no date), and + * inventing one for the neighbour would be worse than leaving the ad where it + * was. + * + * @param {Item} ad + * @param {Item} previous + * @returns {Item} + */ +function datedAfter(ad, previous) { + const at = new Date(String(previous?.published_at ?? '')); + if (Number.isNaN(at.getTime())) return ad; + return { ...ad, published_at: new Date(at.getTime() - 1000).toISOString() }; +} + /** * Wrap a body in CDATA. * diff --git a/packages/feed/test/syndicate-ads.test.js b/packages/feed/test/syndicate-ads.test.js index 28c044f..7f35ec0 100644 --- a/packages/feed/test/syndicate-ads.test.js +++ b/packages/feed/test/syndicate-ads.test.js @@ -78,6 +78,77 @@ test('adSlotsFor predicts exactly what interleaveAds will place', () => { } }); +test('an ad is dated to sort after the post it follows, not above the feed', () => { + // The bug this exists to prevent: ads dated "start of today" while the newest + // post in the river is a day older, so every reader -- which orders by date, + // not by document position -- floated all three above every real post and + // showed them as a block of adverts at the top of the feed. + const dated = Array.from({ length: 30 }, (_, i) => ({ + ...post(i + 1), + published_at: new Date(Date.UTC(2026, 7, 17 - i, 12, 0, 0)).toISOString(), + })); + const ads = [0, 1, 2].map((i) => ({ ...ad, id: `ad-${i}`, published_at: '2026-08-18T00:00:00.000Z' })); + + const out = interleaveAds(dated, ads); + const placed = out.filter((i) => i.sponsored); + assert.equal(placed.length, 2); + + // Each ad is strictly older than the post before it and strictly newer than + // the post after it, so document order and reader order agree. + for (const a of placed) { + const at = out.indexOf(a); + assert.ok( + new Date(a.published_at) < new Date(out[at - 1].published_at), + 'ad must sort below the post it follows', + ); + assert.ok( + new Date(a.published_at) > new Date(out[at + 1].published_at), + 'ad must sort above the post after it', + ); + } + + // And none of them outranks the newest real post. + const newestPost = out.find((i) => !i.sponsored); + for (const a of placed) { + assert.ok(new Date(a.published_at) < new Date(newestPost.published_at)); + } + + // Sorting the document the way a reader does must not move anything. + const bySortedDate = [...out].sort( + (x, y) => new Date(y.published_at) - new Date(x.published_at), + ); + assert.deepEqual(bySortedDate.map((i) => i.id), out.map((i) => i.id)); +}); + +test('two ads in one feed never share a timestamp', () => { + // Identical dates are what made them arrive as one clump rather than spread + // through the river. + const dated = Array.from({ length: 30 }, (_, i) => ({ + ...post(i + 1), + published_at: new Date(Date.UTC(2026, 7, 17 - i, 12, 0, 0)).toISOString(), + })); + const out = interleaveAds(dated, [0, 1, 2].map((i) => ({ ...ad, id: `ad-${i}` }))); + const stamps = out.filter((i) => i.sponsored).map((i) => i.published_at); + assert.equal(new Set(stamps).size, stamps.length); +}); + +test('an undated neighbour leaves the ad dated as it arrived', () => { + // Plenty of rows genuinely have no date; inventing one for the neighbour + // would be worse than leaving the ad where it was. + const undated = Array.from({ length: 20 }, (_, i) => ({ ...post(i + 1), published_at: null })); + const out = interleaveAds(undated, [ad]); + assert.equal(out.find((i) => i.sponsored).published_at, ad.published_at); +}); + +test('re-dating does not touch the identity a reader dedupes on', () => { + const dated = Array.from({ length: 20 }, (_, i) => ({ + ...post(i + 1), + published_at: new Date(Date.UTC(2026, 7, 17 - i, 12, 0, 0)).toISOString(), + })); + const out = interleaveAds(dated, [ad]); + assert.equal(out.find((i) => i.sponsored).id, ad.id); +}); + test('interleaveAds leaves a short list alone', () => { // Nine posts cannot carry an ad without the ad becoming the feed. const short = posts(AD_EVERY - 1);