dsh-armory 2.3.0 → 2.3.1

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.
@@ -1,26 +1,41 @@
1
1
  // awesome-dsh-plugins 市场数据层(T01)。
2
2
  //
3
- // 与既有 codebuddy 插件市场(lib/wb-plugin-market.js 的 zip 管线)完全解耦:
3
+ // 与连接器市场(lib/wb-market.js 的 zip 管线)完全解耦:
4
4
  // awesome 源是单文件裸 JSON(~13.4MB,全量 5 千余条,其中 unverified/broken 占
5
5
  // 绝大多数),本模块只做「拉取链 → 瘦身过滤 → 原子缓存」三件事,纯数据层,
6
6
  // 不做安装、不做 UI 投影。纪律(与 wb-registry.js 同款):
7
7
  // - 源 URL 只活在本文件常量 AWESOME_SOURCES 里(裁定 1:registry 不感知 awesome,
8
8
  // 禁止出现第二处硬编码真相)。
9
9
  // - 缓存目录 <root>/awesome-market/:slim.json(瘦身影射数组)+ meta.json
10
- // ({etag, fetchedAt, updated, counts}),落盘一律 staging + rm + rename 原子写。
10
+ // ({etag, fetchedAt, updated, counts, source, resolvedSha}),落盘一律
11
+ // staging + rm + rename 原子写。
11
12
  // - 测试可通过 options.rootDir 或 DSH_ARMORY_HOME 把根目录指到临时目录,
12
13
  // 绝不碰真实 ~/.dsh。
13
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
14
- import { homedir } from "node:os";
15
- import { join } from "node:path";
14
+ //
15
+ // 新鲜度(为什么要有 resolvedSha):jsdelivr 对 @main 分支文件有 12h CDN 边缘缓存
16
+ // (s-maxage=43200),旧口径「@main + ETag 条件请求」会被陈旧边缘 304 骗过——上游
17
+ // 仓库已更新,但边缘还握着旧副本且其 ETag 恰与本地一致,市场数据最长滞后 12 小时
18
+ // (2026-10 实测复现)。现在首选经 api.github.com 把 main 解析到最新提交 SHA,再从
19
+ // 各镜像的 @<sha> 不可变地址取数:不可变地址 CDN 永不陈旧,SHA 相同即内容相同、
20
+ // 连下载都省掉;API 不可达时回落旧口径(陈旧窗口回归 12h 上限),可用性不降。
21
+ //
22
+ // 第二社区源(聚合):dsh-market 插件展示的 6.7k 目录来自另一个独立注册表
23
+ // awesome-dsh-plugin.com(CC0),其 npm 可装子集与本源并不重合(实测 npm 并集
24
+ // ~7.2k)。这里把它的 npm 包 dsh-plugin-catalog 作为第二源接入:只取 npm 非空
25
+ // 条目(git-only 不进面板),适配成同一瘦身影射后独立落盘 second.json,服务时
26
+ // 按 npm 名与主源去重合并(主源优先)。取数走「registry 解析 latest 版本号 →
27
+ // jsDelivr @<版本> 不可变地址」双镜像链,机制与主源 SHA 同构。
28
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
29
+ import { homedir } from 'node:os';
30
+ import { join } from 'node:path';
16
31
 
17
32
  /** 市场标识:client 接收 catalog 响应后按 res.marketplace 给条目统一贴标。 */
18
- export const AWESOME_MARKETPLACE_ID = "awesome-dsh-plugins";
33
+ export const AWESOME_MARKETPLACE_ID = 'awesome-dsh-plugins';
19
34
 
20
35
  // 双 CDN 拉取链(jsdelivr 主源 → fastly 备源,按序尝试,单源失败切下一源)。
21
36
  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",
37
+ 'https://cdn.jsdelivr.net/gh/dshworks/awesome-dsh-plugins@main/data/plugins.json',
38
+ 'https://fastly.jsdelivr.net/gh/dshworks/awesome-dsh-plugins@main/data/plugins.json',
24
39
  ];
25
40
 
26
41
  /**
@@ -31,9 +46,30 @@ const AWESOME_SOURCES = [
31
46
  */
32
47
  export const SLIM_MAX_BYTES = 3 * 1024 * 1024;
33
48
 
34
- const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)";
49
+ const USER_AGENT = '@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)';
35
50
  const DOWNLOAD_TIMEOUT_MS = 120_000; // 13.4MB 全量下载的超时余量
36
51
  const PROBE_TIMEOUT_MS = 30_000; // HEAD 条件探测轻量快速
52
+ const GITHUB_API_TIMEOUT_MS = 20_000; // 提交解析是小 JSON,短超时快速失败回落
53
+ /** 提交 SHA 的防御性形态校验(7-40 位十六进制),杜绝把垃圾拼进 URL。 */
54
+ const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
55
+ /** SHA 解析备忘 TTL:60s 内同根的 catalog/探测共享一次解析结果,对 GitHub API 客气。 */
56
+ const SHA_MEMO_TTL_MS = 60_000;
57
+
58
+ // ── 第二社区源(awesome-dsh-plugin.com 聚合目录,CC0)──────────────────────
59
+ // 另一个独立注册表(github.com/awesome-dsh-plugin/awesome-dsh-plugin,6.7k+ 条,
60
+ // npm 与 git 安装混收)。这里只取其 **npm 非空** 子集与主源并集(面板口径始终是
61
+ // 「可 npm 安装」;服务时按 npm 名去重、主源优先,git-only 条目不进面板)。
62
+ // 数据发布为 npm 包 dsh-plugin-catalog,随构建逐日出版本号。取数与主源 SHA 机制
63
+ // 同构:先向 npm registry 解析 latest 版本号(小 JSON),再从 jsDelivr 双镜像按
64
+ // @<版本> 取文件——版本锁定地址 CDN 标记 immutable(s-maxage=1 年),永不陈旧;
65
+ // 解析失败回落无版本地址 + If-None-Match(CDN 对 npm latest 同样最长缓存 12h,
66
+ // 可用性不降)。
67
+ const SECOND_CATALOG_PKG = 'dsh-plugin-catalog';
68
+ const SECOND_CATALOG_FILE = 'plugins.json';
69
+ const SECOND_VERSION_TIMEOUT_MS = 20_000;
70
+ const SECOND_MIRRORS = ['https://cdn.jsdelivr.net', 'https://fastly.jsdelivr.net'];
71
+ /** npm 版本号防御性形态校验(semver 字符集),杜绝把垃圾拼进 URL。 */
72
+ const SECOND_VERSION_PATTERN = /^[0-9A-Za-z.+-]+$/;
37
73
 
38
74
  /**
39
75
  * 首屏提速(stale-while-revalidate):磁盘/内存有货就立刻返回,网络再验证挪到后台。
@@ -45,68 +81,74 @@ const REVALIDATE_BACKOFF_MS = 60_000;
45
81
 
46
82
  /** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 wb-registry.js 同款)。 */
47
83
  function awesomeRootDir(options = {}) {
48
- return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
84
+ return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), '.dsh', 'armory');
49
85
  }
50
86
 
51
87
  /** 缓存子目录:<root>/awesome-market。 */
52
88
  function awesomeMarketDir(options = {}) {
53
- return join(awesomeRootDir(options), "awesome-market");
89
+ return join(awesomeRootDir(options), 'awesome-market');
54
90
  }
55
91
 
56
92
  function slimPathOf(options = {}) {
57
- return join(awesomeMarketDir(options), "slim.json");
93
+ return join(awesomeMarketDir(options), 'slim.json');
58
94
  }
59
95
 
60
96
  function metaPathOf(options = {}) {
61
- return join(awesomeMarketDir(options), "meta.json");
97
+ return join(awesomeMarketDir(options), 'meta.json');
62
98
  }
63
99
 
64
100
  /** 读 JSON 文件;不存在/损坏一律返回 null(调用方决定回落语义),永不抛错。 */
65
101
  async function readJsonAt(path) {
66
102
  try {
67
- return JSON.parse(await readFile(path, "utf8"));
103
+ return JSON.parse(await readFile(path, 'utf8'));
68
104
  } catch {
69
105
  return null;
70
106
  }
71
107
  }
72
108
 
