From b246339a2e436459f5554a6178fabd05faa14230 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 5 Sep 2026 22:44:27 +0000 Subject: [PATCH] Campaigns from the API fall back to the page's own copy when no model has credit generateAdCreatives needs an AI provider with balance; with both providers out, every campaign opened from the API or the CLI failed, so a blog post published through myna ran no ad. templateCopy() writes the creative set from the page's title, description and palette, and createCampaignForUrl uses it when the generator throws. The provider is recorded as template. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YafYxayh7Gqe5MWNNQMev2 --- lib/ads/campaigns.ts | 20 +++++++++++-- lib/ads/creative.ts | 43 +++++++++++++++++++++++++++ tests/ads-template-copy.test.ts | 51 +++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 tests/ads-template-copy.test.ts diff --git a/lib/ads/campaigns.ts b/lib/ads/campaigns.ts index 04eea18..f14841c 100644 --- a/lib/ads/campaigns.ts +++ b/lib/ads/campaigns.ts @@ -16,7 +16,8 @@ import { parseCampaignRequest, domainOf, type CampaignRequest, type CampaignStat export { parseCampaignRequest, domainOf, type CampaignRequest, type CampaignStatus }; import { getOrCreateDefaultOrg } from "@/lib/orgs"; -import { generateAdCreatives, cleanSummary, type AdCreative, type AdSummary } from "@/lib/ads/creative"; +import { generateAdCreatives, cleanSummary, creativesFromCopy, templateCopy, summaryDomain, type AdCreative, type AdSummary } from "@/lib/ads/creative"; +import { extractSiteBrand, type SiteBrand } from "@/lib/ads/brand"; import { DEFAULT_BID_CREDITS } from "@/lib/ads/pricing"; export type CampaignSummary = { @@ -116,11 +117,24 @@ export async function createCampaignForUrl(input: { }; } - let generated: Awaited>; + let generated: { brand: SiteBrand; creatives: AdCreative[]; summary: AdSummary | null; provider: string }; try { generated = await generateAdCreatives(request.url, { supabase: sb }); } catch (err) { - return { ok: false, status: 502, error: err instanceof Error ? `Could not write ads for that URL: ${err.message}` : "Could not write ads for that URL." }; + // No model with credit, or one that failed: the page's own words are the + // copy. A campaign that could not open would mean a post with no ad. + try { + const brand = await extractSiteBrand(request.url); + const copy = templateCopy(brand); + generated = { + brand, + creatives: creativesFromCopy(brand, copy, brand.ogImage), + summary: copy.summaryShort ? { short: cleanSummary(copy.summaryShort, 400), long: "", domain: summaryDomain(brand.url || request.url) } : null, + provider: `template (${err instanceof Error ? err.message.slice(0, 80) : "generator failed"})`, + }; + } catch (inner) { + return { ok: false, status: 502, error: inner instanceof Error ? `Could not read that URL: ${inner.message}` : "Could not read that URL." }; + } } if (!generated.creatives.length) return { ok: false, status: 502, error: "No creatives could be written for that URL." }; diff --git a/lib/ads/creative.ts b/lib/ads/creative.ts index 872d57e..795475a 100644 --- a/lib/ads/creative.ts +++ b/lib/ads/creative.ts @@ -186,6 +186,49 @@ function buildUserPrompt(brand: SiteBrand): string { .join("\n"); } +/** + * Copy written from the page alone, with no model in the loop. + * + * The generator needs an AI provider with credit, and when both providers are + * out (a spend cap, an empty balance) every campaign created from the API or + * the CLI would fail — which for a campaign that opens itself the moment a + * blog post is published means the post runs no ad at all. A page's own title + * and description are honest copy: they are what the page says about itself. + * Not as sharp as generated copy, and editable in the dashboard like any + * other creative. + */ +export function templateCopy(brand: SiteBrand): AdCopy { + const clean = (s: string) => (s ?? "").replace(/\s+/g, " ").trim(); + // "NicheDB — sources in, feeds out" → "NicheDB"; the tail is usually the site name or a tagline. + const title = clean(brand.title).split(/\s+[—–|·]\s+/)[0] || clean(brand.title) || brand.domain; + const headline = title.length > 48 ? `${title.slice(0, 47).replace(/\s+\S*$/, "")}` : title; + const shortWords = headline.split(" ").slice(0, 4).join(" "); + const shortHeadline = shortWords.length > 28 ? shortWords.slice(0, 28).replace(/\s+\S*$/, "") : shortWords; + const description = clean(brand.description) || clean(brand.text).split(/(?<=[.!?])\s+/)[0] || `Read more on ${brand.domain}.`; + const body = description.length > 130 ? `${description.slice(0, 129).replace(/\s+\S*$/, "")}…` : description; + const bgColor = brand.themeColor && HEX.test(brand.themeColor) ? brand.themeColor.toLowerCase() : "#0b0d10"; + const accentColor = brand.palette.find((c) => HEX.test(c) && c.toLowerCase() !== bgColor) ?? "#6ee7b7"; + return { + headline: headline || brand.domain, + shortHeadline: shortHeadline || headline.slice(0, 28) || brand.domain, + body, + ctaText: "Learn more", + bgColor, + fgColor: "#e7e9ee", + accentColor, + lightBgColor: null, + lightFgColor: null, + lightAccentColor: null, + summaryShort: clean(brand.description).slice(0, 400), + summaryLong: "", + } as unknown as AdCopy; +} + +/** The generator's creative set from a copy set; exported for the template path. */ +export function creativesFromCopy(brand: SiteBrand, copy: AdCopy, heroUrl: string | null): AdCreative[] { + return copyToCreatives(brand, copy, heroUrl); +} + function copyToCreatives(brand: SiteBrand, copy: AdCopy, heroUrl: string | null): AdCreative[] { const bg = safeHex(copy.bgColor, brand.themeColor && HEX.test(brand.themeColor) ? brand.themeColor : "#0b0d10"); const fg = safeHex(copy.fgColor, "#e7e9ee"); diff --git a/tests/ads-template-copy.test.ts b/tests/ads-template-copy.test.ts new file mode 100644 index 0000000..b027bf1 --- /dev/null +++ b/tests/ads-template-copy.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { templateCopy, creativesFromCopy, AD_FORMAT_IDS } from "@/lib/ads/creative"; +import type { SiteBrand } from "@/lib/ads/brand"; + +const brand: SiteBrand = { + url: "https://nichedb.dev/", + domain: "nichedb.dev", + title: "NicheDB — sources in, feeds out", + description: "An open, ever-growing database of real-time data. Follow a feed, get told. Web, RSS, API, CLI and MCP.", + text: "NicheDB is a platform for databases that only ever grow.", + logoUrl: null, + ogImage: "https://nichedb.dev/icons/icon-512x512.png", + themeColor: "#12161f", + palette: ["#12161f", "#6ee7b7", "#ffffff"], +}; + +describe("template copy, for when no model has credit", () => { + it("is the page's own words, within the creative limits", () => { + const copy = templateCopy(brand); + expect(copy.headline).toBe("NicheDB"); + expect(copy.shortHeadline).toBe("NicheDB"); + expect(copy.body.length).toBeLessThanOrEqual(130); + expect(copy.body.startsWith("An open, ever-growing database")).toBe(true); + expect(copy.ctaText).toBe("Learn more"); + expect(copy.bgColor).toBe("#12161f"); + // The accent is the first palette colour that is not the background. + expect(copy.accentColor).toBe("#6ee7b7"); + expect(copy.summaryShort).toBe(brand.description); + }); + + it("clips a long title on a word and falls back to the domain", () => { + const long = templateCopy({ ...brand, title: "A very long page title that keeps going well past the headline limit for banners" }); + expect(long.headline.length).toBeLessThanOrEqual(48); + expect(long.headline.endsWith(" ")).toBe(false); + expect(long.shortHeadline.split(" ").length).toBeLessThanOrEqual(4); + const bare = templateCopy({ ...brand, title: "", description: "", text: "", themeColor: null, palette: [] }); + expect(bare.headline).toBe("nichedb.dev"); + expect(bare.body).toBe("Read more on nichedb.dev."); + expect(bare.bgColor).toBe("#0b0d10"); + }); + + it("yields one creative per format, with the page image as the hero", () => { + const creatives = creativesFromCopy(brand, templateCopy(brand), brand.ogImage); + expect(creatives.map((c) => c.format)).toEqual(AD_FORMAT_IDS); + for (const creative of creatives) { + expect(creative.imageUrl).toBe(brand.ogImage); + expect(creative.headline.length).toBeGreaterThan(0); + expect(creative.lightBgColor).toBeTruthy(); + } + }); +});