memory-eternal 0.21.0 → 0.23.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.
@@ -0,0 +1,523 @@
1
+ // 记忆核心 · 新闻「关注」(Follow)引擎
2
+ //
3
+ // 解决什么问题:原新闻模块是「编辑型推送」——固定 9 个源,搜索只在已拉到的 80 条缓存里做子串匹配。
4
+ // 于是「搜刘慈欣」= 0 条(不是搜不到,是根本没去搜),「帮我推 github 最新热门」更无处安放。
5
+ //
6
+ // 这里把「一次搜索」升级成「持久关注」:
7
+ // 一句话 → 解析成 { query, domains } → 按域去抓 → 缓存 → 并回主 feed。
8
+ //
9
+ // 三条边界(与原模块一致,不能破):
10
+ // 1. **不做任意 URL 抓取器**:域只能来自 FOLLOW_DOMAINS 枚举,出站 URL 全部由常量拼装,
11
+ // 用户文本只会被 encodeURIComponent 进查询参数,绝不参与 host/path 选择。
12
+ // 2. 与知识卡、审核队列、星图完全隔离:只落 vault/news/follows.json。
13
+ // 3. 抓不到就如实报错(errors[]),不静默返回空 —— 「这个域没有」和「这个域没覆盖」是两回事。
14
+ import { promises as fs } from 'node:fs'
15
+ import os from 'node:os'
16
+ import path from 'node:path'
17
+ import { parseFeedXml, classifyNews } from './news-feed.js'
18
+
19
+ const FOLLOWS_FILE = 'follows.json'
20
+ const CACHE_TTL_MS = 30 * 60 * 1000
21
+ const FETCH_TIMEOUT_MS = 12000
22
+ const MAX_ITEMS_PER_FOLLOW = 40
23
+ const MAX_FOLLOWS = 20
24
+ const UA = 'memory-eternal/news-follows (+https://github.com/EternalNight996/memory-eternal)'
25
+
26
+ /** 可关注的域。新增域必须同时在这里登记,并且出站 URL 必须是常量模板。 */
27
+ export const FOLLOW_DOMAINS = [
28
+ { id: 'news', label: 'AI 资讯', hint: '现有 9 个白名单媒体(量子位 / 雷峰 / 钛媒体 / InfoQ / TC / Verge / MITTR / DeepMind / HN)' },
29
+ { id: 'dsh', label: 'DSH 插件', hint: '官方插件清单(约 9 千个仓库,24 小时刷新;断网时回退本机快照)' },
30
+ { id: 'github', label: 'GitHub', hint: '按 star 排序的仓库(近 30 天创建)' },
31
+ { id: 'hn', label: 'Hacker News', hint: 'HN 全站全文检索' },
32
+ { id: 'arxiv', label: 'arXiv', hint: '论文检索(无需 API Key)' },
33
+ ]
34
+ const DOMAIN_IDS = new Set(FOLLOW_DOMAINS.map((d) => d.id))
35
+
36
+ /** 意图词:命中即认为用户想在那个域里看。只影响「选哪个域」,不影响 URL。 */
37
+ const DOMAIN_HINTS = [
38
+ // 放在 github 之前:「dsh 插件」不该被 github 的「仓库」类词抢走
39
+ // 注意:中文不能用 \b(JS 里 CJK 属 non-word,边界判断不可靠),直接匹配即可
40
+ { id: 'dsh', re: /(dsh|deepseek\s*harness|插件市场|插件)/i },
41
+ { id: 'github', re: /(github|仓库|开源项目|trending|热门项目|热库|repo(sitory)?)/i },
42
+ { id: 'arxiv', re: /(arxiv|论文|paper|文献|预印本)/i },
43
+ { id: 'hn', re: /(hacker\s*news|\bhn\b)/i },
44
+ ]
45
+
46
+ /** 清洗用的 /g 正则(注意:带 g 的 lastIndex 会游走,只能用于 replace,不能拿来做域判断)。 */
47
+ const CLEAN_DOMAIN_RES = [
48
+ /(dsh|deepseek\s*harness|插件市场|插件)/gi,
49
+ /(github|仓库|开源项目|trending|热门项目|热库|repositor(y|ies)|repo)/gi,
50
+ /(arxiv|论文|paper|文献|预印本)/gi,
51
+ /(hacker\s*news|\bhn\b)/gi,
52
+ ]
53
+
54
+ /** 指令性噪音词:从主题里剥掉,剩下的才是真正要搜的东西。 */
55
+ const NOISE_RE = /(帮我|请|麻烦|推送|订阅|关注|跟踪|最新|热门|热库|最热|每天|每日|定时|一下|一些|几个|有什么|有啥|给我|看看|关于|相关|新闻|消息|资讯|动态|更新|上的|里的|方面|讨论)/g
56
+ // 只剥句尾虚词/单字(「新」只在结尾时剥:新能源这类词不会被误伤)
57
+ const STOP_TAIL_RE = /[的了呢吧吗新]$/g
58
+
59
+ /**
60
+ * 已知媒体名 → sourceId。「量子位」「雷峰网」这类词不是主题词,
61
+ * 而是**来源限定**:留在 query 里会变成"标题必须含量子位"的荒谬检索。
62
+ */
63
+ const SOURCE_HINTS = [
64
+ { re: /(量子位|qbitai)/i, id: 'qbitai' },
65
+ { re: /(雷峰网|雷峰|leiphone)/i, id: 'leiphone' },
66
+ { re: /(钛媒体|tmtpost)/i, id: 'tmtpost' },
67
+ { re: /(infoq)/i, id: 'infoq' },
68
+ { re: /(techcrunch)/i, id: 'techcrunch' },
69
+ { re: /(the\s*verge|verge)/i, id: 'verge' },
70
+ { re: /(mit\s*technology\s*review|mittr)/i, id: 'mittr' },
71
+ { re: /(deepmind)/i, id: 'deepmind' },
72
+ ]
73
+
74
+ const SOURCE_IDS = new Set(SOURCE_HINTS.map((h) => h.id))
75
+
76
+ function nowMs() { return Date.now() }
77
+
78
+ function followsPath(root) { return path.join(String(root || ''), 'news', FOLLOWS_FILE) }
79
+
80
+ async function readFileJson(file, fallback) {
81
+ try { return JSON.parse(await fs.readFile(file, 'utf8')) } catch { return fallback }
82
+ }
83
+
84
+ async function writeFileJson(file, data) {
85
+ await fs.mkdir(path.dirname(file), { recursive: true })
86
+ const tmp = file + '.tmp'
87
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8')
88
+ await fs.rename(tmp, file)
89
+ }
90
+
91
+ /** 归一化用户输入:剥离指令词,留下主题。 */
92
+ export function cleanFollowQuery(text) {
93
+ let s = String(text || '').trim()
94
+ if (!s) return ''
95
+ for (const re of CLEAN_DOMAIN_RES) s = s.replace(re, ' ')
96
+ for (const h of SOURCE_HINTS) s = s.replace(h.re, ' ')
97
+ s = s.replace(NOISE_RE, ' ')
98
+ .replace(/[,。!?,.!?、;;::()()「」【】]/g, ' ')
99
+ .replace(/\s+/g, ' ')
100
+ .trim()
101
+ // 只剩虚词尾巴时清掉(「多模态的」→「多模态」)
102
+ s = s.replace(STOP_TAIL_RE, '').trim()
103
+ return s
104
+ }
105
+
106
+ /**
107
+ * 把一句话解析成关注意向(纯函数,便于单测)。
108
+ * 不做 LLM:规则可预测、零成本、失败也不会静默改域名。
109
+ * @param {string} text
110
+ * @returns {{ query: string, domains: string[], label: string, notes: string[] }}
111
+ */
112
+ export function parseFollowIntent(text) {
113
+ const raw = String(text || '').trim()
114
+ const notes = []
115
+ const domains = []
116
+ for (const hint of DOMAIN_HINTS) if (hint.re.test(raw)) domains.push(hint.id)
117
+ if (!domains.length) domains.push('news')
118
+ const query = cleanFollowQuery(raw)
119
+ if (!query && domains.length === 1 && domains[0] === 'news') {
120
+ notes.push('没有从这句话里识别出主题词;AI 资讯域需要一个关键词(例如「具身智能」)。')
121
+ }
122
+ if (domains.includes('news')) {
123
+ notes.push('「AI 资讯」只覆盖现有 9 个白名单媒体;人物类话题(如作家、演员)他们通常不报道。')
124
+ }
125
+ if (domains.includes('dsh')) {
126
+ notes.push('「DSH 插件」每 24 小时拉一次官方清单;拉不到时回退本机快照,并会标注来源。')
127
+ }
128
+ const sourceIds = SOURCE_HINTS.filter((h) => h.re.test(raw)).map((h) => h.id)
129
+ if (sourceIds.length && domains.includes('news')) {
130
+ notes.push('已按来源限定:' + sourceIds.join(' / '))
131
+ }
132
+ const label = query
133
+ || (domains.includes('dsh') ? 'DSH 最新插件'
134
+ : domains.includes('github') ? 'GitHub 热门'
135
+ : domains.includes('arxiv') ? 'arXiv 论文'
136
+ : domains.includes('hn') ? 'HN 讨论' : '关注')
137
+ return { query, domains: [...new Set(domains)], sourceIds: [...new Set(sourceIds)], label, notes }
138
+ }
139
+
140
+ function clampDomains(list) {
141
+ const out = (Array.isArray(list) ? list : []).map(String).filter((d) => DOMAIN_IDS.has(d))
142
+ return out.length ? [...new Set(out)] : ['news']
143
+ }
144
+
145
+ async function fetchText(url, accept = '') {
146
+ const ac = new AbortController()
147
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS)
148
+ try {
149
+ const r = await fetch(url, { signal: ac.signal, headers: { 'user-agent': UA, ...(accept ? { accept } : {}) } })
150
+ if (!r.ok) throw new Error('HTTP ' + r.status)
151
+ return await r.text()
152
+ } finally { clearTimeout(timer) }
153
+ }
154
+
155
+ function shape(extra) {
156
+ return {
157
+ id: extra.id || '',
158
+ title: String(extra.title || '').trim(),
159
+ url: String(extra.url || '').trim(),
160
+ summary: String(extra.summary || '').trim(),
161
+ image: extra.image || '',
162
+ source: extra.source || '',
163
+ sourceId: extra.sourceId || '',
164
+ sourceWeight: Number(extra.sourceWeight) || 1,
165
+ publishedAt: Number(extra.publishedAt) || nowMs(),
166
+ points: Number(extra.points) || 0,
167
+ comments: Number(extra.comments) || 0,
168
+ type: extra.type || 'other',
169
+ lang: extra.lang || 'zh',
170
+ followId: extra.followId || '',
171
+ followLabel: extra.followLabel || '',
172
+ }
173
+ }
174
+
175
+ // ── 域适配器 ─────────────────────────────────────────────────────────────
176
+ // 每个适配器的出站地址都是常量模板;用户输入只进 query 参数。
177
+
178
+ async function fetchGithub({ query, follow }) {
179
+ const since = new Date(nowMs() - 30 * 86400000).toISOString().slice(0, 10)
180
+ const q = query ? `${query} in:name,description,readme created:>${since}` : `created:>${since}`
181
+ const url = 'https://api.github.com/search/repositories?sort=stars&order=desc&per_page=' + MAX_ITEMS_PER_FOLLOW +
182
+ '&q=' + encodeURIComponent(q)
183
+ const data = JSON.parse(await fetchText(url, 'application/vnd.github+json'))
184
+ const items = Array.isArray(data.items) ? data.items : []
185
+ return items.map((repo) => shape({
186
+ id: 'gh-' + repo.id,
187
+ title: repo.full_name + (repo.description ? ' — ' + repo.description : ''),
188
+ url: repo.html_url,
189
+ summary: [repo.description, repo.language, (repo.topics || []).slice(0, 4).join(' / ')].filter(Boolean).join(' · '),
190
+ source: 'GitHub',
191
+ sourceId: 'github',
192
+ sourceWeight: 4,
193
+ publishedAt: Date.parse(repo.created_at) || nowMs(),
194
+ points: Number(repo.stargazers_count) || 0,
195
+ comments: Number(repo.forks_count) || 0,
196
+ type: 'opensource',
197
+ lang: 'en',
198
+ followId: follow.id,
199
+ followLabel: follow.label,
200
+ }))
201
+ }
202
+
203
+ async function fetchHn({ query, follow }) {
204
+ const since = Math.floor((nowMs() - 30 * 86400000) / 1000)
205
+ const url = 'https://hn.algolia.com/api/v1/search?tags=story&hitsPerPage=' + MAX_ITEMS_PER_FOLLOW +
206
+ '&numericFilters=created_at_i>' + since + (query ? '&query=' + encodeURIComponent(query) : '&query=AI')
207
+ const data = JSON.parse(await fetchText(url))
208
+ const hits = Array.isArray(data.hits) ? data.hits : []
209
+ return hits.map((h) => {
210
+ const title = String(h.title || h.story_title || '').trim()
211
+ if (!title) return null
212
+ return shape({
213
+ id: 'hn-' + h.objectID,
214
+ title,
215
+ url: String(h.url || h.story_url || '').trim() || ('https://news.ycombinator.com/item?id=' + h.objectID),
216
+ summary: String(h.story_text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300),
217
+ source: 'Hacker News',
218
+ sourceId: 'hn',
219
+ sourceWeight: 3,
220
+ publishedAt: (Number(h.created_at_i) * 1000) || Date.parse(h.created_at) || nowMs(),
221
+ points: Number(h.points) || 0,
222
+ comments: Number(h.num_comments) || 0,
223
+ type: classifyNews(title, h.story_text || ''),
224
+ lang: 'en',
225
+ followId: follow.id,
226
+ followLabel: follow.label,
227
+ })
228
+ }).filter(Boolean)
229
+ }
230
+
231
+ async function fetchArxiv({ query, follow }) {
232
+ const q = query || 'large language model'
233
+ const url = 'http://export.arxiv.org/api/query?start=0&max_results=' + MAX_ITEMS_PER_FOLLOW +
234
+ '&sortBy=submittedDate&sortOrder=descending&search_query=all:' + encodeURIComponent('"' + q + '"')
235
+ const xml = await fetchText(url)
236
+ const parsed = parseFeedXml(xml, { id: 'arxiv', name: 'arXiv', lang: 'en', weight: 4 })
237
+ return parsed.map((it) => shape({
238
+ ...it,
239
+ type: 'research',
240
+ followId: follow.id,
241
+ followLabel: follow.label,
242
+ }))
243
+ }
244
+
245
+ /** 「AI 资讯」域:只过滤**已经抓下来的**白名单内容,不再额外发请求。 */
246
+ async function fetchNewsDomain({ query, follow, root }) {
247
+ const sourceIds = Array.isArray(follow.sourceIds) ? follow.sourceIds : []
248
+ if (!query && !sourceIds.length) return []
249
+ const { getNewsFeed } = await import('./news-feed.js')
250
+ const r = await getNewsFeed(root, { period: 'month', lang: 'all', q: query })
251
+ let items = Array.isArray(r.items) ? r.items : []
252
+ // 来源限定:用户说「量子位」时,意思是"只看量子位",不是"标题里要有量子位三个字"。
253
+ if (sourceIds.length) items = items.filter((it) => sourceIds.includes(it.sourceId))
254
+ return items.slice(0, MAX_ITEMS_PER_FOLLOW).map((it) => shape({
255
+ ...it,
256
+ followId: follow.id,
257
+ followLabel: follow.label,
258
+ }))
259
+ }
260
+
261
+ /**
262
+ * 从 DSH 市场清单里挑插件(纯函数,便于单测)。
263
+ * 排序:仓库更新时间新 → star 多。与「最新插件」的直觉一致。
264
+ */
265
+ export function pickDshPlugins(repos, { query = '', maxItems = MAX_ITEMS_PER_FOLLOW } = {}) {
266
+ const q = String(query || '').trim().toLowerCase()
267
+ const list = (Array.isArray(repos) ? repos : []).filter((r) => r && r.full_name)
268
+ const matched = q
269
+ ? list.filter((r) => (String(r.name || '') + ' ' + String(r.description || '') + ' ' + (Array.isArray(r.topics) ? r.topics.join(' ') : '')).toLowerCase().includes(q))
270
+ : list
271
+ // 明确标了 non-plugin 的排后面(仍保留:有些"非插件"其实是 bundle/工具)
272
+ const weight = (r) => (r.installable === 'non-plugin' ? 0 : 1)
273
+ return matched
274
+ .slice()
275
+ .sort((a, b) =>
276
+ weight(b) - weight(a) ||
277
+ (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0) ||
278
+ (Number(b.stargazers_count) || 0) - (Number(a.stargazers_count) || 0))
279
+ .slice(0, Math.max(1, Number(maxItems) || MAX_ITEMS_PER_FOLLOW))
280
+ }
281
+
282
+ // ── DSH 插件清单:线上源 + 24h 缓存 + 本机快照回退 ─────────────────────
283
+ // 线上源是 dshmarket 包 package.json 里写明的官方地址(不是猜的):
284
+ // https://awesome-dsh-plugin.com/plugins.json
285
+ // 结构与本机 list-cache/dsh.json 不同:线上是 { plugins: [...] },字段见 normalizeDshRegistry。
286
+ // 出站地址是常量,用户输入只参与关键词过滤 —— 仍然满足"不做任意 URL 抓取器"。
287
+ const DSH_REGISTRY_URL = 'https://awesome-dsh-plugin.com/plugins.json'
288
+ const DSH_REGISTRY_TTL_MS = 24 * 60 * 60 * 1000
289
+ const DSH_REGISTRY_FILE = 'dsh-registry.json'
290
+
291
+ function registryPath(root) { return path.join(String(root || ''), 'news', DSH_REGISTRY_FILE) }
292
+
293
+ /** 线上清单 → 内部统一形状(纯函数,便于单测)。 */
294
+ export function normalizeDshRegistry(raw, { now = nowMs() } = {}) {
295
+ const plugins = Array.isArray(raw && raw.plugins) ? raw.plugins : []
296
+ const repos = plugins.map((p) => {
297
+ const name = String((p && p.name) || '').trim()
298
+ const owner = String((p && p.owner) || '').trim()
299
+ const full = owner ? owner + '/' + name : name
300
+ const descRaw = p && p.description
301
+ const desc = descRaw && typeof descRaw === 'object' ? (descRaw.zh || descRaw.en || '') : String(descRaw || '')
302
+ // added = 收录日期(YYYY-MM-DD),全量 9111 条都有;用它当"最新"的排序轴
303
+ const added = String((p && p.added) || '').trim()
304
+ const ts = Date.parse(added) || 0
305
+ return {
306
+ full_name: full,
307
+ name,
308
+ description: String(desc).replace(/\s+/g, ' ').trim().slice(0, 300),
309
+ html_url: String((p && (p.url || p.page)) || ''),
310
+ stargazers_count: Number(p && p.stars) || 0,
311
+ updated_at: ts ? new Date(ts).toISOString() : '',
312
+ added,
313
+ version: String((p && p.version) || ''),
314
+ category: String((p && p.category) || ''),
315
+ installable: 'plugin',
316
+ topics: [],
317
+ }
318
+ }).filter((r) => r.full_name && r.html_url)
319
+ return {
320
+ savedAt: new Date(now).toISOString(),
321
+ sourceUpdated: String((raw && raw.updated) || ''),
322
+ count: repos.length,
323
+ repos,
324
+ }
325
+ }
326
+
327
+ async function readRegistryCache(root) {
328
+ try { return JSON.parse(await fs.readFile(registryPath(root), 'utf8')) } catch { return null }
329
+ }
330
+
331
+ async function writeRegistryCache(root, data) {
332
+ const file = registryPath(root)
333
+ await fs.mkdir(path.dirname(file), { recursive: true })
334
+ const tmp = file + '.tmp'
335
+ await fs.writeFile(tmp, JSON.stringify(data), 'utf8')
336
+ await fs.rename(tmp, file)
337
+ }
338
+
339
+ /** 刷新线上清单(24h TTL)。失败抛错,由 loadDshMarket 回退。 */
340
+ export async function refreshDshRegistry(root, { force = false, now = nowMs() } = {}) {
341
+ const cached = await readRegistryCache(root)
342
+ if (!force && cached && Array.isArray(cached.repos) && cached.repos.length
343
+ && now - (Date.parse(cached.fetchedAt) || 0) < DSH_REGISTRY_TTL_MS) {
344
+ return { ...cached, fromCache: true }
345
+ }
346
+ const raw = JSON.parse(await fetchText(DSH_REGISTRY_URL, 'application/json'))
347
+ const data = normalizeDshRegistry(raw, { now })
348
+ data.fetchedAt = new Date(now).toISOString()
349
+ await writeRegistryCache(root, data)
350
+ return { ...data, fromCache: false }
351
+ }
352
+
353
+ /** 读本机市场清单(进程内按 mtime 缓存,9MB 不能每次重解析)。 */
354
+ let dshMarketCache = null
355
+ async function loadLocalMarket(env = process.env) {
356
+ const home = (env && env.DSH_HOME) || path.join(os.homedir(), '.dsh')
357
+ const file = path.join(home, 'marketplace', 'list-cache', 'dsh.json')
358
+ const st = await fs.stat(file)
359
+ if (dshMarketCache && dshMarketCache.mtimeMs === st.mtimeMs) return dshMarketCache.data
360
+ const raw = JSON.parse(await fs.readFile(file, 'utf8'))
361
+ const data = { savedAt: String(raw.saved_at || ''), repos: Array.isArray(raw.repos) ? raw.repos : [] }
362
+ dshMarketCache = { mtimeMs: st.mtimeMs, data }
363
+ return data
364
+ }
365
+
366
+ async function loadDshMarket(root, { force = false } = {}) {
367
+ try {
368
+ const reg = await refreshDshRegistry(root, { force })
369
+ return { repos: reg.repos, savedAt: reg.savedAt, sourceUpdated: reg.sourceUpdated, origin: 'live' }
370
+ } catch (e) {
371
+ // 线上不可用(断网 / 代理证书 / 站点故障)→ 回退本机市场快照,并如实标注来源
372
+ try {
373
+ const local = await loadLocalMarket()
374
+ return { ...local, origin: 'snapshot', error: String((e && e.message) || e) }
375
+ } catch (e2) {
376
+ return { repos: [], savedAt: '', sourceUpdated: '', origin: 'none', error: String((e && e.message) || e) }
377
+ }
378
+ }
379
+ }
380
+
381
+ async function fetchDsh({ query, follow, root }) {
382
+ const market = await loadDshMarket(root)
383
+ const picked = pickDshPlugins(market.repos, { query, maxItems: MAX_ITEMS_PER_FOLLOW })
384
+ return picked.map((repo) => {
385
+ const desc = String(repo.description || '').replace(/\s+/g, ' ').trim()
386
+ const stars = Number(repo.stargazers_count) || 0
387
+ const updated = Date.parse(repo.updated_at) || 0
388
+ return shape({
389
+ id: 'dsh-' + repo.full_name,
390
+ title: (repo.pkg_name || repo.name) + (desc ? ' — ' + desc.slice(0, 120) : ''),
391
+ url: repo.html_url,
392
+ summary: [
393
+ repo.category,
394
+ repo.version ? 'v' + repo.version : '',
395
+ stars ? '★' + stars : '',
396
+ updated ? '发布于 ' + new Date(updated).toISOString().slice(0, 10) : '',
397
+ market.origin === 'snapshot' ? '本机快照' : '',
398
+ ].filter(Boolean).join(' · '),
399
+ source: 'DSH 插件市场',
400
+ sourceId: 'dsh',
401
+ sourceWeight: 4,
402
+ publishedAt: updated || nowMs(),
403
+ points: stars,
404
+ comments: 0,
405
+ type: 'opensource',
406
+ lang: /[\u4e00-\u9fff]/.test(desc) ? 'zh' : 'en',
407
+ followId: follow.id,
408
+ followLabel: follow.label,
409
+ })
410
+ })
411
+ }
412
+
413
+ const ADAPTERS = { github: fetchGithub, hn: fetchHn, arxiv: fetchArxiv, news: fetchNewsDomain, dsh: fetchDsh }
414
+
415
+ /**
416
+ * 执行一次关注抓取:按域并发,逐域收集错误(不因一个域失败就整体失败)。
417
+ */
418
+ export async function runFollow(root, follow, { force = false, now = nowMs() } = {}) {
419
+ const cached = follow.cache && Array.isArray(follow.cache.items) ? follow.cache : null
420
+ if (!force && cached && now - (Number(cached.fetchedAt) || 0) < CACHE_TTL_MS) {
421
+ return { ...follow, cache: cached, fromCache: true }
422
+ }
423
+ const domains = clampDomains(follow.domains)
424
+ const errors = []
425
+ const items = []
426
+ // 并发抓:域之间互相独立,串行会让「GitHub 慢」拖垮整个关注。
427
+ const results = await Promise.all(domains.map(async (d) => {
428
+ const fn = ADAPTERS[d]
429
+ if (!fn) return { d, items: [], error: '未知域' }
430
+ try { return { d, items: await fn({ query: follow.query, follow, root }) } }
431
+ catch (e) { return { d, items: [], error: String((e && e.message) || e) } }
432
+ }))
433
+ for (const r of results) {
434
+ if (r.error) errors.push({ domain: r.d, error: r.error })
435
+ items.push(...r.items)
436
+ }
437
+ // 去重:同一 URL 只留热度更高的一条(GitHub/HN 可能撞同一话题)。
438
+ const byUrl = new Map()
439
+ for (const it of items) {
440
+ if (!it.url) continue
441
+ const prev = byUrl.get(it.url)
442
+ if (!prev || (it.points || 0) > (prev.points || 0)) byUrl.set(it.url, it)
443
+ }
444
+ const merged = [...byUrl.values()].sort((a, b) => (b.points || 0) - (a.points || 0) || (b.publishedAt || 0) - (a.publishedAt || 0))
445
+ const cache = { fetchedAt: now, items: merged, errors }
446
+ return { ...follow, cache, fromCache: false }
447
+ }
448
+
449
+ function normalizeFollow(raw) {
450
+ const query = String(raw && raw.query || '').trim().slice(0, 120)
451
+ const domains = clampDomains(raw && raw.domains)
452
+ const label = String(raw && raw.label || '').trim().slice(0, 40) || query || '关注'
453
+ const sourceIds = Array.isArray(raw && raw.sourceIds)
454
+ ? [...new Set(raw.sourceIds.map(String).filter((x) => SOURCE_IDS.has(x)))].slice(0, 4)
455
+ : []
456
+ return {
457
+ id: String(raw && raw.id || '') || 'f' + Math.random().toString(36).slice(2, 10),
458
+ query,
459
+ domains,
460
+ sourceIds,
461
+ label,
462
+ createdAt: Number(raw && raw.createdAt) || nowMs(),
463
+ lastRunAt: Number(raw && raw.lastRunAt) || 0,
464
+ cache: raw && raw.cache && typeof raw.cache === 'object' ? raw.cache : null,
465
+ }
466
+ }
467
+
468
+ export async function listFollows(root) {
469
+ const data = await readFileJson(followsPath(root), { version: 1, follows: [] })
470
+ const follows = Array.isArray(data.follows) ? data.follows.map(normalizeFollow) : []
471
+ return follows
472
+ }
473
+
474
+ export async function saveFollow(root, input) {
475
+ const follows = await listFollows(root)
476
+ if (follows.length >= MAX_FOLLOWS) throw new Error('关注数量已达上限(' + MAX_FOLLOWS + ' 个)')
477
+ const f = normalizeFollow(input)
478
+ // 同一个 query + 同一组域 = 重复关注,直接返回已有的,避免堆叠。
479
+ const dup = follows.find((x) => x.query === f.query && x.domains.join(',') === f.domains.join(',') && (x.sourceIds || []).join(',') === f.sourceIds.join(','))
480
+ if (dup) return { ok: true, follow: dup, duplicate: true, follows }
481
+ follows.push(f)
482
+ await writeFileJson(followsPath(root), { version: 1, follows })
483
+ return { ok: true, follow: f, duplicate: false, follows }
484
+ }
485
+
486
+ export async function removeFollow(root, id) {
487
+ const follows = await listFollows(root)
488
+ const next = follows.filter((f) => f.id !== id)
489
+ await writeFileJson(followsPath(root), { version: 1, follows: next })
490
+ return { ok: true, removed: follows.length - next.length, follows: next }
491
+ }
492
+
493
+ /**
494
+ * 刷新所有关注(有 TTL 保护;force 时全量重抓)。
495
+ * 返回最新的 follows 数组,供 /news/follows 直接回给前端。
496
+ */
497
+ export async function refreshFollows(root, { force = false, now = nowMs() } = {}) {
498
+ const follows = await listFollows(root)
499
+ const next = []
500
+ for (const f of follows) {
501
+ const ran = await runFollow(root, f, { force, now })
502
+ next.push({ ...ran, lastRunAt: ran.fromCache ? f.lastRunAt : now })
503
+ }
504
+ await writeFileJson(followsPath(root), { version: 1, follows: next })
505
+ return next
506
+ }
507
+
508
+ /** 把各关注的最新结果并成一批条目(供 /news/feed 合并)。 */
509
+ export async function followItems(root, follows) {
510
+ const all = []
511
+ for (const f of follows) {
512
+ const cache = f.cache
513
+ if (!cache || !Array.isArray(cache.items)) continue
514
+ all.push(...cache.items)
515
+ }
516
+ const seen = new Set()
517
+ return all.filter((it) => {
518
+ const key = it.url || it.id
519
+ if (!key || seen.has(key)) return false
520
+ seen.add(key)
521
+ return true
522
+ })
523
+ }
package/lib/node-bin.js CHANGED
@@ -11,8 +11,9 @@
11
11
  //
