dsh-armory 1.10.0 → 2.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.
@@ -0,0 +1,721 @@
1
+ // awesome-dsh-plugins 市场数据层(T01,docs/system-design-awesome.md)。
2
+ //
3
+ // 与既有 codebuddy 插件市场(lib/wb-plugin-market.js 的 zip 管线)完全解耦:
4
+ // awesome 源是单文件裸 JSON(~13.4MB,全量 5 千余条,其中 unverified/broken 占
5
+ // 绝大多数),本模块只做「拉取链 → 瘦身过滤 → 原子缓存」三件事,纯数据层,
6
+ // 不做安装、不做 UI 投影。纪律(与 wb-registry.js 同款):
7
+ // - 源 URL 只活在本文件常量 AWESOME_SOURCES 里(裁定 1:registry 不感知 awesome,
8
+ // 禁止出现第二处硬编码真相)。
9
+ // - 缓存目录 <root>/awesome-market/:slim.json(瘦身影射数组)+ meta.json
10
+ // ({etag, fetchedAt, updated, counts}),落盘一律 staging + rm + rename 原子写。
11
+ // - 测试可通过 options.rootDir 或 DSH_ARMORY_HOME 把根目录指到临时目录,
12
+ // 绝不碰真实 ~/.dsh。
13
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ /** 市场标识:client 接收 catalog 响应后按 res.marketplace 给条目统一贴标。 */
18
+ export const AWESOME_MARKETPLACE_ID = "awesome-dsh-plugins";
19
+
20
+ // 双 CDN 拉取链(jsdelivr 主源 → fastly 备源,按序尝试,单源失败切下一源)。
21
+ const AWESOME_SOURCES = [
22
+ "https://cdn.jsdelivr.net/gh/dshworks/awesome-dsh-plugins@main/data/plugins.json",
23
+ "https://fastly.jsdelivr.net/gh/dshworks/awesome-dsh-plugins@main/data/plugins.json",
24
+ ];
25
+
26
+ /**
27
+ * slim.json 体积验收上限(3MB)。真实 13.4MB 源过滤+瘦身后的产物约 1.5-2MB;
28
+ * 若未来源膨胀超限,降级旋钮是把 slimEntries 的 description 截断从 120 收到 80
29
+ * (本文件 truncateText 调用点),预估可再省 ~15%。不在此做动态降级逻辑,
30
+ * 体积断言由 test/wb-awesome-market.mjs 钉住。
31
+ */
32
+ export const SLIM_MAX_BYTES = 3 * 1024 * 1024;
33
+
34
+ const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)";
35
+ const DOWNLOAD_TIMEOUT_MS = 120_000; // 13.4MB 全量下载的超时余量
36
+ const PROBE_TIMEOUT_MS = 30_000; // HEAD 条件探测轻量快速
37
+
38
+ /**
39
+ * 首屏提速(stale-while-revalidate):磁盘/内存有货就立刻返回,网络再验证挪到后台。
40
+ * 缓存龄期超过该值才触发一次后台再验证,期间调用方秒回旧数据、UI 不转圈。
41
+ */
42
+ const REVALIDATE_AFTER_MS = 5 * 60_000;
43
+ /** 后台再验证失败后的退避窗口,避免每次进面板都重试两个 CDN(各 120s 超时)。 */
44
+ const REVALIDATE_BACKOFF_MS = 60_000;
45
+
46
+ /** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 wb-registry.js 同款)。 */
47
+ function awesomeRootDir(options = {}) {
48
+ return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
49
+ }
50
+
51
+ /** 缓存子目录:<root>/awesome-market。 */
52
+ function awesomeMarketDir(options = {}) {
53
+ return join(awesomeRootDir(options), "awesome-market");
54
+ }
55
+
56
+ function slimPathOf(options = {}) {
57
+ return join(awesomeMarketDir(options), "slim.json");
58
+ }
59
+
60
+ function metaPathOf(options = {}) {
61
+ return join(awesomeMarketDir(options), "meta.json");
62
+ }
63
+
64
+ /** 读 JSON 文件;不存在/损坏一律返回 null(调用方决定回落语义),永不抛错。 */
65
+ async function readJsonAt(path) {
66
+ try {
67
+ return JSON.parse(await readFile(path, "utf8"));
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** staging + rename 原子写(wb-registry.js writeAtomic 同款):先写 <file>.staging,
74
+ * Windows rename 不覆盖所以先 rm 旧文件再 rename。文本一律 UTF-8。 */
75
+ async function writeAtomic(path, text) {
76
+ const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
77
+ const dir = cut > 0 ? path.slice(0, cut) : path;
78
+ await mkdir(dir, { recursive: true });
79
+ await writeFile(`${path}.staging`, text, "utf8");
80
+ await rm(path, { force: true });
81
+ await rename(`${path}.staging`, path);
82
+ }
83
+
84
+ // ── 瘦身(纯函数)──────────────────────────────────────────────────────────
85
+
86
+ /** npm 字段防御提取:非空字符串直取;{name} 形态取 npm.name;其余出局(返回 "")。 */
87
+ function extractNpm(value) {
88
+ if (typeof value === "string") {
89
+ const trimmed = value.trim();
90
+ return trimmed || "";
91
+ }
92
+ if (value && typeof value === "object" && !Array.isArray(value)) {
93
+ if (typeof value.name === "string") {
94
+ const trimmed = value.name.trim();
95
+ return trimmed || "";
96
+ }
97
+ }
98
+ return "";
99
+ }
100
+
101
+ /** description 防御截断:非字符串回落 "";字符串截断 ≤120 字符(降级旋钮见 SLIM_MAX_BYTES)。 */
102
+ function truncateText(value, maxChars = 120) {
103
+ return typeof value === "string" ? value.slice(0, maxChars) : "";
104
+ }
105
+
106
+ /** tags 防御归一:非数组 → [];数组只留字符串且最多 2 个。 */
107
+ function normalizeTags(value) {
108
+ if (!Array.isArray(value)) return [];
109
+ return value.filter((tag) => typeof tag === "string").slice(0, 2);
110
+ }
111
+
112
+ /**
113
+ * 源数据 plugins 数组 → 瘦身影射数组(R11 口径)。
114
+ *
115
+ * 过滤:只保留 status === "verified" 且 npm 非空(对象形态取 npm.name)的条目;
116
+ * unverified/broken 在这一步出局,永不进缓存。单条畸形(name 非非空字符串等致命
117
+ * 字段缺失)直接丢弃该条,不抛错、不拖垮整目录。
118
+ *
119
+ * 映射:瘦身后 12 个字段全部必填有默认(见下)。名称排序不在此做(UI 层排序),
120
+ * 保持源顺序输出。畸形输入(非数组)返回 []。
121
+ */
122
+ export function slimEntries(rawArray) {
123
+ if (!Array.isArray(rawArray)) return [];
124
+ const slim = [];
125
+ for (const raw of rawArray) {
126
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
127
+ if (raw.status !== "verified") continue;
128
+ const npm = extractNpm(raw.npm);
129
+ if (!npm) continue;
130
+ const name = typeof raw.name === "string" ? raw.name.trim() : "";
131
+ if (!name) continue;
132
+ const category = typeof raw.category === "string" && raw.category.trim() ? raw.category.trim() : "plugin";
133
+ slim.push({
134
+ name,
135
+ repo: typeof raw.repo === "string" ? raw.repo : "",
136
+ npm,
137
+ description: truncateText(raw.description, 120),
138
+ category,
139
+ status: "verified",
140
+ tags: normalizeTags(raw.tags),
141
+ stars: typeof raw.stars === "number" && Number.isFinite(raw.stars) ? raw.stars : 0,
142
+ official: Boolean(raw.official),
143
+ verifiedAgainst: typeof raw.verifiedAgainst === "string" ? raw.verifiedAgainst : "",
144
+ lastVerified: typeof raw.lastVerified === "string" ? raw.lastVerified : "",
145
+ });
146
+ }
147
+ return slim;
148
+ }
149
+
150
+ /** 瘦身结果 → counts 统计:{ total, byCategory }。 */
151
+ function countsOf(entries) {
152
+ const byCategory = {};
153
+ for (const entry of entries) {
154
+ byCategory[entry.category] = (byCategory[entry.category] || 0) + 1;
155
+ }
156
+ return { total: entries.length, byCategory };
157
+ }
158
+
159
+ // ── 拉取链与缓存落盘 ────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * 按 AWESOME_SOURCES 顺序拉取:单源失败(网络异常 / 非 2xx / 响应体畸形)→ 试下一源。
163
+ * 带 If-None-Match 条件头(仅当 etag 非空);304 且有 etag → { ok, notModified }。
164
+ * 全源失败 → { ok: false, error }。下载全程带 120s 超时。
165
+ */
166
+ async function fetchChain(etag) {
167
+ const headers = { "user-agent": USER_AGENT };
168
+ if (etag) headers["if-none-match"] = etag;
169
+ let lastError = "";
170
+ for (const sourceUrl of AWESOME_SOURCES) {
171
+ let response;
172
+ try {
173
+ response = await fetch(sourceUrl, { headers, signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
174
+ } catch (error) {
175
+ lastError = `${sourceUrl} → ${error.message}`;
176
+ continue;
177
+ }
178
+ if (response.status === 304 && etag) return { ok: true, notModified: true };
179
+ if (!response.ok) {
180
+ lastError = `${sourceUrl} → HTTP ${response.status}`;
181
+ continue;
182
+ }
183
+ try {
184
+ const text = Buffer.from(await response.arrayBuffer()).toString("utf8");
185
+ const data = JSON.parse(text);
186
+ if (!data || typeof data !== "object" || Array.isArray(data) || !Array.isArray(data.plugins)) {
187
+ throw new Error("响应缺少 plugins 数组");
188
+ }
189
+ return { ok: true, data, etag: response.headers.get("etag") || "", source: sourceUrl };
190
+ } catch (error) {
191
+ lastError = `${sourceUrl} → ${error.message}`;
192
+ continue;
193
+ }
194
+ }
195
+ return { ok: false, error: `全部 awesome 源拉取失败:${lastError}` };
196
+ }
197
+
198
+ /** 瘦身结果 + 元信息原子落盘(slim.json + meta.json),返回 counts。
199
+ * 同时刷新进程内缓存层,后续调用免掉 readFile + JSON.parse。
200
+ * source 记录「这份缓存是从哪个源拿到的」:探测时优先回同一源比对 ETag,
201
+ * 避免主源挂掉走备用源取到缓存后、探测却只问主源而误报失败。 */
202
+ async function persistSlim(entries, etag, updated, options = {}, source = "") {
203
+ const counts = countsOf(entries);
204
+ const fetchedAt = Date.now();
205
+ await writeAtomic(slimPathOf(options), JSON.stringify(entries));
206
+ await writeAtomic(metaPathOf(options), JSON.stringify({ etag, fetchedAt, updated, counts, source }));
207
+ setMemSlim(options, entries, { etag, fetchedAt, updated, counts, source });
208
+ return counts;
209
+ }
210
+
211
+ // ── 进程内缓存 + 单航班后台再验证 ─────────────────────────────────────────
212
+ // slim.json 约 1.5-2MB,每次 catalog 都 readFile + JSON.parse 纯属浪费;更要命的是
213
+ // 过去「有缓存也先等一次条件 GET」(两个源串行,最坏 2 × 120s)才肯返回,这是
214
+ // 「市场初次加载慢」的服务端一半。现在内存层命中即返回,过期后只后台再验证。
215
+ let memSlim = null; // { rootKey, entries, meta }
216
+ let revalidating = null; // Promise | null(单航班,避免并发重复拉取)
217
+ let revalidateFailedAt = 0;
218
+
219
+ /** 内存层分键:不同 rootDir(测试/多实例)不得串味。 */
220
+ function memKeyOf(options = {}) {
221
+ return awesomeMarketDir(options);
222
+ }
223
+
224
+ function getMemSlim(options = {}) {
225
+ const key = memKeyOf(options);
226
+ return memSlim && memSlim.rootKey === key ? memSlim : null;
227
+ }
228
+
229
+ function setMemSlim(options = {}, entries, meta) {
230
+ memSlim = { rootKey: memKeyOf(options), entries, meta: meta || {} };
231
+ }
232
+
233
+ /** 缓存龄期(ms);meta 无有效 fetchedAt 视为无限陈旧。 */
234
+ function ageOfMeta(meta) {
235
+ const at = Number(meta && meta.fetchedAt);
236
+ return Number.isFinite(at) && at > 0 ? Date.now() - at : Number.POSITIVE_INFINITY;
237
+ }
238
+
239
+ /**
240
+ * 读瘦身缓存:slim.json 可解析为数组即视为有缓存;meta.json 缺失/损坏回落 {}。
241
+ * 两个文件任一损坏都不抛错。`useMem: false` 用于强制走磁盘(refresh 路径要看到
242
+ * 外部刚写入的缓存,不能被进程内旧值遮住)。
243
+ */
244
+ async function readSlimCache(options = {}, { useMem = true } = {}) {
245
+ if (useMem) {
246
+ const mem = getMemSlim(options);
247
+ if (mem) return { entries: mem.entries, meta: mem.meta };
248
+ }
249
+ const entries = await readJsonAt(slimPathOf(options));
250
+ if (!Array.isArray(entries)) return null;
251
+ const meta = (await readJsonAt(metaPathOf(options))) || {};
252
+ setMemSlim(options, entries, meta);
253
+ return { entries, meta };
254
+ }
255
+
256
+ /**
257
+ * 后台再验证:不阻塞调用方;单航班 + 失败退避。
258
+ * 成功时 refreshFromNetwork 已把新数据原子落盘并刷新内存层,下次请求即拿新结果;
259
+ * 失败只静默保留旧缓存(陈旧但可用,绝不把错误抛给 UI)。
260
+ */
261
+ function revalidateInBackground(options = {}) {
262
+ if (revalidating) return revalidating;
263
+ if (revalidateFailedAt > 0 && Date.now() - revalidateFailedAt < REVALIDATE_BACKOFF_MS) return null;
264
+ revalidating = (async () => {
265
+ try {
266
+ const cache = await readSlimCache(options);
267
+ const etag = cache && typeof cache.meta.etag === "string" ? cache.meta.etag : "";
268
+ const chain = await fetchChain(etag);
269
+ if (!chain.ok) {
270
+ revalidateFailedAt = Date.now();
271
+ return;
272
+ }
273
+ if (!chain.notModified) await refreshFromNetwork(chain, options);
274
+ revalidateFailedAt = 0;
275
+ } catch {
276
+ revalidateFailedAt = Date.now();
277
+ } finally {
278
+ revalidating = null;
279
+ }
280
+ })();
281
+ return revalidating;
282
+ }
283
+
284
+ /**
285
+ * 304 命中:内容未变,只把 meta.fetchedAt 推到当前时刻以重置 stale 计时
286
+ * (否则缓存会永远被判为「陈旧」,每次进面板都白跑一次网络验证)。
287
+ * 写盘失败不影响返回值:内存层已更新。
288
+ */
289
+ async function touchSlimMeta(options = {}, cache) {
290
+ const meta = { ...(cache.meta || {}), fetchedAt: Date.now() };
291
+ setMemSlim(options, cache.entries, meta);
292
+ try {
293
+ await writeAtomic(metaPathOf(options), JSON.stringify(meta));
294
+ } catch {
295
+ /* 元信息落盘失败可接受:下次仍会再验证一次 */
296
+ }
297
+ return meta;
298
+ }
299
+
300
+ /** 统一组装 catalog 响应体(cache/disk 复用):counts 优先用 meta 里落盘的统计。 */
301
+ function catalogResult(entries, meta, loadedFrom, extra = {}) {
302
+ const counts =
303
+ meta && typeof meta.counts === "object" && meta.counts !== null && Number.isFinite(meta.counts.total)
304
+ ? meta.counts
305
+ : countsOf(entries);
306
+ const fetchedAt = Number(meta && meta.fetchedAt);
307
+ return {
308
+ ok: true,
309
+ loadedFrom,
310
+ updated: typeof meta.updated === "string" ? meta.updated : "",
311
+ fetchedAt: Number.isFinite(fetchedAt) && fetchedAt > 0 ? fetchedAt : 0,
312
+ counts,
313
+ plugins: entries,
314
+ marketplace: AWESOME_MARKETPLACE_ID,
315
+ ...extra,
316
+ };
317
+ }
318
+
319
+ /** 拉取链成功后的统一走线:瘦身 → 原子落盘 → 组装 network 响应。 */
320
+ async function refreshFromNetwork(chain, options = {}) {
321
+ const entries = slimEntries(chain.data.plugins);
322
+ const counts = await persistSlim(entries, chain.etag, typeof chain.data.updated === "string" ? chain.data.updated : "", options, typeof chain.source === "string" ? chain.source : "");
323
+ return {
324
+ ok: true,
325
+ loadedFrom: "network",
326
+ updated: typeof chain.data.updated === "string" ? chain.data.updated : "",
327
+ fetchedAt: Date.now(),
328
+ counts,
329
+ plugins: entries,
330
+ marketplace: AWESOME_MARKETPLACE_ID,
331
+ };
332
+ }
333
+
334
+ // ── 四个对外 API ───────────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * 读 awesome 目录(缓存优先 + 后台再验证 → 首屏秒出):
338
+ * 1. refresh: true(UI「重新加载」按钮)→ 同步强刷:条件 GET,304 复用缓存
339
+ * (loadedFrom: "cache");200 重新瘦身落盘("network");全源失败且有缓存
340
+ * 回落 "disk" + stale: true;无缓存 { ok: false }。
341
+ * 2. 缓存新鲜(龄期 < REVALIDATE_AFTER_MS)→ 直接返内存/磁盘缓存("cache"),
342
+ * 完全不碰网络。
343
+ * 3. 缓存陈旧 → 仍立刻返回旧缓存("cache" + refreshing: true),网络验证丢给
344
+ * 后台单航班;下次进面板即拿到新数据,失败静默保留旧缓存。
345
+ * 4. 无缓存(真正的首次)→ 全量拉取 → 瘦身落盘("network")。
346
+ * 响应统一带 fetchedAt(缓存抓取时刻)与(陈旧分支的)refreshing 标记,
347
+ * 供 UI 直接显示「后台更新中」而无需二次请求。
348
+ * 永不抛错(拉取/解析/写盘异常都折进返回体)。
349
+ */
350
+ export async function wbAwesomeCatalog(options = {}) {
351
+ const wantRefresh = Boolean(options) && options.refresh === true;
352
+ try {
353
+ const cache = await readSlimCache(options, { useMem: !wantRefresh });
354
+ if (cache) {
355
+ const etag = typeof cache.meta.etag === "string" ? cache.meta.etag : "";
356
+ if (!wantRefresh && ageOfMeta(cache.meta) < REVALIDATE_AFTER_MS) {
357
+ return catalogResult(cache.entries, cache.meta, "cache");
358
+ }
359
+ if (!wantRefresh) {
360
+ // 陈旧缓存:先秒回旧数据,网络验证交给后台(单航班 + 失败退避)。
361
+ revalidateInBackground(options);
362
+ return catalogResult(cache.entries, cache.meta, "cache", { refreshing: true });
363
+ }
364
+ const chain = await fetchChain(etag);
365
+ if (chain.ok && chain.notModified) return catalogResult(cache.entries, await touchSlimMeta(options, cache), "cache");
366
+ if (chain.ok) return await refreshFromNetwork(chain, options);
367
+ // 全源失败但磁盘有货:返回陈旧缓存,stale 标记交给 UI 出提示条。
368
+ return catalogResult(cache.entries, cache.meta, "disk", { stale: true });
369
+ }
370
+ const chain = await fetchChain("");
371
+ if (!chain.ok) return { ok: false, error: chain.error };
372
+ return await refreshFromNetwork(chain, options);
373
+ } catch (error) {
374
+ return { ok: false, error: `awesome 目录加载异常:${error.message}` };
375
+ }
376
+ }
377
+
378
+ /** 按名字查瘦身缓存单条;无缓存先 ensureSlim(走 wbAwesomeCatalog 全量链);
379
+ * 名字不存在 / 加载失败返回 null。 */
380
+ export async function wbAwesomeDetail(name, options = {}) {
381
+ const wanted = typeof name === "string" ? name.trim() : "";
382
+ if (!wanted) return null;
383
+ try {
384
+ let cache = await readSlimCache(options);
385
+ if (!cache) {
386
+ const fresh = await wbAwesomeCatalog(options);
387
+ if (!fresh.ok) return null;
388
+ return fresh.plugins.find((entry) => entry.name === wanted) ?? null;
389
+ }
390
+ return cache.entries.find((entry) => entry.name === wanted) ?? null;
391
+ } catch {
392
+ return null;
393
+ }
394
+ }
395
+
396
+ /**
397
+ * 单源 ETag 条件探测:HEAD(被 405/501 拒绝则降级 GET 并立刻 cancel 响应体)。
398
+ * 绝不落盘,只取 headers。返回 { ok: true, remoteEtag } 或 { ok: false, error }。
399
+ */
400
+ async function probeEtagAt(sourceUrl, localEtag) {
401
+ const headers = { "user-agent": USER_AGENT, "if-none-match": localEtag };
402
+ let response;
403
+ try {
404
+ response = await fetch(sourceUrl, { method: "HEAD", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
405
+ if (response.status === 405 || response.status === 501) {
406
+ // HEAD 不被支持 → 降级 GET,立刻 cancel() 丢弃响应体,不读进内存。
407
+ response.body?.cancel?.();
408
+ response = await fetch(sourceUrl, { method: "GET", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
409
+ response.body?.cancel?.();
410
+ }
411
+ } catch (error) {
412
+ return { ok: false, error: `${sourceUrl} → ${error.message}` };
413
+ }
414
+ if (response.status === 304) return { ok: true, remoteEtag: localEtag };
415
+ if (response.ok) {
416
+ const remoteEtag = response.headers.get("etag") || "";
417
+ // 探测只信 ETag:响应没有 ETag 就无法判定,宁可报探测失败也不误报「有更新」。
418
+ if (!remoteEtag) return { ok: false, error: `${sourceUrl} → 响应缺少 ETag` };
419
+ return { ok: true, remoteEtag };
420
+ }
421
+ return { ok: false, error: `${sourceUrl} → HTTP ${response.status}` };
422
+ }
423
+
424
+ /**
425
+ * ETag 条件探测(wbPluginUpdateCheck 同款语义):**绝不落盘**,只取 headers。
426
+ * reason 五枚举:no-source / probe-failed / no-cache / up-to-date / update-available。
427
+ * 无缓存(meta 无 etag)→ hasUpdate: false + no-cache(与插件市场不同:awesome 首次
428
+ * 由 catalog 链兜底加载,探测层不引导更新)。
429
+ *
430
+ * 探测与目录拉取必须走同一条源链:过去只问 AWESOME_SOURCES[0],于是「主源不通、
431
+ * 目录靠备用源成功」的机器上目录能正常加载、探测却每次 probe-failed —— 这正是
432
+ * 「DSH 社区目录更新探测失败」的来源。现在按「缓存实际来源 → 全部源」顺序逐个试,
433
+ * 首个拿到 ETag 的源胜出;全部失败才报 probe-failed。
434
+ */
435
+ export async function wbAwesomeUpdateCheck(options = {}) {
436
+ try {
437
+ const meta = (await readJsonAt(metaPathOf(options))) || {};
438
+ const localEtag = typeof meta.etag === "string" ? meta.etag : "";
439
+ if (!localEtag) {
440
+ return { ok: true, hasUpdate: false, reason: "no-cache", localEtag: "", remoteEtag: "", checkedAt: Date.now() };
441
+ }
442
+ // 先问拿到这份缓存的源(ETag 同源可比),再兜底其余源。
443
+ const cachedSource = typeof meta.source === "string" ? meta.source : "";
444
+ const candidates = [...new Set([cachedSource, ...AWESOME_SOURCES].filter(Boolean))];
445
+ let lastError = "";
446
+ for (const sourceUrl of candidates) {
447
+ const probe = await probeEtagAt(sourceUrl, localEtag);
448
+ if (!probe.ok) {
449
+ lastError = probe.error;
450
+ continue;
451
+ }
452
+ const hasUpdate = probe.remoteEtag !== localEtag;
453
+ return { ok: true, hasUpdate, reason: hasUpdate ? "update-available" : "up-to-date", localEtag, remoteEtag: probe.remoteEtag, checkedAt: Date.now() };
454
+ }
455
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `awesome 市场更新探测失败:${lastError}` };
456
+ } catch (error) {
457
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag: "", remoteEtag: "", checkedAt: Date.now(), error: `更新探测异常:${error.message}` };
458
+ }
459
+ }
460
+
461
+ /** 缓存元信息(只读,不联网):{ hasCache, etag, fetchedAt, updated }。 */
462
+ export async function wbAwesomeCachedInfo(options = {}) {
463
+ const cache = await readSlimCache(options);
464
+ if (!cache) return { hasCache: false, etag: "", fetchedAt: 0, updated: "" };
465
+ const meta = cache.meta;
466
+ return {
467
+ hasCache: true,
468
+ etag: typeof meta.etag === "string" ? meta.etag : "",
469
+ fetchedAt: typeof meta.fetchedAt === "number" ? meta.fetchedAt : 0,
470
+ updated: typeof meta.updated === "string" ? meta.updated : "",
471
+ };
472
+ }
473
+
474
+ // ── README 预览(按需拉取 + 落盘缓存,纯只读)────────────────────────────────
475
+ //
476
+ // 为什么单独一条 RPC:目录 slim 里不放 README(13MB 全量本身就只留 13 个字段,
477
+ // 塞进 README 会把瘦身缓存撑爆)。README 只在用户点开某张卡片时按需取一次,
478
+ // 取到后按「npm 包名或仓库」落盘缓存,同一插件再点就秒开、也不怕断网。
479
+
480
+ /** 预览是用户点击触发的一次短请求,不能像目录那样等 120s。 */
481
+ const README_TIMEOUT_MS = 20_000;
482
+ /** 正文上限:超长的 CHANGELOG 类 README 截断,别把几 MB 文本塞进浏览器。 */
483
+ const README_MAX_CHARS = 160_000;
484
+ /** 磁盘缓存 TTL:README 变动不频繁,7 天足够,过期再取一次。 */
485
+ const README_CACHE_TTL_MS = 7 * 24 * 3600_000;
486
+ /** GitHub raw 里按顺序尝试的文件名(大小写在不同仓库里真的不一样)。 */
487
+ const README_FILES = ["README.md", "readme.md", "Readme.md", "README.MD", "README.markdown", "README_EN.md"];
488
+ const NPM_REGISTRY = "https://registry.npmjs.org";
489
+
490
+ function readmeCacheDir(options = {}) {
491
+ return join(awesomeMarketDir(options), "readme");
492
+ }
493
+
494
+ /** 缓存文件名:来源标识压成安全短名(不用 crypto,避免多一个依赖)。 */
495
+ function readmeCachePath(key, options = {}) {
496
+ const safe = String(key || "").toLowerCase().replace(/[^a-z0-9._-]+/g, "_").slice(0, 96) || "item";
497
+ return join(readmeCacheDir(options), `${safe}.json`);
498
+ }
499
+
500
+ /**
501
+ * 从各种形态的仓库字段里提取 `owner/repo`;不是 GitHub 或提取不到返回 ""。
502
+ * 吃到过的形态:`owner/repo`、`https://github.com/o/r`、`git+https://github.com/o/r.git`、
503
+ * `git@github.com:o/r.git`、带 `#readme` / `/tree/main` 尾巴的。
504
+ */
505
+ export function parseGithubRepo(value) {
506
+ if (typeof value !== "string") return "";
507
+ let text = value.trim();
508
+ if (!text) return "";
509
+ const ssh = /^git@github\.com:([^/\s]+)\/([^/\s#]+?)(?:\.git)?$/i.exec(text);
510
+ if (ssh) return `${ssh[1]}/${ssh[2].replace(/\.git$/i, "")}`;
511
+ text = text.replace(/^git\+/i, "");
512
+ const http = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+)/i.exec(text);
513
+ if (http) return `${http[1]}/${http[2].replace(/\.git$/i, "")}`;
514
+ const bare = /^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9._-]+)$/.exec(text);
515
+ if (bare) return `${bare[1]}/${bare[2].replace(/\.git$/i, "")}`;
516
+ return "";
517
+ }
518
+
519
+ /** 正文规整:去 BOM、统一换行、按字符上限截断。 */
520
+ function normalizeReadme(text) {
521
+ let out = String(text || "").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
522
+ if (out.length > README_MAX_CHARS) {
523
+ return { markdown: out.slice(0, README_MAX_CHARS), truncated: true };
524
+ }
525
+ return { markdown: out, truncated: false };
526
+ }
527
+
528
+ /** 单次文本 GET(带超时);非 2xx 也把 body 读掉,避免连接悬挂。 */
529
+ async function fetchReadmeText(url, accept) {
530
+ const controller = new AbortController();
531
+ const timer = setTimeout(() => controller.abort(), README_TIMEOUT_MS);
532
+ try {
533
+ const response = await fetch(url, {
534
+ headers: { "user-agent": USER_AGENT, accept },
535
+ signal: controller.signal,
536
+ redirect: "follow",
537
+ });
538
+ if (!response.ok) {
539
+ try {
540
+ await response.text();
541
+ } catch {
542
+ // 忽略:只为释放连接
543
+ }
544
+ return { ok: false, reachable: true, status: response.status, error: `HTTP ${response.status}` };
545
+ }
546
+ return { ok: true, reachable: true, status: response.status, text: await response.text(), url: response.url || url };
547
+ } catch (error) {
548
+ const reason = error && error.name === "AbortError" ? `超时(${README_TIMEOUT_MS}ms)` : String((error && error.message) || error);
549
+ return { ok: false, reachable: false, status: 0, error: reason };
550
+ } finally {
551
+ clearTimeout(timer);
552
+ }
553
+ }
554
+
555
+ /** npm 侧取 README:先打 `/<pkg>/latest`(小),没有再退全量 packument。 */
556
+ async function readmeFromNpm(npmName) {
557
+ const encoded = String(npmName).replace(/\//g, "%2F");
558
+ const latest = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}/latest`, "application/json");
559
+ if (latest.ok) {
560
+ let doc = null;
561
+ try {
562
+ doc = JSON.parse(latest.text);
563
+ } catch {
564
+ doc = null;
565
+ }
566
+ if (doc && typeof doc.readme === "string" && doc.readme.trim()) {
567
+ return { ok: true, markdown: doc.readme, repo: parseGithubRepo(doc.repository), file: doc.readmeFilename || "README.md" };
568
+ }
569
+ }
570
+ const full = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}`, "application/json");
571
+ if (!full.ok) {
572
+ return { ok: false, error: full.error };
573
+ }
574
+ let packument = null;
575
+ try {
576
+ packument = JSON.parse(full.text);
577
+ } catch {
578
+ return { ok: false, error: "npm 返回的不是合法 JSON" };
579
+ }
580
+ const markdown = packument && typeof packument.readme === "string" ? packument.readme : "";
581
+ if (!markdown.trim()) {
582
+ return { ok: false, error: latest.ok ? "npm 包没有 README" : `${latest.error};npm 包没有 README` };
583
+ }
584
+ return { ok: true, markdown, repo: parseGithubRepo(packument.repository), file: packument.readmeFilename || "README.md" };
585
+ }
586
+
587
+ /**
588
+ * GitHub 侧 README 的候选基底,按「先直连、后被墙可用」排序。
589
+ * raw.githubusercontent.com 在部分网络(含国内常见环境)会直接 DNS 失败,
590
+ * 因此补 jsDelivr 的 GitHub 镜像(@main / @master 两个默认分支都试)。
591
+ * 每个基底各自独立:谁先拿到正文,就用谁的目录当相对图片的解析基准。
592
+ */
593
+ function readmeGithubBases(repo) {
594
+ return [
595
+ `https://raw.githubusercontent.com/${repo}/HEAD/`,
596
+ `https://cdn.jsdelivr.net/gh/${repo}@main/`,
597
+ `https://cdn.jsdelivr.net/gh/${repo}@master/`,
598
+ `https://fastly.jsdelivr.net/gh/${repo}@main/`,
599
+ `https://fastly.jsdelivr.net/gh/${repo}@master/`,
600
+ ];
601
+ }
602
+
603
+ /**
604
+ * GitHub 侧取 README:按 README_FILES 顺序在候选基底上试。
605
+ * 一旦某个基底返回过任何 HTTP 响应,就认定网络可达:此时把该基底的
606
+ * 文件名全试完即可收工(不再换基底,避免对一个没 README 的仓库打 30 次请求)。
607
+ */
608
+ async function readmeFromGithub(repo) {
609
+ let lastError = "";
610
+ let sawReachable = false;
611
+ for (const base of readmeGithubBases(repo)) {
612
+ for (const file of README_FILES) {
613
+ const attempt = await fetchReadmeText(`${base}${file}`, "text/plain");
614
+ if (attempt.ok && attempt.text.trim()) {
615
+ return { ok: true, markdown: attempt.text, base, file };
616
+ }
617
+ if (attempt.ok) {
618
+ lastError = "文件为空";
619
+ sawReachable = true;
620
+ continue;
621
+ }
622
+ if (attempt.reachable) {
623
+ lastError = attempt.error;
624
+ sawReachable = true;
625
+ continue;
626
+ }
627
+ lastError = attempt.error;
628
+ break; // 该基底整体不可达(DNS/超时),换下一个
629
+ }
630
+ if (sawReachable) break;
631
+ }
632
+ return { ok: false, error: lastError || "未找到 README" };
633
+ }
634
+
635
+ /**
636
+ * 读取某个市场条目的 README,供客户端详情弹窗预览。
637
+ *
638
+ * 入参:`{ name, npm, repo, refresh }`(`npm` 可传字符串或 `{name}`)。
639
+ * 返回:`{ ok, reason: "cache" | "network" | "no-source" | "readme-unavailable",
640
+ * markdown, truncated, source: "npm" | "github", base, repo, npm, fetchedAt }`。
641
+ *
642
+ * `base` 是相对图片/链接的解析基准(GitHub raw 目录),客户端据此把 README 里的
643
+ * 相对路径 `assets/a.png` 还原成可加载的绝对 URL —— 不还原的话插图全是坏图。
644
+ */
645
+ export async function wbAwesomeReadme(payload = {}) {
646
+ const name = typeof payload.name === "string" ? payload.name.trim() : "";
647
+ const npmName =
648
+ typeof payload.npm === "string"
649
+ ? payload.npm.trim()
650
+ : payload.npm && typeof payload.npm === "object" && typeof payload.npm.name === "string"
651
+ ? payload.npm.name.trim()
652
+ : "";
653
+ let repo = parseGithubRepo(payload.repo);
654
+ const wantRefresh = payload.refresh === true;
655
+ if (!npmName && !repo) {
656
+ return { ok: false, reason: "no-source", name, npm: "", repo: "", markdown: "", truncated: false, source: "", base: "", fetchedAt: 0, error: "该条目没有 npm 包名或 GitHub 仓库地址,无法读取 README" };
657
+ }
658
+ const cacheKey = npmName || repo;
659
+ const cachePath = readmeCachePath(cacheKey, payload);
660
+ if (!wantRefresh) {
661
+ const cached = await readJsonAt(cachePath);
662
+ if (
663
+ cached &&
664
+ typeof cached.markdown === "string" &&
665
+ cached.markdown &&
666
+ Date.now() - Number(cached.fetchedAt || 0) < README_CACHE_TTL_MS
667
+ ) {
668
+ return {
669
+ ok: true,
670
+ reason: "cache",
671
+ name,
672
+ npm: npmName,
673
+ repo: typeof cached.repo === "string" ? cached.repo : repo,
674
+ markdown: cached.markdown,
675
+ truncated: Boolean(cached.truncated),
676
+ source: typeof cached.source === "string" ? cached.source : "",
677
+ base: typeof cached.base === "string" ? cached.base : "",
678
+ fetchedAt: Number(cached.fetchedAt || 0),
679
+ };
680
+ }
681
+ }
682
+ let markdown = "";
683
+ let source = "";
684
+ let base = "";
685
+ const errors = [];
686
+ if (npmName) {
687
+ const fromNpm = await readmeFromNpm(npmName);
688
+ if (fromNpm.ok) {
689
+ markdown = fromNpm.markdown;
690
+ source = "npm";
691
+ repo = repo || parseGithubRepo(fromNpm.repo);
692
+ base = repo ? `https://raw.githubusercontent.com/${repo}/HEAD/` : "";
693
+ } else {
694
+ errors.push(`npm:${fromNpm.error}`);
695
+ }
696
+ }
697
+ if (!markdown && repo) {
698
+ const fromGithub = await readmeFromGithub(repo);
699
+ if (fromGithub.ok) {
700
+ markdown = fromGithub.markdown;
701
+ source = "github";
702
+ base = fromGithub.base;
703
+ } else {
704
+ errors.push(`github:${fromGithub.error}`);
705
+ }
706
+ }
707
+ if (!markdown.trim()) {
708
+ return { ok: false, reason: "readme-unavailable", name, npm: npmName, repo, markdown: "", truncated: false, source: "", base: "", fetchedAt: 0, error: `读取 README 失败:${errors.join(";") || "没有可用来源"}` };
709
+ }
710
+ const normalized = normalizeReadme(markdown);
711
+ const fetchedAt = Date.now();
712
+ try {
713
+ await writeAtomic(
714
+ cachePath,
715
+ JSON.stringify({ name, npm: npmName, repo, markdown: normalized.markdown, truncated: normalized.truncated, source, base, fetchedAt }),
716
+ );
717
+ } catch {
718
+ // 缓存写不进去不影响这次预览
719
+ }
720
+ return { ok: true, reason: "network", name, npm: npmName, repo, markdown: normalized.markdown, truncated: normalized.truncated, source, base, fetchedAt };
721
+ }