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
12 changes: 8 additions & 4 deletions app/(marketing)/trending/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,16 @@ export default async function TrendingPage() {
<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{" "}
What people are writing and searching for more than they were last week: subjects in the
headlines of a sample of{" "}
<a href="https://rssamp.com" className="underline">
RSSAmp
</a>
, a sample of its feeds, so read it as movement rather than a census.
&apos;s English blogs and news sites, mixed with searches people voted on at{" "}
<a href="https://pit.world" className="underline">
pit.world
</a>
. Read it as movement rather than a census.
</p>

{stale && newest && (
Expand All @@ -67,7 +71,7 @@ export default async function TrendingPage() {
<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">Mentions, {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>
Expand Down
1 change: 1 addition & 0 deletions app/api/cron/ad-trends/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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.trendUrl,
votedUrl: env.votedSearchesUrl,
windowDays,
});
if (!result.ok) return NextResponse.json({ ok: false, error: result.error }, { status: result.status });
Expand Down
47 changes: 46 additions & 1 deletion lib/ads/trends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export function parseTrendPayload(
export type TrendSourceConfig = {
/** Base URL of the trend source, e.g. https://rssamp.com */
url: string;
/** Searches people voted on, mixed into the list. Optional: see votedSignals. */
votedUrl?: string;
windowDays?: number;
limit?: number;
};
Expand Down Expand Up @@ -184,6 +186,47 @@ export async function storeTrends(
return { stored: rows.length, removed };
}

/** A voted search outranks a headline: three votes lands it mid-list. */
export const VOTE_SCORE = 50;

/** The list pit.world publishes, as signals. Any failure is simply no extra signal. */
async function votedSignals(url: string, fetchImpl: typeof fetch): Promise<TrendSignal[]> {
try {
const response = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
if (!response.ok) return [];
const body = (await response.json()) as { topics?: Array<{ phrase?: unknown; votes?: unknown; prior_votes?: unknown }> };
const topics = (body.topics ?? []).map((row) => ({
topic: row.phrase,
score: Number(row.votes) * VOTE_SCORE,
count: row.votes,
prior_count: row.prior_votes,
}));
const parsed = parseTrendPayload({ source: TREND_SOURCE, topics }, { limit: 200 });
return parsed.ok ? parsed.signals : [];
} catch {
return [];
}
}

/**
* One ranking from what is being written and what is being searched. A subject
* in both adds up, which is the strongest signal either list can give.
*/
export function mixTrends(written: TrendSignal[], searched: TrendSignal[], limit = 50): TrendSignal[] {
const merged = new Map(written.map((signal) => [signal.topic, { ...signal }]));
for (const signal of searched) {
const same = merged.get(signal.topic);
if (!same) {
merged.set(signal.topic, { ...signal, windowDays: written[0]?.windowDays ?? signal.windowDays });
continue;
}
same.score += signal.score;
same.mentions += signal.mentions;
same.priorMentions += signal.priorMentions;
}
return [...merged.values()].sort((a, b) => b.score - a.score).slice(0, limit);
}

/** Pull and store in one call. What the cron route and the CLI both run. */
export async function ingestTrends(
sb: SupabaseClient,
Expand All @@ -194,7 +237,9 @@ export async function ingestTrends(
if (!fetched.ok) return fetched;
const windowDays = Math.round(clampNumber(config.windowDays, 1, 90, TREND_WINDOW_DAYS));
try {
const { stored, removed } = await storeTrends(sb, fetched.signals, { windowDays });
const searched = config.votedUrl ? await votedSignals(config.votedUrl, fetchImpl) : [];
const signals = mixTrends(fetched.signals, searched, config.limit ?? 50);
const { stored, removed } = await storeTrends(sb, signals, { windowDays });
return {
ok: true,
source: fetched.signals[0]?.source ?? TREND_SOURCE,
Expand Down
1 change: 1 addition & 0 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const env = {
torSocksUrl: process.env.TOR_SOCKS_URL ?? "",
// Where the trending subjects come from. Public, so no key.
trendUrl: process.env.TREND_URL ?? "https://rssamp.com",
votedSearchesUrl: process.env.VOTED_SEARCHES_URL ?? "https://pit.world/api/voted-searches",
workerUrl: process.env.WORKER_URL ?? "",
workerSecret: process.env.WORKER_SHARED_SECRET ?? "",
cronSecret: process.env.CRON_SECRET ?? "",
Expand Down
33 changes: 32 additions & 1 deletion tests/ads-trending.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
trendsAreStale,
type TrendSignal,
} from "@/lib/ads/trending";
import { parseTrendPayload } from "@/lib/ads/trends";
import { ingestTrends, mixTrends, parseTrendPayload } from "@/lib/ads/trends";
import { parseCampaignPatch, parseCampaignRequest } from "@/lib/ads/campaign-request";
import { TRENDING_CPC_CENTS, CREDIT_CENTS, trendingCpcCredits } from "@/lib/ads/pricing";
import { campaignBodyFromArgs, promoLine, parseArgs } from "@/cli/index";
Expand Down Expand Up @@ -294,3 +294,34 @@ describe("unsafe topics", () => {
expect(parsed.ok && parsed.signals.map((s) => s.label)).toEqual(["midterm results"]);
});
});

describe("mixing written and searched trends", () => {
const signal = (topic: string, score: number, mentions: number) => ({
source: "rssamp", topic, label: topic, score, mentions, priorMentions: 1, windowDays: 7, generatedAt: null, ingestedAt: null,
});

it("adds up a subject in both lists and ranks the result", () => {
const out = mixTrends([signal("blue wave", 100, 60), signal("learjet", 300, 80)], [signal("blue wave", 250, 5), signal("zelda", 150, 3)]);
expect(out.map((s) => s.topic)).toEqual(["blue wave", "learjet", "zelda"]);
expect(out[0]).toMatchObject({ score: 350, mentions: 65, priorMentions: 2 });
});

it("keeps the written list when there are no votes", () => {
expect(mixTrends([signal("a", 1, 1)], []).map((s) => s.topic)).toEqual(["a"]);
});

it("survives the vote source being down", async () => {
const stored: unknown[][] = [];
const sb = {
from: () => ({
upsert: async (rows: unknown[]) => { stored.push(rows); return { error: null }; },
delete: () => ({ eq: () => ({ eq: () => ({ not: () => ({ select: async () => ({ data: [] }) }) }) }) }),
}),
} as never;
const fetchImpl = (async (url: string) =>
String(url).includes("voted") ? new Response("nope", { status: 503 }) : Response.json({ topics: [{ topic: "learjet", score: 5, count: 9 }] })) as typeof fetch;
const out = await ingestTrends(sb, { url: "https://rss.test", votedUrl: "https://pit.test/voted" }, fetchImpl);
expect(out.ok).toBe(true);
expect(stored[0]).toHaveLength(1);
});
});