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/README.md +6 -1
- package/lib/client.js +988 -182
- package/lib/index.js +404 -46
- package/package.json +7 -2
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
|
-
|
|
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
|
-
|
|
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(/>/g, '>')
|
|
166
222
|
.replace(/"/g, '"')
|
|
167
223
|
.replace(/'/g, "'")
|
|
224
|
+
.replace(/'/g, "'")
|
|
225
|
+
/**
|
|
226
|
+
* 数字字符引用统一解码:' 和 ' 是同一条(十六进制/十进制),
|
|
227
|
+
* 页面里两种写法都会出现,只处理 ' 会漏掉 ',显示成 GitHub'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 =
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
930
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/** 仅供测试:直接拿到缓存表,用来伪造写入时间 */
|