From f055e52f5e1ebedfe9f2c464b8f0908521ea3cab Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 11:24:04 +0000 Subject: [PATCH] feat(ads): rotate the house ad so unsold slots stop looking frozen Until a slot has paid inventory every fill on it is a house ad, and the house ad was a single hard-coded creative. That made the unit byte-identical on every request: profullstack.com/motd printed the same "Your ad here" block on every login, which reads as a cached or broken endpoint rather than a live one. Nothing was actually cached -- both /api/ads/motd and the /motd consumer are already no-store -- there was simply only ever one thing to serve. Make the house ad a pool of six variants and draw one per fill, pitched at both sides of the network (publishers with a surface to monetize, advertisers with a product to sell). An unsold slot now rotates on every request, and the 10% house rotation on sold slots stops repeating itself too. The draw happens once in houseFill and is threaded through both houseCreative and renderHouseAdHtml; drawing independently in each would let a single impression show one pitch in its HTML and another in its text. Variants carry a slug that rides the click URL as utm_content so we can tell which pitch converts -- left off the terminal URL, which is printed as literal text inside the ASCII box and has no width to spare. Verified all six variants render ASCII-clean with aligned boxes at cols=44/72/120. Co-Authored-By: Claude Opus 5 (1M context) --- lib/ads/house.ts | 97 +++++++++++++++++++---- tests/contract/ads-house-rotation.test.ts | 67 ++++++++++++++++ 2 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 tests/contract/ads-house-rotation.test.ts diff --git a/lib/ads/house.ts b/lib/ads/house.ts index e3481c6d..a4308b74 100644 --- a/lib/ads/house.ts +++ b/lib/ads/house.ts @@ -10,11 +10,69 @@ import type { Fill } from "./serve"; // AI-generated promo artwork (public/ads/house/*) with an overlaid pitch that // links back to CrawlProof. House ads are not metered or billed. -const HOUSE = { - headline: "Your ad here", - body: "Crypto pay-per-click ads for indie sites — no middlemen.", - cta: "Advertise →", -} as const; +// Until a slot has paid inventory, *every* fill on it is a house ad. With a +// single hard-coded creative that made the unit byte-identical on every +// request — an MOTD or SSH banner would print the same block forever and read +// as frozen (or cached) rather than live. So the house ad is a pool, and each +// fill draws from it at random: an unsold slot still rotates. +// +// `slug` is only for attribution — it rides the click URL as utm_content so we +// can tell which pitch actually converts. It is left off the terminal click +// URL, which is printed as literal text inside the ASCII box and has no room +// to spare. +type HouseCopy = { + slug: string; + headline: string; + body: string; + cta: string; +}; + +// Two audiences, since these render in front of developers who are just as +// likely to have a site to monetize as a product to sell. +const HOUSE_VARIANTS: readonly HouseCopy[] = [ + { + slug: "your-ad-here", + headline: "Your ad here", + body: "Crypto pay-per-click ads for indie sites — no middlemen.", + cta: "Advertise →", + }, + { + slug: "monetize-terminal", + headline: "Monetize your terminal", + body: "Plain-ASCII ads for MOTDs, SSH banners, and CLI tools.", + cta: "Get a slot →", + }, + { + slug: "reach-developers", + headline: "Reach developers where they work", + body: "Put your product in front of shells, not ad blockers.", + cta: "Start a campaign →", + }, + { + slug: "paid-in-crypto", + headline: "Get paid in crypto", + body: "Publishers keep the revenue. No invoices, no net-30.", + cta: "Monetize →", + }, + { + slug: "no-javascript", + headline: "Ads without JavaScript", + body: "One HTTP call, plain text back. No iframe, no tracking pixel.", + cta: "See the docs →", + }, + { + slug: "indie-budget", + headline: "Advertise on an indie budget", + body: "Pay per click, set a daily cap, stop whenever you want.", + cta: "Advertise →", + }, +]; + +// One draw per fill. Callers must pick once and thread the result through, or +// the metered creative and the rendered HTML would disagree. +function pickHouse(): HouseCopy { + return HOUSE_VARIANTS[Math.floor(Math.random() * HOUSE_VARIANTS.length)]; +} // Fraction of fills on a slot with eligible paid inventory that are given the // CrawlProof house ad instead, to keep promoting the ad network. Not metered. @@ -29,13 +87,18 @@ function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } -export function renderHouseAdHtml(format: AdFormatId, clickUrl: string): string { +export function renderHouseAdHtml( + format: AdFormatId, + clickUrl: string, + copy: HouseCopy = pickHouse(), +): string { const { w, h } = formatSpec(format); + const HOUSE = copy; // Terminal ad — same ASCII artwork the /api/ads/motd endpoint serves, in a //
 for the web/iframe paths.
   if (format === TERMINAL_FORMAT_ID) {
-    return renderTerminalHtml(houseCreative(format), clickUrl);
+    return renderTerminalHtml(houseCreative(format, copy), clickUrl);
   }
 
   // Native text link — no artwork, single full-width line.
