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);