dsh-github-hot 0.1.2 → 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
@@ -120,13 +120,55 @@ function dayKeyInSourceTz(ts) {
120
120
  function localDayKey(ts) {
121
121
  return dayKeyInSourceTz(ts);
122
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
+
123
158
  /** 翻译并发:模型调用是 IO 等待,8 条同时翻比 4 条快一倍。 */
124
159
  const TRANSLATE_CONCURRENCY = 8;
125
160
  /** 取数并发:抓对方站点,礼貌一点。 */
126
161
  const FETCH_CONCURRENCY = 4;
127
162
 
128
163
  /** 单次请求超时。 */
129
- const REQUEST_TIMEOUT_MS = 20000;
164
+ /**
165
+ * 单次出网超时。
166
+ *
167
+ * 20 秒太长了:曲线 SVG 偶发挂住时,整个详情页要干等 20 秒才报错,
168
+ * 用户体感就是「点了没反应,过一会儿才出错」。8 秒足够正常请求完成
169
+ * (实测 1~3 秒),超了就放弃这条、界面显示「暂无曲线」,不影响简介。
170
+ */
171
+ const REQUEST_TIMEOUT_MS = 8000;
130
172
  /** 简介最多保留多少字(页面提取的第一段结论通常几百字)。 */
131
173
  const DESC_MAX_CHARS = 260;
132
174
 
@@ -179,6 +221,13 @@ function cleanHtml(input) {
179
221
  .replace(/&gt;/g, '>')
180
222
  .replace(/&quot;/g, '"')
181
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)))
182
231
  .replace(/\s+/g, ' ')
183
232
  .trim();
184
233
  }
@@ -207,8 +256,9 @@ const CACHE_FILE_NAME = 'cache.json';
207
256
  * 旧文件会被整体丢弃 —— 否则会用「结构已过期」的数据静默喂给界面。
208
257
  *
209
258
  * v3:曲线日期改为 UTC 口径统一计算 + 时区统一到数据源(Asia/Shanghai)。
259
+ * v4:详情增加 glance(At a glance 问答),旧的没有这字段的缓存全部作废。
210
260
  */
211
- const CACHE_STRUCTURE_VERSION = 3;
261
+ const CACHE_STRUCTURE_VERSION = 4;
212
262
  const CACHE_MAX_ENTRIES = 800;
213
263
  const FLUSH_DELAY_MS = 3000;
214
264
 
@@ -234,6 +284,7 @@ function ensureCacheLoaded() {
234
284
  const now = Date.now();
235
285
  for (const [key, entry] of Object.entries(data.entries)) {
236
286
  if (!entry || typeof entry.at !== 'number') continue;
287
+ if (entry.value && entry.value.chartPending) continue; // 上次进程的在途请求已不存在
237
288
  // 只恢复「不是太旧」的条目:按天缓存的跨天就丢,避免恢复一堆废数据
238
289
  if (now - entry.at > DAY_MS) continue;
239
290
  cache.set(key, { at: entry.at, value: entry.value });
@@ -320,6 +371,8 @@ const HOST_CONCURRENCY = 4;
320
371
  /** 每个 host 的在途计数与等待队列。 */
321
372
  const hostState = new Map();
322
373
  let lastError = null;
374
+ /** 最近一次 /detail 的分段耗时(毫秒),只在 /diag 里暴露。 */
375
+ let lastTiming = null;
323
376
 
324
377
  function hostOf(url) {
325
378
  try {
@@ -570,6 +623,8 @@ async function fetchWeekly() {
570
623
  }
571
624
 
572
625
  let lastError = null;
626
+ /** 最近一次 /detail 的分段耗时(毫秒),只在 /diag 里暴露。 */
627
+ let lastTiming = null;
573
628
  for (const week of candidates) {
574
629
  const url = BASE + '/weekly/' + week;
575
630
  try {
@@ -614,14 +669,37 @@ function parseAvatarUrl(html) {
614
669
  return m ? 'https://avatars.githubusercontent.com/u/' + m[1] + '?s=96&v=4' : null;
615
670
  }
616
671
 
617
- function parseWhatIsIt(html) {
618
- const m = html.match(/What is it\?[\s\S]{0,80}?<\/dt>[\s\S]*?<dd[^>]*>([\s\S]*?)<\/dd>/i)
619
- || html.match(/<h3[^>]*>\s*What is it\?\s*<\/h3>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/i);
620
- if (m) {
621
- const cleaned = cleanHtml(m[1]);
622
- 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 条,留点余量
623
694
  }
624
- 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);
625
703
  }
626
704
 
627
705
  /**
@@ -779,24 +857,31 @@ let prewarmRunning = false;
779
857
 
780
858
  async function prewarmTranslation(ctx, rows) {
781
859
  if (prewarmRunning || !ctx) return;
782
- 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)));
783
861
  if (targets.length === 0) return;
784
862
  prewarmRunning = true;
785
863
  try {
786
- let cursor = 0;
787
- const worker = async () => {
788
- for (;;) {
789
- const i = cursor++;
790
- if (i >= targets.length) return;
791
- try {
792
- await translateDesc(ctx, targets[i].desc);
793
- } catch {
794
- /* 单条失败忽略 */
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);
795
880
  }
881
+ } catch {
882
+ /* 单条失败忽略,继续下一条 */
796
883
  }
797
- };
798
- // 并发 4:比用户请求低,避免抢带宽
799
- await Promise.all(Array.from({ length: Math.min(4, targets.length) }, worker));
884
+ }
800
885
  } finally {
801
886
  prewarmRunning = false;
802
887
  }