73
- /** staging + rename 原子写(wb-registry.js writeAtomic 同款):先写 <file>.staging,
74
- * Windows rename 不覆盖所以先 rm 旧文件再 rename。文本一律 UTF-8。 */
109
+ /** 唯一临时名 + rename 原子写(wb-registry.js writeAtomic 同款):Windows 的 rename
110
+ * 覆盖已存在文件,无需先 rm——固定 `<file>.staging` 在并发写(后台 revalidate 与
111
+ * refresh 强刷并行)时会互相消费对方的 staging,甚至把刚提交的正本删掉。文本一律 UTF-8。 */
75
112
  async function writeAtomic(path, text) {
76
- const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
113
+ const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
77
114
  const dir = cut > 0 ? path.slice(0, cut) : path;
78
115
  await mkdir(dir, { recursive: true });
79
- await writeFile(`${path}.staging`, text, "utf8");
80
- await rm(path, { force: true });
81
- await rename(`${path}.staging`, path);
116
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
117
+ try {
118
+ await writeFile(tmp, text, 'utf8');
119
+ await rename(tmp, path);
120
+ } catch (error) {
121
+ await rm(tmp, { force: true }).catch(() => {});
122
+ throw error;
123
+ }
82
124
  }
83
125
 
84
126
  // ── 瘦身(纯函数)──────────────────────────────────────────────────────────
85
127
 
86
128
  /** npm 字段防御提取:非空字符串直取;{name} 形态取 npm.name;其余出局(返回 "")。 */
