@noob-stupid/dsh-plugin-console 0.5.34 → 0.5.36

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,242 @@
1
+ // L1 · domain —— market-search.js(0.5.35:市场页搜索的**唯一一份排序判据** + 分页/截断语义)
2
+ //
3
+ // 真机缺陷(2026-10-03 用户实测,市场页输入他刚发布的仓库确切名字 `dsh-connection-card-host`):
4
+ // ① 结果里只有三条**无关的高星**条目(dsh-desktop ★29812 / awesome-dsh-plugin ★17621 /
5
+ // dsh-desktop ★11682,都带「聚合」角标),**他要的仓库既不在前排、往下也没有**;
6
+ // ② 下面**没有「加载更多」**,也就无从"往下看"。
7
+ // 定位结论(离线复现,见 tests/test-market-search.mjs):
8
+ // · 那三条来自 `lib/client.js` 的**本地索引模糊匹配**(500 条封顶的静态索引里没有这个新仓库;
9
+ // 索引匹配只按"子串 / 编辑距离 ≤2"打分,结果里全是恰好带 `dsh`、`host` 这类 token 的无关仓库),
10
+ // 且它选出来的 12 条**按"星数"排的结果**被当作最终顺序**直接 return** —— 活搜 GitHub
11
+ // (本来会把逐字同名的仓库排第一)根本没被执行;
12
+ // · 「加载更多」按钮的渲染条件是 `market.data.length >= 20`:三条命中 ⇒ 按钮不渲染;
13
+ // · 服务端 /search 还把 `page` **夹到 1..5**,翻到第 6 次开始原地返回第 5 页。
14
+ //
15
+ // ★ 用户定案(原话):「所有的搜索链,都应该以**名字相似为主排序**,然后才往下按 star 走」★
16
+ // 即**不是**"只把精确命中置顶",而是**名字相似度分层** —— 同一层内才比 star:
17
+ //
18
+ // Tier 0 full_name 逐字相等(owner/repo 或裸 repo)
19
+ // Tier 1 name 逐字相等(大小写不敏感)
20
+ // Tier 2 name 前缀命中(以查询词开头)
21
+ // Tier 3 name 含查询子串
22
+ // Tier 4 name 的 token **全部**命中(可乱序)
23
+ // Tier 5 description / topics 命中
24
+ // Tier 6 README 命中
25
+ // 其余 不匹配(排在最后,不参与前七层)
26
+ // 同 Tier 内:stars 降序 → 再同分:最近更新优先 → 最后 full_name 字典序(确定性兜底)
27
+ //
28
+ // 本模块只做**纯函数**(无网络、无落盘、无 ctx):查询词归一、分层判据、排序、分页/截断/合计。
29
+ // 「一处定义、处处复用」的落地方式(**双份实现必须逐字一致,由架构守卫钉死**):
30
+ // · 服务端:`lib/server/routes/market.js` 直接 import 本模块;
31
+ // · 浏览器:`lib/client.js` 是**单文件打包产物**(`window.__ModuleLoader__.load`),不能用 import,
32
+ // 所以那里的排序块是**逐字移植**的同一份代码,两端都用下面的提取标记圈住:
33
+ // // <<<market-rank:begin>>> … // <<<market-rank:end>>>
34
+ // `tests/test-market-rank-canonical.mjs` 断言两份**去缩进后逐字相等**(改一边必须改另一边),
35
+ // 架构守卫里同一断言也跑一遍。这样"唯一排序函数"是可验证的事实,而不是口头约定。
36
+ //
37
+ // 铁律:① 只调整**展示顺序**与**分页口径**,绝不改变"谁被搜出来"(过滤条件一处不动);
38
+ // ② 分页**幂等可重复**:同一查询同一页永远同一批条目(排序里必须有确定性 tiebreaker)。
39
+
40
+ // <<<market-rank:begin>>>
41
+ /** 排序层级(越小越靠前)。Tier 0-6 与用户定案逐条对应;NOT_MATCHED 只是"没有名字/描述命中"。 */
42
+ const RANK_TIER = Object.freeze({
43
+ FULL_NAME_EXACT: 0,
44
+ NAME_EXACT: 1,
45
+ NAME_PREFIX: 2,
46
+ NAME_SUBSTRING: 3,
47
+ NAME_TOKENS: 4,
48
+ DESCRIPTION_OR_TOPICS: 5,
49
+ README: 6,
50
+ NOT_MATCHED: 7,
51
+ })
52
+
53
+ /** 归一化:小写 + 去掉尾部 `.git` / 首尾空白。所有判据都用它,避免大小写与 `.git` 差异漏判。 */
54
+ function normalizeSearchName(value) {
55
+ return String(value ?? '').trim().replace(/\.git$/iu, '').toLowerCase()
56
+ }
57
+
58
+ /** 查询词按分隔符切出的 token(≥2 字符,去重保序)——用于 Tier 4「token 全命中」判据。 */
59
+ function queryTokens(query) {
60
+ const q = normalizeSearchName(query)
61
+ if (q === '') return []
62
+ const out = []
63
+ for (const tk of q.split(/[\s\-_/.]+/u)) {
64
+ if (tk.length >= 2 && !out.includes(tk)) out.push(tk)
65
+ }
66
+ return out
67
+ }
68
+
69
+ /** 条目的两个名字形态(都归一化):`full` = owner/repo,`short` = 裸名(缺 name 时从 full 取尾段)。 */
70
+ function itemNameForms(item) {
71
+ const full = normalizeSearchName(item?.fullName ?? item?.full_name)
72
+ const short = normalizeSearchName(item?.name) || (full === '' ? '' : (full.split('/').pop() ?? ''))
73
+ return { full, short }
74
+ }
75
+
76
+ /** 名字里按分隔符切出的 token(小写)。`dsh-connection-card-host` → [dsh, connection, card, host]。 */
77
+ function nameTokens(name) {
78
+ return String(name ?? '').toLowerCase().split(/[\s\-_/.]+/u).filter(Boolean)
79
+ }
80
+
81
+ /** description / topics 是不是命中查询词(整词子串,大小写不敏感)。 */
82
+ function textHitsQuery(query, item) {
83
+ const q = normalizeSearchName(query)
84
+ if (q === '') return false
85
+ const desc = normalizeSearchName(item?.description)
86
+ if (desc !== '' && desc.includes(q)) return true
87
+ const topics = Array.isArray(item?.topics) ? item.topics : []
88
+ return topics.some((topic) => normalizeSearchName(topic).includes(q))
89
+ }
90
+
91
+ /** README 命中:优先看条目**自带**的 readme 文本;没有就认标记位(服务端 in:readme 重查补进来的条目)。
92
+ * 标记位是"这条是从 README 检索面捞到的"的既成事实,不编造内容。 */
93
+ function readmeHitsQuery(query, item) {
94
+ const q = normalizeSearchName(query)
95
+ if (q === '') return false
96
+ const readme = normalizeSearchName(item?.readme)
97
+ if (readme !== '' && readme.includes(q)) return true
98
+ return item?.viaReadme === true
99
+ }
100
+
101
+ /** 名字相似度分层(唯一判据;越小越靠前)。owner/repo 查询只按 full_name 判 Tier 0,
102
+ * 避免"同名不同 owner"被当成逐字命中(与用户"输入确切名字要排第一"的预期一致)。 */
103
+ function rankTier(query, item) {
104
+ const q = normalizeSearchName(query)
105
+ if (q === '') return RANK_TIER.NOT_MATCHED
106
+ const { full, short } = itemNameForms(item)
107
+ const tokens = queryTokens(query)
108
+ const hasOwner = q.includes('/')
109
+ const target = short !== '' ? short : full
110
+ if (full !== '' && full === q) return RANK_TIER.FULL_NAME_EXACT
111
+ if (!hasOwner && short !== '' && short === q) return RANK_TIER.NAME_EXACT
112
+ if (target !== '' && target.startsWith(q)) return RANK_TIER.NAME_PREFIX
113
+ if (target !== '' && target.includes(q)) return RANK_TIER.NAME_SUBSTRING
114
+ if (tokens.length > 0) {
115
+ const owned = nameTokens(full !== '' ? full : short)
116
+ if (tokens.every((tk) => owned.includes(tk))) return RANK_TIER.NAME_TOKENS
117
+ }
118
+ if (textHitsQuery(query, item)) return RANK_TIER.DESCRIPTION_OR_TOPICS
119
+ if (readmeHitsQuery(query, item)) return RANK_TIER.README
120
+ return RANK_TIER.NOT_MATCHED
121
+ }
122
+
123
+ /** 星数(缺省 0;非数字按 0 处理——索引/GitHub 都是外部数据,不能拿它当数字用)。 */
124
+ function starsOf(item) {
125
+ const n = item?.stars ?? item?.stargazers_count
126
+ return typeof n === 'number' && Number.isFinite(n) ? n : 0
127
+ }
128
+
129
+ /** 更新时间戳(毫秒)。缺省/非法一律 0("没给时间"排最后;绝不编造"刚更新")。 */
130
+ function updatedAtOf(item) {
131
+ const raw = item?.updatedAt ?? item?.updated_at
132
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw
133
+ if (typeof raw === 'string' && raw !== '') {
134
+ const parsed = Date.parse(raw)
135
+ if (Number.isFinite(parsed)) return parsed
136
+ }
137
+ return 0
138
+ }
139
+
140
+ /** 确定性兜底 tiebreaker:同层同星同时间时按 full_name 字典序(分页不抖动)。 */
141
+ function nameOf(item) {
142
+ return normalizeSearchName(item?.fullName ?? item?.full_name ?? item?.name)
143
+ }
144
+
145
+ /** 统一比较器:Tier 0-6 升序 → stars 降序 → updatedAt 降序 → full_name 升序。
146
+ * 「名字相似为主、同层才比 star」的全部语义都在这一个函数里;所有搜索链只许调它。 */
147
+ function compareByRank(query) {
148
+ return (a, b) => {
149
+ const ta = rankTier(query, a)
150
+ const tb = rankTier(query, b)
151
+ if (ta !== tb) return ta - tb
152
+ const sa = starsOf(a)
153
+ const sb = starsOf(b)
154
+ if (sa !== sb) return sb - sa
155
+ const ua = updatedAtOf(a)
156
+ const ub = updatedAtOf(b)
157
+ if (ua !== ub) return ub - ua
158
+ const na = nameOf(a)
159
+ const nb = nameOf(b)
160
+ return na < nb ? -1 : (na > nb ? 1 : 0)
161
+ }
162
+ }
163
+
164
+ /** **唯一入口**:按用户定案排序(返回**新数组**;条目对象一个字段都不动 —— 只改顺序不改内容)。
165
+ * 分页幂等的根本保证:同一数组同一 query 的排序结果逐位相同(比较器无随机、无时间依赖)。 */
166
+ function rankSearchResults(items, query) {
167
+ const list = Array.isArray(items) ? items.slice() : []
168
+ if (list.length < 2) return list
169
+ return list.sort(compareByRank(query))
170
+ }
171
+
172
+ /** 分层结果(UI 角标/说明与测试用):[{ tier, items }] 只含**真的命中**(Tier 0-6)的层,层序固定。 */
173
+ function rankTiers(items, query) {
174
+ const buckets = new Map()
175
+ for (const item of Array.isArray(items) ? items : []) {
176
+ const tier = rankTier(query, item)
177
+ if (tier > RANK_TIER.README) continue
178
+ if (!buckets.has(tier)) buckets.set(tier, [])
179
+ buckets.get(tier).push(item)
180
+ }
181
+ return [...buckets.keys()].sort((a, b) => a - b).map((tier) => ({ tier, items: buckets.get(tier) }))
182
+ }
183
+
184
+ /**
185
+ * 分页 + 截断口径(纯函数;GitHub search 的 `total_count` 与 `per_page` 都进这里):
186
+ * · shown 本次实际显示的条目数
187
+ * · total 数据源报的**总命中数**(未知传 null → UI 只说"已显示 M 条",绝不编造总数)
188
+ * · hasMore 还能不能往下翻(`page*perPage < total` **且没有翻过数据源硬上限**;总数为 null 时退化为"本页满页")
189
+ * · truncated 结果被截断了(总数 > 本次已取到的范围)——UI 必须**明说**,不许让用户以为"就是没有"
190
+ * · reason 截断的**可读原因**(GitHub API 最多 1000 条 / 索引封顶 / 无 total 但满页)
191
+ * 幂等:同一 (page, perPage, total) 永远同一结论。
192
+ *
193
+ * ★ 0.5.35 实测(真机 GitHub,2026-10-03):`per_page=20&page=51`(第 1001 条起)不是"返回空"而是
194
+ * **HTTP 422 `Only the first 1000 search results are available`** —— 也就是说越过上限再翻页必然报错。
195
+ * 所以 hasMore 在 `page*perPage >= min(total, cap)` 时必须为 false:按钮不给,用户就不会点出一个错误。
196
+ * 它也在标记块**之内**:浏览器那一半用同一份口径算「加载更多」与「共 N 条」。
197
+ */
198
+ function buildPageEnvelope({ shown = 0, total = null, page = 1, perPage = 20, cap = 1000, capReason = null } = {}) {
199
+ const n = Number.isFinite(shown) && shown > 0 ? shown : 0
200
+ const p = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1
201
+ const size = Number.isFinite(perPage) && perPage > 0 ? Math.floor(perPage) : 20
202
+ const known = typeof total === 'number' && Number.isFinite(total) && total >= 0 ? Math.floor(total) : null
203
+ const capValue = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : Infinity
204
+ const fetched = p * size
205
+ // 数据源硬上限之内还能取到条数(GitHub search 只有前 1000 条;越过就是 422,不是空页)
206
+ const reachable = known === null ? Infinity : Math.min(known, capValue)
207
+ // hasMore 只由**总数与上限**决定(`shown > 0`):本页返回 0 条就是到底了,不许再让用户点空按钮,
208
+ // 也不许让用户点到必然 422 的那一页。
209
+ const hasMore = known === null ? n >= size : (n > 0 && fetched < reachable)
210
+ // 截断分两种,都必须让用户看见(而不是以为"就是没有"):
211
+ // ① 数据源硬上限(GitHub search 只给前 1000 条)→ 这是**始终**成立的事实,与翻到第几页无关;
212
+ // ② 本次只取到前 fetched 条(还有更多没取)→ 翻页可解。
213
+ const overCap = known !== null && known > cap
214
+ const truncated = overCap || (known === null ? n >= size : known > fetched)
215
+ let reason = null
216
+ if (overCap) reason = capReason !== null ? capReason : `数据源最多只返回前 ${cap} 条(命中 ${known} 条)`
217
+ else if (known === null && n >= size) reason = '数据源未返回总数(只能按每页满页判断还有更多)'
218
+ else if (known !== null && known > fetched) reason = `共 ${known} 条,本次只取到前 ${Math.min(fetched, known)} 条`
219
+ // 已经翻到/翻过硬上限:再说"可继续加载"就是骗人(GitHub 第 1001 条起是 422)
220
+ if (overCap && fetched >= cap) {
221
+ reason = capReason !== null ? capReason : `数据源最多只返回前 ${cap} 条(命中 ${known} 条):已翻到第 ${fetched} 条,无法再往下`
222
+ }
223
+ return { shown: n, total: known, page: p, perPage: size, hasMore, truncated, reason }
224
+ }
225
+ // <<<market-rank:end>>>
226
+
227
+ export {
228
+ RANK_TIER,
229
+ normalizeSearchName,
230
+ queryTokens,
231
+ itemNameForms,
232
+ nameTokens,
233
+ textHitsQuery,
234
+ readmeHitsQuery,
235
+ rankTier,
236
+ starsOf,
237
+ updatedAtOf,
238
+ compareByRank,
239
+ rankSearchResults,
240
+ rankTiers,
241
+ buildPageEnvelope,
242
+ }
@@ -2,7 +2,7 @@
2
2
  // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
