memory-eternal 0.18.1 → 0.20.0

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/lib/db.js CHANGED
@@ -373,8 +373,12 @@ export function checkMainStoreInvariant(root) {
373
373
  * @returns {'pending' | 'approved'}
374
374
  */
375
375
  export function enforceAudit(root, kind, submittedBy, fallback = 'pending') {
376
- // 核心记忆必须人工审核:无论 auditMode / 免审名单如何配置,写入时一律待审。
376
+ // 核心记忆 / 技能卡必须人工审核:无论 auditMode / 免审名单如何配置,写入时一律待审。
377
+ // 技能卡由「实际安装的技能」系统生成(submittedBy=skill-sync),不是 agent 沉淀,
378
+ // 但仍要人确认后才进主库 —— 否则技能定义里的任意内容会绕过审核直接注入召回。
377
379
  if (String(kind) === 'core') return 'pending'
380
+ // 技能卡同理:由系统从实际技能生成,但不是 agent 沉淀,仍须人工确认。
381
+ if (String(kind) === 'skill') return 'pending'
378
382
  const db = getDb(root)
379
383
  const get = (key) => { try { return db.prepare('SELECT value FROM config WHERE key = ?').get(key)?.value } catch { return null } }
380
384
  const mode = get('auditMode')
package/lib/mcp.js CHANGED
@@ -26,7 +26,8 @@ export const MCP_TOOLS = [
26
26
  name: 'memory_recall',
27
27
  description:
28
28
  '从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
29
- '或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。' +
29
+ '或领域知识时调用;返回最相关的卡片摘要。也会命中已批准的技能卡:需要某技能时先查这里,再用 skill 工具加载同名技能。' +
30
+ '用 query 描述要找的内容,支持中文整词与字符片段检索。' +
30
31
  '需要 API key / token / 令牌等**凭证**时也先查这里(查「密钥 目录」):记忆里存的是目录' +
31
32
  '(名字、存放位置、取用命令),**值不在记忆库里**,请按卡片给的本机命令取用,不要直接读凭据文件。',
32
33
  inputSchema: {
@@ -91,7 +92,9 @@ export async function callTool(name, args, vaultRoot, meta = {}) {
91
92
  const lines = hits.map((h, i) => {
92
93
  const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : ''
93
94
  const snippet = String(h.summary || '').replace(/\s+/g, ' ').trim().slice(0, 130)
94
- return `### ${i + 1}. ${h.title}${tags}\n路径:${h.path}\n${snippet}`
95
+ const skillBadge = h.kind === 'skill' ? ' · 🧩 技能卡' : ''
96
+ const skillHint = h.kind === 'skill' ? `\n技能:${h.title}(需要时用 skill 工具加载同名技能)` : ''
97
+ return `### ${i + 1}. ${h.title}${tags}${skillBadge}\n路径:${h.path}\n${snippet}${skillHint}`
95
98
  })
96
99
  const out = `从记忆核心检索到 ${hits.length} 条相关卡片:\n\n${lines.join('\n\n')}`
97
100
  recordLedger(vaultRoot, { kind: 'recall', results: hits.length, chars: out.length, note: query.slice(0, 120) })
@@ -0,0 +1,444 @@
1
+ // 记忆核心 · 真实 AI 资讯聚合(RSS / Atom + Hacker News)
2
+ //
3
+ // 边界:
4
+ // - 只存标题、摘要、来源、链接、时间、热度;不转载全文,阅读原文跳外链。
5
+ // - 固定白名单源,不接受用户 URL,避免把插件变成任意 URL 抓取器。
6
+ // - 缓存到 <vault>/news/.feed-cache.json;页面打开/手动刷新时按 TTL 拉取。
7
+ // - 与知识卡、审核队列、知识星图完全隔离。
8
+ import fs from 'node:fs/promises'
9
+ import path from 'node:path'
10
+
11
+ const CACHE_TTL_MS = 20 * 60 * 1000
12
+ const ARCHIVE_TTL_MS = 6 * 60 * 60 * 1000
13
+ const FETCH_TIMEOUT_MS = 12000
14
+ const DAY = 86400000
15
+ const QBITAI_ARCHIVE_PAGES = 24
16
+
17
+ /** 判断一条新闻是否与 AI 相关(中文综合科技源需要过滤)。 */
18
+ const AI_RE = /(人工智能|AI|大模型|模型|LLM|GPT|Claude|Gemini|Llama|Qwen|DeepSeek|OpenAI|Anthropic|机器学习|深度学习|神经网络|算力|芯片|英伟达|NVIDIA|智能体|Agent|多模态|具身智能|机器人|自动驾驶|AIGC|生成式|文心|通义|豆包|Kimi|智谱|月之暗面|商汤|科大讯飞|昇腾|寒武纪|数据中心|推理|训练)/i
19
+
20
+ /** 资讯类型(前端按这些 id 做筛选)。 */
21
+ export const NEWS_TYPES = [
22
+ { id: 'model', label: '模型' },
23
+ { id: 'product', label: '产品' },
24
+ { id: 'research', label: '研究' },
25
+ { id: 'opensource', label: '开源' },
26
+ { id: 'industry', label: '行业' },
27
+ { id: 'policy', label: '政策' },
28
+ { id: 'other', label: '其他' },
29
+ ]
30
+ const TYPE_IDS = new Set(NEWS_TYPES.map((x) => x.id))
31
+
32
+ /** 固定资讯源白名单。中文源在前,默认给中文用户看中文。 */
33
+ export const NEWS_SOURCES = [
34
+ { id: 'qbitai', name: '量子位', url: 'https://www.qbitai.com/feed', home: 'https://www.qbitai.com/', weight: 5, kind: 'rss', lang: 'zh' },
35
+ { id: 'leiphone', name: '雷\u950b网', url: 'https://www.leiphone.com/feed', home: 'https://www.leiphone.com/', weight: 4, kind: 'rss', lang: 'zh', aiOnly: true },
36
+ { id: 'tmtpost', name: '钛媒体', url: 'https://www.tmtpost.com/feed', home: 'https://www.tmtpost.com/', weight: 4, kind: 'rss', lang: 'zh', aiOnly: true },
37
+ { id: 'infoq', name: 'InfoQ 中文', url: 'https://www.infoq.cn/feed', home: 'https://www.infoq.cn/', weight: 3, kind: 'rss', lang: 'zh', aiOnly: true },
38
+ { id: 'techcrunch', name: 'TechCrunch AI', url: 'https://techcrunch.com/category/artificial-intelligence/feed/', home: 'https://techcrunch.com/', weight: 4, kind: 'rss', lang: 'en' },
39
+ { id: 'verge', name: 'The Verge AI', url: 'https://www.theverge.com/rss/ai-artificial-intelligence/index.xml', home: 'https://www.theverge.com/', weight: 4, kind: 'rss', lang: 'en' },
40
+ { id: 'mittr', name: 'MIT Technology Review', url: 'https://www.technologyreview.com/topic/artificial-intelligence/feed', home: 'https://www.technologyreview.com/', weight: 4, kind: 'rss', lang: 'en' },
41
+ { id: 'deepmind', name: 'Google DeepMind', url: 'https://deepmind.google/blog/rss.xml', home: 'https://deepmind.google/', weight: 5, kind: 'rss', lang: 'en' },
42
+ { id: 'hn', name: 'Hacker News', home: 'https://news.ycombinator.com/', weight: 3, kind: 'hn', lang: 'en' },
43
+ ]
44
+
45
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
46
+
47
+ function decodeEntities(s) {
48
+ return String(s || '')
49
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
50
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
51
+ .replace(/&([a-z]+);/gi, (m, n) => ENTITIES[n.toLowerCase()] ?? m)
52
+ }
53
+
54
+ function stripHtml(s) {
55
+ // 先解码再剥标签:很多 RSS 的 description 是 &lt;p&gt; 转义过的 HTML。
56
+ const decoded = decodeEntities(String(s || ''))
57
+ return decodeEntities(decoded
58
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
59
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
60
+ .replace(/<[^>]+>/g, ' '))
61
+ .replace(/\s+/g, ' ')
62
+ .trim()
63
+ }
64
+
65
+ function cleanSummary(raw) {
66
+ const s = stripHtml(raw).replace(/^[>»\s]+|[>»\s]+$/g, '').trim()
67
+ if (!s || /^(点击查看原文|阅读原文|查看全文|Read more|Continue reading)$/i.test(s)) return ''
68
+ return s.slice(0, 420)
69
+ }
70
+
71
+ function unwrapCdata(s) {
72
+ const t = String(s || '').trim()
73
+ const m = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(t)
74
+ return m ? m[1] : t
75
+ }
76
+
77
+ function tagText(xml, name) {
78
+ const re = new RegExp('<' + name + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/' + name + '>', 'i')
79
+ const m = re.exec(xml)
80
+ return m ? unwrapCdata(m[1]).trim() : ''
81
+ }
82
+
83
+ function atomLink(xml) {
84
+ const links = [...xml.matchAll(/<link\b([^>]*)\/?>(?:<\/link>)?/gi)].map((m) => m[1])
85
+ for (const attrs of links) {
86
+ const rel = (/rel=["']([^"']+)["']/i.exec(attrs) || [])[1] || 'alternate'
87
+ const href = (/href=["']([^"']+)["']/i.exec(attrs) || [])[1]
88
+ if (href && (rel === 'alternate' || rel === 'self')) return href
89
+ }
90
+ return ''
91
+ }
92
+
93
+ function detectLang(text) { return /[\u4e00-\u9fff]/.test(String(text || '')) ? 'zh' : 'en' }
94
+
95
+ /** 校验缩略图 URL:只允许 http(s) 外链,拒绝 svg/gif 与超长地址。 */
96
+ export function sanitizeImageUrl(raw) {
97
+ try {
98
+ const u = new URL(decodeEntities(String(raw || '').trim()))
99
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''
100
+ if (/\.(svg|gif)$/i.test(u.pathname)) return ''
101
+ if (u.href.length > 900) return ''
102
+ return u.href
103
+ } catch { return '' }
104
+ }
105
+
106
+ /** 从条目里挑一张缩略图:media:content / media:thumbnail / enclosure / 正文首图。 */
107
+ export function pickFeedImage(block, rawHtml = '') {
108
+ const candidates = []
109
+ // 很多 RSS 的 description 是转义过的 HTML(&lt;img …&gt;),先解码再找首图。
110
+ const html = decodeEntities(String(rawHtml || ''))
111
+ for (const m of String(block || '').matchAll(/<media:content\b[^>]*\burl=["']([^"']+)["']/gi)) candidates.push(m[1])
112
+ for (const m of String(block || '').matchAll(/<media:thumbnail\b[^>]*\burl=["']([^"']+)["']/gi)) candidates.push(m[1])
113
+ for (const m of String(block || '').matchAll(/<enclosure\b[^>]*\burl=["']([^"']+)["']/gi)) candidates.push(m[1])
114
+ for (const m of html.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi)) candidates.push(m[1])
115
+ for (const raw of candidates) {
116
+ const url = sanitizeImageUrl(raw)
117
+ if (url) return url
118
+ }
119
+ return ''
120
+ }
121
+
122
+ function parseDate(s) {
123
+ const t = String(s || '').trim()
124
+ if (!t) return 0
125
+ const n = Date.parse(t)
126
+ return Number.isFinite(n) ? n : 0
127
+ }
128
+
129
+ /** 极简 RSS / Atom 解析(只取列表需要的字段)。 */
130
+ export function parseFeedXml(xml, source = {}) {
131
+ const text = String(xml || '')
132
+ const chunks = [...text.matchAll(/<item\b[\s\S]*?<\/item>/gi)].map((m) => m[0])
133
+ const atoms = [...text.matchAll(/<entry\b[\s\S]*?<\/entry>/gi)].map((m) => m[0])
134
+ const blocks = chunks.length ? chunks : atoms
135
+ const atom = !chunks.length && atoms.length > 0
136
+ const items = []
137
+ for (const block of blocks) {
138
+ const title = stripHtml(tagText(block, 'title'))
139
+ let link = ''
140
+ if (atom) link = atomLink(block) || tagText(block, 'id')
141
+ else link = tagText(block, 'link') || tagText(block, 'guid')
142
+ if (!title || !link) continue
143
+ const rawSummary = tagText(block, 'description') || tagText(block, 'summary') || tagText(block, 'content:encoded') || tagText(block, 'content')
144
+ const summary = cleanSummary(rawSummary)
145
+ const published = parseDate(tagText(block, 'pubDate') || tagText(block, 'published') || tagText(block, 'updated') || tagText(block, 'dc:date'))
146
+ const categories = [...block.matchAll(/<category\b[^>]*>([\s\S]*?)<\/category>/gi)].map((m) => stripHtml(m[1])).filter(Boolean)
147
+ items.push({
148
+ title,
149
+ url: link,
150
+ summary,
151
+ image: pickFeedImage(block, rawSummary),
152
+ sourceId: source.id || '',
153
+ source: source.name || '',
154
+ sourceWeight: Number(source.weight) || 1,
155
+ lang: source.lang || detectLang(title + ' ' + summary),
156
+ publishedAt: published || Date.now(),
157
+ points: 0,
158
+ comments: 0,
159
+ type: classifyNews(title, summary + ' ' + categories.join(' ')),
160
+ })
161
+ }
162
+ return items
163
+ }
164
+
165
+ /** 关键词分类;无法判断时归 other。 */
166
+ export function classifyNews(title, text = '') {
167
+ const s = String(title || '') + '\n' + String(text || '')
168
+ const rules = {
169
+ model: /(大模型|模型|LLM|GPT|Claude|Gemini|Llama|Qwen|DeepSeek|Mistral|Grok|o1|o3|多模态|MoE|推理模型|参数|权重|fine-?tune|微调)/gi,
170
+ product: /(产品|发布|上线|功能|应用|工具|平台|API|Agent|助手|插件|SDK|Copilot|浏览器|手机|硬件)/gi,
171
+ research: /(论文|研究|实验|基准|benchmark|SOTA|成果|科学家|Nature|Science|arXiv|数据集|评测)/gi,
172
+ opensource: /(开源|open[ -]?source|GitHub|仓库|权重开放|Apache|MIT 许可|Hugging Face)/gi,
173
+ industry: /(融资|收购|合作|投资|营收|市场|公司|裁员|IPO|估值|芯片|算力|数据中心|英伟达|NVIDIA)/gi,
174
+ policy: /(监管|法案|政策|合规|安全|治理|版权|诉讼|禁令|隐私|欧盟|白宫|政府)/gi,
175
+ }
176
+ let best = 'other'
177
+ let bestScore = 0
178
+ for (const [id, re] of Object.entries(rules)) {
179
+ if (!TYPE_IDS.has(id)) continue
180
+ const score = (s.match(re) || []).length
181
+ if (score > bestScore) { best = id; bestScore = score }
182
+ }
183
+ return best
184
+ }
185
+
186
+ function canonicalUrl(raw) {
187
+ try {
188
+ const u = new URL(String(raw || ''))
189
+ u.hash = ''
190
+ for (const k of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'ref', 'source']) u.searchParams.delete(k)
191
+ return u.href.replace(/\/$/, '')
192
+ } catch { return String(raw || '') }
193
+ }
194
+
195
+ function fingerprint(item) {
196
+ return canonicalUrl(item.url) || (String(item.title || '').toLowerCase().replace(/\s+/g, ' ').slice(0, 160))
197
+ }
198
+
199
+ function sourceFor(id) { return NEWS_SOURCES.find((s) => s.id === id) || null }
200
+
201
+ async function fetchText(url, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
202
+ const ctl = new AbortController()
203
+ const timer = setTimeout(() => ctl.abort(), timeoutMs)
204
+ try {
205
+ const r = await fetch(url, {
206
+ signal: ctl.signal,
207
+ headers: { 'user-agent': 'memory-eternal/news-feed (+https://github.com/EternalNight996/memory-eternal)', accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, application/json;q=0.9, */*;q=0.5' },
208
+ })
209
+ if (!r.ok) throw new Error('HTTP ' + r.status)
210
+ return await r.text()
211
+ } finally { clearTimeout(timer) }
212
+ }
213
+
214
+ /** 抓取 Hacker News(日榜 + 月榜共用一次拉取)。 */
215
+ async function fetchHackerNews(sinceMs) {
216
+ const since = Math.floor(sinceMs / 1000)
217
+ const url = 'https://hn.algolia.com/api/v1/search?tags=story&query=AI&numericFilters=created_at_i>' + since + '&hitsPerPage=80'
218
+ const data = JSON.parse(await fetchText(url))
219
+ const hits = Array.isArray(data.hits) ? data.hits : []
220
+ return hits.map((h) => {
221
+ const title = String(h.title || h.story_title || '').trim()
222
+ const target = String(h.url || h.story_url || '').trim() || ('https://news.ycombinator.com/item?id=' + h.objectID)
223
+ if (!title) return null
224
+ return {
225
+ id: 'hn-' + h.objectID,
226
+ title,
227
+ url: target,
228
+ // HN 的 story_text 常包含 HTML / 链接;只保留纯文本摘要,避免把 URL 当正文。
229
+ summary: cleanSummary(h.story_text || ''),
230
+ sourceId: 'hn',
231
+ source: 'Hacker News',
232
+ sourceWeight: 3,
233
+ publishedAt: parseDate(h.created_at) || (Number(h.created_at_i) * 1000) || Date.now(),
234
+ points: Number(h.points) || 0,
235
+ comments: Number(h.num_comments) || 0,
236
+ type: classifyNews(title, h.story_text || ''),
237
+ lang: 'en',
238
+ hnId: String(h.objectID || ''),
239
+ }
240
+ }).filter(Boolean)
241
+ }
242
+
243
+ async function mapLimit(list, limit, fn) {
244
+ const out = new Array(list.length)
245
+ let cursor = 0
246
+ async function worker() {
247
+ while (cursor < list.length) {
248
+ const i = cursor++
249
+ out[i] = await fn(list[i], i)
250
+ }
251
+ }
252
+ await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker))
253
+ return out
254
+ }
255
+
256
+ /** 量子位 WordPress feed 支持 ?paged=N;抓多页补足“本月”中文归档。 */
257
+ async function fetchQbitaiArchive(pages = QBITAI_ARCHIVE_PAGES) {
258
+ const source = sourceFor('qbitai')
259
+ if (!source) return []
260
+ const urls = Array.from({ length: pages }, (_, i) => source.url + '?paged=' + (i + 1))
261
+ const results = await mapLimit(urls, 5, async (url) => {
262
+ try { return parseFeedXml(await fetchText(url), source) } catch { return [] }
263
+ })
264
+ return results.flat()
265
+ }
266
+
267
+ async function fetchRssSource(source, { archive = false } = {}) {
268
+ try {
269
+ if (source.id === 'qbitai' && archive) return await fetchQbitaiArchive()
270
+ const xml = await fetchText(source.url)
271
+ let items = parseFeedXml(xml, source)
272
+ if (source.aiOnly) items = items.filter((x) => AI_RE.test(x.title + ' ' + x.summary))
273
+ return items
274
+ } catch (e) {
275
+ throw new Error(source.name + ':' + String((e && e.message) || e))
276
+ }
277
+ }
278
+
279
+ async function readCache(root) {
280
+ try {
281
+ const raw = await fs.readFile(path.join(root, 'news', '.feed-cache.json'), 'utf8')
282
+ const data = JSON.parse(raw)
283
+ return data && Array.isArray(data.items) ? data : null
284
+ } catch { return null }
285
+ }
286
+
287
+ async function writeCache(root, data) {
288
+ await fs.mkdir(path.join(root, 'news'), { recursive: true })
289
+ await fs.writeFile(path.join(root, 'news', '.feed-cache.json'), JSON.stringify(data, null, 2) + '\n', 'utf8')
290
+ }
291
+
292
+ function mergeItems(...lists) {
293
+ const byKey = new Map()
294
+ for (const item of lists.flat()) {
295
+ if (!item) continue
296
+ const key = fingerprint(item)
297
+ if (!key) continue
298
+ const prev = byKey.get(key)
299
+ if (!prev) { byKey.set(key, item); continue }
300
+ // 同一新闻多源命中:保留信息更全/热度更高的一条,并记录 alsoIn
301
+ const better = (item.points || 0) > (prev.points || 0) || (item.summary || '').length > (prev.summary || '').length ? item : prev
302
+ better.alsoIn = [...new Set([...(prev.alsoIn || []), ...(item.alsoIn || []), prev.source, item.source].filter((x) => x && x !== better.source))]
303
+ byKey.set(key, better)
304
+ }
305
+ return [...byKey.values()].sort((a, b) => (b.publishedAt || 0) - (a.publishedAt || 0))
306
+ }
307
+
308
+ /** 抓取全部来源;archive=true 时额外拉量子位多页,补足本月中文归档。 */
309
+ export async function fetchNewsFeed({ now = Date.now(), archive = false } = {}) {
310
+ const errors = []
311
+ const items = []
312
+ const rss = NEWS_SOURCES.filter((s) => s.kind === 'rss')
313
+ const results = await Promise.all(rss.map(async (source) => {
314
+ try {
315
+ return { source, items: await fetchRssSource(source, { archive: archive && source.id === 'qbitai' }) }
316
+ } catch (e) {
317
+ errors.push(String((e && e.message) || e))
318
+ return { source, items: [] }
319
+ }
320
+ }))
321
+ for (const r of results) items.push(...r.items)
322
+ try {
323
+ const hn = await fetchHackerNews(now - 30 * DAY)
324
+ items.push(...hn)
325
+ } catch (e) { errors.push('Hacker News:' + String((e && e.message) || e)) }
326
+ return { items: mergeItems(items).slice(0, 1500), errors, fetchedAt: now }
327
+ }
328
+
329
+ let inflight = null
330
+ /** 读取缓存;过期则刷新。archive=true 时补拉量子位多页(6 小时一次)。 */
331
+ export async function refreshNewsFeed(root, { force = false, archive = false, now = Date.now() } = {}) {
332
+ const cache = await readCache(root)
333
+ const freshEnough = !!cache && !!cache.items.length && now - Number(cache.fetchedAt || 0) < CACHE_TTL_MS
334
+ const needArchive = archive && (!cache || !cache.archiveFetchedAt || now - Number(cache.archiveFetchedAt) > ARCHIVE_TTL_MS)
335
+ if (!force && freshEnough && !needArchive) return cache
336
+ if (inflight) return inflight
337
+ inflight = (async () => {
338
+ try {
339
+ const fetched = await fetchNewsFeed({ now, archive: needArchive })
340
+ const merged = mergeItems(cache?.items || [], fetched.items).slice(0, 1500)
341
+ const next = {
342
+ items: merged,
343
+ fetchedAt: now,
344
+ archiveFetchedAt: needArchive ? now : Number(cache?.archiveFetchedAt || 0),
345
+ errors: fetched.errors || [],
346
+ }
347
+ await writeCache(root, next)
348
+ return next
349
+ } catch (e) {
350
+ if (cache) return { ...cache, stale: true, errors: [...(cache.errors || []), String((e && e.message) || e)] }
351
+ throw e
352
+ } finally { inflight = null }
353
+ })()
354
+ return inflight
355
+ }
356
+
357
+ /** 按天取样:避免「今天 47 条」把过去 30 天全部挤掉(月归档用)。 */
358
+ function sampleByDay(list, { perDay = 4, cap = 120 } = {}) {
359
+ const seen = new Map()
360
+ const picked = []
361
+ for (const item of list) {
362
+ const day = new Date(item.publishedAt || 0).toISOString().slice(0, 10)
363
+ const n = seen.get(day) || 0
364
+ if (n >= perDay) continue
365
+ seen.set(day, n + 1)
366
+ picked.push(item)
367
+ if (picked.length >= cap) break
368
+ }
369
+ return picked
370
+ }
371
+
372
+ /**
373
+ * 查询资讯。
374
+ * @param {object} opts
375
+ * @param {'today'|'month'} [opts.period]
376
+ * @param {string} [opts.type] 模型/产品/研究/开源/行业/政策/其他
377
+ * @param {string} [opts.q]
378
+ * @param {boolean} [opts.force] 强制刷新
379
+ */
380
+ export async function getNewsFeed(root, { period = 'today', type = '', q = '', lang = 'zh', force = false, now = Date.now() } = {}) {
381
+ // lang 口径统一:'' / 'all' / 'any' / '*' 一律表示「不按语言过滤」;zh / en 才是显式过滤。
382
+ // 这样客户端只拉一次 lang=all 就能同时拿到中英条目,分类/搜索/语言全部本地完成。
383
+ const rawLang = lang == null ? '' : String(lang).trim().toLowerCase()
384
+ const anyLang = rawLang === '' || rawLang === 'all' || rawLang === 'any' || rawLang === '*'
385
+ const langFilter = anyLang ? '' : rawLang
386
+ const needArchive = period === 'month' && (anyLang || langFilter === 'zh')
387
+ const cache = await refreshNewsFeed(root, { force, archive: needArchive, now })
388
+ const monthStart = now - 30 * DAY
389
+ const dayStart = now - 2 * DAY
390
+ const periodStart = period === 'month' ? monthStart : dayStart
391
+ const all = Array.isArray(cache.items) ? cache.items : []
392
+ const matchesLang = (x) => !langFilter || x.lang === langFilter
393
+ let items = all.filter((x) => (x.publishedAt || 0) >= periodStart && matchesLang(x))
394
+ if (type && TYPE_IDS.has(type)) items = items.filter((x) => x.type === type)
395
+ if (q) {
396
+ const terms = String(q).trim().toLowerCase().split(/\s+/).filter(Boolean)
397
+ items = items.filter((x) => {
398
+ const hay = [x.title, x.summary, x.source, x.type].join('\n').toLowerCase()
399
+ return terms.every((t) => hay.includes(t))
400
+ })
401
+ }
402
+ const byTime = (a, b) => (b.publishedAt || 0) - (a.publishedAt || 0)
403
+ const byHeat = (a, b) => (b.points || 0) - (a.points || 0) || (b.comments || 0) - (a.comments || 0) || byTime(a, b)
404
+ // 月榜是否成立只看「这批数据里有没有真实热度分数」(HN points),不看请求语言:
405
+ // 中文源没有公开热度 → 自动退回按时间归档,不伪造排名。
406
+ const ranking = period === 'month' && items.some((x) => (x.points || 0) > 0)
407
+ if (period === 'month') {
408
+ if (ranking) {
409
+ items = items.slice().sort(byHeat)
410
+ const hot = items.filter((x) => (x.points || 0) > 0)
411
+ // 不限语言(混合视图)时把没有热度的中文条目按天取样补在后面,保证「不限」中英都有;
412
+ // 显式 lang 视图只保留有真实热度的条目(与旧行为一致)。
413
+ const archived = anyLang ? sampleByDay(items.filter((x) => (x.points || 0) <= 0)) : []
414
+ items = [...hot, ...archived].slice(0, 160)
415
+ let rank = 0
416
+ items = items.map((x) => {
417
+ const heat = (x.points || 0) > 0 ? (x.points || 0) + (x.comments || 0) * 2 : 0
418
+ return { ...x, rank: heat > 0 ? ++rank : 0, heat }
419
+ })
420
+ } else {
421
+ // 中文 / 无热度月归档:按天取样,避免「今天 47 条」把过去 30 天全部挤掉,且不伪造排名。
422
+ items = sampleByDay(items.slice().sort(byTime))
423
+ }
424
+ } else {
425
+ items = items.slice().sort(byTime).slice(0, 80)
426
+ }
427
+ const counts = {}
428
+ for (const x of all) {
429
+ if ((x.publishedAt || 0) >= periodStart && matchesLang(x)) counts[x.type] = (counts[x.type] || 0) + 1
430
+ }
431
+ return {
432
+ ok: true,
433
+ period,
434
+ lang: langFilter,
435
+ ranking,
436
+ items,
437
+ types: NEWS_TYPES,
438
+ counts,
439
+ fetchedAt: Number(cache.fetchedAt) || 0,
440
+ stale: !!cache.stale,
441
+ errors: cache.errors || [],
442
+ sources: NEWS_SOURCES.map((s) => ({ id: s.id, name: s.name, home: s.home, lang: s.lang })),
443
+ }
444
+ }