Skip to content

Commit f148b8f

Browse files
ralyodioclaude
andauthored
feat(ads): terminal (ASCII) ad format served as plain text (#161)
Terminals can't run /ad.js and can't render HTML, so the ad network had nothing to sell to shells, SSH banners, BBS screens, or CLIs. This adds a terminal_ascii format that is fetched, not embedded: curl -s "https://crawlproof.com/api/ads/motd?slot=<id>" - lib/ads/terminal.ts renders a fixed-width, pure-ASCII box. Advertiser copy is untrusted text going straight into a TTY, so escapes/control chars are stripped and non-ASCII is folded (accents) or dropped (CJK, emoji) — both to keep terminals safe and to keep column maths honest. - /api/ads/motd returns text/plain, with ?cols=44..120, ?color=1 for ANSI, and ?src=<tag> to tell surfaces apart. Impressions meter server-side via serveAd exactly like the HTML paths; an unknown slot falls back to the house ad so a login banner is never blank. - Terminal clients identify as curl/wget/etc., which the tracker buckets as "bot". On this endpoint that's the actual audience, so a dedicated classifier lets shell clients through while still excluding crawlers. The click path deliberately keeps the strict rule: scripted hits on /a/<id> stay unbilled. - Clicks use a short /a/<impression_id> URL (the long query-string form is unusable as printed text) and carry utm_source/medium/content through to the advertiser, since a shell sends no referrer. - lib/ads/template.ts parses {{ads}} / {{ads:64}} / {{ads:terminal:64}} so a publisher's server can place the fill in its own template. - Migration widens the format CHECK, adds terminal_ascii to slot inventory, and backfills a terminal creative for every existing campaign from the copy it already has (no LLM call, no advertiser action). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7393336 commit f148b8f

13 files changed

Lines changed: 1059 additions & 10 deletions

File tree

‎app/a/[id]/route.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Short ad click redirector: https://crawlproof.com/a/<impression_id>
2+
//
3+
// Terminal ads print their click URL as literal text into someone's shell, so
4+
// the long /api/ads/click?i=…&s=…&c=…&cr=… form doesn't work — it wraps, it
5+
// looks like spam, and it's unusable when hand-typed. This path carries only
6+
// the impression id and re-reads slot/campaign/creative from the impression
7+
// row, then hands off to the same resolveClick() metering as the web path.
8+
//
9+
// Unknown/expired ids (including unmetered house-ad fills, which never get an
10+
// impression row) redirect to the site rather than dead-ending.
11+
12+
import { NextRequest, NextResponse } from "next/server";
13+
import { resolveClick } from "@/lib/ads/serve";
14+
import { serviceClient } from "@/lib/supabase/service";
15+
import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo";
16+
import { parseDevice } from "@/lib/tracker/device";
17+
import { env } from "@/lib/env";
18+
19+
export const runtime = "nodejs";
20+
export const dynamic = "force-dynamic";
21+
22+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23+
24+
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
25+
const fallback = env.siteUrl || "https://crawlproof.com";
26+
try {
27+
const { id } = await ctx.params;
28+
if (!UUID.test(id)) return NextResponse.redirect(fallback, { status: 302 });
29+
30+
const sb = serviceClient();
31+
const { data: imp } = await sb
32+
.from("ad_impressions")
33+
.select("id, slot_id, campaign_id, creative_id, visitor_id")
34+
.eq("id", id)
35+
.maybeSingle();
36+
if (!imp) return NextResponse.redirect(fallback, { status: 302 });
37+
38+
const ip = clientIpFromHeaders(request.headers);
39+
const geo = await lookupGeo(ip).catch(() => null);
40+
// Deliberately the STRICT classification here, unlike /api/ads/motd: a
41+
// terminal ad is served to curl, but it's clicked from a browser when the
42+
// reader follows the link. Anyone can curl this URL in a loop, so scripted
43+
// hits stay unbilled (recorded with valid=false) rather than paying out.
44+
const device = parseDevice(request.headers.get("user-agent")).deviceType;
45+
46+
const dest = await resolveClick({
47+
impressionId: imp.id,
48+
slotId: imp.slot_id,
49+
campaignId: imp.campaign_id,
50+
creativeId: imp.creative_id,
51+
ctx: {
52+
visitorId: imp.visitor_id,
53+
ip,
54+
country: geo?.countryCode ?? null,
55+
device,
56+
},
57+
});
58+
59+
if (!dest) return NextResponse.redirect(fallback, { status: 302 });
60+
61+
// Terminal traffic is invisible in an advertiser's analytics without a tag
62+
// — there's no referrer from a shell. resolveClick already appended
63+
// ?ref=<campaign slug>; add utm on top, plus the publisher's own ?src=
64+
// surface tag when the ad carried one. Never overwrite utm params the
65+
// advertiser put on their own destination URL.
66+
const q = new URL(request.url).searchParams;
67+
const src = q.get("s") ?? q.get("src");
68+
return NextResponse.redirect(withTerminalUtm(dest, src), { status: 302 });
69+
} catch {
70+
return NextResponse.redirect(fallback, { status: 302 });
71+
}
72+
}
73+
74+
function withTerminalUtm(dest: string, src: string | null): string {
75+
try {
76+
const u = new URL(dest);
77+
if (!u.searchParams.has("utm_source")) u.searchParams.set("utm_source", "crawlproof");
78+
if (!u.searchParams.has("utm_medium")) u.searchParams.set("utm_medium", "terminal");
79+
const tag = (src ?? "").trim().replace(/[^\w.-]/g, "").slice(0, 32);
80+
if (tag && !u.searchParams.has("utm_content")) u.searchParams.set("utm_content", tag);
81+
return u.toString();
82+
} catch {
83+
return dest;
84+
}
85+
}

