trending-news-api 0.1.39 → 0.1.41
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.
- package/README.md +31 -3
- package/dist/index.cjs +10 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/server/index.cjs +189 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.ts +109 -0
- package/dist/server/index.js +157 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Cloudflare Worker so the News API key never reaches the browser.
|
|
|
14
14
|
- Single-topic headline lookup.
|
|
15
15
|
- Compact card row or full article-list layouts.
|
|
16
16
|
- `localStorage` response caching (10 minutes).
|
|
17
|
+
- Runs as a standalone Cloudflare Worker, or from a route your own app already serves.
|
|
17
18
|
- TypeScript + tsup library scaffold.
|
|
18
19
|
|
|
19
20
|
## Install
|
|
@@ -44,6 +45,9 @@ Pass `topic` to render headlines for a single topic instead of the daily trendin
|
|
|
44
45
|
<TrendingNews apiEndpoint="https://trending-news-api.your-subdomain.workers.dev" topic="Donald Trump" />
|
|
45
46
|
```
|
|
46
47
|
|
|
48
|
+
`apiEndpoint` may be a full URL or a path on the current origin (`/api/news/trending`), so a host
|
|
49
|
+
app that serves the data itself doesn't have to hardcode its own domain.
|
|
50
|
+
|
|
47
51
|
The widget renders nothing when `apiEndpoint` is unset, still loading, or errored — safe to drop
|
|
48
52
|
into a layout unconditionally.
|
|
49
53
|
|
|
@@ -76,9 +80,13 @@ npm run build
|
|
|
76
80
|
non-article pages (`Main_Page`, `Special:`, `Wikipedia:`, etc).
|
|
77
81
|
- For each page, searches The News API for matching headlines.
|
|
78
82
|
- Exposes:
|
|
79
|
-
- `GET /` — trending topics with headline counts and articles
|
|
83
|
+
- `GET /` — trending topics with headline counts and articles (`?limit=` topics, default 25,
|
|
84
|
+
capped at 50).
|
|
80
85
|
- `GET /?topic=...` — headlines for a specific topic.
|
|
81
86
|
|
|
87
|
+
Topics with no matching headlines are dropped, so it looks at up to twice as many Wikipedia
|
|
88
|
+
entries as topics requested and stops once the quota is filled. Searches run five at a time.
|
|
89
|
+
|
|
82
90
|
### Deploying the worker
|
|
83
91
|
|
|
84
92
|
```bash
|
|
@@ -89,11 +97,31 @@ npx wrangler secret put THENEWSAPI_API_KEY --config worker/wrangler.jsonc
|
|
|
89
97
|
|
|
90
98
|
Use the resulting `*.workers.dev` URL (or a custom route) as `apiEndpoint`.
|
|
91
99
|
|
|
100
|
+
### Serving it from your own app instead
|
|
101
|
+
|
|
102
|
+
`trending-news-api/server` is the same request handler the worker runs, with no React and no DOM
|
|
103
|
+
in it, so an app that already has a backend can serve the widget from one of its own routes and
|
|
104
|
+
skip the second deployment. QwkSearch does this at `/api/news/trending` — see
|
|
105
|
+
`apps/qwksearch-web/lib/news/trending.ts`.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// e.g. app/api/news/trending/route.ts
|
|
109
|
+
import { handleTrendingNewsRequest } from 'trending-news-api/server';
|
|
110
|
+
|
|
111
|
+
export const GET = (request: Request) =>
|
|
112
|
+
handleTrendingNewsRequest(request, { apiKey: process.env.THENEWSAPI_API_KEY });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The individual steps are exported too, for a route that wants to cache or reshape the data:
|
|
116
|
+
`getTrendingTopics({ apiKey, limit, date })`, `getTopicHeadlines(topic, { apiKey })`,
|
|
117
|
+
`fetchWikipediaTopPages(date, limit)` and `searchNewsForTopic(apiKey, query, limit)`. Each takes an
|
|
118
|
+
optional `fetchImpl` so it can be tested without network access.
|
|
119
|
+
|
|
92
120
|
## Caching
|
|
93
121
|
|
|
94
122
|
`getTrendingNews` / `getTrendingNewsForTopic` cache each response in `localStorage` for 10
|
|
95
|
-
minutes, keyed by the exact request URL
|
|
96
|
-
(e.g. in tests). The cache is a no-op in non-browser environments (SSR) or when `localStorage`
|
|
123
|
+
minutes, keyed by the exact request URL (which includes `limit`). Call
|
|
124
|
+
`clearTrendingNewsCache()` to evict everything (e.g. in tests). The cache is a no-op in non-browser environments (SSR) or when `localStorage`
|
|
97
125
|
is unavailable/full.
|
|
98
126
|
|
|
99
127
|
## Notes
|
package/dist/index.cjs
CHANGED
|
@@ -79,9 +79,18 @@ function mapArticles(articles = []) {
|
|
|
79
79
|
imageUrl: a.image_url
|
|
80
80
|
}));
|
|
81
81
|
}
|
|
82
|
+
function resolveEndpoint(apiEndpoint) {
|
|
83
|
+
const base = typeof window !== "undefined" ? window.location.href : void 0;
|
|
84
|
+
try {
|
|
85
|
+
return new URL(apiEndpoint, base);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(`trending-news-api: apiEndpoint "${apiEndpoint}" is not a valid URL`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
82
90
|
function buildUrl(apiEndpoint, options) {
|
|
83
|
-
const url =
|
|
91
|
+
const url = resolveEndpoint(apiEndpoint);
|
|
84
92
|
if (options.topic) url.searchParams.set("topic", options.topic);
|
|
93
|
+
if (options.limit && !options.topic) url.searchParams.set("limit", String(options.limit));
|
|
85
94
|
return url.toString();
|
|
86
95
|
}
|
|
87
96
|
async function getTrendingNews(options) {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/lib/cache.ts","../src/api/trending.ts","../src/hooks/useTrendingNews.ts","../src/components/TrendingNews.tsx"],"sourcesContent":["export * from './types';\nexport { clearTrendingNewsCache } from './lib/cache';\nexport * from './api/trending';\nexport * from './hooks/useTrendingNews';\nexport * from './components/TrendingNews';\n","const CACHE_PREFIX = 'trending-news-cache:';\nconst CACHE_TTL_MS = 10 * 60 * 1000;\n\ntype CacheEntry<T> = {\n timestamp: number;\n data: T;\n};\n\nfunction getStorage(): Storage | null {\n if (typeof window === 'undefined' || !window.localStorage) return null;\n return window.localStorage;\n}\n\n/** Returns the cached value for `key` if it was written within the last 10 minutes. */\nexport function readCachedTrendingNews<T>(key: string): T | null {\n const storage = getStorage();\n if (!storage) return null;\n\n try {\n const raw = storage.getItem(CACHE_PREFIX + key);\n if (!raw) return null;\n\n const entry = JSON.parse(raw) as CacheEntry<T>;\n if (Date.now() - entry.timestamp > CACHE_TTL_MS) {\n storage.removeItem(CACHE_PREFIX + key);\n return null;\n }\n\n return entry.data;\n } catch {\n return null;\n }\n}\n\nexport function writeCachedTrendingNews<T>(key: string, data: T): void {\n const storage = getStorage();\n if (!storage) return;\n\n try {\n const entry: CacheEntry<T> = { timestamp: Date.now(), data };\n storage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));\n } catch {\n // Storage full or unavailable (e.g. private browsing) — safe to ignore,\n // the next call will simply hit the network again.\n }\n}\n\n/** Clears all cached trending news responses. Mainly useful for tests/debugging. */\nexport function clearTrendingNewsCache(): void {\n const storage = getStorage();\n if (!storage) return;\n\n for (let i = storage.length - 1; i >= 0; i--) {\n const key = storage.key(i);\n if (key?.startsWith(CACHE_PREFIX)) storage.removeItem(key);\n }\n}\n","import type { NewsArticle, TrendingNewsData, TrendingNewsOptions, TrendingNewsTopicData } from '../types';\nimport { readCachedTrendingNews, writeCachedTrendingNews } from '../lib/cache';\n\ntype WorkerArticle = {\n title?: string;\n url?: string;\n source?: string;\n published_at?: string;\n image_url?: string;\n};\n\ntype WorkerTopic = {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: WorkerArticle[];\n};\n\ntype WorkerTopicsResponse = {\n date?: string;\n topics: WorkerTopic[];\n error?: string;\n};\n\ntype WorkerTopicResponse = {\n topic: string;\n news_count: number;\n articles: WorkerArticle[];\n error?: string;\n};\n\nfunction mapArticles(articles: WorkerArticle[] = []): NewsArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n publishedAt: a.published_at,\n imageUrl: a.image_url,\n }));\n}\n\nfunction buildUrl(apiEndpoint: string, options: TrendingNewsOptions) {\n const url = new URL(apiEndpoint);\n if (options.topic) url.searchParams.set('topic', options.topic);\n return url.toString();\n}\n\n/**\n * Fetches trending topics (or, when `options.topic` is set, news for a\n * single topic) from a deployed instance of `worker/index.ts`.\n */\nexport async function getTrendingNews(options: TrendingNewsOptions): Promise<TrendingNewsData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, options);\n\n const cached = readCachedTrendingNews<TrendingNewsData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicsResponse;\n if (data.error) throw new Error(data.error);\n\n const limit = options.limit ?? 25;\n const result: TrendingNewsData = {\n date: data.date,\n topics: (data.topics ?? []).slice(0, limit).map((t) => ({\n topic: t.topic,\n wikiRank: t.wiki_rank,\n wikiViews: t.wiki_views,\n newsCount: t.news_count,\n articles: mapArticles(t.articles),\n })),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n\n/** Fetches news articles for a single topic. */\nexport async function getTrendingNewsForTopic(\n topic: string,\n options: Omit<TrendingNewsOptions, 'topic'>\n): Promise<TrendingNewsTopicData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, { ...options, topic });\n\n const cached = readCachedTrendingNews<TrendingNewsTopicData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicResponse;\n if (data.error) throw new Error(data.error);\n\n const result: TrendingNewsTopicData = {\n topic: data.topic,\n newsCount: data.news_count,\n articles: mapArticles(data.articles),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n","import { useEffect, useState } from 'react';\nimport type { TrendingNewsData, TrendingNewsOptions } from '../types';\nimport { getTrendingNews } from '../api/trending';\n\nexport function useTrendingNews(options: TrendingNewsOptions = {}) {\n const [data, setData] = useState<TrendingNewsData | null>(null);\n const [error, setError] = useState<Error | null>(null);\n const [loading, setLoading] = useState(Boolean(options.apiEndpoint));\n\n useEffect(() => {\n if (!options.apiEndpoint) {\n setLoading(false);\n return;\n }\n\n let active = true;\n setLoading(true);\n setError(null);\n\n getTrendingNews(options)\n .then((result) => { if (active) setData(result); })\n .catch((err) => { if (active) setError(err instanceof Error ? err : new Error('Unknown error')); })\n .finally(() => { if (active) setLoading(false); });\n\n return () => { active = false; };\n }, [options.apiEndpoint, options.topic, options.limit]);\n\n return { data, error, loading };\n}\n","import React, { useState } from 'react';\nimport { useTrendingNews } from '../hooks/useTrendingNews';\nimport type { TrendingNewsOptions, TrendingTopic } from '../types';\n\ntype Props = TrendingNewsOptions & {\n className?: string;\n style?: React.CSSProperties;\n compact?: boolean;\n /** Max trending topics to render (default 8). */\n maxTopics?: number;\n /**\n * When set on a `compact` card, a chevron toggles between the compact\n * topic row and a full topic-by-topic article list, without leaving the\n * card's layout. Ignored outside `compact` mode, which already shows the\n * full list.\n */\n expandable?: boolean;\n /** Max topics to render once expanded (default 15). */\n expandedMaxTopics?: number;\n /** Show each topic's lead article thumbnail image, when available (default true). */\n showImages?: boolean;\n};\n\nfunction ChevronIcon({ up }: { up?: boolean }) {\n return (\n <svg\n width={14}\n height={14}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n style={{ transform: up ? 'rotate(180deg)' : undefined, transition: 'transform 150ms ease' }}\n >\n <polyline points=\"6 9 12 15 18 9\" />\n </svg>\n );\n}\n\nconst styles = {\n root: {\n fontFamily: 'system-ui, sans-serif',\n border: '1px solid #e5e7eb',\n borderRadius: 12,\n padding: 16,\n background: '#fff',\n color: '#111827',\n maxWidth: 640,\n } as React.CSSProperties,\n compactRoot: {\n fontFamily: 'system-ui, sans-serif',\n display: 'flex',\n flexDirection: 'column',\n gap: 6,\n padding: '10px 14px',\n borderRadius: 8,\n } as React.CSSProperties,\n compactHeaderRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } as React.CSSProperties,\n compactHeader: {\n fontSize: 11,\n fontWeight: 600,\n opacity: 0.75,\n letterSpacing: 0.3,\n textTransform: 'uppercase',\n } as React.CSSProperties,\n expandToggle: {\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n background: 'transparent',\n border: 'none',\n padding: 2,\n margin: 0,\n color: 'inherit',\n opacity: 0.6,\n cursor: 'pointer',\n } as React.CSSProperties,\n expandedList: { display: 'flex', flexDirection: 'column', gap: 12, maxHeight: 360, overflowY: 'auto' } as React.CSSProperties,\n topicRow: { display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 2 } as React.CSSProperties,\n topicCard: {\n minWidth: 160,\n maxWidth: 200,\n borderRadius: 8,\n background: 'rgba(255,255,255,0.06)',\n display: 'flex',\n flexDirection: 'column',\n gap: 4,\n textDecoration: 'none',\n color: 'inherit',\n overflow: 'hidden',\n } as React.CSSProperties,\n topicCardBody: { padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 4 } as React.CSSProperties,\n topicThumb: {\n width: '100%',\n height: 84,\n objectFit: 'cover',\n display: 'block',\n } as React.CSSProperties,\n articleThumb: {\n width: 48,\n height: 48,\n objectFit: 'cover',\n borderRadius: 6,\n flexShrink: 0,\n } as React.CSSProperties,\n fullTopicWithThumb: { display: 'flex', gap: 10, alignItems: 'flex-start' } as React.CSSProperties,\n topicName: {\n fontSize: 12,\n fontWeight: 600,\n whiteSpace: 'nowrap',\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n } as React.CSSProperties,\n headline: {\n fontSize: 11,\n opacity: 0.8,\n display: '-webkit-box',\n WebkitLineClamp: 2,\n WebkitBoxOrient: 'vertical',\n overflow: 'hidden',\n } as React.CSSProperties,\n count: { fontSize: 10, opacity: 0.6 } as React.CSSProperties,\n list: { display: 'flex', flexDirection: 'column', gap: 16 } as React.CSSProperties,\n fullTopic: { display: 'flex', flexDirection: 'column', gap: 6 } as React.CSSProperties,\n fullTopicHeader: { display: 'flex', alignItems: 'baseline', gap: 8 } as React.CSSProperties,\n article: { fontSize: 13 } as React.CSSProperties,\n articleLink: { color: 'inherit', textDecoration: 'none' } as React.CSSProperties,\n};\n\nfunction ArticleLine({ article, showImage }: { article: TrendingTopic['articles'][number]; showImage?: boolean }) {\n return (\n <div style={{ ...styles.article, ...(showImage && article.imageUrl ? styles.fullTopicWithThumb : {}) }}>\n {showImage && article.imageUrl && <img src={article.imageUrl} alt=\"\" style={styles.articleThumb} />}\n <div>\n {article.url ? (\n <a href={article.url} target=\"_blank\" rel=\"noreferrer\" style={styles.articleLink}>\n {article.title}\n </a>\n ) : (\n article.title\n )}\n {article.source && <span style={{ opacity: 0.6 }}> · {article.source}</span>}\n </div>\n </div>\n );\n}\n\nfunction TopicArticleList({ topic, showImages }: { topic: TrendingTopic; showImages: boolean }) {\n return (\n <div style={styles.fullTopic}>\n <div style={styles.fullTopicHeader}>\n <strong>{topic.topic}</strong>\n <span style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </span>\n </div>\n {topic.articles.slice(0, 5).map((article, i) => (\n <ArticleLine key={article.url ?? i} article={article} showImage={showImages} />\n ))}\n </div>\n );\n}\n\n/**\n * Trending news widget. Requires `apiEndpoint` pointing at a deployed\n * instance of the bundled Cloudflare Worker (`worker/index.ts`) — renders\n * nothing when it's not configured, still loading, or errored, so it never\n * disrupts the layout it's dropped into.\n */\nexport function TrendingNews(props: Props) {\n const {\n className,\n style,\n compact,\n maxTopics = 8,\n expandable,\n expandedMaxTopics = 15,\n showImages = true,\n ...options\n } = props;\n const { data, loading, error } = useTrendingNews(options);\n const [expanded, setExpanded] = useState(false);\n\n if (!options.apiEndpoint) return null;\n if (loading && !data) return null;\n if (error) return null;\n if (!data || data.topics.length === 0) return null;\n\n const topics = data.topics.slice(0, maxTopics);\n\n if (compact) {\n const showExpanded = expandable && expanded;\n const expandedTopics = showExpanded ? data.topics.slice(0, expandedMaxTopics) : [];\n\n return (\n <div className={className} style={{ ...styles.compactRoot, ...style }}>\n <div style={styles.compactHeaderRow}>\n <div style={styles.compactHeader}>Trending</div>\n {expandable && (\n <button\n type=\"button\"\n onClick={() => setExpanded((e) => !e)}\n aria-expanded={showExpanded}\n aria-label={showExpanded ? 'Collapse trending news' : 'Expand trending news'}\n style={styles.expandToggle}\n >\n <ChevronIcon up={showExpanded} />\n </button>\n )}\n </div>\n\n {showExpanded ? (\n <div style={styles.expandedList}>\n {expandedTopics.map((topic) => (\n <TopicArticleList key={topic.topic} topic={topic} showImages={showImages} />\n ))}\n </div>\n ) : (\n <div style={styles.topicRow}>\n {topics.map((topic) => {\n const thumb = showImages ? topic.articles[0]?.imageUrl : undefined;\n return (\n <a\n key={topic.topic}\n href={topic.articles[0]?.url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={styles.topicCard}\n >\n {thumb && <img src={thumb} alt=\"\" style={styles.topicThumb} />}\n <div style={styles.topicCardBody}>\n <div style={styles.topicName}>{topic.topic}</div>\n {topic.articles[0] && <div style={styles.headline}>{topic.articles[0].title}</div>}\n <div style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </div>\n </div>\n </a>\n );\n })}\n </div>\n )}\n </div>\n );\n }\n\n return (\n <div className={className} style={{ ...styles.root, ...styles.list, ...style }}>\n {topics.map((topic) => (\n <TopicArticleList key={topic.topic} topic={topic} showImages={showImages} />\n ))}\n </div>\n );\n}\n"],"mappings":"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","names":["import_react"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/lib/cache.ts","../src/api/trending.ts","../src/hooks/useTrendingNews.ts","../src/components/TrendingNews.tsx"],"sourcesContent":["export * from './types';\nexport { clearTrendingNewsCache } from './lib/cache';\nexport * from './api/trending';\nexport * from './hooks/useTrendingNews';\nexport * from './components/TrendingNews';\n","const CACHE_PREFIX = 'trending-news-cache:';\nconst CACHE_TTL_MS = 10 * 60 * 1000;\n\ntype CacheEntry<T> = {\n timestamp: number;\n data: T;\n};\n\nfunction getStorage(): Storage | null {\n if (typeof window === 'undefined' || !window.localStorage) return null;\n return window.localStorage;\n}\n\n/** Returns the cached value for `key` if it was written within the last 10 minutes. */\nexport function readCachedTrendingNews<T>(key: string): T | null {\n const storage = getStorage();\n if (!storage) return null;\n\n try {\n const raw = storage.getItem(CACHE_PREFIX + key);\n if (!raw) return null;\n\n const entry = JSON.parse(raw) as CacheEntry<T>;\n if (Date.now() - entry.timestamp > CACHE_TTL_MS) {\n storage.removeItem(CACHE_PREFIX + key);\n return null;\n }\n\n return entry.data;\n } catch {\n return null;\n }\n}\n\nexport function writeCachedTrendingNews<T>(key: string, data: T): void {\n const storage = getStorage();\n if (!storage) return;\n\n try {\n const entry: CacheEntry<T> = { timestamp: Date.now(), data };\n storage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));\n } catch {\n // Storage full or unavailable (e.g. private browsing) — safe to ignore,\n // the next call will simply hit the network again.\n }\n}\n\n/** Clears all cached trending news responses. Mainly useful for tests/debugging. */\nexport function clearTrendingNewsCache(): void {\n const storage = getStorage();\n if (!storage) return;\n\n for (let i = storage.length - 1; i >= 0; i--) {\n const key = storage.key(i);\n if (key?.startsWith(CACHE_PREFIX)) storage.removeItem(key);\n }\n}\n","import type { NewsArticle, TrendingNewsData, TrendingNewsOptions, TrendingNewsTopicData } from '../types';\nimport { readCachedTrendingNews, writeCachedTrendingNews } from '../lib/cache';\n\ntype WorkerArticle = {\n title?: string;\n url?: string;\n source?: string;\n published_at?: string;\n image_url?: string;\n};\n\ntype WorkerTopic = {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: WorkerArticle[];\n};\n\ntype WorkerTopicsResponse = {\n date?: string;\n topics: WorkerTopic[];\n error?: string;\n};\n\ntype WorkerTopicResponse = {\n topic: string;\n news_count: number;\n articles: WorkerArticle[];\n error?: string;\n};\n\nfunction mapArticles(articles: WorkerArticle[] = []): NewsArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n publishedAt: a.published_at,\n imageUrl: a.image_url,\n }));\n}\n\n/**\n * Resolves `apiEndpoint` against the page it is running on, so a host app that\n * serves the trending data from one of its own routes can configure it as a\n * plain path (`/api/news/trending`) instead of a full origin.\n */\nfunction resolveEndpoint(apiEndpoint: string): URL {\n const base = typeof window !== 'undefined' ? window.location.href : undefined;\n try {\n return new URL(apiEndpoint, base);\n } catch {\n throw new Error(`trending-news-api: apiEndpoint \"${apiEndpoint}\" is not a valid URL`);\n }\n}\n\nfunction buildUrl(apiEndpoint: string, options: TrendingNewsOptions) {\n const url = resolveEndpoint(apiEndpoint);\n if (options.topic) url.searchParams.set('topic', options.topic);\n // Ask the server for only as many topics as the caller keeps: each topic\n // costs it one upstream news search.\n if (options.limit && !options.topic) url.searchParams.set('limit', String(options.limit));\n return url.toString();\n}\n\n/**\n * Fetches trending topics (or, when `options.topic` is set, news for a\n * single topic) from a deployed instance of `worker/index.ts`.\n */\nexport async function getTrendingNews(options: TrendingNewsOptions): Promise<TrendingNewsData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, options);\n\n const cached = readCachedTrendingNews<TrendingNewsData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicsResponse;\n if (data.error) throw new Error(data.error);\n\n const limit = options.limit ?? 25;\n const result: TrendingNewsData = {\n date: data.date,\n topics: (data.topics ?? []).slice(0, limit).map((t) => ({\n topic: t.topic,\n wikiRank: t.wiki_rank,\n wikiViews: t.wiki_views,\n newsCount: t.news_count,\n articles: mapArticles(t.articles),\n })),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n\n/** Fetches news articles for a single topic. */\nexport async function getTrendingNewsForTopic(\n topic: string,\n options: Omit<TrendingNewsOptions, 'topic'>\n): Promise<TrendingNewsTopicData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, { ...options, topic });\n\n const cached = readCachedTrendingNews<TrendingNewsTopicData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicResponse;\n if (data.error) throw new Error(data.error);\n\n const result: TrendingNewsTopicData = {\n topic: data.topic,\n newsCount: data.news_count,\n articles: mapArticles(data.articles),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n","import { useEffect, useState } from 'react';\nimport type { TrendingNewsData, TrendingNewsOptions } from '../types';\nimport { getTrendingNews } from '../api/trending';\n\nexport function useTrendingNews(options: TrendingNewsOptions = {}) {\n const [data, setData] = useState<TrendingNewsData | null>(null);\n const [error, setError] = useState<Error | null>(null);\n const [loading, setLoading] = useState(Boolean(options.apiEndpoint));\n\n useEffect(() => {\n if (!options.apiEndpoint) {\n setLoading(false);\n return;\n }\n\n let active = true;\n setLoading(true);\n setError(null);\n\n getTrendingNews(options)\n .then((result) => { if (active) setData(result); })\n .catch((err) => { if (active) setError(err instanceof Error ? err : new Error('Unknown error')); })\n .finally(() => { if (active) setLoading(false); });\n\n return () => { active = false; };\n }, [options.apiEndpoint, options.topic, options.limit]);\n\n return { data, error, loading };\n}\n","import React, { useState } from 'react';\nimport { useTrendingNews } from '../hooks/useTrendingNews';\nimport type { TrendingNewsOptions, TrendingTopic } from '../types';\n\ntype Props = TrendingNewsOptions & {\n className?: string;\n style?: React.CSSProperties;\n compact?: boolean;\n /** Max trending topics to render (default 8). */\n maxTopics?: number;\n /**\n * When set on a `compact` card, a chevron toggles between the compact\n * topic row and a full topic-by-topic article list, without leaving the\n * card's layout. Ignored outside `compact` mode, which already shows the\n * full list.\n */\n expandable?: boolean;\n /** Max topics to render once expanded (default 15). */\n expandedMaxTopics?: number;\n /** Show each topic's lead article thumbnail image, when available (default true). */\n showImages?: boolean;\n};\n\nfunction ChevronIcon({ up }: { up?: boolean }) {\n return (\n <svg\n width={14}\n height={14}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n style={{ transform: up ? 'rotate(180deg)' : undefined, transition: 'transform 150ms ease' }}\n >\n <polyline points=\"6 9 12 15 18 9\" />\n </svg>\n );\n}\n\nconst styles = {\n root: {\n fontFamily: 'system-ui, sans-serif',\n border: '1px solid #e5e7eb',\n borderRadius: 12,\n padding: 16,\n background: '#fff',\n color: '#111827',\n maxWidth: 640,\n } as React.CSSProperties,\n compactRoot: {\n fontFamily: 'system-ui, sans-serif',\n display: 'flex',\n flexDirection: 'column',\n gap: 6,\n padding: '10px 14px',\n borderRadius: 8,\n } as React.CSSProperties,\n compactHeaderRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } as React.CSSProperties,\n compactHeader: {\n fontSize: 11,\n fontWeight: 600,\n opacity: 0.75,\n letterSpacing: 0.3,\n textTransform: 'uppercase',\n } as React.CSSProperties,\n expandToggle: {\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n background: 'transparent',\n border: 'none',\n padding: 2,\n margin: 0,\n color: 'inherit',\n opacity: 0.6,\n cursor: 'pointer',\n } as React.CSSProperties,\n expandedList: { display: 'flex', flexDirection: 'column', gap: 12, maxHeight: 360, overflowY: 'auto' } as React.CSSProperties,\n topicRow: { display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 2 } as React.CSSProperties,\n topicCard: {\n minWidth: 160,\n maxWidth: 200,\n borderRadius: 8,\n background: 'rgba(255,255,255,0.06)',\n display: 'flex',\n flexDirection: 'column',\n gap: 4,\n textDecoration: 'none',\n color: 'inherit',\n overflow: 'hidden',\n } as React.CSSProperties,\n topicCardBody: { padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 4 } as React.CSSProperties,\n topicThumb: {\n width: '100%',\n height: 84,\n objectFit: 'cover',\n display: 'block',\n } as React.CSSProperties,\n articleThumb: {\n width: 48,\n height: 48,\n objectFit: 'cover',\n borderRadius: 6,\n flexShrink: 0,\n } as React.CSSProperties,\n fullTopicWithThumb: { display: 'flex', gap: 10, alignItems: 'flex-start' } as React.CSSProperties,\n topicName: {\n fontSize: 12,\n fontWeight: 600,\n whiteSpace: 'nowrap',\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n } as React.CSSProperties,\n headline: {\n fontSize: 11,\n opacity: 0.8,\n display: '-webkit-box',\n WebkitLineClamp: 2,\n WebkitBoxOrient: 'vertical',\n overflow: 'hidden',\n } as React.CSSProperties,\n count: { fontSize: 10, opacity: 0.6 } as React.CSSProperties,\n list: { display: 'flex', flexDirection: 'column', gap: 16 } as React.CSSProperties,\n fullTopic: { display: 'flex', flexDirection: 'column', gap: 6 } as React.CSSProperties,\n fullTopicHeader: { display: 'flex', alignItems: 'baseline', gap: 8 } as React.CSSProperties,\n article: { fontSize: 13 } as React.CSSProperties,\n articleLink: { color: 'inherit', textDecoration: 'none' } as React.CSSProperties,\n};\n\nfunction ArticleLine({ article, showImage }: { article: TrendingTopic['articles'][number]; showImage?: boolean }) {\n return (\n <div style={{ ...styles.article, ...(showImage && article.imageUrl ? styles.fullTopicWithThumb : {}) }}>\n {showImage && article.imageUrl && <img src={article.imageUrl} alt=\"\" style={styles.articleThumb} />}\n <div>\n {article.url ? (\n <a href={article.url} target=\"_blank\" rel=\"noreferrer\" style={styles.articleLink}>\n {article.title}\n </a>\n ) : (\n article.title\n )}\n {article.source && <span style={{ opacity: 0.6 }}> · {article.source}</span>}\n </div>\n </div>\n );\n}\n\nfunction TopicArticleList({ topic, showImages }: { topic: TrendingTopic; showImages: boolean }) {\n return (\n <div style={styles.fullTopic}>\n <div style={styles.fullTopicHeader}>\n <strong>{topic.topic}</strong>\n <span style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </span>\n </div>\n {topic.articles.slice(0, 5).map((article, i) => (\n <ArticleLine key={article.url ?? i} article={article} showImage={showImages} />\n ))}\n </div>\n );\n}\n\n/**\n * Trending news widget. Requires `apiEndpoint` pointing at a deployed\n * instance of the bundled Cloudflare Worker (`worker/index.ts`) — renders\n * nothing when it's not configured, still loading, or errored, so it never\n * disrupts the layout it's dropped into.\n */\nexport function TrendingNews(props: Props) {\n const {\n className,\n style,\n compact,\n maxTopics = 8,\n expandable,\n expandedMaxTopics = 15,\n showImages = true,\n ...options\n } = props;\n const { data, loading, error } = useTrendingNews(options);\n const [expanded, setExpanded] = useState(false);\n\n if (!options.apiEndpoint) return null;\n if (loading && !data) return null;\n if (error) return null;\n if (!data || data.topics.length === 0) return null;\n\n const topics = data.topics.slice(0, maxTopics);\n\n if (compact) {\n const showExpanded = expandable && expanded;\n const expandedTopics = showExpanded ? data.topics.slice(0, expandedMaxTopics) : [];\n\n return (\n <div className={className} style={{ ...styles.compactRoot, ...style }}>\n <div style={styles.compactHeaderRow}>\n <div style={styles.compactHeader}>Trending</div>\n {expandable && (\n <button\n type=\"button\"\n onClick={() => setExpanded((e) => !e)}\n aria-expanded={showExpanded}\n aria-label={showExpanded ? 'Collapse trending news' : 'Expand trending news'}\n style={styles.expandToggle}\n >\n <ChevronIcon up={showExpanded} />\n </button>\n )}\n </div>\n\n {showExpanded ? (\n <div style={styles.expandedList}>\n {expandedTopics.map((topic) => (\n <TopicArticleList key={topic.topic} topic={topic} showImages={showImages} />\n ))}\n </div>\n ) : (\n <div style={styles.topicRow}>\n {topics.map((topic) => {\n const thumb = showImages ? topic.articles[0]?.imageUrl : undefined;\n return (\n <a\n key={topic.topic}\n href={topic.articles[0]?.url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={styles.topicCard}\n >\n {thumb && <img src={thumb} alt=\"\" style={styles.topicThumb} />}\n <div style={styles.topicCardBody}>\n <div style={styles.topicName}>{topic.topic}</div>\n {topic.articles[0] && <div style={styles.headline}>{topic.articles[0].title}</div>}\n <div style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </div>\n </div>\n </a>\n );\n })}\n </div>\n )}\n </div>\n );\n }\n\n return (\n <div className={className} style={{ ...styles.root, ...styles.list, ...style }}>\n {topics.map((topic) => (\n <TopicArticleList key={topic.topic} topic={topic} showImages={showImages} />\n ))}\n </div>\n );\n}\n"],"mappings":"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","names":["import_react"]}
|
package/dist/index.js
CHANGED
|
@@ -49,9 +49,18 @@ function mapArticles(articles = []) {
|
|
|
49
49
|
imageUrl: a.image_url
|
|
50
50
|
}));
|
|
51
51
|
}
|
|
52
|
+
function resolveEndpoint(apiEndpoint) {
|
|
53
|
+
const base = typeof window !== "undefined" ? window.location.href : void 0;
|
|
54
|
+
try {
|
|
55
|
+
return new URL(apiEndpoint, base);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(`trending-news-api: apiEndpoint "${apiEndpoint}" is not a valid URL`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
52
60
|
function buildUrl(apiEndpoint, options) {
|
|
53
|
-
const url =
|
|
61
|
+
const url = resolveEndpoint(apiEndpoint);
|
|
54
62
|
if (options.topic) url.searchParams.set("topic", options.topic);
|
|
63
|
+
if (options.limit && !options.topic) url.searchParams.set("limit", String(options.limit));
|
|
55
64
|
return url.toString();
|
|
56
65
|
}
|
|
57
66
|
async function getTrendingNews(options) {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/lib/cache.ts","../src/api/trending.ts","../src/hooks/useTrendingNews.ts","../src/components/TrendingNews.tsx"],"sourcesContent":["const CACHE_PREFIX = 'trending-news-cache:';\nconst CACHE_TTL_MS = 10 * 60 * 1000;\n\ntype CacheEntry<T> = {\n timestamp: number;\n data: T;\n};\n\nfunction getStorage(): Storage | null {\n if (typeof window === 'undefined' || !window.localStorage) return null;\n return window.localStorage;\n}\n\n/** Returns the cached value for `key` if it was written within the last 10 minutes. */\nexport function readCachedTrendingNews<T>(key: string): T | null {\n const storage = getStorage();\n if (!storage) return null;\n\n try {\n const raw = storage.getItem(CACHE_PREFIX + key);\n if (!raw) return null;\n\n const entry = JSON.parse(raw) as CacheEntry<T>;\n if (Date.now() - entry.timestamp > CACHE_TTL_MS) {\n storage.removeItem(CACHE_PREFIX + key);\n return null;\n }\n\n return entry.data;\n } catch {\n return null;\n }\n}\n\nexport function writeCachedTrendingNews<T>(key: string, data: T): void {\n const storage = getStorage();\n if (!storage) return;\n\n try {\n const entry: CacheEntry<T> = { timestamp: Date.now(), data };\n storage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));\n } catch {\n // Storage full or unavailable (e.g. private browsing) — safe to ignore,\n // the next call will simply hit the network again.\n }\n}\n\n/** Clears all cached trending news responses. Mainly useful for tests/debugging. */\nexport function clearTrendingNewsCache(): void {\n const storage = getStorage();\n if (!storage) return;\n\n for (let i = storage.length - 1; i >= 0; i--) {\n const key = storage.key(i);\n if (key?.startsWith(CACHE_PREFIX)) storage.removeItem(key);\n }\n}\n","import type { NewsArticle, TrendingNewsData, TrendingNewsOptions, TrendingNewsTopicData } from '../types';\nimport { readCachedTrendingNews, writeCachedTrendingNews } from '../lib/cache';\n\ntype WorkerArticle = {\n title?: string;\n url?: string;\n source?: string;\n published_at?: string;\n image_url?: string;\n};\n\ntype WorkerTopic = {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: WorkerArticle[];\n};\n\ntype WorkerTopicsResponse = {\n date?: string;\n topics: WorkerTopic[];\n error?: string;\n};\n\ntype WorkerTopicResponse = {\n topic: string;\n news_count: number;\n articles: WorkerArticle[];\n error?: string;\n};\n\nfunction mapArticles(articles: WorkerArticle[] = []): NewsArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n publishedAt: a.published_at,\n imageUrl: a.image_url,\n }));\n}\n\nfunction buildUrl(apiEndpoint: string, options: TrendingNewsOptions) {\n const url = new URL(apiEndpoint);\n if (options.topic) url.searchParams.set('topic', options.topic);\n return url.toString();\n}\n\n/**\n * Fetches trending topics (or, when `options.topic` is set, news for a\n * single topic) from a deployed instance of `worker/index.ts`.\n */\nexport async function getTrendingNews(options: TrendingNewsOptions): Promise<TrendingNewsData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, options);\n\n const cached = readCachedTrendingNews<TrendingNewsData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicsResponse;\n if (data.error) throw new Error(data.error);\n\n const limit = options.limit ?? 25;\n const result: TrendingNewsData = {\n date: data.date,\n topics: (data.topics ?? []).slice(0, limit).map((t) => ({\n topic: t.topic,\n wikiRank: t.wiki_rank,\n wikiViews: t.wiki_views,\n newsCount: t.news_count,\n articles: mapArticles(t.articles),\n })),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n\n/** Fetches news articles for a single topic. */\nexport async function getTrendingNewsForTopic(\n topic: string,\n options: Omit<TrendingNewsOptions, 'topic'>\n): Promise<TrendingNewsTopicData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, { ...options, topic });\n\n const cached = readCachedTrendingNews<TrendingNewsTopicData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicResponse;\n if (data.error) throw new Error(data.error);\n\n const result: TrendingNewsTopicData = {\n topic: data.topic,\n newsCount: data.news_count,\n articles: mapArticles(data.articles),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n","import { useEffect, useState } from 'react';\nimport type { TrendingNewsData, TrendingNewsOptions } from '../types';\nimport { getTrendingNews } from '../api/trending';\n\nexport function useTrendingNews(options: TrendingNewsOptions = {}) {\n const [data, setData] = useState<TrendingNewsData | null>(null);\n const [error, setError] = useState<Error | null>(null);\n const [loading, setLoading] = useState(Boolean(options.apiEndpoint));\n\n useEffect(() => {\n if (!options.apiEndpoint) {\n setLoading(false);\n return;\n }\n\n let active = true;\n setLoading(true);\n setError(null);\n\n getTrendingNews(options)\n .then((result) => { if (active) setData(result); })\n .catch((err) => { if (active) setError(err instanceof Error ? err : new Error('Unknown error')); })\n .finally(() => { if (active) setLoading(false); });\n\n return () => { active = false; };\n }, [options.apiEndpoint, options.topic, options.limit]);\n\n return { data, error, loading };\n}\n","import React, { useState } from 'react';\nimport { useTrendingNews } from '../hooks/useTrendingNews';\nimport type { TrendingNewsOptions, TrendingTopic } from '../types';\n\ntype Props = TrendingNewsOptions & {\n className?: string;\n style?: React.CSSProperties;\n compact?: boolean;\n /** Max trending topics to render (default 8). */\n maxTopics?: number;\n /**\n * When set on a `compact` card, a chevron toggles between the compact\n * topic row and a full topic-by-topic article list, without leaving the\n * card's layout. Ignored outside `compact` mode, which already shows the\n * full list.\n */\n expandable?: boolean;\n /** Max topics to render once expanded (default 15). */\n expandedMaxTopics?: number;\n /** Show each topic's lead article thumbnail image, when available (default true). */\n showImages?: boolean;\n};\n\nfunction ChevronIcon({ up }: { up?: boolean }) {\n return (\n <svg\n width={14}\n height={14}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n style={{ transform: up ? 'rotate(180deg)' : undefined, transition: 'transform 150ms ease' }}\n >\n <polyline points=\"6 9 12 15 18 9\" />\n </svg>\n );\n}\n\nconst styles = {\n root: {\n fontFamily: 'system-ui, sans-serif',\n border: '1px solid #e5e7eb',\n borderRadius: 12,\n padding: 16,\n background: '#fff',\n color: '#111827',\n maxWidth: 640,\n } as React.CSSProperties,\n compactRoot: {\n fontFamily: 'system-ui, sans-serif',\n display: 'flex',\n flexDirection: 'column',\n gap: 6,\n padding: '10px 14px',\n borderRadius: 8,\n } as React.CSSProperties,\n compactHeaderRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } as React.CSSProperties,\n compactHeader: {\n fontSize: 11,\n fontWeight: 600,\n opacity: 0.75,\n letterSpacing: 0.3,\n textTransform: 'uppercase',\n } as React.CSSProperties,\n expandToggle: {\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n background: 'transparent',\n border: 'none',\n padding: 2,\n margin: 0,\n color: 'inherit',\n opacity: 0.6,\n cursor: 'pointer',\n } as React.CSSProperties,\n expandedList: { display: 'flex', flexDirection: 'column', gap: 12, maxHeight: 360, overflowY: 'auto' } as React.CSSProperties,\n topicRow: { display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 2 } as React.CSSProperties,\n topicCard: {\n minWidth: 160,\n maxWidth: 200,\n borderRadius: 8,\n background: 'rgba(255,255,255,0.06)',\n display: 'flex',\n flexDirection: 'column',\n gap: 4,\n textDecoration: 'none',\n color: 'inherit',\n overflow: 'hidden',\n } as React.CSSProperties,\n topicCardBody: { padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 4 } as React.CSSProperties,\n topicThumb: {\n width: '100%',\n height: 84,\n objectFit: 'cover',\n display: 'block',\n } as React.CSSProperties,\n articleThumb: {\n width: 48,\n height: 48,\n objectFit: 'cover',\n borderRadius: 6,\n flexShrink: 0,\n } as React.CSSProperties,\n fullTopicWithThumb: { display: 'flex', gap: 10, alignItems: 'flex-start' } as React.CSSProperties,\n topicName: {\n fontSize: 12,\n fontWeight: 600,\n whiteSpace: 'nowrap',\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n } as React.CSSProperties,\n headline: {\n fontSize: 11,\n opacity: 0.8,\n display: '-webkit-box',\n WebkitLineClamp: 2,\n WebkitBoxOrient: 'vertical',\n overflow: 'hidden',\n } as React.CSSProperties,\n count: { fontSize: 10, opacity: 0.6 } as React.CSSProperties,\n list: { display: 'flex', flexDirection: 'column', gap: 16 } as React.CSSProperties,\n fullTopic: { display: 'flex', flexDirection: 'column', gap: 6 } as React.CSSProperties,\n fullTopicHeader: { display: 'flex', alignItems: 'baseline', gap: 8 } as React.CSSProperties,\n article: { fontSize: 13 } as React.CSSProperties,\n articleLink: { color: 'inherit', textDecoration: 'none' } as React.CSSProperties,\n};\n\nfunction ArticleLine({ article, showImage }: { article: TrendingTopic['articles'][number]; showImage?: boolean }) {\n return (\n <div style={{ ...styles.article, ...(showImage && article.imageUrl ? styles.fullTopicWithThumb : {}) }}>\n {showImage && article.imageUrl && <img src={article.imageUrl} alt=\"\" style={styles.articleThumb} />}\n <div>\n {article.url ? (\n <a href={article.url} target=\"_blank\" rel=\"noreferrer\" style={styles.articleLink}>\n {article.title}\n </a>\n ) : (\n article.title\n )}\n {article.source && <span style={{ opacity: 0.6 }}> · {article.source}</span>}\n </div>\n </div>\n );\n}\n\nfunction TopicArticleList({ topic, showImages }: { topic: TrendingTopic; showImages: boolean }) {\n return (\n <div style={styles.fullTopic}>\n <div style={styles.fullTopicHeader}>\n <strong>{topic.topic}</strong>\n <span style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </span>\n </div>\n {topic.articles.slice(0, 5).map((article, i) => (\n <ArticleLine key={article.url ?? i} article={article} showImage={showImages} />\n ))}\n </div>\n );\n}\n\n/**\n * Trending news widget. Requires `apiEndpoint` pointing at a deployed\n * instance of the bundled Cloudflare Worker (`worker/index.ts`) — renders\n * nothing when it's not configured, still loading, or errored, so it never\n * disrupts the layout it's dropped into.\n */\nexport function TrendingNews(props: Props) {\n const {\n className,\n style,\n compact,\n maxTopics = 8,\n expandable,\n expandedMaxTopics = 15,\n showImages = true,\n ...options\n } = props;\n const { data, loading, error } = useTrendingNews(options);\n const [expanded, setExpanded] = useState(false);\n\n if (!options.apiEndpoint) return null;\n if (loading && !data) return null;\n if (error) return null;\n if (!data || data.topics.length === 0) return null;\n\n const topics = data.topics.slice(0, maxTopics);\n\n if (compact) {\n const showExpanded = expandable && expanded;\n const expandedTopics = showExpanded ? data.topics.slice(0, expandedMaxTopics) : [];\n\n return (\n <div className={className} style={{ ...styles.compactRoot, ...style }}>\n <div style={styles.compactHeaderRow}>\n <div style={styles.compactHeader}>Trending</div>\n {expandable && (\n <button\n type=\"button\"\n onClick={() => setExpanded((e) => !e)}\n aria-expanded={showExpanded}\n aria-label={showExpanded ? 'Collapse trending news' : 'Expand trending news'}\n style={styles.expandToggle}\n >\n <ChevronIcon up={showExpanded} />\n </button>\n )}\n </div>\n\n {showExpanded ? (\n <div style={styles.expandedList}>\n {expandedTopics.map((topic) => (\n <TopicArticleList key={topic.topic} topic={topic} showImages={showImages} />\n ))}\n </div>\n ) : (\n <div style={styles.topicRow}>\n {topics.map((topic) => {\n const thumb = showImages ? topic.articles[0]?.imageUrl : undefined;\n return (\n <a\n key={topic.topic}\n href={topic.articles[0]?.url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={styles.topicCard}\n >\n {thumb && <img src={thumb} alt=\"\" style={styles.topicThumb} />}\n <div style={styles.topicCardBody}>\n <div style={styles.topicName}>{topic.topic}</div>\n {topic.articles[0] && <div style={styles.headline}>{topic.articles[0].title}</div>}\n <div style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </div>\n </div>\n </a>\n );\n })}\n </div>\n )}\n </div>\n );\n }\n\n return (\n <div className={className} style={{ ...styles.root, ...styles.list, ...style }}>\n {topics.map((topic) => (\n <TopicArticleList key={topic.topic} topic={topic} showImages={showImages} />\n ))}\n </div>\n );\n}\n"],"mappings":"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","names":["useState","useState"]}
|
|
1
|
+
{"version":3,"sources":["../src/lib/cache.ts","../src/api/trending.ts","../src/hooks/useTrendingNews.ts","../src/components/TrendingNews.tsx"],"sourcesContent":["const CACHE_PREFIX = 'trending-news-cache:';\nconst CACHE_TTL_MS = 10 * 60 * 1000;\n\ntype CacheEntry<T> = {\n timestamp: number;\n data: T;\n};\n\nfunction getStorage(): Storage | null {\n if (typeof window === 'undefined' || !window.localStorage) return null;\n return window.localStorage;\n}\n\n/** Returns the cached value for `key` if it was written within the last 10 minutes. */\nexport function readCachedTrendingNews<T>(key: string): T | null {\n const storage = getStorage();\n if (!storage) return null;\n\n try {\n const raw = storage.getItem(CACHE_PREFIX + key);\n if (!raw) return null;\n\n const entry = JSON.parse(raw) as CacheEntry<T>;\n if (Date.now() - entry.timestamp > CACHE_TTL_MS) {\n storage.removeItem(CACHE_PREFIX + key);\n return null;\n }\n\n return entry.data;\n } catch {\n return null;\n }\n}\n\nexport function writeCachedTrendingNews<T>(key: string, data: T): void {\n const storage = getStorage();\n if (!storage) return;\n\n try {\n const entry: CacheEntry<T> = { timestamp: Date.now(), data };\n storage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));\n } catch {\n // Storage full or unavailable (e.g. private browsing) — safe to ignore,\n // the next call will simply hit the network again.\n }\n}\n\n/** Clears all cached trending news responses. Mainly useful for tests/debugging. */\nexport function clearTrendingNewsCache(): void {\n const storage = getStorage();\n if (!storage) return;\n\n for (let i = storage.length - 1; i >= 0; i--) {\n const key = storage.key(i);\n if (key?.startsWith(CACHE_PREFIX)) storage.removeItem(key);\n }\n}\n","import type { NewsArticle, TrendingNewsData, TrendingNewsOptions, TrendingNewsTopicData } from '../types';\nimport { readCachedTrendingNews, writeCachedTrendingNews } from '../lib/cache';\n\ntype WorkerArticle = {\n title?: string;\n url?: string;\n source?: string;\n published_at?: string;\n image_url?: string;\n};\n\ntype WorkerTopic = {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: WorkerArticle[];\n};\n\ntype WorkerTopicsResponse = {\n date?: string;\n topics: WorkerTopic[];\n error?: string;\n};\n\ntype WorkerTopicResponse = {\n topic: string;\n news_count: number;\n articles: WorkerArticle[];\n error?: string;\n};\n\nfunction mapArticles(articles: WorkerArticle[] = []): NewsArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n publishedAt: a.published_at,\n imageUrl: a.image_url,\n }));\n}\n\n/**\n * Resolves `apiEndpoint` against the page it is running on, so a host app that\n * serves the trending data from one of its own routes can configure it as a\n * plain path (`/api/news/trending`) instead of a full origin.\n */\nfunction resolveEndpoint(apiEndpoint: string): URL {\n const base = typeof window !== 'undefined' ? window.location.href : undefined;\n try {\n return new URL(apiEndpoint, base);\n } catch {\n throw new Error(`trending-news-api: apiEndpoint \"${apiEndpoint}\" is not a valid URL`);\n }\n}\n\nfunction buildUrl(apiEndpoint: string, options: TrendingNewsOptions) {\n const url = resolveEndpoint(apiEndpoint);\n if (options.topic) url.searchParams.set('topic', options.topic);\n // Ask the server for only as many topics as the caller keeps: each topic\n // costs it one upstream news search.\n if (options.limit && !options.topic) url.searchParams.set('limit', String(options.limit));\n return url.toString();\n}\n\n/**\n * Fetches trending topics (or, when `options.topic` is set, news for a\n * single topic) from a deployed instance of `worker/index.ts`.\n */\nexport async function getTrendingNews(options: TrendingNewsOptions): Promise<TrendingNewsData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, options);\n\n const cached = readCachedTrendingNews<TrendingNewsData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicsResponse;\n if (data.error) throw new Error(data.error);\n\n const limit = options.limit ?? 25;\n const result: TrendingNewsData = {\n date: data.date,\n topics: (data.topics ?? []).slice(0, limit).map((t) => ({\n topic: t.topic,\n wikiRank: t.wiki_rank,\n wikiViews: t.wiki_views,\n newsCount: t.news_count,\n articles: mapArticles(t.articles),\n })),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n\n/** Fetches news articles for a single topic. */\nexport async function getTrendingNewsForTopic(\n topic: string,\n options: Omit<TrendingNewsOptions, 'topic'>\n): Promise<TrendingNewsTopicData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, { ...options, topic });\n\n const cached = readCachedTrendingNews<TrendingNewsTopicData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicResponse;\n if (data.error) throw new Error(data.error);\n\n const result: TrendingNewsTopicData = {\n topic: data.topic,\n newsCount: data.news_count,\n articles: mapArticles(data.articles),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n","import { useEffect, useState } from 'react';\nimport type { TrendingNewsData, TrendingNewsOptions } from '../types';\nimport { getTrendingNews } from '../api/trending';\n\nexport function useTrendingNews(options: TrendingNewsOptions = {}) {\n const [data, setData] = useState<TrendingNewsData | null>(null);\n const [error, setError] = useState<Error | null>(null);\n const [loading, setLoading] = useState(Boolean(options.apiEndpoint));\n\n useEffect(() => {\n if (!options.apiEndpoint) {\n setLoading(false);\n return;\n }\n\n let active = true;\n setLoading(true);\n setError(null);\n\n getTrendingNews(options)\n .then((result) => { if (active) setData(result); })\n .catch((err) => { if (active) setError(err instanceof Error ? err : new Error('Unknown error')); })\n .finally(() => { if (active) setLoading(false); });\n\n return () => { active = false; };\n }, [options.apiEndpoint, options.topic, options.limit]);\n\n return { data, error, loading };\n}\n","import React, { useState } from 'react';\nimport { useTrendingNews } from '../hooks/useTrendingNews';\nimport type { TrendingNewsOptions, TrendingTopic } from '../types';\n\ntype Props = TrendingNewsOptions & {\n className?: string;\n style?: React.CSSProperties;\n compact?: boolean;\n /** Max trending topics to render (default 8). */\n maxTopics?: number;\n /**\n * When set on a `compact` card, a chevron toggles between the compact\n * topic row and a full topic-by-topic article list, without leaving the\n * card's layout. Ignored outside `compact` mode, which already shows the\n * full list.\n */\n expandable?: boolean;\n /** Max topics to render once expanded (default 15). */\n expandedMaxTopics?: number;\n /** Show each topic's lead article thumbnail image, when available (default true). */\n showImages?: boolean;\n};\n\nfunction ChevronIcon({ up }: { up?: boolean }) {\n return (\n <svg\n width={14}\n height={14}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n style={{ transform: up ? 'rotate(180deg)' : undefined, transition: 'transform 150ms ease' }}\n >\n <polyline points=\"6 9 12 15 18 9\" />\n </svg>\n );\n}\n\nconst styles = {\n root: {\n fontFamily: 'system-ui, sans-serif',\n border: '1px solid #e5e7eb',\n borderRadius: 12,\n padding: 16,\n background: '#fff',\n color: '#111827',\n maxWidth: 640,\n } as React.CSSProperties,\n compactRoot: {\n fontFamily: 'system-ui, sans-serif',\n display: 'flex',\n flexDirection: 'column',\n gap: 6,\n padding: '10px 14px',\n borderRadius: 8,\n } as React.CSSProperties,\n compactHeaderRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } as React.CSSProperties,\n compactHeader: {\n fontSize: 11,\n fontWeight: 600,\n opacity: 0.75,\n letterSpacing: 0.3,\n textTransform: 'uppercase',\n } as React.CSSProperties,\n expandToggle: {\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n background: 'transparent',\n border: 'none',\n padding: 2,\n margin: 0,\n color: 'inherit',\n opacity: 0.6,\n cursor: 'pointer',\n } as React.CSSProperties,\n expandedList: { display: 'flex', flexDirection: 'column', gap: 12, maxHeight: 360, overflowY: 'auto' } as React.CSSProperties,\n topicRow: { display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 2 } as React.CSSProperties,\n topicCard: {\n minWidth: 160,\n maxWidth: 200,\n borderRadius: 8,\n background: 'rgba(255,255,255,0.06)',\n display: 'flex',\n flexDirection: 'column',\n gap: 4,\n textDecoration: 'none',\n color: 'inherit',\n overflow: 'hidden',\n } as React.CSSProperties,\n topicCardBody: { padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 4 } as React.CSSProperties,\n topicThumb: {\n width: '100%',\n height: 84,\n objectFit: 'cover',\n display: 'block',\n } as React.CSSProperties,\n articleThumb: {\n width: 48,\n height: 48,\n objectFit: 'cover',\n borderRadius: 6,\n flexShrink: 0,\n } as React.CSSProperties,\n fullTopicWithThumb: { display: 'flex', gap: 10, alignItems: 'flex-start' } as React.CSSProperties,\n topicName: {\n fontSize: 12,\n fontWeight: 600,\n whiteSpace: 'nowrap',\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n } as React.CSSProperties,\n headline: {\n fontSize: 11,\n opacity: 0.8,\n display: '-webkit-box',\n WebkitLineClamp: 2,\n WebkitBoxOrient: 'vertical',\n overflow: 'hidden',\n } as React.CSSProperties,\n count: { fontSize: 10, opacity: 0.6 } as React.CSSProperties,\n list: { display: 'flex', flexDirection: 'column', gap: 16 } as React.CSSProperties,\n fullTopic: { display: 'flex', flexDirection: 'column', gap: 6 } as React.CSSProperties,\n fullTopicHeader: { display: 'flex', alignItems: 'baseline', gap: 8 } as React.CSSProperties,\n article: { fontSize: 13 } as React.CSSProperties,\n articleLink: { color: 'inherit', textDecoration: 'none' } as React.CSSProperties,\n};\n\nfunction ArticleLine({ article, showImage }: { article: TrendingTopic['articles'][number]; showImage?: boolean }) {\n return (\n <div style={{ ...styles.article, ...(showImage && article.imageUrl ? styles.fullTopicWithThumb : {}) }}>\n {showImage && article.imageUrl && <img src={article.imageUrl} alt=\"\" style={styles.articleThumb} />}\n <div>\n {article.url ? (\n <a href={article.url} target=\"_blank\" rel=\"noreferrer\" style={styles.articleLink}>\n {article.title}\n </a>\n ) : (\n article.title\n )}\n {article.source && <span style={{ opacity: 0.6 }}> · {article.source}</span>}\n </div>\n </div>\n );\n}\n\nfunction TopicArticleList({ topic, showImages }: { topic: TrendingTopic; showImages: boolean }) {\n return (\n <div style={styles.fullTopic}>\n <div style={styles.fullTopicHeader}>\n <strong>{topic.topic}</strong>\n <span style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </span>\n </div>\n {topic.articles.slice(0, 5).map((article, i) => (\n <ArticleLine key={article.url ?? i} article={article} showImage={showImages} />\n ))}\n </div>\n );\n}\n\n/**\n * Trending news widget. Requires `apiEndpoint` pointing at a deployed\n * instance of the bundled Cloudflare Worker (`worker/index.ts`) — renders\n * nothing when it's not configured, still loading, or errored, so it never\n * disrupts the layout it's dropped into.\n */\nexport function TrendingNews(props: Props) {\n const {\n className,\n style,\n compact,\n maxTopics = 8,\n expandable,\n expandedMaxTopics = 15,\n showImages = true,\n ...options\n } = props;\n const { data, loading, error } = useTrendingNews(options);\n const [expanded, setExpanded] = useState(false);\n\n if (!options.apiEndpoint) return null;\n if (loading && !data) return null;\n if (error) return null;\n if (!data || data.topics.length === 0) return null;\n\n const topics = data.topics.slice(0, maxTopics);\n\n if (compact) {\n const showExpanded = expandable && expanded;\n const expandedTopics = showExpanded ? data.topics.slice(0, expandedMaxTopics) : [];\n\n return (\n <div className={className} style={{ ...styles.compactRoot, ...style }}>\n <div style={styles.compactHeaderRow}>\n <div style={styles.compactHeader}>Trending</div>\n {expandable && (\n <button\n type=\"button\"\n onClick={() => setExpanded((e) => !e)}\n aria-expanded={showExpanded}\n aria-label={showExpanded ? 'Collapse trending news' : 'Expand trending news'}\n style={styles.expandToggle}\n >\n <ChevronIcon up={showExpanded} />\n </button>\n )}\n </div>\n\n {showExpanded ? (\n <div style={styles.expandedList}>\n {expandedTopics.map((topic) => (\n <TopicArticleList key={topic.topic} topic={topic} showImages={showImages} />\n ))}\n </div>\n ) : (\n <div style={styles.topicRow}>\n {topics.map((topic) => {\n const thumb = showImages ? topic.articles[0]?.imageUrl : undefined;\n return (\n <a\n key={topic.topic}\n href={topic.articles[0]?.url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={styles.topicCard}\n >\n {thumb && <img src={thumb} alt=\"\" style={styles.topicThumb} />}\n <div style={styles.topicCardBody}>\n <div style={styles.topicName}>{topic.topic}</div>\n {topic.articles[0] && <div style={styles.headline}>{topic.articles[0].title}</div>}\n <div style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </div>\n </div>\n </a>\n );\n })}\n </div>\n )}\n </div>\n );\n }\n\n return (\n <div className={className} style={{ ...styles.root, ...styles.list, ...style }}>\n {topics.map((topic) => (\n <TopicArticleList key={topic.topic} topic={topic} showImages={showImages} />\n ))}\n </div>\n );\n}\n"],"mappings":"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","names":["useState","useState"]}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/server/index.ts
|
|
21
|
+
var server_exports = {};
|
|
22
|
+
__export(server_exports, {
|
|
23
|
+
DEFAULT_TOPIC_LIMIT: () => DEFAULT_TOPIC_LIMIT,
|
|
24
|
+
MAX_TOPIC_LIMIT: () => MAX_TOPIC_LIMIT,
|
|
25
|
+
fetchWikipediaTopPages: () => fetchWikipediaTopPages,
|
|
26
|
+
getTopicHeadlines: () => getTopicHeadlines,
|
|
27
|
+
getTrendingTopics: () => getTrendingTopics,
|
|
28
|
+
handleTrendingNewsRequest: () => handleTrendingNewsRequest,
|
|
29
|
+
parseTopicLimit: () => parseTopicLimit,
|
|
30
|
+
searchNewsForTopic: () => searchNewsForTopic
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(server_exports);
|
|
33
|
+
var DEFAULT_TOPIC_LIMIT = 25;
|
|
34
|
+
var MAX_TOPIC_LIMIT = 50;
|
|
35
|
+
var ARTICLES_PER_TOPIC = 20;
|
|
36
|
+
var ARTICLES_FOR_SINGLE_TOPIC = 30;
|
|
37
|
+
var SEARCH_CONCURRENCY = 5;
|
|
38
|
+
var CORS_HEADERS = {
|
|
39
|
+
"Access-Control-Allow-Origin": "*",
|
|
40
|
+
"Cache-Control": "no-store"
|
|
41
|
+
};
|
|
42
|
+
var NON_ARTICLE_TITLE = /^(Main_Page|Special:|Wikipedia:|Portal:|File:|Talk:|Category:)/i;
|
|
43
|
+
function jsonResponse(payload, status = 200) {
|
|
44
|
+
return new Response(JSON.stringify(payload), {
|
|
45
|
+
status,
|
|
46
|
+
headers: { "content-type": "application/json", ...CORS_HEADERS }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function parseTopicLimit(raw, fallback = DEFAULT_TOPIC_LIMIT) {
|
|
50
|
+
const value = typeof raw === "string" ? Number(raw) : raw;
|
|
51
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return fallback;
|
|
52
|
+
return Math.min(MAX_TOPIC_LIMIT, Math.floor(value));
|
|
53
|
+
}
|
|
54
|
+
function defaultPageviewsDate(now = /* @__PURE__ */ new Date()) {
|
|
55
|
+
const d = new Date(now);
|
|
56
|
+
d.setUTCDate(d.getUTCDate() - 1);
|
|
57
|
+
return d;
|
|
58
|
+
}
|
|
59
|
+
function formatDate(d) {
|
|
60
|
+
return [
|
|
61
|
+
String(d.getUTCFullYear()).padStart(4, "0"),
|
|
62
|
+
String(d.getUTCMonth() + 1).padStart(2, "0"),
|
|
63
|
+
String(d.getUTCDate()).padStart(2, "0")
|
|
64
|
+
].join("-");
|
|
65
|
+
}
|
|
66
|
+
async function fetchWikipediaTopPages(date, limit = DEFAULT_TOPIC_LIMIT, fetchImpl = fetch) {
|
|
67
|
+
const [year, month, day] = formatDate(date).split("-");
|
|
68
|
+
const url = `https://wikimedia.org/api/rest_v1/metrics/pageviews/top-per-article/en.wikipedia.org/all-access/all-agents/${year}/${month}/${day}`;
|
|
69
|
+
const res = await fetchImpl(url, { headers: { "User-Agent": "trending-news-api" } });
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
throw new Error(`Failed to fetch Wikipedia top pages: ${res.status}`);
|
|
72
|
+
}
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
const items = (data.items ?? []).filter((it) => !NON_ARTICLE_TITLE.test(it.article));
|
|
75
|
+
return items.slice(0, limit).map((it, i) => ({
|
|
76
|
+
rank: it.rank ?? i + 1,
|
|
77
|
+
article: decodeURIComponent(it.article.replace(/_/g, " ")),
|
|
78
|
+
views: it.views
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
async function searchNewsForTopic(apiKey, query, limit = ARTICLES_PER_TOPIC, fetchImpl = fetch) {
|
|
82
|
+
const url = new URL("https://api.thenewsapi.com/v1/news/search");
|
|
83
|
+
url.searchParams.set("api_token", apiKey);
|
|
84
|
+
url.searchParams.set("q", query);
|
|
85
|
+
url.searchParams.set("language", "en");
|
|
86
|
+
url.searchParams.set("limit", String(limit));
|
|
87
|
+
const res = await fetchImpl(url.toString());
|
|
88
|
+
if (!res.ok) return [];
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
return data.data ?? [];
|
|
91
|
+
}
|
|
92
|
+
function toArticlePayload(articles) {
|
|
93
|
+
return articles.map((a) => ({
|
|
94
|
+
title: a.title ?? "",
|
|
95
|
+
url: a.url,
|
|
96
|
+
source: a.source,
|
|
97
|
+
published_at: a.published_at,
|
|
98
|
+
image_url: a.image_url
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
async function getTrendingTopics(options) {
|
|
102
|
+
const { apiKey, fetchImpl = fetch } = options;
|
|
103
|
+
const limit = parseTopicLimit(options.limit);
|
|
104
|
+
const date = options.date ?? defaultPageviewsDate();
|
|
105
|
+
const candidates = await fetchWikipediaTopPages(
|
|
106
|
+
date,
|
|
107
|
+
Math.min(MAX_TOPIC_LIMIT, limit * 2),
|
|
108
|
+
fetchImpl
|
|
109
|
+
);
|
|
110
|
+
const results = [];
|
|
111
|
+
for (let i = 0; i < candidates.length && results.length < limit; i += SEARCH_CONCURRENCY) {
|
|
112
|
+
const batch = candidates.slice(i, i + SEARCH_CONCURRENCY);
|
|
113
|
+
const searched = await Promise.all(
|
|
114
|
+
batch.map(async (entry) => ({
|
|
115
|
+
entry,
|
|
116
|
+
articles: await searchNewsForTopic(apiKey, entry.article, ARTICLES_PER_TOPIC, fetchImpl)
|
|
117
|
+
}))
|
|
118
|
+
);
|
|
119
|
+
for (const { entry, articles } of searched) {
|
|
120
|
+
if (!articles.length) continue;
|
|
121
|
+
results.push({
|
|
122
|
+
topic: entry.article,
|
|
123
|
+
wiki_rank: entry.rank,
|
|
124
|
+
wiki_views: entry.views,
|
|
125
|
+
news_count: articles.length,
|
|
126
|
+
articles: toArticlePayload(articles)
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
results.sort((a, b) => (a.wiki_rank ?? 999) - (b.wiki_rank ?? 999));
|
|
131
|
+
return {
|
|
132
|
+
source: "wikipedia_daily_top",
|
|
133
|
+
date: formatDate(date),
|
|
134
|
+
topics: results.slice(0, limit)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function getTopicHeadlines(topic, options) {
|
|
138
|
+
const articles = await searchNewsForTopic(
|
|
139
|
+
options.apiKey,
|
|
140
|
+
topic,
|
|
141
|
+
ARTICLES_FOR_SINGLE_TOPIC,
|
|
142
|
+
options.fetchImpl ?? fetch
|
|
143
|
+
);
|
|
144
|
+
return {
|
|
145
|
+
topic,
|
|
146
|
+
news_count: articles.length,
|
|
147
|
+
articles: toArticlePayload(articles)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
async function handleTrendingNewsRequest(request, options) {
|
|
151
|
+
const { apiKey, fetchImpl } = options;
|
|
152
|
+
if (!apiKey) {
|
|
153
|
+
return jsonResponse({ error: "THENEWSAPI_API_KEY is not configured" }, 500);
|
|
154
|
+
}
|
|
155
|
+
const params = new URL(request.url).searchParams;
|
|
156
|
+
const topic = params.get("topic");
|
|
157
|
+
try {
|
|
158
|
+
if (topic) {
|
|
159
|
+
return jsonResponse(await getTopicHeadlines(topic, { apiKey, fetchImpl }));
|
|
160
|
+
}
|
|
161
|
+
return jsonResponse(
|
|
162
|
+
await getTrendingTopics({
|
|
163
|
+
apiKey,
|
|
164
|
+
limit: parseTopicLimit(params.get("limit")),
|
|
165
|
+
fetchImpl
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return jsonResponse(
|
|
170
|
+
{
|
|
171
|
+
error: topic ? "Failed to fetch news for topic" : "Failed to fetch Wikipedia trends",
|
|
172
|
+
details: e instanceof Error ? e.message : String(e)
|
|
173
|
+
},
|
|
174
|
+
500
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
179
|
+
0 && (module.exports = {
|
|
180
|
+
DEFAULT_TOPIC_LIMIT,
|
|
181
|
+
MAX_TOPIC_LIMIT,
|
|
182
|
+
fetchWikipediaTopPages,
|
|
183
|
+
getTopicHeadlines,
|
|
184
|
+
getTrendingTopics,
|
|
185
|
+
handleTrendingNewsRequest,
|
|
186
|
+
parseTopicLimit,
|
|
187
|
+
searchNewsForTopic
|
|
188
|
+
});
|
|
189
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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/** 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\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/** 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 [year, month, day] = formatDate(date).split('-');\n const url = `https://wikimedia.org/api/rest_v1/metrics/pageviews/top-per-article/en.wikipedia.org/all-access/all-agents/${year}/${month}/${day}`;\n\n const res = await fetchImpl(url, { headers: { 'User-Agent': 'trending-news-api' } });\n if (!res.ok) {\n throw new Error(`Failed to fetch Wikipedia top pages: ${res.status}`);\n }\n\n const data = (await res.json()) as {\n items?: Array<{ article: string; views: number; rank?: number }>;\n };\n\n const items = (data.items ?? []).filter((it) => !NON_ARTICLE_TITLE.test(it.article));\n\n return items.slice(0, limit).map((it, i) => ({\n rank: it.rank ?? i + 1,\n article: decodeURIComponent(it.article.replace(/_/g, ' ')),\n views: it.views,\n }));\n}\n\n/**\n * Search news for a given query using The News API (thenewsapi.com). A failed\n * lookup yields no articles rather than throwing: one topic the API dislikes\n * shouldn't cost the whole trending list.\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/search');\n url.searchParams.set('api_token', apiKey);\n url.searchParams.set('q', query);\n url.searchParams.set('language', 'en');\n url.searchParams.set('limit', String(limit));\n\n const res = await fetchImpl(url.toString());\n if (!res.ok) return [];\n\n const data = (await res.json()) as { data?: NewsApiArticle[] };\n return data.data ?? [];\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 for (let i = 0; i < candidates.length && results.length < limit; i += SEARCH_CONCURRENCY) {\n const batch = candidates.slice(i, i + SEARCH_CONCURRENCY);\n const searched = await Promise.all(\n batch.map(async (entry) => ({\n entry,\n articles: await searchNewsForTopic(apiKey, entry.article, ARTICLES_PER_TOPIC, fetchImpl),\n }))\n );\n\n for (const { entry, articles } of searched) {\n if (!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 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/** 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 both routes the client speaks:\n *\n * - `GET /` — the daily trending list (`?limit=` topics, default 25).\n * - `GET /?topic=…` — headlines for one topic.\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: 'THENEWSAPI_API_KEY is not configured' }, 500);\n }\n\n const params = new URL(request.url).searchParams;\n const topic = params.get('topic');\n\n try {\n if (topic) {\n return jsonResponse(await getTopicHeadlines(topic, { 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 return jsonResponse(\n {\n error: topic ? 'Failed to fetch news for topic' : '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":[]}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Server half of the widget: joins Wikipedia's daily pageview
|
|
3
|
+
* ranking to headlines from The News API.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the bundled Cloudflare Worker (`worker/index.ts`) and by host apps
|
|
6
|
+
* that would rather serve the widget from an API route they already deploy
|
|
7
|
+
* than stand up a second worker — both go through `handleTrendingNewsRequest`,
|
|
8
|
+
* so there is exactly one wire format for the client in `src/api/trending.ts`
|
|
9
|
+
* to read.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here touches the DOM or React: it is plain `fetch`/`Request`/
|
|
12
|
+
* `Response`, so it runs on Workers, Node 18+, Bun and Deno alike. The News
|
|
13
|
+
* API key stays on the server.
|
|
14
|
+
*/
|
|
15
|
+
/** One headline, in the snake_case wire shape the client maps to camelCase. */
|
|
16
|
+
export interface TrendingNewsWireArticle {
|
|
17
|
+
title: string;
|
|
18
|
+
url?: string;
|
|
19
|
+
source?: string;
|
|
20
|
+
published_at?: string;
|
|
21
|
+
image_url?: string;
|
|
22
|
+
}
|
|
23
|
+
/** One trending topic with the headlines matched to it. */
|
|
24
|
+
export interface TrendingNewsWireTopic {
|
|
25
|
+
topic: string;
|
|
26
|
+
wiki_rank?: number;
|
|
27
|
+
wiki_views?: number;
|
|
28
|
+
news_count: number;
|
|
29
|
+
articles: TrendingNewsWireArticle[];
|
|
30
|
+
}
|
|
31
|
+
/** Body of `GET /` — the daily trending list. */
|
|
32
|
+
export interface TrendingNewsWireResponse {
|
|
33
|
+
source: string;
|
|
34
|
+
date: string;
|
|
35
|
+
topics: TrendingNewsWireTopic[];
|
|
36
|
+
}
|
|
37
|
+
/** Body of `GET /?topic=…` — headlines for one topic. */
|
|
38
|
+
export interface TrendingNewsWireTopicResponse {
|
|
39
|
+
topic: string;
|
|
40
|
+
news_count: number;
|
|
41
|
+
articles: TrendingNewsWireArticle[];
|
|
42
|
+
}
|
|
43
|
+
export interface TrendingTopicsOptions {
|
|
44
|
+
/** The News API (thenewsapi.com) token. */
|
|
45
|
+
apiKey: string;
|
|
46
|
+
/** How many topics to return (default 25, capped at 50). */
|
|
47
|
+
limit?: number;
|
|
48
|
+
/** Day to rank by pageviews. Defaults to yesterday UTC — see below. */
|
|
49
|
+
date?: Date;
|
|
50
|
+
/** Injected for tests; defaults to the global `fetch`. */
|
|
51
|
+
fetchImpl?: typeof fetch;
|
|
52
|
+
}
|
|
53
|
+
type WikiTopPage = {
|
|
54
|
+
rank: number;
|
|
55
|
+
article: string;
|
|
56
|
+
views: number;
|
|
57
|
+
};
|
|
58
|
+
type NewsApiArticle = {
|
|
59
|
+
title?: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
source?: string;
|
|
62
|
+
url?: string;
|
|
63
|
+
published_at?: string;
|
|
64
|
+
image_url?: string;
|
|
65
|
+
};
|
|
66
|
+
export declare const DEFAULT_TOPIC_LIMIT = 25;
|
|
67
|
+
export declare const MAX_TOPIC_LIMIT = 50;
|
|
68
|
+
/** Clamps a caller-supplied topic count to something we're willing to fetch. */
|
|
69
|
+
export declare function parseTopicLimit(raw: string | number | null | undefined, fallback?: number): number;
|
|
70
|
+
/**
|
|
71
|
+
* Fetch daily top Wikipedia articles by pageviews for a given date, via the
|
|
72
|
+
* Wikimedia Analytics API.
|
|
73
|
+
*/
|
|
74
|
+
export declare function fetchWikipediaTopPages(date: Date, limit?: number, fetchImpl?: typeof fetch): Promise<WikiTopPage[]>;
|
|
75
|
+
/**
|
|
76
|
+
* Search news for a given query using The News API (thenewsapi.com). A failed
|
|
77
|
+
* lookup yields no articles rather than throwing: one topic the API dislikes
|
|
78
|
+
* shouldn't cost the whole trending list.
|
|
79
|
+
*/
|
|
80
|
+
export declare function searchNewsForTopic(apiKey: string, query: string, limit?: number, fetchImpl?: typeof fetch): Promise<NewsApiArticle[]>;
|
|
81
|
+
/**
|
|
82
|
+
* The daily trending list: Wikipedia's most-viewed articles, each paired with
|
|
83
|
+
* the headlines The News API returns for it. Topics with no headlines are
|
|
84
|
+
* dropped, so we look at up to twice as many Wikipedia entries as the caller
|
|
85
|
+
* asked for topics and stop as soon as the quota is filled.
|
|
86
|
+
*
|
|
87
|
+
* Pageviews lag about a day, so the ranking is read for *yesterday* (UTC)
|
|
88
|
+
* unless a `date` is passed.
|
|
89
|
+
*/
|
|
90
|
+
export declare function getTrendingTopics(options: TrendingTopicsOptions): Promise<TrendingNewsWireResponse>;
|
|
91
|
+
/** Headlines for a single topic, bypassing the Wikipedia ranking. */
|
|
92
|
+
export declare function getTopicHeadlines(topic: string, options: {
|
|
93
|
+
apiKey: string;
|
|
94
|
+
fetchImpl?: typeof fetch;
|
|
95
|
+
}): Promise<TrendingNewsWireTopicResponse>;
|
|
96
|
+
/**
|
|
97
|
+
* Serves both routes the client speaks:
|
|
98
|
+
*
|
|
99
|
+
* - `GET /` — the daily trending list (`?limit=` topics, default 25).
|
|
100
|
+
* - `GET /?topic=…` — headlines for one topic.
|
|
101
|
+
*
|
|
102
|
+
* Always answers with JSON, including for failures, so a widget that only
|
|
103
|
+
* reads `error` never has to parse an HTML error page.
|
|
104
|
+
*/
|
|
105
|
+
export declare function handleTrendingNewsRequest(request: Request, options: {
|
|
106
|
+
apiKey: string | undefined;
|
|
107
|
+
fetchImpl?: typeof fetch;
|
|
108
|
+
}): Promise<Response>;
|
|
109
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/server/index.ts
|
|
2
|
+
var DEFAULT_TOPIC_LIMIT = 25;
|
|
3
|
+
var MAX_TOPIC_LIMIT = 50;
|
|
4
|
+
var ARTICLES_PER_TOPIC = 20;
|
|
5
|
+
var ARTICLES_FOR_SINGLE_TOPIC = 30;
|
|
6
|
+
var SEARCH_CONCURRENCY = 5;
|
|
7
|
+
var CORS_HEADERS = {
|
|
8
|
+
"Access-Control-Allow-Origin": "*",
|
|
9
|
+
"Cache-Control": "no-store"
|
|
10
|
+
};
|
|
11
|
+
var NON_ARTICLE_TITLE = /^(Main_Page|Special:|Wikipedia:|Portal:|File:|Talk:|Category:)/i;
|
|
12
|
+
function jsonResponse(payload, status = 200) {
|
|
13
|
+
return new Response(JSON.stringify(payload), {
|
|
14
|
+
status,
|
|
15
|
+
headers: { "content-type": "application/json", ...CORS_HEADERS }
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function parseTopicLimit(raw, fallback = DEFAULT_TOPIC_LIMIT) {
|
|
19
|
+
const value = typeof raw === "string" ? Number(raw) : raw;
|
|
20
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return fallback;
|
|
21
|
+
return Math.min(MAX_TOPIC_LIMIT, Math.floor(value));
|
|
22
|
+
}
|
|
23
|
+
function defaultPageviewsDate(now = /* @__PURE__ */ new Date()) {
|
|
24
|
+
const d = new Date(now);
|
|
25
|
+
d.setUTCDate(d.getUTCDate() - 1);
|
|
26
|
+
return d;
|
|
27
|
+
}
|
|
28
|
+
function formatDate(d) {
|
|
29
|
+
return [
|
|
30
|
+
String(d.getUTCFullYear()).padStart(4, "0"),
|
|
31
|
+
String(d.getUTCMonth() + 1).padStart(2, "0"),
|
|
32
|
+
String(d.getUTCDate()).padStart(2, "0")
|
|
33
|
+
].join("-");
|
|
34
|
+
}
|
|
35
|
+
async function fetchWikipediaTopPages(date, limit = DEFAULT_TOPIC_LIMIT, fetchImpl = fetch) {
|
|
36
|
+
const [year, month, day] = formatDate(date).split("-");
|
|
37
|
+
const url = `https://wikimedia.org/api/rest_v1/metrics/pageviews/top-per-article/en.wikipedia.org/all-access/all-agents/${year}/${month}/${day}`;
|
|
38
|
+
const res = await fetchImpl(url, { headers: { "User-Agent": "trending-news-api" } });
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
throw new Error(`Failed to fetch Wikipedia top pages: ${res.status}`);
|
|
41
|
+
}
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
const items = (data.items ?? []).filter((it) => !NON_ARTICLE_TITLE.test(it.article));
|
|
44
|
+
return items.slice(0, limit).map((it, i) => ({
|
|
45
|
+
rank: it.rank ?? i + 1,
|
|
46
|
+
article: decodeURIComponent(it.article.replace(/_/g, " ")),
|
|
47
|
+
views: it.views
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
async function searchNewsForTopic(apiKey, query, limit = ARTICLES_PER_TOPIC, fetchImpl = fetch) {
|
|
51
|
+
const url = new URL("https://api.thenewsapi.com/v1/news/search");
|
|
52
|
+
url.searchParams.set("api_token", apiKey);
|
|
53
|
+
url.searchParams.set("q", query);
|
|
54
|
+
url.searchParams.set("language", "en");
|
|
55
|
+
url.searchParams.set("limit", String(limit));
|
|
56
|
+
const res = await fetchImpl(url.toString());
|
|
57
|
+
if (!res.ok) return [];
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
return data.data ?? [];
|
|
60
|
+
}
|
|
61
|
+
function toArticlePayload(articles) {
|
|
62
|
+
return articles.map((a) => ({
|
|
63
|
+
title: a.title ?? "",
|
|
64
|
+
url: a.url,
|
|
65
|
+
source: a.source,
|
|
66
|
+
published_at: a.published_at,
|
|
67
|
+
image_url: a.image_url
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
async function getTrendingTopics(options) {
|
|
71
|
+
const { apiKey, fetchImpl = fetch } = options;
|
|
72
|
+
const limit = parseTopicLimit(options.limit);
|
|
73
|
+
const date = options.date ?? defaultPageviewsDate();
|
|
74
|
+
const candidates = await fetchWikipediaTopPages(
|
|
75
|
+
date,
|
|
76
|
+
Math.min(MAX_TOPIC_LIMIT, limit * 2),
|
|
77
|
+
fetchImpl
|
|
78
|
+
);
|
|
79
|
+
const results = [];
|
|
80
|
+
for (let i = 0; i < candidates.length && results.length < limit; i += SEARCH_CONCURRENCY) {
|
|
81
|
+
const batch = candidates.slice(i, i + SEARCH_CONCURRENCY);
|
|
82
|
+
const searched = await Promise.all(
|
|
83
|
+
batch.map(async (entry) => ({
|
|
84
|
+
entry,
|
|
85
|
+
articles: await searchNewsForTopic(apiKey, entry.article, ARTICLES_PER_TOPIC, fetchImpl)
|
|
86
|
+
}))
|
|
87
|
+
);
|
|
88
|
+
for (const { entry, articles } of searched) {
|
|
89
|
+
if (!articles.length) continue;
|
|
90
|
+
results.push({
|
|
91
|
+
topic: entry.article,
|
|
92
|
+
wiki_rank: entry.rank,
|
|
93
|
+
wiki_views: entry.views,
|
|
94
|
+
news_count: articles.length,
|
|
95
|
+
articles: toArticlePayload(articles)
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
results.sort((a, b) => (a.wiki_rank ?? 999) - (b.wiki_rank ?? 999));
|
|
100
|
+
return {
|
|
101
|
+
source: "wikipedia_daily_top",
|
|
102
|
+
date: formatDate(date),
|
|
103
|
+
topics: results.slice(0, limit)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async function getTopicHeadlines(topic, options) {
|
|
107
|
+
const articles = await searchNewsForTopic(
|
|
108
|
+
options.apiKey,
|
|
109
|
+
topic,
|
|
110
|
+
ARTICLES_FOR_SINGLE_TOPIC,
|
|
111
|
+
options.fetchImpl ?? fetch
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
topic,
|
|
115
|
+
news_count: articles.length,
|
|
116
|
+
articles: toArticlePayload(articles)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function handleTrendingNewsRequest(request, options) {
|
|
120
|
+
const { apiKey, fetchImpl } = options;
|
|
121
|
+
if (!apiKey) {
|
|
122
|
+
return jsonResponse({ error: "THENEWSAPI_API_KEY is not configured" }, 500);
|
|
123
|
+
}
|
|
124
|
+
const params = new URL(request.url).searchParams;
|
|
125
|
+
const topic = params.get("topic");
|
|
126
|
+
try {
|
|
127
|
+
if (topic) {
|
|
128
|
+
return jsonResponse(await getTopicHeadlines(topic, { apiKey, fetchImpl }));
|
|
129
|
+
}
|
|
130
|
+
return jsonResponse(
|
|
131
|
+
await getTrendingTopics({
|
|
132
|
+
apiKey,
|
|
133
|
+
limit: parseTopicLimit(params.get("limit")),
|
|
134
|
+
fetchImpl
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return jsonResponse(
|
|
139
|
+
{
|
|
140
|
+
error: topic ? "Failed to fetch news for topic" : "Failed to fetch Wikipedia trends",
|
|
141
|
+
details: e instanceof Error ? e.message : String(e)
|
|
142
|
+
},
|
|
143
|
+
500
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export {
|
|
148
|
+
DEFAULT_TOPIC_LIMIT,
|
|
149
|
+
MAX_TOPIC_LIMIT,
|
|
150
|
+
fetchWikipediaTopPages,
|
|
151
|
+
getTopicHeadlines,
|
|
152
|
+
getTrendingTopics,
|
|
153
|
+
handleTrendingNewsRequest,
|
|
154
|
+
parseTopicLimit,
|
|
155
|
+
searchNewsForTopic
|
|
156
|
+
};
|
|
157
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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/** 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\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/** 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 [year, month, day] = formatDate(date).split('-');\n const url = `https://wikimedia.org/api/rest_v1/metrics/pageviews/top-per-article/en.wikipedia.org/all-access/all-agents/${year}/${month}/${day}`;\n\n const res = await fetchImpl(url, { headers: { 'User-Agent': 'trending-news-api' } });\n if (!res.ok) {\n throw new Error(`Failed to fetch Wikipedia top pages: ${res.status}`);\n }\n\n const data = (await res.json()) as {\n items?: Array<{ article: string; views: number; rank?: number }>;\n };\n\n const items = (data.items ?? []).filter((it) => !NON_ARTICLE_TITLE.test(it.article));\n\n return items.slice(0, limit).map((it, i) => ({\n rank: it.rank ?? i + 1,\n article: decodeURIComponent(it.article.replace(/_/g, ' ')),\n views: it.views,\n }));\n}\n\n/**\n * Search news for a given query using The News API (thenewsapi.com). A failed\n * lookup yields no articles rather than throwing: one topic the API dislikes\n * shouldn't cost the whole trending list.\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/search');\n url.searchParams.set('api_token', apiKey);\n url.searchParams.set('q', query);\n url.searchParams.set('language', 'en');\n url.searchParams.set('limit', String(limit));\n\n const res = await fetchImpl(url.toString());\n if (!res.ok) return [];\n\n const data = (await res.json()) as { data?: NewsApiArticle[] };\n return data.data ?? [];\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 for (let i = 0; i < candidates.length && results.length < limit; i += SEARCH_CONCURRENCY) {\n const batch = candidates.slice(i, i + SEARCH_CONCURRENCY);\n const searched = await Promise.all(\n batch.map(async (entry) => ({\n entry,\n articles: await searchNewsForTopic(apiKey, entry.article, ARTICLES_PER_TOPIC, fetchImpl),\n }))\n );\n\n for (const { entry, articles } of searched) {\n if (!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 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/** 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 both routes the client speaks:\n *\n * - `GET /` — the daily trending list (`?limit=` topics, default 25).\n * - `GET /?topic=…` — headlines for one topic.\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: 'THENEWSAPI_API_KEY is not configured' }, 500);\n }\n\n const params = new URL(request.url).searchParams;\n const topic = params.get('topic');\n\n try {\n if (topic) {\n return jsonResponse(await getTopicHeadlines(topic, { 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 return jsonResponse(\n {\n error: topic ? 'Failed to fetch news for topic' : '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.
|
|
3
|
+
"version": "0.1.41",
|
|
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",
|
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
"types": "./dist/index.d.ts",
|
|
26
26
|
"import": "./dist/index.js",
|
|
27
27
|
"require": "./dist/index.cjs"
|
|
28
|
+
},
|
|
29
|
+
"./server": {
|
|
30
|
+
"types": "./dist/server/index.d.ts",
|
|
31
|
+
"import": "./dist/server/index.js",
|
|
32
|
+
"require": "./dist/server/index.cjs"
|
|
28
33
|
}
|
|
29
34
|
},
|
|
30
35
|
"files": [
|