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
59 changes: 59 additions & 0 deletions app/api/ads/v1/campaigns/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// /api/ads/v1/campaigns — campaigns for a bearer-token caller.
//
// POST { url, name?, daily_budget_cents?, bid_credits?, status? }
// 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`.
// GET ?limit=20
// The caller's campaigns, newest first.
//
// Same auth as /api/sp/v1/* and the MCP server: `Authorization: Bearer crp_…`
// from Social → API tokens. This is what `crawlproof ads` and the myna
// crawlproof plugin call.

import { NextResponse, type NextRequest } from "next/server";
import { serviceClient } from "@/lib/supabase/service";
import { authenticateBearer } from "@/lib/sp/apiAuth";
import { createCampaignForUrl, listCampaigns, parseCampaignRequest } from "@/lib/ads/campaigns";
import { env } from "@/lib/env";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Reading the page and writing four creatives takes a while.
export const maxDuration = 120;

const siteUrl = () => (env.siteUrl || "https://crawlproof.com").replace(/\/$/, "");

export async function POST(req: NextRequest) {
const auth = await authenticateBearer(req);
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });

let body: Record<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: "Body must be JSON." }, { status: 400 });
}
const parsed = parseCampaignRequest(body ?? {});
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 });

const sb = serviceClient();
const { data: profile } = await sb.from("profiles").select("email").eq("id", auth.userId).maybeSingle();
const result = await createCampaignForUrl({
sb,
userId: auth.userId,
email: (profile as { email?: string | null } | null)?.email ?? null,
request: parsed.request,
siteUrl: siteUrl(),
});
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status });
return NextResponse.json(result.campaign, { status: result.campaign.existing ? 200 : 201 });
}

export async function GET(req: NextRequest) {
const auth = await authenticateBearer(req);
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
const limit = Number(new URL(req.url).searchParams.get("limit")) || 20;
const campaigns = await listCampaigns({ sb: serviceClient(), userId: auth.userId, limit, siteUrl: siteUrl() });
return NextResponse.json({ campaigns });
}
46 changes: 46 additions & 0 deletions app/api/ads/v1/slots/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// /api/ads/v1/slots — publisher slots for a bearer-token caller.
//
// POST { site, placement?, formats?, format?, status?, enable_tracking? }
// A slot on the site named by hostname or URL. The site's project is
// found or created with the tracker on. Returns the slot with the two
// tags to paste (`embed`, `tracker`). A site that already has a slot
// gets that slot back, with `existing: true`.
// GET The caller's slots, newest first.
//
// Same auth as /api/ads/v1/campaigns. This is what `crawlproof slots` calls.

import { NextResponse, type NextRequest } from "next/server";
import { serviceClient } from "@/lib/supabase/service";
import { authenticateBearer } from "@/lib/sp/apiAuth";
import { createSlotForSite, listSlots, parseSlotRequest } from "@/lib/ads/slots";
import { env } from "@/lib/env";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const siteUrl = () => (env.siteUrl || "https://crawlproof.com").replace(/\/$/, "");

export async function POST(req: NextRequest) {
const auth = await authenticateBearer(req);
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });

let body: Record<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: "Body must be JSON." }, { status: 400 });
}
const parsed = parseSlotRequest(body ?? {});
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 });

const result = await createSlotForSite({ sb: serviceClient(), userId: auth.userId, request: parsed.request, siteUrl: siteUrl() });
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status });
return NextResponse.json(result.slot, { status: result.slot.existing ? 200 : 201 });
}

