Skip to content

Commit d29bbff

Browse files
ralyodioclaude
andauthored
Run ads where the subject is what people are asking about (#248)
Targeting on what chovy.com's SameBrain says founders are trying to build this week, and the 90 days free that comes with turning it on. Two halves have to agree before a campaign is preferred: its own subject has to be trending AND the page being filled has to be about that subject. Either half alone is how an ad network ends up putting a crypto ad on a recipe blog — one is "this is in the news", the other is ordinary contextual targeting. The page's subject is not new work: the autoblog already keeps a hand-checked subject list per site (lx_site.master_keywords) and that is what is read. It is a preference, not a rule. A match carries four times its own bid weight into the same bid-weighted lottery, so it wins more fills and every other eligible campaign still rotates — the auction's hard requirement. The promo separates two questions that look like one. A campaign inside its ninety days COMPETES for real placement (that is what premium delivery means; parked in the backfill pool it would never serve at all next to a paying advertiser) and BOOKS as tier='free', because nothing is charged. So no spend and no publisher earnings are written for a payment that is not happening — which matters most here, where one account owns both sides of nearly every fill and a paid booking would read as revenue on the ROI dashboard. It is not revenue. It is a discount we gave ourselves. Billing at zero is settled in resolveClick rather than inside ad_charge_click: that function is the hottest SQL in the product and its migrations are applied by hand, so teaching it about promos would put a new failure mode in front of every click on the network. Degrading is the design everywhere it touches. A trend list older than 36 hours stops steering delivery instead of targeting last week forever; a failed pull leaves the stored list; a missing table or column — the migration is applied by hand, so a deploy can lead it — reads as "nobody is trending", which is exactly how serving behaved yesterday. On a network where nobody has opted in this costs one cached query a minute and nothing per fill. $0.02 CPC is stated once, in lib/ads/pricing.ts, and quoted from there by the dashboard, the CLI and the promo row. Known gap recorded there: it is smaller than one credit (5c), and nothing bills a fraction of a credit today, so after a promo ends a trending campaign bills at its ordinary bid until sub-credit metering exists. The migration is added, not applied — apply it one file at a time. SAMEBRAIN_URL and SAMEBRAIN_SECRET go in the vault and on the deployment, never in a committed .env file. Claude-Session: https://claude.ai/code/session_01QuUkTrofSjQ15j79mRuy4f Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 77b05c6 commit d29bbff

22 files changed

Lines changed: 2237 additions & 33 deletions

File tree

‎.env.example‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ WORKER_SHARED_SECRET=change-me-to-a-long-random-string
5959
# Cron (Vercel cron will pass this header)
6060
CRON_SECRET=change-me-to-a-long-random-string
6161

62+
# SameBrain — the trend source the ad network targets on (chovy.com). The
63+
# secret is the same value that service has; put both in the vault
64+
# (logicsrc team `crawlproof-com--prod`) and set them on the deployment, never
65+
# in a committed .env file. Unset means no trend ingestion, which degrades to
66+
# ordinary untargeted delivery rather than to an error.
67+
# Ingest hourly: POST /api/cron/ad-trends with the cron secret.
68+
SAMEBRAIN_URL=https://chovy.com
69+
SAMEBRAIN_SECRET=
70+
6271
# Paid scan engines
6372
# Backend text generation for Autoblog/profile enrichment:
6473
# auto = Anthropic when available, otherwise OpenAI

‎README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,38 @@ The endpoints underneath are the same ones anything else can call:
173173
| `GET /api/ads/v1/earnings?days=` | ad delivery and money, both sides |
174174
| `GET/POST /api/ads/v1/campaigns` | list and run campaigns |
175175
| `GET/POST /api/ads/v1/slots` | list and create publisher slots |
176+
| `GET /api/ads/v1/trends` | what is trending, and how old the list is |
177+
178+
### Trending-topic targeting
179+
180+
An advertiser can run where the subject is what people are asking about right
181+
now:
182+
183+
```sh
184+
crawlproof ads trends
185+
crawlproof ads create https://example.com/launch --trending
186+
crawlproof ads trending crawlproof-ad-144 on
187+
```
188+
189+
Two halves have to agree before a campaign is preferred: its own subject has to
190+
be trending **and** the page being filled has to be about that subject. Either
191+
half alone is how an ad network ends up putting a crypto ad on a recipe blog.
192+
The page's subject comes from what CrawlProof already knows about the site —
193+
the autoblog's `lx_site.master_keywords`, its niche, the slot's niche.
194+
195+
The signals come from **SameBrain** on chovy.com (what founders are asking to
196+
build this week), pulled hourly by `POST /api/cron/ad-trends` with the cron
197+
secret and stored in `ad_trend_topics`. A list older than 36 hours stops
198+
steering delivery entirely rather than targeting last week's subjects forever,
199+
and a failed pull is never an outage: delivery falls back to the ordinary
200+
auction.
201+
202+
Turning it on grants **90 days** during which the campaign serves and meters
203+
exactly as usual and every click is billed at **$0.00** (`ad_promos`; the rate
204+
afterwards is $0.02/click). A promo fill competes for real placement but books
205+
under `tier='free'`, because nothing is charged — so no spend and no publisher
206+
earnings are written for a payment that did not happen, and on a network where
207+
one account owns both sides it can never read as revenue.
176208

177209
## Product flows
178210

‎app/(app)/dashboard/ads/[id]/page.tsx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-act
77
import { CampaignTrend } from "@/components/ads/campaign-trend";
88
import { getCampaignDailySeries } from "@/lib/ads/series";
99
import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status";
10+
import { promoStateForCampaign } from "@/lib/ads/promos";
11+
import { TRENDING_CPC_CENTS } from "@/lib/ads/pricing";
1012

1113
export const metadata = { title: "Campaign" };
1214

@@ -79,6 +81,11 @@ export default async function CampaignDetailPage({
7981
.maybeSingle(),
8082
]);
8183

84+
// The promo is its own read: it lives in ad_promos, and the row may not
85+
// exist at all (nobody has enabled trending targeting, or the migration has
86+
// not been applied here yet). Both read as "no promo".
87+
const promo = await promoStateForCampaign(supabase, id);
88+
8289
const impressions = (stats?.impressions as number) ?? 0;
8390
const clicks = (stats?.clicks as number) ?? 0;
8491
const freeImpressions = (stats?.free_impressions as number) ?? 0;
@@ -152,6 +159,28 @@ export default async function CampaignDetailPage({
152159
</p>
153160
)}
154161

162+
{/* The 90 days. Shown whether or not it is still running, because
163+
"your clicks started costing money last Tuesday" is the single most
164+
useful thing this page can say to somebody on the promo. */}
165+
{promo.endsAt && (
166+
<p className="mt-4 rounded-md border border-[var(--color-border)] p-3 text-sm">
167+
{promo.active ? (
168+
<>
169+
<span className="font-semibold">Trending promo: </span>
170+
{promo.daysRemaining} day{promo.daysRemaining === 1 ? "" : "s"} left. Clicks are
171+
billed at <span className="font-mono">$0.00</span> until{" "}
172+
{promo.endsAt.slice(0, 10)}, then{" "}
173+
<span className="font-mono">${(TRENDING_CPC_CENTS / 100).toFixed(2)}</span> per click.
174+
</>
175+
) : (
176+
<>
177+
<span className="font-semibold">Trending promo ended </span>
178+
{promo.endsAt.slice(0, 10)}. Clicks bill normally.
179+
</>
180+
)}
181+
</p>
182+
)}
183+
155184
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
156185
<Stat label="Impressions" value={impressions.toLocaleString()} />
157186
<Stat label="Clicks" value={clicks.toLocaleString()} />

‎app/(app)/dashboard/ads/new/form.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export function NewAdForm() {
1919
const [budget, setBudget] = useState(5); // dollars/day
2020
const [bid, setBid] = useState(0.2); // dollars/click (max bid)
2121
const [name, setName] = useState("");
22+
// Trending targeting, and with it the 90 days. Off by default: it changes
23+
// where the ads run, and that is the advertiser's decision to make.
24+
const [trending, setTrending] = useState(false);
2225
const [brand, setBrand] = useState<SiteBrand | null>(null);
2326
const [creatives, setCreatives] = useState<AdCreative[]>([]);
2427
const [active, setActive] = useState<AdFormatId>("banner_300x250");
@@ -111,6 +114,7 @@ export function NewAdForm() {
111114
bidCredits: Math.max(1, Math.round((bid * 100) / 5)),
112115
brand,
113116
creatives,
117+
trendingTopics: trending,
114118
});
115119
if (!res.ok) {
116120
setError(res.error);
@@ -182,6 +186,22 @@ export function NewAdForm() {
182186
{generating ? "Designing ads…" : hasAds ? "Regenerate" : "Generate ads"}
183187
</button>
184188
</div>
189+
<label className="flex items-start gap-2 text-sm">
190+
<input
191+
type="checkbox"
192+
className="mt-1"
193+
checked={trending}
194+
onChange={(e) => setTrending(e.target.checked)}
195+
/>
196+
<span>
197+
<span className="font-medium">Target trending topics</span>
198+
<span className="block text-xs text-[var(--color-muted)]">
199+
Show this campaign where its subject is what people are asking about right now, on
200+
pages about that subject. Includes 90 days of premium delivery with every click
201+
billed at $0.00 — the rate after that is $0.02 per click.
202+
</span>
203+
</span>
204+
</label>
185205
</form>
186206

187207
{error && (

‎app/actions/ads.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
} from "@/lib/ads/creative";
1717
import type { SiteBrand } from "@/lib/ads/brand";
1818
import { MIN_PAYOUT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";
19+
import { cleanTopics } from "@/lib/ads/trending";
20+
import { grantTrendingPromo } from "@/lib/ads/promos";
1921
import { createCryptoPayout } from "@/lib/coinpay";
2022

2123
const ASSET_BUCKET = "ad-assets";
@@ -149,7 +151,9 @@ export async function saveCampaign(input: {
149151
brand?: SiteBrand | null;
150152
creatives: Partial<AdCreative>[];
151153
summary?: Partial<AdSummary> | null;
152-
}): Promise<{ ok: true; id: string; refSlug: string } | { ok: false; error: string }> {
154+
/** Prefer this campaign where its subject is trending, and take the 90 days. */
155+
trendingTopics?: boolean;
156+
}): Promise<{ ok: true; id: string; refSlug: string; promoDays?: number } | { ok: false; error: string }> {
153157
const supabase = await createClient();
154158
const {
155159
data: { user },
@@ -185,6 +189,15 @@ export async function saveCampaign(input: {
185189
brand: input.brand ?? {},
186190
};
187191
if (org.id) payload.organization_id = org.id;
192+
// Trending targeting, and the subjects it targets on. The subjects come from
193+
// the page's own words rather than anything typed here: a campaign claiming
194+
// topics its landing page never mentions is how contextual targeting gets
195+
// gamed, and this form has the page in front of it already.
196+
if (input.trendingTopics) {
197+
payload.trending_topics = true;
198+
const topics = cleanTopics([input.brand?.title, input.brand?.description, domainOf(check.url).split(".")[0]]);
199+
if (topics.length) payload.topics = topics;
200+
}
188201

189202
// Editorial prose for placements that live inside content. Only stored when
190203
// it actually describes where the campaign points: the user can edit the URL
@@ -217,8 +230,10 @@ export async function saveCampaign(input: {
217230
// of the schema. Retry without them rather than refusing to create the
218231
// campaign: prose is an enhancement, a campaign that cannot be saved is the
219232
// whole product failing. Same trade the impression short_code makes.
220-
if (campaign.error && /summary_|schema cache|column/i.test(campaign.error.message ?? "")) {
233+
if (campaign.error && /summary_|trending_topics|topics|schema cache|column/i.test(campaign.error.message ?? "")) {
221234
for (const key of Object.keys(summaryFields)) delete payload[key];
235+
delete payload.trending_topics;
236+
delete payload.topics;
222237
campaign = await supabase
223238
.from("ad_campaigns")
224239
.insert(payload)
@@ -249,8 +264,18 @@ export async function saveCampaign(input: {
249264
const { error: cErr } = await supabase.from("ad_creatives").insert(rows);
250265
if (cErr) return { ok: false, error: cErr.message };
251266

267+
// Turning trending targeting on is what earns the 90 days, and the grant is
268+
// idempotent — a campaign saved twice does not get a second window. A
269+
// failure to grant never fails the save: the campaign runs and bills
270+
// normally, which the detail page shows by having no promo line at all.
271+
let promoDays: number | undefined;
272+
if (input.trendingTopics) {
273+
const granted = await grantTrendingPromo(supabase, { userId: user.id, campaignId: campaign.data.id, note: "trending targeting enabled in the dashboard" });
274+
promoDays = granted.state.active ? granted.state.daysRemaining : undefined;
275+
}
276+
252277
revalidatePath("/dashboard/ads");
253-
return { ok: true, id: campaign.data.id, refSlug: campaign.data.ref_slug };
278+
return { ok: true, id: campaign.data.id, refSlug: campaign.data.ref_slug, promoDays };
254279
}
255280

256281
// --- Campaign editing (advertiser) ---

‎app/api/ads/v1/campaigns/[id]/route.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
// GET the campaign and its delivery: impressions, clicks, spend, and the
44
// visits the tracker attributed to it (bucket ad:<ref>) on the
55
// caller's own sites.
6-
// PATCH { name?, daily_budget_cents?, bid_credits?, status? }
6+
// PATCH { name?, daily_budget_cents?, bid_credits?, status?, trending_topics?, topics? }
77
// status is active | paused | draft. Going active needs a creative.
8+
// trending_topics true is also what grants the 90-day promo, once.
89
// DELETE removes it, metering included. Pause keeps the history.
910
//
1011
// Same auth as the collection route. This is what `crawlproof ads show|pause|
@@ -13,7 +14,7 @@
1314
import { NextResponse, type NextRequest } from "next/server";
1415
import { serviceClient } from "@/lib/supabase/service";
1516
import { authenticateBearer } from "@/lib/sp/apiAuth";
16-
import { campaignStats, deleteCampaign, findCampaign, parseCampaignPatch, patchCampaign } from "@/lib/ads/campaigns";
17+
import { campaignStats, deleteCampaign, findCampaign, parseCampaignPatch, patchCampaign, withTargeting } from "@/lib/ads/campaigns";
1718
import { env } from "@/lib/env";
1819

1920
export const runtime = "nodejs";
@@ -38,7 +39,10 @@ export async function GET(req: NextRequest, ctx: Ctx) {
3839
const loaded = await load(req, ctx);
3940
if ("error" in loaded) return loaded.error;
4041
const stats = await campaignStats(loaded.sb, loaded.userId, loaded.campaign);
41-
return NextResponse.json({ ...withUrl(loaded.campaign), stats });
42+
// Targeting and the promo come from their own reads; see withTargeting for
43+
// why they are not columns on the campaign select.
44+
const campaign = await withTargeting(loaded.sb, loaded.campaign);
45+
return NextResponse.json({ ...withUrl(campaign), stats });
4246
}
4347

4448
export async function PATCH(req: NextRequest, ctx: Ctx) {

‎app/api/ads/v1/campaigns/route.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
// /api/ads/v1/campaigns — campaigns for a bearer-token caller.
22
//
3-
// POST { url, name?, daily_budget_cents?, bid_credits?, status? }
3+
// POST { url, name?, daily_budget_cents?, bid_credits?, status?,
4+
// trending_topics?, topics? }
45
// Read the page, write the creatives, save the campaign. Active unless
56
// status is "draft". A live campaign for the same URL is returned
67
// instead of a twin, with `existing: true`.
8+
// `trending_topics: true` opts into trending-topic targeting and grants
9+
// the 90-day premium promo — see lib/ads/promos.ts. Topics default to
10+
// what the landing page is about.
711
// GET ?limit=20
812
// The caller's campaigns, newest first.
913
//

‎app/api/ads/v1/trends/route.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// /api/ads/v1/trends — what is trending, for advertisers choosing targeting.
2+
//
3+
// GET ?window=7&limit=50&stale=1
4+
// The current signals, newest ingest, highest score first. `stale=1`
5+
// includes a list too old to steer delivery, which is how the CLI can
6+
// explain why nothing is being boosted rather than showing an empty
7+
// page that looks like a bug.
8+
//
9+
// Same bearer auth as the rest of /api/ads/v1: `Authorization: Bearer crp_…`.
10+
// Ingestion is not here — it runs on a schedule and is gated on the cron
11+
// secret (app/api/cron/ad-trends).
12+
13+
import { NextResponse, type NextRequest } from "next/server";
14+
import { serviceClient } from "@/lib/supabase/service";
15+
import { authenticateBearer } from "@/lib/sp/apiAuth";
16+
import { currentTrends } from "@/lib/ads/trends";
17+
import { TREND_MAX_AGE_HOURS, TREND_SOURCE, TREND_WINDOW_DAYS, trendsAreStale } from "@/lib/ads/trending";
18+
import { TRENDING_CPC_CENTS } from "@/lib/ads/pricing";
19+
import { PROMO_DAYS } from "@/lib/ads/trending";
20+
21+
export const runtime = "nodejs";
22+
export const dynamic = "force-dynamic";
23+
24+
export async function GET(req: NextRequest) {
25+
const auth = await authenticateBearer(req);
26+
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
27+
28+
const params = new URL(req.url).searchParams;
29+
const windowDays = Number(params.get("window")) || TREND_WINDOW_DAYS;
30+
const limit = Number(params.get("limit")) || 50;
31+
const includeStale = params.get("stale") === "1" || params.get("stale") === "true";
32+
33+
const now = Date.now();
34+
const signals = await currentTrends(serviceClient(), { windowDays, limit, includeStale, now });
35+
const newest = signals.reduce<string | null>(
36+
(latest, signal) => (!latest || (signal.ingestedAt ?? "") > latest ? (signal.ingestedAt ?? latest) : latest),
37+
null,
38+
);
39+
40+
return NextResponse.json({
41+
source: TREND_SOURCE,
42+
window_days: windowDays,
43+
ingested_at: newest,
44+
// A list this old no longer steers delivery; saying so is the difference
45+
// between "nothing is trending" and "the puller has been down since
46+
// Tuesday", which look identical from the outside.
47+
stale: trendsAreStale(newest, now),
48+
max_age_hours: TREND_MAX_AGE_HOURS,
49+
promo: { kind: "trending_premium_90", days: PROMO_DAYS, cpc_cents: TRENDING_CPC_CENTS },
50+
topics: signals.map((signal) => ({
51+
topic: signal.topic,
52+
score: signal.score,
53+
mentions: signal.mentions,
54+
prior_mentions: signal.priorMentions,
55+
generated_at: signal.generatedAt,
56+
ingested_at: signal.ingestedAt,
57+
})),
58+
});
59+
}

‎app/api/cron/ad-trends/route.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Pull the trend list in. Run it hourly.
2+
//
3+
// Server to server in both directions: this route is gated on CRON_SECRET the
4+
// same way every other cron route is, and it presents SAMEBRAIN_SECRET to the
5+
// source. Neither secret belongs in a committed .env file — both are set on
6+
// the Railway service and kept in the vault (logicsrc team
7+
// `crawlproof-com--prod`).
8+
//
9+
// A failed pull is not an outage. Serving keeps using the stored list until it
10+
// ages out (TREND_MAX_AGE_HOURS), and after that every campaign simply falls
11+
// back to its ordinary auction weight.
12+
13+
import { NextResponse } from "next/server";
14+
import { serviceClient } from "@/lib/supabase/service";
15+
import { env } from "@/lib/env";
16+
import { ingestTrends } from "@/lib/ads/trends";
17+
import { TREND_WINDOW_DAYS } from "@/lib/ads/trending";
18+
19+
export const runtime = "nodejs";
20+
export const dynamic = "force-dynamic";
21+
22+
export async function GET(req: Request) {
23+
return POST(req);
24+
}
25+
26+
export async function POST(req: Request) {
27+
const incoming =
28+
req.headers.get("x-cron-secret") ??
29+
req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
30+
// No cron secret configured means this route is closed, not open: it writes
31+
// the targeting signals every fill reads.
32+
if (!env.cronSecret || incoming !== env.cronSecret) {
33+
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
34+
}
35+
36+
const windowDays = Number(new URL(req.url).searchParams.get("window")) || TREND_WINDOW_DAYS;
37+
const result = await ingestTrends(serviceClient(), {
38+
url: env.samebrainUrl,
39+
secret: env.samebrainSecret,
40+
windowDays,
41+
});
42+
if (!result.ok) return NextResponse.json({ ok: false, error: result.error }, { status: result.status });
43+
return NextResponse.json(result);
44+
}

0 commit comments

Comments
 (0)