trending-news-api 0.1.148 → 0.1.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -96,7 +96,7 @@ function formatDate(d) {
96
96
  async function fetchWikipediaTopPages(date, limit = DEFAULT_TOPIC_LIMIT, fetchImpl = fetch) {
97
97
  const url = (d) => {
98
98
  const [year, month, day] = formatDate(d).split("-");
99
- return `https://wikimedia.org/api/rest_v1/metrics/pageviews/top-per-article/en.wikipedia.org/all-access/all-agents/${year}/${month}/${day}`;
99
+ return `https://wikimedia.org/api/rest_v1/metrics/pageviews/top/en.wikipedia.org/all-access/${year}/${month}/${day}`;
100
100
  };
101
101
  let res = await fetchImpl(url(date), { headers: { "User-Agent": WIKI_USER_AGENT } });
102
102
  if (res.status === 404) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["/**\n * @fileoverview Server half of the widget: joins Wikipedia's daily pageview\n * ranking to headlines from The News API.\n *\n * Shared by the bundled Cloudflare Worker (`worker/index.ts`) and by host apps\n * that would rather serve the widget from an API route they already deploy\n * than stand up a second worker — both go through `handleTrendingNewsRequest`,\n * so there is exactly one wire format for the client in `src/api/trending.ts`\n * to read.\n *\n * Nothing here touches the DOM or React: it is plain `fetch`/`Request`/\n * `Response`, so it runs on Workers, Node 18+, Bun and Deno alike. The News\n * API key stays on the server.\n */\n\n/** One headline, in the snake_case wire shape the client maps to camelCase. */\nexport interface TrendingNewsWireArticle {\n title: string;\n url?: string;\n source?: string;\n published_at?: string;\n image_url?: string;\n}\n\n/** One trending topic with the headlines matched to it. */\nexport interface TrendingNewsWireTopic {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: TrendingNewsWireArticle[];\n}\n\n/** Body of `GET /` — the daily trending list. */\nexport interface TrendingNewsWireResponse {\n source: string;\n date: string;\n topics: TrendingNewsWireTopic[];\n}\n\n/**\n * The most topics a caller may name in `?topics=`. Each one costs an upstream\n * news search, so an unbounded list is a way to burn the API quota in one\n * request.\n */\nexport const MAX_CUSTOM_TOPICS = 20;\n\n/** Body of `GET /?topic=…` — headlines for one topic. */\nexport interface TrendingNewsWireTopicResponse {\n topic: string;\n news_count: number;\n articles: TrendingNewsWireArticle[];\n}\n\nexport interface TrendingTopicsOptions {\n /** The News API (thenewsapi.com) token. */\n apiKey: string;\n /** How many topics to return (default 25, capped at 50). */\n limit?: number;\n /** Day to rank by pageviews. Defaults to yesterday UTC — see below. */\n date?: Date;\n /** Injected for tests; defaults to the global `fetch`. */\n fetchImpl?: typeof fetch;\n}\n\ntype WikiTopPage = {\n rank: number;\n article: string;\n views: number;\n};\n\ntype NewsApiArticle = {\n title?: string;\n description?: string;\n source?: string;\n url?: string;\n published_at?: string;\n image_url?: string;\n};\n\nexport const DEFAULT_TOPIC_LIMIT = 25;\nexport const MAX_TOPIC_LIMIT = 50;\n\n/** Headlines requested per topic in the daily list, and for a single topic. */\nconst ARTICLES_PER_TOPIC = 20;\nconst ARTICLES_FOR_SINGLE_TOPIC = 30;\n\n/**\n * Topics whose news search runs at once. The daily list needs one News API\n * call per topic; sequentially that is a visible wait on a homepage widget,\n * and unbounded it would burst the API's rate limit.\n */\nconst SEARCH_CONCURRENCY = 5;\n\n/**\n * Wikimedia rejects or rate-limits requests without a descriptive User-Agent\n * (https://meta.wikimedia.org/wiki/User-Agent_policy), so name the project\n * and where to find it.\n */\nconst WIKI_USER_AGENT =\n 'trending-news-api (https://github.com/OpenSourceAGI/qwksearch-research-agent)';\n\n/**\n * A failure talking to The News API — bad or missing token, exhausted quota,\n * plan restriction, rate limit. Carries the API's own message so it can be\n * shown to whoever has to fix it instead of turning into an empty widget.\n */\nexport class NewsApiError extends Error {\n status: number;\n code?: string;\n constructor(message: string, status: number, code?: string) {\n super(message);\n this.name = 'NewsApiError';\n this.status = status;\n this.code = code;\n }\n}\n\nconst CORS_HEADERS = {\n 'Access-Control-Allow-Origin': '*',\n 'Cache-Control': 'no-store',\n};\n\n// Wikipedia's daily \"top viewed\" list is dominated by navigation pages that\n// aren't real trending topics — filter those out rather than surfacing\n// \"Main Page\" as the #1 trend every day.\nconst NON_ARTICLE_TITLE = /^(Main_Page|Special:|Wikipedia:|Portal:|File:|Talk:|Category:)/i;\n\nfunction jsonResponse(payload: unknown, status = 200): Response {\n return new Response(JSON.stringify(payload), {\n status,\n headers: { 'content-type': 'application/json', ...CORS_HEADERS },\n });\n}\n\n/** Clamps a caller-supplied topic count to something we're willing to fetch. */\nexport function parseTopicLimit(\n raw: string | number | null | undefined,\n fallback: number = DEFAULT_TOPIC_LIMIT\n): number {\n const value = typeof raw === 'string' ? Number(raw) : raw;\n if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;\n return Math.min(MAX_TOPIC_LIMIT, Math.floor(value));\n}\n\n/**\n * Parses a caller-supplied custom topic list (`?topics=a,b,c`, or newline\n * separated) into clean, de-duplicated topic names.\n *\n * Topics come from user settings, so they are untrusted: each one is trimmed,\n * length-capped and compared case-insensitively for de-duplication, and the\n * list is capped at `MAX_CUSTOM_TOPICS`.\n */\nexport function parseTopicList(raw: string | string[] | null | undefined): string[] {\n if (!raw) return [];\n const parts = Array.isArray(raw) ? raw : raw.split(/[,\\n]/);\n const seen = new Set<string>();\n const topics: string[] = [];\n for (const part of parts) {\n const topic = String(part).trim().slice(0, 120);\n if (!topic) continue;\n const key = topic.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n topics.push(topic);\n if (topics.length >= MAX_CUSTOM_TOPICS) break;\n }\n return topics;\n}\n\n/** The UTC date the pageviews API has complete data for (see `getTrendingTopics`). */\nfunction defaultPageviewsDate(now: Date = new Date()): Date {\n const d = new Date(now);\n d.setUTCDate(d.getUTCDate() - 1);\n return d;\n}\n\nfunction formatDate(d: Date): string {\n return [\n String(d.getUTCFullYear()).padStart(4, '0'),\n String(d.getUTCMonth() + 1).padStart(2, '0'),\n String(d.getUTCDate()).padStart(2, '0'),\n ].join('-');\n}\n\n/**\n * Fetch daily top Wikipedia articles by pageviews for a given date, via the\n * Wikimedia Analytics API.\n */\nexport async function fetchWikipediaTopPages(\n date: Date,\n limit = DEFAULT_TOPIC_LIMIT,\n fetchImpl: typeof fetch = fetch\n): Promise<WikiTopPage[]> {\n const url = (d: Date) => {\n const [year, month, day] = formatDate(d).split('-');\n return `https://wikimedia.org/api/rest_v1/metrics/pageviews/top-per-article/en.wikipedia.org/all-access/all-agents/${year}/${month}/${day}`;\n };\n\n let res = await fetchImpl(url(date), { headers: { 'User-Agent': WIKI_USER_AGENT } });\n // Yesterday's ranking is usually published a few hours into the UTC day;\n // before then the API answers 404, so fall back to the day before.\n if (res.status === 404) {\n const earlier = new Date(date);\n earlier.setUTCDate(earlier.getUTCDate() - 1);\n res = await fetchImpl(url(earlier), { headers: { 'User-Agent': WIKI_USER_AGENT } });\n }\n if (!res.ok) {\n throw new Error(`Failed to fetch Wikipedia top pages: ${res.status}`);\n }\n\n type Entry = { article: string; views: number; rank?: number };\n const data = (await res.json()) as { items?: Array<Entry | { articles?: Entry[] }> };\n\n // The pageviews API nests the ranking one level down\n // (`items[0].articles[]`); accept a flat `items[]` too.\n const raw = (data.items ?? []).flatMap((it) =>\n 'articles' in it && Array.isArray(it.articles) ? it.articles : [it as Entry]\n );\n const items = raw.filter((it) => it?.article && !NON_ARTICLE_TITLE.test(it.article));\n\n return items.slice(0, limit).map((it, i) => ({\n rank: it.rank ?? i + 1,\n article: safeDecode(it.article.replace(/_/g, ' ')),\n views: it.views,\n }));\n}\n\nfunction safeDecode(value: string): string {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n}\n\n/**\n * Search news for a given query using The News API (thenewsapi.com) — its\n * `/v1/news/all` endpoint, which takes the query as `search`.\n *\n * A non-OK answer throws a `NewsApiError` with the API's own message (bad\n * token, quota, plan limits). The list builders below catch it per topic, so\n * one topic the API dislikes doesn't cost the whole list — but when *every*\n * search fails they rethrow it, so a broken key surfaces as an error instead\n * of an empty widget.\n */\nexport async function searchNewsForTopic(\n apiKey: string,\n query: string,\n limit = ARTICLES_PER_TOPIC,\n fetchImpl: typeof fetch = fetch\n): Promise<NewsApiArticle[]> {\n const url = new URL('https://api.thenewsapi.com/v1/news/all');\n url.searchParams.set('api_token', apiKey);\n url.searchParams.set('search', query);\n url.searchParams.set('language', 'en');\n url.searchParams.set('limit', String(limit));\n\n const res = await fetchImpl(url.toString());\n let data: { data?: NewsApiArticle[]; error?: { code?: string; message?: string } } = {};\n try {\n data = (await res.json()) as typeof data;\n } catch {\n // Non-JSON body; fall through with what the status says.\n }\n if (!res.ok || data.error) {\n const code = data.error?.code;\n const message = data.error?.message ?? `HTTP ${res.status}`;\n throw new NewsApiError(\n `The News API: ${message}${code ? ` (${code})` : ''}`,\n res.status,\n code\n );\n }\n return data.data ?? [];\n}\n\n/**\n * Runs `searchNewsForTopic` for each query, keeping per-topic failures from\n * sinking the batch. Returns `null` articles for a failed topic plus the first\n * error seen.\n */\nasync function searchBatch(\n apiKey: string,\n queries: string[],\n fetchImpl: typeof fetch\n): Promise<{ results: Array<NewsApiArticle[] | null>; error?: Error }> {\n let error: Error | undefined;\n const results = await Promise.all(\n queries.map(async (q) => {\n try {\n return await searchNewsForTopic(apiKey, q, ARTICLES_PER_TOPIC, fetchImpl);\n } catch (e) {\n error ??= e instanceof Error ? e : new Error(String(e));\n return null;\n }\n })\n );\n return { results, error };\n}\n\nfunction toArticlePayload(articles: NewsApiArticle[]): TrendingNewsWireArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n published_at: a.published_at,\n image_url: a.image_url,\n }));\n}\n\n/**\n * The daily trending list: Wikipedia's most-viewed articles, each paired with\n * the headlines The News API returns for it. Topics with no headlines are\n * dropped, so we look at up to twice as many Wikipedia entries as the caller\n * asked for topics and stop as soon as the quota is filled.\n *\n * Pageviews lag about a day, so the ranking is read for *yesterday* (UTC)\n * unless a `date` is passed.\n */\nexport async function getTrendingTopics(\n options: TrendingTopicsOptions\n): Promise<TrendingNewsWireResponse> {\n const { apiKey, fetchImpl = fetch } = options;\n const limit = parseTopicLimit(options.limit);\n const date = options.date ?? defaultPageviewsDate();\n\n const candidates = await fetchWikipediaTopPages(\n date,\n Math.min(MAX_TOPIC_LIMIT, limit * 2),\n fetchImpl\n );\n\n const results: TrendingNewsWireTopic[] = [];\n let searches = 0;\n let failures = 0;\n let firstError: Error | undefined;\n for (let i = 0; i < candidates.length && results.length < limit; i += SEARCH_CONCURRENCY) {\n const batch = candidates.slice(i, i + SEARCH_CONCURRENCY);\n const { results: found, error } = await searchBatch(\n apiKey,\n batch.map((entry) => entry.article),\n fetchImpl\n );\n firstError ??= error;\n searches += found.length;\n failures += found.filter((a) => a === null).length;\n\n for (let j = 0; j < batch.length; j++) {\n const entry = batch[j];\n const articles = found[j];\n if (!articles || !articles.length) continue;\n results.push({\n topic: entry.article,\n wiki_rank: entry.rank,\n wiki_views: entry.views,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n });\n }\n }\n\n // Every search failed: that is the API key or quota, not the topics.\n if (searches > 0 && failures === searches && firstError) throw firstError;\n\n results.sort((a, b) => (a.wiki_rank ?? 999) - (b.wiki_rank ?? 999));\n\n return {\n source: 'wikipedia_daily_top',\n date: formatDate(date),\n topics: results.slice(0, limit),\n };\n}\n\n/**\n * The same wire shape as the daily list, but built from topics the caller\n * named instead of Wikipedia's ranking — this is what backs per-user custom\n * topics and an admin-configured default topic list.\n *\n * Ordering is the caller's, not a popularity ranking, so no `wiki_rank` is\n * attached and the result is not re-sorted. Unlike the daily list, a topic\n * with no headlines is *kept* (with `news_count: 0`): the user asked for it by\n * name, and silently dropping it reads as the setting not having been saved.\n * Searches run in the same bounded batches as the daily list.\n */\nexport async function getCustomTopicNews(\n topics: string[],\n options: { apiKey: string; fetchImpl?: typeof fetch }\n): Promise<TrendingNewsWireResponse> {\n const { apiKey, fetchImpl = fetch } = options;\n const wanted = parseTopicList(topics);\n\n const results: TrendingNewsWireTopic[] = [];\n let failures = 0;\n let firstError: Error | undefined;\n for (let i = 0; i < wanted.length; i += SEARCH_CONCURRENCY) {\n const batch = wanted.slice(i, i + SEARCH_CONCURRENCY);\n const { results: found, error } = await searchBatch(apiKey, batch, fetchImpl);\n firstError ??= error;\n failures += found.filter((a) => a === null).length;\n\n for (let j = 0; j < batch.length; j++) {\n const topic = batch[j];\n const articles = found[j] ?? [];\n results.push({\n topic,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n });\n }\n }\n\n if (wanted.length > 0 && failures === wanted.length && firstError) throw firstError;\n\n return {\n source: 'custom_topics',\n date: formatDate(new Date()),\n topics: results,\n };\n}\n\n/** Headlines for a single topic, bypassing the Wikipedia ranking. */\nexport async function getTopicHeadlines(\n topic: string,\n options: { apiKey: string; fetchImpl?: typeof fetch }\n): Promise<TrendingNewsWireTopicResponse> {\n const articles = await searchNewsForTopic(\n options.apiKey,\n topic,\n ARTICLES_FOR_SINGLE_TOPIC,\n options.fetchImpl ?? fetch\n );\n\n return {\n topic,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n };\n}\n\n/**\n * Serves the three routes the client speaks:\n *\n * - `GET /` — the daily trending list (`?limit=` topics, default 25).\n * - `GET /?topic=…` — headlines for one topic.\n * - `GET /?topics=a,b,c` — headlines for a caller-named topic list, in that\n * order, instead of the Wikipedia ranking.\n *\n * Always answers with JSON, including for failures, so a widget that only\n * reads `error` never has to parse an HTML error page.\n */\nexport async function handleTrendingNewsRequest(\n request: Request,\n options: { apiKey: string | undefined; fetchImpl?: typeof fetch }\n): Promise<Response> {\n const { apiKey, fetchImpl } = options;\n if (!apiKey) {\n return jsonResponse({ error: 'THE_NEWS_API_KEY is not configured' }, 500);\n }\n\n const params = new URL(request.url).searchParams;\n const topic = params.get('topic');\n // `topics` is only honoured when it survives parsing — a list of nothing but\n // whitespace must fall through to the daily ranking rather than render an\n // empty widget.\n const customTopics = parseTopicList(params.get('topics'));\n\n try {\n if (topic) {\n return jsonResponse(await getTopicHeadlines(topic, { apiKey, fetchImpl }));\n }\n\n if (customTopics.length > 0) {\n return jsonResponse(await getCustomTopicNews(customTopics, { apiKey, fetchImpl }));\n }\n\n return jsonResponse(\n await getTrendingTopics({\n apiKey,\n limit: parseTopicLimit(params.get('limit')),\n fetchImpl,\n })\n );\n } catch (e) {\n // A News API failure (bad key, quota) is the actionable part — say that,\n // rather than blaming the step that happened to be running.\n if (e instanceof NewsApiError) {\n return jsonResponse({ error: e.message, code: e.code, upstream_status: e.status }, 502);\n }\n return jsonResponse(\n {\n error: topic\n ? 'Failed to fetch news for topic'\n : customTopics.length > 0\n ? 'Failed to fetch news for topics'\n : 'Failed to fetch Wikipedia trends',\n details: e instanceof Error ? e.message : String(e),\n },\n 500\n );\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["/**\n * @fileoverview Server half of the widget: joins Wikipedia's daily pageview\n * ranking to headlines from The News API.\n *\n * Shared by the bundled Cloudflare Worker (`worker/index.ts`) and by host apps\n * that would rather serve the widget from an API route they already deploy\n * than stand up a second worker — both go through `handleTrendingNewsRequest`,\n * so there is exactly one wire format for the client in `src/api/trending.ts`\n * to read.\n *\n * Nothing here touches the DOM or React: it is plain `fetch`/`Request`/\n * `Response`, so it runs on Workers, Node 18+, Bun and Deno alike. The News\n * API key stays on the server.\n */\n\n/** One headline, in the snake_case wire shape the client maps to camelCase. */\nexport interface TrendingNewsWireArticle {\n title: string;\n url?: string;\n source?: string;\n published_at?: string;\n image_url?: string;\n}\n\n/** One trending topic with the headlines matched to it. */\nexport interface TrendingNewsWireTopic {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: TrendingNewsWireArticle[];\n}\n\n/** Body of `GET /` — the daily trending list. */\nexport interface TrendingNewsWireResponse {\n source: string;\n date: string;\n topics: TrendingNewsWireTopic[];\n}\n\n/**\n * The most topics a caller may name in `?topics=`. Each one costs an upstream\n * news search, so an unbounded list is a way to burn the API quota in one\n * request.\n */\nexport const MAX_CUSTOM_TOPICS = 20;\n\n/** Body of `GET /?topic=…` — headlines for one topic. */\nexport interface TrendingNewsWireTopicResponse {\n topic: string;\n news_count: number;\n articles: TrendingNewsWireArticle[];\n}\n\nexport interface TrendingTopicsOptions {\n /** The News API (thenewsapi.com) token. */\n apiKey: string;\n /** How many topics to return (default 25, capped at 50). */\n limit?: number;\n /** Day to rank by pageviews. Defaults to yesterday UTC — see below. */\n date?: Date;\n /** Injected for tests; defaults to the global `fetch`. */\n fetchImpl?: typeof fetch;\n}\n\ntype WikiTopPage = {\n rank: number;\n article: string;\n views: number;\n};\n\ntype NewsApiArticle = {\n title?: string;\n description?: string;\n source?: string;\n url?: string;\n published_at?: string;\n image_url?: string;\n};\n\nexport const DEFAULT_TOPIC_LIMIT = 25;\nexport const MAX_TOPIC_LIMIT = 50;\n\n/** Headlines requested per topic in the daily list, and for a single topic. */\nconst ARTICLES_PER_TOPIC = 20;\nconst ARTICLES_FOR_SINGLE_TOPIC = 30;\n\n/**\n * Topics whose news search runs at once. The daily list needs one News API\n * call per topic; sequentially that is a visible wait on a homepage widget,\n * and unbounded it would burst the API's rate limit.\n */\nconst SEARCH_CONCURRENCY = 5;\n\n/**\n * Wikimedia rejects or rate-limits requests without a descriptive User-Agent\n * (https://meta.wikimedia.org/wiki/User-Agent_policy), so name the project\n * and where to find it.\n */\nconst WIKI_USER_AGENT =\n 'trending-news-api (https://github.com/OpenSourceAGI/qwksearch-research-agent)';\n\n/**\n * A failure talking to The News API — bad or missing token, exhausted quota,\n * plan restriction, rate limit. Carries the API's own message so it can be\n * shown to whoever has to fix it instead of turning into an empty widget.\n */\nexport class NewsApiError extends Error {\n status: number;\n code?: string;\n constructor(message: string, status: number, code?: string) {\n super(message);\n this.name = 'NewsApiError';\n this.status = status;\n this.code = code;\n }\n}\n\nconst CORS_HEADERS = {\n 'Access-Control-Allow-Origin': '*',\n 'Cache-Control': 'no-store',\n};\n\n// Wikipedia's daily \"top viewed\" list is dominated by navigation pages that\n// aren't real trending topics — filter those out rather than surfacing\n// \"Main Page\" as the #1 trend every day.\nconst NON_ARTICLE_TITLE = /^(Main_Page|Special:|Wikipedia:|Portal:|File:|Talk:|Category:)/i;\n\nfunction jsonResponse(payload: unknown, status = 200): Response {\n return new Response(JSON.stringify(payload), {\n status,\n headers: { 'content-type': 'application/json', ...CORS_HEADERS },\n });\n}\n\n/** Clamps a caller-supplied topic count to something we're willing to fetch. */\nexport function parseTopicLimit(\n raw: string | number | null | undefined,\n fallback: number = DEFAULT_TOPIC_LIMIT\n): number {\n const value = typeof raw === 'string' ? Number(raw) : raw;\n if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;\n return Math.min(MAX_TOPIC_LIMIT, Math.floor(value));\n}\n\n/**\n * Parses a caller-supplied custom topic list (`?topics=a,b,c`, or newline\n * separated) into clean, de-duplicated topic names.\n *\n * Topics come from user settings, so they are untrusted: each one is trimmed,\n * length-capped and compared case-insensitively for de-duplication, and the\n * list is capped at `MAX_CUSTOM_TOPICS`.\n */\nexport function parseTopicList(raw: string | string[] | null | undefined): string[] {\n if (!raw) return [];\n const parts = Array.isArray(raw) ? raw : raw.split(/[,\\n]/);\n const seen = new Set<string>();\n const topics: string[] = [];\n for (const part of parts) {\n const topic = String(part).trim().slice(0, 120);\n if (!topic) continue;\n const key = topic.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n topics.push(topic);\n if (topics.length >= MAX_CUSTOM_TOPICS) break;\n }\n return topics;\n}\n\n/** The UTC date the pageviews API has complete data for (see `getTrendingTopics`). */\nfunction defaultPageviewsDate(now: Date = new Date()): Date {\n const d = new Date(now);\n d.setUTCDate(d.getUTCDate() - 1);\n return d;\n}\n\nfunction formatDate(d: Date): string {\n return [\n String(d.getUTCFullYear()).padStart(4, '0'),\n String(d.getUTCMonth() + 1).padStart(2, '0'),\n String(d.getUTCDate()).padStart(2, '0'),\n ].join('-');\n}\n\n/**\n * Fetch daily top Wikipedia articles by pageviews for a given date, via the\n * Wikimedia Analytics API.\n */\nexport async function fetchWikipediaTopPages(\n date: Date,\n limit = DEFAULT_TOPIC_LIMIT,\n fetchImpl: typeof fetch = fetch\n): Promise<WikiTopPage[]> {\n const url = (d: Date) => {\n const [year, month, day] = formatDate(d).split('-');\n return `https://wikimedia.org/api/rest_v1/metrics/pageviews/top/en.wikipedia.org/all-access/${year}/${month}/${day}`;\n };\n\n let res = await fetchImpl(url(date), { headers: { 'User-Agent': WIKI_USER_AGENT } });\n // Yesterday's ranking is usually published a few hours into the UTC day;\n // before then the API answers 404, so fall back to the day before.\n if (res.status === 404) {\n const earlier = new Date(date);\n earlier.setUTCDate(earlier.getUTCDate() - 1);\n res = await fetchImpl(url(earlier), { headers: { 'User-Agent': WIKI_USER_AGENT } });\n }\n if (!res.ok) {\n throw new Error(`Failed to fetch Wikipedia top pages: ${res.status}`);\n }\n\n type Entry = { article: string; views: number; rank?: number };\n const data = (await res.json()) as { items?: Array<Entry | { articles?: Entry[] }> };\n\n // The pageviews API nests the ranking one level down\n // (`items[0].articles[]`); accept a flat `items[]` too.\n const raw = (data.items ?? []).flatMap((it) =>\n 'articles' in it && Array.isArray(it.articles) ? it.articles : [it as Entry]\n );\n const items = raw.filter((it) => it?.article && !NON_ARTICLE_TITLE.test(it.article));\n\n return items.slice(0, limit).map((it, i) => ({\n rank: it.rank ?? i + 1,\n article: safeDecode(it.article.replace(/_/g, ' ')),\n views: it.views,\n }));\n}\n\nfunction safeDecode(value: string): string {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n}\n\n/**\n * Search news for a given query using The News API (thenewsapi.com) — its\n * `/v1/news/all` endpoint, which takes the query as `search`.\n *\n * A non-OK answer throws a `NewsApiError` with the API's own message (bad\n * token, quota, plan limits). The list builders below catch it per topic, so\n * one topic the API dislikes doesn't cost the whole list — but when *every*\n * search fails they rethrow it, so a broken key surfaces as an error instead\n * of an empty widget.\n */\nexport async function searchNewsForTopic(\n apiKey: string,\n query: string,\n limit = ARTICLES_PER_TOPIC,\n fetchImpl: typeof fetch = fetch\n): Promise<NewsApiArticle[]> {\n const url = new URL('https://api.thenewsapi.com/v1/news/all');\n url.searchParams.set('api_token', apiKey);\n url.searchParams.set('search', query);\n url.searchParams.set('language', 'en');\n url.searchParams.set('limit', String(limit));\n\n const res = await fetchImpl(url.toString());\n let data: { data?: NewsApiArticle[]; error?: { code?: string; message?: string } } = {};\n try {\n data = (await res.json()) as typeof data;\n } catch {\n // Non-JSON body; fall through with what the status says.\n }\n if (!res.ok || data.error) {\n const code = data.error?.code;\n const message = data.error?.message ?? `HTTP ${res.status}`;\n throw new NewsApiError(\n `The News API: ${message}${code ? ` (${code})` : ''}`,\n res.status,\n code\n );\n }\n return data.data ?? [];\n}\n\n/**\n * Runs `searchNewsForTopic` for each query, keeping per-topic failures from\n * sinking the batch. Returns `null` articles for a failed topic plus the first\n * error seen.\n */\nasync function searchBatch(\n apiKey: string,\n queries: string[],\n fetchImpl: typeof fetch\n): Promise<{ results: Array<NewsApiArticle[] | null>; error?: Error }> {\n let error: Error | undefined;\n const results = await Promise.all(\n queries.map(async (q) => {\n try {\n return await searchNewsForTopic(apiKey, q, ARTICLES_PER_TOPIC, fetchImpl);\n } catch (e) {\n error ??= e instanceof Error ? e : new Error(String(e));\n return null;\n }\n })\n );\n return { results, error };\n}\n\nfunction toArticlePayload(articles: NewsApiArticle[]): TrendingNewsWireArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n published_at: a.published_at,\n image_url: a.image_url,\n }));\n}\n\n/**\n * The daily trending list: Wikipedia's most-viewed articles, each paired with\n * the headlines The News API returns for it. Topics with no headlines are\n * dropped, so we look at up to twice as many Wikipedia entries as the caller\n * asked for topics and stop as soon as the quota is filled.\n *\n * Pageviews lag about a day, so the ranking is read for *yesterday* (UTC)\n * unless a `date` is passed.\n */\nexport async function getTrendingTopics(\n options: TrendingTopicsOptions\n): Promise<TrendingNewsWireResponse> {\n const { apiKey, fetchImpl = fetch } = options;\n const limit = parseTopicLimit(options.limit);\n const date = options.date ?? defaultPageviewsDate();\n\n const candidates = await fetchWikipediaTopPages(\n date,\n Math.min(MAX_TOPIC_LIMIT, limit * 2),\n fetchImpl\n );\n\n const results: TrendingNewsWireTopic[] = [];\n let searches = 0;\n let failures = 0;\n let firstError: Error | undefined;\n for (let i = 0; i < candidates.length && results.length < limit; i += SEARCH_CONCURRENCY) {\n const batch = candidates.slice(i, i + SEARCH_CONCURRENCY);\n const { results: found, error } = await searchBatch(\n apiKey,\n batch.map((entry) => entry.article),\n fetchImpl\n );\n firstError ??= error;\n searches += found.length;\n failures += found.filter((a) => a === null).length;\n\n for (let j = 0; j < batch.length; j++) {\n const entry = batch[j];\n const articles = found[j];\n if (!articles || !articles.length) continue;\n results.push({\n topic: entry.article,\n wiki_rank: entry.rank,\n wiki_views: entry.views,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n });\n }\n }\n\n // Every search failed: that is the API key or quota, not the topics.\n if (searches > 0 && failures === searches && firstError) throw firstError;\n\n results.sort((a, b) => (a.wiki_rank ?? 999) - (b.wiki_rank ?? 999));\n\n return {\n source: 'wikipedia_daily_top',\n date: formatDate(date),\n topics: results.slice(0, limit),\n };\n}\n\n/**\n * The same wire shape as the daily list, but built from topics the caller\n * named instead of Wikipedia's ranking — this is what backs per-user custom\n * topics and an admin-configured default topic list.\n *\n * Ordering is the caller's, not a popularity ranking, so no `wiki_rank` is\n * attached and the result is not re-sorted. Unlike the daily list, a topic\n * with no headlines is *kept* (with `news_count: 0`): the user asked for it by\n * name, and silently dropping it reads as the setting not having been saved.\n * Searches run in the same bounded batches as the daily list.\n */\nexport async function getCustomTopicNews(\n topics: string[],\n options: { apiKey: string; fetchImpl?: typeof fetch }\n): Promise<TrendingNewsWireResponse> {\n const { apiKey, fetchImpl = fetch } = options;\n const wanted = parseTopicList(topics);\n\n const results: TrendingNewsWireTopic[] = [];\n let failures = 0;\n let firstError: Error | undefined;\n for (let i = 0; i < wanted.length; i += SEARCH_CONCURRENCY) {\n const batch = wanted.slice(i, i + SEARCH_CONCURRENCY);\n const { results: found, error } = await searchBatch(apiKey, batch, fetchImpl);\n firstError ??= error;\n failures += found.filter((a) => a === null).length;\n\n for (let j = 0; j < batch.length; j++) {\n const topic = batch[j];\n const articles = found[j] ?? [];\n results.push({\n topic,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n });\n }\n }\n\n if (wanted.length > 0 && failures === wanted.length && firstError) throw firstError;\n\n return {\n source: 'custom_topics',\n date: formatDate(new Date()),\n topics: results,\n };\n}\n\n/** Headlines for a single topic, bypassing the Wikipedia ranking. */\nexport async function getTopicHeadlines(\n topic: string,\n options: { apiKey: string; fetchImpl?: typeof fetch }\n): Promise<TrendingNewsWireTopicResponse> {\n const articles = await searchNewsForTopic(\n options.apiKey,\n topic,\n ARTICLES_FOR_SINGLE_TOPIC,\n options.fetchImpl ?? fetch\n );\n\n return {\n topic,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n };\n}\n\n/**\n * Serves the three routes the client speaks:\n *\n * - `GET /` — the daily trending list (`?limit=` topics, default 25).\n * - `GET /?topic=…` — headlines for one topic.\n * - `GET /?topics=a,b,c` — headlines for a caller-named topic list, in that\n * order, instead of the Wikipedia ranking.\n *\n * Always answers with JSON, including for failures, so a widget that only\n * reads `error` never has to parse an HTML error page.\n */\nexport async function handleTrendingNewsRequest(\n request: Request,\n options: { apiKey: string | undefined; fetchImpl?: typeof fetch }\n): Promise<Response> {\n const { apiKey, fetchImpl } = options;\n if (!apiKey) {\n return jsonResponse({ error: 'THE_NEWS_API_KEY is not configured' }, 500);\n }\n\n const params = new URL(request.url).searchParams;\n const topic = params.get('topic');\n // `topics` is only honoured when it survives parsing — a list of nothing but\n // whitespace must fall through to the daily ranking rather than render an\n // empty widget.\n const customTopics = parseTopicList(params.get('topics'));\n\n try {\n if (topic) {\n return jsonResponse(await getTopicHeadlines(topic, { apiKey, fetchImpl }));\n }\n\n if (customTopics.length > 0) {\n return jsonResponse(await getCustomTopicNews(customTopics, { apiKey, fetchImpl }));\n }\n\n return jsonResponse(\n await getTrendingTopics({\n apiKey,\n limit: parseTopicLimit(params.get('limit')),\n fetchImpl,\n })\n );\n } catch (e) {\n // A News API failure (bad key, quota) is the actionable part — say that,\n // rather than blaming the step that happened to be running.\n if (e instanceof NewsApiError) {\n return jsonResponse({ error: e.message, code: e.code, upstream_status: e.status }, 502);\n }\n return jsonResponse(\n {\n error: topic\n ? 'Failed to fetch news for topic'\n : customTopics.length > 0\n ? 'Failed to fetch news for topics'\n : 'Failed to fetch Wikipedia trends',\n details: e instanceof Error ? e.message : String(e),\n },\n 500\n );\n }\n}\n"],"mappings":"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","names":[]}
@@ -61,7 +61,7 @@ function formatDate(d) {
61
61
  async function fetchWikipediaTopPages(date, limit = DEFAULT_TOPIC_LIMIT, fetchImpl = fetch) {
62
62
  const url = (d) => {
63
63
  const [year, month, day] = formatDate(d).split("-");
64
- return `https://wikimedia.org/api/rest_v1/metrics/pageviews/top-per-article/en.wikipedia.org/all-access/all-agents/${year}/${month}/${day}`;
64
+ return `https://wikimedia.org/api/rest_v1/metrics/pageviews/top/en.wikipedia.org/all-access/${year}/${month}/${day}`;
65
65
  };
66
66
  let res = await fetchImpl(url(date), { headers: { "User-Agent": WIKI_USER_AGENT } });
67
67
  if (res.status === 404) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["/**\n * @fileoverview Server half of the widget: joins Wikipedia's daily pageview\n * ranking to headlines from The News API.\n *\n * Shared by the bundled Cloudflare Worker (`worker/index.ts`) and by host apps\n * that would rather serve the widget from an API route they already deploy\n * than stand up a second worker — both go through `handleTrendingNewsRequest`,\n * so there is exactly one wire format for the client in `src/api/trending.ts`\n * to read.\n *\n * Nothing here touches the DOM or React: it is plain `fetch`/`Request`/\n * `Response`, so it runs on Workers, Node 18+, Bun and Deno alike. The News\n * API key stays on the server.\n */\n\n/** One headline, in the snake_case wire shape the client maps to camelCase. */\nexport interface TrendingNewsWireArticle {\n title: string;\n url?: string;\n source?: string;\n published_at?: string;\n image_url?: string;\n}\n\n/** One trending topic with the headlines matched to it. */\nexport interface TrendingNewsWireTopic {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: TrendingNewsWireArticle[];\n}\n\n/** Body of `GET /` — the daily trending list. */\nexport interface TrendingNewsWireResponse {\n source: string;\n date: string;\n topics: TrendingNewsWireTopic[];\n}\n\n/**\n * The most topics a caller may name in `?topics=`. Each one costs an upstream\n * news search, so an unbounded list is a way to burn the API quota in one\n * request.\n */\nexport const MAX_CUSTOM_TOPICS = 20;\n\n/** Body of `GET /?topic=…` — headlines for one topic. */\nexport interface TrendingNewsWireTopicResponse {\n topic: string;\n news_count: number;\n articles: TrendingNewsWireArticle[];\n}\n\nexport interface TrendingTopicsOptions {\n /** The News API (thenewsapi.com) token. */\n apiKey: string;\n /** How many topics to return (default 25, capped at 50). */\n limit?: number;\n /** Day to rank by pageviews. Defaults to yesterday UTC — see below. */\n date?: Date;\n /** Injected for tests; defaults to the global `fetch`. */\n fetchImpl?: typeof fetch;\n}\n\ntype WikiTopPage = {\n rank: number;\n article: string;\n views: number;\n};\n\ntype NewsApiArticle = {\n title?: string;\n description?: string;\n source?: string;\n url?: string;\n published_at?: string;\n image_url?: string;\n};\n\nexport const DEFAULT_TOPIC_LIMIT = 25;\nexport const MAX_TOPIC_LIMIT = 50;\n\n/** Headlines requested per topic in the daily list, and for a single topic. */\nconst ARTICLES_PER_TOPIC = 20;\nconst ARTICLES_FOR_SINGLE_TOPIC = 30;\n\n/**\n * Topics whose news search runs at once. The daily list needs one News API\n * call per topic; sequentially that is a visible wait on a homepage widget,\n * and unbounded it would burst the API's rate limit.\n */\nconst SEARCH_CONCURRENCY = 5;\n\n/**\n * Wikimedia rejects or rate-limits requests without a descriptive User-Agent\n * (https://meta.wikimedia.org/wiki/User-Agent_policy), so name the project\n * and where to find it.\n */\nconst WIKI_USER_AGENT =\n 'trending-news-api (https://github.com/OpenSourceAGI/qwksearch-research-agent)';\n\n/**\n * A failure talking to The News API — bad or missing token, exhausted quota,\n * plan restriction, rate limit. Carries the API's own message so it can be\n * shown to whoever has to fix it instead of turning into an empty widget.\n */\nexport class NewsApiError extends Error {\n status: number;\n code?: string;\n constructor(message: string, status: number, code?: string) {\n super(message);\n this.name = 'NewsApiError';\n this.status = status;\n this.code = code;\n }\n}\n\nconst CORS_HEADERS = {\n 'Access-Control-Allow-Origin': '*',\n 'Cache-Control': 'no-store',\n};\n\n// Wikipedia's daily \"top viewed\" list is dominated by navigation pages that\n// aren't real trending topics — filter those out rather than surfacing\n// \"Main Page\" as the #1 trend every day.\nconst NON_ARTICLE_TITLE = /^(Main_Page|Special:|Wikipedia:|Portal:|File:|Talk:|Category:)/i;\n\nfunction jsonResponse(payload: unknown, status = 200): Response {\n return new Response(JSON.stringify(payload), {\n status,\n headers: { 'content-type': 'application/json', ...CORS_HEADERS },\n });\n}\n\n/** Clamps a caller-supplied topic count to something we're willing to fetch. */\nexport function parseTopicLimit(\n raw: string | number | null | undefined,\n fallback: number = DEFAULT_TOPIC_LIMIT\n): number {\n const value = typeof raw === 'string' ? Number(raw) : raw;\n if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;\n return Math.min(MAX_TOPIC_LIMIT, Math.floor(value));\n}\n\n/**\n * Parses a caller-supplied custom topic list (`?topics=a,b,c`, or newline\n * separated) into clean, de-duplicated topic names.\n *\n * Topics come from user settings, so they are untrusted: each one is trimmed,\n * length-capped and compared case-insensitively for de-duplication, and the\n * list is capped at `MAX_CUSTOM_TOPICS`.\n */\nexport function parseTopicList(raw: string | string[] | null | undefined): string[] {\n if (!raw) return [];\n const parts = Array.isArray(raw) ? raw : raw.split(/[,\\n]/);\n const seen = new Set<string>();\n const topics: string[] = [];\n for (const part of parts) {\n const topic = String(part).trim().slice(0, 120);\n if (!topic) continue;\n const key = topic.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n topics.push(topic);\n if (topics.length >= MAX_CUSTOM_TOPICS) break;\n }\n return topics;\n}\n\n/** The UTC date the pageviews API has complete data for (see `getTrendingTopics`). */\nfunction defaultPageviewsDate(now: Date = new Date()): Date {\n const d = new Date(now);\n d.setUTCDate(d.getUTCDate() - 1);\n return d;\n}\n\nfunction formatDate(d: Date): string {\n return [\n String(d.getUTCFullYear()).padStart(4, '0'),\n String(d.getUTCMonth() + 1).padStart(2, '0'),\n String(d.getUTCDate()).padStart(2, '0'),\n ].join('-');\n}\n\n/**\n * Fetch daily top Wikipedia articles by pageviews for a given date, via the\n * Wikimedia Analytics API.\n */\nexport async function fetchWikipediaTopPages(\n date: Date,\n limit = DEFAULT_TOPIC_LIMIT,\n fetchImpl: typeof fetch = fetch\n): Promise<WikiTopPage[]> {\n const url = (d: Date) => {\n const [year, month, day] = formatDate(d).split('-');\n return `https://wikimedia.org/api/rest_v1/metrics/pageviews/top-per-article/en.wikipedia.org/all-access/all-agents/${year}/${month}/${day}`;\n };\n\n let res = await fetchImpl(url(date), { headers: { 'User-Agent': WIKI_USER_AGENT } });\n // Yesterday's ranking is usually published a few hours into the UTC day;\n // before then the API answers 404, so fall back to the day before.\n if (res.status === 404) {\n const earlier = new Date(date);\n earlier.setUTCDate(earlier.getUTCDate() - 1);\n res = await fetchImpl(url(earlier), { headers: { 'User-Agent': WIKI_USER_AGENT } });\n }\n if (!res.ok) {\n throw new Error(`Failed to fetch Wikipedia top pages: ${res.status}`);\n }\n\n type Entry = { article: string; views: number; rank?: number };\n const data = (await res.json()) as { items?: Array<Entry | { articles?: Entry[] }> };\n\n // The pageviews API nests the ranking one level down\n // (`items[0].articles[]`); accept a flat `items[]` too.\n const raw = (data.items ?? []).flatMap((it) =>\n 'articles' in it && Array.isArray(it.articles) ? it.articles : [it as Entry]\n );\n const items = raw.filter((it) => it?.article && !NON_ARTICLE_TITLE.test(it.article));\n\n return items.slice(0, limit).map((it, i) => ({\n rank: it.rank ?? i + 1,\n article: safeDecode(it.article.replace(/_/g, ' ')),\n views: it.views,\n }));\n}\n\nfunction safeDecode(value: string): string {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n}\n\n/**\n * Search news for a given query using The News API (thenewsapi.com) — its\n * `/v1/news/all` endpoint, which takes the query as `search`.\n *\n * A non-OK answer throws a `NewsApiError` with the API's own message (bad\n * token, quota, plan limits). The list builders below catch it per topic, so\n * one topic the API dislikes doesn't cost the whole list — but when *every*\n * search fails they rethrow it, so a broken key surfaces as an error instead\n * of an empty widget.\n */\nexport async function searchNewsForTopic(\n apiKey: string,\n query: string,\n limit = ARTICLES_PER_TOPIC,\n fetchImpl: typeof fetch = fetch\n): Promise<NewsApiArticle[]> {\n const url = new URL('https://api.thenewsapi.com/v1/news/all');\n url.searchParams.set('api_token', apiKey);\n url.searchParams.set('search', query);\n url.searchParams.set('language', 'en');\n url.searchParams.set('limit', String(limit));\n\n const res = await fetchImpl(url.toString());\n let data: { data?: NewsApiArticle[]; error?: { code?: string; message?: string } } = {};\n try {\n data = (await res.json()) as typeof data;\n } catch {\n // Non-JSON body; fall through with what the status says.\n }\n if (!res.ok || data.error) {\n const code = data.error?.code;\n const message = data.error?.message ?? `HTTP ${res.status}`;\n throw new NewsApiError(\n `The News API: ${message}${code ? ` (${code})` : ''}`,\n res.status,\n code\n );\n }\n return data.data ?? [];\n}\n\n/**\n * Runs `searchNewsForTopic` for each query, keeping per-topic failures from\n * sinking the batch. Returns `null` articles for a failed topic plus the first\n * error seen.\n */\nasync function searchBatch(\n apiKey: string,\n queries: string[],\n fetchImpl: typeof fetch\n): Promise<{ results: Array<NewsApiArticle[] | null>; error?: Error }> {\n let error: Error | undefined;\n const results = await Promise.all(\n queries.map(async (q) => {\n try {\n return await searchNewsForTopic(apiKey, q, ARTICLES_PER_TOPIC, fetchImpl);\n } catch (e) {\n error ??= e instanceof Error ? e : new Error(String(e));\n return null;\n }\n })\n );\n return { results, error };\n}\n\nfunction toArticlePayload(articles: NewsApiArticle[]): TrendingNewsWireArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n published_at: a.published_at,\n image_url: a.image_url,\n }));\n}\n\n/**\n * The daily trending list: Wikipedia's most-viewed articles, each paired with\n * the headlines The News API returns for it. Topics with no headlines are\n * dropped, so we look at up to twice as many Wikipedia entries as the caller\n * asked for topics and stop as soon as the quota is filled.\n *\n * Pageviews lag about a day, so the ranking is read for *yesterday* (UTC)\n * unless a `date` is passed.\n */\nexport async function getTrendingTopics(\n options: TrendingTopicsOptions\n): Promise<TrendingNewsWireResponse> {\n const { apiKey, fetchImpl = fetch } = options;\n const limit = parseTopicLimit(options.limit);\n const date = options.date ?? defaultPageviewsDate();\n\n const candidates = await fetchWikipediaTopPages(\n date,\n Math.min(MAX_TOPIC_LIMIT, limit * 2),\n fetchImpl\n );\n\n const results: TrendingNewsWireTopic[] = [];\n let searches = 0;\n let failures = 0;\n let firstError: Error | undefined;\n for (let i = 0; i < candidates.length && results.length < limit; i += SEARCH_CONCURRENCY) {\n const batch = candidates.slice(i, i + SEARCH_CONCURRENCY);\n const { results: found, error } = await searchBatch(\n apiKey,\n batch.map((entry) => entry.article),\n fetchImpl\n );\n firstError ??= error;\n searches += found.length;\n failures += found.filter((a) => a === null).length;\n\n for (let j = 0; j < batch.length; j++) {\n const entry = batch[j];\n const articles = found[j];\n if (!articles || !articles.length) continue;\n results.push({\n topic: entry.article,\n wiki_rank: entry.rank,\n wiki_views: entry.views,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n });\n }\n }\n\n // Every search failed: that is the API key or quota, not the topics.\n if (searches > 0 && failures === searches && firstError) throw firstError;\n\n results.sort((a, b) => (a.wiki_rank ?? 999) - (b.wiki_rank ?? 999));\n\n return {\n source: 'wikipedia_daily_top',\n date: formatDate(date),\n topics: results.slice(0, limit),\n };\n}\n\n/**\n * The same wire shape as the daily list, but built from topics the caller\n * named instead of Wikipedia's ranking — this is what backs per-user custom\n * topics and an admin-configured default topic list.\n *\n * Ordering is the caller's, not a popularity ranking, so no `wiki_rank` is\n * attached and the result is not re-sorted. Unlike the daily list, a topic\n * with no headlines is *kept* (with `news_count: 0`): the user asked for it by\n * name, and silently dropping it reads as the setting not having been saved.\n * Searches run in the same bounded batches as the daily list.\n */\nexport async function getCustomTopicNews(\n topics: string[],\n options: { apiKey: string; fetchImpl?: typeof fetch }\n): Promise<TrendingNewsWireResponse> {\n const { apiKey, fetchImpl = fetch } = options;\n const wanted = parseTopicList(topics);\n\n const results: TrendingNewsWireTopic[] = [];\n let failures = 0;\n let firstError: Error | undefined;\n for (let i = 0; i < wanted.length; i += SEARCH_CONCURRENCY) {\n const batch = wanted.slice(i, i + SEARCH_CONCURRENCY);\n const { results: found, error } = await searchBatch(apiKey, batch, fetchImpl);\n firstError ??= error;\n failures += found.filter((a) => a === null).length;\n\n for (let j = 0; j < batch.length; j++) {\n const topic = batch[j];\n const articles = found[j] ?? [];\n results.push({\n topic,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n });\n }\n }\n\n if (wanted.length > 0 && failures === wanted.length && firstError) throw firstError;\n\n return {\n source: 'custom_topics',\n date: formatDate(new Date()),\n topics: results,\n };\n}\n\n/** Headlines for a single topic, bypassing the Wikipedia ranking. */\nexport async function getTopicHeadlines(\n topic: string,\n options: { apiKey: string; fetchImpl?: typeof fetch }\n): Promise<TrendingNewsWireTopicResponse> {\n const articles = await searchNewsForTopic(\n options.apiKey,\n topic,\n ARTICLES_FOR_SINGLE_TOPIC,\n options.fetchImpl ?? fetch\n );\n\n return {\n topic,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n };\n}\n\n/**\n * Serves the three routes the client speaks:\n *\n * - `GET /` — the daily trending list (`?limit=` topics, default 25).\n * - `GET /?topic=…` — headlines for one topic.\n * - `GET /?topics=a,b,c` — headlines for a caller-named topic list, in that\n * order, instead of the Wikipedia ranking.\n *\n * Always answers with JSON, including for failures, so a widget that only\n * reads `error` never has to parse an HTML error page.\n */\nexport async function handleTrendingNewsRequest(\n request: Request,\n options: { apiKey: string | undefined; fetchImpl?: typeof fetch }\n): Promise<Response> {\n const { apiKey, fetchImpl } = options;\n if (!apiKey) {\n return jsonResponse({ error: 'THE_NEWS_API_KEY is not configured' }, 500);\n }\n\n const params = new URL(request.url).searchParams;\n const topic = params.get('topic');\n // `topics` is only honoured when it survives parsing — a list of nothing but\n // whitespace must fall through to the daily ranking rather than render an\n // empty widget.\n const customTopics = parseTopicList(params.get('topics'));\n\n try {\n if (topic) {\n return jsonResponse(await getTopicHeadlines(topic, { apiKey, fetchImpl }));\n }\n\n if (customTopics.length > 0) {\n return jsonResponse(await getCustomTopicNews(customTopics, { apiKey, fetchImpl }));\n }\n\n return jsonResponse(\n await getTrendingTopics({\n apiKey,\n limit: parseTopicLimit(params.get('limit')),\n fetchImpl,\n })\n );\n } catch (e) {\n // A News API failure (bad key, quota) is the actionable part — say that,\n // rather than blaming the step that happened to be running.\n if (e instanceof NewsApiError) {\n return jsonResponse({ error: e.message, code: e.code, upstream_status: e.status }, 502);\n }\n return jsonResponse(\n {\n error: topic\n ? 'Failed to fetch news for topic'\n : customTopics.length > 0\n ? 'Failed to fetch news for topics'\n : 'Failed to fetch Wikipedia trends',\n details: e instanceof Error ? e.message : String(e),\n },\n 500\n );\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["/**\n * @fileoverview Server half of the widget: joins Wikipedia's daily pageview\n * ranking to headlines from The News API.\n *\n * Shared by the bundled Cloudflare Worker (`worker/index.ts`) and by host apps\n * that would rather serve the widget from an API route they already deploy\n * than stand up a second worker — both go through `handleTrendingNewsRequest`,\n * so there is exactly one wire format for the client in `src/api/trending.ts`\n * to read.\n *\n * Nothing here touches the DOM or React: it is plain `fetch`/`Request`/\n * `Response`, so it runs on Workers, Node 18+, Bun and Deno alike. The News\n * API key stays on the server.\n */\n\n/** One headline, in the snake_case wire shape the client maps to camelCase. */\nexport interface TrendingNewsWireArticle {\n title: string;\n url?: string;\n source?: string;\n published_at?: string;\n image_url?: string;\n}\n\n/** One trending topic with the headlines matched to it. */\nexport interface TrendingNewsWireTopic {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: TrendingNewsWireArticle[];\n}\n\n/** Body of `GET /` — the daily trending list. */\nexport interface TrendingNewsWireResponse {\n source: string;\n date: string;\n topics: TrendingNewsWireTopic[];\n}\n\n/**\n * The most topics a caller may name in `?topics=`. Each one costs an upstream\n * news search, so an unbounded list is a way to burn the API quota in one\n * request.\n */\nexport const MAX_CUSTOM_TOPICS = 20;\n\n/** Body of `GET /?topic=…` — headlines for one topic. */\nexport interface TrendingNewsWireTopicResponse {\n topic: string;\n news_count: number;\n articles: TrendingNewsWireArticle[];\n}\n\nexport interface TrendingTopicsOptions {\n /** The News API (thenewsapi.com) token. */\n apiKey: string;\n /** How many topics to return (default 25, capped at 50). */\n limit?: number;\n /** Day to rank by pageviews. Defaults to yesterday UTC — see below. */\n date?: Date;\n /** Injected for tests; defaults to the global `fetch`. */\n fetchImpl?: typeof fetch;\n}\n\ntype WikiTopPage = {\n rank: number;\n article: string;\n views: number;\n};\n\ntype NewsApiArticle = {\n title?: string;\n description?: string;\n source?: string;\n url?: string;\n published_at?: string;\n image_url?: string;\n};\n\nexport const DEFAULT_TOPIC_LIMIT = 25;\nexport const MAX_TOPIC_LIMIT = 50;\n\n/** Headlines requested per topic in the daily list, and for a single topic. */\nconst ARTICLES_PER_TOPIC = 20;\nconst ARTICLES_FOR_SINGLE_TOPIC = 30;\n\n/**\n * Topics whose news search runs at once. The daily list needs one News API\n * call per topic; sequentially that is a visible wait on a homepage widget,\n * and unbounded it would burst the API's rate limit.\n */\nconst SEARCH_CONCURRENCY = 5;\n\n/**\n * Wikimedia rejects or rate-limits requests without a descriptive User-Agent\n * (https://meta.wikimedia.org/wiki/User-Agent_policy), so name the project\n * and where to find it.\n */\nconst WIKI_USER_AGENT =\n 'trending-news-api (https://github.com/OpenSourceAGI/qwksearch-research-agent)';\n\n/**\n * A failure talking to The News API — bad or missing token, exhausted quota,\n * plan restriction, rate limit. Carries the API's own message so it can be\n * shown to whoever has to fix it instead of turning into an empty widget.\n */\nexport class NewsApiError extends Error {\n status: number;\n code?: string;\n constructor(message: string, status: number, code?: string) {\n super(message);\n this.name = 'NewsApiError';\n this.status = status;\n this.code = code;\n }\n}\n\nconst CORS_HEADERS = {\n 'Access-Control-Allow-Origin': '*',\n 'Cache-Control': 'no-store',\n};\n\n// Wikipedia's daily \"top viewed\" list is dominated by navigation pages that\n// aren't real trending topics — filter those out rather than surfacing\n// \"Main Page\" as the #1 trend every day.\nconst NON_ARTICLE_TITLE = /^(Main_Page|Special:|Wikipedia:|Portal:|File:|Talk:|Category:)/i;\n\nfunction jsonResponse(payload: unknown, status = 200): Response {\n return new Response(JSON.stringify(payload), {\n status,\n headers: { 'content-type': 'application/json', ...CORS_HEADERS },\n });\n}\n\n/** Clamps a caller-supplied topic count to something we're willing to fetch. */\nexport function parseTopicLimit(\n raw: string | number | null | undefined,\n fallback: number = DEFAULT_TOPIC_LIMIT\n): number {\n const value = typeof raw === 'string' ? Number(raw) : raw;\n if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;\n return Math.min(MAX_TOPIC_LIMIT, Math.floor(value));\n}\n\n/**\n * Parses a caller-supplied custom topic list (`?topics=a,b,c`, or newline\n * separated) into clean, de-duplicated topic names.\n *\n * Topics come from user settings, so they are untrusted: each one is trimmed,\n * length-capped and compared case-insensitively for de-duplication, and the\n * list is capped at `MAX_CUSTOM_TOPICS`.\n */\nexport function parseTopicList(raw: string | string[] | null | undefined): string[] {\n if (!raw) return [];\n const parts = Array.isArray(raw) ? raw : raw.split(/[,\\n]/);\n const seen = new Set<string>();\n const topics: string[] = [];\n for (const part of parts) {\n const topic = String(part).trim().slice(0, 120);\n if (!topic) continue;\n const key = topic.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n topics.push(topic);\n if (topics.length >= MAX_CUSTOM_TOPICS) break;\n }\n return topics;\n}\n\n/** The UTC date the pageviews API has complete data for (see `getTrendingTopics`). */\nfunction defaultPageviewsDate(now: Date = new Date()): Date {\n const d = new Date(now);\n d.setUTCDate(d.getUTCDate() - 1);\n return d;\n}\n\nfunction formatDate(d: Date): string {\n return [\n String(d.getUTCFullYear()).padStart(4, '0'),\n String(d.getUTCMonth() + 1).padStart(2, '0'),\n String(d.getUTCDate()).padStart(2, '0'),\n ].join('-');\n}\n\n/**\n * Fetch daily top Wikipedia articles by pageviews for a given date, via the\n * Wikimedia Analytics API.\n */\nexport async function fetchWikipediaTopPages(\n date: Date,\n limit = DEFAULT_TOPIC_LIMIT,\n fetchImpl: typeof fetch = fetch\n): Promise<WikiTopPage[]> {\n const url = (d: Date) => {\n const [year, month, day] = formatDate(d).split('-');\n return `https://wikimedia.org/api/rest_v1/metrics/pageviews/top/en.wikipedia.org/all-access/${year}/${month}/${day}`;\n };\n\n let res = await fetchImpl(url(date), { headers: { 'User-Agent': WIKI_USER_AGENT } });\n // Yesterday's ranking is usually published a few hours into the UTC day;\n // before then the API answers 404, so fall back to the day before.\n if (res.status === 404) {\n const earlier = new Date(date);\n earlier.setUTCDate(earlier.getUTCDate() - 1);\n res = await fetchImpl(url(earlier), { headers: { 'User-Agent': WIKI_USER_AGENT } });\n }\n if (!res.ok) {\n throw new Error(`Failed to fetch Wikipedia top pages: ${res.status}`);\n }\n\n type Entry = { article: string; views: number; rank?: number };\n const data = (await res.json()) as { items?: Array<Entry | { articles?: Entry[] }> };\n\n // The pageviews API nests the ranking one level down\n // (`items[0].articles[]`); accept a flat `items[]` too.\n const raw = (data.items ?? []).flatMap((it) =>\n 'articles' in it && Array.isArray(it.articles) ? it.articles : [it as Entry]\n );\n const items = raw.filter((it) => it?.article && !NON_ARTICLE_TITLE.test(it.article));\n\n return items.slice(0, limit).map((it, i) => ({\n rank: it.rank ?? i + 1,\n article: safeDecode(it.article.replace(/_/g, ' ')),\n views: it.views,\n }));\n}\n\nfunction safeDecode(value: string): string {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n}\n\n/**\n * Search news for a given query using The News API (thenewsapi.com) — its\n * `/v1/news/all` endpoint, which takes the query as `search`.\n *\n * A non-OK answer throws a `NewsApiError` with the API's own message (bad\n * token, quota, plan limits). The list builders below catch it per topic, so\n * one topic the API dislikes doesn't cost the whole list — but when *every*\n * search fails they rethrow it, so a broken key surfaces as an error instead\n * of an empty widget.\n */\nexport async function searchNewsForTopic(\n apiKey: string,\n query: string,\n limit = ARTICLES_PER_TOPIC,\n fetchImpl: typeof fetch = fetch\n): Promise<NewsApiArticle[]> {\n const url = new URL('https://api.thenewsapi.com/v1/news/all');\n url.searchParams.set('api_token', apiKey);\n url.searchParams.set('search', query);\n url.searchParams.set('language', 'en');\n url.searchParams.set('limit', String(limit));\n\n const res = await fetchImpl(url.toString());\n let data: { data?: NewsApiArticle[]; error?: { code?: string; message?: string } } = {};\n try {\n data = (await res.json()) as typeof data;\n } catch {\n // Non-JSON body; fall through with what the status says.\n }\n if (!res.ok || data.error) {\n const code = data.error?.code;\n const message = data.error?.message ?? `HTTP ${res.status}`;\n throw new NewsApiError(\n `The News API: ${message}${code ? ` (${code})` : ''}`,\n res.status,\n code\n );\n }\n return data.data ?? [];\n}\n\n/**\n * Runs `searchNewsForTopic` for each query, keeping per-topic failures from\n * sinking the batch. Returns `null` articles for a failed topic plus the first\n * error seen.\n */\nasync function searchBatch(\n apiKey: string,\n queries: string[],\n fetchImpl: typeof fetch\n): Promise<{ results: Array<NewsApiArticle[] | null>; error?: Error }> {\n let error: Error | undefined;\n const results = await Promise.all(\n queries.map(async (q) => {\n try {\n return await searchNewsForTopic(apiKey, q, ARTICLES_PER_TOPIC, fetchImpl);\n } catch (e) {\n error ??= e instanceof Error ? e : new Error(String(e));\n return null;\n }\n })\n );\n return { results, error };\n}\n\nfunction toArticlePayload(articles: NewsApiArticle[]): TrendingNewsWireArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n published_at: a.published_at,\n image_url: a.image_url,\n }));\n}\n\n/**\n * The daily trending list: Wikipedia's most-viewed articles, each paired with\n * the headlines The News API returns for it. Topics with no headlines are\n * dropped, so we look at up to twice as many Wikipedia entries as the caller\n * asked for topics and stop as soon as the quota is filled.\n *\n * Pageviews lag about a day, so the ranking is read for *yesterday* (UTC)\n * unless a `date` is passed.\n */\nexport async function getTrendingTopics(\n options: TrendingTopicsOptions\n): Promise<TrendingNewsWireResponse> {\n const { apiKey, fetchImpl = fetch } = options;\n const limit = parseTopicLimit(options.limit);\n const date = options.date ?? defaultPageviewsDate();\n\n const candidates = await fetchWikipediaTopPages(\n date,\n Math.min(MAX_TOPIC_LIMIT, limit * 2),\n fetchImpl\n );\n\n const results: TrendingNewsWireTopic[] = [];\n let searches = 0;\n let failures = 0;\n let firstError: Error | undefined;\n for (let i = 0; i < candidates.length && results.length < limit; i += SEARCH_CONCURRENCY) {\n const batch = candidates.slice(i, i + SEARCH_CONCURRENCY);\n const { results: found, error } = await searchBatch(\n apiKey,\n batch.map((entry) => entry.article),\n fetchImpl\n );\n firstError ??= error;\n searches += found.length;\n failures += found.filter((a) => a === null).length;\n\n for (let j = 0; j < batch.length; j++) {\n const entry = batch[j];\n const articles = found[j];\n if (!articles || !articles.length) continue;\n results.push({\n topic: entry.article,\n wiki_rank: entry.rank,\n wiki_views: entry.views,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n });\n }\n }\n\n // Every search failed: that is the API key or quota, not the topics.\n if (searches > 0 && failures === searches && firstError) throw firstError;\n\n results.sort((a, b) => (a.wiki_rank ?? 999) - (b.wiki_rank ?? 999));\n\n return {\n source: 'wikipedia_daily_top',\n date: formatDate(date),\n topics: results.slice(0, limit),\n };\n}\n\n/**\n * The same wire shape as the daily list, but built from topics the caller\n * named instead of Wikipedia's ranking — this is what backs per-user custom\n * topics and an admin-configured default topic list.\n *\n * Ordering is the caller's, not a popularity ranking, so no `wiki_rank` is\n * attached and the result is not re-sorted. Unlike the daily list, a topic\n * with no headlines is *kept* (with `news_count: 0`): the user asked for it by\n * name, and silently dropping it reads as the setting not having been saved.\n * Searches run in the same bounded batches as the daily list.\n */\nexport async function getCustomTopicNews(\n topics: string[],\n options: { apiKey: string; fetchImpl?: typeof fetch }\n): Promise<TrendingNewsWireResponse> {\n const { apiKey, fetchImpl = fetch } = options;\n const wanted = parseTopicList(topics);\n\n const results: TrendingNewsWireTopic[] = [];\n let failures = 0;\n let firstError: Error | undefined;\n for (let i = 0; i < wanted.length; i += SEARCH_CONCURRENCY) {\n const batch = wanted.slice(i, i + SEARCH_CONCURRENCY);\n const { results: found, error } = await searchBatch(apiKey, batch, fetchImpl);\n firstError ??= error;\n failures += found.filter((a) => a === null).length;\n\n for (let j = 0; j < batch.length; j++) {\n const topic = batch[j];\n const articles = found[j] ?? [];\n results.push({\n topic,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n });\n }\n }\n\n if (wanted.length > 0 && failures === wanted.length && firstError) throw firstError;\n\n return {\n source: 'custom_topics',\n date: formatDate(new Date()),\n topics: results,\n };\n}\n\n/** Headlines for a single topic, bypassing the Wikipedia ranking. */\nexport async function getTopicHeadlines(\n topic: string,\n options: { apiKey: string; fetchImpl?: typeof fetch }\n): Promise<TrendingNewsWireTopicResponse> {\n const articles = await searchNewsForTopic(\n options.apiKey,\n topic,\n ARTICLES_FOR_SINGLE_TOPIC,\n options.fetchImpl ?? fetch\n );\n\n return {\n topic,\n news_count: articles.length,\n articles: toArticlePayload(articles),\n };\n}\n\n/**\n * Serves the three routes the client speaks:\n *\n * - `GET /` — the daily trending list (`?limit=` topics, default 25).\n * - `GET /?topic=…` — headlines for one topic.\n * - `GET /?topics=a,b,c` — headlines for a caller-named topic list, in that\n * order, instead of the Wikipedia ranking.\n *\n * Always answers with JSON, including for failures, so a widget that only\n * reads `error` never has to parse an HTML error page.\n */\nexport async function handleTrendingNewsRequest(\n request: Request,\n options: { apiKey: string | undefined; fetchImpl?: typeof fetch }\n): Promise<Response> {\n const { apiKey, fetchImpl } = options;\n if (!apiKey) {\n return jsonResponse({ error: 'THE_NEWS_API_KEY is not configured' }, 500);\n }\n\n const params = new URL(request.url).searchParams;\n const topic = params.get('topic');\n // `topics` is only honoured when it survives parsing — a list of nothing but\n // whitespace must fall through to the daily ranking rather than render an\n // empty widget.\n const customTopics = parseTopicList(params.get('topics'));\n\n try {\n if (topic) {\n return jsonResponse(await getTopicHeadlines(topic, { apiKey, fetchImpl }));\n }\n\n if (customTopics.length > 0) {\n return jsonResponse(await getCustomTopicNews(customTopics, { apiKey, fetchImpl }));\n }\n\n return jsonResponse(\n await getTrendingTopics({\n apiKey,\n limit: parseTopicLimit(params.get('limit')),\n fetchImpl,\n })\n );\n } catch (e) {\n // A News API failure (bad key, quota) is the actionable part — say that,\n // rather than blaming the step that happened to be running.\n if (e instanceof NewsApiError) {\n return jsonResponse({ error: e.message, code: e.code, upstream_status: e.status }, 502);\n }\n return jsonResponse(\n {\n error: topic\n ? 'Failed to fetch news for topic'\n : customTopics.length > 0\n ? 'Failed to fetch news for topics'\n : 'Failed to fetch Wikipedia trends',\n details: e instanceof Error ? e.message : String(e),\n },\n 500\n );\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trending-news-api",
3
- "version": "0.1.148",
3
+ "version": "0.1.150",
4
4
  "description": "React trending news widget backed by Wikipedia pageviews and The News API, via a Cloudflare Worker proxy",
5
5
  "keywords": [
6
6
  "react",