export async function GET(req: NextRequest) {
const auth = await authenticateBearer(req);
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
const slots = await listSlots({ sb: serviceClient(), userId: auth.userId, siteUrl: siteUrl() });
return NextResponse.json({ slots });
}
2 changes: 1 addition & 1 deletion app/api/track/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ async function ingest(request: NextRequest, parseBody: boolean) {
const gate = await gateAgent(sb, site, userAgent);
if (gate.action !== "allow") return refuse(gate);

const { bucket, isAi } = categorize({ referrer, userAgent });
const { bucket, isAi } = categorize({ referrer, userAgent, url: pageUrl });
// Which side of the human / bot line this hit counts on. The bucket table
// carries the whole bucket; the other rollups record only this, so the
// stats page can split every breakdown, not just the headline.
Expand Down
177 changes: 176 additions & 1 deletion cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,157 @@ async function cmdSweep(args: Args): Promise<number> {
return 0;
}

// ---------------------------------------------------------------- ads / slots
//
// Both talk to /api/ads/v1/* with a CrawlProof API token (Social → API
// tokens), the same token the MCP server and the myna plugin use.

function apiToken(args: Args): string | null {
const token = (args.flags.token as string | undefined) ?? process.env.CRAWLPROOF_TOKEN ?? null;
return token && token.trim() ? token.trim() : null;
}

function apiBase(args: Args): string {
const base =
(args.flags.base as string | undefined) ??
process.env.CRAWLPROOF_SITE_URL ??
"https://crawlproof.com";
return base.replace(/\/$/, "");
}

async function apiCall(
args: Args,
method: "GET" | "POST",
path: string,
body?: Record<string, unknown>,
): Promise<{ status: number; json: Record<string, unknown> }> {
const token = apiToken(args);
if (!token) {
throw new Error("No API token. Set CRAWLPROOF_TOKEN or pass --token (Social → API tokens in the app).");
}
const res = await fetch(`${apiBase(args)}${path}`, {
method,
headers: {
authorization: `Bearer ${token}`,
accept: "application/json",
...(body ? { "content-type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: Record<string, unknown> = {};
try {
json = text ? (JSON.parse(text) as Record<string, unknown>) : {};
} catch {
json = { error: text.slice(0, 200) };
}
return { status: res.status, json };
}

/** The request body `crawlproof ads create` sends, from its flags. Pure, for tests. */
export function campaignBodyFromArgs(args: Args): Record<string, unknown> {
const body: Record<string, unknown> = { url: args.positional[1] };
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);
body.status = args.flags.draft ? "draft" : "active";
return body;
}

/** The request body `crawlproof slots create` sends, from its flags. Pure, for tests. */
export function slotBodyFromArgs(args: Args): Record<string, unknown> {
const body: Record<string, unknown> = { site: args.positional[1] };
if (typeof args.flags.placement === "string") body.placement = args.flags.placement;
if (typeof args.flags.format === "string") body.format = args.flags.format;
if (typeof args.flags.formats === "string") body.formats = args.flags.formats.split(",").map((f) => f.trim());
if (args.flags.inactive) body.status = "inactive";
if (args.flags["no-tracking"]) body.enable_tracking = false;
return body;
}

async function cmdAds(args: Args): Promise<number> {
const sub = args.positional[0];
if (sub === "create") {
if (!args.positional[1]) {
console.error("usage: crawlproof ads create <url> [--name=N] [--budget=CENTS] [--bid=CREDITS] [--draft] [--json]");
return 2;
}
const { status, json } = await apiCall(args, "POST", "/api/ads/v1/campaigns", campaignBodyFromArgs(args));
if (status >= 400) {
console.error(`ads create failed: ${status} ${json.error ?? ""}`);
return 1;
}
if (args.flags.json) {
process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
} else {
const existing = json.existing ? " (already existed)" : "";
process.stdout.write(`${json.status} ${json.ref_slug} ${json.name}${existing}\n ${json.destination_url}\n ${json.dashboard_url ?? ""}\n`);
}
return 0;
}
if (sub === "list" || sub === undefined) {
const limit = (args.flags.limit as string | undefined) ?? "20";
const { status, json } = await apiCall(args, "GET", `/api/ads/v1/campaigns?limit=${encodeURIComponent(limit)}`);
if (status >= 400) {
console.error(`ads list failed: ${status} ${json.error ?? ""}`);
return 1;
}
const campaigns = (json.campaigns as Record<string, unknown>[]) ?? [];
if (args.flags.json) {
process.stdout.write(`${JSON.stringify(campaigns, null, 2)}\n`);
return 0;
}
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`);
}
return 0;
}
console.error(`unknown: crawlproof ads ${sub} (expected: create | list)`);
return 2;
}

async function cmdSlots(args: Args): Promise<number> {
const sub = args.positional[0];
if (sub === "create") {
if (!args.positional[1]) {
console.error("usage: crawlproof slots create <site> [--placement=inline] [--format=text_link] [--formats=a,b] [--inactive] [--no-tracking] [--json]");
return 2;
}
const { status, json } = await apiCall(args, "POST", "/api/ads/v1/slots", slotBodyFromArgs(args));
if (status >= 400) {
console.error(`slots create failed: ${status} ${json.error ?? ""}`);
return 1;
}
if (args.flags.json) {
process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
} else {
const existing = json.existing ? " (already existed)" : "";
process.stdout.write(`${json.status} slot ${json.id} on ${json.site}${existing}\n\nPaste before </body>:\n\n${json.embed}\n`);
}
return 0;
}
if (sub === "list" || sub === undefined) {
const { status, json } = await apiCall(args, "GET", "/api/ads/v1/slots");
if (status >= 400) {
console.error(`slots list failed: ${status} ${json.error ?? ""}`);
return 1;
}
const slots = (json.slots as Record<string, unknown>[]) ?? [];
if (args.flags.json) {
process.stdout.write(`${JSON.stringify(slots, null, 2)}\n`);
return 0;
}
if (!slots.length) process.stdout.write("No slots yet.\n");
for (const s of slots) {
process.stdout.write(`${String(s.status).padEnd(9)} ${s.id} ${s.site} ${s.placement}\n`);
}
return 0;
}
console.error(`unknown: crawlproof slots ${sub} (expected: create | list)`);
return 2;
}

function help() {
console.log(`crawlproof — AEO audit CLI (stub)

Expand Down Expand Up @@ -207,13 +358,31 @@ COMMANDS
Defaults --event to "pageview". Project id can also come from
CRAWLPROOF_PROJECT. Override host with CRAWLPROOF_SITE_URL.

ads create <url> [--name=N] [--budget=CENTS] [--bid=CREDITS] [--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).

ads list [--limit=20] [--json]
Your campaigns, newest first.

slots create <site> [--placement=inline] [--format=text_link] [--formats=a,b] [--inactive] [--no-tracking] [--json]
A publisher slot on a site you own, named by hostname or URL. The
site's project is found or created with the stats tracker on, and
the output is the two tags to paste before </body>.

slots list [--json]
Your slots.

help
Print this message.

ENV
ANTHROPIC_API_KEY Required for --engine=claude.
CRAWLPROOF_SITE_URL Override the API base URL for 'report', 'sweep', and 'track'.
CRAWLPROOF_SITE_URL Override the API base URL for 'report', 'sweep', 'track', 'ads' and 'slots'.
CRAWLPROOF_PROJECT Default project UUID for 'track'.
CRAWLPROOF_TOKEN API token (crp_…) for 'ads' and 'slots'; --token overrides.
CRON_SECRET Required for 'sweep'.

EXAMPLES
Expand All @@ -223,6 +392,8 @@ EXAMPLES
CRAWLPROOF_SITE_URL=http://localhost:3000 crawlproof sweep
CRAWLPROOF_SITE_URL=http://localhost:3000 crawlproof sweep --target=autoblog
crawlproof track --project=ac4e0a7d-... --event=signup --target=hero_cta
CRAWLPROOF_TOKEN=crp_... crawlproof ads create https://nichedb.dev --name "NicheDB"
CRAWLPROOF_TOKEN=crp_... crawlproof slots create nichedb.dev
`);
}

Expand All @@ -238,6 +409,10 @@ async function main() {
return await cmdSweep(args);
case "track":
return await cmdTrack(args);
case "ads":
return await cmdAds(args);
case "slots":
return await cmdSlots(args);
case "help":
case "--help":
case "-h":
Expand Down
57 changes: 57 additions & 0 deletions lib/ads/campaign-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// The shape of a campaign request, parsed without touching a database.
//
// Kept apart from lib/ads/campaigns.ts because that module reaches the
// generator and the org helper, which are server-only; this one is imported
// by tests and could be by a client.

import { isAllowedTargetUrl } from "@/lib/rateLimit";

export type CampaignStatus = "active" | "draft";

export type CampaignRequest = {
url: string;
name?: string;
dailyBudgetCents?: number;
bidCredits?: number;
status?: CampaignStatus;
};

export function domainOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}

/**
* Normalise what a caller sent. Pure, so it is testable without a database:
* the clamps here are the same ones saveCampaign applies.
*/
export function parseCampaignRequest(body: Record<string, unknown>): { ok: true; request: CampaignRequest; url: string } | { ok: false; error: string } {
const rawUrl = typeof body.url === "string" ? body.url : "";
const check = isAllowedTargetUrl(rawUrl);
if (!check.ok) return { ok: false, error: check.reason };

const budgetRaw = body.daily_budget_cents ?? body.dailyBudgetCents;
const bidRaw = body.bid_credits ?? body.bidCredits;
const statusRaw = body.status;
if (statusRaw !== undefined && statusRaw !== "active" && statusRaw !== "draft") {
return { ok: false, error: 'status must be "active" or "draft".' };
}

const request: CampaignRequest = { url: check.url };
if (typeof body.name === "string" && body.name.trim()) request.name = body.name.trim().slice(0, 120);
if (budgetRaw !== undefined && budgetRaw !== null) {
const n = Number(budgetRaw);
if (!Number.isFinite(n) || n < 0) return { ok: false, error: "daily_budget_cents must be a non-negative number." };
request.dailyBudgetCents = Math.round(n);
}
if (bidRaw !== undefined && bidRaw !== null) {
const n = Number(bidRaw);
if (!Number.isFinite(n) || n < 1) return { ok: false, error: "bid_credits must be at least 1." };
request.bidCredits = Math.min(200, Math.round(n));
}
if (statusRaw === "active" || statusRaw === "draft") request.status = statusRaw;
return { ok: true, request, url: check.url };
}
Loading
Loading