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
3 changes: 3 additions & 0 deletions app/(app)/dashboard/ads/new/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ export function NewAdForm() {
/>
<span>
<span className="font-medium">Target trending topics</span>
<a href="/trending" target="_blank" className="ml-2 text-xs underline">
See what is trending
</a>
<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
Expand Down
103 changes: 103 additions & 0 deletions app/(marketing)/trending/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(
(latest, topic) => (!latest || (topic.ingestedAt ?? "") > latest ? (topic.ingestedAt ?? latest) : latest),
null,
);
const stale = topics.length > 0 && trendsAreStale(newest);

return (
<div className="mx-auto max-w-3xl px-6 py-14">
<h1 className="text-3xl font-bold tracking-tight">Trending topics</h1>
<p className="mt-3 text-[var(--color-muted)]">
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{" "}
<a href="https://rssamp.com" className="underline">
RSSAmp
</a>
, a sample of its feeds, so read it as movement rather than a census.
</p>

{stale && newest && (
<p className="card mt-6 p-3 text-sm text-[var(--color-muted)]">
This list is from {updated(newest)} and is out of date, so it is not steering any ads right now.
</p>
)}

{topics.length === 0 ? (
<p className="card mt-8 p-5 text-sm text-[var(--color-muted)]">
The first weekly comparison is still being worked out. Check back shortly.
</p>
) : (
<div className="mt-8 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--color-border)] text-left text-xs uppercase tracking-wider text-[var(--color-muted)]">
<th className="py-2 pr-3 font-medium">#</th>
<th className="py-2 pr-3 font-medium">Subject</th>
<th className="py-2 pr-3 text-right font-medium">Feeds, {TREND_WINDOW_DAYS}d</th>
<th className="py-2 pr-3 text-right font-medium">Week before</th>
<th className="py-2 text-right font-medium">Change</th>
</tr>
</thead>
<tbody>
{topics.map((topic, index) => (
<tr key={topic.topic} className="border-b border-[var(--color-border)]">
<td className="py-2 pr-3 text-[var(--color-muted)]">{index + 1}</td>
<td className="py-2 pr-3 font-medium">{topic.label ?? topic.topic}</td>
<td className="py-2 pr-3 text-right tabular-nums">{topic.mentions}</td>
<td className="py-2 pr-3 text-right tabular-nums">{topic.priorMentions}</td>
<td className="py-2 text-right tabular-nums">{change(topic.mentions, topic.priorMentions)}</td>
</tr>
))}
</tbody>
</table>
{newest && <p className="mt-3 text-xs text-[var(--color-muted)]">Updated {updated(newest)}.</p>}
</div>
)}

<div className="card mt-10 p-5">
<h2 className="font-semibold">Run ads where these subjects are read</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Trending targeting prefers your campaign on pages about a subject that is moving, when your own
page is about it too.
</p>
<Link href="/dashboard/ads/new" className="btn btn-primary mt-3 inline-block">
Create an ad
</Link>
</div>
</div>
);
}
5 changes: 4 additions & 1 deletion app/actions/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
11 changes: 4 additions & 7 deletions app/api/cron/ad-trends/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ 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 },
Expand Down
7 changes: 5 additions & 2 deletions lib/ads/trending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 14 additions & 16 deletions lib/ads/trends.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)),
Expand All @@ -78,17 +75,16 @@ 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;
};

/**
* 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.
Expand 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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down
9 changes: 2 additions & 7 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "",
Expand Down
6 changes: 6 additions & 0 deletions supabase/migrations/20261107020000_ad_trend_topic_label.sql
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 2 additions & 2 deletions tests/ads-trending.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: [
Expand Down