@@ -97,12 +160,12 @@ export function renderHouseAdHtml(format: AdFormatId, clickUrl: string): string
   `;
 }
 
-function houseCreative(format: AdFormatId): AdCreative {
+function houseCreative(format: AdFormatId, copy: HouseCopy): AdCreative {
   return {
     format,
-    headline: HOUSE.headline,
-    body: HOUSE.body,
-    ctaText: HOUSE.cta,
+    headline: copy.headline,
+    body: copy.body,
+    ctaText: copy.cta,
     bgColor: "#070a10",
     fgColor: "#eef3f8",
     accentColor: "#6ee7b7",
@@ -114,13 +177,17 @@ function houseCreative(format: AdFormatId): AdCreative {
 
 /** A default house-ad fill promoting the CrawlProof Ad Network. Not metered. */
 export function houseFill(format: AdFormatId): Fill {
+  // Drawn once here, then threaded through both the creative and the render so
+  // a single fill can't advertise one pitch in its HTML and another in its text.
+  const copy = pickHouse();
   // Terminals print the raw URL, so the terminal house ad uses a short one that
-  // fits the ASCII box instead of the full utm_campaign query.
+  // fits the ASCII box instead of the full utm_campaign query — no utm_content
+  // there either, for the same reason.
   const clickUrl =
     format === TERMINAL_FORMAT_ID
       ? `${env.siteUrl}/?utm_source=house-ad&utm_medium=motd`
-      : `${env.siteUrl}/?utm_source=house-ad&utm_medium=ad&utm_campaign=crawlproof-ads`;
-  const creative = houseCreative(format);
+      : `${env.siteUrl}/?utm_source=house-ad&utm_medium=ad&utm_campaign=crawlproof-ads&utm_content=${copy.slug}`;
+  const creative = houseCreative(format, copy);
   return {
     impressionId: crypto.randomUUID(),
     campaignId: "house",
@@ -128,7 +195,7 @@ export function houseFill(format: AdFormatId): Fill {
     refSlug: "house",
     creative,
     clickUrl,
-    html: renderHouseAdHtml(format, clickUrl),
+    html: renderHouseAdHtml(format, clickUrl, copy),
     text: renderCreativeText(creative, clickUrl, { label: "CRAWLPROOF ADS" }),
     tier: "house",
   };
diff --git a/tests/contract/ads-house-rotation.test.ts b/tests/contract/ads-house-rotation.test.ts
new file mode 100644
index 00000000..9bb20d86
--- /dev/null
+++ b/tests/contract/ads-house-rotation.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { houseFill } from "@/lib/ads/house";
+import { TERMINAL_FORMAT_ID } from "@/lib/ads/formats";
+import type { AdFormatId } from "@/lib/ads/creative";
+
+// Until a slot sells, every fill on it is a house ad. A single hard-coded
+// house creative made those fills byte-identical, so an MOTD or SSH banner
+// printed the same block on every login and read as frozen. These lock in the
+// rotation, and the invariant that one fill speaks with one voice.
+
+const FORMATS: AdFormatId[] = [
+  "banner_300x250",
+  "banner_728x90",
+  "banner_320x50",
+  "text_link",
+  TERMINAL_FORMAT_ID,
+];
+
+// 200 draws makes a false failure vanishingly unlikely while still catching a
+// pool that has collapsed back to one entry.
+function headlinesOver(format: AdFormatId, draws = 200): Set {
+  const seen = new Set();
+  for (let i = 0; i < draws; i++) seen.add(houseFill(format).creative.headline);
+  return seen;
+}
+
+describe("house ad rotation", () => {
+  it("serves more than one creative on repeat fills of the same format", () => {
+    for (const format of FORMATS) {
+      expect(headlinesOver(format).size).toBeGreaterThan(1);
+    }
+  });
+
+  it("keeps a single fill internally consistent across creative, html and text", () => {
+    // The copy is drawn once per fill and threaded through. Drawing separately
+    // per render would let one impression pitch two different things.
+    for (const format of FORMATS) {
+      for (let i = 0; i < 100; i++) {
+        const fill = houseFill(format);
+        const headline = fill.creative.headline;
+        const escaped = headline
+          .replace(/&/g, "&")
+          .replace(//g, ">");
+        expect(fill.html).toContain(escaped);
+        expect(fill.text).toContain(headline);
+      }
+    }
+  });
+
+  it("tags non-terminal click urls with the variant, and leaves terminal urls short", () => {
+    for (let i = 0; i < 50; i++) {
+      // The terminal URL is printed as literal text inside the ASCII box, so it
+      // carries no utm_content — there is no width to spare.
+      expect(houseFill(TERMINAL_FORMAT_ID).clickUrl).not.toContain("utm_content");
+      expect(houseFill("banner_300x250").clickUrl).toMatch(/utm_content=[a-z0-9-]+$/);
+    }
+  });
+
+  it("stays house-tier and unmetered however the copy rotates", () => {
+    for (const format of FORMATS) {
+      const fill = houseFill(format);
+      expect(fill.campaignId).toBe("house");
+      expect(fill.creativeId).toBe("house");
+    }
+  });
+});