dsh-dpharness 0.3.5
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/LICENSE +21 -0
- package/README.md +100 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +930 -0
- package/lib/index.js +646 -0
- package/package.json +49 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-dpharness — host half (v0.3.0).
|
|
3
|
+
*
|
|
4
|
+
* Catalog routes, a write-only telemetry relay and a one-click installer:
|
|
5
|
+
*
|
|
6
|
+
* GET /api/dpharness/search?q=<kw>&take=<n>&sort=stars
|
|
7
|
+
* GET /api/dpharness/meta
|
|
8
|
+
* POST /api/dpharness/event → relayed to https://dpharness.com/api/track/event
|
|
9
|
+
* POST /api/dpharness/install → spawns `dsh plugin --profile <p> add <pkg>`
|
|
10
|
+
* GET /api/dpharness/install → progress of the one in-flight install job
|
|
11
|
+
*
|
|
12
|
+
* The telemetry relay exists because the site's tracker sends no CORS headers,
|
|
13
|
+
* so the browser page served from 127.0.0.1:8787 cannot post to it directly.
|
|
14
|
+
*
|
|
15
|
+
* Privacy contract: the client never sends search keywords. Only event names,
|
|
16
|
+
* an opaque per-install visitor id, package names the user explicitly copied,
|
|
17
|
+
* and a keyword *length* are relayed. Telemetry can be switched off in the UI.
|
|
18
|
+
*
|
|
19
|
+
* Upstream contracts (all read from source / measured 2026-09-17):
|
|
20
|
+
* - GET https://dpharness.com/api/plugins → { count, plugins[] }; `q` covers the
|
|
21
|
+
* whole catalog; `take` is clamped at 500; `sort=stars` is a strictly
|
|
22
|
+
* descending star order; item.installCheck = { status, pkgName }
|
|
23
|
+
* - POST https://dpharness.com/api/track/event → { type, value, path, visitorId }
|
|
24
|
+
* (`path` is truncated at "?", so detail belongs in `value`)
|
|
25
|
+
* - POST /dsh-market/install (dshmarket, when installed) → body { url }, same-origin
|
|
26
|
+
* only, refuses with 400 when the URL is not in its curated registry
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { spawn } from "node:child_process";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
export const name = "dpharness";
|
|
33
|
+
|
|
34
|
+
/** The dsh web server service used to register HTTP routes. */
|
|
35
|
+
export const inject = ["webServer"];
|
|
36
|
+
|
|
37
|
+
export const VERSION = "0.3.5";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Profile used for installs.
|
|
41
|
+
*
|
|
42
|
+
* `dsh plugin --profile <name>` requires the flag, but the running web process
|
|
43
|
+
* is started as `dsh web --host … --port …` without it (the web GUI defaults to
|
|
44
|
+
* the `web` profile). Honour an explicit `--profile` if present, otherwise use
|
|
45
|
+
* that default; DSH_DPHARNESS_PROFILE overrides both.
|
|
46
|
+
*/
|
|
47
|
+
const PROFILE = (() => {
|
|
48
|
+
const flag = process.argv.indexOf("--profile");
|
|
49
|
+
if (flag >= 0 && process.argv[flag + 1]) return process.argv[flag + 1];
|
|
50
|
+
return process.env.DSH_DPHARNESS_PROFILE || "web";
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
const UPSTREAM = "https://dpharness.com/api/plugins";
|
|
54
|
+
const SITE = "https://dpharness.com";
|
|
55
|
+
const TRACK = "https://dpharness.com/api/track/event";
|
|
56
|
+
const DEFAULT_TAKE = 30;
|
|
57
|
+
const MAX_TAKE = 100;
|
|
58
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
59
|
+
const REQUEST_TIMEOUT_MS = 15 * 1000;
|
|
60
|
+
const EVENT_TIMEOUT_MS = 8 * 1000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Only these site event types may be relayed.
|
|
64
|
+
*
|
|
65
|
+
* The site's `app/api/track/event/route.ts` owns an ALLOWED set; `plugin_hub`
|
|
66
|
+
* was added there together with this plugin (it does not exist upstream yet).
|
|
67
|
+
* `copy_install` is the site's existing type for a copied install command and
|
|
68
|
+
* already carries the package name in `value` — the plugin reuses it as is.
|
|
69
|
+
*/
|
|
70
|
+
const EVENT_ALLOWLIST = new Set(["copy_install", "plugin_hub"]);
|
|
71
|
+
const EVENT_WINDOW_MS = 60 * 1000;
|
|
72
|
+
const EVENT_MAX_PER_WINDOW = 120;
|
|
73
|
+
|
|
74
|
+
/* --------------------------------------------------------------------- install */
|
|
75
|
+
/**
|
|
76
|
+
* Install targets are validated with the same character allowlist dshmarket
|
|
77
|
+
* uses before handing anything to a child process (lib/dsh-cli.js TARGET_RE).
|
|
78
|
+
* Anything containing shell metacharacters is refused outright.
|
|
79
|
+
*/
|
|
80
|
+
const TARGET_RE = /^[A-Za-z0-9@:./_#+~^=-]+$/;
|
|
81
|
+
const INSTALL_TIMEOUT_MS = 180 * 1000;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Re-invoke the dsh CLI that launched this host, instead of looking for `dsh`
|
|
85
|
+
* on PATH (it is not there). Same trick as dshmarket's dshArgv(): the entry is
|
|
86
|
+
* process.argv[1], and spawning it with the current node executable also keeps
|
|
87
|
+
* `process.execArgv` (loader flags) consistent.
|
|
88
|
+
*
|
|
89
|
+
* Spawning through the managed node's bin directory also puts `pnpm` on PATH,
|
|
90
|
+
* because the dsh web process is started by that same node.
|
|
91
|
+
*/
|
|
92
|
+
function dshInvocation() {
|
|
93
|
+
const entry = process.argv[1];
|
|
94
|
+
if (typeof entry !== "string" || !/[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) return null;
|
|
95
|
+
const abs = path.resolve(entry);
|
|
96
|
+
return { file: process.execPath, args: [...process.execArgv, abs], cwd: path.dirname(abs) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const installJob = {
|
|
100
|
+
active: false,
|
|
101
|
+
stage: "idle",
|
|
102
|
+
target: "",
|
|
103
|
+
startedAt: 0,
|
|
104
|
+
finishedAt: 0,
|
|
105
|
+
code: null,
|
|
106
|
+
output: [],
|
|
107
|
+
error: null,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build a clean environment for the child process.
|
|
112
|
+
*
|
|
113
|
+
* The host CLI injects a `safe-delete` shim through PATH, NODE_OPTIONS and
|
|
114
|
+
* CODEBUDDY_SAFE_DELETE_*; pnpm unlinks many temp files during an install, so
|
|
115
|
+
* the shim aborts it (measured failure mode: SAFE_DELETE_BULK_CONFIRM_REQUIRED,
|
|
116
|
+
* and EPERM when the shim refuses to unlink locks). Stripping it mirrors what
|
|
117
|
+
* the dsh web supervisor already does for the server process itself.
|
|
118
|
+
*/
|
|
119
|
+
function sanitizedEnv(binDir) {
|
|
120
|
+
const env = { ...process.env };
|
|
121
|
+
const keep = (entry) => entry && !/cli[\\/]vendor[\\/]shim|safe-delete|vendor[\\/]brokered-bin/.test(entry);
|
|
122
|
+
const parts = String(env.PATH || "").split(path.delimiter).filter(keep);
|
|
123
|
+
env.PATH = [binDir, ...parts].join(path.delimiter);
|
|
124
|
+
for (const key of Object.keys(env)) {
|
|
125
|
+
if (key.startsWith("CODEBUDDY_SAFE_DELETE")) delete env[key];
|
|
126
|
+
}
|
|
127
|
+
if (env.NODE_OPTIONS) {
|
|
128
|
+
const cleaned = env.NODE_OPTIONS
|
|
129
|
+
.split(/\s+/)
|
|
130
|
+
.filter((token) => !/safe-delete|brokered-bin/.test(token))
|
|
131
|
+
.join(" ")
|
|
132
|
+
.trim();
|
|
133
|
+
// 收紧到 --require 被摘掉后可能剩下的孤立路径参数
|
|
134
|
+
if (cleaned === "" || !/--require|--import|--loader/.test(cleaned)) delete env.NODE_OPTIONS;
|
|
135
|
+
else env.NODE_OPTIONS = cleaned;
|
|
136
|
+
}
|
|
137
|
+
return env;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function startInstall(target) {
|
|
141
|
+
const invocation = dshInvocation();
|
|
142
|
+
if (invocation === null) {
|
|
143
|
+
installJob.active = false;
|
|
144
|
+
installJob.stage = "failed";
|
|
145
|
+
installJob.error = "cannot locate the dsh entry point (process.argv[1])";
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
installJob.active = true;
|
|
149
|
+
installJob.stage = "running";
|
|
150
|
+
installJob.target = target;
|
|
151
|
+
installJob.startedAt = Date.now();
|
|
152
|
+
installJob.finishedAt = 0;
|
|
153
|
+
installJob.code = null;
|
|
154
|
+
installJob.output = [];
|
|
155
|
+
installJob.error = null;
|
|
156
|
+
|
|
157
|
+
const binDir = path.dirname(process.execPath);
|
|
158
|
+
const child = spawn(invocation.file, [...invocation.args, "plugin", "--profile", PROFILE, "add", target], {
|
|
159
|
+
cwd: invocation.cwd,
|
|
160
|
+
env: sanitizedEnv(binDir),
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const push = (chunk) => {
|
|
165
|
+
const text = String(chunk);
|
|
166
|
+
installJob.output.push(text);
|
|
167
|
+
if (installJob.output.length > 200) installJob.output.shift();
|
|
168
|
+
};
|
|
169
|
+
child.stdout.on("data", push);
|
|
170
|
+
child.stderr.on("data", push);
|
|
171
|
+
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
installJob.error = "timeout";
|
|
174
|
+
try { child.kill("SIGKILL"); } catch (error) { /* already gone */ }
|
|
175
|
+
}, INSTALL_TIMEOUT_MS);
|
|
176
|
+
|
|
177
|
+
child.on("error", (error) => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
installJob.active = false;
|
|
180
|
+
installJob.stage = "failed";
|
|
181
|
+
installJob.error = String((error && error.message) || error);
|
|
182
|
+
installJob.finishedAt = Date.now();
|
|
183
|
+
});
|
|
184
|
+
child.on("close", (code) => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
installJob.active = false;
|
|
187
|
+
installJob.code = code;
|
|
188
|
+
installJob.finishedAt = Date.now();
|
|
189
|
+
installJob.stage = code === 0 ? "done" : "failed";
|
|
190
|
+
if (code !== 0 && installJob.error === null) installJob.error = errorLine();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function tail() {
|
|
195
|
+
return installJob.output.join("").trim().slice(-600);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Pull the actionable line out of pnpm output for the failure message.
|
|
200
|
+
*
|
|
201
|
+
* pnpm prints its banner and progress first and the reason last, but the reason
|
|
202
|
+
* is followed by generic prose ("This error happened while installing…"), so a
|
|
203
|
+
* plain "last match wins" scan returns the prose instead of the code. Two passes:
|
|
204
|
+
* a distinctive pnpm error code first, generic wording only as a fallback.
|
|
205
|
+
*/
|
|
206
|
+
function errorLine() {
|
|
207
|
+
return errorLineFrom(installJob.output.join(""));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Same extraction, exposed for tests (input is the raw captured output). */
|
|
211
|
+
export function errorLineFrom(text) {
|
|
212
|
+
const lines = String(text || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
213
|
+
const codeRe = /\[ERR_[A-Z_]+\]|ERR_PNPM_[A-Z_]+|E404|404 Not Found|No matching version|Conflicting peer dep/;
|
|
214
|
+
const softRe = /\berror\b|failed|cannot find package|not in the npm registry/i;
|
|
215
|
+
for (const pattern of [codeRe, softRe]) {
|
|
216
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
217
|
+
// 只剥掉「✓ / 项目符号 / 空白」这类装饰前缀,保留 [ERR_…] 的方括号
|
|
218
|
+
const line = lines[i].replace(/^[\s✓✔×✗•·\-–—:]+/, "").replace(/^dsh:\s*/, "");
|
|
219
|
+
if (pattern.test(line)) return line.slice(0, 240);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return String(text || "").trim().slice(-240);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Serializable view of the install job (the raw output buffer stays server-side). */
|
|
226
|
+
function jobView() {
|
|
227
|
+
return {
|
|
228
|
+
active: installJob.active,
|
|
229
|
+
stage: installJob.stage,
|
|
230
|
+
target: installJob.target,
|
|
231
|
+
seconds: installJob.active ? Math.round((Date.now() - installJob.startedAt) / 1000) : 0,
|
|
232
|
+
code: installJob.code,
|
|
233
|
+
error: installJob.error,
|
|
234
|
+
tail: installJob.output.length > 0 ? tail() : "",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** key -> { at: epochMs, value: { count, plugins } } */
|
|
239
|
+
const cache = new Map();
|
|
240
|
+
let eventWindow = { startedAt: 0, count: 0 };
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 分类中文名 —— 抄自站点 `lib/category.ts` 的 `CATEGORY_LABEL`(2026-09-17)。
|
|
244
|
+
* 站点未把中文名放进公开 API,这里留一份带出处的副本;站点新增分类需同步。
|
|
245
|
+
*/
|
|
246
|
+
const CATEGORY_ZH = {
|
|
247
|
+
ads: "营销 / 广告",
|
|
248
|
+
market: "市场 / 管理",
|
|
249
|
+
vision: "视觉能力",
|
|
250
|
+
browser: "浏览器",
|
|
251
|
+
platform: "平台集成",
|
|
252
|
+
ui: "UI / 主题",
|
|
253
|
+
chat: "对话 / 记忆",
|
|
254
|
+
desktop: "桌面端",
|
|
255
|
+
tool: "工具 / 效率",
|
|
256
|
+
other: "其他",
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 从上游文本里挑出「可用的中文句子」,找不到就返回 null。
|
|
261
|
+
*
|
|
262
|
+
* 为什么必须过滤(2026-09-17 实测上游数据,样本见 test-local.mjs):
|
|
263
|
+
* `tagline` 约一半不可用 —— 有英文原文、有被截断的中英混排、还有两条是垃圾:
|
|
264
|
+
* `安装 `、`Overview • Architecture • Key Features • Getting Started •…`。
|
|
265
|
+
* 直接展示等于把站点的噪声搬到插件里,与「严选」的定位相反。
|
|
266
|
+
*
|
|
267
|
+
* 判据(都不是拍脑袋,是按实测样本定的):
|
|
268
|
+
* - 先按 `• · | |` 再按句末标点切分,**逐段**找中文:站点把中英拼在一起时顺序不固定
|
|
269
|
+
* (`English · 中文` 也出现过);
|
|
270
|
+
* - 中文字符数 ≥ 6:滤掉「安装」「插件」这类词,保留真卖点;
|
|
271
|
+
* - 段长 ≥ 8:滤掉 `安装 `;
|
|
272
|
+
* - 命中英文导航词开头(Overview/Installation/…)直接丢弃;
|
|
273
|
+
* - **截断后必须仍含中文**(见 fitZh)。
|
|
274
|
+
*
|
|
275
|
+
* ⚠️ 踩过的坑(2026-09-17 上线后由端到端验证抓到):
|
|
276
|
+
* 最初在**完整文本**上判中文字数、再 `slice(0, max)` 输出,
|
|
277
|
+
* 于是"中文出现在 max 之后"的样本被切成**纯英文开头**——
|
|
278
|
+
* 卡片上出现了 `Chrome sidebar extension that lets DeepSeek Harness operate…` 这种
|
|
279
|
+
* 既没汉化、又被硬截断的卖点。修法:按句切分(让中文句成为可独立命中的候选)
|
|
280
|
+
* + `fitZh()` 保证截断结果仍含中文。
|
|
281
|
+
*/
|
|
282
|
+
function fitZh(text, max) {
|
|
283
|
+
if (text.length <= max) return text;
|
|
284
|
+
const firstZh = text.search(/[\u4e00-\u9fff]/);
|
|
285
|
+
// 中文起点在截断点附近或更靠后时,从中文处起截,避免输出一段纯英文
|
|
286
|
+
if (firstZh >= 0 && firstZh > max - 12) return text.slice(firstZh, firstZh + max).trim();
|
|
287
|
+
return text.slice(0, max).trim();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 丢掉中文之前的英文长前缀。
|
|
292
|
+
*
|
|
293
|
+
* 为什么需要(2026-09-17 端到端验证抓到的第二个真实问题):
|
|
294
|
+
* `dsh-context` 的 descriptionZh 长 346 字、含 43 个中文字,但**前 262 字全是英文**
|
|
295
|
+
* ("The best DeepSeek Harness plugin for context insight and management, 一站式…")。
|
|
296
|
+
* 于是卡片上用户先看到 60+ 字的英文 —— 名义上"有汉化",观感仍是英文。
|
|
297
|
+
* 短前缀保留(如 "Browser4——面向自主智能体…" 的 "Browser4——" 只有 9 字符)。
|
|
298
|
+
*/
|
|
299
|
+
function leadWithChinese(text) {
|
|
300
|
+
const firstZh = text.search(/[\u4e00-\u9fff]/);
|
|
301
|
+
if (firstZh <= 12) return text.trim();
|
|
302
|
+
return text.slice(firstZh).trim();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function pickZh(text, max = 120) {
|
|
306
|
+
if (typeof text !== "string") return null;
|
|
307
|
+
const cleaned = text.replace(/ | /g, " ").replace(/\s+/g, " ").trim();
|
|
308
|
+
if (!cleaned) return null;
|
|
309
|
+
const segments = cleaned
|
|
310
|
+
.split(/[•·||]/)
|
|
311
|
+
.flatMap((chunk) => chunk.split(/(?<=[。!?!?;;])\s*/))
|
|
312
|
+
.map((part) => part.trim())
|
|
313
|
+
.filter(Boolean);
|
|
314
|
+
for (const segment of segments) {
|
|
315
|
+
if (segment.length < 8) continue;
|
|
316
|
+
const zhChars = (segment.match(/[\u4e00-\u9fff]/g) || []).length;
|
|
317
|
+
if (zhChars < 6) continue;
|
|
318
|
+
if (/^(overview|installation|usage|features|readme|getting started|table of contents|documentation)/i.test(segment)) continue;
|
|
319
|
+
const out = fitZh(leadWithChinese(segment.replace(/[…]+$/, "").replace(/\s*[·||]\s*$/, "").trim()), max);
|
|
320
|
+
// 截断后可能只剩英文前缀 —— 那种情况不算汉化,继续找下一段
|
|
321
|
+
if ((out.match(/[\u4e00-\u9fff]/g) || []).length < 4) continue;
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function sendJson(res, status, payload) {
|
|
328
|
+
const body = JSON.stringify(payload);
|
|
329
|
+
res.writeHead(status, {
|
|
330
|
+
"content-type": "application/json; charset=utf-8",
|
|
331
|
+
"cache-control": "no-store",
|
|
332
|
+
"content-length": Buffer.byteLength(body),
|
|
333
|
+
});
|
|
334
|
+
res.end(body);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function readBody(req, limitBytes) {
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
const chunks = [];
|
|
340
|
+
let size = 0;
|
|
341
|
+
req.on("data", (chunk) => {
|
|
342
|
+
size += chunk.length;
|
|
343
|
+
if (size > limitBytes) {
|
|
344
|
+
reject(new Error("payload too large"));
|
|
345
|
+
req.destroy();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
chunks.push(chunk);
|
|
349
|
+
});
|
|
350
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
351
|
+
req.on("error", reject);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Reduce an upstream item to the fields the client actually renders. */
|
|
356
|
+
function slim(item) {
|
|
357
|
+
const check = item && item.installCheck;
|
|
358
|
+
// 与站点 lib/installCmd.ts 的 resolveInstallTarget 保持**同一口径**:
|
|
359
|
+
// 只有 installCheck.status === "pass" 时才敢用 npm 包名(校验未过时 npm 上的同名包可能属于别人);
|
|
360
|
+
// 否则退回 owner/repo,走 GitHub 源安装。
|
|
361
|
+
// ⚠️ 2026-09-17 实测教训:本站此前只认 pkgName,于是「未发布到 npm 但可从源码安装」的插件
|
|
362
|
+
// 在插件里**连命令都不给**,而站点详情页给的是 `dsh plugin --profile web add owner/repo`
|
|
363
|
+
// 并标注「该插件未发布到 npm,走 GitHub 源安装」—— 两套口径,正是 installCmd.ts 注释里
|
|
364
|
+
// 记过的那类分歧(同一插件在不同出口给不同命令)。
|
|
365
|
+
const verified = !!check && check.status === "pass";
|
|
366
|
+
const pkgName = verified && typeof check.pkgName === "string" && check.pkgName ? check.pkgName : null;
|
|
367
|
+
const target = pkgName || item.fullName || "";
|
|
368
|
+
const rawDesc = typeof item.description === "string" ? item.description : "";
|
|
369
|
+
return {
|
|
370
|
+
fullName: item.fullName || null,
|
|
371
|
+
name: item.name || null,
|
|
372
|
+
nameZh: item.nameZh || null,
|
|
373
|
+
owner: item.owner || null,
|
|
374
|
+
url: item.htmlUrl || null,
|
|
375
|
+
// 卖点:优先站点 tagline,其次汉化正文,最后从英文 description 里抠中文段
|
|
376
|
+
sell: pickZh(item.tagline, 90) || pickZh(item.descriptionZh, 90) || pickZh(rawDesc, 90) || null,
|
|
377
|
+
// 汉化正文:站点 descriptionZh(2026-09-17 起公开 API 才带出来)→ 中文段 → null
|
|
378
|
+
descZh: pickZh(item.descriptionZh, 400) || pickZh(rawDesc, 400) || null,
|
|
379
|
+
// 原文兜底:没有汉化时展示它,并让客户端标注「未汉化」
|
|
380
|
+
desc: rawDesc.replace(/\s+/g, " ").trim().slice(0, 280),
|
|
381
|
+
cat: CATEGORY_ZH[item.category] || CATEGORY_ZH.other,
|
|
382
|
+
stars: typeof item.stars === "number" ? item.stars : 0,
|
|
383
|
+
category: item.category || null,
|
|
384
|
+
tier: item.tier || null,
|
|
385
|
+
type: item.pluginType || null,
|
|
386
|
+
compat: item.dshCompat || null,
|
|
387
|
+
risk: item.riskLevel || null,
|
|
388
|
+
verify: item.verifyStatus || null,
|
|
389
|
+
tested: item.testedStatus || null,
|
|
390
|
+
/** 一键安装按钮的开关:只在站点已验证过 npm 包名时打开 */
|
|
391
|
+
pkg: pkgName,
|
|
392
|
+
/** 命令的来源类型,供前端标注「npm」还是「GitHub 源」 */
|
|
393
|
+
src: pkgName ? "npm" : "repo",
|
|
394
|
+
/** 站点是否验证通过(pass);未验证/警告都按保守处理 */
|
|
395
|
+
verified,
|
|
396
|
+
// `--profile web` 是 dsh CLI 必需参数;target 已是同口径的结果
|
|
397
|
+
cmd: target ? `dsh plugin --profile web add ${target}` : null,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseQuery(req) {
|
|
402
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
403
|
+
const q = (url.searchParams.get("q") || "").trim().slice(0, 120);
|
|
404
|
+
const sort = (url.searchParams.get("sort") || "").trim().slice(0, 32);
|
|
405
|
+
const rawTake = Number.parseInt(url.searchParams.get("take") || "", 10);
|
|
406
|
+
const take = Number.isFinite(rawTake) ? Math.min(Math.max(rawTake, 1), MAX_TAKE) : DEFAULT_TAKE;
|
|
407
|
+
const params = new URLSearchParams();
|
|
408
|
+
if (q) params.set("q", q);
|
|
409
|
+
params.set("take", String(take));
|
|
410
|
+
if (sort) params.set("sort", sort);
|
|
411
|
+
return params;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function fetchCatalog(params) {
|
|
415
|
+
const key = params.toString();
|
|
416
|
+
const now = Date.now();
|
|
417
|
+
const hit = cache.get(key);
|
|
418
|
+
if (hit && now - hit.at < CACHE_TTL_MS) {
|
|
419
|
+
return { count: hit.value.count, plugins: hit.value.plugins, cached: true, fetchedAt: hit.at };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const controller = new AbortController();
|
|
423
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
424
|
+
try {
|
|
425
|
+
const url = key ? `${UPSTREAM}?${key}` : UPSTREAM;
|
|
426
|
+
const response = await fetch(url, {
|
|
427
|
+
headers: {
|
|
428
|
+
accept: "application/json",
|
|
429
|
+
"user-agent": `dsh-dpharness/${VERSION}`,
|
|
430
|
+
},
|
|
431
|
+
signal: controller.signal,
|
|
432
|
+
});
|
|
433
|
+
if (!response.ok) throw new Error(`upstream HTTP ${response.status}`);
|
|
434
|
+
const body = await response.json();
|
|
435
|
+
const list = Array.isArray(body && body.plugins) ? body.plugins : [];
|
|
436
|
+
const value = { count: list.length, plugins: list.map(slim) };
|
|
437
|
+
cache.set(key, { at: Date.now(), value });
|
|
438
|
+
return { ...value, cached: false, fetchedAt: Date.now() };
|
|
439
|
+
} finally {
|
|
440
|
+
clearTimeout(timer);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function withinEventBudget() {
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
if (now - eventWindow.startedAt > EVENT_WINDOW_MS) {
|
|
447
|
+
eventWindow = { startedAt: now, count: 0 };
|
|
448
|
+
}
|
|
449
|
+
eventWindow.count += 1;
|
|
450
|
+
return eventWindow.count <= EVENT_MAX_PER_WINDOW;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const clampText = (value, max) => (typeof value === "string" ? value.slice(0, max) : "");
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Build the site's event payload.
|
|
457
|
+
*
|
|
458
|
+
* Site contract (read from app/api/track/event/route.ts, not guessed):
|
|
459
|
+
* { type, value, path, visitorId }
|
|
460
|
+
* - type ∈ ALLOWED set there
|
|
461
|
+
* - path is truncated at "?" (`path.split("?")[0]`) and must not start with
|
|
462
|
+
* /api, /dashboard or /_next → so all detail goes in `value`, never in a query
|
|
463
|
+
* - value trimmed and capped at 120 chars, free text
|
|
464
|
+
* - visitorId must match /^[A-Za-z0-9-]{8,64}$/ or it is dropped
|
|
465
|
+
* - the route also filters loopback IPs, bot UAs and SELF_IP_HASHES
|
|
466
|
+
*/
|
|
467
|
+
function buildTrackPayload(input) {
|
|
468
|
+
const type = clampText(input.event, 32);
|
|
469
|
+
const rawVisitor = clampText(input.visitorId, 64);
|
|
470
|
+
const visitorId = /^[A-Za-z0-9-]{8,64}$/.test(rawVisitor) ? rawVisitor : null;
|
|
471
|
+
const detail = input.detail && typeof input.detail === "object" ? input.detail : {};
|
|
472
|
+
const clean = (value, max) => String(value == null ? "" : value).replace(/[^A-Za-z0-9@/._:-]/g, "").slice(0, max);
|
|
473
|
+
// 命令含空格,需单独净化:保留空格但折叠,其余字符收紧
|
|
474
|
+
const cleanCommand = (value) => String(value == null ? "" : value)
|
|
475
|
+
.replace(/[^A-Za-z0-9@/._: -]/g, "")
|
|
476
|
+
.replace(/\s+/g, " ")
|
|
477
|
+
.trim()
|
|
478
|
+
.slice(0, 120);
|
|
479
|
+
|
|
480
|
+
let value = "";
|
|
481
|
+
if (type === "copy_install") {
|
|
482
|
+
// 与站点既有口径一致:copy_install 的 value 是**整条命令**
|
|
483
|
+
// (站点 components/HubUI.tsx 的 InstallBlock 就是 trackEvent("copy_install", command))。
|
|
484
|
+
// 来源靠 path 区分(站点是页面路径,插件固定 /dsh-plugin),不靠 value。
|
|
485
|
+
value = cleanCommand(detail.cmd) || cleanCommand(detail.pkg);
|
|
486
|
+
} else {
|
|
487
|
+
// plugin_hub carries "<action>:<detail>" — query strings would be truncated away.
|
|
488
|
+
const action = clean(detail.action, 24) || "unknown";
|
|
489
|
+
const parts = [action];
|
|
490
|
+
if (Number.isFinite(detail.length)) parts.push("len=" + Number(detail.length));
|
|
491
|
+
if (Number.isFinite(detail.results)) parts.push("hits=" + Number(detail.results));
|
|
492
|
+
if (detail.scope) parts.push("via=" + clean(detail.scope, 12));
|
|
493
|
+
value = parts.join(":").slice(0, 120);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return { type, value, path: "/dsh-plugin", visitorId };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function registerRoute(ctx, pathname, handler) {
|
|
500
|
+
ctx.effect(
|
|
501
|
+
() => ctx.webServer.register({ kind: "prefix", path: pathname, handler }),
|
|
502
|
+
`dpharness: ${pathname} route`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function apply(ctx) {
|
|
507
|
+
registerRoute(ctx, "/api/dpharness/search", async (req, res) => {
|
|
508
|
+
if (req.method !== "GET") {
|
|
509
|
+
res.writeHead(405, { allow: "GET" });
|
|
510
|
+
res.end();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const params = parseQuery(req);
|
|
514
|
+
try {
|
|
515
|
+
const result = await fetchCatalog(params);
|
|
516
|
+
sendJson(res, 200, {
|
|
517
|
+
ok: true,
|
|
518
|
+
source: SITE,
|
|
519
|
+
cached: result.cached,
|
|
520
|
+
fetchedAt: new Date(result.fetchedAt).toISOString(),
|
|
521
|
+
query: Object.fromEntries(params),
|
|
522
|
+
count: result.count,
|
|
523
|
+
plugins: result.plugins,
|
|
524
|
+
});
|
|
525
|
+
} catch (error) {
|
|
526
|
+
const message = error && error.name === "AbortError" ? "upstream timeout" : String((error && error.message) || error);
|
|
527
|
+
sendJson(res, 502, { ok: false, error: message, source: SITE });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
registerRoute(ctx, "/api/dpharness/event", async (req, res) => {
|
|
532
|
+
if (req.method !== "POST") {
|
|
533
|
+
res.writeHead(405, { allow: "POST" });
|
|
534
|
+
res.end();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!withinEventBudget()) {
|
|
538
|
+
sendJson(res, 429, { ok: false, error: "rate limited" });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
let input;
|
|
542
|
+
try {
|
|
543
|
+
input = JSON.parse(await readBody(req, 4096) || "{}");
|
|
544
|
+
} catch (error) {
|
|
545
|
+
sendJson(res, 400, { ok: false, error: String((error && error.message) || error) });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!EVENT_ALLOWLIST.has(clampText(input && input.event, 32))) {
|
|
549
|
+
sendJson(res, 400, { ok: false, error: "event not allowed" });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const payload = buildTrackPayload(input || {});
|
|
553
|
+
const controller = new AbortController();
|
|
554
|
+
const timer = setTimeout(() => controller.abort(), EVENT_TIMEOUT_MS);
|
|
555
|
+
try {
|
|
556
|
+
const response = await fetch(TRACK, {
|
|
557
|
+
method: "POST",
|
|
558
|
+
headers: {
|
|
559
|
+
"content-type": "application/json",
|
|
560
|
+
accept: "application/json",
|
|
561
|
+
// Verified: this UA is not classified as a bot by the site's filter
|
|
562
|
+
// (curl's default UA is). Keep the honest identifier.
|
|
563
|
+
"user-agent": `dsh-dpharness/${VERSION}`,
|
|
564
|
+
},
|
|
565
|
+
body: JSON.stringify(payload),
|
|
566
|
+
signal: controller.signal,
|
|
567
|
+
});
|
|
568
|
+
sendJson(res, 200, { ok: response.ok, forwarded: { type: payload.type, value: payload.value } });
|
|
569
|
+
} catch (error) {
|
|
570
|
+
const message = error && error.name === "AbortError" ? "relay timeout" : String((error && error.message) || error);
|
|
571
|
+
// Telemetry must never surface as an error in the UI; report and move on.
|
|
572
|
+
sendJson(res, 202, { ok: false, error: message });
|
|
573
|
+
} finally {
|
|
574
|
+
clearTimeout(timer);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* One-click install.
|
|
580
|
+
*
|
|
581
|
+
* The client tries dshmarket's own route first (same origin, no CORS issue,
|
|
582
|
+
* restart-free hot mount, rollback available). That route only accepts URLs
|
|
583
|
+
* present in its curated registry, so anything else falls back here — this
|
|
584
|
+
* spawns the dsh CLI directly, which changes the profile and therefore only
|
|
585
|
+
* takes effect after a restart.
|
|
586
|
+
*/
|
|
587
|
+
registerRoute(ctx, "/api/dpharness/install", async (req, res) => {
|
|
588
|
+
if (req.method === "GET") {
|
|
589
|
+
sendJson(res, 200, { ok: true, profile: PROFILE, job: jobView() });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (req.method !== "POST") {
|
|
593
|
+
res.writeHead(405, { allow: "GET, POST" });
|
|
594
|
+
res.end();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (installJob.active) {
|
|
598
|
+
sendJson(res, 409, { ok: false, error: "an install is already running", job: jobView() });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
let input;
|
|
602
|
+
try {
|
|
603
|
+
input = JSON.parse((await readBody(req, 2048)) || "{}");
|
|
604
|
+
} catch (error) {
|
|
605
|
+
sendJson(res, 400, { ok: false, error: String((error && error.message) || error) });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const target = typeof (input && input.pkg) === "string" ? input.pkg.trim().slice(0, 214) : "";
|
|
609
|
+
if (!TARGET_RE.test(target)) {
|
|
610
|
+
sendJson(res, 400, { ok: false, error: "unsafe plugin target rejected" });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
startInstall(target);
|
|
614
|
+
sendJson(res, 202, { ok: true, target, profile: PROFILE, job: jobView() });
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
registerRoute(ctx, "/api/dpharness/meta", (req, res) => {
|
|
618
|
+
if (req.method !== "GET") {
|
|
619
|
+
res.writeHead(405, { allow: "GET" });
|
|
620
|
+
res.end();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
sendJson(res, 200, {
|
|
624
|
+
ok: true,
|
|
625
|
+
name,
|
|
626
|
+
version: VERSION,
|
|
627
|
+
profile: PROFILE,
|
|
628
|
+
source: SITE,
|
|
629
|
+
upstream: UPSTREAM,
|
|
630
|
+
tracker: TRACK,
|
|
631
|
+
routes: [
|
|
632
|
+
"/api/dpharness/search",
|
|
633
|
+
"/api/dpharness/event",
|
|
634
|
+
"/api/dpharness/install",
|
|
635
|
+
"/api/dpharness/meta",
|
|
636
|
+
],
|
|
637
|
+
events: Array.from(EVENT_ALLOWLIST),
|
|
638
|
+
install: { running: installJob.active, target: installJob.target },
|
|
639
|
+
cache: {
|
|
640
|
+
ttlMs: CACHE_TTL_MS,
|
|
641
|
+
entries: cache.size,
|
|
642
|
+
keys: Array.from(cache.keys()),
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-dpharness",
|
|
3
|
+
"version": "0.3.5",
|
|
4
|
+
"description": "Curated dpharness.com plugin catalog inside the DeepSeek Harness Web GUI: a conversation-view tab, a sidebar entry and a site-wide floating panel, with one-click install via dshmarket or the dsh CLI. Read-only catalog plus an opt-out telemetry relay.",
|
|
5
|
+
"author": "zhanghao3693 <zhanghao3693@qq.com>",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zhanghao3693/dsh-dpharness.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://dpharness.com/plugin/zhanghao3693/dsh-dpharness",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/zhanghao3693/dsh-dpharness/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "lib/index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./lib/index.js",
|
|
18
|
+
"./client": "./lib/client.js",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"dsh": {
|
|
22
|
+
"client": {
|
|
23
|
+
"platform": "web",
|
|
24
|
+
"inject": [
|
|
25
|
+
"@deepseek-ai/dsh-client-locale",
|
|
26
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
27
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
28
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"bundle": {
|
|
32
|
+
"patch": "./cordis.patch.yml"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"lib/index.js",
|
|
37
|
+
"lib/client.js",
|
|
38
|
+
"README.md",
|
|
39
|
+
"cordis.patch.yml"
|
|
40
|
+
],
|
|
41
|
+
"keywords": [
|
|
42
|
+
"dsh",
|
|
43
|
+
"deepseek-harness",
|
|
44
|
+
"plugin",
|
|
45
|
+
"catalog",
|
|
46
|
+
"dpharness"
|
|
47
|
+
],
|
|
48
|
+
"license": "MIT"
|
|
49
|
+
}
|