From 6ee353a6323c4811bab5c4eaac6c2105bb561f80 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 18 Aug 2026 16:11:38 +0000 Subject: [PATCH] Teach the ad generator to write prose, for ads that live inside content Every format so far renders the same tiny copy set: a headline, one benefit line averaging 76 characters, and a call to action. That is the right size for a 300x250 box and far too little for a placement that sits *inside* somebody's writing -- a sponsored paragraph in a blog post, or the body of a feed item, where the ad is read rather than glanced at. So the generator now also writes two lengths of editorial prose from the destination site it already fetches for colours and copy: summary_short one or two sentences -- the inline mention summary_long two or three paragraphs -- the blog-post form The register is the point, and it is stated explicitly in the prompt: third person, factual, no second person, no call to action, no hype adjectives, "the way a journalist would describe the product in one line of a round-up". Without that instruction the model returns three restatements of the headline, which is useless -- ad voice is exactly what makes a sponsored paragraph read as an intrusion and get skipped. The no-invention rule is restated harder for these than for the display copy, because more room is more room to fabricate: if the page does not say who it is for or what it costs, neither do we. **The domain decides what is stored.** summary_domain records which site the prose was written from. A user can paste a URL, get a preview, then edit the URL before saving, and prose confidently describing the first site is worse than no prose at all. So it is only stored when the domain it was written from matches where the campaign points, and serving re-checks the same thing on read: a mismatch is treated as absent and the ad falls back to its short creative body. Regeneration re-derives the domain from the campaign's current URL, since a changed destination is exactly when a summary goes stale. Rendering: a new feed style `article` -- artwork, heading, the real paragraphs, the call to action, and a disclosure line -- in HTML, Markdown and plain text. `card` now prefers the short summary over the banner line. Both fall back to the old behaviour for the campaigns generated before this existed, because an "article" carrying a single line is just a worse card. Disclosure is strongest in the article form, deliberately. The whole point of the long form is that it reads like editorial, so the line saying it is paid for is the only thing distinguishing it from the post above it. Two structural choices worth keeping: The summary lookup is its own query rather than two more columns on the creative join in serveAd. That join is *the* serving query -- if it fails, every unit on every slot goes dark -- and these columns sit behind an `add column if not exists` in a migration applied by hand, so a deploy can run ahead of the schema. One extra round trip on the feed path, which is one fetch per publisher build rather than one per reader, is a cheap price for not being able to take banner and terminal serving down with it. The write path is resilient for the same reason: an unknown column retries without the summary rather than refusing to create the campaign. A test caught a real bug on the way: `^\s*` in the markdown-stripping regex also matches the newline *before* the anchor under the m flag, so stripping a heading or bullet swallowed the blank line separating it from the previous paragraph and silently merged two paragraphs into one. Co-Authored-By: Claude Opus 5 (1M context) --- app/actions/ads.ts | 97 ++++++++++++- app/api/ads/feed/route.ts | 14 +- lib/ads/creative.ts | 110 ++++++++++++++- lib/ads/feeditem.ts | 130 ++++++++++++++++-- lib/ads/serve.ts | 45 ++++++ lib/ads/snippets.ts | 54 +++++++- .../20260818180000_ad_campaign_summaries.sql | 40 ++++++ tests/ads-feed-item.test.ts | 96 +++++++++++++ tests/ads-summary-domain.test.ts | 57 ++++++++ 9 files changed, 626 insertions(+), 17 deletions(-) create mode 100644 supabase/migrations/20260818180000_ad_campaign_summaries.sql create mode 100644 tests/ads-summary-domain.test.ts diff --git a/app/actions/ads.ts b/app/actions/ads.ts index 5fe9d3e..a0aef4a 100644 --- a/app/actions/ads.ts +++ b/app/actions/ads.ts @@ -8,8 +8,11 @@ import { getOrCreateDefaultOrg } from "@/lib/orgs"; import { generateAdCreatives, AD_FORMAT_IDS, + cleanSummary, + summaryDomain, type AdCreative, type AdFormatId, + type AdSummary, } from "@/lib/ads/creative"; import type { SiteBrand } from "@/lib/ads/brand"; import { MIN_PAYOUT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing"; @@ -26,10 +29,55 @@ function domainOf(url: string): string { } } +/** + * The summary columns to write, or {} when there is nothing trustworthy. + * + * Two guards, and the second is the point of the whole feature. Prose is only + * stored when it is non-empty *and* when the domain it was written from is the + * domain the campaign now points at — the user can paste one URL, get a + * preview, then edit the URL before saving, and a summary describing the first + * site would be a confident description of the wrong product. + * + * The lengths are re-clamped here rather than trusted from the client: this is + * a server action and its input crosses the network. + * + * @param summary what the generator produced (or the client sent back) + * @param domain the campaign's destination domain, normalised + */ +function summaryPayload( + summary: Partial | null | undefined, + domain: string, +): Record { + if (!summary) return {}; + + const short = cleanSummary(summary.short, 400); + const long = cleanSummary(summary.long, 1600); + if (!short && !long) return {}; + + // summaryDomain() normalises a URL; the value here is already a bare host, so + // compare it directly and case-insensitively. + const wrote = String(summary.domain ?? "").toLowerCase(); + if (!wrote || wrote !== domain.toLowerCase()) return {}; + + return { + summary_short: short || null, + summary_long: long || null, + summary_domain: domain.toLowerCase(), + summary_generated_at: new Date().toISOString(), + }; +} + // Auto-generate ad creatives from a destination URL. No DB write — the client // holds the result, lets the user edit/replace, then calls saveCampaign. export async function previewAds(input: { url: string }): Promise< - | { ok: true; brand: SiteBrand; creatives: AdCreative[]; provider: string; suggestedName: string } + | { + ok: true; + brand: SiteBrand; + creatives: AdCreative[]; + provider: string; + suggestedName: string; + summary: AdSummary; + } | { ok: false; error: string } > { const supabase = await createClient(); @@ -42,13 +90,16 @@ export async function previewAds(input: { url: string }): Promise< if (!check.ok) return { ok: false, error: check.reason }; try { - const { brand, creatives, provider } = await generateAdCreatives(check.url, { supabase }); + const { brand, creatives, provider, summary } = await generateAdCreatives(check.url, { + supabase, + }); return { ok: true, brand, creatives, provider, suggestedName: brand.title?.slice(0, 60) || domainOf(check.url), + summary, }; } catch (err) { return { @@ -89,6 +140,7 @@ export async function saveCampaign(input: { bidCredits?: number; brand?: SiteBrand | null; creatives: Partial[]; + summary?: Partial | null; }): Promise<{ ok: true; id: string; refSlug: string } | { ok: false; error: string }> { const supabase = await createClient(); const { @@ -126,6 +178,13 @@ export async function saveCampaign(input: { }; if (org.id) payload.organization_id = org.id; + // Editorial prose for placements that live inside content. Only stored when + // it actually describes where the campaign points: the user can edit the URL + // between preview and save, and prose about a different site is worse than + // none. Same reason serving re-checks it (see summaryFor in lib/ads/serve). + const summaryFields = summaryPayload(input.summary, domainOf(check.url)); + Object.assign(payload, summaryFields); + let campaign = await supabase .from("ad_campaigns") .insert(payload) @@ -144,6 +203,20 @@ export async function saveCampaign(input: { .select("id, ref_slug") .single(); } + + // The summary columns live behind an `add column if not exists` and + // migrations here are applied by hand, so this deploy can briefly run ahead + // of the schema. Retry without them rather than refusing to create the + // campaign: prose is an enhancement, a campaign that cannot be saved is the + // whole product failing. Same trade the impression short_code makes. + if (campaign.error && /summary_|schema cache|column/i.test(campaign.error.message ?? "")) { + for (const key of Object.keys(summaryFields)) delete payload[key]; + campaign = await supabase + .from("ad_campaigns") + .insert(payload) + .select("id, ref_slug") + .single(); + } if (campaign.error || !campaign.data) { return { ok: false, error: campaign.error?.message ?? "Failed to save campaign." }; } @@ -278,6 +351,26 @@ export async function regenerateCampaign(input: { }; } + // Refresh the editorial prose alongside the creatives. Regenerating is + // exactly when a stale summary gets fixed — the destination may well be what + // changed — so the domain is re-derived from the campaign's current URL + // rather than from whatever the summary previously claimed. + const summaryFields = summaryPayload( + generated.summary, + summaryDomain(campaign.destination_url), + ); + if (Object.keys(summaryFields).length > 0) { + const { error: sErr } = await supabase + .from("ad_campaigns") + .update(summaryFields) + .eq("id", input.id); + // A missing column (deploy ahead of the hand-applied migration) must not + // fail a regeneration whose creatives are fine. + if (sErr && !/summary_|schema cache|column/i.test(sErr.message ?? "")) { + return { ok: false, error: sErr.message }; + } + } + const { data: existing } = await supabase .from("ad_creatives") .select("id, format") diff --git a/app/api/ads/feed/route.ts b/app/api/ads/feed/route.ts index 38766d2..7d5da8f 100644 --- a/app/api/ads/feed/route.ts +++ b/app/api/ads/feed/route.ts @@ -16,7 +16,10 @@ // bare curl still rotates real campaigns. // as wire shape: rss | atom | json | html | markdown | text | fields. // Default rss. See lib/ads/feeditem for what each one is for. -// style body style: text | card | terminal. Default text — the long thin one. +// style body style: text | card | terminal | article. Default text — the long +// thin one. `article` renders the campaign's editorial summary as real +// paragraphs, for an ad that lives inside a blog post; it falls back to +// `card` for campaigns with no prose. // guid identity rotation: daily | weekly | fill | static. Default daily. // n how many ads to return, 1..5. Each is an independent fill with its // own impression and its own identity. @@ -33,7 +36,7 @@ // go through the ordinary redirector and are metered per click. import { NextRequest, NextResponse } from "next/server"; -import { serveAd } from "@/lib/ads/serve"; +import { campaignSummary, serveAd } from "@/lib/ads/serve"; import { houseFill } from "@/lib/ads/house"; import { FEED_FORMAT_ID } from "@/lib/ads/formats"; import { @@ -187,9 +190,16 @@ export async function GET(request: NextRequest) { const isHouse = fill.campaignId === "house"; const clickUrl = src && isHouse ? withParam(fill.clickUrl, "s", src) : fill.clickUrl; + // Editorial prose, when the campaign has any that still describes where + // it points. Its own query, deliberately not part of the serving join — + // see campaignSummary. Best-effort: a null here just renders the short + // creative body, which is what every campaign did before this existed. + const summary = await campaignSummary(fill.campaignId); + inputs.push({ creative: fill.creative, clickUrl, + summary, slotId: slotId || "default", impressionId: fill.impressionId, tier: fill.tier, diff --git a/lib/ads/creative.ts b/lib/ads/creative.ts index 3016294..df03366 100644 --- a/lib/ads/creative.ts +++ b/lib/ads/creative.ts @@ -9,6 +9,9 @@ import { extractSiteBrand, type SiteBrand } from "./brand"; import { resolveAdHeroImage } from "./heroImage"; import { renderCreativeText, renderTerminalHtml } from "./terminal"; import { renderFeedHtml } from "./feeditem"; +// One implementation, next to the renderers that consume it. Re-exported so a +// server-side caller working with summaries has a single import. +export { summaryParagraphs } from "./feeditem"; import { AD_FORMATS, AD_FORMAT_IDS, @@ -60,6 +63,25 @@ const CopySchema = z.object({ bgColor: z.string().describe("Background hex like #0b0d10 — on-brand, good contrast with fg."), fgColor: z.string().describe("Text hex with strong contrast against bg."), accentColor: z.string().describe("Accent/CTA hex — the brand's signature colour if visible."), + // The two prose lengths. Everything above is display copy sized for a box; + // these are for placements that sit *inside* somebody's writing, where the ad + // is read rather than glanced at. + summaryShort: z + .string() + .max(400) + .describe( + "One or two plain sentences saying what this is and who it is for, in third person. " + + "Reads as an editorial note, not a slogan — no exclamation marks, no second person, no CTA.", + ), + summaryLong: z + .string() + .max(1600) + .describe( + "Two or three short paragraphs, separated by a blank line, for a sponsored section of a " + + "blog post. Third person, factual, specific to this product. Describe what it does, who " + + "it is for, and what is distinctive — only from the page content. No headings, no lists, " + + "no markdown, no links, no invented metrics or prices.", + ), }); type AdCopy = z.infer; @@ -72,6 +94,20 @@ const SYSTEM_PROMPT = [ "invent features, prices, or claims. Keep it concrete and specific to this product.", "Colours must be readable: high contrast between background and foreground.", "Prefer the site's real brand/accent colour when the palette makes it obvious.", + // The summaries are a different register from the display copy, and saying so + // explicitly is what stops the model returning three restatements of the + // headline. They run inside other people's writing, next to their prose, so + // ad voice is exactly what makes them read as an intrusion and get skipped. + "ALSO write two editorial summaries of the advertiser, in a different register", + "from the ad copy above: third person, calm, factual, the way a journalist would", + "describe the product in one line of a round-up. No slogans, no second person, no", + "calls to action, no exclamation marks, no hype adjectives ('revolutionary',", + "'game-changing', 'seamless'). summaryShort is one or two sentences. summaryLong", + "is two or three short paragraphs separated by blank lines.", + "The same no-invention rule binds them, and binds them harder: these are longer,", + "so there is more room to fabricate. Every fact must come from the page content.", + "If the page does not say who it is for, or what it costs, or how it works, then", + "neither do you — write less rather than filling the space.", ].join(" "); function buildUserPrompt(brand: SiteBrand): string { @@ -111,10 +147,71 @@ function copyToCreatives(brand: SiteBrand, copy: AdCopy, heroUrl: string | null) })); } +/** + * Editorial prose about the advertiser, in two lengths, plus the domain it was + * written from. + * + * `domain` is what makes the pair trustworthy later: a campaign's destination + * can be edited after generation, and prose describing a site the campaign no + * longer points at is worse than no prose at all. Serving compares this against + * the campaign's current domain and treats a mismatch as absent. + */ +export type AdSummary = { + short: string; + long: string; + domain: string; +}; + +/** The host a summary describes, normalised the way ad_campaigns stores it. */ +export function summaryDomain(rawUrl: string): string { + try { + return new URL(rawUrl).hostname.replace(/^www\./, "").toLowerCase(); + } catch { + return ""; + } +} + +/** + * Tidy a generated summary. + * + * Collapses the runs of blank lines a model likes to emit into single paragraph + * breaks, strips any markdown it reached for despite being told not to, and + * caps the length. Returns "" when there is nothing usable, which every caller + * treats as "no summary" rather than rendering an empty paragraph. + */ +export function cleanSummary(v: unknown, maxLen: number): string { + const text = String(v ?? "") + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "") + // Markdown emphasis/heading/list marks: the summaries are rendered as HTML + // and as plain text, and neither wants a stray asterisk. + // [ \t] rather than \s: with the m flag, \s also matches the newline + // *before* the anchor, so stripping a heading or a bullet swallowed the + // blank line that separated it from the previous paragraph and silently + // merged the two. + .replace(/^[ \t]*#{1,6}[ \t]+/gm, "") + .replace(/^[ \t]*[-*+][ \t]+/gm, "") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/(^|\s)[*_](\S[^*_]*?)[*_](?=\s|$)/g, "$1$2") + .replace(/\r\n?/g, "\n") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .split("\n") + .map((line) => line.trim()) + .join("\n") + .trim(); + return text.slice(0, maxLen); +} + export async function generateAdCreatives( rawUrl: string, opts: { supabase?: SupabaseClient } = {}, -): Promise<{ brand: SiteBrand; creatives: AdCreative[]; provider: string }> { +): Promise<{ + brand: SiteBrand; + creatives: AdCreative[]; + provider: string; + summary: AdSummary; +}> { const brand = await extractSiteBrand(rawUrl); const anthropic = env.anthropicApiKey ? new Anthropic({ apiKey: env.anthropicApiKey }) : null; @@ -149,7 +246,16 @@ export async function generateAdCreatives( heroUrl = hero?.url ?? null; } - return { brand, creatives: copyToCreatives(brand, output, heroUrl), provider }; + // The summaries are written from the page we just read, so the domain they + // describe is that page's — not whatever the campaign's destination is edited + // to later. Recording it here is what lets serving decide the prose is stale. + const summary: AdSummary = { + short: cleanSummary(output.summaryShort, 400), + long: cleanSummary(output.summaryLong, 1600), + domain: summaryDomain(brand.url || rawUrl), + }; + + return { brand, creatives: copyToCreatives(brand, output, heroUrl), provider, summary }; } // Append the campaign ref for click attribution, preserving any existing query. diff --git a/lib/ads/feeditem.ts b/lib/ads/feeditem.ts index 9622d65..3c87be7 100644 --- a/lib/ads/feeditem.ts +++ b/lib/ads/feeditem.ts @@ -56,11 +56,18 @@ export type FeedShape = (typeof FEED_SHAPES)[number]; * How much of the ad the body carries. * * `text` is the long thin one — a single sponsored line that reads as a line of - * the feed rather than an interruption of it. `card` adds the logo and puts the - * call to action on its own line. `terminal` is the ASCII box, for feeds whose - * readers are developers. + * the feed rather than an interruption of it. `card` adds the artwork and puts + * the call to action on its own line. `terminal` is the ASCII box, for feeds + * whose readers are developers. + * + * `article` is the long form: the campaign's editorial summary rendered as real + * paragraphs, for a placement that sits inside somebody's writing — a sponsored + * section of a blog post, or a feed whose items are read rather than scanned. + * It is the only style that needs data beyond the creative (see + * `FeedItemInput.summary`), and it degrades to `card` when a campaign has no + * prose, because an "article" with one line in it is just a worse card. */ -export const FEED_STYLES = ["text", "card", "terminal"] as const; +export const FEED_STYLES = ["text", "card", "terminal", "article"] as const; export type FeedStyle = (typeof FEED_STYLES)[number]; /** How the item's identity rotates. See `adGuid`. */ @@ -319,10 +326,25 @@ export type FeedRenderOpts = { guidMode?: GuidMode; /** Box width for the terminal style. */ cols?: number; + /** + * The campaign's editorial prose. `card` prefers the short form over the + * creative's one-line body; `article` is built from the long form. Threaded + * through opts rather than the creative because it belongs to the campaign, + * not to a format — every creative of a campaign shares one. + */ + summary?: { short: string | null; long: string | null } | null; /** Clock injection, for tests. */ now?: Date; }; +/** Paragraphs of a long summary, cleaned. Empty when there is no prose. */ +export function summaryParagraphs(long: string | null | undefined): string[] { + return String(long ?? "") + .split(/\n\s*\n/) + .map((p) => oneLine(p)) + .filter(Boolean); +} + /** Disclosure wording, sanitised. Empty input falls back rather than removing it. */ export function labelText(v: string | null | undefined): string { const s = oneLine(v).replace(/[[\]]/g, "").slice(0, 24); @@ -382,7 +404,43 @@ export function renderFeedHtml( ].join("\n"); } - if (style === "card") { + // The long form. Real paragraphs of editorial prose, for a placement that + // sits inside somebody's writing. Falls through to the card when a campaign + // has no long summary: an "article" carrying a single line is just a worse + // card, and every campaign generated before this feature has none. + if (style === "article") { + const paragraphs = summaryParagraphs(opts.summary?.long); + if (paragraphs.length > 0) { + const parts: string[] = []; + + if (creative.imageUrl) { + parts.push( + `

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

`, + ); + } + parts.push(`

${link(headline)}

`); + // The prose is the body here, not the one-line creative copy. It is + // third-person editorial written from the advertiser's own site, which is + // what lets it sit next to a blog post without reading as an intrusion. + for (const paragraph of paragraphs) parts.push(`

${esc(paragraph)}

`); + parts.push(`

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

`); + + const host = destinationHost(clickUrl, creative); + const mark = creative.logoUrl + ? ` ` + : ""; + // Disclosure matters more here than anywhere else in this file: the whole + // point of the long form is that it reads like editorial, so the line + // saying it is paid for is the only thing distinguishing it from the + // post above it. + parts.push( + `

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

`, + ); + return parts.join("\n"); + } + } + + if (style === "card" || style === "article") { // 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 @@ -403,7 +461,11 @@ export function renderFeedHtml( } parts.push(`

${link(headline)}

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

${body}

`); + // The short summary when the campaign has one: it is a written sentence + // rather than a 76-character banner line, which is what the item needs when + // it sits between real posts. The creative body is the fallback. + const prose = esc(oneLine(opts.summary?.short)) || body; + if (prose) parts.push(`

${prose}

`); parts.push(`

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

`); // The brand line: the logo where there is one, and always the destination @@ -459,13 +521,29 @@ export function renderFeedMarkdown( return [`**${label}**`, "", "```", art, "```", "", credit].join("\n"); } - if (style === "card") { + // Long form, for a Markdown-templated blog post or newsletter. + if (style === "article") { + const paragraphs = summaryParagraphs(opts.summary?.long); + if (paragraphs.length > 0) { + const lines: string[] = []; + if (creative.imageUrl) lines.push(`[![${headline}](${mdUrl(creative.imageUrl)})](${url})`, ""); + lines.push(`### [${headline}](${url})`, ""); + for (const paragraph of paragraphs) lines.push(mdEsc(paragraph), ""); + lines.push(`[**${cta} →**](${url})`, ""); + const host = destinationHost(clickUrl, creative); + lines.push(`*${label}${host ? ` · ${mdEsc(host)}` : ""} · ${mdEsc(ATTRIBUTION)}*`); + return lines.join("\n"); + } + } + + if (style === "card" || style === "article") { // 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, ""); + const prose = mdEsc(oneLine(opts.summary?.short)) || body; + if (prose) lines.push(prose, ""); lines.push(`[**${cta} →**](${url})`, ""); const host = destinationHost(clickUrl, creative); lines.push(`*${label}${host ? ` · ${mdEsc(host)}` : ""} · ${mdEsc(ATTRIBUTION)}*`); @@ -490,8 +568,25 @@ export function renderFeedText( return renderCreativeText(creative, clickUrl, { cols: opts.cols, color: false }); } const headline = oneLine(creative.headline); - const body = oneLine(creative.body); const cta = ctaLabel(creative.ctaText); + + // The long form as plain prose, paragraphs separated by blank lines. + const paragraphs = (opts.style ?? "text") === "article" + ? summaryParagraphs(opts.summary?.long) + : []; + if (paragraphs.length > 0) { + return [ + `[${label}] ${headline}`, + "", + paragraphs.join("\n\n"), + "", + `${cta}: ${clickUrl}`, + `-- ${ATTRIBUTION} (${ATTRIBUTION_URL})`, + ].join("\n"); + } + + // Short summary where there is one, else the creative's own line. + const body = oneLine(opts.summary?.short) || oneLine(creative.body); return [`[${label}] ${headline}`, body, `${cta}: ${clickUrl}`, `-- ${ATTRIBUTION} (${ATTRIBUTION_URL})`] .filter(Boolean) .join("\n"); @@ -557,10 +652,20 @@ export type FeedItemInput = { tier?: string; /** 0-based place in a multi-ad request. Namespaces the guid — see `adGuid`. */ position?: number; + /** + * The campaign's editorial prose, when it still describes the destination. + * `card` prefers the short form over the creative's one-line body, and + * `article` is built from the long form. Absent is ordinary, not an error. + */ + summary?: { short: string | null; long: string | null } | null; }; /** Everything the shapes below are rendered from, computed once. */ function assemble(input: FeedItemInput, opts: FeedRenderOpts) { + // The prose belongs to the campaign and arrives on the input; the body + // renderers read it off opts, so merge it in once here rather than at each + // of the three call sites below. + if (input.summary && !opts.summary) opts = { ...opts, summary: input.summary }; const { guid, published } = adGuid(opts.guidMode ?? "daily", { slotId: input.slotId, impressionId: input.impressionId, @@ -694,6 +799,13 @@ export function feedFields( logoUrl: c.logoUrl, imageUrl: c.imageUrl, colors: { bg: c.bgColor, fg: c.fgColor, accent: c.accentColor }, + // Editorial prose about the advertiser, for a publisher writing the ad into + // their own content rather than rendering ours. Both are null for campaigns + // that predate the feature or whose destination has since been edited, so a + // consumer must treat them as optional and fall back to `body`. + summaryShort: oneLine(input.summary?.short) || null, + summaryLong: input.summary?.long || null, + summaryParagraphs: summaryParagraphs(input.summary?.long), html: a.html, markdown: a.markdown, text: a.text, diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index 9b3a31e..336cfc8 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -318,6 +318,51 @@ export async function serveAd( }; } +/** + * A campaign's editorial prose, when it still describes where the campaign points. + * + * Deliberately a separate query rather than two more columns on the creative + * join in serveAd. That join is *the* serving query — if it fails, every unit + * on every slot goes dark — and these columns sit behind an `add column if not + * exists` in a migration applied by hand, so a deploy can briefly run ahead of + * the schema. One extra round trip on the feed path is a cheap price for not + * being able to take banner and terminal serving down with it. Feed fills are + * rare anyway: one per publisher build, not one per reader. + * + * Returns null when the columns are missing, the row is gone, the prose is + * empty, or `summary_domain` disagrees with `destination_domain` — a campaign's + * destination can be edited after generation, and prose confidently describing + * a site the campaign no longer points at is worse than no prose. + */ +export async function campaignSummary( + campaignId: string, +): Promise<{ short: string | null; long: string | null } | null> { + if (!campaignId || campaignId === "house") return null; + try { + const sb = serviceClient(); + const { data, error } = await sb + .from("ad_campaigns") + .select("summary_short, summary_long, summary_domain, destination_domain") + .eq("id", campaignId) + .maybeSingle(); + if (error || !data) return null; + + const short = (data.summary_short as string | null) ?? null; + const long = (data.summary_long as string | null) ?? null; + if (!short && !long) return null; + + const wrote = String(data.summary_domain ?? "").toLowerCase(); + const points = String(data.destination_domain ?? "").toLowerCase(); + // No recorded domain means we cannot show it is still accurate. + if (!wrote || (points && wrote !== points)) return null; + + return { short, long }; + } catch { + // Unknown column, network, anything — the ad renders from its short body. + return null; + } +} + // Resolve a click: record it, return the destination URL (with ?ref=) to // redirect to. Returns null if the campaign/creative can't be resolved. export async function resolveClick(input: { diff --git a/lib/ads/snippets.ts b/lib/ads/snippets.ts index 7eaa667..c9b5bdc 100644 --- a/lib/ads/snippets.ts +++ b/lib/ads/snippets.ts @@ -310,7 +310,7 @@ function feedSnippets(slotId: string, origin: string): Snippet[] { // No &v= here on purpose: the caller of this endpoint is a build, not a // reader, so a visitor id would identify the publisher's CI box rather // than a person and would make every impression look like one visitor. - note: "as=rss|atom|json|html|markdown|text|fields · style=text|card|terminal · guid=daily|weekly|fill|static · n=1..5", + note: "as=rss|atom|json|html|markdown|text|fields · style=text|card|terminal|article · guid=daily|weekly|fill|static · n=1..5", code: [ "# One RSS , ready to paste inside your :", `curl -fsS "${rssUrl}"`, @@ -749,6 +749,52 @@ function feedSnippets(slotId: string, origin: string): Snippet[] { `curl -fsS "${atomUrl}"`, ].join("\n"), }, + { + id: "blogpost", + label: "Blog post", + lang: "bash", + note: "summaryShort/summaryLong are written from the advertiser's own site; both are null on older campaigns, so fall back to `body`.", + code: [ + "# The long form: the advertiser's editorial summary as real paragraphs,", + "# for a sponsored section inside a post rather than a unit beside it.", + `curl -fsS "${feedAdUrl(origin, slotId, { as: "html", style: "article" })}"`, + "", + "# Writing the section yourself? Take the prose and template it:", + `curl -fsS "${fieldsUrl}" | jq -r '.items[0] | {summaryShort, summaryLong, url, label}'`, + "", + "# Markdown, for a static-site build:", + `curl -fsS "${feedAdUrl(origin, slotId, { as: "markdown", style: "article" })}"`, + "", + "# Disclose it. The long form reads like editorial on purpose, so the", + "# line saying it is paid for is the only thing telling it apart from", + "# the post above it -- keep the label the payload gives you.", + ].join("\n"), + }, + { + id: "sponsor-line", + label: "Sponsor line", + lang: "javascript", + note: "The one-sentence form, for a 'this post is sponsored by' note at the top or bottom of an article.", + code: [ + "// Pulls the short summary and renders a single disclosed sentence.", + "async function sponsorLine() {", + " try {", + ` const r = await fetch('${fieldsUrl}', { signal: AbortSignal.timeout(2000) });`, + " if (!r.ok) return '';", + " const ad = (await r.json()).items[0];", + " if (!ad) return '';", + "", + " // summaryShort is editorial prose about the advertiser; body is the", + " // 76-character banner line. Prefer the first, fall back to the second.", + " const prose = ad.summaryShort || ad.body;", + " return `

${ad.label}: ' +", + " `${ad.headline} — ${prose}

`;", + " } catch {", + " return '';", + " }", + "}", + ].join("\n"), + }, { id: "markdown", label: "Markdown", @@ -794,7 +840,11 @@ export function formatBlurb(format: AdFormatId): string { return "Fetched as plain text and printed by a shell — MOTDs, SSH banners, CLI tools."; } if (format === FEED_FORMAT_ID) { - return "Fetched at build time and spliced into a feed you generate — RSS, Atom, JSON Feed, newsletters."; + return ( + "Fetched at build time and spliced into a feed you generate — RSS, Atom, JSON Feed, " + + "newsletters. style=article renders the advertiser's editorial summary as paragraphs, " + + "for an ad inside a blog post." + ); } return "Rendered in the browser by ad.js inside an isolated iframe."; } diff --git a/supabase/migrations/20260818180000_ad_campaign_summaries.sql b/supabase/migrations/20260818180000_ad_campaign_summaries.sql new file mode 100644 index 0000000..84d91b8 --- /dev/null +++ b/supabase/migrations/20260818180000_ad_campaign_summaries.sql @@ -0,0 +1,40 @@ +-- Editorial summaries of the advertiser, for ads that live inside content. +-- +-- The display formats all render the same tiny copy set: a headline, one +-- benefit line of ~76 characters, and a call to action. That is the right size +-- for a 300x250 box and far too little for a placement that sits *inside* +-- somebody's writing — a sponsored paragraph in a blog post, or the long-form +-- body of a feed item, where the ad is read rather than glanced at. +-- +-- So a campaign now also carries prose, in two lengths: +-- +-- summary_short one or two sentences. The inline mention: enough for a card +-- body or a "this post is sponsored by" line. +-- summary_long a few short paragraphs. The blog-post form. +-- +-- These belong on the campaign rather than on a creative because they describe +-- the *advertiser*, not a format. Every creative of a campaign shares them, and +-- a campaign has exactly one destination to describe. +-- +-- summary_domain records which domain the prose was written from. That is what +-- makes the pair trustworthy over time: a campaign's destination_url can be +-- edited after the fact, and a summary describing a site the campaign no longer +-- points at is worse than no summary. Serving compares the two and treats a +-- mismatch as absent, so an edited destination quietly falls back to the short +-- creative body until the copy is regenerated. +-- +-- NOTE: prod migration history diverged — apply this single file via psql over +-- the pooler or the Supabase MCP, do NOT `supabase db push`. + +alter table public.ad_campaigns + add column if not exists summary_short text, + add column if not exists summary_long text, + add column if not exists summary_domain text, + add column if not exists summary_generated_at timestamptz; + +comment on column public.ad_campaigns.summary_short is + 'One or two sentences describing the advertiser, for an inline sponsored mention. Written from summary_domain.'; +comment on column public.ad_campaigns.summary_long is + 'A few short paragraphs, for a sponsored blog-post body. Written from summary_domain. Paragraphs are separated by a blank line.'; +comment on column public.ad_campaigns.summary_domain is + 'The domain the summaries were written from. Serving ignores the summaries when this does not match destination_domain.'; diff --git a/tests/ads-feed-item.test.ts b/tests/ads-feed-item.test.ts index 29d72d4..6ea3245 100644 --- a/tests/ads-feed-item.test.ts +++ b/tests/ads-feed-item.test.ts @@ -4,6 +4,7 @@ import { ATTRIBUTION, adGuid, destinationHost, + summaryParagraphs, cdata, ctaLabel, feedDeviceType, @@ -451,3 +452,98 @@ describe("rfc822", () => { expect(rfc822(new Date("2026-08-18T00:00:00.000Z"))).toBe("Tue, 18 Aug 2026 00:00:00 GMT"); }); }); + +describe("editorial summaries", () => { + const summary = { + short: "Widgets is a deployment tool for small teams that do not run a platform group.", + long: + "Widgets deploys applications from a single command, and rolls them back with another.\n\n" + + "It is aimed at teams without a dedicated platform group, where the person shipping is " + + "also the person on call.\n\n" + + "The rollback path is the same one used to deploy, so it is exercised on every release.", + }; + const withProse: FeedItemInput = { ...input, summary }; + + it("splits the long form into paragraphs", () => { + expect(summaryParagraphs(summary.long)).toHaveLength(3); + expect(summaryParagraphs(null)).toEqual([]); + expect(summaryParagraphs(" ")).toEqual([]); + // Blank-line runs are one break, not several empty paragraphs. + expect(summaryParagraphs("a\n\n\n\nb")).toEqual(["a", "b"]); + }); + + it("renders the long form as real paragraphs in an article", () => { + const html = renderFeedHtml(creative, input.clickUrl, { style: "article", summary }); + const paras = html.match(/

(?!"); + }); + + it("falls back to the card when a campaign has no long form", () => { + // Every campaign generated before this feature has none, and an "article" + // carrying one line is just a worse card. + const html = renderFeedHtml(creative, input.clickUrl, { style: "article", summary: null }); + expect(html).toContain("

"); + expect(html).toContain(creative.body); + }); + + it("prefers the short form over the banner line in a card", () => { + const html = renderFeedHtml(creative, input.clickUrl, { style: "card", summary }); + expect(html).toContain("do not run a platform group"); + expect(html).not.toContain(creative.body); + }); + + it("still discloses in the long form, where it matters most", () => { + // The whole point of the article style is that it reads like editorial, so + // the disclosure is the only thing telling it apart from the post above it. + const html = renderFeedHtml(creative, input.clickUrl, { style: "article", summary }); + expect(html).toContain("Sponsored"); + expect(html).toContain(ATTRIBUTION); + for (const rel of html.matchAll(/rel="([^"]*)"/g)) { + expect(rel[1]).toContain("sponsored"); + expect(rel[1]).toContain("nofollow"); + } + }); + + it("carries the long form through markdown and plain text", () => { + const md = renderFeedMarkdown(creative, input.clickUrl, { style: "article", summary }); + expect(md).toContain("### ["); + expect(md).toContain("also the person on call"); + + const text = renderFeedText(creative, input.clickUrl, { style: "article", summary }); + expect(text).toContain("also the person on call"); + expect(text).not.toMatch(/<[a-z]/i); + }); + + it("keeps an article well-formed inside a host channel", () => { + const doc = inRssChannel(renderRssItem(withProse, { style: "article", now: NOW })); + expect(XMLValidator.validate(doc)).toBe(true); + }); + + it("survives prose that is itself markup", () => { + const hostile: FeedItemInput = { + ...input, + summary: { short: "a > b", long: "Ends a section \n\nand ]]> too" }, + }; + const doc = inRssChannel(renderRssItem(hostile, { style: "article", now: NOW })); + expect(XMLValidator.validate(doc)).toBe(true); + const parsed = parser.parse(doc); + expect(Array.isArray(parsed.rss.channel.item)).toBe(false); + }); + + it("hands a consumer both lengths, and null when there are none", () => { + const f = feedFields(withProse, { now: NOW }) as Record; + expect(f.summaryShort).toContain("deployment tool"); + expect(String(f.summaryLong)).toContain("also the person on call"); + expect(f.summaryParagraphs).toHaveLength(3); + + const bare = feedFields(input, { now: NOW }) as Record; + expect(bare.summaryShort).toBeNull(); + expect(bare.summaryLong).toBeNull(); + expect(bare.summaryParagraphs).toEqual([]); + // The one-line creative copy is still there as the fallback. + expect(bare.body).toBe(creative.body); + }); +}); diff --git a/tests/ads-summary-domain.test.ts b/tests/ads-summary-domain.test.ts new file mode 100644 index 0000000..47c1b25 --- /dev/null +++ b/tests/ads-summary-domain.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { cleanSummary, summaryDomain, summaryParagraphs } from "@/lib/ads/creative"; + +// The summaries are prose a model wrote about a website, stored and then +// rendered inside somebody else's blog post weeks later. Two things therefore +// have to hold: the text must be safe to render, and it must still describe the +// site the campaign actually points at. The domain is what decides the second, +// which is why it is stored alongside rather than inferred at read time. + +describe("summaryDomain", () => { + it("normalises the way ad_campaigns stores a destination domain", () => { + expect(summaryDomain("https://www.widgets.example/pricing?a=1")).toBe("widgets.example"); + expect(summaryDomain("http://Widgets.Example")).toBe("widgets.example"); + expect(summaryDomain("https://docs.widgets.example/")).toBe("docs.widgets.example"); + }); + + it("returns empty for anything it cannot read a host from", () => { + // Empty is the safe answer: a summary with no recorded domain can never be + // shown to still match, so serving treats it as absent. + expect(summaryDomain("not a url")).toBe(""); + expect(summaryDomain("")).toBe(""); + }); +}); + +describe("cleanSummary", () => { + it("keeps paragraph breaks but collapses the runs a model emits", () => { + const out = cleanSummary("First para.\n\n\n\nSecond para.", 400); + expect(out).toBe("First para.\n\nSecond para."); + expect(summaryParagraphs(out)).toHaveLength(2); + }); + + it("strips markdown the model reached for despite being told not to", () => { + // The prose is rendered as HTML and as plain text; neither wants a stray + // asterisk, and a heading inside a sponsored paragraph is worse still. + expect(cleanSummary("## Heading\n\n- a bullet\n\n**bold** and *italic*", 400)).toBe( + "Heading\n\na bullet\n\nbold and italic", + ); + }); + + it("drops the control characters that would break an XML document", () => { + expect(cleanSummary("clean\u0000text\u0007here", 400)).toBe("cleantexthere"); + }); + + it("caps the length, because this is third-party text in our document", () => { + expect(cleanSummary("x".repeat(5000), 400)).toHaveLength(400); + }); + + it("returns empty for nothing usable, which callers treat as no summary", () => { + expect(cleanSummary(null, 400)).toBe(""); + expect(cleanSummary(" \n\n ", 400)).toBe(""); + expect(cleanSummary(undefined, 400)).toBe(""); + }); + + it("normalises newlines so paragraph splitting is not platform-dependent", () => { + expect(summaryParagraphs(cleanSummary("a\r\n\r\nb", 400))).toEqual(["a", "b"]); + }); +});