diff --git a/.env.example b/.env.example
index f97bf82..d3c18a1 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 4f91fb0..0bbba21 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 5ccd057..38a7a5b 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 f737486..6670144 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 41753a1..2b44bbd 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 04cea3a..e95e426 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 3ed1340..5ea19c6 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 0000000..dff2bd8
--- /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 0000000..73f1196
--- /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 a98c31b..9ea518c 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 1965f0c..c255589 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 d2c0e22..ce7ef8d 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