@having5548/dsh-downloader 0.0.0-stage → 0.5.3

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,130 @@
1
+ /**
2
+ * Built-in DIRECT allow-list for Chinese AI platforms.
3
+ *
4
+ * Why this exists: a proxy node that flaps takes down every long-lived
5
+ * connection that goes through it. The model APIs this harness talks to are all
6
+ * domestic services that never needed a proxy in the first place, so the
7
+ * downloader pins them to DIRECT at the highest rule priority — ahead of the
8
+ * subscription's own rules and ahead of the user's `extraRules`.
9
+ *
10
+ * Two things consume this list:
11
+ * - `UpstreamManager` turns it into `DOMAIN-SUFFIX,<domain>,DIRECT` lines so no
12
+ * download to an AI endpoint can ever be routed through a node;
13
+ * - `session-guard.js` turns it into a `NO_PROXY` value for `$DSH_HOME/.env`, so
14
+ * the harness's *own* LLM traffic stays direct even when the launcher's global
15
+ * proxy policy is active.
16
+ *
17
+ * Entries are matched as suffixes (`deepseek.com` also covers
18
+ * `api.deepseek.com`). A stale entry is harmless — it simply never matches.
19
+ * @module dsh-downloader/ai-domains
20
+ */
21
+
22
+ /** Domestic AI platform domains that must always be reached directly. */
23
+ export const AI_DIRECT_DOMAINS = [
24
+ // DeepSeek — this harness's own model endpoint
25
+ "deepseek.com",
26
+ // 智谱 AI / Z.ai / ChatGLM
27
+ "bigmodel.cn",
28
+ "z.ai",
29
+ "chatglm.cn",
30
+ // 月之暗面 Kimi
31
+ "moonshot.cn",
32
+ "moonshot.ai",
33
+ "kimi.com",
34
+ // 阿里通义千问 / 百炼 DashScope
35
+ "dashscope.aliyuncs.com",
36
+ "tongyi.aliyun.com",
37
+ // 字节跳动 豆包 / 火山方舟 Ark
38
+ "volces.com",
39
+ "volcengine.com",
40
+ "doubao.com",
41
+ // 百度文心一言 / 千帆
42
+ "baidubce.com",
43
+ "yiyan.baidu.com",
44
+ // 腾讯混元
45
+ "hunyuan.tencent.com",
46
+ "tencentcloudapi.com",
47
+ // 科大讯飞 星火
48
+ "xfyun.cn",
49
+ "xf-yun.com",
50
+ // MiniMax
51
+ "minimax.chat",
52
+ "minimaxi.com",
53
+ // 零一万物
54
+ "lingyiwanwu.com",
55
+ "01.ai",
56
+ // 阶跃星辰
57
+ "stepfun.com",
58
+ "stepfun.ai",
59
+ // 商汤 日日新
60
+ "sensetime.com",
61
+ "sensecore.io",
62
+ // 百川智能
63
+ "baichuan-ai.com",
64
+ // 硅基流动 SiliconFlow
65
+ "siliconflow.cn",
66
+ "siliconflow.com",
67
+ // 昆仑万维 天工
68
+ "tiangong.cn",
69
+ "skywork.ai",
70
+ // 华为云 盘古 / ModelArts
71
+ "huaweicloud.com",
72
+ "myhuaweicloud.com",
73
+ // 网易有道 子曰
74
+ "youdao.com",
75
+ // 澜舟科技
76
+ "langboat.com",
77
+ // 元象 XVERSE
78
+ "xverse.cn",
79
+ // 面壁智能
80
+ "modelbest.cn",
81
+ // 出门问问
82
+ "mobvoi.com",
83
+ // 国家超算互联网 / 智算平台
84
+ "scnet.cn",
85
+ "openi.cn"
86
+ ];
87
+
88
+ /**
89
+ * Normalize one user-supplied domain into a bare lowercase suffix.
90
+ * Accepts `https://api.example.com/v1`, `*.example.com`, ` example.com `.
91
+ * @returns the bare host, or "" when nothing usable remains.
92
+ */
93
+ export function normalizeDomain(value) {
94
+ if (typeof value !== "string") return "";
95
+ let text = value.trim().toLowerCase();
96
+ if (text.length === 0) return "";
97
+ if (text.includes("://")) {
98
+ try {
99
+ text = new URL(text).hostname;
100
+ } catch {
101
+ text = text.slice(text.indexOf("://") + 3);
102
+ }
103
+ }
104
+ text = text.split("/")[0].split(":")[0].replace(/^\*\./, "").replace(/^\.+/, "").replace(/\.+$/, "");
105
+ return /^[a-z0-9.-]+$/.test(text) && text.includes(".") ? text : "";
106
+ }
107
+
108
+ /**
109
+ * Compose the effective DIRECT domain list.
110
+ * @param options - `protectAiPlatforms` toggle and user `extraDirectDomains`.
111
+ * @returns a de-duplicated, sorted list of bare suffixes.
112
+ */
113
+ export function composeDirectDomains({ protectAiPlatforms = true, extraDirectDomains = [] } = {}) {
114
+ const domains = new Set();
115
+ if (protectAiPlatforms !== false) for (const domain of AI_DIRECT_DOMAINS) domains.add(domain);
116
+ for (const raw of Array.isArray(extraDirectDomains) ? extraDirectDomains : []) {
117
+ const normalized = normalizeDomain(raw);
118
+ if (normalized.length > 0) domains.add(normalized);
119
+ }
120
+ return [...domains].sort();
121
+ }
122
+
123
+ /**
124
+ * Turn a domain list into rule-engine lines.
125
+ * @param domains - bare suffixes.
126
+ * @returns `DOMAIN-SUFFIX,<domain>,DIRECT` lines in list order.
127
+ */
128
+ export function directRuleLines(domains) {
129
+ return domains.map((domain) => `DOMAIN-SUFFIX,${domain},DIRECT`);
130
+ }
Binary file
Binary file
Binary file
@@ -0,0 +1,372 @@
1
+ /**
2
+ * FlClash-style configuration store.
3
+ *
4
+ * FlClash keeps a *list* of profiles, each of which is either a subscription
5
+ * URL or an imported local file, with its own label, last-update time and
6
+ * auto-update interval; the newest label comes from the response's
7
+ * `Content-Disposition`, and traffic/expiry come from `subscription-userinfo`.
8
+ * This module mirrors that model so the plugin can do the same from the
9
+ * settings panel.
10
+ *
11
+ * Layout under `$DSH_HOME/dsh-downloader/`:
12
+ * - `profiles.json` — the metadata list (this module owns it)
13
+ * - `profiles/<id>.yaml` — the body of each imported configuration
14
+ *
15
+ * A "file" profile never auto-updates, exactly like FlClash's `realAutoUpdate`.
16
+ * @module dsh-downloader/profiles
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { parseSubscription } from "./subscription.js";
21
+ import { filenameFromDisposition } from "../download/util.js";
22
+
23
+ /** Default auto-update interval for a newly added profile (FlClash's default is comparable). */
24
+ export const DEFAULT_AUTO_UPDATE_HOURS = 24;
25
+
26
+ /** Tolerance for a "合理的" auto-update interval, in hours. */
27
+ const MIN_AUTO_UPDATE_HOURS = 1;
28
+ const MAX_AUTO_UPDATE_HOURS = 24 * 30;
29
+
30
+ /** Short, sortable, collision-resistant id (no external dependency). */
31
+ function newId() {
32
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
33
+ }
34
+
35
+ /**
36
+ * Parse the `subscription-userinfo` response header.
37
+ * Format: `upload=0; download=0; total=0; expire=0` (bytes; expire is unix seconds).
38
+ * @returns `{ upload, download, total, expire }`, all numbers, 0 when absent.
39
+ */
40
+ export function parseSubscriptionUserinfo(headerValue) {
41
+ const info = { upload: 0, download: 0, total: 0, expire: 0 };
42
+ if (typeof headerValue !== "string" || headerValue.trim().length === 0) return info;
43
+ for (const part of headerValue.split(";")) {
44
+ const eq = part.indexOf("=");
45
+ if (eq === -1) continue;
46
+ const key = part.slice(0, eq).trim();
47
+ const value = Number.parseInt(part.slice(eq + 1).trim(), 10);
48
+ if (!Number.isFinite(value)) continue;
49
+ if (key in info) info[key] = value;
50
+ }
51
+ return info;
52
+ }
53
+
54
+ /**
55
+ * Reduce any accepted import string to the raw subscription URL.
56
+ *
57
+ * Accepts a plain `http(s)://` URL and the `clash://` forms FlClash handles as
58
+ * deep links: `clash://install-config?url=<encoded>`, `clash://?url=<encoded>`,
59
+ * and a bare `clash://<encoded url>`.
60
+ * @returns the URL, or null when nothing usable is found.
61
+ */
62
+ export function parseImportLink(value) {
63
+ if (typeof value !== "string") return null;
64
+ const text = value.trim();
65
+ if (text.length === 0) return null;
66
+ if (/^https?:\/\//i.test(text)) return text;
67
+ if (!/^clash:\/\//i.test(text)) return null;
68
+ const query = text.slice(text.indexOf("?") + 1);
69
+ const params = new URLSearchParams(text.includes("?") ? query : "");
70
+ const fromQuery = params.get("url") ?? params.get("Url") ?? params.get("URL");
71
+ if (typeof fromQuery === "string" && /^https?:\/\//i.test(fromQuery)) return fromQuery;
72
+ // clash://install-config/<encoded> or clash://<encoded>
73
+ const tail = text.replace(/^clash:\/\//i, "").replace(/^(install-config|config)\/?/i, "");
74
+ if (tail.length === 0) return null;
75
+ try {
76
+ const decoded = decodeURIComponent(tail);
77
+ if (/^https?:\/\//i.test(decoded)) return decoded;
78
+ } catch {
79
+ // Not percent-encoded.
80
+ }
81
+ return /^https?:\/\//i.test(tail) ? tail : null;
82
+ }
83
+
84
+ /** A stable, human label for a URL whose response carried no filename. */
85
+ export function labelFromUrl(url) {
86
+ try {
87
+ const parsed = new URL(url);
88
+ return parsed.hostname.replace(/^www\./, "");
89
+ } catch {
90
+ return "配置";
91
+ }
92
+ }
93
+
94
+ /** Reject a label that would escape the profiles directory. */
95
+ function safeLabel(value, fallback) {
96
+ const text = String(value ?? "").trim().replace(/[\\/:*?"<>|]+/g, "_").slice(0, 60);
97
+ return text.length > 0 ? text : fallback;
98
+ }
99
+
100
+ /** Validate a configuration body; throws with a readable reason when unusable. */
101
+ export function validateConfigText(raw) {
102
+ if (typeof raw !== "string" || raw.trim().length === 0) throw new Error("配置内容为空");
103
+ let parsed;
104
+ try {
105
+ parsed = parseSubscription(raw);
106
+ } catch (error) {
107
+ throw new Error(`无法解析配置:${error instanceof Error ? error.message : String(error)}`);
108
+ }
109
+ if (parsed.names.length === 0) throw new Error("配置里没有解析到任何节点");
110
+ return parsed;
111
+ }
112
+
113
+ export class ProfileStore {
114
+ #dataDir;
115
+ /** @type {{version: number, currentId: string|null, profiles: object[]}} */
116
+ #state = { version: 1, currentId: null, profiles: [] };
117
+ #loaded = false;
118
+
119
+ constructor({ dataDir }) {
120
+ this.#dataDir = dataDir;
121
+ }
122
+
123
+ get metaPath() {
124
+ return join(this.#dataDir, "profiles.json");
125
+ }
126
+
127
+ get bodyDir() {
128
+ return join(this.#dataDir, "profiles");
129
+ }
130
+
131
+ bodyPath(id) {
132
+ return join(this.bodyDir, `${id}.yaml`);
133
+ }
134
+
135
+ /** Read the metadata file (idempotent). */
136
+ load() {
137
+ if (this.#loaded) return this.#state;
138
+ this.#loaded = true;
139
+ try {
140
+ const parsed = JSON.parse(readFileSync(this.metaPath, "utf8"));
141
+ if (parsed !== null && typeof parsed === "object" && Array.isArray(parsed.profiles)) {
142
+ this.#state = {
143
+ version: 1,
144
+ currentId: typeof parsed.currentId === "string" ? parsed.currentId : null,
145
+ profiles: parsed.profiles.filter((item) => item !== null && typeof item === "object" && typeof item.id === "string")
146
+ };
147
+ }
148
+ } catch {
149
+ // First run, or unreadable metadata: start empty rather than fail.
150
+ }
151
+ if (this.#state.currentId === null && this.#state.profiles.length > 0) this.#state.currentId = this.#state.profiles[0].id;
152
+ return this.#state;
153
+ }
154
+
155
+ #persist() {
156
+ mkdirSync(this.#dataDir, { recursive: true });
157
+ const temp = `${this.metaPath}.tmp`;
158
+ writeFileSync(temp, JSON.stringify(this.#state, null, 2), "utf8");
159
+ renameSync(temp, this.metaPath);
160
+ }
161
+
162
+ #writeBody(id, raw) {
163
+ mkdirSync(this.bodyDir, { recursive: true });
164
+ const target = this.bodyPath(id);
165
+ const temp = `${target}.tmp`;
166
+ writeFileSync(temp, raw, "utf8");
167
+ renameSync(temp, target);
168
+ }
169
+
170
+ /** Every profile, with the derived flags the UI needs. */
171
+ list() {
172
+ const state = this.load();
173
+ return state.profiles.map((profile) => ({
174
+ ...profile,
175
+ current: profile.id === state.currentId,
176
+ dueAt: profile.source === "url" && profile.autoUpdate === true && typeof profile.lastUpdateAt === "number"
177
+ ? profile.lastUpdateAt + profile.autoUpdateHours * 3600 * 1000
178
+ : null
179
+ }));
180
+ }
181
+
182
+ /** The selected profile's metadata, or null. */
183
+ current() {
184
+ const state = this.load();
185
+ return state.profiles.find((profile) => profile.id === state.currentId) ?? null;
186
+ }
187
+
188
+ get(id) {
189
+ return this.load().profiles.find((profile) => profile.id === id) ?? null;
190
+ }
191
+
192
+ /** The configuration body of one profile, or null when the file is gone. */
193
+ body(id) {
194
+ const path = this.bodyPath(id);
195
+ try {
196
+ return readFileSync(path, "utf8");
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /** The selected profile's body, or null. */
203
+ currentBody() {
204
+ const profile = this.current();
205
+ return profile === null ? null : this.body(profile.id);
206
+ }
207
+
208
+ /** Profiles whose auto-update interval has elapsed. */
209
+ dueProfiles(now = Date.now()) {
210
+ return this.list().filter((profile) => profile.dueAt !== null && profile.dueAt <= now);
211
+ }
212
+
213
+ /**
214
+ * Add a subscription URL.
215
+ * @param options - `label`, `autoUpdateHours`, and the `fetcher` used to download it.
216
+ */
217
+ async addFromUrl(url, { label, autoUpdateHours = DEFAULT_AUTO_UPDATE_HOURS, fetcher, fetchProxyUrl = "" } = {}) {
218
+ if (typeof fetcher !== "function") throw new Error("内部错误:缺少抓取函数");
219
+ const response = await fetcher(url, fetchProxyUrl);
220
+ const raw = typeof response === "string" ? response : response.text;
221
+ const headers = typeof response === "string" ? {} : response.headers ?? {};
222
+ validateConfigText(raw);
223
+ const id = newId();
224
+ this.#writeBody(id, raw);
225
+ const profile = {
226
+ id,
227
+ label: safeLabel(label ?? filenameFromDisposition(headers["content-disposition"]) ?? "", labelFromUrl(url)),
228
+ source: "url",
229
+ url,
230
+ autoUpdate: true,
231
+ autoUpdateHours: clampHours(autoUpdateHours),
232
+ lastUpdateAt: Date.now(),
233
+ addedAt: Date.now(),
234
+ subscriptionInfo: parseSubscriptionUserinfo(headers["subscription-userinfo"])
235
+ };
236
+ const state = this.load();
237
+ state.profiles.push(profile);
238
+ if (state.currentId === null) state.currentId = id;
239
+ this.#persist();
240
+ return profile;
241
+ }
242
+
243
+ /** Add a configuration the user supplied as a file (never auto-updates). */
244
+ addFromFile(name, raw, { label } = {}) {
245
+ validateConfigText(raw);
246
+ const id = newId();
247
+ this.#writeBody(id, raw);
248
+ const fallback = String(name ?? "").replace(/\.[^.]+$/, "") || "本地配置";
249
+ const profile = {
250
+ id,
251
+ label: safeLabel(label ?? fallback, "本地配置"),
252
+ source: "file",
253
+ url: "",
254
+ autoUpdate: false,
255
+ autoUpdateHours: DEFAULT_AUTO_UPDATE_HOURS,
256
+ lastUpdateAt: Date.now(),
257
+ addedAt: Date.now(),
258
+ subscriptionInfo: null,
259
+ fileName: String(name ?? "")
260
+ };
261
+ const state = this.load();
262
+ state.profiles.push(profile);
263
+ if (state.currentId === null) state.currentId = id;
264
+ this.#persist();
265
+ return profile;
266
+ }
267
+
268
+ /** Re-download a URL profile. File profiles are refused, like FlClash. */
269
+ async update(id, { fetcher, fetchProxyUrl = "" } = {}) {
270
+ const profile = this.get(id);
271
+ if (profile === null) throw new Error(`找不到配置:${id}`);
272
+ if (profile.source !== "url" || profile.url.length === 0) throw new Error("本地文件配置不能从网络更新");
273
+ const response = await fetcher(profile.url, fetchProxyUrl);
274
+ const raw = typeof response === "string" ? response : response.text;
275
+ const headers = typeof response === "string" ? {} : response.headers ?? {};
276
+ validateConfigText(raw);
277
+ this.#writeBody(id, raw);
278
+ const fromHeader = filenameFromDisposition(headers["content-disposition"]);
279
+ profile.label = safeLabel(fromHeader ?? profile.label, profile.label);
280
+ profile.lastUpdateAt = Date.now();
281
+ profile.subscriptionInfo = parseSubscriptionUserinfo(headers["subscription-userinfo"]);
282
+ this.#persist();
283
+ return this.get(id);
284
+ }
285
+
286
+ remove(id) {
287
+ const state = this.load();
288
+ const index = state.profiles.findIndex((profile) => profile.id === id);
289
+ if (index === -1) throw new Error(`找不到配置:${id}`);
290
+ state.profiles.splice(index, 1);
291
+ try {
292
+ rmSync(this.bodyPath(id), { force: true });
293
+ } catch {
294
+ // The metadata is the source of truth; a stray body is harmless.
295
+ }
296
+ if (state.currentId === id) state.currentId = state.profiles.length > 0 ? state.profiles[0].id : null;
297
+ this.#persist();
298
+ return this.current();
299
+ }
300
+
301
+ /** Rename a profile (FlClash lets the label be edited). */
302
+ rename(id, label) {
303
+ const profile = this.get(id);
304
+ if (profile === null) throw new Error(`找不到配置:${id}`);
305
+ profile.label = safeLabel(label, profile.label);
306
+ this.#persist();
307
+ return profile;
308
+ }
309
+
310
+ select(id) {
311
+ const state = this.load();
312
+ if (!state.profiles.some((profile) => profile.id === id)) throw new Error(`找不到配置:${id}`);
313
+ state.currentId = id;
314
+ this.#persist();
315
+ return this.current();
316
+ }
317
+
318
+ /** Reorder by an explicit id list; unknown ids are ignored, missing ones appended. */
319
+ reorder(ids) {
320
+ const state = this.load();
321
+ const wanted = Array.isArray(ids) ? ids : [];
322
+ const ordered = [];
323
+ for (const id of wanted) {
324
+ const found = state.profiles.find((profile) => profile.id === id);
325
+ if (found !== undefined && !ordered.includes(found)) ordered.push(found);
326
+ }
327
+ for (const profile of state.profiles) if (!ordered.includes(profile)) ordered.push(profile);
328
+ state.profiles = ordered;
329
+ this.#persist();
330
+ return this.list();
331
+ }
332
+
333
+ /** Toggle/replace the auto-update settings of one profile. */
334
+ setAutoUpdate(id, enabled, hours) {
335
+ const profile = this.get(id);
336
+ if (profile === null) throw new Error(`找不到配置:${id}`);
337
+ if (profile.source !== "url") throw new Error("本地文件配置没有自动更新");
338
+ profile.autoUpdate = enabled !== false;
339
+ if (hours !== undefined) profile.autoUpdateHours = clampHours(hours);
340
+ this.#persist();
341
+ return profile;
342
+ }
343
+
344
+ /** Removed bodies left behind by a crash; keeps the directory tidy. */
345
+ pruneOrphanBodies() {
346
+ const state = this.load();
347
+ const known = new Set(state.profiles.map((profile) => `${profile.id}.yaml`));
348
+ let removed = 0;
349
+ try {
350
+ for (const name of readdirSync(this.bodyDir)) {
351
+ if (known.has(name) || name.endsWith(".tmp")) continue;
352
+ rmSync(join(this.bodyDir, name), { force: true });
353
+ removed += 1;
354
+ }
355
+ } catch {
356
+ // No directory yet.
357
+ }
358
+ return removed;
359
+ }
360
+
361
+ /** True when a metadata file already exists (used for the legacy migration path). */
362
+ get exists() {
363
+ return existsSync(this.metaPath);
364
+ }
365
+ }
366
+
367
+ /** Keep an interval inside a sane range. */
368
+ function clampHours(value) {
369
+ const hours = Number(value);
370
+ if (!Number.isFinite(hours)) return DEFAULT_AUTO_UPDATE_HOURS;
371
+ return Math.min(MAX_AUTO_UPDATE_HOURS, Math.max(MIN_AUTO_UPDATE_HOURS, Math.round(hours)));
372
+ }