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