memory-eternal 0.22.0 → 0.24.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,109 @@
1
+ // 记忆核心 · 关注摘要(News Digest)
2
+ //
3
+ // 定位:「关注」负责持续抓取,摘要负责**把结果压成一句话送给 Agent**。
4
+ // 送达通道复用插件的 systemPrompt 分段(与核心记忆同一机制),因此不需要新的宿主能力。
5
+ //
6
+ // 三条纪律:
7
+ // 1. **默认关闭**:这是会打扰人的功能,必须由用户显式开启(newsDigestEnabled)。
8
+ // 2. 只送「新东西」:generatedAt > consumedAt 才注入;一轮会话消费后立刻静默,不刷屏。
9
+ // 3. 本地可预览:配置页有「立即生成」,用户能在开启前先看到会推送什么。
10
+ import { promises as fs } from 'node:fs'
11
+ import path from 'node:path'
12
+ import { refreshFollows, followItems } from './news-follows.js'
13
+
14
+ const DIGEST_FILE = 'digest.json'
15
+
16
+ function digestPath(root) { return path.join(String(root || ''), 'news', DIGEST_FILE) }
17
+
18
+ export async function readDigest(root) {
19
+ try { return JSON.parse(await fs.readFile(digestPath(root), 'utf8')) } catch { return null }
20
+ }
21
+
22
+ export async function writeDigest(root, digest) {
23
+ const file = digestPath(root)
24
+ await fs.mkdir(path.dirname(file), { recursive: true })
25
+ const tmp = file + '.tmp'
26
+ await fs.writeFile(tmp, JSON.stringify(digest, null, 2), 'utf8')
27
+ await fs.rename(tmp, file)
28
+ return digest
29
+ }
30
+
31
+ /**
32
+ * 从关注结果里挑出摘要条目(纯函数,便于单测)。
33
+ * 排序:热度优先(GitHub star / HN points),其次时间。
34
+ */
35
+ export function pickDigestItems(items, { sinceMs = 0, maxItems = 5, seenUrls = [] } = {}) {
36
+ const seen = new Set(Array.isArray(seenUrls) ? seenUrls : [])
37
+ return (Array.isArray(items) ? items : [])
38
+ .filter((it) => it && it.url && !seen.has(it.url) && (!sinceMs || (Number(it.publishedAt) || 0) >= sinceMs))
39
+ .slice()
40
+ .sort((a, b) => (Number(b.points) || 0) - (Number(a.points) || 0) || (Number(b.publishedAt) || 0) - (Number(a.publishedAt) || 0))
41
+ .filter((it) => { if (seen.has(it.url)) return false; seen.add(it.url); return true })
42
+ .slice(0, Math.max(1, Number(maxItems) || 5))
43
+ }
44
+
45
+ /**
46
+ * 生成一份摘要快照并落盘。
47
+ * @param {string} root vault
48
+ * @param {{windowHours?:number,maxItems?:number,force?:boolean,now?:number}} opts
49
+ */
50
+ export async function buildDigest(root, { windowHours = 24, maxItems = 5, force = false, now = Date.now() } = {}) {
51
+ const follows = await refreshFollows(root, { force, now })
52
+ const all = await followItems(root, follows)
53
+ // 「新」的判定 = **没推送过**,而不是「刚发布」。
54
+ // 为什么:GitHub 仓库的时间戳是创建时间,按 12/24 小时窗口筛会把它们全筛掉,
55
+ // 于是「GitHub 热门」的摘要永远是空的。用 sentUrls 记账才符合"关注"的语义。
56
+ const prev = await readDigest(root)
57
+ const prevSent = Array.isArray(prev && prev.sentUrls) ? prev.sentUrls : []
58
+ const picked = pickDigestItems(all, { maxItems, seenUrls: prevSent })
59
+ // 关键:把**这一次扫到的全部 URL**记为已推送,而不是只记被挑中的那几条。
60
+ // 否则每次摘要都会从积压里再掏 5 条 —— 那不叫"新内容",叫"清库存",用户会被反复骚扰。
61
+ const sentUrls = [...new Set([...prevSent, ...all.map((it) => it.url)])].slice(-2000)
62
+ const digest = {
63
+ ok: true,
64
+ generatedAt: now,
65
+ consumedAt: 0,
66
+ windowHours,
67
+ maxItems,
68
+ followCount: follows.length,
69
+ scanned: all.length,
70
+ sentUrls,
71
+ items: picked.map((it) => ({
72
+ title: it.title, url: it.url, source: it.source, followLabel: it.followLabel || '',
73
+ points: Number(it.points) || 0, publishedAt: Number(it.publishedAt) || 0,
74
+ })),
75
+ }
76
+ await writeDigest(root, digest)
77
+ return digest
78
+ }
79
+
80
+ /** 标记「这一份已经进过一轮会话」,避免同一个摘要反复注入。 */
81
+ export async function consumeDigest(root, generatedAt) {
82
+ const d = await readDigest(root)
83
+ if (!d || Number(d.generatedAt) !== Number(generatedAt)) return null
84
+ d.consumedAt = d.generatedAt
85
+ await writeDigest(root, d)
86
+ return d
87
+ }
88
+
89
+ /**
90
+ * 生成注入文本。返回空串 = 本轮不注入。
91
+ * 刻意要求 Agent「一两句话带过」:摘要是提醒,不是让模型把标题念一遍。
92
+ */
93
+ export function digestPrompt(digest, { maxItems = 5 } = {}) {
94
+ const items = Array.isArray(digest && digest.items) ? digest.items : []
95
+ if (!items.length) return ''
96
+ const top = items.slice(0, Math.max(1, Number(maxItems) || 5))
97
+ const lines = top.map((it, i) => {
98
+ const src = it.followLabel || it.source || '关注'
99
+ const heat = it.points ? ' · ' + it.points : ''
100
+ return (i + 1) + '. ' + it.title + '(' + src + heat + ')'
101
+ })
102
+ return [
103
+ '【关注动态(记忆核心 · 新闻关注)】',
104
+ '后台按你开启的「关注摘要」抓到了 ' + items.length + ' 条新内容:',
105
+ ...lines,
106
+ '请在本次回复开头用一两句话告诉用户「有哪些关注更新」,不要整段罗列标题;',
107
+ '用户要看全部时再展开(新闻页有「关注动态」专区)。',
108
+ ].join('\n')
109
+ }
@@ -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
+ }