dsh-github-hot 0.0.0-stage → 0.1.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/index.js ADDED
@@ -0,0 +1,1750 @@
1
+ /**
2
+ * dsh-github-hot —— 宿主半侧(host half)
3
+ *
4
+ * 职责:把 Hysen Labs(hysenlabs.com)的公开榜单数据取回来、解析成结构化 JSON、
5
+ * 按周期缓存,通过 /dsh-github-hot/ 前缀的只读接口交给浏览器半侧去画。
6
+ * 浏览器半侧**只负责渲染**,不做任何取数与解析。
7
+ *
8
+ * ============================ 数据源(全部免 key、公开可访问)============================
9
+ *
10
+ * 日榜 GET https://hysenlabs.com/data/star-growth-24h.csv 500 项,CSV
11
+ * 月榜 GET https://hysenlabs.com/data/star-growth-30d.csv 500 项,CSV
12
+ * 周榜 GET https://hysenlabs.com/weekly/{YYYY-MM-DD} TOP10,HTML
13
+ * 曲线 GET https://hysenlabs.com/chart/{slug}.svg 星数历史折线(SVG 路径)
14
+ * 简介 GET https://hysenlabs.com/projects/{slug} 编辑分析(HTML,取首个结论段)
15
+ *
16
+ * CSV 的字段(两份一致,只有「名次变化」那一列不同名):
17
+ * rank, repository, github_url, hysenlabs_url, stars_gained, start_stars,
18
+ * end_stars, growth_pct, rank_change_vs_day_ago | rank_change_vs_week_ago,
19
+ * language, license, window_start, window_end
20
+ *
21
+ * ============================ 三个必须守住的口径 ============================
22
+ *
23
+ * 1) **缺口如实标注,绝不编数**:CSV / HTML / SVG 任一取不到,那一段就标 stale 并给出
24
+ * 原因与上次成功时间;界面照实显示「这次没拿到」,而不是悄悄退回旧数据当新的。
25
+ *
26
+ * 2) **简介不编**:项目分析页取不到时,desc 字段留空、descSource 标 'none',
27
+ * 界面显示「暂无简介」;绝不拿模型生成一段冒充 Hysen 的编辑分析。
28
+ *
29
+ * 3) **解析容错**:周榜是页面 HTML(对方改版即失效)、曲线是 SVG 路径(坐标口径可能变)。
30
+ * 两处都做「解析不到就整段标失败」,不做模糊兜底 —— 模糊兜底会产出看似正常的错数据。
31
+ *
32
+ * ============================ 缓存与限流 ============================
33
+ * 榜单缓存 10 分钟(Hysen 数据每天才动一次,没必要勤刷);单项目详情(简介 + 曲线)
34
+ * 缓存 6 小时。所有请求串行排队,同一 URL 在途去重,避免并发打爆对方站点。
35
+ *
36
+ * 依赖策略:静态 import 只有 node 内置模块 + 全局 fetch,不 import 任何 @deepseek-ai/*,
37
+ * 这样官方包改名换布局都不会导致本插件整体加载失败。
38
+ */
39
+
40
+ import { createHash, randomUUID } from 'node:crypto';
41
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
42
+ import { homedir } from 'node:os';
43
+ import { join } from 'node:path';
44
+ import { pathToFileURL } from 'node:url';
45
+
46
+ /** 插件名:必须与包名、cordis.patch.yml 的 id 一致。 */
47
+ export const name = 'dsh-github-hot';
48
+
49
+ /** 硬依赖:只要 webServer(提供路由)。 */
50
+ export const inject = ['webServer'];
51
+
52
+ const VERSION = '0.1.0';
53
+ const ROUTE_PREFIX = '/dsh-github-hot';
54
+
55
+ const BASE = 'https://hysenlabs.com';
56
+ const UA = 'dsh-github-hot/' + VERSION + ' (+https://github.com/)';
57
+
58
+ /**
59
+ * 缓存时长 —— 按**数据源的真实更新节奏**定,而不是按「刷新得勤快一点」定。
60
+ *
61
+ * 上游数据**每天只更新一次**(其方法页写明 "A daily job captures public repository
62
+ * Star counts"),日榜窗口为「前一天 → 当天」,月榜为「30 天前 → 当天」。所以:
63
+ *
64
+ * · 榜单:同一自然日内结果不会变 → 按天缓存,跨天自动失效。
65
+ * · 项目详情(简介 + 曲线):曲线也是每日快照 → 同样按天缓存。
66
+ * · 翻译结果:按原文哈希缓存,永不过期(原文不变就不重翻)。
67
+ *
68
+ * 这样「反复打开面板」不会再打到对方站点,也不会有拉不到新数据的错觉。
69
+ */
70
+ const DAY_MS = 24 * 60 * 60 * 1000;
71
+ /** 榜单缓存:跨自然日才失效(不是 24 小时,是「今天 vs 昨天」)。 */
72
+ const BOARD_TTL_MS = DAY_MS;
73
+ /** 单项目详情(简介 + 曲线):同样按天。 */
74
+ const DETAIL_TTL_MS = DAY_MS;
75
+
76
+ /** 「本地日期」串(YYYY-MM-DD),用于判断缓存是否跨天。 */
77
+ /**
78
+ * 数据源时区。
79
+ *
80
+ * ★ 这是全插件**唯一**的时区基准,别处不要再自己算日期。
81
+ *
82
+ * 为什么必须是 Asia/Shanghai:Hysen Labs 的周榜口径明确写着
83
+ * 「完整的周一到周日,时区 Asia/Shanghai」,日榜/月榜的窗口也是按这个时区切的。
84
+ * 如果按用户本地时区算「今天是哪天」,那么:
85
+ * · 北京时间周一 08:00(美西还是周日)的用户会算出「上周一」,拿到的是旧周榜;
86
+ * · 跨时区用户看到的「数据更新到 X 月 X 日」会互相不一致。
87
+ * 数据是哪天的,应该由**数据源**说了算,不是由看的人所在时区说了算。
88
+ */
89
+ const SOURCE_TZ = 'Asia/Shanghai';
90
+
91
+ /** 把某个时刻格式化成数据源时区下的 YYYY-MM-DD。 */
92
+ function dayKeyInSourceTz(ts) {
93
+ const d = new Date(ts === undefined || ts === null ? Date.now() : ts);
94
+ // Intl 直接给出目标时区的年月日,不受运行机器时区影响
95
+ const parts = new Intl.DateTimeFormat('en-CA', {
96
+ timeZone: SOURCE_TZ,
97
+ year: 'numeric',
98
+ month: '2-digit',
99
+ day: '2-digit',
100
+ }).formatToParts(d);
101
+ const get = (t) => (parts.find((x) => x.type === t) || {}).value;
102
+ return get('year') + '-' + get('month') + '-' + get('day');
103
+ }
104
+
105
+ /** 兼容旧名字:缓存有效期判断用的就是「数据源时区的今天」。 */
106
+ function localDayKey(ts) {
107
+ return dayKeyInSourceTz(ts);
108
+ }
109
+ /** 翻译并发:模型调用是 IO 等待,8 条同时翻比 4 条快一倍。 */
110
+ const TRANSLATE_CONCURRENCY = 8;
111
+ /** 取数并发:抓对方站点,礼貌一点。 */
112
+ const FETCH_CONCURRENCY = 4;
113
+
114
+ /** 单次请求超时。 */
115
+ const REQUEST_TIMEOUT_MS = 20000;
116
+ /** 简介最多保留多少字(页面提取的第一段结论通常几百字)。 */
117
+ const DESC_MAX_CHARS = 260;
118
+
119
+ /* ------------------------------------------------------------------ 工具 */
120
+
121
+ function nowIso() {
122
+ return new Date().toISOString();
123
+ }
124
+
125
+ function safeInt(value) {
126
+ const n = Number(String(value ?? '').replace(/[, ]/g, ''));
127
+ return Number.isFinite(n) ? Math.round(n) : null;
128
+ }
129
+
130
+ function safeFloat(value) {
131
+ const n = Number(String(value ?? '').replace(/[,% ]/g, ''));
132
+ return Number.isFinite(n) ? n : null;
133
+ }
134
+
135
+ function text(value) {
136
+ return String(value ?? '').trim();
137
+ }
138
+
139
+ /** 把 "owner/repo" 归一成 Hysen 的 slug:小写 + 斜杠换横线。 */
140
+ /**
141
+ * "owner/repo" → Hysen 的 slug(小写 + 斜杠换横线)。
142
+ *
143
+ * 安全要点:结果要同时用作 **URL 路径** 和 **本地缓存文件名**,所以必须彻底净化:
144
+ * · 只留 [a-z0-9-];
145
+ * · 点号一律去掉 —— 否则 `..` 会作为文件名的一部分被保留,形成目录穿越隐患;
146
+ * · 连续横线压成一个,首尾横线去掉,避免生成 `-etc-hosts` 这种怪名。
147
+ */
148
+ function slugOf(repository) {
149
+ return text(repository)
150
+ .toLowerCase()
151
+ .replace(/\//g, '-') // owner/repo 的分隔
152
+ .replace(/[^a-z0-9-]/g, '') // 只留字母数字和横线(点号也去掉)
153
+ .replace(/-{2,}/g, '-') // 压缩连续横线
154
+ .replace(/^-+|-+$/g, ''); // 去掉首尾横线
155
+ }
156
+
157
+ function cleanHtml(input) {
158
+ return String(input)
159
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
160
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
161
+ .replace(/<[^>]+>/g, ' ')
162
+ .replace(/&nbsp;/g, ' ')
163
+ .replace(/&amp;/g, '&')
164
+ .replace(/&lt;/g, '<')
165
+ .replace(/&gt;/g, '>')
166
+ .replace(/&quot;/g, '"')
167
+ .replace(/&#39;/g, "'")
168
+ .replace(/\s+/g, ' ')
169
+ .trim();
170
+ }
171
+
172
+ /** 一个带 TTL 的极简缓存。 */
173
+ const cache = new Map();
174
+
175
+ /* ------------------------------------------------------------ 磁盘缓存 */
176
+
177
+ /**
178
+ * 内存缓存 + 磁盘缓存。
179
+ *
180
+ * 为什么要落盘:面板关闭、DSH 重启之后,内存缓存全丢 ⇒ 再打开就要全量重取一遍,
181
+ * 既慢又白打对方站点。数据源每天才更新一次,所以缓存完全可以跨进程复用。
182
+ *
183
+ * 落盘策略:
184
+ * · 路径:$DSH_HOME/dsh-github-hot/cache.json(没有 DSH_HOME 就退回 ~/.dsh)
185
+ * · 时机:写入后**节流落盘**(最多每 3 秒一次),避免每张卡片都触发一次磁盘写
186
+ * · 容量:最多保留 800 条,超出按写入时间淘汰最旧的
187
+ * · 容错:文件损坏 / 无权限一律当作空缓存,**绝不让缓存问题拖垮插件**
188
+ */
189
+
190
+ const CACHE_FILE_NAME = 'cache.json';
191
+ /**
192
+ * 缓存结构版本。改了缓存里存的结构(例如给曲线点加了 date 字段)就 +1,
193
+ * 旧文件会被整体丢弃 —— 否则会用「结构已过期」的数据静默喂给界面。
194
+ *
195
+ * v3:曲线日期改为 UTC 口径统一计算 + 时区统一到数据源(Asia/Shanghai)。
196
+ */
197
+ const CACHE_STRUCTURE_VERSION = 3;
198
+ const CACHE_MAX_ENTRIES = 800;
199
+ const FLUSH_DELAY_MS = 3000;
200
+
201
+ let cacheLoaded = false;
202
+ let flushTimer = null;
203
+
204
+ function cacheFilePath() {
205
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh');
206
+ return join(home, 'dsh-github-hot', CACHE_FILE_NAME);
207
+ }
208
+
209
+ /** 启动时把磁盘缓存读进内存(只做一次)。 */
210
+ function ensureCacheLoaded() {
211
+ if (cacheLoaded) return;
212
+ cacheLoaded = true;
213
+ try {
214
+ const file = cacheFilePath();
215
+ if (!existsSync(file)) return;
216
+ const raw = readFileSync(file, 'utf8');
217
+ const data = JSON.parse(raw);
218
+ if (!data || typeof data !== 'object' || !data.entries) return;
219
+ if (data.version !== CACHE_STRUCTURE_VERSION) return; // 结构变了,整份作废
220
+ const now = Date.now();
221
+ for (const [key, entry] of Object.entries(data.entries)) {
222
+ if (!entry || typeof entry.at !== 'number') continue;
223
+ // 只恢复「不是太旧」的条目:按天缓存的跨天就丢,避免恢复一堆废数据
224
+ if (now - entry.at > DAY_MS) continue;
225
+ cache.set(key, { at: entry.at, value: entry.value });
226
+ }
227
+ } catch {
228
+ /* 缓存读不出来就当空缓存,不影响功能 */
229
+ }
230
+ }
231
+
232
+ /** 节流落盘。 */
233
+ function scheduleFlush() {
234
+ if (flushTimer !== null) return;
235
+ flushTimer = setTimeout(() => {
236
+ flushTimer = null;
237
+ flushCache();
238
+ }, FLUSH_DELAY_MS);
239
+ if (typeof flushTimer.unref === 'function') flushTimer.unref();
240
+ }
241
+
242
+ function flushCache() {
243
+ try {
244
+ const file = cacheFilePath();
245
+ const dir = join(file, '..');
246
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
247
+ // 超出上限就按时间保留最新的那批
248
+ let entries = [...cache.entries()];
249
+ if (entries.length > CACHE_MAX_ENTRIES) {
250
+ entries.sort((a, b) => b[1].at - a[1].at);
251
+ entries = entries.slice(0, CACHE_MAX_ENTRIES);
252
+ }
253
+ const payload = {
254
+ version: CACHE_STRUCTURE_VERSION,
255
+ savedAt: new Date().toISOString(),
256
+ entries: Object.fromEntries(entries.map(([k, v]) => [k, { at: v.at, value: v.value }])),
257
+ };
258
+ writeFileSync(file, JSON.stringify(payload), 'utf8');
259
+ } catch {
260
+ /* 写不进去就只留内存缓存 */
261
+ }
262
+ }
263
+
264
+ function cacheGet(key, ttlMs) {
265
+ ensureCacheLoaded();
266
+ const hit = cache.get(key);
267
+ if (!hit) return null;
268
+ // 按天缓存:只要还是同一个自然日,就算命中(哪怕已经过去 23 小时)
269
+ if (ttlMs >= DAY_MS) {
270
+ if (localDayKey(hit.at) !== localDayKey(Date.now())) return null;
271
+ return hit.value;
272
+ }
273
+ if (Date.now() - hit.at > ttlMs) return null;
274
+ return hit.value;
275
+ }
276
+
277
+ function cacheSet(key, value) {
278
+ ensureCacheLoaded();
279
+ cache.set(key, { at: Date.now(), value });
280
+ scheduleFlush();
281
+ // 防止无限增长:超过 400 条就清最早的一半
282
+ if (cache.size > 400) {
283
+ const keys = [...cache.keys()].slice(0, 200);
284
+ for (const k of keys) cache.delete(k);
285
+ }
286
+ }
287
+
288
+ /* -------------------------------------------------------------- 出网取数 */
289
+
290
+ /** 在途去重:同一 URL 同时只发一次。 */
291
+ const inflight = new Map();
292
+
293
+ /**
294
+ * 出网请求调度。
295
+ *
296
+ * 之前用的是**单条全局串行队列** —— 结果一个批次要取 20 个项目时,
297
+ * 后面进来的请求(哪怕只是刷新榜单)也得排队等前面 20 个跑完,
298
+ * 一个批次取 20 个项目时,后进来的请求会被全部阻塞。
299
+ *
300
+ * 现在改成**按 host 分队列 + 限并发**:
301
+ * · 每个 host 独立一条队列,互不阻塞(hysenlabs 与 GitHub 头像不互相等);
302
+ * · 同一 host 最多 CONCURRENCY 个在途,既是礼貌,也避免被对方限流。
303
+ */
304
+ const HOST_CONCURRENCY = 4;
305
+
306
+ /** 每个 host 的在途计数与等待队列。 */
307
+ const hostState = new Map();
308
+ let lastError = null;
309
+
310
+ function hostOf(url) {
311
+ try {
312
+ return new URL(url).host;
313
+ } catch {
314
+ return 'unknown';
315
+ }
316
+ }
317
+
318
+ /**
319
+ * 在指定 host 的并发槽里执行 task。
320
+ * 超过并发上限时排队,有空位立刻放行。
321
+ */
322
+ function runOnHost(host, task) {
323
+ let state = hostState.get(host);
324
+ if (!state) {
325
+ state = { active: 0, waiting: [] };
326
+ hostState.set(host, state);
327
+ }
328
+ return new Promise((resolve, reject) => {
329
+ const start = () => {
330
+ state.active += 1;
331
+ Promise.resolve()
332
+ .then(task)
333
+ .then(resolve, reject)
334
+ .finally(() => {
335
+ state.active -= 1;
336
+ const next = state.waiting.shift();
337
+ if (next) next();
338
+ });
339
+ };
340
+ if (state.active < HOST_CONCURRENCY) start();
341
+ else state.waiting.push(start);
342
+ });
343
+ }
344
+
345
+ /**
346
+ * GET 一个 URL 并返回文本。
347
+ * 失败抛错(带状态码 / 超时 / 网络原因),**不做重试**:上层按需决定是否保旧。
348
+ */
349
+ async function fetchText(url, { accept } = {}) {
350
+ const existing = inflight.get(url);
351
+ if (existing) return existing;
352
+ const task = runOnHost(hostOf(url), async () => {
353
+ const controller = new AbortController();
354
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
355
+ try {
356
+ const response = await fetch(url, {
357
+ method: 'GET',
358
+ headers: {
359
+ 'User-Agent': UA,
360
+ Accept: accept || 'text/html,text/csv,application/xml,text/xml,*/*',
361
+ },
362
+ signal: controller.signal,
363
+ redirect: 'follow',
364
+ });
365
+ if (!response.ok) {
366
+ const error = new Error('HTTP ' + response.status + ' ' + response.statusText);
367
+ error.status = response.status;
368
+ throw error;
369
+ }
370
+ return await response.text();
371
+ } catch (error) {
372
+ if (error && error.name === 'AbortError') {
373
+ const timeout = new Error('请求超时(' + REQUEST_TIMEOUT_MS / 1000 + 's)');
374
+ timeout.timeout = true;
375
+ throw timeout;
376
+ }
377
+ throw error;
378
+ } finally {
379
+ clearTimeout(timer);
380
+ }
381
+ });
382
+ inflight.set(url, task);
383
+ try {
384
+ return await task;
385
+ } finally {
386
+ inflight.delete(url);
387
+ }
388
+ }
389
+
390
+ /* --------------------------------------------------------------- CSV 解析 */
391
+
392
+ /** 极简 CSV 解析(Hysen 的 CSV 没有引号包裹字段,但仍按引号规则处理一遍)。 */
393
+ function parseCsv(raw) {
394
+ const rows = [];
395
+ let row = [];
396
+ let field = '';
397
+ let quoted = false;
398
+ for (let i = 0; i < raw.length; i += 1) {
399
+ const ch = raw[i];
400
+ if (quoted) {
401
+ if (ch === '"') {
402
+ if (raw[i + 1] === '"') {
403
+ field += '"';
404
+ i += 1;
405
+ } else {
406
+ quoted = false;
407
+ }
408
+ } else {
409
+ field += ch;
410
+ }
411
+ continue;
412
+ }
413
+ if (ch === '"') {
414
+ quoted = true;
415
+ } else if (ch === ',') {
416
+ row.push(field);
417
+ field = '';
418
+ } else if (ch === '\n') {
419
+ row.push(field);
420
+ rows.push(row);
421
+ row = [];
422
+ field = '';
423
+ } else if (ch !== '\r') {
424
+ field += ch;
425
+ }
426
+ }
427
+ if (field !== '' || row.length > 0) {
428
+ row.push(field);
429
+ rows.push(row);
430
+ }
431
+ return rows.filter((r) => r.length > 1 || text(r[0]) !== '');
432
+ }
433
+
434
+ /** CSV 行 → 结构化榜单项。 */
435
+ function boardRow(item, rankFallback) {
436
+ const repository = text(item.repository);
437
+ return {
438
+ rank: safeInt(item.rank) ?? rankFallback,
439
+ repository,
440
+ slug: slugOf(repository),
441
+ githubUrl: text(item.github_url),
442
+ hysenUrl: text(item.hysenlabs_url),
443
+ starsGained: safeInt(item.stars_gained) ?? 0,
444
+ startStars: safeInt(item.start_stars),
445
+ endStars: safeInt(item.end_stars),
446
+ growthPct: safeFloat(item.growth_pct),
447
+ // 两份 CSV 的「名次变化」列名不同,统一成 rankChange
448
+ rankChange:
449
+ item.rank_change_vs_day_ago !== undefined
450
+ ? safeInt(item.rank_change_vs_day_ago)
451
+ : safeInt(item.rank_change_vs_week_ago),
452
+ language: text(item.language),
453
+ license: text(item.license),
454
+ };
455
+ }
456
+
457
+ /* ------------------------------------------------------------ 周榜 HTML 解析 */
458
+
459
+ /**
460
+ * 解析周榜页。
461
+ *
462
+ * 依赖的页面特征:
463
+ * <li ... data-testid="ranking-row-3"> ... <a class="...__repo" href="/projects/slug"><span>owner</span> / <!-- -->repo</a>
464
+ * ... <strong class="...__weekly" data-testid="weekly-stars-3">13,165</strong>
465
+ * ... <span class="...__total">47,747</span> <span class="...__steady">—</span>
466
+ * ... <span class="...__language">TypeScript</span>
467
+ *
468
+ * 只要行数、涨星数、仓库名三者对不齐,就整段判失败(返回 null),不做模糊补齐。
469
+ */
470
+ function parseWeeklyHtml(html) {
471
+ // 逐行切:以 ranking-row-N 为界
472
+ const rowStarts = [];
473
+ const rowRe = /data-testid="ranking-row-(\d+)"/g;
474
+ let match = rowRe.exec(html);
475
+ while (match) {
476
+ rowStarts.push({ rank: Number(match[1]), index: match.index });
477
+ match = rowRe.exec(html);
478
+ }
479
+ if (rowStarts.length === 0) return null;
480
+
481
+ const rows = [];
482
+ for (let i = 0; i < rowStarts.length; i += 1) {
483
+ const start = rowStarts[i].index;
484
+ const end = i + 1 < rowStarts.length ? rowStarts[i + 1].index : Math.min(html.length, start + 4000);
485
+ const chunk = html.slice(start, end);
486
+
487
+ const repoMatch = chunk.match(/__repo"\s+href="([^"]*)"[\s\S]*?<span>([^<]*)<\/span>\s*\/\s*(?:<!--\s*-->)?([^<]*)<\/a>/);
488
+ const gainMatch = chunk.match(/__weekly"[^>]*data-testid="weekly-stars-\d+"[^>]*>([\d,]+)</);
489
+ if (!repoMatch || !gainMatch) continue;
490
+
491
+ const owner = text(repoMatch[2]);
492
+ const repo = text(repoMatch[3]);
493
+ const href = text(repoMatch[1]);
494
+ const slug = href.startsWith('/projects/') ? href.slice('/projects/'.length) : slugOf(owner + '/' + repo);
495
+
496
+ const totalMatch = chunk.match(/__total">([\d,]+)</);
497
+ const langMatch = chunk.match(/__language">([^<]+)</);
498
+ const steadyMatch = chunk.match(/__steady[^"]*">([^<]+)</);
499
+
500
+ rows.push({
501
+ rank: rowStarts[i].rank,
502
+ repository: owner + '/' + repo,
503
+ slug,
504
+ githubUrl: 'https://github.com/' + owner + '/' + repo,
505
+ hysenUrl: BASE + '/projects/' + slug,
506
+ starsGained: safeInt(gainMatch[1]) ?? 0,
507
+ startStars: null,
508
+ endStars: totalMatch ? safeInt(totalMatch[1]) : null,
509
+ growthPct: null,
510
+ rankChange: steadyMatch && text(steadyMatch[1]) !== '—' ? safeInt(steadyMatch[1]) : null,
511
+ language: langMatch ? text(langMatch[1]) : '',
512
+ license: '',
513
+ });
514
+ }
515
+ if (rows.length === 0) return null;
516
+ return rows.sort((a, b) => a.rank - b.rank);
517
+ }
518
+
519
+ /** 从周榜页取统计周期文字(例如 "October 26, 2026 — November 1, 2026")。 */
520
+ function parseWeeklyWindow(html) {
521
+ const m = html.match(/([A-Z][a-z]+ \d{1,2}, \d{4})\s*—\s*([A-Z][a-z]+ \d{1,2}, \d{4})/);
522
+ return m ? { start: m[1], end: m[2] } : null;
523
+ }
524
+
525
+ /** 当前这一周(周一)的日期串,用于拼周榜 URL。 */
526
+ /**
527
+ * 数据源时区下的「本周一」(周榜 URL 用的就是它)。
528
+ *
529
+ * 必须按 Asia/Shanghai 算:周榜是「周一→周日」切窗口的,
530
+ * 若按用户本地时区算,跨时区用户会请求到错误的周。
531
+ */
532
+ function currentWeekMonday() {
533
+ const todayKey = dayKeyInSourceTz(); // YYYY-MM-DD,数据源时区
534
+ // 用 UTC 解析这个「日期串」再算星期,避免再次引入本地时区
535
+ const [y, m, d] = todayKey.split('-').map(Number);
536
+ const dt = new Date(Date.UTC(y, m - 1, d));
537
+ const dow = dt.getUTCDay(); // 0=周日
538
+ const diff = dow === 0 ? -6 : 1 - dow;
539
+ dt.setUTCDate(dt.getUTCDate() + diff);
540
+ return dt.toISOString().slice(0, 10);
541
+ }
542
+
543
+ /** 解析某一周页面里标注的期号(用页面里的 "week of YYYY-MM-DD" 或直接传入的 Monday)。 */
544
+ async function fetchWeekly() {
545
+ const candidates = [];
546
+ const monday = currentWeekMonday();
547
+ candidates.push(monday);
548
+ // 往前推两周,避免「这周一还没发榜」时直接空手
549
+ for (let k = 1; k <= 2; k += 1) {
550
+ // 用 UTC 做日期减法:monday 本身就是「数据源时区的日期串」,
551
+ // 再用本地 Date 去减会被机器时区带偏一天。
552
+ const [y, m, dd] = monday.split('-').map(Number);
553
+ const d = new Date(Date.UTC(y, m - 1, dd));
554
+ d.setUTCDate(d.getUTCDate() - 7 * k);
555
+ candidates.push(d.toISOString().slice(0, 10));
556
+ }
557
+
558
+ let lastError = null;
559
+ for (const week of candidates) {
560
+ const url = BASE + '/weekly/' + week;
561
+ try {
562
+ const html = await fetchText(url);
563
+ const rows = parseWeeklyHtml(html);
564
+ if (rows && rows.length > 0) {
565
+ return { week, rows, window: parseWeeklyWindow(html), url };
566
+ }
567
+ lastError = new Error('周榜页解析失败(页面上找不到榜单行)');
568
+ } catch (error) {
569
+ lastError = error;
570
+ }
571
+ }
572
+ throw lastError || new Error('周榜取数失败');
573
+ }
574
+
575
+ /* --------------------------------------------------------- 单项目详情解析 */
576
+
577
+ /** 从项目分析页里取「编辑结论」那一段(Hysen 的每篇分析都有这一节)。 */
578
+ function parseConclusion(html) {
579
+ // 分析页结构:<h3>Editorial conclusion</h3><p>...</p>
580
+ const m = html.match(/<h3[^>]*>\s*Editorial conclusion\s*<\/h3>\s*<p[^>]*>([\s\S]*?)<\/p>/i);
581
+ if (m) {
582
+ const body = m[1];
583
+ // 段内可能分多个 <p>?这里只取第一个 <p> 的正文,保留其全部文字
584
+ const cleaned = cleanHtml(body);
585
+ if (cleaned) return cleaned;
586
+ }
587
+ return null;
588
+ }
589
+
590
+ /** 从项目分析页里取「这是什么」(At a glance → What is it?)。 */
591
+ /**
592
+ * 从项目页里抠出仓库所有者头像的 **CDN 直链**。
593
+ *
594
+ * 为什么不走 `github.com/{owner}.png`:那条路要靠 github.com 302 重定向到 CDN,
595
+ * 而 github.com 在国内网络环境下常不可达 —— 但 `avatars.githubusercontent.com`
596
+ * 本身可直连。所以直接从项目页里读取现成的 CDN 地址。
597
+ */
598
+ function parseAvatarUrl(html) {
599
+ const m = html.match(/avatars\.githubusercontent\.com\/u\/(\d+)(?:\?[^"'\s]*)?/);
600
+ return m ? 'https://avatars.githubusercontent.com/u/' + m[1] + '?s=96&v=4' : null;
601
+ }
602
+
603
+ function parseWhatIsIt(html) {
604
+ const m = html.match(/What is it\?[\s\S]{0,80}?<\/dt>[\s\S]*?<dd[^>]*>([\s\S]*?)<\/dd>/i)
605
+ || html.match(/<h3[^>]*>\s*What is it\?\s*<\/h3>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/i);
606
+ if (m) {
607
+ const cleaned = cleanHtml(m[1]);
608
+ if (cleaned) return cleaned;
609
+ }
610
+ return null;
611
+ }
612
+
613
+ /**
614
+ * 解析星数历史 SVG,还原成数据点。
615
+ *
616
+ * SVG 里:
617
+ * <path d="M60 325.8 L73.8 325.8 L87.5 325.8 ..." />
618
+ * y 轴刻度线:每个 <line y1=... /> 配一个数值 <text>(0 / 200k / 400k / 600k)
619
+ * x 轴:Sep 12 / Sep 25 / ... / Nov 3
620
+ *
621
+ * 返回 [{ index, stars }](按 x 位置排序)与坐标轴信息;解析不出返回 null。
622
+ */
623
+ function parseChartSvg(svg) {
624
+ const pathMatch = svg.match(/<path\s+d="([^"]+)"/);
625
+ if (!pathMatch) return null;
626
+ const d = pathMatch[1];
627
+ const points = [];
628
+ const re = /([ML])\s*([-\d.]+)\s+([-\d.]+)/g;
629
+ let m = re.exec(d);
630
+ while (m) {
631
+ points.push({ cmd: m[1], x: Number(m[2]), y: Number(m[3]) });
632
+ m = re.exec(d);
633
+ }
634
+ if (points.length < 3) return null;
635
+
636
+ // y 轴刻度:<line x1="60" x2="776" y1="326" y2="326" ...> 后面紧跟 <text x="52" y="330">0</text>
637
+ const ticks = [];
638
+ const tickRe = /<line[^>]*y1="([\d.]+)"[^>]*\/>\s*<text[^>]*y="([\d.]+)"[^>]*>([^<]+)<\/text>/g;
639
+ let tm = tickRe.exec(svg);
640
+ while (tm) {
641
+ const label = text(tm[3]);
642
+ const value = /k$/i.test(label) ? Number(label.replace(/k$/i, '')) * 1000 : safeInt(label);
643
+ if (value !== null && Number.isFinite(value)) {
644
+ ticks.push({ y: Number(tm[1]), value });
645
+ }
646
+ tm = tickRe.exec(svg);
647
+ }
648
+
649
+ // x 轴日期标签
650
+ const dates = [];
651
+ const dateRe = /<text[^>]*x="([\d.]+)"[^>]*y="348"[^>]*>([A-Z][a-z]{2} \d{1,2})<\/text>/g;
652
+ let dm = dateRe.exec(svg);
653
+ while (dm) {
654
+ dates.push({ x: Number(dm[1]), label: text(dm[2]) });
655
+ dm = dateRe.exec(svg);
656
+ }
657
+
658
+ if (ticks.length < 2) return null;
659
+
660
+ // 用刻度做线性回归:value = a*y + b(刻度可能不是严格线性,但 SVG 是线性的)
661
+ ticks.sort((a, b) => a.y - b.y);
662
+ const y1 = ticks[0].y;
663
+ const v1 = ticks[0].value;
664
+ const y2 = ticks[ticks.length - 1].y;
665
+ const v2 = ticks[ticks.length - 1].value;
666
+ if (y2 === y1) return null;
667
+ const a = (v2 - v1) / (y2 - y1);
668
+
669
+ const series = points
670
+ .map((p, index) => ({ x: p.x, y: p.y, stars: v1 + (p.y - y1) * a, index }))
671
+ .sort((p, q) => p.x - q.x);
672
+
673
+ /**
674
+ * 把 x 像素还原成**真实日期**(YYYY-MM-DD)。
675
+ *
676
+ * 为什么必须还原:不同项目的曲线跨度差别很大(有的从 7 月开始、有的从 9 月开始),
677
+ * 但终点都是同一个快照日。多曲线对比时只有按真实日期对齐才有可比性,
678
+ * 按「第几个点」硬叠会把不同时间段的走势错位拼在一起。
679
+ *
680
+ * 做法:拿 x 轴首尾两个日期刻度做线性插值。刻度解析不出来就跳过日期,只留像素。
681
+ */
682
+ const sortedDates = dates.sort((p, q) => p.x - q.x);
683
+ let dated = false;
684
+ if (sortedDates.length >= 2) {
685
+ const first = sortedDates[0];
686
+ const last = sortedDates[sortedDates.length - 1];
687
+ const d0 = parseAxisDate(first.label);
688
+ const d1 = parseAxisDate(last.label);
689
+ if (d0 !== null && d1 !== null && last.x !== first.x) {
690
+ const msPerPx = (d1 - d0) / (last.x - first.x);
691
+ /**
692
+ * 日期还原:**用 UTC 构造、用 UTC 取日期串**。
693
+ *
694
+ * 之前写的是 new Date(ts).toISOString().slice(0,10) —— 本身没问题,
695
+ * 但 ts 是「UTC 当天 00:00」再按像素插值算出来的,插值后可能落到
696
+ * 前一天的 23:xx 或当天的 01:xx;只要机器时区不是 UTC,本地读法就会差一天。
697
+ * 这里统一用 UTC 口径取 YYYY-MM-DD,结果与运行机器的时区无关。
698
+ */
699
+ const seen = new Set();
700
+ for (const p of series) {
701
+ const ts = Math.round(d0 + (p.x - first.x) * msPerPx);
702
+ // 先取到「天」,再把时间钉回当天 00:00 UTC —— 避免插值把相邻点挤到同一天
703
+ const utcDay = Date.UTC(
704
+ new Date(ts).getUTCFullYear(),
705
+ new Date(ts).getUTCMonth(),
706
+ new Date(ts).getUTCDate(),
707
+ );
708
+ p.date = new Date(utcDay).toISOString().slice(0, 10);
709
+ /**
710
+ * 说明:同一个日期可能对应多个数据点。
711
+ * 这不是解析错误 —— SVG 画布里一天只有约 14px,相邻两点很可能落在
712
+ * 同一个「天」里。保留原始 x 像素值(前端的曲线就是按 x 比例画的),
713
+ * 日期只用来对齐时间轴和显示,不用于去重。
714
+ */
715
+ if (seen.has(p.date)) p.dupDate = true;
716
+ else seen.add(p.date);
717
+ }
718
+ dated = true;
719
+ }
720
+ }
721
+
722
+ const titleMatch = svg.match(/<title>([^<]+)<\/title>/);
723
+ return {
724
+ title: titleMatch ? text(titleMatch[1]) : '',
725
+ series,
726
+ xLabels: sortedDates,
727
+ yTicks: ticks,
728
+ /** 首尾日期(有的话),方便前端直接按日期对齐 */
729
+ range: dated ? { start: series[0].date, end: series[series.length - 1].date } : null,
730
+ };
731
+ }
732
+
733
+ /** 解析 x 轴上的 "Sep 12" 这种标签成时间戳(年份默认今年)。 */
734
+ function parseAxisDate(label) {
735
+ const m = String(label || '').match(/^([A-Z][a-z]{2}) (\d{1,2})$/);
736
+ if (!m) return null;
737
+ const months = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
738
+ const month = months[m[1]];
739
+ if (month === undefined) return null;
740
+ const day = Number(m[2]);
741
+ // 基准年同样取**数据源时区**的今年,避免跨年边界上差一年
742
+ const yearNow = Number(dayKeyInSourceTz().slice(0, 4));
743
+ const ts = Date.UTC(yearNow, month, day);
744
+ // 若推出来比「现在」还晚很多,说明那是去年的日期(跨年)
745
+ const nowMs = Date.now();
746
+ return ts > nowMs + 7 * 24 * 3600 * 1000 ? Date.UTC(yearNow - 1, month, day) : ts;
747
+ }
748
+
749
+ /* ------------------------------------------------------------ 预翻译 */
750
+
751
+ /**
752
+ * 后台预翻译:取完榜单后,顺手把前 N 个项目的简介翻成中文。
753
+ *
754
+ * 为什么:用户切到「中文」时才翻译的话,首次要等 20 条模型调用 —— 体感就是
755
+ * 「点了中文,半天才出来」。预翻译在用户还没切过去的时候就把活干了,
756
+ * 等他要中文时,绝大多数已经有译文(命中缓存,秒出)。
757
+ *
758
+ * 三条约束:
759
+ * · **低优先级**:串行、限并发 4,不抢用户当前请求的带宽;
760
+ * · **失败静默**:翻译失败只是没预存,用户切过去时还会再试,不报错;
761
+ * · **只翻前 24 个**:榜单展示上限就是 20,多翻没用。
762
+ */
763
+ const PREWARM_LIMIT = 24;
764
+ let prewarmRunning = false;
765
+
766
+ async function prewarmTranslation(ctx, rows) {
767
+ if (prewarmRunning || !ctx) return;
768
+ const targets = (rows || []).slice(0, PREWARM_LIMIT).filter((r) => r && r.desc);
769
+ if (targets.length === 0) return;
770
+ prewarmRunning = true;
771
+ try {
772
+ let cursor = 0;
773
+ const worker = async () => {
774
+ for (;;) {
775
+ const i = cursor++;
776
+ if (i >= targets.length) return;
777
+ try {
778
+ await translateDesc(ctx, targets[i].desc);
779
+ } catch {
780
+ /* 单条失败忽略 */
781
+ }
782
+ }
783
+ };
784
+ // 并发 4:比用户请求低,避免抢带宽
785
+ await Promise.all(Array.from({ length: Math.min(4, targets.length) }, worker));
786
+ } finally {
787
+ prewarmRunning = false;
788
+ }
789
+ }
790
+
791
+ /* ---------------------------------------------------------------- 取数编排 */
792
+
793
+ /** 拉一份 CSV 榜单。 */
794
+ async function fetchCsvBoard(path) {
795
+ const url = BASE + path;
796
+ const raw = await fetchText(url, { accept: 'text/csv,text/plain,*/*' });
797
+ const table = parseCsv(raw);
798
+ if (table.length < 2) throw new Error('CSV 为空');
799
+ const header = table[0].map((h) => text(h));
800
+ const rows = [];
801
+ let firstRaw = null;
802
+ for (let i = 1; i < table.length; i += 1) {
803
+ const cells = table[i];
804
+ if (cells.length !== header.length) continue;
805
+ const item = {};
806
+ for (let c = 0; c < header.length; c += 1) item[header[c]] = cells[c];
807
+ if (!text(item.repository)) continue;
808
+ if (firstRaw === null) firstRaw = cells; // 跟随**实际入列**的第一行,避免跳过行后错位
809
+ rows.push(boardRow(item, i));
810
+ }
811
+ const rawWindow = { start: '', end: '' };
812
+ if (firstRaw) {
813
+ const idxStart = header.indexOf('window_start');
814
+ const idxEnd = header.indexOf('window_end');
815
+ if (idxStart >= 0) rawWindow.start = text(firstRaw[idxStart]);
816
+ if (idxEnd >= 0) rawWindow.end = text(firstRaw[idxEnd]);
817
+ }
818
+ return {
819
+ rows,
820
+ window: rawWindow.start || rawWindow.end ? rawWindow : null,
821
+ };
822
+ }
823
+
824
+ /** 取三个榜单(互不阻塞:任一失败不影响其它)。 */
825
+ async function fetchAllBoards() {
826
+ const jobs = [
827
+ { id: 'day', label: '日榜', run: () => fetchCsvBoard('/data/star-growth-24h.csv') },
828
+ { id: 'week', label: '周榜', run: () => fetchWeekly() },
829
+ { id: 'month', label: '月榜', run: () => fetchCsvBoard('/data/star-growth-30d.csv') },
830
+ ];
831
+ const results = await Promise.all(
832
+ jobs.map(async (job) => {
833
+ const cacheKey = 'board:' + job.id;
834
+ const cached = cacheGet(cacheKey, BOARD_TTL_MS);
835
+ if (cached) return { ...cached, cached: true };
836
+ try {
837
+ const data = await job.run();
838
+ const value = {
839
+ id: job.id,
840
+ label: job.label,
841
+ ok: true,
842
+ fetchedAt: nowIso(),
843
+ rows: data.rows,
844
+ window: data.window,
845
+ week: data.week || null,
846
+ error: null,
847
+ };
848
+ cacheSet(cacheKey, value);
849
+ return { ...value, cached: false };
850
+ } catch (error) {
851
+ const stale = cache.get(cacheKey);
852
+ lastError = { at: nowIso(), scope: job.id, message: String(error && error.message ? error.message : error) };
853
+ // 取数失败:如果有更早的成功结果,标 stale 一并返回(界面显示"这是几点的数据")
854
+ return {
855
+ id: job.id,
856
+ label: job.label,
857
+ ok: false,
858
+ fetchedAt: null,
859
+ rows: stale ? stale.value.rows : [],
860
+ window: stale ? stale.value.window : null,
861
+ week: stale ? stale.value.week : null,
862
+ error: String(error && error.message ? error.message : error),
863
+ staleAt: stale ? new Date(stale.at).toISOString() : null,
864
+ cached: false,
865
+ };
866
+ }
867
+ }),
868
+ );
869
+ return results;
870
+ }
871
+
872
+ /**
873
+ * 取单项目详情:简介 + 星数曲线。
874
+ *
875
+ * 两件事各自独立失败:简介取不到不影响曲线,曲线解析不出不影响简介。
876
+ * 结果缓存 6 小时(Hysen 的曲线一天才动一次)。
877
+ */
878
+ /* ------------------------------------------------------------ 中文翻译 */
879
+
880
+ /**
881
+ * 简介翻译(用 DSH 自带的模型)。
882
+ *
883
+ * 为什么放在宿主侧:模型调用依赖 ctx.llm,浏览器半侧拿不到;而且翻好的结果要落缓存,
884
+ * 同一句话只翻一次。
885
+ *
886
+ * 三条口径:
887
+ * 1. **只翻不做别的**:只把英文简介翻成中文,不改写、不扩写、不加评论。
888
+ * 2. **按原文缓存**:原文改了就重翻(键是原文的哈希),不拿旧译文指鹿为马。
889
+ * 3. **翻不了就留空**:模型不可用 / 超时 / 答非所问时 descZh 为 null,
890
+ * 界面回退显示英文原文 —— 绝不拿半截英文半截中文糊弄。
891
+ */
892
+
893
+ /**
894
+ * 翻译结果缓存:key = 原文 sha1,value = zh。
895
+ *
896
+ * 翻译一次要几秒到几十秒,重启后不该重来 —— 所以和榜单缓存一起落盘。
897
+ */
898
+ const zhCache = new Map();
899
+ const ZH_MAX = 600;
900
+ let zhLoaded = false;
901
+ let zhFlushTimer = null;
902
+
903
+ function zhFilePath() {
904
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh');
905
+ return join(home, 'dsh-github-hot', 'zh.json');
906
+ }
907
+
908
+ function ensureZhLoaded() {
909
+ if (zhLoaded) return;
910
+ zhLoaded = true;
911
+ try {
912
+ const file = zhFilePath();
913
+ if (!existsSync(file)) return;
914
+ const data = JSON.parse(readFileSync(file, 'utf8'));
915
+ if (data && typeof data === 'object') {
916
+ for (const [k, v] of Object.entries(data)) {
917
+ if (typeof v === 'string') zhCache.set(k, v);
918
+ }
919
+ }
920
+ } catch {
921
+ /* 读不出来当空 */
922
+ }
923
+ }
924
+
925
+ function scheduleZhFlush() {
926
+ if (zhFlushTimer !== null) return;
927
+ zhFlushTimer = setTimeout(() => {
928
+ zhFlushTimer = null;
929
+ try {
930
+ const file = zhFilePath();
931
+ const dir = join(file, '..');
932
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
933
+ let entries = [...zhCache.entries()];
934
+ if (entries.length > ZH_MAX) entries = entries.slice(-ZH_MAX);
935
+ writeFileSync(file, JSON.stringify(Object.fromEntries(entries)), 'utf8');
936
+ } catch {
937
+ /* 忽略 */
938
+ }
939
+ }, 3000);
940
+ if (typeof zhFlushTimer.unref === 'function') zhFlushTimer.unref();
941
+ }
942
+
943
+ function sha1(input) {
944
+ return createHash('sha1').update(String(input)).digest('hex');
945
+ }
946
+
947
+ function zhCacheGet(text) {
948
+ ensureZhLoaded();
949
+ const hit = zhCache.get(sha1(text));
950
+ return hit ? hit.zh : null;
951
+ }
952
+
953
+ function zhCacheSet(text, zh) {
954
+ ensureZhLoaded();
955
+ zhCache.set(sha1(text), zh);
956
+ scheduleZhFlush();
957
+ if (zhCache.size > ZH_MAX) {
958
+ const keys = [...zhCache.keys()].slice(0, Math.floor(ZH_MAX / 2));
959
+ for (const k of keys) zhCache.delete(k);
960
+ }
961
+ }
962
+
963
+ /** 安全取 DSH 服务(未声明的服务直接属性访问可能抛错)。 */
964
+ function service(ctx, name) {
965
+ try {
966
+ if (typeof ctx?.get === 'function') {
967
+ const viaGet = ctx.get(name);
968
+ if (viaGet !== undefined && viaGet !== null) return viaGet;
969
+ }
970
+ } catch {
971
+ /* 落回属性读取 */
972
+ }
973
+ try {
974
+ return ctx?.[name];
975
+ } catch {
976
+ return undefined;
977
+ }
978
+ }
979
+
980
+ /**
981
+ * 惰性加载官方 LLM 包。
982
+ *
983
+ * 裸 import 在 link: 挂载的插件里常常解析不到(插件的 node_modules 里没有这个包),
984
+ * 所以再按 DSH 的目录约定试几个绝对路径;全失败就返回 null,用内置消息结构兜底。
985
+ */
986
+ let llmModulePromise;
987
+ function llmEntryCandidates() {
988
+ const home = homedir();
989
+ const out = [];
990
+ for (const profile of ['desktop', 'web', 'headless', 'dsh-tui']) {
991
+ out.push(join(home, '.dsh', 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh-llm', 'lib', 'index.js'));
992
+ }
993
+ // 共享层:~/.dsh/profiles/node_modules
994
+ out.push(join(home, '.dsh', 'profiles', 'node_modules', '@deepseek-ai', 'dsh-llm', 'lib', 'index.js'));
995
+ return out;
996
+ }
997
+
998
+ function loadLlmModule() {
999
+ if (llmModulePromise === undefined) {
1000
+ llmModulePromise = (async () => {
1001
+ try {
1002
+ const mod = await import('@deepseek-ai/dsh-llm');
1003
+ if (typeof mod?.createUserMessage === 'function' || typeof mod?.BlockAssembler === 'function') return mod;
1004
+ } catch {
1005
+ /* 试绝对路径 */
1006
+ }
1007
+ for (const candidate of llmEntryCandidates()) {
1008
+ if (!existsSync(candidate)) continue;
1009
+ try {
1010
+ const mod = await import(pathToFileURL(candidate).href);
1011
+ if (typeof mod?.createUserMessage === 'function' || typeof mod?.BlockAssembler === 'function') return mod;
1012
+ } catch {
1013
+ /* 试下一个 */
1014
+ }
1015
+ }
1016
+ return null;
1017
+ })();
1018
+ }
1019
+ return llmModulePromise;
1020
+ }
1021
+
1022
+ /** 从流式分片里拼出文本(BlockAssembler 不可用时的兜底)。 */
1023
+ function textFromChunks(chunks) {
1024
+ const parts = [];
1025
+ let finish;
1026
+ for (const chunk of chunks) {
1027
+ if (!chunk || typeof chunk !== 'object') continue;
1028
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') parts.push(chunk.text);
1029
+ else if (chunk.type === 'text' && typeof chunk.text === 'string') parts.push(chunk.text);
1030
+ if (chunk.type === 'finish' || chunk.finish) finish = chunk.finish || chunk;
1031
+ }
1032
+ return { text: parts.join(''), finish };
1033
+ }
1034
+
1035
+ /**
1036
+ * 把一段英文简介翻成中文。返回中文字符串,或 null(表示翻不了)。
1037
+ * 任何异常都在这里被吃掉 —— 翻译失败不该让整个面板挂掉。
1038
+ */
1039
+ /**
1040
+ * 挑一个可用于翻译的模型。
1041
+ *
1042
+ * resolveModelInfo 的签名是 (provider, model),空调用必然失败,所以这里必须:
1043
+ * 1. 先问宿主「当前默认模型」是什么(agentDefaultModel 服务);
1044
+ * 2. 拿不到就枚举 llm.listProviders(),再对每个 provider 调 listModels(),
1045
+ * 取第一个模型。只认**真的有模型**的 provider,不猜。
1046
+ * 全都拿不到返回 null —— 翻译降级为「只显示英文」,不影响其它功能。
1047
+ */
1048
+ async function pickModel(ctx, llm) {
1049
+ // 1) 宿主默认模型
1050
+ try {
1051
+ const svc = service(ctx, 'agentDefaultModel');
1052
+ const def = typeof svc?.get === 'function' ? svc.get() : svc?.current ?? svc?.value;
1053
+ const resolved = await def;
1054
+ const provider = resolved?.provider ?? resolved?.providerId;
1055
+ const model = resolved?.model ?? resolved?.modelId;
1056
+ if (typeof provider === 'string' && typeof model === 'string' && provider && model) {
1057
+ return { provider, model };
1058
+ }
1059
+ } catch {
1060
+ /* 继续枚举 */
1061
+ }
1062
+
1063
+ // 2) 枚举 provider 与模型
1064
+ try {
1065
+ if (typeof llm.listProviders === 'function' && typeof llm.listModels === 'function') {
1066
+ const providers = await llm.listProviders();
1067
+ for (const p of Array.isArray(providers) ? providers : []) {
1068
+ const pid = typeof p === 'string' ? p : p?.id ?? p?.provider;
1069
+ if (typeof pid !== 'string' || !pid) continue;
1070
+ try {
1071
+ const models = await llm.listModels(pid);
1072
+ const first = Array.isArray(models) ? models[0] : null;
1073
+ const mid = typeof first === 'string' ? first : first?.id;
1074
+ if (typeof mid === 'string' && mid) return { provider: pid, model: mid };
1075
+ } catch {
1076
+ /* 这个 provider 没有可用模型,试下一个 */
1077
+ }
1078
+ }
1079
+ }
1080
+ } catch {
1081
+ /* 放弃 */
1082
+ }
1083
+ return null;
1084
+ }
1085
+
1086
+ async function translateDesc(ctx, text) {
1087
+ const src = String(text || '').trim();
1088
+ if (!src) return null;
1089
+ const cached = zhCacheGet(src);
1090
+ if (cached) return cached;
1091
+
1092
+ const llm = service(ctx, 'llm');
1093
+ if (llm === undefined || typeof llm.stream !== 'function') return null;
1094
+
1095
+ // 模型解析:resolveModelInfo(provider, model) 需要显式参数,不能空调用。
1096
+ // 顺序:宿主默认模型 → 枚举 provider 取第一个可用模型。
1097
+ const picked = await pickModel(ctx, llm);
1098
+ if (!picked) return null;
1099
+ const { provider, model } = picked;
1100
+
1101
+ const llmMod = await loadLlmModule();
1102
+ const system =
1103
+ '你是技术编辑。把给定的一段英文项目简介翻译成简体中文。' +
1104
+ '要求:只输出译文本身;保留专有名词(项目名、工具名、命令、许可证名)原样不译;' +
1105
+ '不要任何解释、前缀、Markdown 或引号;译文长度与原文相当,不超过 120 个汉字。';
1106
+ const userText = src;
1107
+
1108
+ const message =
1109
+ typeof llmMod?.createUserMessage === 'function'
1110
+ ? llmMod.createUserMessage({ content: [{ type: 'text', text: userText }], source: { kind: 'user' } })
1111
+ : { id: randomUUID(), role: 'user', content: [{ type: 'text', text: userText }], source: { kind: 'user' } };
1112
+
1113
+ const options = {
1114
+ provider,
1115
+ model,
1116
+ sessionId: 'dsh-github-hot:translate',
1117
+ messages: [message],
1118
+ system,
1119
+ temperature: 0.2,
1120
+ reasoningEffort: 'off',
1121
+ // 超时缩短到 12 秒:一条译文不值得让用户等 45 秒。
1122
+ // 超时的那条会留英文原文,下次进插件再翻(有缓存,只补缺的)。
1123
+ signal: AbortSignal.timeout(12000),
1124
+ };
1125
+
1126
+ try {
1127
+ let raw = '';
1128
+ if (typeof llmMod?.BlockAssembler === 'function') {
1129
+ const assembler = new llmMod.BlockAssembler();
1130
+ for await (const chunk of llm.stream(options)) assembler.push(chunk);
1131
+ raw = assembler
1132
+ .blocks()
1133
+ .filter((b) => b.type === 'text')
1134
+ .map((b) => ('text' in b ? b.text : ''))
1135
+ .join('');
1136
+ } else {
1137
+ const chunks = [];
1138
+ for await (const chunk of llm.stream(options)) chunks.push(chunk);
1139
+ raw = textFromChunks(chunks).text;
1140
+ }
1141
+ const zh = String(raw || '')
1142
+ .replace(/^["'“”「」\s]+|["'“”「」\s]+$/g, '')
1143
+ .trim();
1144
+ // 基本校验:必须含中文,且不是原文照抄,否则当作失败
1145
+ if (!zh || !/[\u4e00-\u9fa5]/.test(zh) || zh === src) return null;
1146
+ zhCacheSet(src, zh);
1147
+ return zh;
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * 判断一份已缓存的详情是否「结构够新」。
1155
+ *
1156
+ * 光靠版本号不够 —— 内存里也可能躺着进程启动后写入、但结构已旧的条目。
1157
+ * 这里直接按字段校验:曲线如果有,每个点必须带 date,否则视为无效(触发重取)。
1158
+ */
1159
+ function detailShapeOk(detail) {
1160
+ if (!detail || typeof detail !== 'object') return false;
1161
+ const chart = detail.chart;
1162
+ if (!chart) return true; // 没曲线是允许的(可能只是取不到)
1163
+ if (!Array.isArray(chart.series) || chart.series.length === 0) return false;
1164
+ return chart.series.every((p) => p && typeof p.date === 'string' && p.date.length === 10);
1165
+ }
1166
+
1167
+ /**
1168
+ * 在途去重:同一个项目同时被多处问(批量接口 + 详情弹窗)时只发一轮请求。
1169
+ * 没有这个的话,快速点开卡片会把同一页 Hysen 抓两次。
1170
+ */
1171
+ const detailInflight = new Map();
1172
+
1173
+ async function fetchDetail(repository) {
1174
+ const repo = text(repository);
1175
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
1176
+ const error = new Error('仓库名格式不对(应形如 owner/repo)');
1177
+ error.status = 400;
1178
+ throw error;
1179
+ }
1180
+ const slug = slugOf(repo);
1181
+ const pending = detailInflight.get(slug);
1182
+ if (pending) return pending;
1183
+ const task = fetchDetailInner(repo, slug).finally(() => {
1184
+ detailInflight.delete(slug);
1185
+ });
1186
+ detailInflight.set(slug, task);
1187
+ return task;
1188
+ }
1189
+
1190
+ async function fetchDetailInner(repo, slug) {
1191
+ const cacheKey = 'detail:' + slug;
1192
+ const cached = cacheGet(cacheKey, DETAIL_TTL_MS);
1193
+ if (cached && detailShapeOk(cached)) return { ...cached, cached: true };
1194
+ if (cached) cache.delete(cacheKey); // 结构过期,丢掉重取
1195
+
1196
+ const [pageResult, chartResult] = await Promise.allSettled([
1197
+ fetchText(BASE + '/projects/' + slug),
1198
+ fetchText(BASE + '/chart/' + slug + '.svg', { accept: 'image/svg+xml,*/*' }),
1199
+ ]);
1200
+
1201
+ let desc = null;
1202
+ let descSource = 'none';
1203
+ let pageUrl = BASE + '/projects/' + slug;
1204
+ let started = null;
1205
+ let whatIsIt = null;
1206
+
1207
+ let avatarUrl = null;
1208
+ if (pageResult.status === 'fulfilled') {
1209
+ const html = pageResult.value;
1210
+ avatarUrl = parseAvatarUrl(html);
1211
+ const conclusion = parseConclusion(html);
1212
+ whatIsIt = parseWhatIsIt(html);
1213
+ started = (html.match(/Last updated\s+([A-Z][a-z]+ \d{1,2}, \d{4})/i) || [])[1] || null;
1214
+ if (conclusion) {
1215
+ desc = conclusion.length > DESC_MAX_CHARS ? conclusion.slice(0, DESC_MAX_CHARS) + '…' : conclusion;
1216
+ descSource = 'conclusion';
1217
+ } else if (whatIsIt) {
1218
+ desc = whatIsIt.length > DESC_MAX_CHARS ? whatIsIt.slice(0, DESC_MAX_CHARS) + '…' : whatIsIt;
1219
+ descSource = 'overview';
1220
+ }
1221
+ }
1222
+
1223
+ let chart = null;
1224
+ let chartError = null;
1225
+ if (chartResult.status === 'fulfilled') {
1226
+ chart = parseChartSvg(chartResult.value);
1227
+ if (!chart) chartError = '曲线 SVG 解析失败';
1228
+ } else {
1229
+ chartError = String(chartResult.reason && chartResult.reason.message ? chartResult.reason.message : chartResult.reason);
1230
+ }
1231
+
1232
+ const value = {
1233
+ repository: repo,
1234
+ slug,
1235
+ pageUrl,
1236
+ /** 所有者头像的 CDN 直链(从项目页里读出来的);没有就 null */
1237
+ avatarUrl,
1238
+ desc,
1239
+ descSource,
1240
+ whatIsIt,
1241
+ analysisUpdated: started,
1242
+ chart,
1243
+ chartError,
1244
+ pageError:
1245
+ pageResult.status === 'rejected'
1246
+ ? String(pageResult.reason && pageResult.reason.message ? pageResult.reason.message : pageResult.reason)
1247
+ : null,
1248
+ fetchedAt: nowIso(),
1249
+ };
1250
+ cacheSet(cacheKey, value);
1251
+ return { ...value, cached: false };
1252
+ }
1253
+
1254
+ /* ---------------------------------------------------------------- 路由 */
1255
+
1256
+ function sendJson(res, status, payload) {
1257
+ const body = Buffer.from(JSON.stringify(payload), 'utf8');
1258
+ res.writeHead(status, {
1259
+ 'Content-Type': 'application/json; charset=utf-8',
1260
+ 'Content-Length': body.length,
1261
+ 'Cache-Control': 'no-store',
1262
+ });
1263
+ res.end(body);
1264
+ }
1265
+
1266
+ /* ------------------------------------------------------------ 头像 */
1267
+
1268
+ /**
1269
+ * 仓库所有者头像代理。
1270
+ *
1271
+ * 为什么不让浏览器直接引 `github.com/{owner}.png`:
1272
+ * · 一张卡一次外网请求,20 张卡就是 20 次(还跨域);
1273
+ * · 每次重开面板都会重下。
1274
+ * 所以这里由宿主取一次、缓存起来,浏览器只打本机接口。
1275
+ *
1276
+ * 头像按 owner 缓存 7 天(头像几乎不变),失败负缓存 10 分钟(免得反复撞墙)。
1277
+ */
1278
+ const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000;
1279
+ const AVATAR_FAIL_TTL_MS = 10 * 60 * 1000;
1280
+ const avatarCache = new Map(); // owner -> { at, type, body } | { at, error }
1281
+
1282
+ /** 头像按 owner 缓存(含磁盘):{ at, type, body(base64) } */
1283
+ function avatarCacheDir() {
1284
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh');
1285
+ return join(home, 'dsh-github-hot', 'avatars');
1286
+ }
1287
+
1288
+ /** 从磁盘读一张已缓存的头像,返回 { type, body } 或 null。 */
1289
+ function readAvatarDisk(owner) {
1290
+ try {
1291
+ // 兜底:文件名只允许安全字符,双保险(调用方已校验过一次)
1292
+ if (!/^[A-Za-z0-9-]+$/.test(owner)) return null;
1293
+ const file = join(avatarCacheDir(), owner + '.json');
1294
+ if (!existsSync(file)) return null;
1295
+ const data = JSON.parse(readFileSync(file, 'utf8'));
1296
+ if (!data || typeof data.body !== 'string') return null;
1297
+ if (Date.now() - Number(data.at || 0) > AVATAR_TTL_MS) return null;
1298
+ return { type: data.type || 'image/png', body: Buffer.from(data.body, 'base64') };
1299
+ } catch {
1300
+ return null;
1301
+ }
1302
+ }
1303
+
1304
+ /** 把头像写到磁盘(base64 存 JSON,避免二进制与元数据分开管)。 */
1305
+ function writeAvatarDisk(owner, entry) {
1306
+ try {
1307
+ if (!/^[A-Za-z0-9-]+$/.test(owner)) return; // 兜底,绝不把非法名当文件名
1308
+ const dir = avatarCacheDir();
1309
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1310
+ writeFileSync(
1311
+ join(dir, owner + '.json'),
1312
+ JSON.stringify({ at: Date.now(), type: entry.type, body: entry.body.toString('base64') }),
1313
+ 'utf8',
1314
+ );
1315
+ pruneAvatarDir(); // 顺带清理过期/超量的头像
1316
+ } catch {
1317
+ /* 写不进去就只留内存 */
1318
+ }
1319
+ }
1320
+
1321
+ /** 头像文件上限:超了就按修改时间删最旧的(目录不能无限长)。 */
1322
+ const AVATAR_MAX_FILES = 400;
1323
+
1324
+ /**
1325
+ * 清理头像目录:删掉过期的和超出上限的。
1326
+ * 每次写新头像时顺带跑一次,成本很低(只是一次 readdir)。
1327
+ */
1328
+ function pruneAvatarDir() {
1329
+ try {
1330
+ const dir = avatarCacheDir();
1331
+ if (!existsSync(dir)) return;
1332
+ const names = readdirSync(dir).filter((n) => n.endsWith('.json'));
1333
+ if (names.length <= AVATAR_MAX_FILES) {
1334
+ // 数量不多时只清过期的
1335
+ const now = Date.now();
1336
+ for (const n of names) {
1337
+ const file = join(dir, n);
1338
+ try {
1339
+ const data = JSON.parse(readFileSync(file, 'utf8'));
1340
+ if (now - Number(data.at || 0) > AVATAR_TTL_MS) unlinkSync(file);
1341
+ } catch {
1342
+ unlinkSync(file); // 读不出来的也删掉,别让它一直占位
1343
+ }
1344
+ }
1345
+ return;
1346
+ }
1347
+ // 超上限:按 mtime 从旧到新删掉多余的
1348
+ const items = names.map((n) => {
1349
+ const file = join(dir, n);
1350
+ let mtime = 0;
1351
+ try { mtime = statSync(file).mtimeMs; } catch { /* 忽略 */ }
1352
+ return { file, mtime };
1353
+ }).sort((a, b) => a.mtime - b.mtime);
1354
+ const removeCount = items.length - AVATAR_MAX_FILES;
1355
+ for (let i = 0; i < removeCount; i += 1) {
1356
+ try { unlinkSync(items[i].file); } catch { /* 忽略 */ }
1357
+ }
1358
+ } catch {
1359
+ /* 清理失败不影响功能 */
1360
+ }
1361
+ }
1362
+
1363
+ /**
1364
+ * 尝试的图片来源顺序(**都不经过 github.com 的重定向**):
1365
+ * 1. 详情里从 Hysen 项目页抠到的 CDN 直链(最快且最准)
1366
+ * 2. GitHub 官方头像 CDN(只要知道用户 id)
1367
+ * 3. unavatar.io(第三方聚合,慢一点但覆盖面广)
1368
+ */
1369
+ function avatarSources(owner, knownUrl) {
1370
+ const out = [];
1371
+ if (knownUrl) out.push(knownUrl);
1372
+ out.push('https://unavatar.io/github/' + encodeURIComponent(owner));
1373
+ return out;
1374
+ }
1375
+
1376
+ /** 取某个仓库所有者头像(二进制),带内存 + 磁盘两级缓存。 */
1377
+ async function fetchAvatar(owner, knownUrl) {
1378
+ const key = String(owner || '').toLowerCase();
1379
+
1380
+ // ① 内存
1381
+ const mem = avatarCache.get(key);
1382
+ if (mem) {
1383
+ if (mem.body && Date.now() - mem.at < AVATAR_TTL_MS) return mem;
1384
+ if (mem.error && Date.now() - mem.at < AVATAR_FAIL_TTL_MS) {
1385
+ const err = new Error(mem.error);
1386
+ err.status = 502;
1387
+ throw err;
1388
+ }
1389
+ }
1390
+
1391
+ // ② 磁盘(重启后仍然有效)
1392
+ const disk = readAvatarDisk(key);
1393
+ if (disk) {
1394
+ const entry = { at: Date.now(), ...disk };
1395
+ avatarCache.set(key, entry);
1396
+ return entry;
1397
+ }
1398
+
1399
+ // ③ 出网
1400
+ let lastError = null;
1401
+ for (const url of avatarSources(key, knownUrl)) {
1402
+ const controller = new AbortController();
1403
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
1404
+ try {
1405
+ const response = await fetch(url, {
1406
+ headers: { 'User-Agent': UA, Accept: 'image/*' },
1407
+ signal: controller.signal,
1408
+ redirect: 'follow',
1409
+ });
1410
+ if (!response.ok) throw new Error('HTTP ' + response.status);
1411
+ const type = response.headers.get('content-type') || 'image/png';
1412
+ if (!type.startsWith('image/')) throw new Error('不是图片:' + type);
1413
+ const body = Buffer.from(await response.arrayBuffer());
1414
+ // 空图 / 1x1 占位都算失败,别把占位图当头像缓存起来
1415
+ if (body.length < 200) throw new Error('图片过小(' + body.length + 'B),疑似占位图');
1416
+ const entry = { at: Date.now(), type, body };
1417
+ avatarCache.set(key, entry);
1418
+ writeAvatarDisk(key, entry);
1419
+ return entry;
1420
+ } catch (error) {
1421
+ lastError = error;
1422
+ } finally {
1423
+ clearTimeout(timer);
1424
+ }
1425
+ }
1426
+
1427
+ const message = String(lastError && lastError.message ? lastError.message : lastError);
1428
+ avatarCache.set(key, { at: Date.now(), error: message });
1429
+ const err = new Error(message);
1430
+ err.status = 502;
1431
+ throw err;
1432
+ }
1433
+
1434
+ async function handleRoute(ctx, req, res) {
1435
+ const url = new URL(req.url || '/', 'http://localhost');
1436
+ const path = url.pathname.startsWith(ROUTE_PREFIX) ? url.pathname.slice(ROUTE_PREFIX.length) || '/' : url.pathname;
1437
+
1438
+ if (req.method !== 'GET') {
1439
+ sendJson(res, 405, { error: '只支持 GET' });
1440
+ return;
1441
+ }
1442
+
1443
+ try {
1444
+ if (path === '/boards' || path === '/') {
1445
+ const boards = await fetchAllBoards();
1446
+ sendJson(res, 200, {
1447
+ ok: true,
1448
+ plugin: name,
1449
+ version: VERSION,
1450
+ fetchedAt: nowIso(),
1451
+ boards,
1452
+ lastError,
1453
+ });
1454
+ return;
1455
+ }
1456
+
1457
+ if (path === '/detail') {
1458
+ const repository = url.searchParams.get('repo') || '';
1459
+ const detail = await fetchDetail(repository);
1460
+ // 简介翻成中文(拿不到译文就留 null,界面回退英文原文)
1461
+ let descZh = null;
1462
+ if (detail && detail.desc) {
1463
+ try { descZh = await translateDesc(ctx, detail.desc); } catch { descZh = null; }
1464
+ }
1465
+ sendJson(res, 200, { ok: true, detail: { ...detail, descZh } });
1466
+ return;
1467
+ }
1468
+
1469
+ /**
1470
+ * 批量取详情:一次给一批仓库的简介 + 曲线。
1471
+ *
1472
+ * 为什么需要它:列表页的卡片要显示简介,一张卡一次请求会把浏览器连接打满,
1473
+ * 也会让对方站点承受 30 次并发。这里在宿主侧**串行**取(复用 fetchText 的队列),
1474
+ * 并发度由队列天然限成 1,取不到的那几条留空、不阻塞其余。
1475
+ *
1476
+ * GET /batch?repos=a/b,c/d&limit=8
1477
+ */
1478
+ if (path === '/batch') {
1479
+ const rawList = (url.searchParams.get('repos') || '').split(',').map(text).filter(Boolean);
1480
+ const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 8, 1), 20);
1481
+ /**
1482
+ * 默认**不翻译**,秒回英文简介 + 曲线。
1483
+ *
1484
+ * 为什么改回来:早期试过「一次取全(含翻译)」,结果是首次打开要等模型
1485
+ * 把 20 条全翻完 —— 网络慢的时候十几秒白屏。现在的分工是:
1486
+ * · /batch :只取数据(不碰模型),通常 1~2 秒
1487
+ * · /translate:单独翻译,前端拿到数据后再补,界面先用英文顶着
1488
+ * 用户选了「中文」时,前端会等翻译;选「English」就直接用英文,一次都不问模型。
1489
+ */
1490
+ const wantZh = url.searchParams.get('translate') === '1';
1491
+ const repos = rawList.slice(0, limit);
1492
+ if (repos.length === 0) {
1493
+ sendJson(res, 400, { ok: false, error: '缺少 repos 参数' });
1494
+ return;
1495
+ }
1496
+ // 限并发 4:串行取 8 条要一分钟以上,并发后通常在十几秒内
1497
+ const slots = new Array(repos.length).fill(null);
1498
+ let cursor = 0;
1499
+ const worker = async () => {
1500
+ for (;;) {
1501
+ const i = cursor++;
1502
+ if (i >= repos.length) return;
1503
+ const repo = repos[i];
1504
+ try {
1505
+ const detail = await fetchDetail(repo);
1506
+ let descZh = null;
1507
+ if (wantZh && detail.desc) {
1508
+ try { descZh = await translateDesc(ctx, detail.desc); } catch { descZh = null; }
1509
+ }
1510
+ /**
1511
+ * 头像内联成 data URI:20 张卡各自发一个 /avatar 请求会被浏览器的
1512
+ * 「同域名 6 并发」限制排队,反而拖慢 /batch。
1513
+ *
1514
+ * 但这里**不能让头像拖住详情**:头像走独立缓存,命中就极快;
1515
+ * 没命中时最多等 3 秒就放弃(界面用首字母块顶着,下次再取)。
1516
+ */
1517
+ let avatar = null;
1518
+ try {
1519
+ const owner = repo.split('/')[0];
1520
+ const av = await Promise.race([
1521
+ fetchAvatar(owner, detail.avatarUrl || null),
1522
+ new Promise((resolve) => setTimeout(() => resolve(null), 3000)),
1523
+ ]);
1524
+ if (av && av.body && av.body.length > 0) {
1525
+ avatar = 'data:' + av.type + ';base64,' + av.body.toString('base64');
1526
+ }
1527
+ } catch {
1528
+ avatar = null; // 取不到就让界面用首字母块
1529
+ }
1530
+ slots[i] = {
1531
+ repository: repo,
1532
+ desc: detail.desc,
1533
+ descZh,
1534
+ descSource: detail.descSource,
1535
+ chart: detail.chart,
1536
+ chartError: detail.chartError,
1537
+ avatar,
1538
+ };
1539
+ } catch (error) {
1540
+ slots[i] = {
1541
+ repository: repo,
1542
+ desc: null,
1543
+ descZh: null,
1544
+ descSource: 'none',
1545
+ chart: null,
1546
+ chartError: String(error && error.message ? error.message : error),
1547
+ };
1548
+ }
1549
+ }
1550
+ };
1551
+ await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, repos.length) }, worker));
1552
+ const items = slots.filter(Boolean);
1553
+ sendJson(res, 200, { ok: true, items });
1554
+
1555
+ /**
1556
+ * 响应发出去之后再**后台预翻译**(不 await,不拖慢这次响应)。
1557
+ * 用户此刻看到的是英文;等他切到「中文」时,译文大多已经热好了。
1558
+ */
1559
+ if (wantZh !== true) {
1560
+ void prewarmTranslation(ctx, items.map((it) => ({ desc: it.desc, repository: it.repository })));
1561
+ }
1562
+ return;
1563
+ }
1564
+
1565
+ /** 诊断:把「能不能翻译」的每一步都摊开,便于定位(只读,不花钱)。 */
1566
+ if (path === '/diag') {
1567
+ const llm = service(ctx, 'llm');
1568
+ const out = {
1569
+ hasLlm: llm !== undefined,
1570
+ llmType: typeof llm,
1571
+ hasStream: typeof llm?.stream,
1572
+ hasListProviders: typeof llm?.listProviders,
1573
+ hasListModels: typeof llm?.listModels,
1574
+ providers: null,
1575
+ picked: null,
1576
+ llmModule: null,
1577
+ translateSample: null,
1578
+ };
1579
+ try {
1580
+ const mod = await loadLlmModule();
1581
+ out.llmModule = mod ? 'loaded' : 'null';
1582
+ } catch (e) {
1583
+ out.llmModule = 'error: ' + String(e && e.message ? e.message : e);
1584
+ }
1585
+ try {
1586
+ const provs = await llm?.listProviders?.();
1587
+ out.providers = Array.isArray(provs) ? provs.map((x) => (typeof x === 'string' ? x : x?.id ?? x?.provider)) : String(provs);
1588
+ } catch (e) {
1589
+ out.providers = 'error: ' + String(e && e.message ? e.message : e);
1590
+ }
1591
+ try {
1592
+ out.picked = await pickModel(ctx, llm);
1593
+ } catch (e) {
1594
+ out.picked = 'error: ' + String(e && e.message ? e.message : e);
1595
+ }
1596
+ try {
1597
+ out.translateSample = await translateDesc(ctx, 'A fast web framework for Node.js.');
1598
+ } catch (e) {
1599
+ out.translateSample = 'error: ' + String(e && e.message ? e.message : e);
1600
+ }
1601
+ sendJson(res, 200, { ok: true, diag: out });
1602
+ return;
1603
+ }
1604
+
1605
+ /**
1606
+ * 批量翻译:给一批仓库的简介做中译。
1607
+ * 前端先拿到英文简介秒开,再调这个接口把中文补上 —— 首屏不被模型拖慢。
1608
+ * 串行调用(复用同一条队列),单条失败留 null,不阻塞其余。
1609
+ */
1610
+ if (path === '/translate') {
1611
+ const rawList = (url.searchParams.get('repos') || '').split(',').map(text).filter(Boolean);
1612
+ const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 8, 1), 20);
1613
+ const repos = rawList.slice(0, limit);
1614
+ if (repos.length === 0) {
1615
+ sendJson(res, 400, { ok: false, error: '缺少 repos 参数' });
1616
+ return;
1617
+ }
1618
+ // 限并发 4:比完全串行快得多,又不至于把模型打到限流
1619
+ const results = new Array(repos.length).fill(null);
1620
+ let cursor = 0;
1621
+ const worker = async () => {
1622
+ for (;;) {
1623
+ const i = cursor++;
1624
+ if (i >= repos.length) return;
1625
+ const repo = repos[i];
1626
+ try {
1627
+ const detail = await fetchDetail(repo);
1628
+ let descZh = null;
1629
+ if (detail.desc) descZh = await translateDesc(ctx, detail.desc);
1630
+ results[i] = { repository: repo, descZh };
1631
+ } catch {
1632
+ results[i] = { repository: repo, descZh: null };
1633
+ }
1634
+ }
1635
+ };
1636
+ await Promise.all(Array.from({ length: Math.min(TRANSLATE_CONCURRENCY, repos.length) }, worker));
1637
+ sendJson(res, 200, { ok: true, items: results.filter(Boolean) });
1638
+ return;
1639
+ }
1640
+
1641
+ /**
1642
+ * 头像:/avatar?repo=owner/name 或 /avatar?owner=xxx
1643
+ * 直接回图片字节;取不到回 1x1 透明 PNG(界面自己降级成首字母块)。
1644
+ */
1645
+ if (path === '/avatar') {
1646
+ const repo = url.searchParams.get('repo') || '';
1647
+ const ownerParam = url.searchParams.get('owner') || '';
1648
+ const owner = ownerParam || (repo.includes('/') ? repo.split('/')[0] : '');
1649
+ if (!owner) {
1650
+ sendJson(res, 400, { ok: false, error: '缺少 owner' });
1651
+ return;
1652
+ }
1653
+ /**
1654
+ * 严格校验 owner:只允许 GitHub 用户名/组织名的合法字符。
1655
+ * 不校验的话,`../../etc/passwd` 这类值会被拼进 URL 和**本地缓存文件名**,
1656
+ * 造成路径穿越(写到 avatars/ 目录之外)。
1657
+ */
1658
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner)) {
1659
+ sendJson(res, 400, { ok: false, error: 'owner 格式不合法' });
1660
+ return;
1661
+ }
1662
+ // 如果详情已经取过,就直接用它从 Hysen 页面抠到的 CDN 直链(免去猜 id)
1663
+ let knownUrl = null;
1664
+ const slug = repo.includes('/') ? slugOf(repo) : null;
1665
+ if (slug) {
1666
+ const cached = cacheGet('detail:' + slug, DETAIL_TTL_MS);
1667
+ if (cached && cached.avatarUrl) knownUrl = cached.avatarUrl;
1668
+ }
1669
+ try {
1670
+ const entry = await fetchAvatar(owner, knownUrl);
1671
+ res.writeHead(200, {
1672
+ 'Content-Type': entry.type,
1673
+ 'Content-Length': entry.body.length,
1674
+ 'Cache-Control': 'public, max-age=86400',
1675
+ });
1676
+ res.end(entry.body);
1677
+ } catch {
1678
+ // 1x1 透明 PNG
1679
+ const px = Buffer.from(
1680
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
1681
+ 'base64',
1682
+ );
1683
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': px.length, 'Cache-Control': 'no-store' });
1684
+ res.end(px);
1685
+ }
1686
+ return;
1687
+ }
1688
+
1689
+ if (path === '/status') {
1690
+ sendJson(res, 200, {
1691
+ ok: true,
1692
+ plugin: name,
1693
+ version: VERSION,
1694
+ cacheSize: cache.size,
1695
+ lastError,
1696
+ });
1697
+ return;
1698
+ }
1699
+
1700
+ sendJson(res, 404, { error: '未知路径:' + path });
1701
+ } catch (error) {
1702
+ const status = error && error.status ? error.status : 500;
1703
+ lastError = { at: nowIso(), scope: path, message: String(error && error.message ? error.message : error) };
1704
+ sendJson(res, status, { ok: false, error: String(error && error.message ? error.message : error) });
1705
+ }
1706
+ }
1707
+
1708
+ /* ---------------------------------------------------------------- 入口 */
1709
+
1710
+ export function apply(ctx) {
1711
+ const logger = ctx.logger || console;
1712
+
1713
+ ctx.effect(
1714
+ () =>
1715
+ ctx.webServer.register({
1716
+ kind: 'prefix',
1717
+ path: ROUTE_PREFIX,
1718
+ handler: (req, res) => {
1719
+ void handleRoute(ctx, req, res);
1720
+ },
1721
+ }),
1722
+ 'dsh-github-hot: /dsh-github-hot routes',
1723
+ );
1724
+
1725
+ logger.info?.('[dsh-github-hot] 已加载 v' + VERSION + ':数据源 hysenlabs.com(日/周/月榜 + 星数曲线 + 项目简介)');
1726
+ }
1727
+
1728
+ /** 仅供测试:解析函数都是纯函数,值得直接钉住。 */
1729
+ export const __internals = {
1730
+ parseCsv,
1731
+ parseWeeklyHtml,
1732
+ parseChartSvg,
1733
+ parseConclusion,
1734
+ parseWhatIsIt,
1735
+ slugOf,
1736
+ currentWeekMonday,
1737
+ dayKeyInSourceTz,
1738
+ SOURCE_TZ,
1739
+ parseAxisDate,
1740
+ handleRoute,
1741
+ parseAvatarUrl,
1742
+ fetchAvatar,
1743
+ detailShapeOk,
1744
+ cacheGet,
1745
+ cacheSet,
1746
+ localDayKey,
1747
+ DAY_MS,
1748
+ /** 仅供测试:直接拿到缓存表,用来伪造写入时间 */
1749
+ __cache: cache,
1750
+ };