3
3
 
4
4
  import { readFile, writeFile } from 'node:fs/promises'
5
- import { dirname } from 'node:path'
5
+ import { dirname, join } from 'node:path'
6
6
  import { frameworkBases, isFrameworkModuleName } from '../infra/framework-root.js'
7
7
  import { queuedWrite } from '../infra/fsx.js'
8
8
  import { escapeRegExp } from '../infra/mask.js'
@@ -71,11 +71,42 @@ async function enableEntry(patchPath, id) {
71
71
  })
72
72
  }
73
73
 
74
- /** 追加一条 insert 启用行(插件包需已安装到 profile)。 */
74
+ /** 读 profile 清单里的 `dsh.profile.bundles`(包名数组)。
75
+ * 读不到就返回空数组 —— 绝不因为读清单失败而阻断既有写入行为。 */
76
+ async function readProfileBundles(patchPath) {
77
+ try {
78
+ const manifest = JSON.parse(await readFile(join(dirname(patchPath), 'package.json'), 'utf8'))
79
+ const list = manifest?.dsh?.profile?.bundles
80
+ return Array.isArray(list) ? list.filter((x) => typeof x === 'string' && x.trim() !== '') : []
81
+ } catch {
82
+ return []
83
+ }
84
+ }
85
+
86
+ /** 追加一条 insert 启用行(插件包需已安装到 profile)。
87
+ *
88
+ * 幂等护栏(2026-10-03 用户实测事故后加):以下三种情况都**不写**,只回报 `changed:false` + `reason`:
89
+ * ① `same-id` 同 id 已经在 insert 里(原有行为);
90
+ * ② `same-package` **同一个包名**已由别的 insert 行声明 —— 再写一行 = 重复装配,
91
+ * 第二行会因重复注册而挂载失败;
92
+ * ③ `bundle` **同一个包名**已由 `dsh.profile.bundles` 声明 —— bundle 自带的
93
+ * patch 会在启动时自动装配它自己的行,我们再插一行同样是重复装配。
94
+ *
95
+ * 事故复现:用户先用**官方插件管理**启用插件(= 走 bundle 声明),再在我们面板点「启用」
96
+ * → 我们多写了一条 insert 行 → 同一插件被装配两次 → 第二行抛 `already registered`
97
+ * → 面板显示「已启用 + 挂载失败」。 */
75
98
  async function appendInsert(patchPath, entryId, packageName) {
76
99
  return queuedWrite(async () => {
77
100
  const { inserts, text } = await readPatchState(patchPath)
78
- if (inserts.includes(entryId)) return { changed: false }
101
+ if (inserts.includes(entryId)) return { changed: false, reason: 'same-id' }
102
+ const name = String(packageName ?? '').trim()
103
+ if (name !== '') {
104
+ for (const declared of parseInsertNames(text).values()) {
105
+ if (String(declared ?? '').trim() === name) return { changed: false, reason: 'same-package' }
106
+ }
107
+ const bundles = await readProfileBundles(patchPath)
108
+ if (bundles.includes(name)) return { changed: false, reason: 'bundle' }
109
+ }
79
110
  const clean = sanitizePatchText(text)
80
111
  const next = clean.length === 0 || clean.endsWith('\n') ? clean : `${clean}\n`
81
112
  const block = `- insert:\n - id: ${entryId}\n name: '${packageName}'\n`
@@ -6,6 +6,7 @@ import { writeFile } from 'node:fs/promises'
6
6
  import { resolve } from 'node:path'
7
7
  import { readGithubAuth } from '../domain/install.js'
8
8
  import { enrichItems, fetchRepoPackage, fetchSubpackageNames, githubRepoInfo, hasDirectNameHit, normalizePlatformItems, searchNpmPackages, searchSubpackageItems } from '../domain/market.js'
9
+ import { buildPageEnvelope, rankSearchResults } from '../domain/market-search.js'
9
10
  import { SKILL_TOPICS, detectSkillRepo, fetchSkillMeta } from '../domain/skills.js'
10
11
  import { DEFAULT_SEARCH, gitCloneUrls, orderedRegistries, readGiteeConfig, readSources } from '../domain/sources.js'
11
12
  import { GITHUB_API, META_BUDGET_MS, curlJson, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextWithFallback } from '../infra/http.js'
@@ -13,19 +14,48 @@ import { sendError, sendJson } from '../infra/httpd.js'
13
14
  import { marketIndexCacheFile } from '../infra/paths.js'
14
15
  import { marketIndexCache, setMarketIndexCache } from '../state.js'
15
16
 
17
+ /** 技能检索的 per_page:三 topic 合并去重后不一定还有 20 条,取少了下拉就白丢条目
18
+ * (旧行为每 topic 20 条再截前 20 ⇒ 点「加载更多」也翻不出新东西)。 */
19
+ const SKILL_TOPIC_PER_PAGE = 50
20
+ /** 技能池缓存:同一个查询的**同一份合并池**重复切片 ⇒ 翻页幂等(不重不漏)。
21
+ * 不进 state.js:这是路由层的短命缓存(与静态索引缓存不是一回事)。 */
22
+ const SKILL_POOL_TTL_MS = 300000
23
+ const skillPoolCache = new Map()
24
+ function skillPoolCacheGet(key) {
25
+ const hit = skillPoolCache.get(key)
26
+ if (hit === undefined) return null
27
+ if (Date.now() - hit.at > SKILL_POOL_TTL_MS) { skillPoolCache.delete(key); return null }
28
+ return hit.items
29
+ }
30
+ function skillPoolCacheSet(key, items) {
31
+ if (skillPoolCache.size >= 8) skillPoolCache.delete(skillPoolCache.keys().next().value)
32
+ skillPoolCache.set(key, { at: Date.now(), items })
33
+ }
34
+
16
35
  async function routeSearch(req, res, rc) {
17
36
  const DEFAULT_SEARCH = rc.deps.DEFAULT_SEARCH
37
+ // 网络取数的注入缝(默认就是真通道):测试要断言"排序/分页/失败路径"时喂固定数据,
38
+ // 免得把判据与 CI 稳定性绑在网络可用性上(与 routes/index.js 的 deps 同一种风格)。
39
+ const fetchJson = typeof rc.deps?.fetchJson === 'function' ? rc.deps.fetchJson : fetchJsonUrl
40
+ const searchRepos = typeof rc.deps?.githubJson === 'function' ? rc.deps.githubJson : githubJson
18
41
  const ctx = rc.ctx
19
42
  const pathname = rc.pathname
20
43
  const method = rc.method
21
44
  const body = rc.body
22
45
  const raw = typeof body.q === 'string' ? body.q.trim() : ''
23
46
  const query = raw === '' ? DEFAULT_SEARCH : raw
24
- const page = Math.max(Math.min(Number.parseInt(String(body.page), 10) || 1, 5), 1)
47
+ // 0.5.35 改错:旧代码把 page **夹到 1..5** —— 第 6 次「加载更多」起原地返回第 5 页,
48
+ // 用户看到的是"怎么点都不出新条目"(与本次"没有加载更多"同族的第二个截断层)。
49
+ // 现在只做合法性下限(≥1),真实可翻页性由 buildPageEnvelope 依据数据源的 total 如实回报。
50
+ const page = Math.max(Number.parseInt(String(body.page), 10) || 1, 1)
25
51
  const source = typeof body.source === 'string' && body.source !== '' ? body.source : 'github'
26
52
  const all = body.all === true
27
53
  const auth = readGithubAuth()
28
54
  let items = []
55
+ // 数据源报的**总命中数**(GitHub search 的 total_count;自定义源没有就留 null —— 绝不编造)
56
+ let total = null
57
+ // in:readme 重查那一层有没有被自己的 40 条上限截断(说清楚,别让用户以为"就是没有")
58
+ let readmeTruncated = false
29
59
  // 给前端的补充说明(如"代码搜索需登录")——放响应里,前端据此给提示,不改 items 结构
30
60
  const extraNotes = {}
31
61
  // 增量检索(前端浏览器直连成功时**并行**调用):只补「npm 包名映射 + in:readme 重查 + 代码搜索子包」,
@@ -36,7 +66,7 @@ async function routeSearch(req, res, rc) {
36
66
  if (raw !== '') {
37
67
  try { extra.push(...await searchNpmPackages(raw, orderedRegistries(readSources()), 3, auth.token)) } catch {}
38
68
  try {
39
- const again = await githubJson(
69
+ const again = await searchRepos(
40
70
  `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${raw} in:name,description,readme${all ? '' : ' topic:dsh-plugin'}`)}&sort=stars&order=desc&per_page=20&page=1`,
41
71
  req.signal,
42
72
  auth.token,
@@ -48,7 +78,8 @@ async function routeSearch(req, res, rc) {
48
78
  sendJson(res, 200, {
49
79
  ok: true,
50
80
  query: raw,
51
- items: extra,
81
+ // 增量结果同样过**唯一**排序函数:客户端拿到后只需合并,不需要(也不许)自己另排一份
82
+ items: rankSearchResults(extra, raw),
52
83
  extras: true,
53
84
  authenticated: auth.loggedIn,
54
85
  source: 'github',
@@ -63,11 +94,12 @@ async function routeSearch(req, res, rc) {
63
94
  const tasks = [
64
95
  (async () => {
65
96
  try {
66
- const data = await githubJson(
97
+ const data = await searchRepos(
67
98
  `${GITHUB_API}/search/repositories?q=${encodeURIComponent(all ? query : `${query} topic:dsh-plugin`)}&sort=stars&order=desc&per_page=20&page=${page}`,
68
99
  req.signal,
69
100
  auth.token,
70
101
  )
102
+ if (typeof data?.total_count === 'number') total = data.total_count
71
103
  return normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github', sourceName: 'GitHub' }))
72
104
  } catch {
73
105
  return []
@@ -76,7 +108,7 @@ async function routeSearch(req, res, rc) {
76
108
  ...sources.searchSources.filter((s) => s.type === 'custom').map((s) => (async () => {
77
109
  try {
78
110
  const url = s.url.replace('{q}', encodeURIComponent(query)).replace('{page}', String(page))
79
- const data = await fetchJsonUrl(url, 15000, s.headers ?? {})
111
+ const data = await fetchJson(url, 15000, s.headers ?? {})
80
112
  return normalizePlatformItems(data, 'main').map((item) => ({ ...item, source: s.id, sourceName: s.name }))
81
113
  } catch {
82
114
  return []
@@ -86,40 +118,63 @@ async function routeSearch(req, res, rc) {
86
118
  const results = await Promise.all(tasks)
87
119
  items = results.flat()
88
120
  items = await enrichItems(items)
89
- sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source: 'all', multi: true })
121
+ // 多源合并后**只排一次**(唯一排序函数):跨源条目按同一套名字分层,同层才比 star
122
+ items = rankSearchResults(items, raw)
123
+ sendJson(res, 200, {
124
+ ok: true, query, items, authenticated: auth.loggedIn, source: 'all', multi: true,
125
+ ...buildPageEnvelope({ shown: items.length, total, page }),
126
+ })
90
127
  return
91
128
  }
92
129
  if (body.skills === true) {
93
- // 技能模式搜索:agent-skills / claude-skills / dsh-skill 三 topic 并行检索后合并去重
94
- // (GitHub search 的 OR 语法优先级不可靠,分开查最稳),按 star 排序取前 20。
130
+ // 技能模式搜索:agent-skills / claude-skills / dsh-skill 三 topic 并行检索后合并去重。
131
+ // (GitHub search 的 OR 语法优先级不可靠,分开查最稳。)
132
+ // 0.5.35 改错:旧行为「取前 20 条」把合并池**截断**在 20 —— 客户端点「加载更多」请求下一页,
133
+ // 服务端仍从**每个 topic 的第 N 页**取 20 条再截前 20,去重后经常一条新的都没有(按钮白点)。
134
+ // 现在:合并池按唯一排序函数排序 + 去重,然后**照分页口径切片**;并集池按查询缓存,
135
+ // 同一查询的任何一页都从同一份池里切 ⇒ 翻页幂等(不重不漏)。
95
136
  if (source !== 'github') {
96
137
  sendError(res, 400, '技能搜索仅支持 GitHub 源')
97
138
  return
98
139
  }
99
140
  const keyword = raw === '' ? '' : `${raw} in:name,description,topics `
100
- const tasks = SKILL_TOPICS.map((topic) => (async () => {
101
- try {
102
- const data = await githubJson(
103
- `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${keyword}topic:${topic}`)}&sort=stars&order=desc&per_page=20&page=${page}`,
104
- req.signal,
105
- auth.token,
106
- )
107
- return normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github', skillTopics: [topic] }))
108
- } catch {
109
- return []
141
+ const poolKey = `${raw}\u0000${all ? '1' : '0'}`
142
+ let pool = skillPoolCacheGet(poolKey)
143
+ if (pool === null) {
144
+ const tasks = SKILL_TOPICS.map((topic) => (async () => {
145
+ try {
146
+ const data = await searchRepos(
147
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${keyword}topic:${topic}`)}&sort=stars&order=desc&per_page=${SKILL_TOPIC_PER_PAGE}&page=1`,
148
+ req.signal,
149
+ auth.token,
150
+ )
151
+ if (typeof data?.total_count === 'number') total = (total ?? 0) + data.total_count
152
+ return normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github', skillTopics: [topic] }))
153
+ } catch {
154
+ return []
155
+ }
156
+ })())
157
+ const merged = (await Promise.all(tasks)).flat()
158
+ const seen = new Set()
159
+ const deduped = []
160
+ for (const item of rankSearchResults(merged, raw)) {
161
+ if (seen.has(item.fullName)) continue
162
+ seen.add(item.fullName)
163
+ deduped.push(item)
110
164
  }
111
- })())
112
- const merged = (await Promise.all(tasks)).flat()
113
- const seen = new Set()
114
- items = []
115
- for (const item of merged.sort((a, b) => b.stars - a.stars)) {
116
- if (seen.has(item.fullName)) continue
117
- seen.add(item.fullName)
118
- items.push(item)
119
- if (items.length >= 20) break
165
+ pool = deduped
166
+ skillPoolCacheSet(poolKey, pool)
120
167
  }
168
+ const perPage = 20
169
+ const start = (page - 1) * perPage
170
+ items = pool.slice(start, start + perPage)
121
171
  items = await enrichItems(items)
122
- sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source: 'github', skills: true })
172
+ sendJson(res, 200, {
173
+ ok: true, query, items, authenticated: auth.loggedIn, source: 'github', skills: true,
174
+ // 技能池的真实总量(三 topic 并集去重后的条数),不是 GitHub 的 total_count 之和
175
+ // (三个 topic 之间有重叠,直接相加会把"共 N 条"报大)
176
+ ...buildPageEnvelope({ shown: items.length, total: pool.length, page, perPage }),
177
+ })
123
178
  return
124
179
  }
125
180
  if (source === 'gitee') {
@@ -140,13 +195,16 @@ async function routeSearch(req, res, rc) {
140
195
  // repo 已由 githubRepoInfo 校验;分段编码(只编码中文等非 ASCII,斜杠保留原样——
141
196
  // Gitee 服务器不认 %2F 编码的路径分隔,返回 404)
142
197
  const [owner, name] = repo.split('/')
143
- const data = await fetchJsonUrl(`https://gitee.com/api/v5/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}${tokenQ}`)
198
+ const data = await fetchJson(`https://gitee.com/api/v5/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}${tokenQ}`)
144
199
  items = normalizePlatformItems([data], 'master').map((item) => ({ ...item, source: 'gitee' }))
145
200
  } catch (error) {
146
201
  giteeError = error instanceof Error ? error.message : String(error)
147
202
  }
148
203
  }
149
- sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source, giteeNeedsLogin: false, directOnly: true, giteeError })
204
+ sendJson(res, 200, {
205
+ ok: true, query, items, authenticated: auth.loggedIn, source, giteeNeedsLogin: false, directOnly: true, giteeError,
206
+ ...buildPageEnvelope({ shown: items.length, total, page }),
207
+ })
150
208
  return
