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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions app/(app)/dashboard/ads/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -152,6 +159,28 @@ export default async function CampaignDetailPage({
</p>
)}

{/* 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 && (
<p className="mt-4 rounded-md border border-[var(--color-border)] p-3 text-sm">
{promo.active ? (
<>
<span className="font-semibold">Trending promo: </span>
{promo.daysRemaining} day{promo.daysRemaining === 1 ? "" : "s"} left. Clicks are
billed at <span className="font-mono">$0.00</span> until{" "}
{promo.endsAt.slice(0, 10)}, then{" "}
<span className="font-mono">${(TRENDING_CPC_CENTS / 100).toFixed(2)}</span> per click.
</>
) : (
<>
<span className="font-semibold">Trending promo ended </span>
{promo.endsAt.slice(0, 10)}. Clicks bill normally.
</>
)}
</p>
)}

<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Stat label="Impressions" value={impressions.toLocaleString()} />
<Stat label="Clicks" value={clicks.toLocaleString()} />
Expand Down
20 changes: 20 additions & 0 deletions app/(app)/dashboard/ads/new/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SiteBrand | null>(null);
const [creatives, setCreatives] = useState<AdCreative[]>([]);
const [active, setActive] = useState<AdFormatId>("banner_300x250");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -182,6 +186,22 @@ export function NewAdForm() {
{generating ? "Designing ads…" : hasAds ? "Regenerate" : "Generate ads"}
</button>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={trending}
onChange={(e) => setTrending(e.target.checked)}
/>
<span>
<span className="font-medium">Target trending topics</span>
<span className="block text-xs text-[var(--color-muted)]">
Show this campaign where its subject is what people are asking about right now, on
pages about that subject. Includes 90 days of premium delivery with every click
billed at $0.00 — the rate after that is $0.02 per click.
</span>
</span>
</label>
</form>

{error && (
Expand Down
31 changes: 28 additions & 3 deletions app/actions/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -149,7 +151,9 @@ export async function saveCampaign(input: {
brand?: SiteBrand | null;
creatives: Partial<AdCreative>[];
summary?: Partial<AdSummary> | 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 },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) ---
Expand Down
10 changes: 7 additions & 3 deletions app/api/ads/v1/campaigns/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
// GET the campaign and its delivery: impressions, clicks, spend, and the
// visits the tracker attributed to it (bucket ad:<ref>) 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|
Expand All @@ -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";
Expand All @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion app/api/ads/v1/campaigns/route.ts
Original file line number Diff line number Diff line change
@@ -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.
//
Expand Down
59 changes: 59 additions & 0 deletions app/api/ads/v1/trends/route.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(
(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,
})),
});
}
44 changes: 44 additions & 0 deletions app/api/cron/ad-trends/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading