diff --git a/app/(marketing)/ads/page.tsx b/app/(marketing)/ads/page.tsx index 32979d1..9b11705 100644 --- a/app/(marketing)/ads/page.tsx +++ b/app/(marketing)/ads/page.tsx @@ -14,6 +14,8 @@ import { PUBLISHER_FORMAT_IDS, TERMINAL_FORMAT_ID, TERMINAL_COLS_LABEL, + FEED_FORMAT_ID, + FEED_ITEM_LABEL, formatSpec, } from "@/lib/ads/formats"; @@ -54,6 +56,7 @@ export default async function AdsMarketingPage() { const perClickPayout = creditsToPayoutCents(CPC_CREDITS); const publisherFormats = PUBLISHER_FORMAT_IDS.map((id) => formatSpec(id)); const terminal = AD_FORMATS.find((f) => f.id === TERMINAL_FORMAT_ID); + const feed = AD_FORMATS.find((f) => f.id === FEED_FORMAT_ID); return (
@@ -99,7 +102,7 @@ export default async function AdsMarketingPage() { /> )} + {feed && ( + + {feed.label}{" "} + {FEED_ITEM_LABEL} + + )}

The terminal unit is the odd one out and the point of it is that nobody else sells it: @@ -237,6 +246,17 @@ export default async function AdsMarketingPage() { into an SSH login banner, a shell MOTD, a BBS screen or a CLI tool's output. It is fetched over HTTP rather than embedded, so it works in places a browser never reaches.

+

+ The feed unit is the other one. Ask{" "} + /api/ads/feed for an RSS{" "} + <item>, an Atom{" "} + <entry>, a JSON Feed item, or a bare + HTML, Markdown or plain-text body, and splice it into a feed you already publish — + typically one sponsored item every ten posts. There are copy-and-paste + recipes for Node, Next.js, Hugo, Eleventy, Jekyll, Astro, WordPress, PHP, Python, + Django, Ruby, Go and Cloudflare Workers. Your subscribers see it in the reader they + already use, and nothing on your site has to load a script. +

{/* ── Both sides ──────────────────────────────────────────────── */} diff --git a/app/api/ads/feed/route.ts b/app/api/ads/feed/route.ts new file mode 100644 index 0000000..38766d2 --- /dev/null +++ b/app/api/ads/feed/route.ts @@ -0,0 +1,232 @@ +// Feed ad fill — a creative as a syndication item. +// +// curl -s "https://crawlproof.com/api/ads/feed?slot=" +// curl -s "https://crawlproof.com/api/ads/feed?slot=&as=atom" +// curl -s "https://crawlproof.com/api/ads/feed?slot=&as=fields&n=3" +// +// Meant to be called by whatever builds a feed — a static site generator, a +// CMS, a cron job, an aggregator like rssamplifier.com — and the result pasted +// into the document being built. Nothing here renders in a browser and nothing +// here is embedded: like /api/ads/motd, this is fetched. +// +// Parameters, all optional: +// +// slot the publisher placement being filled; impressions and click earnings +// are credited to it. Omitted falls back to ADS_DEFAULT_SLOT_ID, so a +// 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. +// 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. +// label disclosure wording. Defaults to "Sponsored", and cannot be removed. +// cols box width for style=terminal. +// src publisher's surface tag, recorded on the impression. +// v visitor id, if the caller has one. +// +// Metering matches every other serving path: serveAd records the impression +// server-side, at fetch time. That is worth being explicit about, because a +// feed is fan-out — one fetch by a publisher's build produces one impression, +// and the document it lands in may then be read by thousands of subscribers. +// Feed impressions therefore undercount reach by design. Clicks are exact: they +// go through the ordinary redirector and are metered per click. + +import { NextRequest, NextResponse } from "next/server"; +import { serveAd } from "@/lib/ads/serve"; +import { houseFill } from "@/lib/ads/house"; +import { FEED_FORMAT_ID } from "@/lib/ads/formats"; +import { + feedDeviceType, + feedFields, + isFeedShape, + isFeedStyle, + isGuidMode, + jsonFeedItem, + renderFeedAd, + type FeedItemInput, + type FeedRenderOpts, + type FeedShape, +} from "@/lib/ads/feeditem"; +import { clampCols } from "@/lib/ads/terminal"; +import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo"; +import { parseDevice } from "@/lib/tracker/device"; +import { env } from "@/lib/env"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Most ads a single request will return, however many were asked for. */ +const MAX_ADS = 5; + +function headers(request: Request, contentType: string): Record { + const origin = request.headers.get("origin"); + return { + "content-type": contentType, + // Every request is a fresh fill and a fresh impression, so nothing shared + // may cache it. A publisher who wants to fetch less often should cache on + // their side, where they can key it to their own build. + "cache-control": "no-store", + "access-control-allow-origin": origin ?? "*", + "access-control-allow-methods": "GET, OPTIONS", + vary: "Origin", + "x-robots-tag": "noindex", + }; +} + +export function OPTIONS(request: NextRequest) { + return new NextResponse(null, { + status: 204, + headers: headers(request, "text/plain; charset=utf-8"), + }); +} + +/** Publisher's surface tag. Sanitised hard — it is recorded and re-emitted. */ +function cleanSrc(v: string | null): string { + return (v ?? "").trim().replace(/[^\w.-]/g, "").slice(0, 32); +} + +/** + * Put a query parameter on a URL, leaving it alone if it will not parse. + * + * Only used for the house ad's surface tag — see below. + */ +function withParam(rawUrl: string, key: string, value: string): string { + try { + const u = new URL(rawUrl); + u.searchParams.set(key, value); + return u.toString(); + } catch { + return rawUrl; + } +} + +function clampCount(v: string | null): number { + const n = parseInt(String(v ?? ""), 10); + if (!Number.isFinite(n)) return 1; + return Math.min(MAX_ADS, Math.max(1, n)); +} + +/** + * The empty answer for a shape. + * + * A feed build is usually string concatenation, so the failure that costs a + * publisher least is one that contributes nothing: an empty fragment splices + * into a document invisibly, while an error page spliced into a `` + * makes the whole feed unparseable for every subscriber. Status stays 200 for + * the same reason — a build script checking `res.ok` should not abort the + * publisher's deploy because our ad server had a bad minute. + */ +function emptyBody(shape: FeedShape): string { + if (shape === "json") return "[]\n"; + if (shape === "fields") return `${JSON.stringify({ ok: false, count: 0, items: [] }, null, 2)}\n`; + return ""; +} + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const shape: FeedShape = isFeedShape(url.searchParams.get("as")) + ? (url.searchParams.get("as") as FeedShape) + : "rss"; + + try { + const slotId = url.searchParams.get("slot") || env.adsDefaultSlotId; + const visitorId = url.searchParams.get("v"); + const src = cleanSrc(url.searchParams.get("src")); + const count = clampCount(url.searchParams.get("n")); + + const styleParam = url.searchParams.get("style"); + const guidParam = url.searchParams.get("guid"); + const opts: FeedRenderOpts = { + style: isFeedStyle(styleParam) ? styleParam : "text", + guidMode: isGuidMode(guidParam) ? guidParam : "daily", + label: url.searchParams.get("label") ?? undefined, + cols: url.searchParams.has("cols") + ? clampCols(url.searchParams.get("cols")) + : undefined, + }; + + // Geo and device are resolved once and reused across the fills: they + // describe the caller, and the caller does not change between ad one and + // ad three of the same request. + const ip = clientIpFromHeaders(request.headers); + const geo = await lookupGeo(ip).catch(() => null); + const ua = request.headers.get("user-agent"); + // Feed builders identify as HTTP libraries, which the generic tracker calls + // a bot — and bots get the unmetered house ad. feedDeviceType keeps real + // crawlers out while letting builders and readers through; anything it + // cannot place falls back to ordinary parsing. Without this a feed slot + // could never earn. Same trap, same fix as the terminal endpoint. + const device = feedDeviceType(ua) ?? parseDevice(ua).deviceType; + + // Fills are sequential rather than concurrent on purpose: serveAd picks by + // weighted lottery and writes an impression row per call, and firing them + // in parallel against the same slot is how you get the same campaign three + // times in one document. + const inputs: FeedItemInput[] = []; + for (let i = 0; i < count; i += 1) { + let fill = null; + if (slotId) { + fill = await serveAd(slotId, FEED_FORMAT_ID, { + visitorId, + ip, + country: geo?.countryCode ?? null, + device, + src: src || null, + }); + } + // No slot given, or the slot is inactive / has no feed inventory. A feed + // is a document somebody already published, so a missing fill is better + // filled by the house ad than left as a hole in their river. + if (!fill) fill = houseFill(FEED_FORMAT_ID); + + // A paid fill already carries the publisher's surface tag on its + // impression row, which /a/ reads back when it builds utm_content. + // A house fill has no impression row, so its tag has to ride the URL — + // /h re-applies it server-side. Same split as the MOTD endpoint. + const isHouse = fill.campaignId === "house"; + const clickUrl = src && isHouse ? withParam(fill.clickUrl, "s", src) : fill.clickUrl; + + inputs.push({ + creative: fill.creative, + clickUrl, + slotId: slotId || "default", + impressionId: fill.impressionId, + tier: fill.tier, + position: i, + }); + } + + // The JSON shapes are collections rather than concatenated documents, so + // they are assembled here instead of joined as strings. + if (shape === "json") { + const items = inputs.map((input) => jsonFeedItem(input, opts)); + return new NextResponse(`${JSON.stringify(items, null, 2)}\n`, { + status: 200, + headers: headers(request, "application/json; charset=utf-8"), + }); + } + if (shape === "fields") { + const items = inputs.map((input) => feedFields(input, opts)); + return new NextResponse( + `${JSON.stringify({ ok: true, count: items.length, items }, null, 2)}\n`, + { status: 200, headers: headers(request, "application/json; charset=utf-8") }, + ); + } + + const parts = inputs.map((input) => renderFeedAd(shape, input, opts)); + const contentType = parts[0]?.contentType ?? "text/plain; charset=utf-8"; + return new NextResponse(parts.map((p) => p.body).join(""), { + status: 200, + headers: headers(request, contentType), + }); + } catch { + // Never break somebody's feed build. See emptyBody. + return new NextResponse(emptyBody(shape), { + status: 200, + headers: headers(request, shape === "json" || shape === "fields" + ? "application/json; charset=utf-8" + : "text/plain; charset=utf-8"), + }); + } +} diff --git a/components/ads/ad-preview.tsx b/components/ads/ad-preview.tsx index 3d90249..d0974e5 100644 --- a/components/ads/ad-preview.tsx +++ b/components/ads/ad-preview.tsx @@ -2,8 +2,15 @@ import type { CSSProperties } from "react"; import type { AdCreative } from "@/lib/ads/formats"; -import { brandInitial, formatSpec, hexToRgba, TERMINAL_FORMAT_ID } from "@/lib/ads/formats"; +import { + brandInitial, + formatSpec, + hexToRgba, + FEED_FORMAT_ID, + TERMINAL_FORMAT_ID, +} from "@/lib/ads/formats"; import { renderCreativeText } from "@/lib/ads/terminal"; +import { ATTRIBUTION, ctaLabel, DEFAULT_LABEL, oneLine } from "@/lib/ads/feeditem"; // Stand-in for the real /a/ click URL, so the preview box is the // width the served ad will actually be. @@ -39,6 +46,49 @@ export function AdPreview({ creative, scale = 1 }: { creative: AdCreative; scale ); } + // Feed ad — the sponsored line as a reader will actually show it. + // + // Deliberately *not* styled to match the other units. The served body carries + // no CSS at all (readers strip it), so its appearance comes entirely from the + // subscriber's own stylesheet — and a preview painted in the advertiser's + // brand colours would promise a look we have no way to deliver. What this + // shows instead is the structure the reader will get: the disclosure, the + // headline as a link, the body, the call to action, the attribution. + if (creative.format === FEED_FORMAT_ID) { + return ( +
+
+

+ {DEFAULT_LABEL} + {" \u00b7 "} + + {oneLine(creative.headline) || "Your headline"} + + {creative.body ? ` \u2014 ${oneLine(creative.body)}` : ""}{" "} + + {ctaLabel(creative.ctaText)} {"\u2192"} + {" "} + ({ATTRIBUTION}) +

+
+

+ Shown in the subscriber's reader, which supplies its own styling. +

+
+ ); + } + // Native text link — a borderless, full-width single line. if (creative.format === "text_link") { return ( diff --git a/components/ads/slot-manager.tsx b/components/ads/slot-manager.tsx index f792560..c959611 100644 --- a/components/ads/slot-manager.tsx +++ b/components/ads/slot-manager.tsx @@ -4,6 +4,9 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { createSlot, setSlotStatus, saveSlotPayout, requestPayout } from "@/app/actions/ads"; import { + FEED_FORMAT_ID, + FEED_ITEM_LABEL, + PUBLISHER_FEED_FORMAT_IDS, PUBLISHER_FORMAT_IDS, PUBLISHER_TEXT_FORMAT_IDS, TERMINAL_COLS_LABEL, @@ -11,51 +14,23 @@ import { formatSpec, type AdFormatId, } from "@/lib/ads/formats"; +import { formatBlurb, snippetsFor } from "@/lib/ads/snippets"; // Every unit a publisher can install: HTML embeds first, then the fetch-based -// text formats (terminal/MOTD). +// formats — the terminal box (printed by a shell), then the feed item (spliced +// into a document the publisher generates). const INSTALLABLE_FORMAT_IDS: AdFormatId[] = [ ...PUBLISHER_FORMAT_IDS, ...PUBLISHER_TEXT_FORMAT_IDS, + ...PUBLISHER_FEED_FORMAT_IDS, ]; -// The paste-once embed for a given size. data-format tells /ad.js which creative -// to request; the medium rectangle stays the default the auto-installer uses. -// The terminal format isn't embedded at all — it's curled, so it gets a shell -// snippet instead of markup. -function embedFor(slotId: string, format: AdFormatId, origin: string): string { - if (format === TERMINAL_FORMAT_ID) { - return [ - "# Terminal ad — plain ASCII over HTTP. No JavaScript, no HTML, no iframe.", - "# Drop in ~/.zshrc, /etc/profile.d/, /etc/update-motd.d, or any CLI banner.", - `curl -fsS --max-time 3 "${origin}/api/ads/motd?slot=${slotId}&cols=72"`, - "", - "# Options: &color=1 for ANSI colour, &cols=44..120 for width,", - "# &src= to tell surfaces apart (rides through to the click URL).", - "", - "# Repeat visitors: &v=. A terminal has no cookies and no localStorage,", - "# so unlike the web tag we can't mint this for you — without it every", - "# fetch looks like a brand new person. Generate one stable random id per", - "# machine at install time and pass it every time:", - "# id=$(cat /etc/crawlproof-visitor 2>/dev/null) || {", - "# id=$(head -c16 /dev/urandom | od -An -tx1 | tr -d ' \\n')", - "# printf '%s' \"$id\" >/etc/crawlproof-visitor", - "# }", - `# curl -fsS "${origin}/api/ads/motd?slot=${slotId}&cols=72&v=$id"`, - "# Use an opaque random value — never a hostname, username, or IP.", - "", - "# Rendering a template server-side? Leave a token where the ad goes —", - "# {{ads}} {{ads:64}} {{ads:terminal:64}}", - "# — and swap it for the fetched text before you send the response.", - ].join("\n"); - } - return `
\n`; -} - -// Button caption: pixel sizes for banners, columns for the terminal box. +// Button caption: pixel sizes for banners, columns for the terminal box, and +// "1 item" for the feed unit — which has neither. function formatButtonLabel(id: AdFormatId): string { const spec = formatSpec(id); if (id === TERMINAL_FORMAT_ID) return `${spec.label} · ${TERMINAL_COLS_LABEL}`; + if (id === FEED_FORMAT_ID) return `${spec.label} · ${FEED_ITEM_LABEL}`; return `${spec.label} · ${spec.w}×${spec.h}`; } @@ -131,13 +106,22 @@ export function SlotManager({ // Which size's embed is currently revealed, and whether the code block is open. const [fmt, setFmt] = useState(PUBLISHER_FORMAT_IDS[0]); const [showCode, setShowCode] = useState(true); + // Which recipe within that size. Keyed by id rather than index so that + // switching formats and coming back does not land on an unrelated language + // just because it happened to sit at the same position. + const [snippetId, setSnippetId] = useState(null); const [prBusy, setPrBusy] = useState(false); const [prMsg, setPrMsg] = useState<{ ok: boolean; text: string; url?: string } | null>(null); const [repoChoices, setRepoChoices] = useState< { owner: string; repo: string; installation_id: number }[] | null >(null); - const embed = slot ? embedFor(slot.id, fmt, origin) : null; + const snippets = slot ? snippetsFor(fmt, slot.id, origin) : []; + // The first recipe is the canonical one for each unit, so an unset selection + // (or one left over from another format) falls back to it rather than to + // nothing. + const snippet = snippets.find((sn) => sn.id === snippetId) ?? snippets[0] ?? null; + const embed = snippet?.code ?? null; function enable() { start(async () => { @@ -285,7 +269,7 @@ export function SlotManager({ )}
- Embed — pick a unit, paste on your page (or in your shell) + Install — pick a unit, then the stack you build with
{/* One button per available size. Clicking reveals that size's code; clicking the open size again collapses it. */} @@ -302,6 +286,9 @@ export function SlotManager({ setShowCode((s) => !s); } else { setFmt(id); + // Formats do not share recipes, so a selection made for + // the last one cannot carry over. + setSnippetId(null); setShowCode(true); } }} @@ -311,14 +298,34 @@ export function SlotManager({ ); })}
- {showCode && embed && ( + {showCode && snippet && ( <> +

{formatBlurb(fmt)}

+ {/* One button per stack. The banners need this least — a script + tag is a script tag — and the feed unit needs it most, since + the snippet has to be written in whatever language builds + the publisher's feed. */} +
+ {snippets.map((sn) => ( + + ))} +
-                  {embed}
+                  {snippet.code}
                 
+ {snippet.note && ( +

{snippet.note}

+ )}
diff --git a/lib/ads/creative.ts b/lib/ads/creative.ts index 1270f43..3016294 100644 --- a/lib/ads/creative.ts +++ b/lib/ads/creative.ts @@ -8,6 +8,7 @@ import { generateStructuredOutput } from "@/lib/lx/backendAi"; import { extractSiteBrand, type SiteBrand } from "./brand"; import { resolveAdHeroImage } from "./heroImage"; import { renderCreativeText, renderTerminalHtml } from "./terminal"; +import { renderFeedHtml } from "./feeditem"; import { AD_FORMATS, AD_FORMAT_IDS, @@ -194,6 +195,22 @@ export function renderCreativeHtml(creative: AdCreative, clickUrl: string): stri return renderTerminalHtml(creative, clickUrl); } + // Feed ad — the sponsored line as it will appear inside somebody's reader. + // Canonically delivered as a syndication item by /api/ads/feed; this path is + // what makes the same creative previewable in the browser, so it deliberately + // adds no styling the feed body would not survive. The wrapper only supplies + // a readable page background, since a feed body is rendered by the reader's + // own stylesheet rather than by ours. + if (creative.format === "feed_item") { + return `${renderFeedHtml(creative, clickUrl)}`; + } + // Native text link — a borderless, full-width single line. No image/box. if (creative.format === "text_link") { const body = creative.body diff --git a/lib/ads/feeditem.ts b/lib/ads/feeditem.ts new file mode 100644 index 0000000..3ec710d --- /dev/null +++ b/lib/ads/feeditem.ts @@ -0,0 +1,699 @@ +// Feed ads — a creative rendered as a syndication item. +// +// The consumer here is neither a browser nor a TTY: it is somebody else's RSS, +// Atom or JSON Feed document, being built by a static site generator, a CMS, or +// a directory like rssamplifier.com. So the unit is not a box with pixels, it +// is an *item* — a title, a link, a date, an identity, and a body — spliced +// into a river of real posts every ~10 entries. +// +// Three constraints drive every decision below, and none of them apply to the +// web or terminal formats: +// +// 1. **No namespaces.** A fragment is pasted inside a `` whose root +// element we did not write. If we emit `` or `` +// and the publisher's `` never declared that prefix, the document is +// not merely ugly — it is not well-formed, and every reader drops the whole +// feed rather than the one item. So the RSS fragment uses core RSS 2.0 +// elements only, and the Atom fragment uses core Atom elements only (which +// need no prefix: they inherit the default namespace from ``). +// +// 2. **No CSS.** Feed readers strip `