151
209
  } else if (source !== 'github') {
152
210
  // 自定义搜索源:URL 模板({q}/{page} 占位符),返回数组或 {items} 结构;支持配置的请求头
@@ -159,7 +217,7 @@ async function routeSearch(req, res, rc) {
159
217
  const url = custom.url
160
218
  .replace('{q}', encodeURIComponent(query))
161
219
  .replace('{page}', String(page))
162
- const data = await fetchJsonUrl(url, 15000, custom.headers ?? {})
220
+ const data = await fetchJson(url, 15000, custom.headers ?? {})
163
221
  items = normalizePlatformItems(data, 'main').map((item) => ({ ...item, source, sourceName: custom.name }))
164
222
  } else {
165
223
  // npm 包名搜索与 GitHub 仓库搜索**并行**:npm 侧要查 registry 搜索 + packument + 仓库元数据
@@ -167,12 +225,13 @@ async function routeSearch(req, res, rc) {
167
225
  const npmPromise = raw !== '' && !body.skills
168
226
  ? searchNpmPackages(raw, orderedRegistries(readSources()), 3, auth.token).catch(() => [])
169
227
  : Promise.resolve([])
170
- const data = await githubJson(
228
+ const data = await searchRepos(
171
229
  `${GITHUB_API}/search/repositories?q=${encodeURIComponent(all ? query : `${query} topic:dsh-plugin`)}&sort=stars&order=desc&per_page=20&page=${page}`,
172
230
  req.signal,
173
231
  auth.token,
174
232
  )
175
233
  items = normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github' }))
234
+ if (typeof data?.total_count === 'number') total = data.total_count
176
235
  items = await enrichItems(items)
177
236
  // monorepo 子包增强(OpenViking/examples/dsh-memory-plugin 等可按子包名搜到;
178
237
  // 代码搜索需登录,未登录时该函数返回空数组,见 domain/market.js 说明)
@@ -188,7 +247,7 @@ async function routeSearch(req, res, rc) {
188
247
  // 首屏没有"名字逐词命中"的条目时,用 in:name,description,readme 再查一次(未登录也能用)。
189
248
  if (raw !== '' && !body.skills && page === 1 && !hasDirectNameHit(items, raw)) {
190
249
  try {
191
- const again = await githubJson(
250
+ const again = await searchRepos(
192
251
  `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${raw} in:name,description,readme${all ? '' : ' topic:dsh-plugin'}`)}&sort=stars&order=desc&per_page=20&page=1`,
193
252
  req.signal,
194
253
  auth.token,
@@ -196,7 +255,9 @@ async function routeSearch(req, res, rc) {
196
255
  for (const it of normalizePlatformItems(again.items ?? [], 'main')) {
197
256
  if (items.some((x) => x.fullName === it.fullName)) continue
198
257
  items.push({ ...it, source: 'github', viaReadme: true })
199
- if (items.length >= 40) break
258
+ // 截断上限 40 是**既有**的检索面口径(不改"谁被搜出来"):只影响 in:readme 重查这一路,
259
+ // 且被截断这件事由下面的 envelope 如实告知(truncated/reason),不再静默。
260
+ if (items.length >= 40) { readmeTruncated = true; break }
200
261
  }
201
262
  } catch {}
202
263
  }
@@ -217,7 +278,19 @@ async function routeSearch(req, res, rc) {
217
278
  if (raw !== '' && !body.skills && !auth.loggedIn) extraNotes.codeSearchSkipped = true
218
279
  }
219
280
  items = items.filter((item) => item.fullName !== '')
220
- sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source, ...(extraNotes) })
281
+ // ★ 唯一一处排序 ★:所有搜索链(github 多路合并 / multi 多源 / custom / gitee)在**这里**统一
282
+ // 过同一个名字分层函数。上一版各分支自己排/自己截(skills 还截前 20),才会有"点两次结果不同"
283
+ // 与"高星无关仓库压过逐字命中"。
284
+ items = rankSearchResults(items, raw)
285
+ // 分页/截断如实回报:客户端据此决定「加载更多」是否可点、要不要明说"已截断"。
286
+ const envelope = buildPageEnvelope({ shown: items.length, total, page })
287
+ sendJson(res, 200, {
288
+ ok: true, query, items, authenticated: auth.loggedIn, source,
289
+ ...envelope,
290
+ // 自己那一层 40 条上限截断了 → 明说(否则用户以为"就是没有";翻页也解不了这一层)
291
+ ...(readmeTruncated ? { truncated: true, reason: envelope.reason !== null ? `${envelope.reason};另:README 重查层已取满 40 条` : 'README 重查层已取满 40 条(可换更精确的关键词缩小范围)' } : {}),
292
+ ...(extraNotes),
293
+ })
221
294
  return
222
295
  }
223
296
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.34",
3
+ "version": "0.5.36",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",