Monetize a site
diff --git a/app/(app)/ads/slots/page.tsx b/app/(app)/ads/slots/page.tsx
index febdb2a5..62fe1523 100644
--- a/app/(app)/ads/slots/page.tsx
+++ b/app/(app)/ads/slots/page.tsx
@@ -95,14 +95,19 @@ export default async function SlotsPage() {
+
Monetize your site
- {/* Always-visible way to add a site to monetize — a site is a project,
- so this starts the project-creation flow. Previously the only link
- to it was hidden inside the empty state. */}
-
- Monetize a new site
-
+
+
+ Earnings & reports
+
+ {/* Always-visible way to add a site to monetize — a site is a project,
+ so this starts the project-creation flow. Previously the only link
+ to it was hidden inside the empty state. */}
+
+ Monetize a new site
+
+
Each site you monetize is a CrawlProof project . Add a site to
diff --git a/app/api/ads/earnings/pdf/route.ts b/app/api/ads/earnings/pdf/route.ts
new file mode 100644
index 00000000..1ac7d7c4
--- /dev/null
+++ b/app/api/ads/earnings/pdf/route.ts
@@ -0,0 +1,50 @@
+// PDF earnings/spend report for the logged-in account. Runs the same
+// RLS-scoped queries as /ads/earnings, renders a self-contained HTML report,
+// and hands it to the worker's Playwright /pdf endpoint (html branch).
+
+import { NextResponse } from "next/server";
+import { createClient } from "@/lib/supabase/server";
+import { env } from "@/lib/env";
+import { loadEarnings } from "@/lib/ads/earnings-data";
+import { buildEarningsReportHtml } from "@/lib/ads/earnings-report";
+
+export const runtime = "nodejs";
+
+export async function GET(req: Request) {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
+
+ if (!env.workerUrl) {
+ return NextResponse.json({ error: "Worker not configured" }, { status: 500 });
+ }
+
+ const daysParam = Number(new URL(req.url).searchParams.get("days"));
+ const days = Number.isFinite(daysParam) && daysParam >= 1 && daysParam <= 365 ? Math.floor(daysParam) : 30;
+
+ const model = await loadEarnings(supabase, user.id, days);
+ const html = buildEarningsReportHtml({
+ model,
+ account: user.email ?? user.id,
+ generatedAt: new Date().toISOString(),
+ });
+
+ const workerRes = await fetch(`${env.workerUrl}/pdf`, {
+ method: "POST",
+ headers: { "content-type": "application/json", "x-worker-secret": env.workerSecret },
+ body: JSON.stringify({ html }),
+ });
+ if (!workerRes.ok) {
+ return NextResponse.json({ error: "PDF render failed" }, { status: 502 });
+ }
+ const buf = await workerRes.arrayBuffer();
+ const stamp = new Date().toISOString().slice(0, 10);
+ return new NextResponse(buf, {
+ headers: {
+ "content-type": "application/pdf",
+ "content-disposition": `attachment; filename="crawlproof-earnings-${stamp}.pdf"`,
+ },
+ });
+}
diff --git a/components/ads/earnings-pdf-button.tsx b/components/ads/earnings-pdf-button.tsx
new file mode 100644
index 00000000..1a53c80f
--- /dev/null
+++ b/components/ads/earnings-pdf-button.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { useState } from "react";
+
+export function EarningsPdfButton({ days = 30 }: { days?: number }) {
+ const [busy, setBusy] = useState(false);
+ return (
+ {
+ setBusy(true);
+ setTimeout(() => setBusy(false), 4000);
+ }}
+ >
+ {busy ? "Preparing PDF…" : "Download PDF report"}
+
+ );
+}
diff --git a/components/ads/money-trend.tsx b/components/ads/money-trend.tsx
new file mode 100644
index 00000000..abd4b93e
--- /dev/null
+++ b/components/ads/money-trend.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import {
+ CartesianGrid,
+ Legend,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import type { MoneyDailyPoint } from "@/lib/ads/earnings-data";
+
+// Spend vs. earnings over time, in dollars. Mirrors the styling of
+// CampaignTrend but plots money on a single $ axis. Loaded via next/dynamic
+// (ssr:false) so recharts stays out of the server bundle.
+export function MoneyTrend({ data }: { data: MoneyDailyPoint[] }) {
+ const total = data.reduce((s, p) => s + p.spentCents + p.earnedCents, 0);
+ if (total === 0) {
+ return (
+
+ No spend or earnings recorded yet.
+
+ );
+ }
+
+ const chartData = data.map((p) => ({
+ date: p.date,
+ spent: p.spentCents / 100,
+ earned: p.earnedCents / 100,
+ }));
+
+ return (
+
+
+
Spend & earnings over time
+ Last {data.length} days
+
+
+
+
+
+
+ new Date(v).toLocaleDateString(undefined, { month: "short", day: "numeric" })
+ }
+ />
+ `$${v}`}
+ width={48}
+ />
+ [`$${Number(v).toFixed(2)}`, name]) as any}
+ labelFormatter={(v) => (typeof v === "string" ? new Date(v).toLocaleDateString() : "")}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/lib/ads/earnings-data.ts b/lib/ads/earnings-data.ts
new file mode 100644
index 00000000..228f5399
--- /dev/null
+++ b/lib/ads/earnings-data.ts
@@ -0,0 +1,199 @@
+import type { SupabaseClient } from "@supabase/supabase-js";
+import {
+ getCampaignDailySeries,
+ getSlotDailySeries,
+ mergeMoneySeries,
+} from "./series";
+
+// Unified money model for one account — a single user is both an advertiser
+// (ad_campaigns.owner_id) and a publisher (ad_slots.owner_id), so the earnings
+// page shows BOTH spend and earnings. Everything is RLS-scoped to the caller;
+// pass a request-scoped Supabase client whose auth.uid() is the account.
+
+export type MoneyDailyPoint = { date: string; spentCents: number; earnedCents: number };
+
+export type EarningsCampaignRow = {
+ id: string;
+ name: string;
+ status: string;
+ impressions: number;
+ clicks: number;
+ spentCents: number;
+};
+
+export type EarningsSlotRow = {
+ id: string;
+ name: string;
+ status: string;
+ impressions: number;
+ clicks: number;
+ earnedCents: number;
+};
+
+export type EarningsPayoutRow = {
+ amountCents: number;
+ currency: string;
+ status: string;
+ txHash: string | null;
+ createdAt: string;
+};
+
+export type EarningsModel = {
+ rangeDays: number;
+ totals: {
+ spentCents: number;
+ earnedCents: number;
+ netCents: number;
+ availableCents: number;
+ withdrawnCents: number;
+ spendTodayCents: number;
+ earnedTodayCents: number;
+ advImpressions: number;
+ advClicks: number;
+ pubImpressions: number;
+ pubClicks: number;
+ };
+ campaigns: EarningsCampaignRow[];
+ slots: EarningsSlotRow[];
+ payouts: EarningsPayoutRow[];
+ daily: MoneyDailyPoint[];
+};
+
+type CampaignRow = {
+ id: string;
+ name: string;
+ status: string;
+ total_spent_cents: number | null;
+ spend_today_cents: number | null;
+ spend_date: string | null;
+};
+type CampaignStat = { campaign_id: string; impressions: number; clicks: number; spent_cents: number };
+type Project = { id: string; name: string };
+type Slot = { id: string; project_id: string; status: string };
+type SlotStat = { slot_id: string; impressions: number; clicks: number; earned_cents: number };
+type LedgerRow = { slot_id: string | null; amount_cents: number | null };
+type PayoutRow = {
+ amount_cents: number | null;
+ currency: string;
+ status: string;
+ tx_hash: string | null;
+ created_at: string;
+};
+
+export async function loadEarnings(
+ supabase: SupabaseClient,
+ userId: string,
+ days = 30,
+): Promise
{
+ const [
+ { data: campaignsData },
+ { data: campaignStatsData },
+ { data: projectsData },
+ { data: slotsData },
+ { data: slotStatsData },
+ { data: ledgerData },
+ { data: payoutsData },
+ ] = await Promise.all([
+ supabase.from("ad_campaigns").select("id, name, status, total_spent_cents, spend_today_cents, spend_date"),
+ supabase.from("ad_campaign_stats").select("campaign_id, impressions, clicks, spent_cents"),
+ // Monetization is owner-only (payouts go to the slot owner), like /ads/slots.
+ supabase.from("projects").select("id, name").eq("owner_id", userId),
+ supabase.from("ad_slots").select("id, project_id, status"),
+ supabase.from("ad_slot_stats").select("slot_id, impressions, clicks, earned_cents"),
+ supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual"),
+ supabase
+ .from("ad_payouts")
+ .select("amount_cents, currency, status, tx_hash, created_at")
+ .order("created_at", { ascending: false }),
+ ]);
+
+ const campaigns = (campaignsData as CampaignRow[]) ?? [];
+ const campaignStats = new Map();
+ for (const s of (campaignStatsData as CampaignStat[]) ?? []) campaignStats.set(s.campaign_id, s);
+ const projectsById = new Map();
+ for (const p of (projectsData as Project[]) ?? []) projectsById.set(p.id, p);
+ // Only slots for projects the user owns (mirrors the /ads/slots scoping).
+ const slots = ((slotsData as Slot[]) ?? []).filter((s) => projectsById.has(s.project_id));
+ const slotStats = new Map();
+ for (const s of (slotStatsData as SlotStat[]) ?? []) slotStats.set(s.slot_id, s);
+ const earnedBySlot = new Map();
+ for (const row of (ledgerData as LedgerRow[]) ?? []) {
+ if (row.slot_id) earnedBySlot.set(row.slot_id, (earnedBySlot.get(row.slot_id) ?? 0) + (row.amount_cents ?? 0));
+ }
+ const payouts = (payoutsData as PayoutRow[]) ?? [];
+
+ const todayUtc = new Date().toISOString().slice(0, 10);
+
+ const campaignRows: EarningsCampaignRow[] = campaigns.map((c) => {
+ const s = campaignStats.get(c.id);
+ return {
+ id: c.id,
+ name: c.name,
+ status: c.status,
+ impressions: s?.impressions ?? 0,
+ clicks: s?.clicks ?? 0,
+ spentCents: c.total_spent_cents ?? 0,
+ };
+ });
+
+ const slotRows: EarningsSlotRow[] = slots.map((sl) => {
+ const s = slotStats.get(sl.id);
+ return {
+ id: sl.id,
+ name: projectsById.get(sl.project_id)?.name ?? "Site",
+ status: sl.status,
+ impressions: s?.impressions ?? 0,
+ clicks: s?.clicks ?? 0,
+ earnedCents: earnedBySlot.get(sl.id) ?? 0,
+ };
+ });
+
+ const spentCents = campaignRows.reduce((a, c) => a + c.spentCents, 0);
+ const earnedCents = [...earnedBySlot.values()].reduce((a, v) => a + v, 0);
+ const withdrawnCents = payouts
+ .filter((p) => p.status !== "failed")
+ .reduce((a, p) => a + (p.amount_cents ?? 0), 0);
+ const spendTodayCents = campaigns
+ .filter((c) => c.spend_date === todayUtc)
+ .reduce((a, c) => a + (c.spend_today_cents ?? 0), 0);
+
+ // Daily series (spend from campaigns, earnings from slots), merged by day.
+ const [campaignSeries, slotSeries] = await Promise.all([
+ getCampaignDailySeries(supabase, campaignRows.map((c) => c.id), days),
+ getSlotDailySeries(supabase, slotRows.map((s) => s.id), days),
+ ]);
+ const daily = mergeMoneySeries(campaignSeries, slotSeries, days);
+ const earnedTodayCents = daily.length ? daily[daily.length - 1].earnedCents : 0;
+
+ return {
+ rangeDays: days,
+ totals: {
+ spentCents,
+ earnedCents,
+ netCents: earnedCents - spentCents,
+ availableCents: Math.max(0, earnedCents - withdrawnCents),
+ withdrawnCents,
+ spendTodayCents,
+ earnedTodayCents,
+ advImpressions: campaignRows.reduce((a, c) => a + c.impressions, 0),
+ advClicks: campaignRows.reduce((a, c) => a + c.clicks, 0),
+ pubImpressions: slotRows.reduce((a, s) => a + s.impressions, 0),
+ pubClicks: slotRows.reduce((a, s) => a + s.clicks, 0),
+ },
+ campaigns: campaignRows,
+ slots: slotRows,
+ payouts: payouts.map((p) => ({
+ amountCents: p.amount_cents ?? 0,
+ currency: p.currency,
+ status: p.status,
+ txHash: p.tx_hash,
+ createdAt: p.created_at,
+ })),
+ daily,
+ };
+}
+
+export function dollars(cents: number): string {
+ const neg = cents < 0;
+ return `${neg ? "-" : ""}$${(Math.abs(cents) / 100).toFixed(2)}`;
+}
diff --git a/lib/ads/earnings-report.ts b/lib/ads/earnings-report.ts
new file mode 100644
index 00000000..19736897
--- /dev/null
+++ b/lib/ads/earnings-report.ts
@@ -0,0 +1,183 @@
+import { dollars, type EarningsModel } from "./earnings-data";
+
+// Build a self-contained, print-friendly HTML report of ad spend & earnings.
+// Rendered to PDF by the worker's Playwright /pdf endpoint (html branch), so it
+// must be a complete document with inline styles and no external assets. The
+// chart is inline SVG (no recharts) so it prints reliably.
+
+function esc(s: string): string {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function ctr(clicks: number, impressions: number): string {
+ return impressions ? `${((clicks / impressions) * 100).toFixed(1)}%` : "—";
+}
+
+// Two-line inline-SVG chart (earnings vs spend) over the daily series.
+function chartSvg(daily: EarningsModel["daily"]): string {
+ const w = 720;
+ const h = 200;
+ const pad = { top: 10, right: 10, bottom: 22, left: 44 };
+ const iw = w - pad.left - pad.right;
+ const ih = h - pad.top - pad.bottom;
+ const n = daily.length;
+ const max = Math.max(1, ...daily.map((d) => Math.max(d.spentCents, d.earnedCents)));
+ const x = (i: number) => pad.left + (n <= 1 ? 0 : (i / (n - 1)) * iw);
+ const y = (cents: number) => pad.top + ih - (cents / max) * ih;
+ const line = (key: "spentCents" | "earnedCents") =>
+ daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d[key]).toFixed(1)}`).join(" ");
+ const gridY = [0, 0.5, 1].map((f) => {
+ const yy = pad.top + ih - f * ih;
+ const val = (max * f) / 100;
+ return `
+ $${val.toFixed(0)} `;
+ });
+ return `
+ ${gridY.join("")}
+
+
+ ${esc(daily[0]?.date ?? "")}
+ ${esc(
+ daily[n - 1]?.date ?? "",
+ )}
+ `;
+}
+
+export function buildEarningsReportHtml(input: {
+ model: EarningsModel;
+ account: string;
+ generatedAt: string; // ISO
+}): string {
+ const { model, account, generatedAt } = input;
+ const t = model.totals;
+ const gen = new Date(generatedAt);
+ const from = model.daily[0]?.date ?? "";
+ const to = model.daily[model.daily.length - 1]?.date ?? "";
+
+ const summaryRow = (label: string, value: string, strong = false) =>
+ `${esc(label)} ${esc(value)} `;
+
+ const campaignRows =
+ model.campaigns.length === 0
+ ? `No ad campaigns. `
+ : model.campaigns
+ .map(
+ (c) =>
+ `${esc(c.name)} ${esc(c.status)} ${c.impressions.toLocaleString()} ${c.clicks.toLocaleString()} ${esc(
+ dollars(c.spentCents),
+ )} `,
+ )
+ .join("");
+
+ const slotRows =
+ model.slots.length === 0
+ ? `No monetized sites. `
+ : model.slots
+ .map(
+ (s) =>
+ `${esc(s.name)} ${esc(s.status)} ${s.impressions.toLocaleString()} ${s.clicks.toLocaleString()} ${esc(
+ dollars(s.earnedCents),
+ )} `,
+ )
+ .join("");
+
+ const payoutRows =
+ model.payouts.length === 0
+ ? `No payouts yet. `
+ : model.payouts
+ .map(
+ (p) =>
+ `${esc(new Date(p.createdAt).toLocaleDateString())} ${esc(
+ dollars(p.amountCents),
+ )} ${esc(p.currency.toUpperCase())} ${esc(p.status)}${
+ p.txHash ? ` · ${esc(p.txHash.slice(0, 12))}… ` : ""
+ } `,
+ )
+ .join("");
+
+ return `CrawlProof Ads — Earnings & Spend
+
+ CrawlProof Ads — Earnings & Spend
+ Account: ${esc(account)} · Period: ${esc(from)} → ${esc(to)} (${model.rangeDays} days) · Generated ${esc(
+ gen.toLocaleString(),
+ )}
+
+
+
Total earned
${esc(dollars(t.earnedCents))}
+
Total spend
${esc(dollars(t.spentCents))}
+
Net
${esc(
+ dollars(t.netCents),
+ )}
+
Available to withdraw
${esc(
+ dollars(t.availableCents),
+ )}
+
+
+ Spend & earnings over time
+
+ ${chartSvg(model.daily)}
+
Earnings Spend
+
+
+ Summary
+
+
+ ${summaryRow("Total earned (publisher)", dollars(t.earnedCents), true)}
+ ${summaryRow("Total spend (advertiser)", dollars(t.spentCents), true)}
+ ${summaryRow("Net position", dollars(t.netCents), true)}
+ ${summaryRow("Withdrawn to date", dollars(t.withdrawnCents))}
+ ${summaryRow("Available to withdraw", dollars(t.availableCents))}
+ ${summaryRow("Earned today", dollars(t.earnedTodayCents))}
+ ${summaryRow("Spent today", dollars(t.spendTodayCents))}
+ ${summaryRow("Publisher impressions / clicks", `${t.pubImpressions.toLocaleString()} / ${t.pubClicks.toLocaleString()} (CTR ${ctr(t.pubClicks, t.pubImpressions)})`)}
+ ${summaryRow("Advertiser impressions / clicks", `${t.advImpressions.toLocaleString()} / ${t.advClicks.toLocaleString()} (CTR ${ctr(t.advClicks, t.advImpressions)})`)}
+
+
+
+ Earnings by site (publisher)
+
+ Site Status Impressions Clicks Earned
+ ${slotRows}
+
+
+ Spend by campaign (advertiser)
+
+ Campaign Status Impressions Clicks Spent
+ ${campaignRows}
+
+
+ Payout history
+
+ Date Amount Currency Status
+ ${payoutRows}
+
+
+ Generated by CrawlProof · crawlproof.com · Figures are point-in-time and for informational purposes.
+ `;
+}
diff --git a/lib/ads/series.ts b/lib/ads/series.ts
index 075228fa..1c5c34c3 100644
--- a/lib/ads/series.ts
+++ b/lib/ads/series.ts
@@ -8,6 +8,13 @@ export type CampaignDailyPoint = {
spentCents: number;
};
+export type SlotDailyPoint = {
+ /** UTC calendar day, YYYY-MM-DD */
+ date: string;
+ clicks: number;
+ earnedCents: number;
+};
+
function dayKey(iso: string): string {
return iso.slice(0, 10);
}
@@ -80,3 +87,70 @@ export async function getCampaignDailySeries(
return result;
}
+
+type SlotSeriesRow = {
+ slot_id: string;
+ day: string; // YYYY-MM-DD (UTC)
+ clicks: number | string;
+ earned_cents: number | string;
+};
+
+/**
+ * Per-slot daily clicks / publisher earnings for the last `days`.
+ * Server-side aggregate via the ad_slot_daily_series RPC (security_invoker →
+ * RLS scopes to the caller's own slots). Same 1000-row-cap rationale as
+ * getCampaignDailySeries. See migration 20260717150000_ad_slot_daily_series_rpc.sql.
+ */
+export async function getSlotDailySeries(
+ supabase: SupabaseClient,
+ slotIds: string[],
+ days = 30,
+): Promise> {
+ const axis = dayAxis(days);
+ const result = new Map();
+ const emptyFor = () => axis.map((date) => ({ date, clicks: 0, earnedCents: 0 }));
+ for (const id of slotIds) result.set(id, emptyFor());
+ if (slotIds.length === 0) return result;
+
+ const index = new Map>();
+ for (const id of slotIds) {
+ const byDay = new Map();
+ for (const p of result.get(id)!) byDay.set(p.date, p);
+ index.set(id, byDay);
+ }
+
+ const { data, error } = await supabase.rpc("ad_slot_daily_series", { days });
+ if (error) return result;
+
+ for (const row of (data as SlotSeriesRow[]) ?? []) {
+ const point = index.get(row.slot_id)?.get(dayKey(row.day));
+ if (point) {
+ point.clicks += Number(row.clicks) || 0;
+ point.earnedCents += Number(row.earned_cents) || 0;
+ }
+ }
+
+ return result;
+}
+
+/** Merge campaign spend series + slot earnings series into one account-wide
+ * daily spend/earn axis (zero-filled, oldest first). */
+export function mergeMoneySeries(
+ campaign: Map,
+ slot: Map,
+ days = 30,
+): { date: string; spentCents: number; earnedCents: number }[] {
+ const byDate = new Map();
+ for (const date of dayAxis(days)) byDate.set(date, { date, spentCents: 0, earnedCents: 0 });
+ for (const points of campaign.values())
+ for (const p of points) {
+ const d = byDate.get(p.date);
+ if (d) d.spentCents += p.spentCents;
+ }
+ for (const points of slot.values())
+ for (const p of points) {
+ const d = byDate.get(p.date);
+ if (d) d.earnedCents += p.earnedCents;
+ }
+ return [...byDate.values()];
+}
diff --git a/supabase/migrations/20260717150000_ad_slot_daily_series_rpc.sql b/supabase/migrations/20260717150000_ad_slot_daily_series_rpc.sql
new file mode 100644
index 00000000..be8615bb
--- /dev/null
+++ b/supabase/migrations/20260717150000_ad_slot_daily_series_rpc.sql
@@ -0,0 +1,49 @@
+-- Ad network: per-slot daily PUBLISHER EARNINGS as a server-side aggregate.
+--
+-- Mirror of ad_campaign_daily_series (advertiser spend) for the publisher side,
+-- powering the /ads/earnings page's earnings-over-time chart and PDF report.
+-- Same rationale: bucketing raw ad_clicks in JS would hit PostgREST's 1000-row
+-- cap once a slot is busy, so aggregate in Postgres (at most slots * days rows).
+--
+-- security_invoker: RLS on ad_slots/ad_clicks applies to the caller, and we
+-- additionally scope to slots they OWN so nothing leaks across accounts.
+-- Earnings come from ad_clicks.publisher_earn_cents (floor rate), matching the
+-- ad_slot_stats view and the publisher_accrual ledger.
+--
+-- Days are UTC calendar days to match dayAxis() in lib/ads/series.ts.
+-- Apply via psql over the pooler / MCP (prod migration history diverged).
+
+create or replace function public.ad_slot_daily_series(days integer default 30)
+returns table (
+ slot_id uuid,
+ day date,
+ clicks bigint,
+ earned_cents bigint
+)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ with bounds as (
+ select greatest(coalesce(days, 30), 1) as n
+ ),
+ since as (
+ select ((now() at time zone 'UTC')::date - (n - 1))::timestamptz as from_ts
+ from bounds
+ ),
+ owned as (
+ select id from public.ad_slots where owner_id = auth.uid()
+ )
+ select cl.slot_id,
+ (cl.ts at time zone 'UTC')::date as day,
+ count(*)::bigint as clicks,
+ coalesce(sum(cl.publisher_earn_cents), 0)::bigint as earned_cents
+ from public.ad_clicks cl
+ where cl.valid
+ and cl.slot_id in (select id from owned)
+ and cl.ts >= (select from_ts from since)
+ group by 1, 2;
+$$;
+
+grant execute on function public.ad_slot_daily_series(integer) to authenticated, service_role;
diff --git a/worker/index.ts b/worker/index.ts
index b8a287cc..8b6f0f12 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -831,12 +831,17 @@ const server = http.createServer(async (req, res) => {
const payload = JSON.parse(body || "{}") as {
token?: string;
markdown?: string;
+ html?: string;
title?: string;
target?: string;
score?: number;
};
let pdf: Buffer;
- if (payload.markdown) {
+ if (payload.html) {
+ // Caller supplies a complete, self-contained HTML document (e.g. the
+ // ads earnings report). Render it as-is — no markdown step.
+ pdf = await renderPdfFromHtml(payload.html);
+ } else if (payload.markdown) {
const bodyHtml = await markdownToHtml(payload.markdown);
const html = htmlDocument({
title: payload.title ?? "CrawlProof audit",