dsh-github-hot 0.1.1 → 0.1.3

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 CHANGED
@@ -39,6 +39,7 @@
39
39
 
40
40
  import { createHash, randomUUID } from 'node:crypto';
41
41
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
42
+ import { createRequire } from 'node:module';
42
43
  import { homedir } from 'node:os';
43
44
  import { join } from 'node:path';
44
45
  import { pathToFileURL } from 'node:url';
@@ -49,7 +50,20 @@ export const name = 'dsh-github-hot';
49
50
  /** 硬依赖:只要 webServer(提供路由)。 */
50
51
  export const inject = ['webServer'];
51
52
 
52
- const VERSION = '0.1.0';
53
+ /**
54
+ * 版本号:从 package.json 读,**不要在这里写死**。
55
+ *
56
+ * 写死的话每次发版都要记得改两处,漏改就会让 /status 报旧版本
57
+ * (用户排查问题时会被误导)。这里用 createRequire 读同包的 manifest。
58
+ */
59
+ const VERSION = (() => {
60
+ try {
61
+ const require = createRequire(import.meta.url);
62
+ return require('../package.json').version || '0.0.0';
63
+ } catch {
64
+ return '0.0.0';
65
+ }
66
+ })();
53
67
  const ROUTE_PREFIX = '/dsh-github-hot';
54
68
 
55
69
  const BASE = 'https://hysenlabs.com';
@@ -106,13 +120,55 @@ function dayKeyInSourceTz(ts) {
106
120
  function localDayKey(ts) {
107
121
  return dayKeyInSourceTz(ts);
108
122
  }
123
+ /** 版本检查缓存:一天查一次足够(npm 上不会一天发几十个版本)。 */
124
+ const UPDATE_CACHE_KEY = 'update:npm';
125
+ const UPDATE_TTL_MS = 24 * 60 * 60 * 1000;
126
+
127
+ /**
128
+ * 比较两个 semver 版本号(忽略预发布后缀)。
129
+ * 返回 1:a 更新;0:相同;-1:b 更新。
130
+ */
131
+ function compareVersion(a, b) {
132
+ const norm = (v) => String(v || '').split('-')[0].split('.').map((x) => Number(x) || 0);
133
+ const pa = norm(a);
134
+ const pb = norm(b);
135
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
136
+ const x = pa[i] || 0;
137
+ const y = pb[i] || 0;
138
+ if (x > y) return 1;
139
+ if (x < y) return -1;
140
+ }
141
+ return 0;
142
+ }
143
+
144
+ /**
145
+ * 退出前把未落盘的译文写掉。
146
+ * 只注册一次;用 once 事件避免多次注册。
147
+ */
148
+ let zhExitHookBound = false;
149
+ function bindZhExitHook() {
150
+ if (zhExitHookBound) return;
151
+ zhExitHookBound = true;
152
+ const flush = () => { try { flushZhNow(); } catch { /* 忽略 */ } };
153
+ process.once('exit', flush);
154
+ process.once('SIGINT', flush);
155
+ process.once('SIGTERM', flush);
156
+ }
157
+
109
158
  /** 翻译并发:模型调用是 IO 等待,8 条同时翻比 4 条快一倍。 */
110
159
  const TRANSLATE_CONCURRENCY = 8;
111
160
  /** 取数并发:抓对方站点,礼貌一点。 */
112
161
  const FETCH_CONCURRENCY = 4;
113
162
 
114
163
  /** 单次请求超时。 */
115
- const REQUEST_TIMEOUT_MS = 20000;
164
+ /**
165
+ * 单次出网超时。
166
+ *
167
+ * 20 秒太长了:曲线 SVG 偶发挂住时,整个详情页要干等 20 秒才报错,
168
+ * 用户体感就是「点了没反应,过一会儿才出错」。8 秒足够正常请求完成
169
+ * (实测 1~3 秒),超了就放弃这条、界面显示「暂无曲线」,不影响简介。
170
+ */
171
+ const REQUEST_TIMEOUT_MS = 8000;
116
172
  /** 简介最多保留多少字(页面提取的第一段结论通常几百字)。 */
117
173
  const DESC_MAX_CHARS = 260;
118
174
 