87
129
  function extractNpm(value) {
88
- if (typeof value === "string") {
130
+ if (typeof value === 'string') {
89
131
  const trimmed = value.trim();
90
- return trimmed || "";
132
+ return trimmed || '';
91
133
  }
92
- if (value && typeof value === "object" && !Array.isArray(value)) {
93
- if (typeof value.name === "string") {
134
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
135
+ if (typeof value.name === 'string') {
94
136
  const trimmed = value.name.trim();
95
- return trimmed || "";
137
+ return trimmed || '';
96
138
  }
97
139
  }
98
- return "";
140
+ return '';
99
141
  }
100
142
 
101
143
  /** description 防御截断:非字符串回落 "";字符串截断 ≤120 字符(降级旋钮见 SLIM_MAX_BYTES)。 */
102
144
  function truncateText(value, maxChars = 120) {
103
- return typeof value === "string" ? value.slice(0, maxChars) : "";
145
+ return typeof value === 'string' ? value.slice(0, maxChars) : '';
104
146
  }
105
147
 
106
148
  /** tags 防御归一:非数组 → [];数组只留字符串且最多 2 个。 */
107
149
  function normalizeTags(value) {
108
150
  if (!Array.isArray(value)) return [];
109
- return value.filter((tag) => typeof tag === "string").slice(0, 2);
151
+ return value.filter((tag) => typeof tag === 'string').slice(0, 2);
110
152
  }
111
153
 
112
154
  /**
@@ -123,25 +165,25 @@ export function slimEntries(rawArray) {
123
165
  if (!Array.isArray(rawArray)) return [];
124
166
  const slim = [];
125
167
  for (const raw of rawArray) {
126
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
127
- if (raw.status !== "verified") continue;
168
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
169
+ if (raw.status !== 'verified') continue;
128
170
  const npm = extractNpm(raw.npm);
129
171
  if (!npm) continue;
130
- const name = typeof raw.name === "string" ? raw.name.trim() : "";
172
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
131
173
  if (!name) continue;
132
- const category = typeof raw.category === "string" && raw.category.trim() ? raw.category.trim() : "plugin";
174
+ const category = typeof raw.category === 'string' && raw.category.trim() ? raw.category.trim() : 'plugin';
133
175
  slim.push({
134
176
  name,
135
- repo: typeof raw.repo === "string" ? raw.repo : "",
177
+ repo: typeof raw.repo === 'string' ? raw.repo : '',
136
178
  npm,
137
179
  description: truncateText(raw.description, 120),
138
180
  category,
139
- status: "verified",
181
+ status: 'verified',
140
182
  tags: normalizeTags(raw.tags),
141
- stars: typeof raw.stars === "number" && Number.isFinite(raw.stars) ? raw.stars : 0,
183
+ stars: typeof raw.stars === 'number' && Number.isFinite(raw.stars) ? raw.stars : 0,
142
184
  official: Boolean(raw.official),
143
- verifiedAgainst: typeof raw.verifiedAgainst === "string" ? raw.verifiedAgainst : "",
144
- lastVerified: typeof raw.lastVerified === "string" ? raw.lastVerified : "",
185
+ verifiedAgainst: typeof raw.verifiedAgainst === 'string' ? raw.verifiedAgainst : '',
186
+ lastVerified: typeof raw.lastVerified === 'string' ? raw.lastVerified : '',
145
187
  });
146
188
  }
147
189
  return slim;
@@ -156,17 +198,179 @@ function countsOf(entries) {
156
198
  return { total: entries.length, byCategory };
157
199
  }
158
200
 
201
+ /**
202
+ * 第二源目录条目 → 瘦身影射形态(纯函数)。只保留 **npm 非空** 条目——合并口径
203
+ * 是「可 npm 安装」,git-only 条目不进面板。字段映射:description 中英双语取
204
+ * zh 优先(该源面向中文社区);repo 由 url 归一成 owner/repo;category 用该源
205
+ * 自有的功能分类 id(面板分类筛选自然出现两套体系);该源不携带 dsh 版本验证
206
+ * 元数据,verifiedAgainst/lastVerified 置空、status 恒为 verified(其 curated 口径)。
207
+ */
208
+ export function adaptSecondEntries(rawArray) {
209
+ if (!Array.isArray(rawArray)) return [];
210
+ const slim = [];
211
+ for (const raw of rawArray) {
212
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
213
+ const npm = extractNpm(raw.npm);
214
+ if (!npm) continue;
215
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
216
+ if (!name) continue;
217
+ const desc = raw.description;
218
+ const text =
219
+ typeof desc === 'string'
220
+ ? desc
221
+ : desc && typeof desc === 'object'
222
+ ? typeof desc.zh === 'string' && desc.zh
223
+ ? desc.zh
224
+ : typeof desc.en === 'string'
225
+ ? desc.en
226
+ : ''
227
+ : '';
228
+ slim.push({
229
+ name,
230
+ repo: parseGithubRepo(raw.url),
231
+ npm,
232
+ description: truncateText(text, 120),
233
+ category: typeof raw.category === 'string' && raw.category.trim() ? raw.category.trim() : 'plugin',
234
+ status: 'verified',
235
+ tags: [],
236
+ stars: typeof raw.stars === 'number' && Number.isFinite(raw.stars) ? raw.stars : 0,
237
+ official: false,
238
+ verifiedAgainst: '',
239
+ lastVerified: '',
240
+ });
241
+ }
242
+ return slim;
243
+ }
244
+
245
+ /**
246
+ * 服务时合并(纯函数):主源瘦身条目 + 第二源中主源没有的 npm 名。主源优先——
247
+ * 同一 npm 包在面板只出现一次,且主源后续补录的条目会自然顶掉第二源副本,
248
+ * 无需重拉第二源。secondEntries 为空/损坏时原样返回主源。
249
+ */
250
+ export function mergeSecondEntries(primaryEntries, secondEntries) {
251
+ const primary = Array.isArray(primaryEntries) ? primaryEntries : [];
252
+ if (!Array.isArray(secondEntries) || secondEntries.length === 0) return primary;
253
+ const seen = new Set(primary.map((entry) => entry.npm));
254
+ const extra = secondEntries.filter((entry) => entry && entry.npm && !seen.has(entry.npm));
255
+ return [...primary, ...extra];
256
+ }
257
+
159
258
  // ── 拉取链与缓存落盘 ────────────────────────────────────────────────────────
160
259
 
161
260
  /**
162
- * 按 AWESOME_SOURCES 顺序拉取:单源失败(网络异常 / 非 2xx / 响应体畸形)→ 试下一源。
163
- * 带 If-None-Match 条件头(仅当 etag 非空);304 且有 etag → { ok, notModified }。
164
- * 全源失败 → { ok: false, error }。下载全程带 120s 超时。
261
+ * 从 gh 源 URL 解析 <owner>/<repo>@<branch>/<path> 形态;不符合返回 null。
262
+ * 形态:https://<镜像域名>/gh/<owner>/<repo>@<branch>/<path>。
263
+ */
264
+ function parseGhSource(sourceUrl) {
265
+ const match = /^https?:\/\/[^/\s]+\/gh\/([^/\s@]+)\/([^/\s@]+)@([^/\s]+)\/(.+)$/.exec(String(sourceUrl || ''));
266
+ return match ? { owner: match[1], repo: match[2], branch: match[3], filePath: match[4] } : null;
267
+ }
268
+
269
+ /**
270
+ * 把 gh 源 URL 的分支段替换成提交 SHA(保留各自镜像域名)。替换没发生(形态
271
+ * 不符 / SHA 非法)返回空串,调用方跳过该源——绝不把未校验的字符串拼进 URL。
272
+ */
273
+ function pinSourceToSha(sourceUrl, sha) {
274
+ if (!SHA_PATTERN.test(String(sha || ''))) return '';
275
+ const gh = parseGhSource(sourceUrl);
276
+ if (!gh) return '';
277
+ const pinned = String(sourceUrl).replace(`/${gh.owner}/${gh.repo}@${gh.branch}/`, `/${gh.owner}/${gh.repo}@${sha}/`);
278
+ return pinned !== sourceUrl ? pinned : '';
279
+ }
280
+
281
+ /**
282
+ * 解析默认分支最新提交 SHA(GitHub commits API,小 JSON)。失败(网络/限流/畸形)
283
+ * 返回 "",调用方回落 @main 链——绝不让解析失败拖垮目录加载。
284
+ * 备忘按缓存根分键(与 memSlim 同款):60s 内同根调用共享一次解析,测试各用
285
+ * 各的临时目录互不串味。
165
286
  */
166
- async function fetchChain(etag) {
167
- const headers = { "user-agent": USER_AGENT };
168
- if (etag) headers["if-none-match"] = etag;
169
- let lastError = "";
287
+ let memSha = null; // { rootKey, sha, at }
288
+ async function resolveBranchSha(options = {}) {
289
+ const rootKey = memKeyOf(options);
290
+ if (memSha && memSha.rootKey === rootKey && Date.now() - memSha.at < SHA_MEMO_TTL_MS) {
291
+ return memSha.sha;
292
+ }
293
+ const gh = parseGhSource(AWESOME_SOURCES[0]);
294
+ let sha = '';
295
+ if (gh) {
296
+ try {
297
+ const response = await fetch(`https://api.github.com/repos/${gh.owner}/${gh.repo}/commits/${gh.branch}`, {
298
+ headers: { 'user-agent': USER_AGENT, accept: 'application/vnd.github+json' },
299
+ signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS),
300
+ });
301
+ if (response.ok) {
302
+ const doc = await response.json();
303
+ if (doc && typeof doc.sha === 'string' && SHA_PATTERN.test(doc.sha)) sha = doc.sha;
304
+ }
305
+ } catch {
306
+ /* API 不可达:sha 保持 "",走 @main 回落链 */
307
+ }
308
+ }
309
+ memSha = { rootKey, sha, at: Date.now() };
310
+ return sha;
311
+ }
312
+
313
+ /** 拉到的响应体 → 校验过形状的源数据;缺 plugins 数组抛错(由调用方折进 lastError)。 */
314
+ function parsePluginsPayload(buffer) {
315
+ const data = JSON.parse(Buffer.from(buffer).toString('utf8'));
316
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.plugins)) {
317
+ throw new Error('响应缺少 plugins 数组');
318
+ }
319
+ return data;
320
+ }
321
+
322
+ /**
323
+ * 拉取链(两级):
324
+ * ① 首选 SHA 口径:解析分支最新提交 → 与 meta.resolvedSha 相同即内容未变
325
+ * (不可变地址的性质),直接 notModified,连 CDN 都不用碰;不同则按
326
+ * AWESOME_SOURCES 顺序拉 @<sha> 不可变地址(CDN 永不陈旧,根因修复点)。
327
+ * ② 回落 @main 口径(API 不可达 / pinned 链全挂时):条件 GET 行为原样保留,
328
+ * 单源失败(网络异常 / 非 2xx / 响应体畸形)→ 试下一源;304 且有 etag →
329
+ * { ok, notModified }。
330
+ * 返回 { ok, notModified } | { ok, data, etag, source, sha } | { ok: false, error }。
331
+ * 下载全程带 120s 超时。注意:② 成功时不含 sha 字段(@main 无法知道对应哪个提交,
332
+ * 落盘 resolvedSha 置空,下轮再验证重新解析)。
333
+ */
334
+ async function fetchChain(localMeta = {}, options = {}) {
335
+ const etag = localMeta && typeof localMeta.etag === 'string' ? localMeta.etag : '';
336
+ const localSha = localMeta && typeof localMeta.resolvedSha === 'string' ? localMeta.resolvedSha : '';
337
+ let lastError = '';
338
+
339
+ // ① SHA 口径:不可变地址,新鲜且可缓存终身
340
+ const sha = await resolveBranchSha(options);
341
+ if (sha) {
342
+ if (sha === localSha) return { ok: true, notModified: true };
343
+ const pinnedErrors = [];
344
+ for (const sourceUrl of AWESOME_SOURCES) {
345
+ const pinnedUrl = pinSourceToSha(sourceUrl, sha);
346
+ if (!pinnedUrl) continue;
347
+ let response;
348
+ try {
349
+ response = await fetch(pinnedUrl, {
350
+ headers: { 'user-agent': USER_AGENT },
351
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
352
+ });
353
+ } catch (error) {
354
+ pinnedErrors.push(`${pinnedUrl} → ${error.message}`);
355
+ continue;
356
+ }
357
+ if (!response.ok) {
358
+ pinnedErrors.push(`${pinnedUrl} → HTTP ${response.status}`);
359
+ continue;
360
+ }
361
+ try {
362
+ const data = parsePluginsPayload(await response.arrayBuffer());
363
+ return { ok: true, data, etag: response.headers.get('etag') || '', source: pinnedUrl, sha };
364
+ } catch (error) {
365
+ pinnedErrors.push(`${pinnedUrl} → ${error.message}`);
366
+ }
367
+ }
368
+ lastError = pinnedErrors.join('; ');
369
+ }
370
+
371
+ // ② @main 回落口径:条件 GET(现状行为)
372
+ const headers = { 'user-agent': USER_AGENT };
373
+ if (etag) headers['if-none-match'] = etag;
170
374
  for (const sourceUrl of AWESOME_SOURCES) {
171
375
  let response;
172
376
  try {
@@ -181,12 +385,8 @@ async function fetchChain(etag) {
181
385
  continue;
182
386
  }
183
387
  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 };
388
+ const data = parsePluginsPayload(await response.arrayBuffer());
389
+ return { ok: true, data, etag: response.headers.get('etag') || '', source: sourceUrl };
190
390
  } catch (error) {
191
391
  lastError = `${sourceUrl} → ${error.message}`;
192
392
  continue;
@@ -195,17 +395,115 @@ async function fetchChain(etag) {
195
395
  return { ok: false, error: `全部 awesome 源拉取失败:${lastError}` };
196
396
  }
197
397
 
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;
398
+ // ── 第二源拉取链(npm 版本轮,与主源 SHA 机制同构)─────────────────────────
399
+
400
+ let memSecondVersion = null; // { rootKey, version, at }
401
+ /** 版本锁定地址(双镜像);版本形态非法返回空数组,调用方跳过 pinned 链。 */
402
+ function secondUrlsOf(version) {
403
+ if (!SECOND_VERSION_PATTERN.test(String(version || ''))) return [];
404
+ return SECOND_MIRRORS.map((host) => `${host}/npm/${SECOND_CATALOG_PKG}@${version}/${SECOND_CATALOG_FILE}`);
405
+ }
406
+ /** 无版本回落地址(CDN 对 npm latest 的缓存最长 12h,仅解析失败时使用)。 */
407
+ const SECOND_FALLBACK_URLS = SECOND_MIRRORS.map((host) => `${host}/npm/${SECOND_CATALOG_PKG}/${SECOND_CATALOG_FILE}`);
408
+
409
+ /**
410
+ * 解析第二源 npm 包 latest 版本号(registry 小 JSON)。失败(网络/限流/畸形)
411
+ * 返回 "",调用方回落无版本链——绝不让解析失败拖垮目录加载。备忘按缓存根分键,
412
+ * TTL 与 SHA 备忘一致。
413
+ */
414
+ async function resolveSecondVersion(options = {}) {
415
+ const rootKey = memKeyOf(options);
416
+ if (memSecondVersion && memSecondVersion.rootKey === rootKey && Date.now() - memSecondVersion.at < SHA_MEMO_TTL_MS) {
417
+ return memSecondVersion.version;
418
+ }
419
+ let version = '';
420
+ try {
421
+ const response = await fetch(`https://registry.npmjs.org/${SECOND_CATALOG_PKG}/latest`, {
422
+ headers: { 'user-agent': USER_AGENT, accept: 'application/json' },
423
+ signal: AbortSignal.timeout(SECOND_VERSION_TIMEOUT_MS),
424
+ });
425
+ if (response.ok) {
426
+ const doc = await response.json();
427
+ if (doc && typeof doc.version === 'string' && SECOND_VERSION_PATTERN.test(doc.version)) version = doc.version;
428
+ }
429
+ } catch {
430
+ /* registry 不可达:version 保持 "",走无版本回落链 */
431
+ }
432
+ memSecondVersion = { rootKey, version, at: Date.now() };
433
+ return version;
434
+ }
435
+
436
+ /**
437
+ * 第二源拉取链(两级):
438
+ * ① 首选版本口径:解析 npm latest 版本 → 与 meta.second.version 相同即内容
439
+ * 未变(不可变地址性质),直接 notModified;不同则按镜像序取 @<版本> 地址
440
+ * (CDN immutable,永不陈旧)。
441
+ * ② 回落无版本口径(registry 不可达 / pinned 链全挂时):条件 GET,304 且有
442
+ * etag → { ok, notModified }。
443
+ * 返回 { ok, notModified } | { ok, data, etag, source, version } | { ok: false, error }。
444
+ * 注意:② 成功时不含 version 字段(无版本地址不知道对应哪个构建,落盘 version
445
+ * 置空,下轮再验证重新解析)。
446
+ */
447
+ async function fetchSecondChain(localSecond = {}, options = {}) {
448
+ const etag = localSecond && typeof localSecond.etag === 'string' ? localSecond.etag : '';
449
+ const localVersion = localSecond && typeof localSecond.version === 'string' ? localSecond.version : '';
450
+ let lastError = '';
451
+
452
+ // ① 版本口径:不可变地址
453
+ const version = await resolveSecondVersion(options);
454
+ if (version) {
455
+ if (version === localVersion) return { ok: true, notModified: true };
456
+ const pinnedErrors = [];
457
+ for (const sourceUrl of secondUrlsOf(version)) {
458
+ let response;
459
+ try {
460
+ response = await fetch(sourceUrl, {
461
+ headers: { 'user-agent': USER_AGENT },
462
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
463
+ });
464
+ } catch (error) {
465
+ pinnedErrors.push(`${sourceUrl} → ${error.message}`);
466
+ continue;
467
+ }
468
+ if (!response.ok) {
469
+ pinnedErrors.push(`${sourceUrl} → HTTP ${response.status}`);
470
+ continue;
471
+ }
472
+ try {
473
+ const data = parsePluginsPayload(await response.arrayBuffer());
474
+ return { ok: true, data, etag: response.headers.get('etag') || '', source: sourceUrl, version };
475
+ } catch (error) {
476
+ pinnedErrors.push(`${sourceUrl} → ${error.message}`);
477
+ }
478
+ }
479
+ lastError = pinnedErrors.join('; ');
480
+ }
481
+
482
+ // ② 无版本回落口径:条件 GET
483
+ const headers = { 'user-agent': USER_AGENT };
484
+ if (etag) headers['if-none-match'] = etag;
485
+ for (const sourceUrl of SECOND_FALLBACK_URLS) {
486
+ let response;
487
+ try {
488
+ response = await fetch(sourceUrl, { headers, signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
489
+ } catch (error) {
490
+ lastError = `${sourceUrl} → ${error.message}`;
491
+ continue;
492
+ }
493
+ if (response.status === 304 && etag) return { ok: true, notModified: true };
494
+ if (!response.ok) {
495
+ lastError = `${sourceUrl} → HTTP ${response.status}`;
496
+ continue;
497
+ }
498
+ try {
499
+ const data = parsePluginsPayload(await response.arrayBuffer());
500
+ return { ok: true, data, etag: response.headers.get('etag') || '', source: sourceUrl };
501
+ } catch (error) {
502
+ lastError = `${sourceUrl} → ${error.message}`;
503
+ continue;
504
+ }
505
+ }
506
+ return { ok: false, error: `第二社区源拉取失败:${lastError}` };
209
507
  }
210
508
 
211
509
  // ── 进程内缓存 + 单航班后台再验证 ─────────────────────────────────────────
@@ -253,25 +551,121 @@ async function readSlimCache(options = {}, { useMem = true } = {}) {
253
551
  return { entries, meta };
254
552
  }
255
553
 
554
+ // ── 第二源缓存层(second.json = 适配后的第二源 npm 条目数组)────────────────
555
+ // 与 slim.json 分开落盘:合并去重在服务时做(主源补录条目会自然顶掉第二源
556
+ // 副本,无需重拉第二源);两源的元信息统一存 meta.json 的 second 键。
557
+
558
+ function secondPathOf(options = {}) {
559
+ return join(awesomeMarketDir(options), 'second.json');
560
+ }
561
+
562
+ let memSecond = null; // { rootKey, entries }
563
+
564
+ function getMemSecond(options = {}) {
565
+ const key = memKeyOf(options);
566
+ return memSecond && memSecond.rootKey === key ? memSecond : null;
567
+ }
568
+
569
+ function setMemSecond(options = {}, entries) {
570
+ memSecond = { rootKey: memKeyOf(options), entries };
571
+ }
572
+
573
+ /** 读第二源适配缓存;不存在/损坏返回 null,永不抛错。useMem 语义与 readSlimCache 同。 */
574
+ async function readSecondCache(options = {}, { useMem = true } = {}) {
575
+ if (useMem) {
576
+ const mem = getMemSecond(options);
577
+ if (mem) return mem.entries;
578
+ }
579
+ const entries = await readJsonAt(secondPathOf(options));
580
+ if (!Array.isArray(entries)) return null;
581
+ setMemSecond(options, entries);
582
+ return entries;
583
+ }
584
+
585
+ /**
586
+ * 双源同步(refresh 同步路径与后台再验证共用):源1(fetchChain)与源2
587
+ * (fetchSecondChain)并行拉取、各自独立容错——任一源失败只保留自己的旧缓存,
588
+ * 绝不拖垮另一源。返回各源的新数据/缓存与判定标记。
589
+ */
590
+ async function syncSources(slimCache, secondEntries, options = {}) {
591
+ const [chain1, chain2] = await Promise.all([
592
+ fetchChain(slimCache ? slimCache.meta : {}, options),
593
+ fetchSecondChain(slimCache && slimCache.meta && slimCache.meta.second ? slimCache.meta.second : {}, options),
594
+ ]);
595
+ let entries1 = slimCache ? slimCache.entries : null;
596
+ if (chain1.ok && !chain1.notModified) entries1 = slimEntries(chain1.data.plugins);
597
+ let entries2 = Array.isArray(secondEntries) ? secondEntries : null;
598
+ if (chain2.ok && !chain2.notModified) entries2 = adaptSecondEntries(chain2.data.plugins);
599
+ const meta2Next =
600
+ chain2.ok && !chain2.notModified
601
+ ? {
602
+ version: chain2.version || '',
603
+ etag: chain2.etag || '',
604
+ source: chain2.source || '',
605
+ fetchedAt: Date.now(),
606
+ updated: typeof chain2.data.updated === 'string' ? chain2.data.updated : '',
607
+ counts: countsOf(entries2),
608
+ }
609
+ : null;
610
+ return { chain1, chain2, entries1, entries2, meta2Next };
611
+ }
612
+
613
+ /**
614
+ * 同步结果落盘:slim.json=源1瘦身条目(仅在源1有新数据时重写)、second.json=源2
615
+ * 适配条目(仅在源2有新数据时重写)、meta.json=源1元信息 + second 键(源2元信息;
616
+ * 以 base 展开重建,旧 meta 里的 second 在源2无新数据时原样保留)。
617
+ * fetchedAt 语义:源1「未变更」(SHA 相同 / 304)或「更新」都推到当前时刻以重置
618
+ * 陈旧计时;源1失败时保留原值(下轮尽快重试)。两源全部失败且无缓存时不动盘。
619
+ */
620
+ async function persistSync({ chain1, chain2, slimCache, entries1, entries2, meta2Next }, options = {}) {
621
+ const meta1Changed = Boolean(chain1.ok && !chain1.notModified);
622
+ const meta2Changed = Boolean(chain2.ok && !chain2.notModified);
623
+ const base = slimCache && slimCache.meta ? slimCache.meta : {};
624
+ let finalMeta1;
625
+ if (meta1Changed) {
626
+ finalMeta1 = {
627
+ etag: chain1.etag || '',
628
+ fetchedAt: Date.now(),
629
+ updated: typeof chain1.data.updated === 'string' ? chain1.data.updated : '',
630
+ counts: countsOf(entries1),
631
+ source: chain1.source || '',
632
+ resolvedSha: typeof chain1.sha === 'string' ? chain1.sha : '',
633
+ };
634
+ } else {
635
+ finalMeta1 = { ...base, fetchedAt: chain1.ok ? Date.now() : Number(base.fetchedAt) || 0 };
636
+ }
637
+ finalMeta1.second = meta2Next || (base.second && typeof base.second === 'object' ? base.second : null);
638
+ if (meta1Changed) await writeAtomic(slimPathOf(options), JSON.stringify(entries1));
639
+ if (meta1Changed || chain1.ok || meta2Changed) {
640
+ await writeAtomic(metaPathOf(options), JSON.stringify(finalMeta1));
641
+ // 源1冷启动失败(entries1 为 null)时不污染内存层:slim 缓存仍视为不存在。
642
+ if (Array.isArray(entries1)) setMemSlim(options, entries1, finalMeta1);
643
+ }
644
+ if (meta2Next) {
645
+ await writeAtomic(secondPathOf(options), JSON.stringify(entries2));
646
+ setMemSecond(options, entries2);
647
+ }
648
+ return { finalMeta1, meta1Changed, meta2Changed };
649
+ }
650
+
256
651
  /**
257
652
  * 后台再验证:不阻塞调用方;单航班 + 失败退避。
258
- * 成功时 refreshFromNetwork 已把新数据原子落盘并刷新内存层,下次请求即拿新结果;
259
- * 失败只静默保留旧缓存(陈旧但可用,绝不把错误抛给 UI)。
653
+ * 双源同步(syncSources + persistSync):任一源成功即落盘相应部分,下次请求即拿
654
+ * 新结果;源1失败记退避(源2不受退避影响,但单航班内的本轮已并行试过);失败只
655
+ * 静默保留旧缓存(陈旧但可用,绝不把错误抛给 UI)。
260
656
  */
261
657
  function revalidateInBackground(options = {}) {
262
658
  if (revalidating) return revalidating;
263
659
  if (revalidateFailedAt > 0 && Date.now() - revalidateFailedAt < REVALIDATE_BACKOFF_MS) return null;
264
660
  revalidating = (async () => {
265
661
  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;
662
+ const slimCache = await readSlimCache(options);
663
+ const secondEntries = await readSecondCache(options);
664
+ const sync = await syncSources(slimCache, secondEntries, options);
665
+ // 源1失败也照常落盘:persistSync 会保留源1旧缓存、只写源2的新数据——
666
+ // 两源独立推进,任一源成功都不白跑。
667
+ await persistSync(sync, options);
668
+ revalidateFailedAt = sync.chain1.ok ? 0 : Date.now();
275
669
  } catch {
276
670
  revalidateFailedAt = Date.now();
277
671
  } finally {
@@ -281,33 +675,15 @@ function revalidateInBackground(options = {}) {
281
675
  return revalidating;
282
676
  }
283
677
 
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 里落盘的统计。 */
678
+ /** 统一组装 catalog 响应体(cache/disk 复用):entries 一律是合并后的面板条目,
679
+ * counts 服务时现算(第二源并入后 meta.counts 只记源1,作诊断不进响应)。 */
301
680
  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);
681
+ const counts = countsOf(entries);
306
682
  const fetchedAt = Number(meta && meta.fetchedAt);
307
683
  return {
308
684
  ok: true,
309
685
  loadedFrom,
310
- updated: typeof meta.updated === "string" ? meta.updated : "",
686
+ updated: typeof meta.updated === 'string' ? meta.updated : '',
311
687
  fetchedAt: Number.isFinite(fetchedAt) && fetchedAt > 0 ? fetchedAt : 0,
312
688
  counts,
313
689
  plugins: entries,
@@ -316,33 +692,21 @@ function catalogResult(entries, meta, loadedFrom, extra = {}) {
316
692
  };
317
693
  }
318
694
 
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
695
  // ── 四个对外 API ───────────────────────────────────────────────────────────
335
696
 
336
697
  /**
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")。
698
+ * 读 awesome 目录(缓存优先 + 后台再验证 → 首屏秒出;双源合并):
699
+ * 1. refresh: true(UI「重新加载」按钮)→ 同步强刷:两源并行(syncSources),
700
+ * 各源未变更复用缓存 / 有新数据重新落盘(任一源有更新 → loadedFrom
701
+ * "network");源1全源失败且有缓存回落 "disk" + stale: true;无缓存且源1
702
+ * 失败 { ok: false }(源2失败只静默少并一块,不拖垮面板)。
703
+ * 2. 缓存新鲜(龄期 < REVALIDATE_AFTER_MS)→ 直接返合并缓存("cache"),完全不
704
+ * 碰网络。
705
+ * 3. 缓存陈旧 → 仍立刻返回旧合并缓存("cache" + refreshing: true),网络验证
706
+ * 丢给后台单航班;下次进面板即拿到新数据,失败静默保留旧缓存。
707
+ * 4. 无缓存(真正的首次)→ 双源并行全量拉取 → 瘦身/适配落盘("network")。
708
+ * 面板条目 = 源1瘦身条目 + 第二源 npm 条目中主源没有的(mergeSecondEntries,
709
+ * 服务时现算);counts 按合并结果统计。
346
710
  * 响应统一带 fetchedAt(缓存抓取时刻)与(陈旧分支的)refreshing 标记,
347
711
  * 供 UI 直接显示「后台更新中」而无需二次请求。
348
712
  * 永不抛错(拉取/解析/写盘异常都折进返回体)。
@@ -350,35 +714,43 @@ async function refreshFromNetwork(chain, options = {}) {
350
714
  export async function wbAwesomeCatalog(options = {}) {
351
715
  const wantRefresh = Boolean(options) && options.refresh === true;
352
716
  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 });
717
+ const slimCache = await readSlimCache(options, { useMem: !wantRefresh });
718
+ const secondEntries = await readSecondCache(options, { useMem: !wantRefresh });
719
+ if (slimCache && !wantRefresh && ageOfMeta(slimCache.meta) < REVALIDATE_AFTER_MS) {
720
+ return catalogResult(mergeSecondEntries(slimCache.entries, secondEntries), slimCache.meta, 'cache');
721
+ }
722
+ if (slimCache && !wantRefresh) {
723
+ // 陈旧缓存:先秒回旧数据,网络验证交给后台(单航班 + 失败退避)。
724
+ revalidateInBackground(options);
725
+ return catalogResult(mergeSecondEntries(slimCache.entries, secondEntries), slimCache.meta, 'cache', {
726
+ refreshing: true,
727
+ });
728
+ }
729
+ // UI 强刷或冷启动:两源并行同步,各自独立容错。
730
+ const { chain1, chain2, entries1, entries2, meta2Next } = await syncSources(slimCache, secondEntries, options);
731
+ // persistSync 先于成败判定:它只在 meta2Next 存在时写 second.json,源1失败不动 slim 层——
732
+ // 「两源独立推进,任一源成功都不白跑」与后台单航班同规。放在 !entries1 早退之后的话,
733
+ // 冷启动遇源1全链失败时,源2刚拉到的整包会被原样丢掉,下次进面板还得重拉一遍。
734
+ const { finalMeta1, meta1Changed, meta2Changed } = await persistSync(
735
+ { chain1, chain2, slimCache, entries1, entries2, meta2Next },
736
+ options,
737
+ );
738
+ if (!entries1) {
739
+ // 源1无货且无缓存:面板主体缺失,维持 ok:false 语义(第二源单飞不成板)。
740
+ return { ok: false, error: chain1.ok ? 'awesome 目录加载异常' : chain1.error };
369
741
  }
370
- const chain = await fetchChain("");
371
- if (!chain.ok) return { ok: false, error: chain.error };
372
- return await refreshFromNetwork(chain, options);
742
+ const merged = mergeSecondEntries(entries1, entries2);
743
+ const loadedFrom = chain1.ok ? (meta1Changed || meta2Changed ? 'network' : 'cache') : 'disk';
744
+ return catalogResult(merged, finalMeta1, loadedFrom, chain1.ok ? {} : { stale: true });
373
745
  } catch (error) {
374
746
  return { ok: false, error: `awesome 目录加载异常:${error.message}` };
375
747
  }
376
748
  }
377
749
 
378
- /** 按名字查瘦身缓存单条;无缓存先 ensureSlim(走 wbAwesomeCatalog 全量链);
750
+ /** 按名字查合并后的面板条目;无缓存先走 wbAwesomeCatalog 全量链;
379
751
  * 名字不存在 / 加载失败返回 null。 */
380
752
  export async function wbAwesomeDetail(name, options = {}) {
381
- const wanted = typeof name === "string" ? name.trim() : "";
753
+ const wanted = typeof name === 'string' ? name.trim() : '';
382
754
  if (!wanted) return null;
383
755
  try {
384
756
  let cache = await readSlimCache(options);
@@ -387,7 +759,9 @@ export async function wbAwesomeDetail(name, options = {}) {
387
759
  if (!fresh.ok) return null;
388
760
  return fresh.plugins.find((entry) => entry.name === wanted) ?? null;
389
761
  }
390
- return cache.entries.find((entry) => entry.name === wanted) ?? null;
762
+ const secondEntries = await readSecondCache(options);
763
+ const merged = mergeSecondEntries(cache.entries, secondEntries);
764
+ return merged.find((entry) => entry.name === wanted) ?? null;
391
765
  } catch {
392
766
  return null;
393
767
  }
@@ -398,14 +772,14 @@ export async function wbAwesomeDetail(name, options = {}) {
398
772
  * 绝不落盘,只取 headers。返回 { ok: true, remoteEtag } 或 { ok: false, error }。
399
773
  */
400
774
  async function probeEtagAt(sourceUrl, localEtag) {
401
- const headers = { "user-agent": USER_AGENT, "if-none-match": localEtag };
775
+ const headers = { 'user-agent': USER_AGENT, 'if-none-match': localEtag };
402
776
  let response;
403
777
  try {
404
- response = await fetch(sourceUrl, { method: "HEAD", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
778
+ response = await fetch(sourceUrl, { method: 'HEAD', headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
405
779
  if (response.status === 405 || response.status === 501) {
406
780
  // HEAD 不被支持 → 降级 GET,立刻 cancel() 丢弃响应体,不读进内存。
407
781
  response.body?.cancel?.();
408
- response = await fetch(sourceUrl, { method: "GET", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
782
+ response = await fetch(sourceUrl, { method: 'GET', headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
409
783
  response.body?.cancel?.();
410
784
  }
411
785
  } catch (error) {
@@ -413,7 +787,7 @@ async function probeEtagAt(sourceUrl, localEtag) {
413
787
  }
414
788
  if (response.status === 304) return { ok: true, remoteEtag: localEtag };
415
789
  if (response.ok) {
416
- const remoteEtag = response.headers.get("etag") || "";
790
+ const remoteEtag = response.headers.get('etag') || '';
417
791
  // 探测只信 ETag:响应没有 ETag 就无法判定,宁可报探测失败也不误报「有更新」。
418
792
  if (!remoteEtag) return { ok: false, error: `${sourceUrl} → 响应缺少 ETag` };
419
793
  return { ok: true, remoteEtag };
@@ -422,27 +796,49 @@ async function probeEtagAt(sourceUrl, localEtag) {
422
796
  }
423
797
 
424
798
  /**
425
- * ETag 条件探测(wbPluginUpdateCheck 同款语义):**绝不落盘**,只取 headers。
799
+ * 更新探测:**绝不落盘**,只做判定。
426
800
  * reason 五枚举:no-source / probe-failed / no-cache / up-to-date / update-available。
427
- * 无缓存(meta 无 etag)→ hasUpdate: false + no-cache(与插件市场不同:awesome 首次
428
- * 由 catalog 链兜底加载,探测层不引导更新)。
801
+ * 无缓存(meta 无 etag、无 resolvedSha、second 无 version)→ hasUpdate: false +
802
+ * no-cache(awesome 首次由 catalog 链兜底加载,探测层不引导更新)。
429
803
  *
430
- * 探测与目录拉取必须走同一条源链:过去只问 AWESOME_SOURCES[0],于是「主源不通、
431
- * 目录靠备用源成功」的机器上目录能正常加载、探测却每次 probe-failed —— 这正是
432
- * 「DSH 社区目录更新探测失败」的来源。现在按「缓存实际来源 → 全部源」顺序逐个试,
433
- * 首个拿到 ETag 的源胜出;全部失败才报 probe-failed。
804
+ * 双源两级口径:
805
+ * ① 首选不可变口径:并行解析源1提交 SHA 与源2 npm 版本号,与缓存记录直接比对
806
+ * ——不可变地址的性质保证「同 SHA/同版本即同内容」,判定不受 CDN 陈旧窗口
807
+ * 影响(这正是旧口径「@main + ETag」被陈旧边缘 304 骗过、报出虚假
808
+ * up-to-date 的根因)。任一源有更新即报 update-available;旧缓存缺记录而
809
+ * 解析成功时按 update-available 报(大概率确实落后,且引导一次刷新后自愈:
810
+ * 落盘记录后判定恢复精确)。
811
+ * ② 回落 ETag 口径(两级解析都失败时):按「缓存实际来源 → 全部源」顺序 HEAD
812
+ * 条件探测(HEAD 被拒降级 GET 并 cancel 响应体),首个拿到 ETag 的源胜出;
813
+ * 全部失败才报 probe-failed。
434
814
  */
435
815
  export async function wbAwesomeUpdateCheck(options = {}) {
436
816
  try {
437
817
  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() };
818
+ const localEtag = typeof meta.etag === 'string' ? meta.etag : '';
819
+ const localSha = typeof meta.resolvedSha === 'string' ? meta.resolvedSha : '';
820
+ const second = meta.second && typeof meta.second === 'object' ? meta.second : {};
821
+ const localVersion2 = typeof second.version === 'string' ? second.version : '';
822
+ if (!localEtag && !localSha && !localVersion2) {
823
+ return { ok: true, hasUpdate: false, reason: 'no-cache', localEtag: '', remoteEtag: '', checkedAt: Date.now() };
441
824
  }
442
- // 先问拿到这份缓存的源(ETag 同源可比),再兜底其余源。
443
- const cachedSource = typeof meta.source === "string" ? meta.source : "";
825
+ // ① 不可变口径:源1比对提交 SHA,源2比对 npm 版本号,任一前进即有更新
826
+ const [sha, version2] = await Promise.all([resolveBranchSha(options), resolveSecondVersion(options)]);
827
+ if (sha || version2) {
828
+ const hasUpdate = (Boolean(sha) && sha !== localSha) || (Boolean(version2) && version2 !== localVersion2);
829
+ return {
830
+ ok: true,
831
+ hasUpdate,
832
+ reason: hasUpdate ? 'update-available' : 'up-to-date',
833
+ localEtag,
834
+ remoteEtag: hasUpdate ? '' : localEtag,
835
+ checkedAt: Date.now(),
836
+ };
837
+ }
838
+ // ② ETag 回落口径:先问拿到这份缓存的源(ETag 同源可比),再兜底其余源。
839
+ const cachedSource = typeof meta.source === 'string' ? meta.source : '';
444
840
  const candidates = [...new Set([cachedSource, ...AWESOME_SOURCES].filter(Boolean))];
445
- let lastError = "";
841
+ let lastError = '';
446
842
  for (const sourceUrl of candidates) {
447
843
  const probe = await probeEtagAt(sourceUrl, localEtag);
448
844
  if (!probe.ok) {
@@ -450,24 +846,47 @@ export async function wbAwesomeUpdateCheck(options = {}) {
450
846
  continue;
451
847
  }
452
848
  const hasUpdate = probe.remoteEtag !== localEtag;
453
- return { ok: true, hasUpdate, reason: hasUpdate ? "update-available" : "up-to-date", localEtag, remoteEtag: probe.remoteEtag, checkedAt: Date.now() };
849
+ return {
850
+ ok: true,
851
+ hasUpdate,
852
+ reason: hasUpdate ? 'update-available' : 'up-to-date',
853
+ localEtag,
854
+ remoteEtag: probe.remoteEtag,
855
+ checkedAt: Date.now(),
856
+ };
454
857
  }
455
- return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `awesome 市场更新探测失败:${lastError}` };
858
+ return {
859
+ ok: false,
860
+ hasUpdate: false,
861
+ reason: 'probe-failed',
862
+ localEtag,
863
+ remoteEtag: '',
864
+ checkedAt: Date.now(),
865
+ error: `awesome 市场更新探测失败:${lastError}`,
866
+ };
456
867
  } catch (error) {
457
- return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag: "", remoteEtag: "", checkedAt: Date.now(), error: `更新探测异常:${error.message}` };
868
+ return {
869
+ ok: false,
870
+ hasUpdate: false,
871
+ reason: 'probe-failed',
872
+ localEtag: '',
873
+ remoteEtag: '',
874
+ checkedAt: Date.now(),
875
+ error: `更新探测异常:${error.message}`,
876
+ };
458
877
  }
459
878
  }
460
879
 
461
880
  /** 缓存元信息(只读,不联网):{ hasCache, etag, fetchedAt, updated }。 */
462
881
  export async function wbAwesomeCachedInfo(options = {}) {
463
882
  const cache = await readSlimCache(options);
464
- if (!cache) return { hasCache: false, etag: "", fetchedAt: 0, updated: "" };
883
+ if (!cache) return { hasCache: false, etag: '', fetchedAt: 0, updated: '' };
465
884
  const meta = cache.meta;
466
885
  return {
467
886
  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 : "",
887
+ etag: typeof meta.etag === 'string' ? meta.etag : '',
888
+ fetchedAt: typeof meta.fetchedAt === 'number' ? meta.fetchedAt : 0,
889
+ updated: typeof meta.updated === 'string' ? meta.updated : '',
471
890
  };
472
891
  }
473
892
 
@@ -484,16 +903,20 @@ const README_MAX_CHARS = 160_000;
484
903
  /** 磁盘缓存 TTL:README 变动不频繁,7 天足够,过期再取一次。 */
485
904
  const README_CACHE_TTL_MS = 7 * 24 * 3600_000;
486
905
  /** 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";
906
+ const README_FILES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README.markdown', 'README_EN.md'];
907
+ const NPM_REGISTRY = 'https://registry.npmjs.org';
489
908
 
490
909
  function readmeCacheDir(options = {}) {
491
- return join(awesomeMarketDir(options), "readme");
910
+ return join(awesomeMarketDir(options), 'readme');
492
911
  }
493
912
 
494
913
  /** 缓存文件名:来源标识压成安全短名(不用 crypto,避免多一个依赖)。 */
495
914
  function readmeCachePath(key, options = {}) {
496
- const safe = String(key || "").toLowerCase().replace(/[^a-z0-9._-]+/g, "_").slice(0, 96) || "item";
915
+ const safe =
916
+ String(key || '')
917
+ .toLowerCase()
918
+ .replace(/[^a-z0-9._-]+/g, '_')
919
+ .slice(0, 96) || 'item';
497
920
  return join(readmeCacheDir(options), `${safe}.json`);
498
921
  }
499
922
 
@@ -503,22 +926,24 @@ function readmeCachePath(key, options = {}) {
503
926
  * `git@github.com:o/r.git`、带 `#readme` / `/tree/main` 尾巴的。
504
927
  */
505
928
  export function parseGithubRepo(value) {
506
- if (typeof value !== "string") return "";
929
+ if (typeof value !== 'string') return '';
507
930
  let text = value.trim();
508
- if (!text) return "";
931
+ if (!text) return '';
509
932
  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, "");
933
+ if (ssh) return `${ssh[1]}/${ssh[2].replace(/\.git$/i, '')}`;
934
+ text = text.replace(/^git\+/i, '');
512
935
  const http = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+)/i.exec(text);
513
- if (http) return `${http[1]}/${http[2].replace(/\.git$/i, "")}`;
936
+ if (http) return `${http[1]}/${http[2].replace(/\.git$/i, '')}`;
514
937
  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 "";
938
+ if (bare) return `${bare[1]}/${bare[2].replace(/\.git$/i, '')}`;
939
+ return '';
517
940
  }
518
941
 
519
942
  /** 正文规整:去 BOM、统一换行、按字符上限截断。 */
520
943
  function normalizeReadme(text) {
521
- let out = String(text || "").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
944
+ let out = String(text || '')
945
+ .replace(/^\uFEFF/, '')
946
+ .replace(/\r\n?/g, '\n');
522
947
  if (out.length > README_MAX_CHARS) {
523
948
  return { markdown: out.slice(0, README_MAX_CHARS), truncated: true };
524
949
  }
@@ -531,9 +956,9 @@ async function fetchReadmeText(url, accept) {
531
956
  const timer = setTimeout(() => controller.abort(), README_TIMEOUT_MS);
532
957
  try {
533
958
  const response = await fetch(url, {
534
- headers: { "user-agent": USER_AGENT, accept },
959
+ headers: { 'user-agent': USER_AGENT, accept },
535
960
  signal: controller.signal,
536
- redirect: "follow",
961
+ redirect: 'follow',
537
962
  });
538
963
  if (!response.ok) {
539
964
  try {
@@ -543,9 +968,18 @@ async function fetchReadmeText(url, accept) {
543
968
  }
544
969
  return { ok: false, reachable: true, status: response.status, error: `HTTP ${response.status}` };
545
970
  }
546
- return { ok: true, reachable: true, status: response.status, text: await response.text(), url: response.url || url };
971
+ return {
972
+ ok: true,
973
+ reachable: true,
974
+ status: response.status,
975
+ text: await response.text(),
976
+ url: response.url || url,
977
+ };
547
978
  } catch (error) {
548
- const reason = error && error.name === "AbortError" ? `超时(${README_TIMEOUT_MS}ms)` : String((error && error.message) || error);
979
+ const reason =
980
+ error && error.name === 'AbortError'
981
+ ? `超时(${README_TIMEOUT_MS}ms)`
982
+ : String((error && error.message) || error);
549
983
  return { ok: false, reachable: false, status: 0, error: reason };
550
984
  } finally {
551
985
  clearTimeout(timer);
@@ -554,8 +988,8 @@ async function fetchReadmeText(url, accept) {
554
988
 
555
989
  /** npm 侧取 README:先打 `/<pkg>/latest`(小),没有再退全量 packument。 */
556
990
  async function readmeFromNpm(npmName) {
557
- const encoded = String(npmName).replace(/\//g, "%2F");
558
- const latest = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}/latest`, "application/json");
991
+ const encoded = String(npmName).replace(/\//g, '%2F');
992
+ const latest = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}/latest`, 'application/json');
559
993
  if (latest.ok) {
560
994
  let doc = null;
561
995
  try {
@@ -563,11 +997,16 @@ async function readmeFromNpm(npmName) {
563
997
  } catch {
564
998
  doc = null;
565
999
  }
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" };
1000
+ if (doc && typeof doc.readme === 'string' && doc.readme.trim()) {
1001
+ return {
1002
+ ok: true,
1003
+ markdown: doc.readme,
1004
+ repo: parseGithubRepo(doc.repository),
1005
+ file: doc.readmeFilename || 'README.md',
1006
+ };
568
1007
  }
569
1008
  }
570
- const full = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}`, "application/json");
1009
+ const full = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}`, 'application/json');
571
1010
  if (!full.ok) {
572
1011
  return { ok: false, error: full.error };
573
1012
  }
@@ -575,13 +1014,18 @@ async function readmeFromNpm(npmName) {
575
1014
  try {
576
1015
  packument = JSON.parse(full.text);
577
1016
  } catch {
578
- return { ok: false, error: "npm 返回的不是合法 JSON" };
1017
+ return { ok: false, error: 'npm 返回的不是合法 JSON' };
579
1018
  }
580
- const markdown = packument && typeof packument.readme === "string" ? packument.readme : "";
1019
+ const markdown = packument && typeof packument.readme === 'string' ? packument.readme : '';
581
1020
  if (!markdown.trim()) {
582
- return { ok: false, error: latest.ok ? "npm 包没有 README" : `${latest.error};npm 包没有 README` };
1021
+ return { ok: false, error: latest.ok ? 'npm 包没有 README' : `${latest.error};npm 包没有 README` };
583
1022
  }
584
- return { ok: true, markdown, repo: parseGithubRepo(packument.repository), file: packument.readmeFilename || "README.md" };
1023
+ return {
1024
+ ok: true,
1025
+ markdown,
1026
+ repo: parseGithubRepo(packument.repository),
1027
+ file: packument.readmeFilename || 'README.md',
1028
+ };
585
1029
  }
586
1030
 
587
1031
  /**
@@ -606,16 +1050,16 @@ function readmeGithubBases(repo) {
606
1050
  * 文件名全试完即可收工(不再换基底,避免对一个没 README 的仓库打 30 次请求)。
607
1051
  */
608
1052
  async function readmeFromGithub(repo) {
609
- let lastError = "";
1053
+ let lastError = '';
610
1054
  let sawReachable = false;
611
1055
  for (const base of readmeGithubBases(repo)) {
612
1056
  for (const file of README_FILES) {
613
- const attempt = await fetchReadmeText(`${base}${file}`, "text/plain");
1057
+ const attempt = await fetchReadmeText(`${base}${file}`, 'text/plain');
614
1058
  if (attempt.ok && attempt.text.trim()) {
615
1059
  return { ok: true, markdown: attempt.text, base, file };
616
1060
  }
617
1061
  if (attempt.ok) {
618
- lastError = "文件为空";
1062
+ lastError = '文件为空';
619
1063
  sawReachable = true;
620
1064
  continue;
621
1065
  }
@@ -629,7 +1073,7 @@ async function readmeFromGithub(repo) {
629
1073
  }
630
1074
  if (sawReachable) break;
631
1075
  }
632
- return { ok: false, error: lastError || "未找到 README" };
1076
+ return { ok: false, error: lastError || '未找到 README' };
633
1077
  }
634
1078
 
635
1079
  /**
@@ -641,55 +1085,71 @@ async function readmeFromGithub(repo) {
641
1085
  *
642
1086
  * `base` 是相对图片/链接的解析基准(GitHub raw 目录),客户端据此把 README 里的
643
1087
  * 相对路径 `assets/a.png` 还原成可加载的绝对 URL —— 不还原的话插图全是坏图。
1088
+ *
1089
+ * 第二参 `options` 只承载存储位置(`rootDir`,测试隔离用);RPC 层不传、走真实 `~/.dsh`。
644
1090
  */
645
- export async function wbAwesomeReadme(payload = {}) {
646
- const name = typeof payload.name === "string" ? payload.name.trim() : "";
1091
+ export async function wbAwesomeReadme(payload = {}, options = {}) {
1092
+ const name = typeof payload.name === 'string' ? payload.name.trim() : '';
647
1093
  const npmName =
648
- typeof payload.npm === "string"
1094
+ typeof payload.npm === 'string'
649
1095
  ? payload.npm.trim()
650
- : payload.npm && typeof payload.npm === "object" && typeof payload.npm.name === "string"
1096
+ : payload.npm && typeof payload.npm === 'object' && typeof payload.npm.name === 'string'
651
1097
  ? payload.npm.name.trim()
652
- : "";
1098
+ : '';
653
1099
  let repo = parseGithubRepo(payload.repo);
654
1100
  const wantRefresh = payload.refresh === true;
655
1101
  if (!npmName && !repo) {
656
- return { ok: false, reason: "no-source", name, npm: "", repo: "", markdown: "", truncated: false, source: "", base: "", fetchedAt: 0, error: "该条目没有 npm 包名或 GitHub 仓库地址,无法读取 README" };
1102
+ return {
1103
+ ok: false,
1104
+ reason: 'no-source',
1105
+ name,
1106
+ npm: '',
1107
+ repo: '',
1108
+ markdown: '',
1109
+ truncated: false,
1110
+ source: '',
1111
+ base: '',
1112
+ fetchedAt: 0,
1113
+ error: '该条目没有 npm 包名或 GitHub 仓库地址,无法读取 README',
1114
+ };
657
1115
  }
658
1116
  const cacheKey = npmName || repo;
659
- const cachePath = readmeCachePath(cacheKey, payload);
1117
+ // 缓存路径只认 options(rootDir 测试隔离契约,与 wbAwesomeCatalog 同规);
1118
+ // payload 是 RPC 入参,把它当 options 传会让显式 rootDir 失效、测试打到真实 ~/.dsh。
1119
+ const cachePath = readmeCachePath(cacheKey, options);
660
1120
  if (!wantRefresh) {
661
1121
  const cached = await readJsonAt(cachePath);
662
1122
  if (
663
1123
  cached &&
664
- typeof cached.markdown === "string" &&
1124
+ typeof cached.markdown === 'string' &&
665
1125
  cached.markdown &&
666
1126
  Date.now() - Number(cached.fetchedAt || 0) < README_CACHE_TTL_MS
667
1127
  ) {
668
1128
  return {
669
1129
  ok: true,
670
- reason: "cache",
1130
+ reason: 'cache',
671
1131
  name,
672
1132
  npm: npmName,
673
- repo: typeof cached.repo === "string" ? cached.repo : repo,
1133
+ repo: typeof cached.repo === 'string' ? cached.repo : repo,
674
1134
  markdown: cached.markdown,
675
1135
  truncated: Boolean(cached.truncated),
676
- source: typeof cached.source === "string" ? cached.source : "",
677
- base: typeof cached.base === "string" ? cached.base : "",
1136
+ source: typeof cached.source === 'string' ? cached.source : '',
1137
+ base: typeof cached.base === 'string' ? cached.base : '',
678
1138
  fetchedAt: Number(cached.fetchedAt || 0),
679
1139
  };
680
1140
  }
681
1141
  }
682
- let markdown = "";
683
- let source = "";
684
- let base = "";
1142
+ let markdown = '';
1143
+ let source = '';
1144
+ let base = '';
685
1145
  const errors = [];
686
1146
  if (npmName) {
687
1147
  const fromNpm = await readmeFromNpm(npmName);
688
1148
  if (fromNpm.ok) {
689
1149
  markdown = fromNpm.markdown;
690
- source = "npm";
1150
+ source = 'npm';
691
1151
  repo = repo || parseGithubRepo(fromNpm.repo);
692
- base = repo ? `https://raw.githubusercontent.com/${repo}/HEAD/` : "";
1152
+ base = repo ? `https://raw.githubusercontent.com/${repo}/HEAD/` : '';
693
1153
  } else {
694
1154
  errors.push(`npm:${fromNpm.error}`);
695
1155
  }
@@ -698,24 +1158,56 @@ export async function wbAwesomeReadme(payload = {}) {
698
1158
  const fromGithub = await readmeFromGithub(repo);
699
1159
  if (fromGithub.ok) {
700
1160
  markdown = fromGithub.markdown;
701
- source = "github";
1161
+ source = 'github';
702
1162
  base = fromGithub.base;
703
1163
  } else {
704
1164
  errors.push(`github:${fromGithub.error}`);
705
1165
  }
706
1166
  }
707
1167
  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(";") || "没有可用来源"}` };
1168
+ return {
1169
+ ok: false,
1170
+ reason: 'readme-unavailable',
1171
+ name,
1172
+ npm: npmName,
1173
+ repo,
1174
+ markdown: '',
1175
+ truncated: false,
1176
+ source: '',
1177
+ base: '',
1178
+ fetchedAt: 0,
1179
+ error: `读取 README 失败:${errors.join(';') || '没有可用来源'}`,
1180
+ };
709
1181
  }
710
1182
  const normalized = normalizeReadme(markdown);
711
1183
  const fetchedAt = Date.now();
712
1184
  try {
713
1185
  await writeAtomic(
714
1186
  cachePath,
715
- JSON.stringify({ name, npm: npmName, repo, markdown: normalized.markdown, truncated: normalized.truncated, source, base, fetchedAt }),
1187
+ JSON.stringify({
1188
+ name,
1189
+ npm: npmName,
1190
+ repo,
1191
+ markdown: normalized.markdown,
1192
+ truncated: normalized.truncated,
1193
+ source,
1194
+ base,
1195
+ fetchedAt,
1196
+ }),
716
1197
  );
717
1198
  } catch {
718
1199
  // 缓存写不进去不影响这次预览
719
1200
  }
720
- return { ok: true, reason: "network", name, npm: npmName, repo, markdown: normalized.markdown, truncated: normalized.truncated, source, base, fetchedAt };
1201
+ return {
1202
+ ok: true,
1203
+ reason: 'network',
1204
+ name,
1205
+ npm: npmName,
1206
+ repo,
1207
+ markdown: normalized.markdown,
1208
+ truncated: normalized.truncated,
1209
+ source,
1210
+ base,
1211
+ fetchedAt,
1212
+ };
721
1213
  }