Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions apps/web/src/lib/feedAds.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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,
};
}
Expand Down
43 changes: 39 additions & 4 deletions packages/feed/src/syndicate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -170,14 +187,32 @@ 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;
}
}

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.
*
Expand Down
71 changes: 71 additions & 0 deletions packages/feed/test/syndicate-ads.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down