From 217f8819168ad50cad6c9c54f88236e3a8fe144b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 18 Aug 2026 15:29:05 +0000 Subject: [PATCH] Make the feed ad worth opening: artwork, a brand line, a quieter title 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 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

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) --- lib/ads/feeditem.ts | 89 +++++++++++++++---- .../20260818160000_ad_feed_item_artwork.sql | 34 +++++++ tests/ads-feed-item.test.ts | 64 ++++++++++++- 3 files changed, 168 insertions(+), 19 deletions(-) create mode 100644 supabase/migrations/20260818160000_ad_feed_item_artwork.sql diff --git a/lib/ads/feeditem.ts b/lib/ads/feeditem.ts index 3ec710d..9622d65 100644 --- a/lib/ads/feeditem.ts +++ b/lib/ads/feeditem.ts @@ -332,13 +332,20 @@ export function labelText(v: string | null | undefined): string { /** * The item title. * - * The disclosure is in the title rather than only in the body because a great - * many readers show titles in a list and bodies only on click — a sponsored - * item whose sponsorship is disclosed exclusively in the body is, for those - * readers, an undisclosed one. + * The disclosure stays in the title, because a great many readers show titles + * in a list and bodies only on click — an item whose sponsorship is disclosed + * exclusively in the body is, for those readers, an undisclosed one. + * + * But it goes at the *end*. A leading "[Sponsored]" is the first thing the eye + * lands on in a list of headlines, and it reads as a subject-line prefix on + * spam, 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. Nothing is hidden: this is the same word, in the same field, + * and `` carries it machine-readably besides. */ export function feedTitle(creative: AdCreative, label = DEFAULT_LABEL): string { - return `[${labelText(label)}] ${oneLine(creative.headline) || "A message from our sponsor"}`; + const headline = oneLine(creative.headline) || "A message from our sponsor"; + return `${headline} (${labelText(label)})`; } /** @@ -376,19 +383,41 @@ export function renderFeedHtml( } if (style === "card") { - const parts = [`
`, `

${esc(label)}

`]; - // Dimensions are attributes rather than CSS for the same reason as - // everything else here, and are capped small: this is a feed item, not a - // banner, and an oversized logo is what makes an ad feel like an intrusion. - if (creative.logoUrl) { + // The substantial one. A feed item sits between real blog posts, each of + // which has a title, a picture and a few paragraphs — so a bare line of + // text does not read as restrained next to them, it reads as broken, and + // gets skipped. This carries the artwork the advertiser already has. + // + // Every field here is one the advertiser supplied. Nothing is padded out + // with invented copy: an ad that describes a product in words its owner + // never wrote is a fabricated claim, however good it looks. + const parts: string[] = []; + + // Lead with the picture, the way the posts around it do. width is set and + // height deliberately is not: readers scale to their own column width, and + // a fixed height would distort every image that is not exactly 2:1. + if (creative.imageUrl) { parts.push( - `

${link(``)}

`, + `

${link(`${headline}`)}

`, ); } - parts.push(`

${link(`${headline}`)}

`); + + parts.push(`

${link(headline)}

`); if (body) parts.push(`

${body}

`); parts.push(`

${link(`${cta} →`)}

`); - parts.push(`

${credit}

`, `
`); + + // The brand line: the logo where there is one, and always the destination + // host. Naming who is paying is the single most useful thing a disclosure + // can do — "Sponsored" tells a reader an ad is an ad, and the domain tells + // them whose it is, which is what they actually decide on. + const host = destinationHost(clickUrl, creative); + const mark = creative.logoUrl + ? ` ` + : ""; + parts.push( + `

${mark}${esc(label)}${host ? ` · ${esc(host)}` : ""} · ${credit}

`, + ); + return parts.join("\n"); } @@ -431,11 +460,15 @@ export function renderFeedMarkdown( } if (style === "card") { - const lines = ["---", "", `**${label}**`, ""]; - if (creative.logoUrl) lines.push(`[![](${mdUrl(creative.logoUrl)})](${url})`, ""); + // Mirrors the HTML card: artwork first, then the headline, the body, the + // call to action, and a brand line naming who is paying. + const lines: string[] = []; + if (creative.imageUrl) lines.push(`[![${headline}](${mdUrl(creative.imageUrl)})](${url})`, ""); lines.push(`### [${headline}](${url})`, ""); if (body) lines.push(body, ""); - lines.push(`[**${cta} →**](${url})`, "", credit, "", "---"); + lines.push(`[**${cta} →**](${url})`, ""); + const host = destinationHost(clickUrl, creative); + lines.push(`*${label}${host ? ` · ${mdEsc(host)}` : ""} · ${mdEsc(ATTRIBUTION)}*`); return lines.join("\n"); } @@ -464,6 +497,30 @@ export function renderFeedText( .join("\n"); } +/** + * The host a reader will end up on, for the brand line. + * + * Our click URL is a redirector, so its host is always crawlproof.com and is + * useless here — the advertiser's own domain is what the reader wants to see. + * It is not on the creative, so this falls back to the logo's host, which for + * essentially every campaign is the advertiser's own CDN or site. Returns "" + * rather than guessing when there is nothing trustworthy to show. + */ +export function destinationHost(clickUrl: string, creative: AdCreative): string { + for (const candidate of [creative.logoUrl, creative.imageUrl]) { + if (!candidate) continue; + try { + const host = new URL(candidate).hostname.replace(/^www\./, ""); + // Our own storage tells the reader nothing about who is advertising. + if (/crawlproof\.com$|supabase\.(co|in)$/i.test(host)) continue; + return host; + } catch { + // Not a URL we can read a host out of; try the next one. + } + } + return ""; +} + /** Escape the Markdown punctuation that would otherwise reformat ad copy. */ function mdEsc(v: string): string { return String(v ?? "").replace(/([\\`*_[\]()<>#+\-!|])/g, "\\$1"); diff --git a/supabase/migrations/20260818160000_ad_feed_item_artwork.sql b/supabase/migrations/20260818160000_ad_feed_item_artwork.sql new file mode 100644 index 0000000..1851b84 --- /dev/null +++ b/supabase/migrations/20260818160000_ad_feed_item_artwork.sql @@ -0,0 +1,34 @@ +-- Give feed creatives their artwork back. +-- +-- 20260818120000_ad_feed_item.sql deliberately dropped image_url when it +-- backfilled the feed creatives, on the reasoning that the default feed body is +-- a single sponsored line and would never render artwork. +-- +-- That reasoning was right about the `text` style and wrong about the format. +-- A feed item sits between real blog posts, each of which has a title, a +-- picture and several paragraphs, and a bare line of text does not read as +-- restrained next to them — it reads as broken, and gets skipped. The `card` +-- style renders a banner, and rssamplifier.com now asks for it, so the column +-- has to carry the image the advertiser already has. +-- +-- Source is the campaign's medium rectangle, which is the creative the hero +-- image was resolved for (see lib/ads/heroImage.ts) and the only format that +-- reliably has one: 96 of 106 campaigns. The other 10 stay null and render the +-- card without a picture, which is why the renderer treats the image as +-- optional rather than assuming it. +-- +-- Only touches rows the backfill created and left null, so a creative whose +-- image was set by hand afterwards is not overwritten. +-- +-- NOTE: prod migration history diverged — apply this single file via psql over +-- the pooler or the Supabase MCP, do NOT `supabase db push`. + +update public.ad_creatives f + set image_url = r.image_url + from public.ad_creatives r + where f.format = 'feed_item' + and f.image_url is null + and r.campaign_id = f.campaign_id + and r.format = 'banner_300x250' + and r.image_url is not null + and f.ai_provenance ->> 'source' = 'backfill_feed_item'; diff --git a/tests/ads-feed-item.test.ts b/tests/ads-feed-item.test.ts index b4deef1..29d72d4 100644 --- a/tests/ads-feed-item.test.ts +++ b/tests/ads-feed-item.test.ts @@ -3,6 +3,7 @@ import { XMLParser, XMLValidator } from "fast-xml-parser"; import { ATTRIBUTION, adGuid, + destinationHost, cdata, ctaLabel, feedDeviceType, @@ -233,11 +234,20 @@ describe("identity", () => { describe("disclosure", () => { it("is in the title, where a reader listing titles will see it", () => { - expect(feedTitle(creative)).toBe("[Sponsored] Ship faster with Widgets"); + expect(feedTitle(creative)).toBe("Ship faster with Widgets (Sponsored)"); + }); + + // Leading "[Sponsored]" is the first thing the eye meets in a list of + // headlines and reads as a spam subject-line prefix, so the item is skipped + // before the offer is read. The label travels just as far at the end. + it("does not lead with the label", () => { + expect(feedTitle(creative).startsWith("[")).toBe(false); + expect(feedTitle(creative).startsWith("Sponsored")).toBe(false); + expect(feedTitle(creative)).toContain("Sponsored"); }); it("can be reworded but not removed", () => { - expect(feedTitle(creative, "Ad")).toBe("[Ad] Ship faster with Widgets"); + expect(feedTitle(creative, "Ad")).toBe("Ship faster with Widgets (Ad)"); expect(labelText("")).toBe("Sponsored"); expect(labelText(" ")).toBe("Sponsored"); expect(labelText(null)).toBe("Sponsored"); @@ -300,6 +310,54 @@ describe("bodies", () => { ); }); + // The card sits between real blog posts that each have a picture and a few + // paragraphs; a bare line next to them reads as broken rather than restrained. + it("leads the card with the advertiser's artwork", () => { + const withArt = { ...creative, imageUrl: "https://widgets.example/hero.png" }; + const html = renderFeedHtml(withArt, input.clickUrl, { style: "card" }); + + expect(html).toContain('src="https://widgets.example/hero.png"'); + // Width only: readers scale to their own column, and a fixed height would + // distort every image that is not exactly the ratio we guessed. + expect(html).toContain('width="600"'); + expect(html).not.toMatch(/]*hero\.png[^>]*height=/); + // The picture comes before the headline, the way the posts around it do. + expect(html.indexOf("hero.png")).toBeLessThan(html.indexOf("

")); + }); + + it("renders a card without artwork rather than a broken image", () => { + const noArt = { ...creative, imageUrl: null }; + const html = renderFeedHtml(noArt, input.clickUrl, { style: "card" }); + expect(html).not.toContain(""); + expect(html).toContain(ATTRIBUTION); + }); + + it("names who is paying, which is what a reader actually decides on", () => { + const html = renderFeedHtml(creative, input.clickUrl, { style: "card" }); + expect(html).toContain("widgets.example"); + }); + + it("never offers our own redirector as the advertiser's identity", () => { + // The click URL is always crawlproof.com, so it can never be the brand + // line, and neither can artwork we host on the advertiser's behalf. + const ours = { + ...creative, + logoUrl: "https://crawlproof.com/ads/house/promo.webp", + imageUrl: null, + }; + expect(destinationHost(input.clickUrl, ours)).toBe(""); + expect(destinationHost(input.clickUrl, creative)).toBe("widgets.example"); + }); + + it("puts the same artwork in the markdown card", () => { + const withArt = { ...creative, imageUrl: "https://widgets.example/hero.png" }; + const md = renderFeedMarkdown(withArt, input.clickUrl, { style: "card" }); + expect(md).toContain("!["); + expect(md).toContain("https://widgets.example/hero.png"); + expect(md).toContain("widgets.example"); + }); + it("does not double the arrow on copy that already has one", () => { expect(ctaLabel("Try it free →")).toBe("Try it free"); expect(ctaLabel("Learn more ->")).toBe("Learn more"); @@ -347,7 +405,7 @@ describe("json shapes", () => { // The raw headline, without the disclosure prefix baked in, so a consumer // that discloses its own way is not forced to string-strip ours. expect(f.headline).toBe("Ship faster with Widgets"); - expect(f.title).toBe("[Sponsored] Ship faster with Widgets"); + expect(f.title).toBe("Ship faster with Widgets (Sponsored)"); expect(f.tier).toBe("paid"); }); });