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
8 changes: 7 additions & 1 deletion app/(marketing)/trending/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,13 @@ export default async function TrendingPage() {
))}
</tbody>
</table>
{newest && <p className="mt-3 text-xs text-[var(--color-muted)]">Updated {updated(newest)}.</p>}
<p className="mt-3 text-xs text-[var(--color-muted)]">
{newest && `Updated ${updated(newest)}. `}Also as{" "}
<a href="/api/trending" className="underline">
JSON
</a>{" "}
and the <code>list_trending_topics</code> MCP tool.
</p>
</div>
)}

Expand Down
2 changes: 2 additions & 0 deletions app/api/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { registerAffiliateTools } from "@/lib/mcp/affiliate";
import { registerActorTools } from "@/lib/mcp/actors";
import { registerCreditTools } from "@/lib/mcp/credits";
import { registerWebhookTools } from "@/lib/mcp/webhooks";
import { registerTrendTools } from "@/lib/mcp/trends";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand All @@ -32,6 +33,7 @@ const handler = createMcpHandler(
registerActorTools(server);
registerCreditTools(server);
registerWebhookTools(server);
registerTrendTools(server);
},
{},
// The route is mounted at /api/mcp, so mcp-handler must derive its endpoint
Expand Down
17 changes: 17 additions & 0 deletions app/api/trending/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// GET /api/trending?limit=50 — the list behind /trending, open and with no key.
// The token-authed /api/ads/v1/trends adds what advertisers need on top.

import { NextResponse, type NextRequest } from "next/server";
import { serviceClient } from "@/lib/supabase/service";
import { publicTrendList } from "@/lib/ads/trendList";

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

export async function GET(req: NextRequest) {
const limit = Number(new URL(req.url).searchParams.get("limit")) || 50;
const list = await publicTrendList(serviceClient(), { limit });
return NextResponse.json(list, {
headers: { "cache-control": "public, max-age=3600", "access-control-allow-origin": "*" },
});
}
31 changes: 31 additions & 0 deletions lib/ads/trendList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// The trend list as outsiders see it: the public API and the MCP tool answer with
// this, so the two cannot drift apart.

import type { SupabaseClient } from "@supabase/supabase-js";
import { currentTrends } from "./trends";
import { TREND_MAX_AGE_HOURS, TREND_SOURCE, TREND_WINDOW_DAYS, trendsAreStale } from "./trending";

export async function publicTrendList(sb: SupabaseClient, options: { limit?: number; now?: number } = {}) {
const now = options.now ?? Date.now();
const limit = Math.min(200, Math.max(1, Math.round(Number(options.limit)) || 50));
const signals = await currentTrends(sb, { limit, includeStale: true, now });
const newest = signals.reduce<string | null>(
(latest, signal) => (!latest || (signal.ingestedAt ?? "") > latest ? (signal.ingestedAt ?? latest) : latest),
null,
);
return {
source: TREND_SOURCE,
window_days: TREND_WINDOW_DAYS,
ingested_at: newest,
// Past this the list no longer steers delivery, and an agent should know.
stale: signals.length > 0 && trendsAreStale(newest, now),
max_age_hours: TREND_MAX_AGE_HOURS,
topics: signals.map((signal) => ({
topic: signal.label ?? signal.topic,
match_key: signal.topic,
score: signal.score,
feeds: signal.mentions,
prior_feeds: signal.priorMentions,
})),
};
}
5 changes: 4 additions & 1 deletion lib/ads/trends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export type IngestResult =
| { ok: true; source: string; windowDays: number; stored: number; removed: number; generatedAt: string | null }
| { ok: false; status: number; error: string };

// Real news, but nothing to publish on a sales page or sell an ad against.
const UNSAFE_TOPIC = /\b(abuse|assault|rape\w*|sexual\w*|sex|porn\w*|nude\w*|child|children|minor|minors|murder\w*|suicide|massacre|shooting|stabbing|beheaded|terror\w*)\b/i;

const clampNumber = (value: unknown, min: number, max: number, fallback: number): number => {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
Expand Down Expand Up @@ -56,7 +59,7 @@ export function parseTrendPayload(
for (const item of rawTopics) {
const row = (item ?? {}) as Record<string, unknown>;
const topic = normalizeTopic(String(row.topic ?? ""));
if (!topic || seen.has(topic)) continue;
if (!topic || seen.has(topic) || UNSAFE_TOPIC.test(String(row.topic))) continue;
seen.add(topic);
signals.push({
source,
Expand Down
19 changes: 19 additions & 0 deletions lib/mcp/trends.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { serviceClient } from "@/lib/supabase/service";
import { publicTrendList } from "@/lib/ads/trendList";

export function registerTrendTools(server: McpServer): void {
server.registerTool(
"list_trending_topics",
{
description:
"Subjects blogs and news sites are writing about more than last week, the list trending ad targeting matches on. Feeds are counted in a sample, so read them as movement. `stale: true` means the list no longer steers delivery.",
inputSchema: { limit: z.number().int().min(1).max(200).optional().describe("Default 50.") },
},
async (args) => {
const list = await publicTrendList(serviceClient(), { limit: args.limit });
return { content: [{ type: "text" as const, text: JSON.stringify(list, null, 2) }] };
},
);
}
46 changes: 46 additions & 0 deletions tests/ads-trend-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { publicTrendList } from "@/lib/ads/trendList";

const NOW = Date.parse("2026-11-07T12:00:00Z");
const row = (topic: string, label: string, ingested_at: string) => ({
source: "rssamp",
topic,
label,
mentions: 60,
prior_mentions: 2,
score: 40,
window_days: 7,
generated_at: null,
ingested_at,
});

function client(rows: unknown[]) {
const chain = {
select: () => chain,
eq: () => chain,
order: () => chain,
limit: async () => ({ data: rows, error: null }),
};
return { from: () => chain } as never;
}

describe("publicTrendList", () => {
it("shows the subject as written and keeps the match key beside it", async () => {
const list = await publicTrendList(client([row("midterm result", "midterm results", "2026-11-07T11:00:00Z")]), { now: NOW });
expect(list.stale).toBe(false);
expect(list.topics).toEqual([
{ topic: "midterm results", match_key: "midterm result", score: 40, feeds: 60, prior_feeds: 2 },
]);
});

it("says when the list has stopped steering delivery", async () => {
const list = await publicTrendList(client([row("a", "a", "2026-11-01T11:00:00Z")]), { now: NOW });
expect(list.stale).toBe(true);
expect(list.topics).toHaveLength(1);
});

it("is not stale when there is simply nothing yet", async () => {
const list = await publicTrendList(client([]), { now: NOW });
expect(list).toMatchObject({ stale: false, ingested_at: null, topics: [] });
});
});
7 changes: 7 additions & 0 deletions tests/ads-trending.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,10 @@ describe("asking for trending targeting", () => {
expect(promoLine(null)).toBe("");
});
});

describe("unsafe topics", () => {
it("are dropped before they can be shown or targeted", () => {
const parsed = parseTrendPayload({ topics: [{ topic: "abuse material", score: 9 }, { topic: "midterm results", score: 5 }] });
expect(parsed.ok && parsed.signals.map((s) => s.label)).toEqual(["midterm results"]);
});
});