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