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
89 changes: 73 additions & 16 deletions lib/ads/feeditem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<category>` 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)})`;
}

/**
Expand Down Expand Up @@ -376,19 +383,41 @@ export function renderFeedHtml(
}

if (style === "card") {
const parts = [`<hr />`, `<p><strong>${esc(label)}</strong></p>`];
// 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(
`<p>${link(`<img src="${esc(creative.logoUrl)}" alt="" width="120" height="32" />`)}</p>`,
`<p>${link(`<img src="${esc(creative.imageUrl)}" alt="${headline}" width="600" />`)}</p>`,
);
}
parts.push(`<p>${link(`<strong>${headline}</strong>`)}</p>`);

parts.push(`<h3>${link(headline)}</h3>`);
if (body) parts.push(`<p>${body}</p>`);
parts.push(`<p>${link(`<strong>${cta} &#8594;</strong>`)}</p>`);
parts.push(`<p><small>${credit}</small></p>`, `<hr />`);

// 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
? `<img src="${esc(creative.logoUrl)}" alt="" height="20" /> `
: "";
parts.push(
`<p><small>${mark}${esc(label)}${host ? ` &#183; ${esc(host)}` : ""} &#183; ${credit}</small></p>`,
);

return parts.join("\n");
}

Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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");
Expand Down
34 changes: 34 additions & 0 deletions supabase/migrations/20260818160000_ad_feed_item_artwork.sql
Original file line number Diff line number Diff line change
@@ -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';
64 changes: 61 additions & 3 deletions tests/ads-feed-item.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { XMLParser, XMLValidator } from "fast-xml-parser";
import {
ATTRIBUTION,
adGuid,
destinationHost,
cdata,
ctaLabel,
feedDeviceType,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(/<img[^>]*hero\.png[^>]*height=/);
// The picture comes before the headline, the way the posts around it do.
expect(html.indexOf("hero.png")).toBeLessThan(html.indexOf("<h3>"));
});

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("<img src=\"\"");
expect(html).toContain("<h3>");
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");
Expand Down Expand Up @@ -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");
});
});
Expand Down
Loading