Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 82 additions & 15 deletions lib/ads/house.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,13 +87,18 @@ function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

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
// <pre> 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.
Expand Down Expand Up @@ -97,12 +160,12 @@ export function renderHouseAdHtml(format: AdFormatId, clickUrl: string): string
</body></html>`;
}

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",
Expand All @@ -114,21 +177,25 @@ 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",
creativeId: "house",
refSlug: "house",
creative,
clickUrl,
html: renderHouseAdHtml(format, clickUrl),
html: renderHouseAdHtml(format, clickUrl, copy),
text: renderCreativeText(creative, clickUrl, { label: "CRAWLPROOF ADS" }),
tier: "house",
};
Expand Down
67 changes: 67 additions & 0 deletions tests/contract/ads-house-rotation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const seen = new Set<string>();
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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");
}
});
});
Loading