@@ -165,6 +221,13 @@ function cleanHtml(input) {
165
221
  .replace(/&gt;/g, '>')
166
222
  .replace(/&quot;/g, '"')
167
223
  .replace(/&#39;/g, "'")
224
+ .replace(/&apos;/g, "'")
225
+ /**
226
+ * 数字字符引用统一解码:&#x27; 和 &#39; 是同一条(十六进制/十进制),
227
+ * 页面里两种写法都会出现,只处理 &#39; 会漏掉 &#x27;,显示成 GitHub&#x27;s。
228
+ */
229
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
230
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
168
231
  .replace(/\s+/g, ' ')
169
232
  .trim();
170
233
  }
@@ -193,8 +256,9 @@ const CACHE_FILE_NAME = 'cache.json';
193
256
  * 旧文件会被整体丢弃 —— 否则会用「结构已过期」的数据静默喂给界面。
194
257
  *
195
258
  * v3:曲线日期改为 UTC 口径统一计算 + 时区统一到数据源(Asia/Shanghai)。
259
+ * v4:详情增加 glance(At a glance 问答),旧的没有这字段的缓存全部作废。
196
260
  */
197
- const CACHE_STRUCTURE_VERSION = 3;
261
+ const CACHE_STRUCTURE_VERSION = 4;
198
262
  const CACHE_MAX_ENTRIES = 800;
199
263
  const FLUSH_DELAY_MS = 3000;
200
264
 
@@ -220,6 +284,7 @@ function ensureCacheLoaded() {
220
284
  const now = Date.now();
221
285
  for (const [key, entry] of Object.entries(data.entries)) {
222
286
  if (!entry || typeof entry.at !== 'number') continue;
287
+ if (entry.value && entry.value.chartPending) continue; // 上次进程的在途请求已不存在
223
288
  // 只恢复「不是太旧」的条目:按天缓存的跨天就丢,避免恢复一堆废数据
224
289
  if (now - entry.at > DAY_MS) continue;
225
290
  cache.set(key, { at: entry.at, value: entry.value });
@@ -306,6 +371,8 @@ const HOST_CONCURRENCY = 4;
306
371
  /** 每个 host 的在途计数与等待队列。 */
307
372
  const hostState = new Map();
308
373
  let lastError = null;
374
+ /** 最近一次 /detail 的分段耗时(毫秒),只在 /diag 里暴露。 */
375
+ let lastTiming = null;
309
376
 
310
377
  function hostOf(url) {
311
378
  try {
@@ -556,6 +623,8 @@ async function fetchWeekly() {
556
623
  }
557
624
 
558
625
  let lastError = null;
626
+ /** 最近一次 /detail 的分段耗时(毫秒),只在 /diag 里暴露。 */
627
+ let lastTiming = null;
559
628
  for (const week of candidates) {
560
629
  const url = BASE + '/weekly/' + week;
561
630
  try {
@@ -600,14 +669,37 @@ function parseAvatarUrl(html) {
600
669
  return m ? 'https://avatars.githubusercontent.com/u/' + m[1] + '?s=96&v=4' : null;
601
670
  }
602
671
 
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;
672
+ /**
673
+ * 解析项目页的「At a glance」区块 —— 一组结构化的问答。
674
+ *
675
+ * 页面结构:
676
+ * <dl><div><dt>What is it?</dt><dd>...</dd></div>
677
+ * <div><dt>Who is it for?</dt><dd>...</dd></div> ...</dl>
678
+ *
679
+ * 为什么整组都要:只取「What is it?」那一段会丢掉「能不能商用」「还在维护吗」
680
+ * 「用什么写的」这些用户真正关心的信息。源码里写成一个数组,
681
+ * 前端按顺序渲染成表格式问答。
682
+ *
683
+ * 返回 [{ q, a }](英文原文);解析不出返回空数组。
684
+ */
685
+ function parseGlance(html) {
686
+ const items = [];
687
+ const re = /<dt[^>]*>([\s\S]*?)<\/dt>\s*<dd[^>]*>([\s\S]*?)<\/dd>/gi;
688
+ let m;
689
+ while ((m = re.exec(html)) !== null) {
690
+ const q = cleanHtml(m[1]);
691
+ const a = cleanHtml(m[2]);
692
+ if (q && a) items.push({ q, a });
693
+ if (items.length >= 8) break; // 正常 5 条,留点余量
609
694
  }
610
- return null;
695
+ return items;
696
+ }
697
+
698
+ /** 只取「What is it?」的答案(兼容旧字段)。 */
699
+ function parseWhatIsIt(html) {
700
+ const items = parseGlance(html);
701
+ const hit = items.find((it) => /^what\s+is\s+it\??$/i.test(it.q));
702
+ return hit ? hit.a : (items[0] ? items[0].a : null);
611
703
  }
612
704
 
613
705
  /**
@@ -765,24 +857,31 @@ let prewarmRunning = false;
765
857
 
766
858
  async function prewarmTranslation(ctx, rows) {
767
859
  if (prewarmRunning || !ctx) return;
768
- const targets = (rows || []).slice(0, PREWARM_LIMIT).filter((r) => r && r.desc);
860
+ const targets = (rows || []).slice(0, PREWARM_LIMIT).filter((r) => r && (r.desc || (r.glance && r.glance.length)));
769
861
  if (targets.length === 0) return;
770
862
  prewarmRunning = true;
771
863
  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
- /* 单条失败忽略 */
864
+ /**
865
+ * **按排名严格串行**预翻译。
866
+ *
867
+ * 为什么串行而不是并发 4:用户点开的第一张卡几乎总是排在最前面的那名。
868
+ * 串行保证「第 1 名先好、第 2 名其次」—— 用户看第一个的时候,后面几个
869
+ * 正在依次翻译,滚下去时大多已经有了。并发 4 的话前 4 名同时开跑,
870
+ * 第 1 名反而不一定最早完成。
871
+ *
872
+ * 每翻完一条就落盘(zhCacheSet 内部有节流 flush),进程重启也不丢。
873
+ */
874
+ for (const row of targets) {
875
+ try {
876
+ // 简介与问答都要翻;内部有哈希缓存,翻过的不再调模型
877
+ if (row.desc) await translateDesc(ctx, row.desc);
878
+ if (Array.isArray(row.glance) && row.glance.length > 0) {
879
+ await translateGlance(ctx, row.glance);
781
880
  }
881
+ } catch {
882
+ /* 单条失败忽略,继续下一条 */
782
883
  }
783
- };
784
- // 并发 4:比用户请求低,避免抢带宽
785
- await Promise.all(Array.from({ length: Math.min(4, targets.length) }, worker));
884
+ }
786
885
  } finally {
787
886
  prewarmRunning = false;
788
887
  }
@@ -922,21 +1021,33 @@ function ensureZhLoaded() {
922
1021
  }
923
1022
  }
924
1023
 
1024
+ /** 把译文缓存整体写盘。 */
1025
+ function flushZhNow() {
1026
+ try {
1027
+ const file = zhFilePath();
1028
+ const dir = join(file, '..');
1029
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1030
+ let entries = [...zhCache.entries()];
1031
+ if (entries.length > ZH_MAX) entries = entries.slice(-ZH_MAX);
1032
+ writeFileSync(file, JSON.stringify(Object.fromEntries(entries)), 'utf8');
1033
+ return true;
1034
+ } catch {
1035
+ return false;
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * 落盘节流:写完一条后 800ms 内不再写,避免批量翻译时频繁 IO。
1041
+ *
1042
+ * 另外注册了进程退出钩子直接同步写一次 —— 否则刚翻完就关掉 DSH,
1043
+ * 那几条译文只在内存里,下次打开还得重翻(用户感知就是「缓存没用」)。
1044
+ */
925
1045
  function scheduleZhFlush() {
926
1046
  if (zhFlushTimer !== null) return;
927
1047
  zhFlushTimer = setTimeout(() => {
928
1048
  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);
1049
+ flushZhNow();
1050
+ }, 800);
940
1051
  if (typeof zhFlushTimer.unref === 'function') zhFlushTimer.unref();
941
1052
  }
942
1053
 
@@ -946,12 +1057,23 @@ function sha1(input) {
946
1057
 
947
1058
  function zhCacheGet(text) {
948
1059
  ensureZhLoaded();
1060
+ /**
1061
+ * ⚠️ 这里存的是**纯字符串**(见 zhCacheSet:set(hash, zh))。
1062
+ *
1063
+ * 之前写成 `hit ? hit.zh : null` —— 字符串没有 .zh 属性,永远取到 undefined,
1064
+ * 于是译文缓存**只写不读**:每条都要重新调模型,用户感知就是「完全没缓存」。
1065
+ * 现在按字符串取,同时兼容历史 JSON 里可能出现的 {zh} 结构。
1066
+ */
949
1067
  const hit = zhCache.get(sha1(text));
950
- return hit ? hit.zh : null;
1068
+ if (hit === undefined || hit === null) return null;
1069
+ if (typeof hit === 'string') return hit || null;
1070
+ if (typeof hit === 'object' && typeof hit.zh === 'string') return hit.zh || null;
1071
+ return null;
951
1072
  }
952
1073
 
953
1074
  function zhCacheSet(text, zh) {
954
1075
  ensureZhLoaded();
1076
+ bindZhExitHook();
955
1077
  zhCache.set(sha1(text), zh);
956
1078
  scheduleZhFlush();
957
1079
  if (zhCache.size > ZH_MAX) {
@@ -1049,7 +1171,8 @@ async function pickModel(ctx, llm) {
1049
1171
  // 1) 宿主默认模型
1050
1172
  try {
1051
1173
  const svc = service(ctx, 'agentDefaultModel');
1052
- const def = typeof svc?.get === 'function' ? svc.get() : svc?.current ?? svc?.value;
1174
+ const def = typeof svc?.currentSelection === 'function' ? svc.currentSelection()
1175
+ : typeof svc?.get === 'function' ? svc.get() : svc?.current ?? svc?.value;
1053
1176
  const resolved = await def;
1054
1177
  const provider = resolved?.provider ?? resolved?.providerId;
1055
1178
  const model = resolved?.model ?? resolved?.modelId;
@@ -1083,6 +1206,85 @@ async function pickModel(ctx, llm) {
1083
1206
  return null;
1084
1207
  }
1085
1208
 
1209
+ /**
1210
+ * 把一组问答**一次性**翻成中文。
1211
+ *
1212
+ * 为什么批量:3 条问答各调一次模型,光排队就要十几秒;合成一次请求
1213
+ * 让模型按序号返回,总耗时和翻一段差不多。
1214
+ *
1215
+ * 返回与入参等长的数组;任何一条翻不出来就留 null,界面回退英文原文。
1216
+ */
1217
+ async function translateGlance(ctx, items) {
1218
+ const list = Array.isArray(items) ? items : [];
1219
+ if (list.length === 0) return [];
1220
+
1221
+ // 先看缓存:每条按原文哈希查,全命中就直接返回,一次模型都不调
1222
+ const cached = list.map((it) => zhCacheGet(it.a));
1223
+ if (cached.every((x) => x)) return cached;
1224
+
1225
+ const llm = service(ctx, 'llm');
1226
+ if (llm === undefined || typeof llm.stream !== 'function') return cached.map((x) => x || null);
1227
+ const picked = await pickModel(ctx, llm);
1228
+ if (!picked) return cached.map((x) => x || null);
1229
+ const { provider, model } = picked;
1230
+ const llmMod = await loadLlmModule();
1231
+
1232
+ // 拼成 1) ... 2) ... 3) ... 的编号格式,要求按同样编号返回
1233
+ const body = list
1234
+ .map((it, i) => (i + 1) + '. ' + String(it.a).trim())
1235
+ .join('\n\n');
1236
+ const system =
1237
+ '你是技术编辑。下面是若干条英文项目说明,按编号逐条翻译成简体中文。' +
1238
+ '要求:严格按 "编号. 译文" 的格式输出,每个编号一行,不要合并或省略;' +
1239
+ '保留专有名词(项目名、工具名、命令、许可证名)原样不译;' +
1240
+ '不要任何解释、前缀或 Markdown。';
1241
+
1242
+ const message =
1243
+ typeof llmMod?.createUserMessage === 'function'
1244
+ ? llmMod.createUserMessage({ content: [{ type: 'text', text: body }], source: { kind: 'user' } })
1245
+ : { id: randomUUID(), role: 'user', content: [{ type: 'text', text: body }], source: { kind: 'user' } };
1246
+
1247
+ try {
1248
+ let raw = '';
1249
+ const options = {
1250
+ provider,
1251
+ model,
1252
+ sessionId: 'dsh-github-hot:translate-glance',
1253
+ messages: [message],
1254
+ system,
1255
+ temperature: 0.2,
1256
+ reasoningEffort: 'off',
1257
+ // 多条一起翻,给足时间但别无限等
1258
+ signal: AbortSignal.timeout(20000),
1259
+ };
1260
+ if (typeof llmMod?.BlockAssembler === 'function') {
1261
+ const assembler = new llmMod.BlockAssembler();
1262
+ for await (const chunk of llm.stream(options)) assembler.push(chunk);
1263
+ raw = assembler.blocks().filter((b) => b.type === 'text').map((b) => ('text' in b ? b.text : '')).join('');
1264
+ } else {
1265
+ const chunks = [];
1266
+ for await (const chunk of llm.stream(options)) chunks.push(chunk);
1267
+ raw = textFromChunks(chunks).text;
1268
+ }
1269
+
1270
+ // 按 "编号." 切开,逐条对上
1271
+ const parts = String(raw || '').split(/\n\s*(?=\d+[.、)]\s)/);
1272
+ const out = list.map((it, i) => {
1273
+ if (cached[i]) return cached[i]; // 已缓存的不动
1274
+ const seg = parts.find((x) => x.trim().startsWith((i + 1) + '.') || x.trim().startsWith((i + 1) + '、'));
1275
+ if (!seg) return null;
1276
+ const zh = seg.replace(/^\s*\d+[.、)]\s*/, '').trim();
1277
+ // 校验:必须含中文且不等于原文
1278
+ if (!zh || !/[\u4e00-\u9fa5]/.test(zh) || zh === String(it.a).trim()) return null;
1279
+ zhCacheSet(it.a, zh);
1280
+ return zh;
1281
+ });
1282
+ return out;
1283
+ } catch {
1284
+ return cached.map((x) => x || null);
1285
+ }
1286
+ }
1287
+
1086
1288
  async function translateDesc(ctx, text) {
1087
1289
  const src = String(text || '').trim();
1088
1290
  if (!src) return null;
@@ -1158,6 +1360,15 @@ async function translateDesc(ctx, text) {
1158
1360
  */
1159
1361
  function detailShapeOk(detail) {
1160
1362
  if (!detail || typeof detail !== 'object') return false;
1363
+ /**
1364
+ * 必须带 glance 字段(哪怕是空数组)。
1365
+ *
1366
+ * 少了这一条,0.1.x 时期缓存的老数据会被当成有效缓存继续用 ——
1367
+ * 界面就拿不到问答表,表现为「新版装了但详情页没变化」。宁可重取一次。
1368
+ */
1369
+ if (!Array.isArray(detail.glance)) return false;
1370
+ // 取数失败的结果不能当成有效缓存(否则那天再点都返回错误)
1371
+ if (detail.pageError) return false;
1161
1372
  const chart = detail.chart;
1162
1373
  if (!chart) return true; // 没曲线是允许的(可能只是取不到)
1163
1374
  if (!Array.isArray(chart.series) || chart.series.length === 0) return false;
@@ -1193,22 +1404,49 @@ async function fetchDetailInner(repo, slug) {
1193
1404
  if (cached && detailShapeOk(cached)) return { ...cached, cached: true };
1194
1405
  if (cached) cache.delete(cacheKey); // 结构过期,丢掉重取
1195
1406
 
1196
- const [pageResult, chartResult] = await Promise.allSettled([
1197
- fetchText(BASE + '/projects/' + slug),
1198
- fetchText(BASE + '/chart/' + slug + '.svg', { accept: 'image/svg+xml,*/*' }),
1407
+ /**
1408
+ * 页面与曲线**同时发起**,但**页面一到就先把详情返回给用户**,曲线在后台补。
1409
+ *
1410
+ * 为什么不能等两个都完成:曲线 SVG 偶发挂住(尤其冷门项目),
1411
+ * 等它会让「简介、问答、KPI」一起干等 8 秒 —— 明明页面数据早就到了。
1412
+ * 现在用 GRACE_MS 给曲线一个很短的等待窗口:赶得上就一起返回,
1413
+ * 赶不上就先返回页面数据、曲线稍后由前端再取一次。
1414
+ */
1415
+ const chartPromise = fetchText(BASE + '/chart/' + slug + '.svg', { accept: 'image/svg+xml,*/*' });
1416
+ const pagePromise = fetchText(BASE + '/projects/' + slug);
1417
+ const GRACE_MS = 1200;
1418
+ const race = await Promise.race([
1419
+ Promise.allSettled([pagePromise, chartPromise]),
1420
+ pagePromise.then(
1421
+ (v) => ({ early: true, page: v }),
1422
+ (e) => ({ early: true, pageError: e }),
1423
+ ).then((r) => new Promise((res) => setTimeout(() => res(r), GRACE_MS))),
1199
1424
  ]);
1200
1425
 
1426
+ let pageResult;
1427
+ let chartResult;
1428
+ if (race && race.early) {
1429
+ pageResult = race.pageError ? { status: 'rejected', reason: race.pageError } : { status: 'fulfilled', value: race.page };
1430
+ // 曲线还没回来:给它一个明确的「稍后再取」标记,而不是报错
1431
+ chartResult = { status: 'pending' };
1432
+ } else {
1433
+ [pageResult, chartResult] = race;
1434
+ }
1435
+
1201
1436
  let desc = null;
1202
1437
  let descSource = 'none';
1203
1438
  let pageUrl = BASE + '/projects/' + slug;
1204
1439
  let started = null;
1205
1440
  let whatIsIt = null;
1441
+ /** At a glance 的完整问答(5 组);详情页展示来源用,比单段简介信息量大 */
1442
+ let glance = [];
1206
1443
 
1207
1444
  let avatarUrl = null;
1208
1445
  if (pageResult.status === 'fulfilled') {
1209
1446
  const html = pageResult.value;
1210
1447
  avatarUrl = parseAvatarUrl(html);
1211
1448
  const conclusion = parseConclusion(html);
1449
+ glance = parseGlance(html);
1212
1450
  whatIsIt = parseWhatIsIt(html);
1213
1451
  started = (html.match(/Last updated\s+([A-Z][a-z]+ \d{1,2}, \d{4})/i) || [])[1] || null;
1214
1452
  if (conclusion) {
@@ -1222,11 +1460,16 @@ async function fetchDetailInner(repo, slug) {
1222
1460
 
1223
1461
  let chart = null;
1224
1462
  let chartError = null;
1225
- if (chartResult.status === 'fulfilled') {
1463
+ let chartPending = false;
1464
+ if (chartResult.status === 'pending') {
1465
+ chartPending = true; // 前端据此再取一次,不影响页面其它内容
1466
+ } else if (chartResult.status === 'fulfilled') {
1226
1467
  chart = parseChartSvg(chartResult.value);
1227
1468
  if (!chart) chartError = '曲线 SVG 解析失败';
1228
1469
  } else {
1229
- chartError = String(chartResult.reason && chartResult.reason.message ? chartResult.reason.message : chartResult.reason);
1470
+ const why = chartResult.reason && chartResult.reason.message ? chartResult.reason.message : String(chartResult.reason);
1471
+ // 超时单独说清楚,别把「网络慢」显示成「fetch failed」
1472
+ chartError = /abort|timeout|超时/i.test(why) ? '网络超时' : why;
1230
1473
  }
1231
1474
 
1232
1475
  const value = {
@@ -1238,16 +1481,37 @@ async function fetchDetailInner(repo, slug) {
1238
1481
  desc,
1239
1482
  descSource,
1240
1483
  whatIsIt,
1484
+ glance,
1241
1485
  analysisUpdated: started,
1242
1486
  chart,
1243
1487
  chartError,
1488
+ chartPending,
1244
1489
  pageError:
1245
1490
  pageResult.status === 'rejected'
1246
1491
  ? String(pageResult.reason && pageResult.reason.message ? pageResult.reason.message : pageResult.reason)
1247
1492
  : null,
1248
1493
  fetchedAt: nowIso(),
1249
1494
  };
1250
- cacheSet(cacheKey, value);
1495
+ /**
1496
+ * 只缓存**页面取到**的结果。
1497
+ *
1498
+ * 之前无条件 cacheSet —— 网络抖一次就把「pageError: fetch failed」存进缓存,
1499
+ * 之后那天再点这个项目都直接返回这份失败结果(cached: true),
1500
+ * 表现就是「第一个项目永远拉取失败,点别的却正常」。失败的不能缓存。
1501
+ */
1502
+ if (!value.pageError) cacheSet(cacheKey, value);
1503
+ if (chartPending) {
1504
+ // 页面先返回后,仍需把晚到的曲线写回缓存,不能全天缓存 pending。
1505
+ void chartPromise.then((svg) => {
1506
+ value.chart = parseChartSvg(svg);
1507
+ value.chartError = value.chart ? null : '曲线 SVG 解析失败';
1508
+ }, (error) => {
1509
+ value.chartError = String(error && error.message ? error.message : error);
1510
+ }).finally(() => {
1511
+ value.chartPending = false;
1512
+ if (cache.get(cacheKey)?.value === value) cacheSet(cacheKey, value);
1513
+ });
1514
+ }
1251
1515
  return { ...value, cached: false };
1252
1516
  }
1253
1517
 
@@ -1398,6 +1662,8 @@ async function fetchAvatar(owner, knownUrl) {
1398
1662
 
1399
1663
  // ③ 出网
1400
1664
  let lastError = null;
1665
+ /** 最近一次 /detail 的分段耗时(毫秒),只在 /diag 里暴露。 */
1666
+ let lastTiming = null;
1401
1667
  for (const url of avatarSources(key, knownUrl)) {
1402
1668
  const controller = new AbortController();
1403
1669
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -1456,13 +1722,28 @@ async function handleRoute(ctx, req, res) {
1456
1722
 
1457
1723
  if (path === '/detail') {
1458
1724
  const repository = url.searchParams.get('repo') || '';
1725
+ const t0 = Date.now();
1459
1726
  const detail = await fetchDetail(repository);
1460
- // 简介翻成中文(拿不到译文就留 null,界面回退英文原文)
1727
+ const t1 = Date.now();
1728
+ /**
1729
+ * 简介与问答分别翻成中文(拿不到译文就留 null,界面回退英文原文)。
1730
+ *
1731
+ * 两个函数内部都按原文哈希查缓存,命中时不调模型 —— 所以这里直接调用,
1732
+ * 不需要额外判断「翻译过没有」。
1733
+ */
1461
1734
  let descZh = null;
1462
- if (detail && detail.desc) {
1735
+ if (url.searchParams.get('translate') !== '0' && detail && detail.desc) {
1463
1736
  try { descZh = await translateDesc(ctx, detail.desc); } catch { descZh = null; }
1464
1737
  }
1465
- sendJson(res, 200, { ok: true, detail: { ...detail, descZh } });
1738
+ const t2 = Date.now();
1739
+ let glanceZh = null;
1740
+ if (url.searchParams.get('translate') !== '0' && detail && Array.isArray(detail.glance) && detail.glance.length > 0) {
1741
+ try { glanceZh = await translateGlance(ctx, detail.glance); } catch { glanceZh = null; }
1742
+ }
1743
+ const t3 = Date.now();
1744
+ // 计时只在 /diag 可见,正常请求不打日志
1745
+ lastTiming = { detail: t1 - t0, descZh: t2 - t1, glanceZh: t3 - t2, total: t3 - t0 };
1746
+ sendJson(res, 200, { ok: true, detail: { ...detail, descZh, glanceZh } });
1466
1747
  return;
1467
1748
  }
1468
1749
 
@@ -1534,6 +1815,7 @@ async function handleRoute(ctx, req, res) {
1534
1815
  descSource: detail.descSource,
1535
1816
  chart: detail.chart,
1536
1817
  chartError: detail.chartError,
1818
+ chartPending: detail.chartPending,
1537
1819
  avatar,
1538
1820
  };
1539
1821
  } catch (error) {
@@ -1556,7 +1838,7 @@ async function handleRoute(ctx, req, res) {
1556
1838
  * 响应发出去之后再**后台预翻译**(不 await,不拖慢这次响应)。
1557
1839
  * 用户此刻看到的是英文;等他切到「中文」时,译文大多已经热好了。
1558
1840
  */
1559
- if (wantZh !== true) {
1841
+ if (wantZh !== true && url.searchParams.get('translate') !== '0') {
1560
1842
  void prewarmTranslation(ctx, items.map((it) => ({ desc: it.desc, repository: it.repository })));
1561
1843
  }
1562
1844
  return;
@@ -1686,6 +1968,76 @@ async function handleRoute(ctx, req, res) {
1686
1968
  return;
1687
1969
  }
1688
1970
 
1971
+ /**
1972
+ * 版本检查:告诉前端「有没有新版本」。
1973
+ *
1974
+ * 为什么放在宿主侧而不是浏览器直接查 npm:
1975
+ * · 浏览器跨域请求 npm registry 会被 CORS 拦;
1976
+ * · 顺便可以做缓存,避免每次开面板都打一次 npm。
1977
+ *
1978
+ * 缓存策略:每天最多查一次(数据源思路一致,不需要分钟级)。
1979
+ * npm 查询失败不影响插件使用,返回 checked:false 即可。
1980
+ */
1981
+ /**
1982
+ * 预翻译:前端拿到榜单后调用,让宿主**按排名顺序**把前 N 个项目的
1983
+ * 简介与问答翻好并落盘。
1984
+ *
1985
+ * 立即返回(不等待),翻译在后台跑。这样用户点开第 1 名时,
1986
+ * 第 2、3、4 名正在依次翻译中。
1987
+ */
1988
+ if (path === '/prewarm') {
1989
+ const repos = String(url.searchParams.get('repos') || '').split(',').map((x) => x.trim()).filter(Boolean);
1990
+ const limit = Math.min(Number(url.searchParams.get('limit')) || 20, 30);
1991
+ sendJson(res, 200, { ok: true, queued: repos.length, running: prewarmRunning });
1992
+ if (!prewarmRunning && repos.length > 0) {
1993
+ void (async () => {
1994
+ const rows = [];
1995
+ for (const repo of repos.slice(0, limit)) {
1996
+ try {
1997
+ const detail = await fetchDetail(repo);
1998
+ rows.push({ repository: repo, desc: detail.desc, glance: detail.glance });
1999
+ } catch {
2000
+ /* 单个失败跳过 */
2001
+ }
2002
+ }
2003
+ await prewarmTranslation(ctx, rows);
2004
+ })();
2005
+ }
2006
+ return;
2007
+ }
2008
+
2009
+ if (path === '/update') {
2010
+ const force = url.searchParams.get('force') === '1';
2011
+ const hit = cacheGet(UPDATE_CACHE_KEY, UPDATE_TTL_MS);
2012
+ if (hit && !force) {
2013
+ sendJson(res, 200, { ok: true, ...hit, cached: true });
2014
+ return;
2015
+ }
2016
+ let latest = null;
2017
+ let error = null;
2018
+ try {
2019
+ const raw = await fetchText('https://registry.npmjs.org/' + name + '/latest', {
2020
+ accept: 'application/json',
2021
+ timeout: 8000,
2022
+ });
2023
+ const parsed = JSON.parse(raw);
2024
+ latest = parsed && parsed.version ? String(parsed.version) : null;
2025
+ } catch (e) {
2026
+ error = String(e && e.message ? e.message : e);
2027
+ }
2028
+ const payload = {
2029
+ current: VERSION,
2030
+ latest,
2031
+ hasUpdate: latest ? compareVersion(latest, VERSION) > 0 : false,
2032
+ checkedAt: nowIso(),
2033
+ error,
2034
+ };
2035
+ // 查到了才缓存;失败不缓存,下次还能重试
2036
+ if (latest) cache.set(UPDATE_CACHE_KEY, { at: Date.now(), value: payload });
2037
+ sendJson(res, 200, { ok: true, ...payload, cached: false });
2038
+ return;
2039
+ }
2040
+
1689
2041
  if (path === '/status') {
1690
2042
  sendJson(res, 200, {
1691
2043
  ok: true,
@@ -1693,6 +2045,7 @@ async function handleRoute(ctx, req, res) {
1693
2045
  version: VERSION,
1694
2046
  cacheSize: cache.size,
1695
2047
  lastError,
2048
+ lastTiming,
1696
2049
  });
1697
2050
  return;
1698
2051
  }
@@ -1732,6 +2085,7 @@ export const __internals = {
1732
2085
  parseChartSvg,
1733
2086
  parseConclusion,
1734
2087
  parseWhatIsIt,
2088
+ parseGlance,
1735
2089
  slugOf,
1736
2090
  currentWeekMonday,
1737
2091
  dayKeyInSourceTz,
@@ -1740,9 +2094,13 @@ export const __internals = {
1740
2094
  handleRoute,
1741
2095
  parseAvatarUrl,
1742
2096
  fetchAvatar,
2097
+ compareVersion,
1743
2098
  detailShapeOk,
1744
2099
  cacheGet,
1745
2100
  cacheSet,
2101
+ flushZhNow,
2102
+ zhCacheGet,
2103
+ zhCacheSet,
1746
2104
  localDayKey,
1747
2105
  DAY_MS,
1748
2106
  /** 仅供测试:直接拿到缓存表,用来伪造写入时间 */