dsh-pick 0.0.0-stage → 0.2.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.
- package/README.md +142 -2
- package/bin/dsh-pick.js +155 -0
- package/data/catalog.json +1 -0
- package/lib/classify.js +56 -0
- package/lib/engine.js +409 -0
- package/lib/metrics.js +105 -0
- package/lib/words.js +71 -0
- package/package.json +23 -4
- package/plugin/client.js +119 -0
- package/plugin/cordis.patch.yml +4 -0
- package/plugin/index.js +109 -0
- package/web/panel.html +272 -0
- package/web/panel.js +428 -0
package/lib/classify.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// lib/classify.js — DSH 插件分类
|
|
2
|
+
//
|
|
3
|
+
// 三级判定:关键词命中(权重 3) > 包名命中(2) > 描述命中(1)。
|
|
4
|
+
// 得分低于阈值归「其他」。
|
|
5
|
+
// 这套规则是兜底;P2 会由宿主端批量跑语义分类覆盖(准确率 65% → 90%+)。
|
|
6
|
+
|
|
7
|
+
export const CATEGORIES = [
|
|
8
|
+
{ label: '界面与主题', keys: ['web-ui', 'theme', 'skin', 'ui', 'sidebar', 'wallpaper', 'appearance', 'visual', 'font', 'layout', 'render', 'css', 'design', 'widget', 'pet', 'dafeiyu', 'screenshot'] },
|
|
9
|
+
{ label: '记忆与上下文', keys: ['memory', 'context', 'session', 'rewind', 'palimpsest', 'mnemon', 'recall', 'archive', 'compact'] },
|
|
10
|
+
{ label: '模型与网关', keys: ['model-provider', 'llm-gateway', 'provider', 'llm', 'codex', 'claude-code', 'chatgpt', 'openai', 'model', 'gateway', 'router', 'proxy', 'subscription', 'token'] },
|
|
11
|
+
{ label: '搜索与检索', keys: ['web-search', 'search', 'retrieval', 'rag', 'find', 'index', 'crawl'] },
|
|
12
|
+
{ label: '用量与计费', keys: ['cost', 'usage', 'balance', 'quota', 'billing', 'price', 'credit', 'billing'] },
|
|
13
|
+
{ label: '自动化与多智能体', keys: ['automation', 'agent', 'subagent', 'agent-teams', 'agent-preset', 'workflow', 'schedule', 'task', 'orchestr', 'team', 'cron'] },
|
|
14
|
+
{ label: '远程与部署', keys: ['vps', 'ssh', 'remote', 'deploy', 'tunnel', 'mobile', 'pocket', 'bridge', 'self-hosted', 'server', 'docker', 'home'] },
|
|
15
|
+
{ label: '文档与办公', keys: ['markdown', 'office', 'univer', 'tiddlywiki', 'doc', 'note', 'pdf', 'excel', 'paper', 'ocr', 'reader', 'write'] },
|
|
16
|
+
{ label: '媒体与生成', keys: ['image', 'vision', 'voice', 'video', 'audio', 'generation', 'tts', 'live', 'camera', 'draw', 'music'] },
|
|
17
|
+
{ label: '集成与连接', keys: ['mcp', 'im', 'feishu', 'wechat', 'wecom', 'github', 'connector', 'telegram', 'slack', 'discord', 'messenger', 'bot', 'notify', 'notification', 'message'] },
|
|
18
|
+
{ label: '插件市场与工具链', keys: ['marketplace', 'market', 'plugin-manager', 'plugin-update', 'updates', 'bundle', 'cli', 'plugin-console', 'install', 'shop', 'share'] },
|
|
19
|
+
{ label: '安全与权限', keys: ['approval', 'gate', 'verify', 'permission', 'safety', 'sandbox', 'auth', 'secret', 'security', 'pentest'] },
|
|
20
|
+
{ label: '代码与开发', keys: ['git', 'code', 'review', 'test', 'lint', 'refactor', 'build', 'typescript', 'file', 'edit', 'patch', 'diff', 'repo', 'worktree'] },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export const OTHER = '其他'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 分类单个插件
|
|
27
|
+
* @param {string} name 包名
|
|
28
|
+
* @param {string[]} kws 关键词
|
|
29
|
+
* @param {string} desc 描述
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function classify(name, kws = [], desc = '') {
|
|
33
|
+
const kwLower = new Set(kws.map(k => String(k).toLowerCase()))
|
|
34
|
+
const text = (name + ' ' + (desc || '')).toLowerCase()
|
|
35
|
+
const scores = new Map()
|
|
36
|
+
|
|
37
|
+
for (const cat of CATEGORIES) {
|
|
38
|
+
let s = 0
|
|
39
|
+
for (const k of cat.keys) {
|
|
40
|
+
if (kwLower.has(k)) s += 3
|
|
41
|
+
if (text.includes(k)) s += 1
|
|
42
|
+
}
|
|
43
|
+
if (s > 0) scores.set(cat.label, s)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (scores.size === 0) return OTHER
|
|
47
|
+
let best = null, bestScore = 0
|
|
48
|
+
for (const [label, s] of scores) {
|
|
49
|
+
if (s > bestScore) { best = label; bestScore = s }
|
|
50
|
+
}
|
|
51
|
+
return bestScore >= 2 ? best : OTHER
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function allCategories() {
|
|
55
|
+
return CATEGORIES.map(c => c.label).concat([OTHER])
|
|
56
|
+
}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// lib/engine.js — dsh-pick 数据引擎
|
|
2
|
+
//
|
|
3
|
+
// 一个引擎,三个入口(设置页面板 / `/pick-` 命令 / AI 工具)都走这里。
|
|
4
|
+
//
|
|
5
|
+
// 目录构建在宿主端(Node)跑,没有 CORS、可以并发,所以能全量抓;
|
|
6
|
+
// 页面只读 catalog.json,秒开。
|
|
7
|
+
|
|
8
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
9
|
+
import { join, dirname } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { classify } from './classify.js'
|
|
12
|
+
import { expandWords } from './words.js'
|
|
13
|
+
import { baselineWithStart, growth, cadence, ageDays, score, sum } from './metrics.js'
|
|
14
|
+
|
|
15
|
+
const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
16
|
+
export const CATALOG_PATH = join(PKG_ROOT, 'data', 'catalog.json')
|
|
17
|
+
|
|
18
|
+
const SEARCH_API = 'https://registry.npmjs.org/-/v1/search'
|
|
19
|
+
const DL_API = 'https://api.npmjs.org/downloads'
|
|
20
|
+
const REG_API = 'https://registry.npmjs.org'
|
|
21
|
+
|
|
22
|
+
/* ---------------------------------------------------------------- 网络 */
|
|
23
|
+
|
|
24
|
+
async function jget(url, tries = 4) {
|
|
25
|
+
for (let i = 0; i < tries; i++) {
|
|
26
|
+
try {
|
|
27
|
+
const r = await fetch(url, { headers: { accept: 'application/json' } })
|
|
28
|
+
if (r.status === 429 || r.status === 503) {
|
|
29
|
+
await sleep(800 * (i + 1))
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (!r.ok) return null
|
|
33
|
+
return await r.json()
|
|
34
|
+
} catch {
|
|
35
|
+
await sleep(400 * (i + 1))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
42
|
+
|
|
43
|
+
const enc = s => String(s).replace(/\//g, '%2f')
|
|
44
|
+
|
|
45
|
+
async function pool(items, limit, fn) {
|
|
46
|
+
const out = new Array(items.length)
|
|
47
|
+
let idx = 0
|
|
48
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
49
|
+
while (idx < items.length) {
|
|
50
|
+
const i = idx++
|
|
51
|
+
out[i] = await fn(items[i], i)
|
|
52
|
+
}
|
|
53
|
+
}))
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const shiftDate = (iso, d) => {
|
|
58
|
+
const t = new Date(iso + 'T00:00:00Z')
|
|
59
|
+
t.setUTCDate(t.getUTCDate() + d)
|
|
60
|
+
return t.toISOString().slice(0, 10)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** npm 统计滞后约 5 天,先探测最后一个已汇总的完整日 */
|
|
64
|
+
async function resolveAnchor() {
|
|
65
|
+
const probe = await jget(`${DL_API}/point/last-day/dsh-market`)
|
|
66
|
+
return (probe && probe.end) || shiftDate(new Date().toISOString().slice(0, 10), -5)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------------------------------------------------------------- 目录构建 */
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 抓取 DSH 插件目录
|
|
73
|
+
* @param {object} opt
|
|
74
|
+
* @param {number} opt.deep 对周下载 Top N 抓日序列 + 发布历史,算出噪指标
|
|
75
|
+
* @param {number} opt.max 最多抓多少个包(search 深分页上限约 2250)
|
|
76
|
+
* @param {boolean} opt.deepOnly 只补算去噪指标,不重新抓目录(省 5 分钟)
|
|
77
|
+
* @param {(msg:string)=>void} opt.onProgress 进度回调
|
|
78
|
+
*/
|
|
79
|
+
export async function buildCatalog({ deep = 200, max = 2250, pages = 9, deepOnly = false, onProgress = () => {} } = {}) {
|
|
80
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
81
|
+
const anchor = await resolveAnchor()
|
|
82
|
+
const start = shiftDate(anchor, -29)
|
|
83
|
+
onProgress(`统计截止日 ${anchor},${deepOnly ? '只补算去噪指标' : '开始抓取目录'}…`)
|
|
84
|
+
|
|
85
|
+
const records = new Map()
|
|
86
|
+
|
|
87
|
+
if (deepOnly) {
|
|
88
|
+
const prev = await loadCatalog()
|
|
89
|
+
if (!prev.total) throw new Error('还没有目录,先跑一次不带 --deep-only 的 build')
|
|
90
|
+
for (const rec of Object.values(prev.packages)) records.set(rec.n, rec)
|
|
91
|
+
onProgress(`沿用已有目录 ${records.size} 个包`)
|
|
92
|
+
} else {
|
|
93
|
+
for (let p = 0; p < pages; p++) {
|
|
94
|
+
const from = p * 250
|
|
95
|
+
onProgress(`抓取目录 ${from + 1}–${from + 250} / ${max}…`)
|
|
96
|
+
const d = await jget(`${SEARCH_API}?text=${encodeURIComponent('keywords:dsh-plugin')}&size=250&from=${from}`)
|
|
97
|
+
const objs = (d && d.objects) || []
|
|
98
|
+
for (const o of objs) {
|
|
99
|
+
const pkg = o.package
|
|
100
|
+
if (records.has(pkg.name)) continue
|
|
101
|
+
records.set(pkg.name, {
|
|
102
|
+
n: pkg.name,
|
|
103
|
+
v: pkg.version,
|
|
104
|
+
d: (pkg.description || '').slice(0, 160),
|
|
105
|
+
kw: pkg.keywords || [],
|
|
106
|
+
w: (o.downloads && o.downloads.weekly) || 0,
|
|
107
|
+
m: (o.downloads && o.downloads.monthly) || 0,
|
|
108
|
+
pub: (pkg.date || '').slice(0, 10),
|
|
109
|
+
// search API 直接给 links,不额外花请求
|
|
110
|
+
home: (pkg.links && pkg.links.homepage) || '',
|
|
111
|
+
repo: (pkg.links && pkg.links.repository) || '',
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
if (records.size >= max) break
|
|
115
|
+
if (objs.length < 250) break
|
|
116
|
+
await sleep(220)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 分类:**全部**目录都要有(v1 只给深挖目标分了类,导致其余全是 undefined)
|
|
121
|
+
for (const rec of records.values()) {
|
|
122
|
+
rec.cat = classify(rec.n, rec.kw, rec.d)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
onProgress(`目录 ${records.size} 个包已分类,开始算去噪指标 Top ${deep}…`)
|
|
126
|
+
|
|
127
|
+
// 深挖:日序列 + 发布历史 → 日常基线 / 增速 / 版本节奏 / 新鲜度
|
|
128
|
+
// 注意:api.npmjs.org 并发稍高就开始 429,所以下载量请求必须低并发;
|
|
129
|
+
// registry.npmjs.org 宽松得多,可以并发。两段分开抓。
|
|
130
|
+
// deepOnly 时只补缺失的;全量时按周下载取 Top N
|
|
131
|
+
const targets = [...records.values()]
|
|
132
|
+
.filter(r => (deepOnly ? !r.deep : true))
|
|
133
|
+
.sort((a, b) => b.w - a.w)
|
|
134
|
+
.slice(0, deep)
|
|
135
|
+
|
|
136
|
+
let deepOk = 0
|
|
137
|
+
let pending = targets.slice()
|
|
138
|
+
|
|
139
|
+
// 最多 3 轮:最后一轮降到并发 1,专治偶发 429
|
|
140
|
+
for (let round = 1; round <= 3 && pending.length; round++) {
|
|
141
|
+
const conc = round >= 3 ? 1 : 2
|
|
142
|
+
onProgress(` 日序列 第 ${round} 轮,${pending.length} 个(并发 ${conc})…`)
|
|
143
|
+
|
|
144
|
+
const ranges = await pool(pending, conc, async rec => {
|
|
145
|
+
const r = await jget(`${DL_API}/range/${start}:${anchor}/${enc(rec.n)}`)
|
|
146
|
+
await sleep(round >= 3 ? 420 : 180)
|
|
147
|
+
return (r && Array.isArray(r.downloads)) ? r.downloads.map(x => x.downloads) : null
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const ok = pending.filter((_, i) => ranges[i])
|
|
151
|
+
|
|
152
|
+
if (ok.length) {
|
|
153
|
+
onProgress(` 发布历史 ${ok.length} 个…`)
|
|
154
|
+
const metas = await pool(ok, 8, async rec => jget(`${REG_API}/${enc(rec.n)}`))
|
|
155
|
+
ok.forEach((rec, i) => {
|
|
156
|
+
const daily = ranges[pending.indexOf(rec)]
|
|
157
|
+
const meta = metas[i]
|
|
158
|
+
const releaseDates = (meta && meta.time)
|
|
159
|
+
? Object.entries(meta.time)
|
|
160
|
+
.filter(([k]) => k !== 'created' && k !== 'modified')
|
|
161
|
+
.map(([, v]) => String(v).slice(0, 10))
|
|
162
|
+
: []
|
|
163
|
+
|
|
164
|
+
const b = baselineWithStart(daily, releaseDates, start)
|
|
165
|
+
const g = growth(daily)
|
|
166
|
+
const vd = releaseDates.slice().sort()
|
|
167
|
+
const lastRelease = vd.length ? vd[vd.length - 1] : rec.pub
|
|
168
|
+
const age = ageDays(lastRelease, today)
|
|
169
|
+
const c = cadence(releaseDates)
|
|
170
|
+
|
|
171
|
+
rec.deep = {
|
|
172
|
+
daily,
|
|
173
|
+
base: b,
|
|
174
|
+
growth: g,
|
|
175
|
+
cadence: c,
|
|
176
|
+
age,
|
|
177
|
+
lastRelease,
|
|
178
|
+
versions: releaseDates.length,
|
|
179
|
+
s: score({ baseline: b, growth: g, age, cadence: c }),
|
|
180
|
+
}
|
|
181
|
+
rec.rel = releaseDates
|
|
182
|
+
deepOk++
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
pending = pending.filter((_, i) => !ranges[i])
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const catalog = {
|
|
190
|
+
tool: 'dsh-pick',
|
|
191
|
+
asOf: today,
|
|
192
|
+
anchor,
|
|
193
|
+
windowStart: start,
|
|
194
|
+
total: records.size,
|
|
195
|
+
deepCount: [...records.values()].filter(r => r.deep).length,
|
|
196
|
+
packages: Object.fromEntries([...records.entries()]),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await mkdir(dirname(CATALOG_PATH), { recursive: true })
|
|
200
|
+
await writeFile(CATALOG_PATH, JSON.stringify(catalog), 'utf8')
|
|
201
|
+
onProgress(`完成:${records.size} 个包,其中 ${deepOk} 个算出去噪指标 → ${CATALOG_PATH}`)
|
|
202
|
+
return catalog
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ---------------------------------------------------------------- 查询 */
|
|
206
|
+
|
|
207
|
+
let cache = null
|
|
208
|
+
|
|
209
|
+
export async function loadCatalog() {
|
|
210
|
+
if (cache) return cache
|
|
211
|
+
try {
|
|
212
|
+
cache = normalize(JSON.parse(await readFile(CATALOG_PATH, 'utf8')))
|
|
213
|
+
} catch {
|
|
214
|
+
cache = { asOf: null, anchor: null, packages: {}, total: 0 }
|
|
215
|
+
}
|
|
216
|
+
return cache
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function reloadCatalog(cat) {
|
|
220
|
+
cache = normalize(cat)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 归一化:补上 npm 地址、GitHub 地址、总排行、分类排行
|
|
225
|
+
* 排名字段不落盘,加载时算一次(2,250 个包一次排序,毫秒级),
|
|
226
|
+
* 这样 catalog.json 重建与否都不影响排名显示。
|
|
227
|
+
*/
|
|
228
|
+
export function normalize(cat) {
|
|
229
|
+
if (!cat || !cat.packages) return cat
|
|
230
|
+
const all = Object.values(cat.packages).filter(r => r && r.n)
|
|
231
|
+
const byName = new Map(all.map(r => [r.n, r]))
|
|
232
|
+
|
|
233
|
+
// 总排行:按周下载
|
|
234
|
+
const byWeekly = all.slice().sort((a, b) => (b.w || 0) - (a.w || 0))
|
|
235
|
+
byWeekly.forEach((r, i) => { r.rank = i + 1 })
|
|
236
|
+
|
|
237
|
+
// 分类排行:同类内按周下载
|
|
238
|
+
const byCat = new Map()
|
|
239
|
+
byWeekly.forEach(r => {
|
|
240
|
+
if (!r.cat) return
|
|
241
|
+
const n = (byCat.get(r.cat) || 0) + 1
|
|
242
|
+
byCat.set(r.cat, n)
|
|
243
|
+
r.crank = n
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// 规范化 links(scoped 包名要编码)
|
|
247
|
+
for (const r of all) {
|
|
248
|
+
r.npmUrl = 'https://www.npmjs.com/package/' + r.n.replace('/', '%2F')
|
|
249
|
+
let repo = (r.repo || '').replace(/^git\+/, '').replace(/\.git$/, '')
|
|
250
|
+
if (repo && !/^https?:\/\//.test(repo)) repo = ''
|
|
251
|
+
r.repoUrl = repo
|
|
252
|
+
// GitHub 地址优先取 repo,其次 homepage(很多包装配的是 github.io)
|
|
253
|
+
const gh = (repo || r.home || '')
|
|
254
|
+
r.ghUrl = /github\.com/.test(gh) ? gh : ''
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return cat
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* 同义词词典与中文二元切分见 lib/words.js(引擎与面板共用同一份) */
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 选型主流程第 2 步:按需求描述找候选
|
|
264
|
+
* 三层词法匹配 + 下载量微弱加权,返回带相关性与下载量的候选
|
|
265
|
+
*/
|
|
266
|
+
export function searchByNeed(catalog, need, { limit = 12, category = null } = {}) {
|
|
267
|
+
const terms = expandWords(need)
|
|
268
|
+
if (!terms.length) return []
|
|
269
|
+
|
|
270
|
+
const maxWeight = terms.reduce((s, t) => s + t.weight, 0)
|
|
271
|
+
const scored = []
|
|
272
|
+
for (const rec of Object.values(catalog.packages || {})) {
|
|
273
|
+
if (category && rec.cat !== category) continue
|
|
274
|
+
const hay = [(rec.n || ''), ...(rec.kw || []), rec.d || ''].join(' ').toLowerCase()
|
|
275
|
+
let hit = 0
|
|
276
|
+
let any = false
|
|
277
|
+
for (const t of terms) {
|
|
278
|
+
if (hay.includes(t.w)) { hit += t.weight; any = true }
|
|
279
|
+
}
|
|
280
|
+
if (!any) continue
|
|
281
|
+
const relevance = hit / maxWeight
|
|
282
|
+
// 下载量只做微弱加权,避免「发版本最勤」挤掉真正相关的
|
|
283
|
+
const pop = Math.log10(Math.max(rec.w, 1)) / 6
|
|
284
|
+
scored.push({ rec, relevance, score: relevance * 0.85 + pop * 0.15 })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
scored.sort((a, b) => b.score - a.score)
|
|
288
|
+
return scored.slice(0, limit).map(x => ({
|
|
289
|
+
name: x.rec.n,
|
|
290
|
+
version: x.rec.v,
|
|
291
|
+
category: x.rec.cat,
|
|
292
|
+
description: x.rec.d,
|
|
293
|
+
weekly: x.rec.w,
|
|
294
|
+
monthly: x.rec.m,
|
|
295
|
+
deep: x.rec.deep
|
|
296
|
+
? {
|
|
297
|
+
baseline: x.rec.deep.base,
|
|
298
|
+
growth: x.rec.deep.growth,
|
|
299
|
+
cadence: x.rec.deep.cadence,
|
|
300
|
+
ageDays: x.rec.deep.age,
|
|
301
|
+
lastRelease: x.rec.deep.lastRelease,
|
|
302
|
+
versions: x.rec.deep.versions,
|
|
303
|
+
score: x.rec.deep.s,
|
|
304
|
+
}
|
|
305
|
+
: null,
|
|
306
|
+
relevance: Math.round(x.relevance * 100),
|
|
307
|
+
}))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 排行 */
|
|
311
|
+
export function rank(catalog, { metric = 'weekly', category = null, limit = 50, from = 0 } = {}) {
|
|
312
|
+
const all = Object.values(catalog.packages || {}).filter(r => !category || r.cat === category)
|
|
313
|
+
const val = r => metric === 'score' ? (r.deep ? r.deep.s : -1)
|
|
314
|
+
: metric === 'baseline' ? (r.deep ? r.deep.base : -1)
|
|
315
|
+
: metric === 'growth' ? (r.deep && r.deep.growth !== null ? r.deep.growth : -Infinity)
|
|
316
|
+
: metric === 'monthly' ? r.m : r.w
|
|
317
|
+
all.sort((a, b) => val(b) - val(a))
|
|
318
|
+
return all.slice(from, from + limit)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 获取单个/多个包的完整数据(含目录里没有时实时查) */
|
|
322
|
+
export async function stats(catalog, names) {
|
|
323
|
+
const out = []
|
|
324
|
+
for (const name of names) {
|
|
325
|
+
const rec = catalog.packages[name]
|
|
326
|
+
if (rec && rec.deep) {
|
|
327
|
+
out.push({ source: 'catalog', ...serialize(rec) })
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
const live = await fetchLive(name)
|
|
331
|
+
out.push(live ? { source: 'live', ...live } : { source: 'not-found', name })
|
|
332
|
+
}
|
|
333
|
+
return out
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function fetchLive(name) {
|
|
337
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
338
|
+
const anchor = await resolveAnchor()
|
|
339
|
+
const start = shiftDate(anchor, -29)
|
|
340
|
+
const [range, total, meta] = await Promise.all([
|
|
341
|
+
jget(`${DL_API}/range/${start}:${anchor}/${enc(name)}`),
|
|
342
|
+
jget(`${DL_API}/point/2015-01-01:${today}/${enc(name)}`),
|
|
343
|
+
jget(`${REG_API}/${enc(name)}`),
|
|
344
|
+
])
|
|
345
|
+
if (!range || !Array.isArray(range.downloads) || !range.downloads.length) return null
|
|
346
|
+
|
|
347
|
+
const daily = range.downloads.map(x => x.downloads)
|
|
348
|
+
let releaseDates = []
|
|
349
|
+
if (meta && meta.time) {
|
|
350
|
+
releaseDates = Object.entries(meta.time)
|
|
351
|
+
.filter(([k]) => k !== 'created' && k !== 'modified')
|
|
352
|
+
.map(([, v]) => String(v).slice(0, 10))
|
|
353
|
+
}
|
|
354
|
+
const b = baselineWithStart(daily, releaseDates, start)
|
|
355
|
+
const g = growth(daily)
|
|
356
|
+
const last = releaseDates.slice().sort().pop() || ''
|
|
357
|
+
const age = ageDays(last, today)
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
name,
|
|
361
|
+
description: (meta && meta.description) || '',
|
|
362
|
+
keywords: (meta && meta.keywords) || [],
|
|
363
|
+
latest: meta && meta['dist-tags'] ? meta['dist-tags'].latest : null,
|
|
364
|
+
weekly: sum(daily.slice(-7)),
|
|
365
|
+
monthly: sum(daily),
|
|
366
|
+
total: total && typeof total.downloads === 'number' ? total.downloads : null,
|
|
367
|
+
daily,
|
|
368
|
+
deep: {
|
|
369
|
+
baseline: b,
|
|
370
|
+
growth: g,
|
|
371
|
+
cadence: cadence(releaseDates),
|
|
372
|
+
ageDays: age,
|
|
373
|
+
lastRelease: last,
|
|
374
|
+
versions: releaseDates.length,
|
|
375
|
+
score: score({ baseline: b, growth: g, age, cadence: cadence(releaseDates) }),
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function serialize(rec) {
|
|
381
|
+
return {
|
|
382
|
+
name: rec.n,
|
|
383
|
+
version: rec.v,
|
|
384
|
+
description: rec.d,
|
|
385
|
+
keywords: rec.kw,
|
|
386
|
+
category: rec.cat,
|
|
387
|
+
weekly: rec.w,
|
|
388
|
+
monthly: rec.m,
|
|
389
|
+
rank: rec.rank || null,
|
|
390
|
+
crank: rec.crank || null,
|
|
391
|
+
npmUrl: rec.npmUrl || null,
|
|
392
|
+
ghUrl: rec.ghUrl || null,
|
|
393
|
+
repoUrl: rec.repoUrl || null,
|
|
394
|
+
deep: rec.deep
|
|
395
|
+
? {
|
|
396
|
+
baseline: rec.deep.base,
|
|
397
|
+
growth: rec.deep.growth,
|
|
398
|
+
cadence: rec.deep.cadence,
|
|
399
|
+
ageDays: rec.deep.age,
|
|
400
|
+
lastRelease: rec.deep.lastRelease,
|
|
401
|
+
versions: rec.deep.versions,
|
|
402
|
+
score: rec.deep.s,
|
|
403
|
+
}
|
|
404
|
+
: null,
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
export default { buildCatalog, loadCatalog, reloadCatalog, searchByNeed, rank, stats, CATALOG_PATH, resolveAnchor }
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// lib/metrics.js — 去噪指标
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这一层:npm 下载量是噪声极大的指标。我们自己的 5 个包统计里,
|
|
4
|
+
// 单日峰值全部落在版本发布日(dsh-vps 于 10-02 连发 7 个版本,单日 1,068)。
|
|
5
|
+
// 按原始下载量排序,排出来的是「发版本最勤的那个」,不是「最值得装的那个」。
|
|
6
|
+
//
|
|
7
|
+
// 所以结论要建立在去噪后的指标上。
|
|
8
|
+
|
|
9
|
+
export function median(arr) {
|
|
10
|
+
if (!arr || !arr.length) return 0
|
|
11
|
+
const a = [...arr].sort((x, y) => x - y)
|
|
12
|
+
const m = a.length >> 1
|
|
13
|
+
return a.length % 2 ? a[m] : Math.round((a[m - 1] + a[m]) / 2)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function mean(arr) {
|
|
17
|
+
if (!arr || !arr.length) return 0
|
|
18
|
+
return Math.round(arr.reduce((x, y) => x + y, 0) / arr.length)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sum(arr) {
|
|
22
|
+
if (!arr || !arr.length) return 0
|
|
23
|
+
return arr.reduce((x, y) => x + y, 0)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const dayMs = 86400000
|
|
27
|
+
const toDay = iso => Math.floor(new Date(iso + 'T00:00:00Z').getTime() / dayMs)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 日常基线:扣掉发布日影响后的每日中位数
|
|
31
|
+
* 发布日当天 + 后一天很可能是拉取安装产生的虚高,剔除后再取中位数。
|
|
32
|
+
* 中位数本身对尖峰不敏感,再剔一次发布日就更接近真实使用量。
|
|
33
|
+
* @param {number[]} daily 近 30 天每日下载
|
|
34
|
+
* @param {string[]} releaseDates 该窗口内发布过版本的日期(ISO)
|
|
35
|
+
* @param {string} startISO 窗口首日(ISO)
|
|
36
|
+
* @returns {number}
|
|
37
|
+
*/
|
|
38
|
+
export function baselineWithStart(daily = [], releaseDates = [], startISO = '') {
|
|
39
|
+
if (!daily.length) return 0
|
|
40
|
+
if (!releaseDates.length || !startISO) return median(daily)
|
|
41
|
+
|
|
42
|
+
const spikes = new Set()
|
|
43
|
+
const s = toDay(startISO)
|
|
44
|
+
for (const d of releaseDates) {
|
|
45
|
+
const n = toDay(d)
|
|
46
|
+
if (n >= s && n <= s + daily.length) {
|
|
47
|
+
spikes.add(n - s) // 发布当天
|
|
48
|
+
spikes.add(n - s + 1) // 发布次日
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const kept = daily.filter((_, i) => !spikes.has(i))
|
|
52
|
+
return kept.length >= 5 ? median(kept) : median(daily)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 增速:最近 7 天 ÷ 之前 7 天 - 1
|
|
57
|
+
* 序列长度不足 14 天返回 null(发布时间太短,增速没意义)
|
|
58
|
+
* @returns {number|null}
|
|
59
|
+
*/
|
|
60
|
+
export function growth(daily = []) {
|
|
61
|
+
if (daily.length < 14) return null
|
|
62
|
+
const last7 = sum(daily.slice(-7))
|
|
63
|
+
const prev7 = sum(daily.slice(-14, -7))
|
|
64
|
+
if (prev7 === 0) return last7 > 0 ? 1 : null
|
|
65
|
+
return (last7 - prev7) / prev7
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 版本节奏:平均几天发一个版本
|
|
70
|
+
* @param {string[]} versionDates 已按时间排序的版本发布时间(ISO 日期)
|
|
71
|
+
* @returns {number|null}
|
|
72
|
+
*/
|
|
73
|
+
export function cadence(versionDates = []) {
|
|
74
|
+
if (!versionDates || versionDates.length < 2) return null
|
|
75
|
+
const first = toDay(versionDates[0])
|
|
76
|
+
const last = toDay(versionDates[versionDates.length - 1])
|
|
77
|
+
const span = last - first
|
|
78
|
+
if (span <= 0) return 0.1 // 同一天发多个
|
|
79
|
+
return Math.round((span / (versionDates.length - 1)) * 10) / 10
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 距今天数 */
|
|
83
|
+
export function ageDays(iso = '', todayISO = '') {
|
|
84
|
+
if (!iso) return null
|
|
85
|
+
const ref = todayISO || new Date().toISOString().slice(0, 10)
|
|
86
|
+
return toDay(ref) - toDay(iso)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 综合评分 0~100,用于「首选 / 备选」的默认排序依据
|
|
91
|
+
* 权重:日常基线 40% + 增速 25% + 新鲜度 20% + 版本节奏 15%
|
|
92
|
+
* 注意:这是给面板用的默认口径,AI 拿到同样的字段可以自行调整权重并解释
|
|
93
|
+
*/
|
|
94
|
+
export function score({ baseline: b = 0, growth: g = null, age = null, cadence: c = null }) {
|
|
95
|
+
const baselinePart = clamp01(Math.log10(Math.max(b, 1)) / 4) // 1 → 0, 1万 → 1
|
|
96
|
+
const growthPart = g === null ? 0.5 : clamp01((g + 0.5) / 1.5) // -50% → 0, +100% → 1
|
|
97
|
+
const agePart = age === null ? 0.4 : clamp01(1 - age / 180) // 今天 → 1, 180 天前 → 0
|
|
98
|
+
const cadencePart = c === null ? 0.4 : clamp01(1 - (c - 1) / 21) // 1天/版 → 1, 22天/版 → 0
|
|
99
|
+
|
|
100
|
+
return Math.round(
|
|
101
|
+
baselinePart * 40 + growthPart * 25 + agePart * 20 + cadencePart * 15
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x }
|
package/lib/words.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// lib/words.js — 需求 → 搜索词
|
|
2
|
+
//
|
|
3
|
+
// 这一层是纯函数,不依赖 Node,所以 lib/engine.js(CLI/宿主端)和
|
|
4
|
+
// web/panel.js(浏览器)都能 import 同一份,避免两套实现各自漂移。
|
|
5
|
+
//
|
|
6
|
+
// 三层展开,否则中文需求在英文关键词面前命中率极低:
|
|
7
|
+
// 1) 用户原词(权重 3)
|
|
8
|
+
// 2) 同义词词典:「数据库」→ sql/database/postgres…(权重 2)
|
|
9
|
+
// 3) 长中文词二元切分:「数据库管理」→ 数据/据库/管理(权重 1,兜底词典没覆盖的说法)
|
|
10
|
+
|
|
11
|
+
export const STOP = new Set(['什么', '怎么', '如何', '可以', '一个', '我想', '我要', '需要', '有没有',
|
|
12
|
+
'what', 'how', 'the', 'and', 'for', 'with', 'can', 'you', 'help', 'please', '插件', '东西', '工具', '功能'])
|
|
13
|
+
|
|
14
|
+
export const SYNONYMS = {
|
|
15
|
+
'数据库': ['sql', 'database', 'db', 'postgres', 'mysql', 'sqlite', 'redis', 'mongo'],
|
|
16
|
+
'微信': ['wechat', 'wecom', 'weixin'],
|
|
17
|
+
'企业微信': ['wecom', 'wechat'],
|
|
18
|
+
'飞书': ['feishu', 'lark'],
|
|
19
|
+
'通知': ['notify', 'notification', 'message', 'im'],
|
|
20
|
+
'记忆': ['memory', 'context', 'recall', 'session'],
|
|
21
|
+
'搜索': ['search', 'web-search', 'retrieval'],
|
|
22
|
+
'远程': ['remote', 'ssh', 'vps', 'server'],
|
|
23
|
+
'服务器': ['server', 'vps', 'ssh', 'host'],
|
|
24
|
+
'部署': ['deploy', 'install', 'setup', 'self-hosted'],
|
|
25
|
+
'美化': ['theme', 'skin', 'wallpaper', 'appearance', 'ui'],
|
|
26
|
+
'界面': ['web-ui', 'ui', 'sidebar', 'theme', 'appearance'],
|
|
27
|
+
'终端': ['terminal', 'tui', 'shell', 'cli'],
|
|
28
|
+
'费用': ['cost', 'billing', 'usage', 'price', 'quota'],
|
|
29
|
+
'统计': ['stats', 'meter', 'usage', 'audit'],
|
|
30
|
+
'图片': ['image', 'vision', 'generation'],
|
|
31
|
+
'视频': ['video', 'live', 'camera'],
|
|
32
|
+
'语音': ['voice', 'audio', 'tts'],
|
|
33
|
+
'安全': ['security', 'sandbox', 'auth', 'permission', 'safety'],
|
|
34
|
+
'权限': ['permission', 'auth', 'approval'],
|
|
35
|
+
'审查': ['review', 'audit', 'lint', 'diff'],
|
|
36
|
+
'代码': ['code', 'git', 'diff', 'review', 'repo'],
|
|
37
|
+
'文档': ['markdown', 'office', 'pdf', 'doc', 'note'],
|
|
38
|
+
'模型': ['model', 'llm', 'provider', 'gateway'],
|
|
39
|
+
'网关': ['gateway', 'router', 'proxy'],
|
|
40
|
+
'插件': ['plugin', 'marketplace', 'install'],
|
|
41
|
+
'智能体': ['agent', 'subagent', 'team'],
|
|
42
|
+
'自动化': ['automation', 'workflow', 'schedule', 'task'],
|
|
43
|
+
'备份': ['backup', 'archive', 'restore'],
|
|
44
|
+
'导入': ['import', 'export', 'migrate'],
|
|
45
|
+
'翻译': ['translate', 'i18n', 'lang'],
|
|
46
|
+
'墙纸': ['wallpaper', 'background', 'skin'],
|
|
47
|
+
'文件': ['file', 'fs', 'edit'],
|
|
48
|
+
'管理': ['manager', 'manage', 'admin'],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} need 用户原话
|
|
53
|
+
* @returns {{w:string, weight:number}[]}
|
|
54
|
+
*/
|
|
55
|
+
export function expandWords(need) {
|
|
56
|
+
const raw = String(need || '').toLowerCase()
|
|
57
|
+
.split(/[\s,,、。??!!/;;::()()"'《》【】\[\]+]+/).map(w => w.trim())
|
|
58
|
+
.filter(w => w.length >= 2 && !STOP.has(w))
|
|
59
|
+
|
|
60
|
+
const out = []
|
|
61
|
+
for (const w of raw) {
|
|
62
|
+
out.push({ w, weight: 3 })
|
|
63
|
+
const syn = SYNONYMS[w]
|
|
64
|
+
if (syn) for (const s of syn) out.push({ w: s, weight: 2 })
|
|
65
|
+
// 长中文词切二元,兜底同义词词典没覆盖的说法
|
|
66
|
+
if (/[\u4e00-\u9fa5]/.test(w) && w.length >= 4) {
|
|
67
|
+
for (let i = 0; i + 2 <= w.length; i++) out.push({ w: w.slice(i, i + 2), weight: 1 })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pick",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "帮你在 DSH 生态里选对插件:说需求 → 找候选 → 多维比较 → 出结论。下载量是证据,AI 是推理。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "dsh-pick": "bin/dsh-pick.js" },
|
|
7
|
+
"files": ["bin", "lib", "plugin", "web", "data", "README.md"],
|
|
8
|
+
"keywords": [
|
|
9
|
+
"dsh-plugin",
|
|
10
|
+
"dsh-plugins",
|
|
11
|
+
"dsh",
|
|
12
|
+
"deepseek-harness",
|
|
13
|
+
"deepseek-harness-plugin",
|
|
14
|
+
"deepseek",
|
|
15
|
+
"cordis",
|
|
16
|
+
"plugin",
|
|
17
|
+
"plugin-directory",
|
|
18
|
+
"plugin-advisor",
|
|
19
|
+
"pick",
|
|
20
|
+
"npm-stats",
|
|
21
|
+
"plugin-market"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"engines": { "node": ">=20" }
|
|
25
|
+
}
|