‎app/api/ads/motd/route.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Plain-text ad fill for terminals — the MOTD endpoint.
2+
//
3+
// curl -s "https://crawlproof.com/api/ads/motd?slot=<slot_id>"
4+
// curl -s "https://crawlproof.com/api/ads/motd?slot=<slot_id>&cols=64&color=1"
5+
//
6+
// Returns an ASCII box (text/plain), sized to `cols`, with optional ANSI
7+
// colour. Meant for shell MOTDs, SSH login banners, BBS screens, and CLI tools
8+
// — anywhere an <iframe> can't go. Impressions are metered server-side by
9+
// serveAd, exactly like the HTML paths; an unknown or inactive slot falls back
10+
// to the (unmetered) CrawlProof house ad so a login banner is never blank.
11+
12+
import { NextRequest, NextResponse } from "next/server";
13+
import { serveAd } from "@/lib/ads/serve";
14+
import { houseFill } from "@/lib/ads/house";
15+
import { TERMINAL_FORMAT_ID } from "@/lib/ads/formats";
16+
import { clampCols, renderCreativeText, terminalDeviceType } from "@/lib/ads/terminal";
17+
import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo";
18+
import { parseDevice } from "@/lib/tracker/device";
19+
20+
export const runtime = "nodejs";
21+
export const dynamic = "force-dynamic";
22+
23+
function headers(request: Request): Record<string, string> {
24+
const origin = request.headers.get("origin");
25+
return {
26+
"content-type": "text/plain; charset=utf-8",
27+
// Every request is a fresh fill + impression, so never cache at the edge.
28+
"cache-control": "no-store",
29+
"access-control-allow-origin": origin ?? "*",
30+
"access-control-allow-methods": "GET, OPTIONS",
31+
vary: "Origin",
32+
"x-robots-tag": "noindex",
33+
};
34+
}
35+
36+
export function OPTIONS(request: NextRequest) {
37+
return new NextResponse(null, { status: 204, headers: headers(request) });
38+
}
39+
40+
// color=1 / true / yes / ansi all opt into ANSI escapes. Default is plain text:
41+
// a consumer piping us into a file or a web page must not get escape codes.
42+
function wantsColor(v: string | null): boolean {
43+
return v !== null && /^(1|true|yes|ansi|on)$/i.test(v);
44+
}
45+
46+
// Optional publisher-supplied surface tag (?src=bbs, ?src=ssh-banner, …). It
47+
// rides along on the click URL so one slot can tell its surfaces apart in the
48+
// advertiser's own analytics. Sanitised hard: it ends up in a printed URL.
49+
function cleanSrc(v: string | null): string {
50+
return (v ?? "").trim().replace(/[^\w.-]/g, "").slice(0, 32);
51+
}
52+
53+
function withParam(rawUrl: string, key: string, value: string): string {
54+
try {
55+
const u = new URL(rawUrl);
56+
u.searchParams.set(key, value);
57+
return u.toString();
58+
} catch {
59+
return rawUrl;
60+
}
61+
}
62+
63+
export async function GET(request: NextRequest) {
64+
const h = headers(request);
65+
try {
66+
const url = new URL(request.url);
67+
const slotId = url.searchParams.get("slot");
68+
const visitorId = url.searchParams.get("v");
69+
const cols = clampCols(url.searchParams.get("cols") ?? url.searchParams.get("width"));
70+
const color = wantsColor(url.searchParams.get("color"));
71+
const src = cleanSrc(url.searchParams.get("src"));
72+
73+
let fill = null;
74+
if (slotId) {
75+
const ip = clientIpFromHeaders(request.headers);
76+
const geo = await lookupGeo(ip).catch(() => null);
77+
// Terminal clients identify as curl/wget/etc., which the generic tracker
78+
// buckets as "bot" — correct for a web page, wrong here, where that's the
79+
// actual audience. terminalDeviceType keeps real crawlers out and lets
80+
// shell clients through; anything else falls back to normal parsing.
81+
const ua = request.headers.get("user-agent");
82+
const device = terminalDeviceType(ua) ?? parseDevice(ua).deviceType;
83+
fill = await serveAd(slotId, TERMINAL_FORMAT_ID, {
84+
visitorId,
85+
ip,
86+
country: geo?.countryCode ?? null,
87+
device,
88+
});
89+
}
90+
// No slot given, or the slot is inactive / has no terminal inventory.
91+
if (!fill) fill = houseFill(TERMINAL_FORMAT_ID);
92+
93+
// Re-render at the caller's width/colour from the same creative + click URL
94+
// the fill was metered with. House fills keep their own border label so an
95+
// unsold slot doesn't read as a paid placement.
96+
// Short key: the click URL is printed as literal text, so every character
97+
// spent here is a character of box width.
98+
const clickUrl = src ? withParam(fill.clickUrl, "s", src) : fill.clickUrl;
99+
const body = renderCreativeText(fill.creative, clickUrl, {
100+
cols,
101+
color,
102+
label: fill.campaignId === "house" ? "CRAWLPROOF ADS" : undefined,
103+
});
104+
return new NextResponse(`${body}\n`, { status: 200, headers: h });
105+
} catch {
106+
// Never fail a login banner: fall back to the house ad at defaults.
107+
const fallback = houseFill(TERMINAL_FORMAT_ID);
108+
return new NextResponse(`${fallback.text}\n`, { status: 200, headers: h });
109+
}
110+
}

