Skip to content

Commit f6fd26b

Browse files
ralyodioclaude
andauthored
Make the feed ad worth opening: artwork, a brand line, a quieter title (#201)
Reported off rssamplifier.com/topics/rust.rss: the ads read as spam and nobody would open them. Three changes, none of which touch what the advertiser wrote. **The title no longer leads with the label.** "[Sponsored] Ship faster" becomes "Ship faster (Sponsored)". A leading bracket is the first thing the eye meets in a list of headlines and it reads as a spam subject-line prefix, so the item is skipped before the offer is ever read. Trailing, the headline gets to say what it is and the label still travels everywhere the title travels -- and <category> carries it machine-readably besides. Nothing is hidden; the same word is in the same field. **The card carries the artwork.** A feed item sits between real blog posts that each have a title, a picture and a few paragraphs. A bare line of text next to them does not read as restrained, it reads as broken, and gets scrolled past. The card now leads with the advertiser's hero image, then an <h3> headline, the body, and the call to action. width is set and height deliberately is not: readers scale to their own column, and a fixed height distorts every image that is not exactly the ratio we guessed. **A brand line names who is paying.** The logo where there is one, and always the destination host. "Sponsored" tells a reader an ad is an ad; the domain tells them whose it is, which is what they actually decide on. The host cannot come from our click URL -- that is always crawlproof.com -- so destinationHost reads it off the advertiser's own logo/image and returns "" rather than guessing when the only candidate is our own storage. The companion migration undoes a decision from the format's first migration, which dropped image_url on the reasoning that a feed item has no artwork. That was right about the one-line `text` style and wrong about the format. Restores it from each campaign's medium rectangle, which is the creative the hero image was resolved for: 96 of 106 campaigns. The other 10 render a card with no picture, which the renderer treats as ordinary rather than assuming an image. No copy is invented. Bodies average 76 characters because that is what the advertisers wrote; an ad describing a product in words its owner never wrote is a fabricated claim however good it looks. What changed is the layout around it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 91bef25 commit f6fd26b

3 files changed

Lines changed: 168 additions & 19 deletions

File tree

‎lib/ads/feeditem.ts‎

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -332,13 +332,20 @@ export function labelText(v: string | null | undefined): string {
332332
/**
333333
* The item title.
334334
*
335-
* The disclosure is in the title rather than only in the body because a great
336-
* many readers show titles in a list and bodies only on click — a sponsored
337-
* item whose sponsorship is disclosed exclusively in the body is, for those
338-
* readers, an undisclosed one.
335+
* The disclosure stays in the title, because a great many readers show titles
336+
* in a list and bodies only on click — an item whose sponsorship is disclosed
337+
* exclusively in the body is, for those readers, an undisclosed one.
338+
*
339+
* But it goes at the *end*. A leading "[Sponsored]" is the first thing the eye
340+
* lands on in a list of headlines, and it reads as a subject-line prefix on
341+
* spam, so the item is skipped before the offer is ever read. Trailing, the
342+
* headline gets to say what it is and the label still travels everywhere the
343+
* title travels. Nothing is hidden: this is the same word, in the same field,
344+
* and `<category>` carries it machine-readably besides.
339345
*/
340346
export function feedTitle(creative: AdCreative, label = DEFAULT_LABEL): string {
341-
return `[${labelText(label)}] ${oneLine(creative.headline) || "A message from our sponsor"}`;
347+
const headline = oneLine(creative.headline) || "A message from our sponsor";
348+
return `${headline} (${labelText(label)})`;
342349
}
343350

344351
/**
@@ -376,19 +383,41 @@ export function renderFeedHtml(
376383
}
377384

378385
if (style === "card") {
379-
const parts = [`<hr />`, `<p><strong>${esc(label)}</strong></p>`];
380-
// Dimensions are attributes rather than CSS for the same reason as
381-
// everything else here, and are capped small: this is a feed item, not a
382-
// banner, and an oversized logo is what makes an ad feel like an intrusion.
383-
if (creative.logoUrl) {
386+
// The substantial one. A feed item sits between real blog posts, each of
387+
// which has a title, a picture and a few paragraphs — so a bare line of
388+
// text does not read as restrained next to them, it reads as broken, and
389+
// gets skipped. This carries the artwork the advertiser already has.
390+
//
391+
// Every field here is one the advertiser supplied. Nothing is padded out
392+
// with invented copy: an ad that describes a product in words its owner
393+
// never wrote is a fabricated claim, however good it looks.
394+
const parts: string[] = [];
395+
396+
// Lead with the picture, the way the posts around it do. width is set and
397+
// height deliberately is not: readers scale to their own column width, and
398+
// a fixed height would distort every image that is not exactly 2:1.
399+
if (creative.imageUrl) {
384400
parts.push(
385-
`<p>${link(`<img src="${esc(creative.logoUrl)}" alt="" width="120" height="32" />`)}</p>`,
401+
`<p>${link(`<img src="${esc(creative.imageUrl)}" alt="${headline}" width="600" />`)}</p>`,
386402
);
387403
}
388-
parts.push(`<p>${link(`<strong>${headline}</strong>`)}</p>`);
404+
405+
parts.push(`<h3>${link(headline)}</h3>`);
389406
if (body) parts.push(`<p>${body}</p>`);
390407
parts.push(`<p>${link(`<strong>${cta} &#8594;</strong>`)}</p>`);
391-
parts.push(`<p><small>${credit}</small></p>`, `<hr />`);
408+
409+
// The brand line: the logo where there is one, and always the destination
410+
// host. Naming who is paying is the single most useful thing a disclosure
411+
// can do — "Sponsored" tells a reader an ad is an ad, and the domain tells
412+
// them whose it is, which is what they actually decide on.
413+
const host = destinationHost(clickUrl, creative);
414+
const mark = creative.logoUrl
415+
? `<img src="${esc(creative.logoUrl)}" alt="" height="20" /> `
416+
: "";
417+
parts.push(
418+
`<p><small>${mark}${esc(label)}${host ? ` &#183; ${esc(host)}` : ""} &#183; ${credit}</small></p>`,
419+
);
420+
392421
return parts.join("\n");
393422
}
394423

@@ -431,11 +460,15 @@ export function renderFeedMarkdown(
431460
}
432461

433462
if (style === "card") {
434-
const lines = ["---", "", `**${label}**`, ""];
435-
if (creative.logoUrl) lines.push(`[![](${mdUrl(creative.logoUrl)})](${url})`, "");
463+
// Mirrors the HTML card: artwork first, then the headline, the body, the
464+
// call to action, and a brand line naming who is paying.
465+
const lines: string[] = [];
466+
if (creative.imageUrl) lines.push(`[![${headline}](${mdUrl(creative.imageUrl)})](${url})`, "");
436467
lines.push(`### [${headline}](${url})`, "");
437468
if (body) lines.push(body, "");
438-
lines.push(`[**${cta} →**](${url})`, "", credit, "", "---");
469+
lines.push(`[**${cta} →**](${url})`, "");
470+
const host = destinationHost(clickUrl, creative);
471+
lines.push(`*${label}${host ? ` · ${mdEsc(host)}` : ""} · ${mdEsc(ATTRIBUTION)}*`);
439472
return lines.join("\n");
440473
}
441474

@@ -464,6 +497,30 @@ export function renderFeedText(
464497
.join("\n");
465498
}
466499

500+
/**
501+
* The host a reader will end up on, for the brand line.
502+
*
503+
* Our click URL is a redirector, so its host is always crawlproof.com and is
504+
* useless here — the advertiser's own domain is what the reader wants to see.
505+
* It is not on the creative, so this falls back to the logo's host, which for
506+
* essentially every campaign is the advertiser's own CDN or site. Returns ""
507+
* rather than guessing when there is nothing trustworthy to show.
508+
*/
509+
export function destinationHost(clickUrl: string, creative: AdCreative): string {
510+
for (const candidate of [creative.logoUrl, creative.imageUrl]) {
511+
if (!candidate) continue;
512+
try {
513+
const host = new URL(candidate).hostname.replace(/^www\./, "");
514+
// Our own storage tells the reader nothing about who is advertising.
515+
if (/crawlproof\.com$|supabase\.(co|in)$/i.test(host)) continue;
516+
return host;
517+
} catch {
518+
// Not a URL we can read a host out of; try the next one.
519+
}
520+
}
521+
return "";
522+
}
523+
467524
/** Escape the Markdown punctuation that would otherwise reformat ad copy. */
468525
function mdEsc(v: string): string {
469526
return String(v ?? "").replace(/([\\`*_[\]()<>#+\-!|])/g, "\\$1");
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- Give feed creatives their artwork back.
2+
--
3+
-- 20260818120000_ad_feed_item.sql deliberately dropped image_url when it
4+
-- backfilled the feed creatives, on the reasoning that the default feed body is
5+
-- a single sponsored line and would never render artwork.
6+
--
7+
-- That reasoning was right about the `text` style and wrong about the format.
8+
-- A feed item sits between real blog posts, each of which has a title, a
9+
-- picture and several paragraphs, and a bare line of text does not read as
10+
-- restrained next to them — it reads as broken, and gets skipped. The `card`
11+
-- style renders a banner, and rssamplifier.com now asks for it, so the column
12+
-- has to carry the image the advertiser already has.
13+
--
14+
-- Source is the campaign's medium rectangle, which is the creative the hero
15+
-- image was resolved for (see lib/ads/heroImage.ts) and the only format that
16+
-- reliably has one: 96 of 106 campaigns. The other 10 stay null and render the
17+
-- card without a picture, which is why the renderer treats the image as
18+
-- optional rather than assuming it.
19+
--
20+
-- Only touches rows the backfill created and left null, so a creative whose
21+
-- image was set by hand afterwards is not overwritten.
22+
--
23+
-- NOTE: prod migration history diverged — apply this single file via psql over
24+
-- the pooler or the Supabase MCP, do NOT `supabase db push`.
25+
26+
update public.ad_creatives f
27+
set image_url = r.image_url
28+
from public.ad_creatives r
29+
where f.format = 'feed_item'
30+
and f.image_url is null
31+
and r.campaign_id = f.campaign_id
32+
and r.format = 'banner_300x250'
33+
and r.image_url is not null
34+
and f.ai_provenance ->> 'source' = 'backfill_feed_item';

‎tests/ads-feed-item.test.ts‎

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { XMLParser, XMLValidator } from "fast-xml-parser";
33
import {
44
ATTRIBUTION,
55
adGuid,
6+
destinationHost,
67
cdata,
78
ctaLabel,
89
feedDeviceType,
@@ -233,11 +234,20 @@ describe("identity", () => {
233234

234235
describe("disclosure", () => {
235236
it("is in the title, where a reader listing titles will see it", () => {
236-
expect(feedTitle(creative)).toBe("[Sponsored] Ship faster with Widgets");
237+
expect(feedTitle(creative)).toBe("Ship faster with Widgets (Sponsored)");
238+
});
239+
240+
// Leading "[Sponsored]" is the first thing the eye meets in a list of
241+
// headlines and reads as a spam subject-line prefix, so the item is skipped
242+
// before the offer is read. The label travels just as far at the end.
243+
it("does not lead with the label", () => {
244+
expect(feedTitle(creative).startsWith("[")).toBe(false);
245+
expect(feedTitle(creative).startsWith("Sponsored")).toBe(false);
246+
expect(feedTitle(creative)).toContain("Sponsored");
237247
});
238248

239249
it("can be reworded but not removed", () => {
240-
expect(feedTitle(creative, "Ad")).toBe("[Ad] Ship faster with Widgets");
250+
expect(feedTitle(creative, "Ad")).toBe("Ship faster with Widgets (Ad)");
241251
expect(labelText("")).toBe("Sponsored");
242252
expect(labelText(" ")).toBe("Sponsored");
243253
expect(labelText(null)).toBe("Sponsored");
@@ -300,6 +310,54 @@ describe("bodies", () => {
300310
);
301311
});
302312

313+
// The card sits between real blog posts that each have a picture and a few
314+
// paragraphs; a bare line next to them reads as broken rather than restrained.
315+
it("leads the card with the advertiser's artwork", () => {
316+
const withArt = { ...creative, imageUrl: "https://widgets.example/hero.png" };
317+
const html = renderFeedHtml(withArt, input.clickUrl, { style: "card" });
318+
319+
expect(html).toContain('src="https://widgets.example/hero.png"');
320+
// Width only: readers scale to their own column, and a fixed height would
321+
// distort every image that is not exactly the ratio we guessed.
322+
expect(html).toContain('width="600"');
323+
expect(html).not.toMatch(/<img[^>]*hero\.png[^>]*height=/);
324+
// The picture comes before the headline, the way the posts around it do.
325+
expect(html.indexOf("hero.png")).toBeLessThan(html.indexOf("<h3>"));
326+
});
327+
328+
it("renders a card without artwork rather than a broken image", () => {
329+
const noArt = { ...creative, imageUrl: null };
330+
const html = renderFeedHtml(noArt, input.clickUrl, { style: "card" });
331+
expect(html).not.toContain("<img src=\"\"");
332+
expect(html).toContain("<h3>");
333+
expect(html).toContain(ATTRIBUTION);
334+
});
335+
336+
it("names who is paying, which is what a reader actually decides on", () => {
337+
const html = renderFeedHtml(creative, input.clickUrl, { style: "card" });
338+
expect(html).toContain("widgets.example");
339+
});
340+
341+
it("never offers our own redirector as the advertiser's identity", () => {
342+
// The click URL is always crawlproof.com, so it can never be the brand
343+
// line, and neither can artwork we host on the advertiser's behalf.
344+
const ours = {
345+
...creative,
346+
logoUrl: "https://crawlproof.com/ads/house/promo.webp",
347+
imageUrl: null,
348+
};
349+
expect(destinationHost(input.clickUrl, ours)).toBe("");
350+
expect(destinationHost(input.clickUrl, creative)).toBe("widgets.example");
351+
});
352+
353+
it("puts the same artwork in the markdown card", () => {
354+
const withArt = { ...creative, imageUrl: "https://widgets.example/hero.png" };
355+
const md = renderFeedMarkdown(withArt, input.clickUrl, { style: "card" });
356+
expect(md).toContain("![");
357+
expect(md).toContain("https://widgets.example/hero.png");
358+
expect(md).toContain("widgets.example");
359+
});
360+
303361
it("does not double the arrow on copy that already has one", () => {
304362
expect(ctaLabel("Try it free →")).toBe("Try it free");
305363
expect(ctaLabel("Learn more ->")).toBe("Learn more");
@@ -347,7 +405,7 @@ describe("json shapes", () => {
347405
// The raw headline, without the disclosure prefix baked in, so a consumer
348406
// that discloses its own way is not forced to string-strip ours.
349407
expect(f.headline).toBe("Ship faster with Widgets");
350-
expect(f.title).toBe("[Sponsored] Ship faster with Widgets");
408+
expect(f.title).toBe("Ship faster with Widgets (Sponsored)");
351409
expect(f.tier).toBe("paid");
352410
});
353411
});

0 commit comments

Comments
 (0)