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