‎components/ads/ad-preview.tsx‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,43 @@
22

33
import type { CSSProperties } from "react";
44
import type { AdCreative } from "@/lib/ads/formats";
5-
import { brandInitial, formatSpec, hexToRgba } from "@/lib/ads/formats";
5+
import { brandInitial, formatSpec, hexToRgba, TERMINAL_FORMAT_ID } from "@/lib/ads/formats";
6+
import { renderCreativeText } from "@/lib/ads/terminal";
7+
8+
// Stand-in for the real /a/<impression_id> click URL, so the preview box is the
9+
// width the served ad will actually be.
10+
const PREVIEW_CLICK_URL = "https://crawlproof.com/a/00000000-0000-0000-0000-000000000000";
611

712
// Live React mirror of renderCreativeHtml (lib/ads/creative.ts). Kept visually
813
// in sync with the served HTML so the editor preview matches production.
914
export function AdPreview({ creative, scale = 1 }: { creative: AdCreative; scale?: number }) {
1015
const { w, h } = formatSpec(creative.format);
1116

17+
// Terminal ad — render the exact ASCII the MOTD endpoint serves, monospaced.
18+
if (creative.format === TERMINAL_FORMAT_ID) {
19+
return (
20+
<div style={{ width: w * scale, maxWidth: "100%", flex: "0 0 auto" }}>
21+
<pre
22+
style={{
23+
margin: 0,
24+
background: creative.bgColor,
25+
color: creative.fgColor,
26+
border: "1px solid rgba(255,255,255,.08)",
27+
borderRadius: 8,
28+
padding: "10px 12px",
29+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
30+
fontSize: 11,
31+
lineHeight: 1.35,
32+
whiteSpace: "pre",
33+
overflowX: "auto",
34+
}}
35+
>
36+
{renderCreativeText(creative, PREVIEW_CLICK_URL)}
37+
</pre>
38+
</div>
39+
);
40+
}
41+
1242
// Native text link — a borderless, full-width single line.
1343
if (creative.format === "text_link") {
1444
return (

‎components/ads/slot-manager.tsx‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,51 @@
33
import { useState, useTransition } from "react";
44
import { useRouter } from "next/navigation";
55
import { createSlot, setSlotStatus, saveSlotPayout, requestPayout } from "@/app/actions/ads";
6-
import { PUBLISHER_FORMAT_IDS, formatSpec, type AdFormatId } from "@/lib/ads/formats";
6+
import {
7+
PUBLISHER_FORMAT_IDS,
8+
PUBLISHER_TEXT_FORMAT_IDS,
9+
TERMINAL_COLS_LABEL,
10+
TERMINAL_FORMAT_ID,
11+
formatSpec,
12+
type AdFormatId,
13+
} from "@/lib/ads/formats";
14+
15+
// Every unit a publisher can install: HTML embeds first, then the fetch-based
16+
// text formats (terminal/MOTD).
17+
const INSTALLABLE_FORMAT_IDS: AdFormatId[] = [
18+
...PUBLISHER_FORMAT_IDS,
19+
...PUBLISHER_TEXT_FORMAT_IDS,
20+
];
721

822
// The paste-once embed for a given size. data-format tells /ad.js which creative
923
// to request; the medium rectangle stays the default the auto-installer uses.
24+
// The terminal format isn't embedded at all — it's curled, so it gets a shell
25+
// snippet instead of markup.
1026
function embedFor(slotId: string, format: AdFormatId, origin: string): string {
27+
if (format === TERMINAL_FORMAT_ID) {
28+
return [
29+
"# Terminal ad — plain ASCII over HTTP. No JavaScript, no HTML, no iframe.",
30+
"# Drop in ~/.zshrc, /etc/profile.d/, /etc/update-motd.d, or any CLI banner.",
31+
`curl -fsS --max-time 3 "${origin}/api/ads/motd?slot=${slotId}&cols=72"`,
32+
"",
33+
"# Options: &color=1 for ANSI colour, &cols=44..120 for width,",
34+
"# &src=<tag> to tell surfaces apart (rides through to the click URL).",
35+
"",
36+
"# Rendering a template server-side? Leave a token where the ad goes —",
37+
"# {{ads}} {{ads:64}} {{ads:terminal:64}}",
38+
"# — and swap it for the fetched text before you send the response.",
39+
].join("\n");
40+
}
1141
return `<div data-cp-ad data-slot="${slotId}" data-format="${format}"></div>\n<script src="${origin}/ad.js" async></script>`;
1242
}
1343

44+
// Button caption: pixel sizes for banners, columns for the terminal box.
45+
function formatButtonLabel(id: AdFormatId): string {
46+
const spec = formatSpec(id);
47+
if (id === TERMINAL_FORMAT_ID) return `${spec.label} · ${TERMINAL_COLS_LABEL}`;
48+
return `${spec.label} · ${spec.w}×${spec.h}`;
49+
}
50+
1451
type Project = { id: string; name: string; url: string };
1552
type Slot = {
1653
id: string;
@@ -237,13 +274,12 @@ export function SlotManager({
237274
)}
238275
<div>
239276
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
240-
Embed — pick a size, paste on your page
277+
Embed — pick a unit, paste on your page (or in your shell)
241278
</div>
242279
{/* One button per available size. Clicking reveals that size's code;
243280
clicking the open size again collapses it. */}
244281
<div className="mt-2 flex flex-wrap gap-2">
245-
{PUBLISHER_FORMAT_IDS.map((id) => {
246-
const spec = formatSpec(id);
282+
{INSTALLABLE_FORMAT_IDS.map((id) => {
247283
const open = id === fmt && showCode;
248284
return (
249285
<button
@@ -259,7 +295,7 @@ export function SlotManager({
259295
}
260296
}}
261297
>
262-
{spec.label} · {spec.w}×{spec.h}
298+
{formatButtonLabel(id)}
263299
</button>
264300
);
265301
})}

‎lib/ads/creative.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { env } from "@/lib/env";
77
import { generateStructuredOutput } from "@/lib/lx/backendAi";
88
import { extractSiteBrand, type SiteBrand } from "./brand";
99
import { resolveAdHeroImage } from "./heroImage";
10+
import { renderCreativeText, renderTerminalHtml } from "./terminal";
1011
import {
1112
AD_FORMATS,
1213
AD_FORMAT_IDS,
@@ -20,6 +21,7 @@ import {
2021
// Re-export the client-safe format primitives so existing server importers of
2122
// this module keep working; client components should import from ./formats.
2223
export { AD_FORMATS, AD_FORMAT_IDS, formatSpec };
24+
export { renderCreativeText, renderTerminalHtml };
2325
export type { AdCreative, AdFormatId };
2426

2527
// Copy is generated once per URL by a frontier-ish model; sizes are rendered
@@ -186,6 +188,12 @@ function markHtml(creative: AdCreative, size: number): string {
186188
export function renderCreativeHtml(creative: AdCreative, clickUrl: string): string {
187189
const { w, h } = formatSpec(creative.format);
188190

191+
// Terminal ad — the ASCII artwork in a <pre>, so the same creative can also
192+
// fill a web slot. The canonical delivery is /api/ads/motd (text/plain).
193+
if (creative.format === "terminal_ascii") {
194+
return renderTerminalHtml(creative, clickUrl);
195+
}
196+
189197
// Native text link — a borderless, full-width single line. No image/box.
190198
if (creative.format === "text_link") {
191199
const body = creative.body

‎lib/ads/formats.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ export const AD_FORMATS = [
1010
// Native, borderless single-line text ad. Renders full-width (the w/h below
1111
// is the nominal iframe box; the unit itself fills its container).
1212
{ id: "text_link", label: "Text Link", w: 600, h: 40 },
13+
// ASCII box for terminals — SSH banners, shell MOTDs, BBS screens, CLI tools.
14+
// There is no pixel box: w/h below is only the nominal size used by the web
15+
// preview when the same creative is rendered in a <pre>. The real dimension
16+
// is TERMINAL_COLS (see ./terminal).
17+
{ id: "terminal_ascii", label: "Terminal (ASCII)", w: 600, h: 148 },
1318
] as const;
1419

1520
export type AdFormatId = (typeof AD_FORMATS)[number]["id"];
@@ -19,13 +24,29 @@ export const AD_FORMAT_IDS = AD_FORMATS.map((f) => f.id) as AdFormatId[];
1924
// embed for and install. A subset of AD_FORMATS that grows as each size is
2025
// surfaced (one PR per size). Keep the medium rectangle first: it's the safe
2126
// default the auto-installer falls back to.
27+
//
28+
// WEB ONLY: these are the formats rendered as an <iframe> by /ad.js and dropped
29+
// into HTML by the GitHub auto-installer. Text/terminal formats must not be in
30+
// this list — they're fetched, not embedded.
2231
export const PUBLISHER_FORMAT_IDS: AdFormatId[] = [
2332
"banner_300x250",
2433
"banner_728x90",
2534
"banner_320x50",
2635
"text_link",
2736
];
2837

38+
// The terminal format id, kept as a named constant since several call sites
39+
// (serving, the MOTD endpoint, the publisher snippet) branch on it.
40+
export const TERMINAL_FORMAT_ID = "terminal_ascii" as const;
41+
42+
// Caption for the terminal unit's real dimension — it has columns, not pixels.
43+
export const TERMINAL_COLS_LABEL = "76 cols";
44+
45+
// Formats a publisher consumes over plain HTTP as text rather than embedding —
46+
// `curl https://crawlproof.com/api/ads/motd?slot=<id>` in a login banner, MOTD,
47+
// or CLI. Shown as their own group on the Monetize page.
48+
export const PUBLISHER_TEXT_FORMAT_IDS: AdFormatId[] = [TERMINAL_FORMAT_ID];
49+
2950
export function formatSpec(id: AdFormatId) {
3051
return AD_FORMATS.find((f) => f.id === id) ?? AD_FORMATS[0];
3152
}

0 commit comments

Comments
 (0)