From 75aa2210170d7e6b4dfe042f7e64c8c27fcfee54 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Fri, 11 Sep 2026 21:43:41 +0000
Subject: [PATCH] Run ads where the subject is what people are asking about
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01QuUkTrofSjQ15j79mRuy4f
---
.env.example | 9 +
README.md | 32 ++
app/(app)/dashboard/ads/[id]/page.tsx | 29 ++
app/(app)/dashboard/ads/new/form.tsx | 20 +
app/actions/ads.ts | 31 +-
app/api/ads/v1/campaigns/[id]/route.ts | 10 +-
app/api/ads/v1/campaigns/route.ts | 6 +-
app/api/ads/v1/trends/route.ts | 59 +++
app/api/cron/ad-trends/route.ts | 44 +++
cli/index.ts | 108 +++++-
lib/ads/campaign-request.ts | 34 +-
lib/ads/campaigns.ts | 136 ++++++-
lib/ads/pricing.ts | 19 +
lib/ads/promos.ts | 158 ++++++++
lib/ads/serve.ts | 117 +++++-
lib/ads/trendContext.ts | 157 ++++++++
lib/ads/trending.ts | 354 ++++++++++++++++++
lib/ads/trends.ts | 267 +++++++++++++
lib/env.ts | 7 +
.../20260911120000_ad_trend_targeting.sql | 122 ++++++
tests/ads-trending.test.ts | 289 ++++++++++++++
tests/contract/ads-trend-serving.test.ts | 262 +++++++++++++
22 files changed, 2237 insertions(+), 33 deletions(-)
create mode 100644 app/api/ads/v1/trends/route.ts
create mode 100644 app/api/cron/ad-trends/route.ts
create mode 100644 lib/ads/promos.ts
create mode 100644 lib/ads/trendContext.ts
create mode 100644 lib/ads/trending.ts
create mode 100644 lib/ads/trends.ts
create mode 100644 supabase/migrations/20260911120000_ad_trend_targeting.sql
create mode 100644 tests/ads-trending.test.ts
create mode 100644 tests/contract/ads-trend-serving.test.ts
diff --git a/.env.example b/.env.example
index f97bf824..d3c18a1e 100644
--- a/.env.example
+++ b/.env.example
@@ -59,6 +59,15 @@ WORKER_SHARED_SECRET=change-me-to-a-long-random-string
# Cron (Vercel cron will pass this header)
CRON_SECRET=change-me-to-a-long-random-string
+# SameBrain — the trend source the ad network targets on (chovy.com). The
+# secret is the same value that service has; put both in the vault
+# (logicsrc team `crawlproof-com--prod`) and set them on the deployment, never
+# in a committed .env file. Unset means no trend ingestion, which degrades to
+# ordinary untargeted delivery rather than to an error.
+# Ingest hourly: POST /api/cron/ad-trends with the cron secret.
+SAMEBRAIN_URL=https://chovy.com
+SAMEBRAIN_SECRET=
+
# Paid scan engines
# Backend text generation for Autoblog/profile enrichment:
# auto = Anthropic when available, otherwise OpenAI
diff --git a/README.md b/README.md
index 4f91fb0c..0bbba213 100644
--- a/README.md
+++ b/README.md
@@ -173,6 +173,38 @@ The endpoints underneath are the same ones anything else can call:
| `GET /api/ads/v1/earnings?days=` | ad delivery and money, both sides |
| `GET/POST /api/ads/v1/campaigns` | list and run campaigns |
| `GET/POST /api/ads/v1/slots` | list and create publisher slots |
+| `GET /api/ads/v1/trends` | what is trending, and how old the list is |
+
+### Trending-topic targeting
+
+An advertiser can run where the subject is what people are asking about right
+now:
+
+```sh
+crawlproof ads trends
+crawlproof ads create https://example.com/launch --trending
+crawlproof ads trending crawlproof-ad-144 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.
+The page's subject comes from what CrawlProof already knows about the site —
+the autoblog's `lx_site.master_keywords`, its niche, the slot's niche.
+
+The signals come from **SameBrain** on chovy.com (what founders are asking to
+build this week), pulled hourly by `POST /api/cron/ad-trends` with the cron
+secret and stored in `ad_trend_topics`. A list older than 36 hours stops
+steering delivery entirely rather than targeting last week's subjects forever,
+and a failed pull is never an outage: delivery falls back to the ordinary
+auction.
+
+Turning it on grants **90 days** during which the campaign serves and meters
+exactly as usual and every click is billed at **$0.00** (`ad_promos`; the rate
+afterwards is $0.02/click). A promo fill competes for real placement but books
+under `tier='free'`, because nothing is charged — so no spend and no publisher
+earnings are written for a payment that did not happen, and on a network where
+one account owns both sides it can never read as revenue.
## Product flows
diff --git a/app/(app)/dashboard/ads/[id]/page.tsx b/app/(app)/dashboard/ads/[id]/page.tsx
index 5ccd057d..38a7a5b2 100644
--- a/app/(app)/dashboard/ads/[id]/page.tsx
+++ b/app/(app)/dashboard/ads/[id]/page.tsx
@@ -7,6 +7,8 @@ import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-act
import { CampaignTrend } from "@/components/ads/campaign-trend";
import { getCampaignDailySeries } from "@/lib/ads/series";
import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status";
+import { promoStateForCampaign } from "@/lib/ads/promos";
+import { TRENDING_CPC_CENTS } from "@/lib/ads/pricing";
export const metadata = { title: "Campaign" };
@@ -79,6 +81,11 @@ export default async function CampaignDetailPage({
.maybeSingle(),
]);
+ // The promo is its own read: it lives in ad_promos, and the row may not
+ // exist at all (nobody has enabled trending targeting, or the migration has
+ // not been applied here yet). Both read as "no promo".
+ const promo = await promoStateForCampaign(supabase, id);
+
const impressions = (stats?.impressions as number) ?? 0;
const clicks = (stats?.clicks as number) ?? 0;
const freeImpressions = (stats?.free_impressions as number) ?? 0;
@@ -152,6 +159,28 @@ export default async function CampaignDetailPage({
)}
+ {/* The 90 days. Shown whether or not it is still running, because
+ "your clicks started costing money last Tuesday" is the single most
+ useful thing this page can say to somebody on the promo. */}
+ {promo.endsAt && (
+
diff --git a/app/(app)/dashboard/ads/new/form.tsx b/app/(app)/dashboard/ads/new/form.tsx
index f737486d..66701440 100644
--- a/app/(app)/dashboard/ads/new/form.tsx
+++ b/app/(app)/dashboard/ads/new/form.tsx
@@ -19,6 +19,9 @@ export function NewAdForm() {
const [budget, setBudget] = useState(5); // dollars/day
const [bid, setBid] = useState(0.2); // dollars/click (max bid)
const [name, setName] = useState("");
+ // Trending targeting, and with it the 90 days. Off by default: it changes
+ // where the ads run, and that is the advertiser's decision to make.
+ const [trending, setTrending] = useState(false);
const [brand, setBrand] = useState(null);
const [creatives, setCreatives] = useState([]);
const [active, setActive] = useState("banner_300x250");
@@ -111,6 +114,7 @@ export function NewAdForm() {
bidCredits: Math.max(1, Math.round((bid * 100) / 5)),
brand,
creatives,
+ trendingTopics: trending,
});
if (!res.ok) {
setError(res.error);
@@ -182,6 +186,22 @@ export function NewAdForm() {
{generating ? "Designing ads…" : hasAds ? "Regenerate" : "Generate ads"}
+
{error && (
diff --git a/app/actions/ads.ts b/app/actions/ads.ts
index 41753a1f..2b44bbde 100644
--- a/app/actions/ads.ts
+++ b/app/actions/ads.ts
@@ -16,6 +16,8 @@ import {
} from "@/lib/ads/creative";
import type { SiteBrand } from "@/lib/ads/brand";
import { MIN_PAYOUT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";
+import { cleanTopics } from "@/lib/ads/trending";
+import { grantTrendingPromo } from "@/lib/ads/promos";
import { createCryptoPayout } from "@/lib/coinpay";
const ASSET_BUCKET = "ad-assets";
@@ -149,7 +151,9 @@ export async function saveCampaign(input: {
brand?: SiteBrand | null;
creatives: Partial[];
summary?: Partial | null;
-}): Promise<{ ok: true; id: string; refSlug: string } | { ok: false; error: string }> {
+ /** Prefer this campaign where its subject is trending, and take the 90 days. */
+ trendingTopics?: boolean;
+}): Promise<{ ok: true; id: string; refSlug: string; promoDays?: number } | { ok: false; error: string }> {
const supabase = await createClient();
const {
data: { user },
@@ -185,6 +189,15 @@ export async function saveCampaign(input: {
brand: input.brand ?? {},
};
if (org.id) payload.organization_id = org.id;
+ // Trending targeting, and the subjects it targets on. The subjects come from
+ // the page's own words rather than anything typed here: a campaign claiming
+ // topics its landing page never mentions is how contextual targeting gets
+ // gamed, and this form has the page in front of it already.
+ if (input.trendingTopics) {
+ payload.trending_topics = true;
+ const topics = cleanTopics([input.brand?.title, input.brand?.description, domainOf(check.url).split(".")[0]]);
+ if (topics.length) payload.topics = topics;
+ }
// Editorial prose for placements that live inside content. Only stored when
// it actually describes where the campaign points: the user can edit the URL
@@ -217,8 +230,10 @@ export async function saveCampaign(input: {
// of the schema. Retry without them rather than refusing to create the
// campaign: prose is an enhancement, a campaign that cannot be saved is the
// whole product failing. Same trade the impression short_code makes.
- if (campaign.error && /summary_|schema cache|column/i.test(campaign.error.message ?? "")) {
+ if (campaign.error && /summary_|trending_topics|topics|schema cache|column/i.test(campaign.error.message ?? "")) {
for (const key of Object.keys(summaryFields)) delete payload[key];
+ delete payload.trending_topics;
+ delete payload.topics;
campaign = await supabase
.from("ad_campaigns")
.insert(payload)
@@ -249,8 +264,18 @@ export async function saveCampaign(input: {
const { error: cErr } = await supabase.from("ad_creatives").insert(rows);
if (cErr) return { ok: false, error: cErr.message };
+ // Turning trending targeting on is what earns the 90 days, and the grant is
+ // idempotent — a campaign saved twice does not get a second window. A
+ // failure to grant never fails the save: the campaign runs and bills
+ // normally, which the detail page shows by having no promo line at all.
+ let promoDays: number | undefined;
+ if (input.trendingTopics) {
+ const granted = await grantTrendingPromo(supabase, { userId: user.id, campaignId: campaign.data.id, note: "trending targeting enabled in the dashboard" });
+ promoDays = granted.state.active ? granted.state.daysRemaining : undefined;
+ }
+
revalidatePath("/dashboard/ads");
- return { ok: true, id: campaign.data.id, refSlug: campaign.data.ref_slug };
+ return { ok: true, id: campaign.data.id, refSlug: campaign.data.ref_slug, promoDays };
}
// --- Campaign editing (advertiser) ---
diff --git a/app/api/ads/v1/campaigns/[id]/route.ts b/app/api/ads/v1/campaigns/[id]/route.ts
index 04cea3ac..e95e4262 100644
--- a/app/api/ads/v1/campaigns/[id]/route.ts
+++ b/app/api/ads/v1/campaigns/[id]/route.ts
@@ -3,8 +3,9 @@
// GET the campaign and its delivery: impressions, clicks, spend, and the
// visits the tracker attributed to it (bucket ad:) on the
// caller's own sites.
-// PATCH { name?, daily_budget_cents?, bid_credits?, status? }
+// PATCH { name?, daily_budget_cents?, bid_credits?, status?, trending_topics?, topics? }
// status is active | paused | draft. Going active needs a creative.
+// trending_topics true is also what grants the 90-day promo, once.
// DELETE removes it, metering included. Pause keeps the history.
//
// Same auth as the collection route. This is what `crawlproof ads show|pause|
@@ -13,7 +14,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { serviceClient } from "@/lib/supabase/service";
import { authenticateBearer } from "@/lib/sp/apiAuth";
-import { campaignStats, deleteCampaign, findCampaign, parseCampaignPatch, patchCampaign } from "@/lib/ads/campaigns";
+import { campaignStats, deleteCampaign, findCampaign, parseCampaignPatch, patchCampaign, withTargeting } from "@/lib/ads/campaigns";
import { env } from "@/lib/env";
export const runtime = "nodejs";
@@ -38,7 +39,10 @@ export async function GET(req: NextRequest, ctx: Ctx) {
const loaded = await load(req, ctx);
if ("error" in loaded) return loaded.error;
const stats = await campaignStats(loaded.sb, loaded.userId, loaded.campaign);
- return NextResponse.json({ ...withUrl(loaded.campaign), stats });
+ // Targeting and the promo come from their own reads; see withTargeting for
+ // why they are not columns on the campaign select.
+ const campaign = await withTargeting(loaded.sb, loaded.campaign);
+ return NextResponse.json({ ...withUrl(campaign), stats });
}
export async function PATCH(req: NextRequest, ctx: Ctx) {
diff --git a/app/api/ads/v1/campaigns/route.ts b/app/api/ads/v1/campaigns/route.ts
index 3ed13408..5ea19c63 100644
--- a/app/api/ads/v1/campaigns/route.ts
+++ b/app/api/ads/v1/campaigns/route.ts
@@ -1,9 +1,13 @@
// /api/ads/v1/campaigns — campaigns for a bearer-token caller.
//
-// POST { url, name?, daily_budget_cents?, bid_credits?, status? }
+// POST { url, name?, daily_budget_cents?, bid_credits?, status?,
+// trending_topics?, topics? }
// Read the page, write the creatives, save the campaign. Active unless
// status is "draft". A live campaign for the same URL is returned
// instead of a twin, with `existing: true`.
+// `trending_topics: true` opts into trending-topic targeting and grants
+// the 90-day premium promo — see lib/ads/promos.ts. Topics default to
+// what the landing page is about.
// GET ?limit=20
// The caller's campaigns, newest first.
//
diff --git a/app/api/ads/v1/trends/route.ts b/app/api/ads/v1/trends/route.ts
new file mode 100644
index 00000000..dff2bd8f
--- /dev/null
+++ b/app/api/ads/v1/trends/route.ts
@@ -0,0 +1,59 @@
+// /api/ads/v1/trends — what is trending, for advertisers choosing targeting.
+//
+// GET ?window=7&limit=50&stale=1
+// The current signals, newest ingest, highest score first. `stale=1`
+// includes a list too old to steer delivery, which is how the CLI can
+// explain why nothing is being boosted rather than showing an empty
+// page that looks like a bug.
+//
+// Same bearer auth as the rest of /api/ads/v1: `Authorization: Bearer crp_…`.
+// Ingestion is not here — it runs on a schedule and is gated on the cron
+// secret (app/api/cron/ad-trends).
+
+import { NextResponse, type NextRequest } from "next/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { authenticateBearer } from "@/lib/sp/apiAuth";
+import { currentTrends } from "@/lib/ads/trends";
+import { TREND_MAX_AGE_HOURS, TREND_SOURCE, TREND_WINDOW_DAYS, trendsAreStale } from "@/lib/ads/trending";
+import { TRENDING_CPC_CENTS } from "@/lib/ads/pricing";
+import { PROMO_DAYS } from "@/lib/ads/trending";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(req: NextRequest) {
+ const auth = await authenticateBearer(req);
+ if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
+ const params = new URL(req.url).searchParams;
+ const windowDays = Number(params.get("window")) || TREND_WINDOW_DAYS;
+ const limit = Number(params.get("limit")) || 50;
+ const includeStale = params.get("stale") === "1" || params.get("stale") === "true";
+
+ const now = Date.now();
+ const signals = await currentTrends(serviceClient(), { windowDays, limit, includeStale, now });
+ const newest = signals.reduce(
+ (latest, signal) => (!latest || (signal.ingestedAt ?? "") > latest ? (signal.ingestedAt ?? latest) : latest),
+ null,
+ );
+
+ return NextResponse.json({
+ source: TREND_SOURCE,
+ window_days: windowDays,
+ ingested_at: newest,
+ // A list this old no longer steers delivery; saying so is the difference
+ // between "nothing is trending" and "the puller has been down since
+ // Tuesday", which look identical from the outside.
+ stale: trendsAreStale(newest, now),
+ max_age_hours: TREND_MAX_AGE_HOURS,
+ promo: { kind: "trending_premium_90", days: PROMO_DAYS, cpc_cents: TRENDING_CPC_CENTS },
+ topics: signals.map((signal) => ({
+ topic: signal.topic,
+ score: signal.score,
+ mentions: signal.mentions,
+ prior_mentions: signal.priorMentions,
+ generated_at: signal.generatedAt,
+ ingested_at: signal.ingestedAt,
+ })),
+ });
+}
diff --git a/app/api/cron/ad-trends/route.ts b/app/api/cron/ad-trends/route.ts
new file mode 100644
index 00000000..73f11963
--- /dev/null
+++ b/app/api/cron/ad-trends/route.ts
@@ -0,0 +1,44 @@
+// Pull the trend list in. Run it hourly.
+//
+// Server to server in both directions: this route is gated on CRON_SECRET the
+// same way every other cron route is, and it presents SAMEBRAIN_SECRET to the
+// source. Neither secret belongs in a committed .env file — both are set on
+// the Railway service and kept in the vault (logicsrc team
+// `crawlproof-com--prod`).
+//
+// A failed pull is not an outage. Serving keeps using the stored list until it
+// ages out (TREND_MAX_AGE_HOURS), and after that every campaign simply falls
+// back to its ordinary auction weight.
+
+import { NextResponse } from "next/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { env } from "@/lib/env";
+import { ingestTrends } from "@/lib/ads/trends";
+import { TREND_WINDOW_DAYS } from "@/lib/ads/trending";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(req: Request) {
+ return POST(req);
+}
+
+export async function POST(req: Request) {
+ const incoming =
+ req.headers.get("x-cron-secret") ??
+ req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
+ // No cron secret configured means this route is closed, not open: it writes
+ // the targeting signals every fill reads.
+ if (!env.cronSecret || incoming !== env.cronSecret) {
+ return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
+ }
+
+ const windowDays = Number(new URL(req.url).searchParams.get("window")) || TREND_WINDOW_DAYS;
+ const result = await ingestTrends(serviceClient(), {
+ url: env.samebrainUrl,
+ secret: env.samebrainSecret,
+ windowDays,
+ });
+ if (!result.ok) return NextResponse.json({ ok: false, error: result.error }, { status: result.status });
+ return NextResponse.json(result);
+}
diff --git a/cli/index.ts b/cli/index.ts
index a98c31bf..9ea518cb 100644
--- a/cli/index.ts
+++ b/cli/index.ts
@@ -249,10 +249,26 @@ export function campaignBodyFromArgs(args: Args): Record {
if (typeof args.flags.name === "string") body.name = args.flags.name;
if (typeof args.flags.budget === "string") body.daily_budget_cents = Number(args.flags.budget);
if (typeof args.flags.bid === "string") body.bid_credits = Number(args.flags.bid);
+ // --trending both turns the targeting on and is what earns the 90 days.
+ if (args.flags.trending) body.trending_topics = true;
+ if (typeof args.flags.topics === "string") body.topics = args.flags.topics.split(",").map((topic) => topic.trim());
body.status = args.flags.draft ? "draft" : "active";
return body;
}
+/** One line about a campaign's promo, or nothing at all. Pure, for tests. */
+export function promoLine(promo: unknown): string {
+ const state = (promo ?? null) as { active?: boolean; daysRemaining?: number; endsAt?: string | null; cpcCents?: number } | null;
+ if (!state) return "";
+ if (state.active) {
+ const days = Number(state.daysRemaining) || 0;
+ const rate = Number(state.cpcCents) ? ` (then $${(Number(state.cpcCents) / 100).toFixed(2)}/click)` : "";
+ return ` promo: ${days} day${days === 1 ? "" : "s"} left, clicks billed at $0.00${rate}\n`;
+ }
+ const ended = state.endsAt ? ` (ended ${String(state.endsAt).slice(0, 10)})` : "";
+ return ` promo: over${ended}, clicks bill normally\n`;
+}
+
/** The request body `crawlproof slots create` sends, from its flags. Pure, for tests. */
export function slotBodyFromArgs(args: Args): Record {
const body: Record = { site: args.positional[1] };
@@ -268,7 +284,7 @@ async function cmdAds(args: Args): Promise {
const sub = args.positional[0];
if (sub === "create") {
if (!args.positional[1]) {
- console.error("usage: crawlproof ads create [--name=N] [--budget=CENTS] [--bid=CREDITS] [--draft] [--json]");
+ console.error("usage: crawlproof ads create [--name=N] [--budget=CENTS] [--bid=CREDITS] [--trending] [--topics=a,b] [--draft] [--json]");
return 2;
}
const { status, json } = await apiCall(args, "POST", "/api/ads/v1/campaigns", campaignBodyFromArgs(args));
@@ -284,6 +300,64 @@ async function cmdAds(args: Args): Promise {
}
return 0;
}
+ if (sub === "trends") {
+ const windowDays = (args.flags.window as string | undefined) ?? "7";
+ const limit = (args.flags.limit as string | undefined) ?? "20";
+ const stale = args.flags.stale ? "&stale=1" : "";
+ const { status, json } = await apiCall(args, "GET", `/api/ads/v1/trends?window=${encodeURIComponent(windowDays)}&limit=${encodeURIComponent(limit)}${stale}`);
+ if (status >= 400) {
+ console.error(`ads trends failed: ${status} ${json.error ?? ""}`);
+ return 1;
+ }
+ if (args.flags.json) {
+ process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
+ return 0;
+ }
+ const topics = (json.topics as Record[]) ?? [];
+ const promo = (json.promo ?? {}) as { days?: number; cpc_cents?: number };
+ // A stale list steers nothing, and saying so is the difference between
+ // "nothing is trending" and "the puller has been down since Tuesday".
+ if (json.stale) {
+ process.stdout.write(
+ `The trend list is stale (last ingest ${json.ingested_at ?? "never"}); nothing is being boosted on it.\n`,
+ );
+ }
+ if (!topics.length) process.stdout.write("No trending topics stored.\n");
+ for (const topic of topics) {
+ process.stdout.write(
+ `${String(topic.score).padStart(8)} ${String(topic.topic).padEnd(28)} ${topic.mentions} mention(s), was ${topic.prior_mentions}\n`,
+ );
+ }
+ if (topics.length) {
+ process.stdout.write(
+ `\nTarget these with: crawlproof ads create --trending (${promo.days ?? 90} days free, then $${((promo.cpc_cents ?? 2) / 100).toFixed(2)}/click)\n`,
+ );
+ }
+ return 0;
+ }
+ if (sub === "trending") {
+ const ref = args.positional[1];
+ const want = (args.positional[2] ?? "on").toLowerCase();
+ if (!ref || !["on", "off"].includes(want)) {
+ console.error("usage: crawlproof ads trending on|off [--topics=a,b]");
+ return 2;
+ }
+ const body: Record = { trending_topics: want === "on" };
+ if (typeof args.flags.topics === "string") body.topics = args.flags.topics.split(",").map((topic) => topic.trim());
+ const { status, json } = await apiCall(args, "PATCH", `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`, body);
+ if (status >= 400) {
+ console.error(`ads trending failed: ${status} ${json.error ?? ""}`);
+ return 1;
+ }
+ if (args.flags.json) {
+ process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
+ return 0;
+ }
+ const topics = (json.topics as string[]) ?? [];
+ process.stdout.write(`${json.ref_slug} trending targeting ${json.trending_topics ? "on" : "off"}${topics.length ? ` — ${topics.join(", ")}` : ""}\n`);
+ process.stdout.write(promoLine(json.promo));
+ return 0;
+ }
if (sub === "show" || sub === "pause" || sub === "resume" || sub === "budget" || sub === "delete") {
const ref = args.positional[1];
if (!ref) {
@@ -325,6 +399,11 @@ async function cmdAds(args: Args): Promise {
}
const stats = json.stats as Record | undefined;
process.stdout.write(`${json.status} ${json.ref_slug} ${json.name}\n ${json.destination_url}\n ${json.daily_budget_cents}¢/day, bid ${json.bid_credits ?? "default"}\n`);
+ if (json.trending_topics) {
+ const topics = (json.topics as string[]) ?? [];
+ process.stdout.write(` trending targeting on${topics.length ? ` — ${topics.join(", ")}` : ""}\n`);
+ }
+ process.stdout.write(promoLine(json.promo));
if (stats) {
const visits = stats.visits as { total: number } | undefined;
process.stdout.write(
@@ -347,11 +426,13 @@ async function cmdAds(args: Args): Promise {
}
if (!campaigns.length) process.stdout.write("No campaigns yet.\n");
for (const c of campaigns) {
- process.stdout.write(`${String(c.status).padEnd(8)} ${String(c.ref_slug).padEnd(20)} ${c.name} ${c.destination_url}\n`);
+ const promo = (c.promo ?? null) as { active?: boolean; daysRemaining?: number } | null;
+ const mark = c.trending_topics ? (promo?.active ? ` [trending · ${promo.daysRemaining}d free]` : " [trending]") : "";
+ process.stdout.write(`${String(c.status).padEnd(8)} ${String(c.ref_slug).padEnd(20)} ${c.name} ${c.destination_url}${mark}\n`);
}
return 0;
}
- console.error(`unknown: crawlproof ads ${sub} (expected: create | list | show | pause | resume | budget | delete)`);
+ console.error(`unknown: crawlproof ads ${sub} (expected: create | list | show | pause | resume | budget | delete | trending | trends)`);
return 2;
}
@@ -540,18 +621,33 @@ COMMANDS
Defaults --event to "pageview". Project id can also come from
CRAWLPROOF_PROJECT. Override host with CRAWLPROOF_SITE_URL.
- ads create [--name=N] [--budget=CENTS] [--bid=CREDITS] [--draft] [--json]
+ ads create [--name=N] [--budget=CENTS] [--bid=CREDITS] [--trending]
+ [--topics=a,b] [--draft] [--json]
Run an ad campaign for a URL: CrawlProof reads the page, writes the
creatives and starts serving (active unless --draft). A URL that
already has a live campaign gets that campaign back. Needs an API
token (CRAWLPROOF_TOKEN, from Social → API tokens).
+ --trending prefers this campaign on pages whose subject is what
+ people are asking about right now, and earns 90 days during which
+ every click is billed at $0.00 (the rate after that is $0.02/click).
+
+ ads trends [--window=7] [--limit=20] [--stale] [--json]
+ What is trending, highest score first, with how many separate
+ parties used each subject this window and last. --stale shows a
+ list too old to steer delivery instead of hiding it.
+
+ ads trending on|off [--topics=a,b] [--json]
+ Turn trending targeting on or off for a campaign. Turning it on is
+ what grants the 90 days, once; turning it off later does not take
+ the remaining days away.
ads list [--limit=20] [--json]
Your campaigns, newest first.
ads show [--json]
- One campaign with its delivery: impressions, clicks, spend, and the
- visits the tracker attributed to it on your own sites.
+ One campaign with its delivery: impressions, clicks, spend, the
+ visits the tracker attributed to it on your own sites, and the days
+ left on its promo.
ads pause | ads resume | ads budget
Change a campaign in place. A ref looks like crawlproof-ad-144.
diff --git a/lib/ads/campaign-request.ts b/lib/ads/campaign-request.ts
index 1965f0c2..c2555893 100644
--- a/lib/ads/campaign-request.ts
+++ b/lib/ads/campaign-request.ts
@@ -5,6 +5,7 @@
// by tests and could be by a client.
import { isAllowedTargetUrl } from "@/lib/rateLimit";
+import { cleanTopics } from "@/lib/ads/trending";
export type CampaignStatus = "active" | "draft";
@@ -14,8 +15,27 @@ export type CampaignRequest = {
dailyBudgetCents?: number;
bidCredits?: number;
status?: CampaignStatus;
+ /** Prefer this campaign where its subject is what people are asking about. */
+ trendingTopics?: boolean;
+ /** The subjects it is about. Derived from the page when the caller says nothing. */
+ topics?: string[];
};
+/**
+ * A boolean as somebody typed it.
+ *
+ * `--trending` from a shell arrives as the string "true", a JSON caller sends
+ * a real boolean, and a form sends "on". Anything else is not a yes.
+ */
+function asBoolean(value: unknown): boolean | undefined {
+ if (value === undefined || value === null) return undefined;
+ if (typeof value === "boolean") return value;
+ const text = String(value).trim().toLowerCase();
+ if (["true", "1", "yes", "on"].includes(text)) return true;
+ if (["false", "0", "no", "off", ""].includes(text)) return false;
+ return undefined;
+}
+
export function domainOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
@@ -53,6 +73,11 @@ export function parseCampaignRequest(body: Record): { ok: true;
request.bidCredits = Math.min(200, Math.round(n));
}
if (statusRaw === "active" || statusRaw === "draft") request.status = statusRaw;
+
+ const trending = asBoolean(body.trending_topics ?? body.trendingTopics ?? body.trending);
+ if (trending !== undefined) request.trendingTopics = trending;
+ const topics = cleanTopics(body.topics);
+ if (topics.length) request.topics = topics;
return { ok: true, request, url: check.url };
}
@@ -61,6 +86,8 @@ export type CampaignPatch = {
dailyBudgetCents?: number;
bidCredits?: number;
status?: "active" | "paused" | "draft";
+ trendingTopics?: boolean;
+ topics?: string[];
};
/** Pure: a PATCH body, normalised with the dashboard's clamps. Empty is an error. */
@@ -88,7 +115,12 @@ export function parseCampaignPatch(body: Record): { ok: true; p
}
patch.status = body.status;
}
- if (!Object.keys(patch).length) return { ok: false, error: "Nothing to change: send name, daily_budget_cents, bid_credits or status." };
+ const trending = asBoolean(body.trending_topics ?? body.trendingTopics ?? body.trending);
+ if (trending !== undefined) patch.trendingTopics = trending;
+ if (body.topics !== undefined) patch.topics = cleanTopics(body.topics);
+ if (!Object.keys(patch).length) {
+ return { ok: false, error: "Nothing to change: send name, daily_budget_cents, bid_credits, status, trending_topics or topics." };
+ }
return { ok: true, patch };
}
diff --git a/lib/ads/campaigns.ts b/lib/ads/campaigns.ts
index d2c0e221..ce7ef8d9 100644
--- a/lib/ads/campaigns.ts
+++ b/lib/ads/campaigns.ts
@@ -19,6 +19,8 @@ import { getOrCreateDefaultOrg } from "@/lib/orgs";
import { generateAdCreatives, cleanSummary, creativesFromCopy, templateCopy, summaryDomain, type AdCreative, type AdSummary } from "@/lib/ads/creative";
import { extractSiteBrand, type SiteBrand } from "@/lib/ads/brand";
import { DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";
+import { cleanTopics, promoState, type PromoState } from "@/lib/ads/trending";
+import { grantTrendingPromo, promoForCampaign, promosForCampaigns } from "@/lib/ads/promos";
export type CampaignSummary = {
id: string;
@@ -33,6 +35,12 @@ export type CampaignSummary = {
dashboard_url?: string;
/** True when a live campaign for this URL already existed and was returned instead. */
existing?: boolean;
+ /** Opted into trending-topic targeting. */
+ trending_topics?: boolean;
+ /** The subjects this campaign is about. */
+ topics?: string[];
+ /** The 90-day premium promo, when there is one. */
+ promo?: PromoState & { kind: string } | null;
};
export type CampaignResult =
@@ -76,7 +84,55 @@ function summaryColumns(summary: AdSummary | null | undefined, domain: string):
};
}
-const schemaLag = (message: string | undefined) => /organization_id|summary_|schema cache|column/i.test(message ?? "");
+const schemaLag = (message: string | undefined) => /organization_id|summary_|trending_topics|topics|schema cache|column/i.test(message ?? "");
+
+/** Columns a hand-applied migration may not have created yet. Dropped on retry. */
+const OPTIONAL_COLUMNS = ["organization_id", "trending_topics", "topics"];
+const isOptionalColumn = (key: string) => OPTIONAL_COLUMNS.includes(key) || key.startsWith("summary_");
+
+/**
+ * Trending targeting and promo state for campaigns, read separately.
+ *
+ * A separate query rather than two more columns on every campaign select, for
+ * the same reason `campaignSummary` is separate in lib/ads/serve.ts: these
+ * columns ride behind a migration applied by hand, and a select naming a
+ * column that does not exist yet returns nothing at all — which would empty
+ * the campaign list rather than hide one field of it.
+ */
+async function targetingFor(
+ sb: SupabaseClient,
+ campaignIds: string[],
+): Promise