@@ -936,21 +1021,33 @@ function ensureZhLoaded() {
936
1021
  }
937
1022
  }
938
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
+ */
939
1045
  function scheduleZhFlush() {
940
1046
  if (zhFlushTimer !== null) return;
941
1047
  zhFlushTimer = setTimeout(() => {
942
1048
  zhFlushTimer = null;
943
- try {
944
- const file = zhFilePath();
945
- const dir = join(file, '..');
946
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
947
- let entries = [...zhCache.entries()];
948
- if (entries.length > ZH_MAX) entries = entries.slice(-ZH_MAX);
949
- writeFileSync(file, JSON.stringify(Object.fromEntries(entries)), 'utf8');
950
- } catch {
951
- /* 忽略 */
952
- }
953
- }, 3000);
1049
+ flushZhNow();
1050
+ }, 800);
954
1051
  if (typeof zhFlushTimer.unref === 'function') zhFlushTimer.unref();
955
1052
  }
956
1053
 
@@ -960,12 +1057,23 @@ function sha1(input) {
960
1057
 
961
1058
  function zhCacheGet(text) {
962
1059
  ensureZhLoaded();
1060
+ /**
1061
+ * ⚠️ 这里存的是**纯字符串**(见 zhCacheSet:set(hash, zh))。
1062
+ *
1063
+ * 之前写成 `hit ? hit.zh : null` —— 字符串没有 .zh 属性,永远取到 undefined,
1064
+ * 于是译文缓存**只写不读**:每条都要重新调模型,用户感知就是「完全没缓存」。
1065
+ * 现在按字符串取,同时兼容历史 JSON 里可能出现的 {zh} 结构。
1066
+ */
963
1067
  const hit = zhCache.get(sha1(text));
964
- 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;
965
1072
  }
966
1073
 
967
1074
  function zhCacheSet(text, zh) {
968
1075
  ensureZhLoaded();
1076
+ bindZhExitHook();
969
1077
  zhCache.set(sha1(text), zh);
970
1078
  scheduleZhFlush();
971
1079
  if (zhCache.size > ZH_MAX) {
@@ -1063,7 +1171,8 @@ async function pickModel(ctx, llm) {
1063
1171
  // 1) 宿主默认模型
1064
1172
  try {
1065
1173
  const svc = service(ctx, 'agentDefaultModel');
1066
- 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;
1067
1176
  const resolved = await def;
1068
1177
  const provider = resolved?.provider ?? resolved?.providerId;
1069
1178
  const model = resolved?.model ?? resolved?.modelId;
@@ -1097,6 +1206,85 @@ async function pickModel(ctx, llm) {
1097
1206
  return null;
1098
1207
  }
1099
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
+
1100
1288
  async function translateDesc(ctx, text) {
1101
1289
  const src = String(text || '').trim();
1102
1290
  if (!src) return null;
@@ -1172,6 +1360,15 @@ async function translateDesc(ctx, text) {
1172
1360
  */
1173
1361
  function detailShapeOk(detail) {
1174
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;
1175
1372
  const chart = detail.chart;
1176
1373
  if (!chart) return true; // 没曲线是允许的(可能只是取不到)
1177
1374
  if (!Array.isArray(chart.series) || chart.series.length === 0) return false;
@@ -1207,22 +1404,49 @@ async function fetchDetailInner(repo, slug) {
1207
1404
  if (cached && detailShapeOk(cached)) return { ...cached, cached: true };
1208
1405
  if (cached) cache.delete(cacheKey); // 结构过期,丢掉重取
1209
1406
 
1210
- const [pageResult, chartResult] = await Promise.allSettled([
1211
- fetchText(BASE + '/projects/' + slug),
1212
- 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))),
1213
1424
  ]);
1214
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
+
1215
1436
  let desc = null;
1216
1437
  let descSource = 'none';
1217
1438
  let pageUrl = BASE + '/projects/' + slug;
1218
1439
  let started = null;
1219
1440
  let whatIsIt = null;
1441
+ /** At a glance 的完整问答(5 组);详情页展示来源用,比单段简介信息量大 */
1442
+ let glance = [];
1220
1443
 
1221
1444
  let avatarUrl = null;
1222
1445
  if (pageResult.status === 'fulfilled') {
1223
1446
  const html = pageResult.value;
1224
1447
  avatarUrl = parseAvatarUrl(html);
1225
1448
  const conclusion = parseConclusion(html);
1449
+ glance = parseGlance(html);
1226
1450
  whatIsIt = parseWhatIsIt(html);
1227
1451
  started = (html.match(/Last updated\s+([A-Z][a-z]+ \d{1,2}, \d{4})/i) || [])[1] || null;
1228
1452
  if (conclusion) {
@@ -1236,11 +1460,16 @@ async function fetchDetailInner(repo, slug) {
1236
1460
 
1237
1461
  let chart = null;
1238
1462
  let chartError = null;
1239
- if (chartResult.status === 'fulfilled') {
1463
+ let chartPending = false;
1464
+ if (chartResult.status === 'pending') {
1465
+ chartPending = true; // 前端据此再取一次,不影响页面其它内容
1466
+ } else if (chartResult.status === 'fulfilled') {
1240
1467
  chart = parseChartSvg(chartResult.value);
1241
1468
  if (!chart) chartError = '曲线 SVG 解析失败';
1242
1469
  } else {
1243
- 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;
1244
1473
  }
1245
1474
 
1246
1475
  const value = {
@@ -1252,16 +1481,37 @@ async function fetchDetailInner(repo, slug) {
1252
1481
  desc,
1253
1482
  descSource,
1254
1483
  whatIsIt,
1484
+ glance,
1255
1485
  analysisUpdated: started,
1256
1486
  chart,
1257
1487
  chartError,
1488
+ chartPending,
1258
1489
  pageError:
1259
1490
  pageResult.status === 'rejected'
1260
1491
  ? String(pageResult.reason && pageResult.reason.message ? pageResult.reason.message : pageResult.reason)
1261
1492
  : null,
1262
1493
  fetchedAt: nowIso(),
1263
1494
  };
1264
- 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
+ }
1265
1515
  return { ...value, cached: false };
1266
1516
  }
1267
1517
 
@@ -1412,6 +1662,8 @@ async function fetchAvatar(owner, knownUrl) {
1412
1662
 
1413
1663
  // ③ 出网
1414
1664
  let lastError = null;
1665
+ /** 最近一次 /detail 的分段耗时(毫秒),只在 /diag 里暴露。 */
1666
+ let lastTiming = null;
1415
1667
  for (const url of avatarSources(key, knownUrl)) {
1416
1668
  const controller = new AbortController();
1417
1669
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -1470,13 +1722,28 @@ async function handleRoute(ctx, req, res) {
1470
1722
 
1471
1723
  if (path === '/detail') {
1472
1724
  const repository = url.searchParams.get('repo') || '';
1725
+ const t0 = Date.now();
1473
1726
  const detail = await fetchDetail(repository);
1474
- // 简介翻成中文(拿不到译文就留 null,界面回退英文原文)
1727
+ const t1 = Date.now();
1728
+ /**
1729
+ * 简介与问答分别翻成中文(拿不到译文就留 null,界面回退英文原文)。
1730
+ *
1731
+ * 两个函数内部都按原文哈希查缓存,命中时不调模型 —— 所以这里直接调用,
1732
+ * 不需要额外判断「翻译过没有」。
1733
+ */
1475
1734
  let descZh = null;
1476
- if (detail && detail.desc) {
1735
+ if (url.searchParams.get('translate') !== '0' && detail && detail.desc) {
1477
1736
  try { descZh = await translateDesc(ctx, detail.desc); } catch { descZh = null; }
1478
1737
  }
1479
- 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 } });
1480
1747
  return;
1481
1748
  }
1482
1749
 
@@ -1548,6 +1815,7 @@ async function handleRoute(ctx, req, res) {
1548
1815
  descSource: detail.descSource,
1549
1816
  chart: detail.chart,
1550
1817
  chartError: detail.chartError,
1818
+ chartPending: detail.chartPending,
1551
1819
  avatar,
1552
1820
  };
1553
1821
  } catch (error) {
@@ -1570,7 +1838,7 @@ async function handleRoute(ctx, req, res) {
1570
1838
  * 响应发出去之后再**后台预翻译**(不 await,不拖慢这次响应)。
1571
1839
  * 用户此刻看到的是英文;等他切到「中文」时,译文大多已经热好了。
1572
1840
  */
1573
- if (wantZh !== true) {
1841
+ if (wantZh !== true && url.searchParams.get('translate') !== '0') {
1574
1842
  void prewarmTranslation(ctx, items.map((it) => ({ desc: it.desc, repository: it.repository })));
1575
1843
  }
1576
1844
  return;
@@ -1700,6 +1968,76 @@ async function handleRoute(ctx, req, res) {
1700
1968
  return;
1701
1969
  }
1702
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
+
1703
2041
  if (path === '/status') {
1704
2042
  sendJson(res, 200, {
1705
2043
  ok: true,
@@ -1707,6 +2045,7 @@ async function handleRoute(ctx, req, res) {
1707
2045
  version: VERSION,
1708
2046
  cacheSize: cache.size,
1709
2047
  lastError,
2048
+ lastTiming,
1710
2049
  });
1711
2050
  return;
1712
2051
  }
@@ -1746,6 +2085,7 @@ export const __internals = {
1746
2085
  parseChartSvg,
1747
2086
  parseConclusion,
1748
2087
  parseWhatIsIt,
2088
+ parseGlance,
1749
2089
  slugOf,
1750
2090
  currentWeekMonday,
1751
2091
  dayKeyInSourceTz,
@@ -1754,9 +2094,13 @@ export const __internals = {
1754
2094
  handleRoute,
1755
2095
  parseAvatarUrl,
1756
2096
  fetchAvatar,
2097
+ compareVersion,
1757
2098
  detailShapeOk,
1758
2099
  cacheGet,
1759
2100
  cacheSet,
2101
+ flushZhNow,
2102
+ zhCacheGet,
2103
+ zhCacheSet,
1760
2104
  localDayKey,
1761
2105
  DAY_MS,
1762
2106
  /** 仅供测试:直接拿到缓存表,用来伪造写入时间 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-github-hot",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "DSH GitHub 涨星榜插件:日/周/月三榜切换,展示每期涨星 TOP20。卡片流榜单,每个项目带独立中文简介、星数曲线趋势图与多项目走势对比。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -41,7 +41,7 @@
41
41
  "node": ">=18.0.0"
42
42
  },
43
43
  "scripts": {
44
- "test": "node test/client.mjs && node test/render.mjs && node test/visual.mjs && node test/baseline.mjs && node test/cache.mjs && node test/uicache.mjs && node test/persist.mjs && node test/smooth.mjs && node test/timezone.mjs && node test/lang.mjs && node test/concurrency.mjs && node test/hardening.mjs && node test/resilience.mjs && node test/parsers.mjs && node test/detail.mjs && node test/avatar.mjs"
44
+ "test": "node test/client.mjs && node test/render.mjs && node test/visual.mjs && node test/baseline.mjs && node test/cache.mjs && node test/uicache.mjs && node test/persist.mjs && node test/smooth.mjs && node test/timezone.mjs && node test/lang.mjs && node test/i18n.mjs && node test/settings.mjs && node test/dateformat.mjs && node test/zcache.mjs && node test/concurrency.mjs && node test/hardening.mjs && node test/resilience.mjs && node test/parsers.mjs && node test/detail.mjs && node test/avatar.mjs && node test/pickactions.mjs && node --test test/interactions.mjs test/routes.mjs"
45
45
  },
46
46
  "dsh": {
47
47
  "bundle": {
@@ -52,5 +52,10 @@
52
52
  "immediately": true,
53
53
  "inject": []
54
54
  }
55
+ },
56
+ "devDependencies": {
57
+ "react": "18.3.1",
58
+ "react-dom": "18.3.1",
59
+ "react-test-renderer": "18.3.1"
55
60
  }
56
61
  }