12
12
  // 本模块把「找出一个真正能跑 node 脚本的子进程入口」收敛到一处:
13
13
  // 1. MEMORY_ETERNAL_NODE 显式指定(逃生口,便于测试与非常规宿主);
14
- // 2. 非 Electron 宿主:process.execPath 本身就是 node,直接用;
15
- // 3. Electron 宿主:优先从 PATH 找真正的 node;
14
+ // 2. 非 Electron 宿主:**优先从 PATH 找 node**(issue #56:brew/nvm/volta 在 PATH 上的入口
15
+ // 是稳定 shim,而 process.execPath 是带版本号的真实路径),execPath 降为兜底;
16
+ // 3. Electron 宿主:同样优先从 PATH 找真正的 node;
16
17
  // 4. 都找不到时退回 process.execPath,并由 childEnv() 打开
17
18
  // ELECTRON_RUN_AS_NODE,让 Electron 二进制以纯 node 模式运行。
18
19
 
@@ -22,8 +23,8 @@ import path from 'node:path'
22
23
  let cachedBinary = null
23
24
  let cachedSource = null
24
25
 
25
- /** 在 PATH 中查找真正的 node 可执行文件;找不到返回 null。 */
26
- function findNodeOnPath() {
26
+ /** 在 PATH 中查找真正的 node 可执行文件;找不到返回 null。(导出便于测试断言 PATH 优先) */
27
+ export function findNodeOnPath() {
27
28
  const names = process.platform === 'win32' ? ['node.exe', 'node.cmd', 'node'] : ['node', 'node.exe']
28
29
  const dirs = String(process.env.PATH || '').split(path.delimiter)
29
30
  for (const dir of dirs) {
@@ -72,17 +73,21 @@ export function nodeBinary() {
72
73
  }
73
74
 
74
75
  if (!process.versions.electron) {
75
- // 非 Electron:正常情况下 process.execPath 就是 node 本身;但**长命进程**(watchdog)的
76
- // execPath 指向的是它启动时那份 node —— brew 升级后可能已被删除(#44)。此时退回 PATH。
77
- if (exists(process.execPath)) {
78
- cachedBinary = process.execPath
79
- cachedSource = 'execPath'
80
- return cachedBinary
81
- }
76
+ // issue #56:非 Electron 下**必须 PATH 优先**。process.execPath 是「这一次装的那份 node」的
77
+ // 真实路径(macOS Homebrew:/opt/homebrew/Cellar/node/26.13.0_2/bin/node),它会被写进
78
+ // ~/.claude.json 等外部客户端的 MCP 条目;brew upgrade node 一删旧目录,这些条目就 ENOENT。
79
+ // PATH 上的入口(/opt/homebrew/bin/node、~/.volta/bin/node、nvm 的 shim)才是稳定的。
80
+ // 同时它决定了 align 的 currentNodePath —— 写入侧与校验侧一起收敛到 shim,才不会互相打脸。
81
+ // execPath 仍保留为兜底(PATH 上没有 node 时行为与 #44 之前一致)。
82
82
  const viaPath = findNodeOnPath()
83
83
  if (viaPath) {
84
84
  cachedBinary = viaPath
85
- cachedSource = 'path-fallback'
85
+ cachedSource = 'path'
86
+ return cachedBinary
87
+ }
88
+ if (exists(process.execPath)) {
89
+ cachedBinary = process.execPath
90
+ cachedSource = 'execPath'
86
91
  return cachedBinary
87
92
  }
88
93
  cachedBinary = process.execPath