From 11ceea9de774c0a5b1dc4ad9a61bb083f78680c9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 02:31:26 +0000 Subject: [PATCH] Trending: public /trending page on a real source, and the promo actually granted Trending targeting was doing nothing: SameBrain returns no topics and nothing ran the pull, so the list had been stale since September and no ad was boosted. - Pull from RSSAmp's public /api/trending, with no secret. - Add /trending, showing subjects as written (label) rather than the stemmed match key, with an honest note when the list is out of date. - Grant the 90-day promo with the service client: ad_promos has no insert policy, so under the user's session it failed silently. Co-Authored-By: Claude Opus 5.5 --- app/(app)/dashboard/ads/new/form.tsx | 3 + app/(marketing)/trending/page.tsx | 103 ++++++++++++++++++ app/actions/ads.ts | 5 +- app/api/cron/ad-trends/route.ts | 11 +- app/sitemap.ts | 1 + lib/ads/trending.ts | 7 +- lib/ads/trends.ts | 30 +++-- lib/env.ts | 9 +- .../20261107020000_ad_trend_topic_label.sql | 6 + tests/ads-trending.test.ts | 4 +- 10 files changed, 144 insertions(+), 35 deletions(-) create mode 100644 app/(marketing)/trending/page.tsx create mode 100644 supabase/migrations/20261107020000_ad_trend_topic_label.sql diff --git a/app/(app)/dashboard/ads/new/form.tsx b/app/(app)/dashboard/ads/new/form.tsx index e3102259..50515f96 100644 --- a/app/(app)/dashboard/ads/new/form.tsx +++ b/app/(app)/dashboard/ads/new/form.tsx @@ -245,6 +245,9 @@ export function NewAdForm() { /> Target trending topics + + See what is trending + 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 diff --git a/app/(marketing)/trending/page.tsx b/app/(marketing)/trending/page.tsx new file mode 100644 index 00000000..ec4bec37 --- /dev/null +++ b/app/(marketing)/trending/page.tsx @@ -0,0 +1,103 @@ +import Link from "next/link"; +import { serviceClient } from "@/lib/supabase/service"; +import { currentTrends } from "@/lib/ads/trends"; +import { TREND_WINDOW_DAYS, trendsAreStale } from "@/lib/ads/trending"; + +export const metadata = { + title: "Trending topics — what the open web is writing about this week", + description: + "The subjects independent blogs and news sites are writing about more than they were last week, measured across hundreds of thousands of feeds and updated daily.", + alternates: { canonical: "/trending" }, + openGraph: { + title: "Trending topics on the open web", + description: "What blogs and news sites are writing about more than they were last week.", + url: "/trending", + }, +}; + +// The source moves once a day, so an hour is plenty fresh. +export const revalidate = 3600; + +const LIMIT = 50; + +function change(now: number, before: number): string { + if (before === 0) return "new"; + const ratio = now / before; + return ratio >= 10 ? `${Math.round(ratio)}x` : `${ratio.toFixed(1)}x`; +} + +function updated(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); +} + +export default async function TrendingPage() { + const topics = await currentTrends(serviceClient(), { limit: LIMIT, includeStale: true }); + const newest = topics.reduce( + (latest, topic) => (!latest || (topic.ingestedAt ?? "") > latest ? (topic.ingestedAt ?? latest) : latest), + null, + ); + const stale = topics.length > 0 && trendsAreStale(newest); + + return ( +
+

Trending topics

+

+ What English blogs and news sites are writing about more than they were last week, by how many + feeds used the subject in a headline. It comes from{" "} + + RSSAmp + + , a sample of its feeds, so read it as movement rather than a census. +

+ + {stale && newest && ( +

+ This list is from {updated(newest)} and is out of date, so it is not steering any ads right now. +

+ )} + + {topics.length === 0 ? ( +

+ The first weekly comparison is still being worked out. Check back shortly. +

+ ) : ( +
+ + + + + + + + + + + + {topics.map((topic, index) => ( + + + + + + + + ))} + +
#SubjectFeeds, {TREND_WINDOW_DAYS}dWeek beforeChange
{index + 1}{topic.label ?? topic.topic}{topic.mentions}{topic.priorMentions}{change(topic.mentions, topic.priorMentions)}
+ {newest &&

Updated {updated(newest)}.

} +
+ )} + +
+

Run ads where these subjects are read

+

+ Trending targeting prefers your campaign on pages about a subject that is moving, when your own + page is about it too. +

+ + Create an ad + +
+
+ ); +} diff --git a/app/actions/ads.ts b/app/actions/ads.ts index e579d7d7..b73388fc 100644 --- a/app/actions/ads.ts +++ b/app/actions/ads.ts @@ -334,7 +334,10 @@ export async function saveCampaign(input: { // 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" }); + // The service client: ad_promos is written by us, never by its owner (it + // has no insert policy), so under the user's session this failed silently + // and the promised ninety days never started. + const granted = await grantTrendingPromo(serviceClient(), { userId: user.id, campaignId: campaign.data.id, note: "trending targeting enabled in the dashboard" }); promoDays = granted.state.active ? granted.state.daysRemaining : undefined; } diff --git a/app/api/cron/ad-trends/route.ts b/app/api/cron/ad-trends/route.ts index 73f11963..3f0a64cc 100644 --- a/app/api/cron/ad-trends/route.ts +++ b/app/api/cron/ad-trends/route.ts @@ -1,10 +1,8 @@ // 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`). +// Gated on CRON_SECRET the same way every other cron route is. The caller is the +// `crawlproof ad-trends` line in anthony's crontab on dev2: nothing else runs +// this, and for eight weeks nothing did. // // 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 @@ -35,8 +33,7 @@ export async function POST(req: Request) { const windowDays = Number(new URL(req.url).searchParams.get("window")) || TREND_WINDOW_DAYS; const result = await ingestTrends(serviceClient(), { - url: env.samebrainUrl, - secret: env.samebrainSecret, + url: env.trendUrl, windowDays, }); if (!result.ok) return NextResponse.json({ ok: false, error: result.error }, { status: result.status }); diff --git a/app/sitemap.ts b/app/sitemap.ts index ccdd1c06..0163c981 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -23,6 +23,7 @@ export default async function sitemap(): Promise { { url: `${base}/press`, changeFrequency: "monthly", priority: 0.6 }, { url: `${base}/blog`, changeFrequency: "weekly", priority: 0.8 }, { url: `${base}/recent`, changeFrequency: "hourly", priority: 0.9 }, + { url: `${base}/trending`, changeFrequency: "daily", priority: 0.8 }, { url: `${base}/bot`, changeFrequency: "monthly", priority: 0.5 }, { url: `${base}/privacy`, changeFrequency: "yearly", priority: 0.3 }, { url: `${base}/terms`, changeFrequency: "yearly", priority: 0.3 }, diff --git a/lib/ads/trending.ts b/lib/ads/trending.ts index 9457dff9..c5bb00cd 100644 --- a/lib/ads/trending.ts +++ b/lib/ads/trending.ts @@ -15,8 +15,8 @@ import { DEFAULT_BID_CREDITS } from "./pricing"; -/** The only trend source today: chovy.com's SameBrain read on what founders are building. */ -export const TREND_SOURCE = "samebrain"; +/** RSSAmp's weekly read of what its blogs and news sites write about more than last week. */ +export const TREND_SOURCE = "rssamp"; /** Default window to target on, matching what the source ranks over. */ export const TREND_WINDOW_DAYS = 7; @@ -45,7 +45,10 @@ export const TREND_MATCH_MULTIPLIER = 4; export type TrendSignal = { source: string; + /** Stemmed for matching ("midterm result"). Compare on this. */ topic: string; + /** The subject as it was written ("midterm results"). Show this. */ + label?: string; score: number; mentions: number; priorMentions: number; diff --git a/lib/ads/trends.ts b/lib/ads/trends.ts index 7e3e9e6e..343c0b5e 100644 --- a/lib/ads/trends.ts +++ b/lib/ads/trends.ts @@ -1,13 +1,9 @@ // Pulling the trend list in, and reading it back out. // -// chovy.com's SameBrain endpoint answers "what are founders trying to build -// this week" as a list of subjects with a score. This module fetches that over -// a shared secret, normalises it, and stores it as targeting signals. Serving -// reads `currentTrends`; nothing on the serving path ever reaches chovy.com. -// -// The secret lives in the vault (logicsrc team `crawlproof-com--prod`) as -// SAMEBRAIN_SECRET and is set as an environment variable on the Railway -// service — never in a committed .env file. +// RSSAmp answers "what is the open web writing about more than last week" as a +// list of subjects with a score (rssamp.com/api/trending, public, no key). This +// module fetches that, normalises it, and stores it as targeting signals. +// Serving reads `currentTrends`; nothing on the serving path ever reaches RSSAmp. import type { SupabaseClient } from "@supabase/supabase-js"; import { @@ -65,6 +61,7 @@ export function parseTrendPayload( signals.push({ source, topic, + label: String(row.topic ?? "").trim().slice(0, 80) || topic, score: clampNumber(row.score, 0, 1_000_000, 0), mentions: Math.round(clampNumber(row.count ?? row.mentions, 0, 1_000_000, 0)), priorMentions: Math.round(clampNumber(row.prior_count ?? row.priorMentions, 0, 1_000_000, 0)), @@ -78,9 +75,8 @@ export function parseTrendPayload( } export type TrendSourceConfig = { - /** Base URL of the trend source, e.g. https://chovy.com */ + /** Base URL of the trend source, e.g. https://rssamp.com */ url: string; - secret: string; windowDays?: number; limit?: number; }; @@ -88,7 +84,7 @@ export type TrendSourceConfig = { /** * Ask the source what is trending. * - * Server to server, bearer secret, short timeout: this runs on a schedule and + * Server to server, short timeout: this runs on a schedule and * a trend list is never worth holding a request open for. A failure here is * not an outage — serving falls back to the stored list, and past * TREND_MAX_AGE_HOURS to no trend targeting at all. @@ -97,18 +93,17 @@ export async function fetchTrends( config: TrendSourceConfig, fetchImpl: typeof fetch = fetch, ): Promise<{ ok: true; signals: TrendSignal[]; generatedAt: string | null } | { ok: false; status: number; error: string }> { - if (!config.url) return { ok: false, status: 503, error: "No trend source configured (SAMEBRAIN_URL)." }; - if (!config.secret) return { ok: false, status: 503, error: "No trend source secret configured (SAMEBRAIN_SECRET)." }; + if (!config.url) return { ok: false, status: 503, error: "No trend source configured (TREND_URL)." }; const windowDays = Math.round(clampNumber(config.windowDays, 1, 90, TREND_WINDOW_DAYS)); const limit = Math.round(clampNumber(config.limit, 1, 200, 50)); const base = config.url.replace(/\/$/, ""); - const url = `${base}/api/samebrain/trending?window=${windowDays}&limit=${limit}`; + const url = `${base}/api/trending?window=${windowDays}&limit=${limit}`; let response: Response; try { response = await fetchImpl(url, { - headers: { authorization: `Bearer ${config.secret}`, accept: "application/json" }, + headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000), }); } catch (error) { @@ -149,6 +144,7 @@ export async function storeTrends( const rows = signals.map((signal) => ({ source, topic: signal.topic, + label: signal.label ?? signal.topic, mentions: signal.mentions, prior_mentions: signal.priorMentions, score: signal.score, @@ -212,6 +208,7 @@ export async function ingestTrends( type TrendRow = { source: string; topic: string; + label: string | null; mentions: number | null; prior_mentions: number | null; score: number | null; @@ -223,6 +220,7 @@ type TrendRow = { const rowToSignal = (row: TrendRow): TrendSignal => ({ source: row.source, topic: row.topic, + label: row.label ?? row.topic, score: Number(row.score) || 0, mentions: Number(row.mentions) || 0, priorMentions: Number(row.prior_mentions) || 0, @@ -250,7 +248,7 @@ export async function currentTrends( try { const { data, error } = await sb .from("ad_trend_topics") - .select("source, topic, mentions, prior_mentions, score, window_days, generated_at, ingested_at") + .select("source, topic, label, mentions, prior_mentions, score, window_days, generated_at, ingested_at") .eq("source", source) .eq("window_days", windowDays) .order("score", { ascending: false }) diff --git a/lib/env.ts b/lib/env.ts index 52a378ee..3d206730 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -61,13 +61,8 @@ export const env = { // Tor SOCKS5 proxy for reaching .onion targets, e.g. socks5h://127.0.0.1:9050. // Empty = .onion audits/ads are unreachable (fail with a clear message). torSocksUrl: process.env.TOR_SOCKS_URL ?? "", - // SameBrain — chovy.com's read on what founders are asking to build this - // week, which the ad network targets on. The secret is the same value the - // source has; both live in the vault (logicsrc team `crawlproof-com--prod`) - // and are set on the Railway service, never in a committed .env file. - // Unset means no trend ingestion, which degrades to no trend targeting. - samebrainUrl: process.env.SAMEBRAIN_URL ?? "https://chovy.com", - samebrainSecret: process.env.SAMEBRAIN_SECRET ?? "", + // Where the trending subjects come from. Public, so no key. + trendUrl: process.env.TREND_URL ?? "https://rssamp.com", workerUrl: process.env.WORKER_URL ?? "", workerSecret: process.env.WORKER_SHARED_SECRET ?? "", cronSecret: process.env.CRON_SECRET ?? "", diff --git a/supabase/migrations/20261107020000_ad_trend_topic_label.sql b/supabase/migrations/20261107020000_ad_trend_topic_label.sql new file mode 100644 index 00000000..eedea44c --- /dev/null +++ b/supabase/migrations/20261107020000_ad_trend_topic_label.sql @@ -0,0 +1,6 @@ +-- `topic` is stemmed for matching ("midterm result"), which is no way to show a +-- subject to a person. The public /trending page shows this instead. +alter table public.ad_trend_topics add column if not exists label text; + +-- The list from the old source: stale since 2026-09-12, and never subjects. +delete from public.ad_trend_topics where source = 'samebrain'; diff --git a/tests/ads-trending.test.ts b/tests/ads-trending.test.ts index d176e74f..2bcf5f88 100644 --- a/tests/ads-trending.test.ts +++ b/tests/ads-trending.test.ts @@ -25,7 +25,7 @@ import { campaignBodyFromArgs, promoLine, parseArgs } from "@/cli/index"; const DAY = 24 * 60 * 60 * 1000; const signal = (topic: string, score = 10): TrendSignal => ({ - source: "samebrain", + source: "rssamp", topic, score, mentions: 5, @@ -119,7 +119,7 @@ describe("matching a campaign to a page", () => { describe("the trend payload another service sends", () => { it("keeps what it can use and drops the rest", () => { const parsed = parseTrendPayload({ - source: "samebrain", + source: "rssamp", window_days: 7, generated_at: 1_757_500_000_000, topics: [