ehbrowser 1.0.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.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +531 -0
  3. package/dist/api/contract.js +7 -0
  4. package/dist/api/dto/auth.js +5 -0
  5. package/dist/api/dto/common.js +6 -0
  6. package/dist/api/dto/download.js +5 -0
  7. package/dist/api/dto/favorite.js +5 -0
  8. package/dist/api/dto/gallery.js +43 -0
  9. package/dist/api/dto/index.js +6 -0
  10. package/dist/api/dto/library.js +5 -0
  11. package/dist/api/dto/log.js +5 -0
  12. package/dist/api/dto/playlist.js +8 -0
  13. package/dist/api/dto/settings.js +5 -0
  14. package/dist/api/dto/storage.js +5 -0
  15. package/dist/api/dto/translate.js +6 -0
  16. package/dist/api/envelope.js +41 -0
  17. package/dist/api/events.js +20 -0
  18. package/dist/api/index.js +12 -0
  19. package/dist/api/routes.js +516 -0
  20. package/dist/config/atomic.js +85 -0
  21. package/dist/config/auth-setting.js +97 -0
  22. package/dist/config/cli.js +19 -0
  23. package/dist/config/db.js +168 -0
  24. package/dist/config/index.js +48 -0
  25. package/dist/config/json-store.js +219 -0
  26. package/dist/config/migrations.js +100 -0
  27. package/dist/config/schema.js +108 -0
  28. package/dist/config/user-setting.js +21 -0
  29. package/dist/config/validate.js +173 -0
  30. package/dist/eh/api.js +106 -0
  31. package/dist/eh/archives.js +100 -0
  32. package/dist/eh/auth.js +89 -0
  33. package/dist/eh/cookies.js +65 -0
  34. package/dist/eh/favorites.js +94 -0
  35. package/dist/eh/html.js +158 -0
  36. package/dist/eh/http.js +214 -0
  37. package/dist/eh/index.js +13 -0
  38. package/dist/eh/types.js +11 -0
  39. package/dist/eh/urls.js +81 -0
  40. package/dist/main.js +170 -0
  41. package/dist/platform/errors.js +76 -0
  42. package/dist/platform/open-external.js +90 -0
  43. package/dist/platform/os.js +28 -0
  44. package/dist/platform/paths.js +140 -0
  45. package/dist/server.js +1074 -0
  46. package/dist/services/auth-service.js +276 -0
  47. package/dist/services/config-service.js +227 -0
  48. package/dist/services/detail-cache.js +193 -0
  49. package/dist/services/detail-store.js +208 -0
  50. package/dist/services/download-file.js +127 -0
  51. package/dist/services/download-service.js +586 -0
  52. package/dist/services/favorite-service.js +237 -0
  53. package/dist/services/gallery-service.js +403 -0
  54. package/dist/services/local-library.js +471 -0
  55. package/dist/services/log-service.js +139 -0
  56. package/dist/services/playlist-service.js +85 -0
  57. package/dist/services/search-cache.js +78 -0
  58. package/dist/services/storage-service.js +27 -0
  59. package/dist/services/translate-service.js +208 -0
  60. package/dist/services/update-service.js +268 -0
  61. package/dist/services/upstream-service.js +64 -0
  62. package/dist/services/zip.js +210 -0
  63. package/dist/web/assets/index-C0tAHpmx.css +1 -0
  64. package/dist/web/assets/index-myYEnJ1q.js +4 -0
  65. package/dist/web/index.html +13 -0
  66. package/package.json +63 -0
@@ -0,0 +1,100 @@
1
+ /*
2
+ * 版本迁移。约定:迁移前备份;文件版本高于程序版本时只读;migrate 为纯函数。
3
+ * 只补「这一版新增且无法从旧值推出」的字段,缺失字段另有默认值层兜底。
4
+ */
5
+ import { AUTH_SETTING_VERSION, USER_SETTING_VERSION } from "./schema.js";
6
+ /** 结构变更示例:
7
+ * { from: 1, to: 2, migrate: (data) => ({ ...data, schemaVersion: 2, 新字段: 默认值 }) }
8
+ */
9
+ export const USER_SETTING_MIGRATIONS = [
10
+ {
11
+ // v2 为播放器补上缓存上限、并发加载与自动播放设置,另加翻译开关
12
+ from: 1,
13
+ to: 2,
14
+ migrate: (data) => {
15
+ const viewer = (data["viewer"] ?? {});
16
+ return {
17
+ ...data,
18
+ schemaVersion: 2,
19
+ viewer: {
20
+ ...viewer,
21
+ ...(viewer["maxCacheMb"] === undefined ? { maxCacheMb: 512 } : {}),
22
+ ...(viewer["maxConcurrentLoads"] === undefined
23
+ ? { maxConcurrentLoads: 3 }
24
+ : {}),
25
+ ...(viewer["autoplay"] === undefined
26
+ ? { autoplay: { enabled: false, intervalSeconds: 5, loop: false } }
27
+ : {}),
28
+ },
29
+ ...(data["translate"] === undefined ? { translate: { tags: false } } : {}),
30
+ };
31
+ },
32
+ },
33
+ {
34
+ // v3 增加紧急避险:默认开启提示,跳转地址为空白页
35
+ from: 2,
36
+ to: 3,
37
+ migrate: (data) => ({
38
+ ...data,
39
+ schemaVersion: 3,
40
+ ...(data["safety"] === undefined
41
+ ? { safety: { hintEnabled: true, url: "about:blank" } }
42
+ : {}),
43
+ }),
44
+ },
45
+ {
46
+ // v4 增加日志:默认写入文件,目录留空时使用默认的 ~/ehbrowser/logs
47
+ from: 3,
48
+ to: 4,
49
+ migrate: (data) => ({
50
+ ...data,
51
+ schemaVersion: 4,
52
+ ...(data["log"] === undefined ? { log: { enabled: true, directory: "" } } : {}),
53
+ }),
54
+ },
55
+ {
56
+ // v5 增加详情页缓存条数:默认缓存 20 个画廊,0 表示不缓存
57
+ from: 4,
58
+ to: 5,
59
+ migrate: (data) => {
60
+ const ui = (data["ui"] ?? {});
61
+ return {
62
+ ...data,
63
+ schemaVersion: 5,
64
+ ui: {
65
+ ...ui,
66
+ ...(ui["cachedGalleries"] === undefined ? { cachedGalleries: 20 } : {}),
67
+ },
68
+ };
69
+ },
70
+ },
71
+ ];
72
+ export const AUTH_SETTING_MIGRATIONS = [];
73
+ export const CURRENT_VERSIONS = {
74
+ user: USER_SETTING_VERSION,
75
+ auth: AUTH_SETTING_VERSION,
76
+ };
77
+ export function migrationsFor(file) {
78
+ return file === "user" ? USER_SETTING_MIGRATIONS : AUTH_SETTING_MIGRATIONS;
79
+ }
80
+ /** 将 data.schemaVersion 升至 target。缺失步骤直接抛错,避免写入不完整数据 */
81
+ export function runMigrations(data, target, migrations) {
82
+ let current = data;
83
+ let version = readVersion(current);
84
+ const applied = [];
85
+ while (version < target) {
86
+ const step = migrations.find((m) => m.from === version);
87
+ if (step === undefined) {
88
+ throw new Error(`没有 v${version} → 更上层的迁移步骤,代码里的版本是 v${target}`);
89
+ }
90
+ current = step.migrate(current);
91
+ version = step.to;
92
+ applied.push(step.to);
93
+ }
94
+ return { data: current, applied };
95
+ }
96
+ /** 无法读取版本号时按 1 处理 */
97
+ export function readVersion(data) {
98
+ const raw = data["schemaVersion"];
99
+ return typeof raw === "number" && Number.isInteger(raw) && raw >= 1 ? raw : 1;
100
+ }
@@ -0,0 +1,108 @@
1
+ /*
2
+ * 类型、默认值与字段表的唯一定义处。
3
+ * 字段变更涉及三处:本文件的类型/默认值/字段表、版本号、migrations.ts 中的迁移步骤。
4
+ */
5
+ export const USER_SETTING_VERSION = 5;
6
+ export const USER_SETTING_DEFAULTS = {
7
+ schemaVersion: USER_SETTING_VERSION,
8
+ locale: "zh-CN",
9
+ preferredSite: "e-hentai",
10
+ network: {
11
+ requestIntervalMs: 5000,
12
+ maxSequentialRequests: 5,
13
+ requestTimeoutMs: 30000,
14
+ proxy: {
15
+ enabled: false,
16
+ protocol: "http",
17
+ host: "127.0.0.1",
18
+ port: 7897,
19
+ },
20
+ },
21
+ viewer: {
22
+ mode: "mpv",
23
+ imageQuality: "org",
24
+ preloadCount: 2,
25
+ maxCacheMb: 512,
26
+ maxConcurrentLoads: 3,
27
+ autoplay: {
28
+ enabled: false,
29
+ intervalSeconds: 5,
30
+ loop: false,
31
+ },
32
+ },
33
+ translate: {
34
+ tags: false,
35
+ },
36
+ safety: {
37
+ // 默认落到空白页:未配置地点时,按快捷键也能立即替换屏幕内容
38
+ hintEnabled: true,
39
+ url: "about:blank",
40
+ },
41
+ download: {
42
+ directory: "",
43
+ keepArchive: true,
44
+ preferredResolution: "org",
45
+ concurrency: 2,
46
+ },
47
+ log: {
48
+ enabled: true,
49
+ // 空字符串有效:表示用默认目录
50
+ directory: "",
51
+ },
52
+ ui: {
53
+ theme: "system",
54
+ thumbnailSize: 250,
55
+ pageSize: 25,
56
+ cachedGalleries: 20,
57
+ },
58
+ };
59
+ export const USER_SETTING_FIELDS = [
60
+ { path: "schemaVersion", kind: "int", min: 1 },
61
+ { path: "locale", kind: "string", minLength: 2, maxLength: 20 },
62
+ { path: "preferredSite", kind: "enum", values: ["e-hentai", "exhentai"] },
63
+ { path: "network.requestIntervalMs", kind: "int", min: 1000, max: 60000 },
64
+ { path: "network.maxSequentialRequests", kind: "int", min: 1, max: 25 },
65
+ { path: "network.requestTimeoutMs", kind: "int", min: 1000, max: 300000 },
66
+ { path: "network.proxy.enabled", kind: "boolean" },
67
+ { path: "network.proxy.protocol", kind: "enum", values: ["http", "socks5"] },
68
+ { path: "network.proxy.host", kind: "string", minLength: 1, maxLength: 255 },
69
+ { path: "network.proxy.port", kind: "int", min: 1, max: 65535 },
70
+ { path: "viewer.mode", kind: "enum", values: ["mpv", "single"] },
71
+ { path: "viewer.imageQuality", kind: "enum", values: ["org", "res"] },
72
+ { path: "viewer.preloadCount", kind: "int", min: 0, max: 20 },
73
+ { path: "viewer.maxCacheMb", kind: "int", min: 64, max: 8192 },
74
+ { path: "viewer.maxConcurrentLoads", kind: "int", min: 1, max: 8 },
75
+ { path: "viewer.autoplay.enabled", kind: "boolean" },
76
+ { path: "viewer.autoplay.intervalSeconds", kind: "int", min: 1, max: 120 },
77
+ { path: "viewer.autoplay.loop", kind: "boolean" },
78
+ { path: "translate.tags", kind: "boolean" },
79
+ { path: "safety.hintEnabled", kind: "boolean" },
80
+ // 空字符串有效:表示未设置,按快捷键时落到空白页
81
+ { path: "safety.url", kind: "string", maxLength: 4096 },
82
+ // 空字符串有效,表示尚未设置
83
+ { path: "download.directory", kind: "string", maxLength: 4096 },
84
+ { path: "download.keepArchive", kind: "boolean" },
85
+ { path: "download.preferredResolution", kind: "string", minLength: 1, maxLength: 16 },
86
+ { path: "download.concurrency", kind: "int", min: 1, max: 8 },
87
+ { path: "log.enabled", kind: "boolean" },
88
+ // 空字符串有效,表示用默认目录
89
+ { path: "log.directory", kind: "string", maxLength: 4096 },
90
+ { path: "ui.theme", kind: "enum", values: ["system", "light", "dark"] },
91
+ { path: "ui.thumbnailSize", kind: "int", min: 100, max: 1000 },
92
+ { path: "ui.pageSize", kind: "int", min: 5, max: 100 },
93
+ { path: "ui.cachedGalleries", kind: "int", min: 0, max: 500 },
94
+ ];
95
+ export const AUTH_SETTING_VERSION = 1;
96
+ export const AUTH_SETTING_DEFAULTS = {
97
+ schemaVersion: AUTH_SETTING_VERSION,
98
+ activeAccountId: null,
99
+ accounts: [],
100
+ proxyAuth: { username: "", password: "" },
101
+ };
102
+ /** accounts 元素不在此校验,结构可变,由 auth-setting.ts 逐项校验 */
103
+ export const AUTH_SETTING_FIELDS = [
104
+ { path: "schemaVersion", kind: "int", min: 1 },
105
+ { path: "activeAccountId", kind: "string", optional: true },
106
+ { path: "proxyAuth.username", kind: "string", maxLength: 255 },
107
+ { path: "proxyAuth.password", kind: "string", maxLength: 255 },
108
+ ];
@@ -0,0 +1,21 @@
1
+ /* user_setting.json 的接线,不含业务逻辑 */
2
+ import { join } from "node:path";
3
+ import { getPaths } from "../platform/paths.js";
4
+ import { createJsonStore } from "./json-store.js";
5
+ import { USER_SETTING_MIGRATIONS } from "./migrations.js";
6
+ import { USER_SETTING_DEFAULTS, USER_SETTING_FIELDS } from "./schema.js";
7
+ export const USER_SETTING_FILENAME = "user_setting.json";
8
+ export function userSettingFile(configDir = getPaths().configDir) {
9
+ return join(configDir, USER_SETTING_FILENAME);
10
+ }
11
+ /** 权限 0644。文件不含密钥,用户可以直接编辑或分享。 */
12
+ export function createUserSettingStore(options = {}) {
13
+ return createJsonStore({
14
+ file: options.file ?? userSettingFile(),
15
+ defaults: USER_SETTING_DEFAULTS,
16
+ fields: USER_SETTING_FIELDS,
17
+ migrations: USER_SETTING_MIGRATIONS,
18
+ mode: 0o644,
19
+ logger: options.logger,
20
+ });
21
+ }
@@ -0,0 +1,173 @@
1
+ /*
2
+ * 字段级校验与对象工具。
3
+ * 仅处理结构:类型、枚举、范围。跨字段约束(如启用代理需填写主机)属于 services 层。
4
+ */
5
+ const INVALID = { ok: false, issues: [] };
6
+ export function isPlainObject(value) {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+ /** 读取点分路径,不存在时返回 undefined。 */
10
+ export function getAtPath(root, path) {
11
+ let current = root;
12
+ for (const key of path.split(".")) {
13
+ if (!isPlainObject(current)) {
14
+ return undefined;
15
+ }
16
+ current = current[key];
17
+ }
18
+ return current;
19
+ }
20
+ /** 写入点分路径,缺失的中间对象自动创建 */
21
+ export function setAtPath(root, path, value) {
22
+ const keys = path.split(".");
23
+ const last = keys.pop();
24
+ if (last === undefined) {
25
+ return;
26
+ }
27
+ let current = root;
28
+ for (const key of keys) {
29
+ const next = current[key];
30
+ if (!isPlainObject(next)) {
31
+ const created = {};
32
+ current[key] = created;
33
+ current = created;
34
+ }
35
+ else {
36
+ current = next;
37
+ }
38
+ }
39
+ current[last] = value;
40
+ }
41
+ /** 深合并:仅覆盖 overlay 中出现的键。数组整体替换,不逐项合并 */
42
+ export function deepMerge(base, overlay) {
43
+ if (!isPlainObject(base) || !isPlainObject(overlay)) {
44
+ return (overlay === undefined ? base : overlay);
45
+ }
46
+ const out = { ...base };
47
+ for (const [key, value] of Object.entries(overlay)) {
48
+ out[key] = key in out ? deepMerge(out[key], value) : value;
49
+ }
50
+ return out;
51
+ }
52
+ /** 返回 patch 中未声明的路径。未声明字段会被原样写入配置文件,因此在入口拦截 */
53
+ export function collectUnknownPaths(patch, known, prefix = "") {
54
+ if (!isPlainObject(patch) || !isPlainObject(known)) {
55
+ return [];
56
+ }
57
+ const out = [];
58
+ for (const [key, value] of Object.entries(patch)) {
59
+ const path = prefix === "" ? key : `${prefix}.${key}`;
60
+ if (!(key in known)) {
61
+ out.push(path);
62
+ continue;
63
+ }
64
+ out.push(...collectUnknownPaths(value, known[key], path));
65
+ }
66
+ return out;
67
+ }
68
+ /** 一次校验一组字段 */
69
+ export function validateFields(value, fields) {
70
+ const issues = [];
71
+ for (const field of fields) {
72
+ const current = getAtPath(value, field.path);
73
+ if (current === undefined || current === null) {
74
+ if (field.optional !== true) {
75
+ issues.push({ path: field.path, code: "missing", message: "缺少该字段" });
76
+ }
77
+ continue;
78
+ }
79
+ const issue = checkField(field, current);
80
+ if (issue !== null) {
81
+ issues.push(issue);
82
+ }
83
+ }
84
+ return issues.length === 0 ? { ok: true, value: value } : { ok: false, issues };
85
+ }
86
+ /** 坏值就地替换为默认值,返回替换项。用于配置损坏时仍可启动。 */
87
+ export function repairFields(target, defaults, fields) {
88
+ const issues = [];
89
+ for (const field of fields) {
90
+ const current = getAtPath(target, field.path);
91
+ if (current === undefined || current === null) {
92
+ if (field.optional !== true) {
93
+ issues.push({ path: field.path, code: "missing", message: "缺少该字段" });
94
+ const fallback = getAtPath(defaults, field.path);
95
+ if (fallback !== undefined) {
96
+ setAtPath(target, field.path, fallback);
97
+ }
98
+ }
99
+ continue;
100
+ }
101
+ const issue = checkField(field, current);
102
+ if (issue === null) {
103
+ continue;
104
+ }
105
+ issues.push(issue);
106
+ const fallback = getAtPath(defaults, field.path);
107
+ if (fallback !== undefined) {
108
+ setAtPath(target, field.path, fallback);
109
+ }
110
+ }
111
+ return issues;
112
+ }
113
+ function checkField(field, current) {
114
+ const bad = (code, message) => ({
115
+ path: field.path,
116
+ code,
117
+ message,
118
+ });
119
+ switch (field.kind) {
120
+ case "string": {
121
+ if (typeof current !== "string") {
122
+ return bad("type", "类型应为字符串");
123
+ }
124
+ if (field.minLength !== undefined && current.length < field.minLength) {
125
+ return bad("range", `长度不应小于 ${field.minLength}`);
126
+ }
127
+ if (field.maxLength !== undefined && current.length > field.maxLength) {
128
+ return bad("range", `长度不应超过 ${field.maxLength}`);
129
+ }
130
+ return null;
131
+ }
132
+ case "int":
133
+ case "number": {
134
+ if (typeof current !== "number" || !Number.isFinite(current)) {
135
+ return bad("type", "类型应为数字");
136
+ }
137
+ if (field.kind === "int" && !Number.isInteger(current)) {
138
+ return bad("type", "类型应为整数");
139
+ }
140
+ if (field.min !== undefined && current < field.min) {
141
+ return bad("range", `数值不应小于 ${field.min}`);
142
+ }
143
+ if (field.max !== undefined && current > field.max) {
144
+ return bad("range", `数值不应大于 ${field.max}`);
145
+ }
146
+ return null;
147
+ }
148
+ case "boolean": {
149
+ return typeof current === "boolean" ? null : bad("type", "类型应为布尔值");
150
+ }
151
+ case "enum": {
152
+ const values = field.values ?? [];
153
+ if (typeof current !== "string" || !values.includes(current)) {
154
+ return bad("enum", `取值必须是 ${values.join(" / ")}`);
155
+ }
156
+ return null;
157
+ }
158
+ case "stringArray": {
159
+ if (!Array.isArray(current) || current.some((item) => typeof item !== "string")) {
160
+ return bad("type", "类型应为字符串数组");
161
+ }
162
+ return null;
163
+ }
164
+ case "enumArray": {
165
+ const values = field.values ?? [];
166
+ if (!Array.isArray(current) ||
167
+ current.some((item) => !values.includes(item))) {
168
+ return bad("enum", `元素取值必须是 ${values.join(" / ")}`);
169
+ }
170
+ return null;
171
+ }
172
+ }
173
+ }
package/dist/eh/api.js ADDED
@@ -0,0 +1,106 @@
1
+ /*
2
+ * 上游接口封装:gdata / gtoken / showpage。
3
+ * 请求体与响应字段名保持上游原样;单次上限 25 条,超出自动分批。
4
+ * 图片地址来自 showpage 的片段解析,静态 HTML 里没有。
5
+ */
6
+ import { callApiJson } from "./http.js";
7
+ import { MAX_ENTRIES_PER_REQUEST, UPSTREAM_API, } from "./types.js";
8
+ /** 图库元数据。返回顺序与入参一致,失败条目带 error 字段而非抛错 */
9
+ export async function gdata(pairs, options = {}) {
10
+ const collected = [];
11
+ for (const part of chunked(pairs, MAX_ENTRIES_PER_REQUEST)) {
12
+ const response = await callApiJson({
13
+ ...requestOf(options),
14
+ body: JSON.stringify({
15
+ method: "gdata",
16
+ gidlist: part.map(([gid, token]) => [gid, token]),
17
+ // 带上命名空间前缀,返回形如 "female:stockings"
18
+ namespace: 1,
19
+ }),
20
+ });
21
+ if (response.gmetadata === undefined) {
22
+ throw new Error("gdata 响应缺少 gmetadata");
23
+ }
24
+ collected.push(...response.gmetadata);
25
+ }
26
+ return collected;
27
+ }
28
+ /** 由图片页链接反查画廊 token。返回顺序与入参一致,失败条目带 error 字段 */
29
+ export async function gtoken(pages, options = {}) {
30
+ const collected = [];
31
+ for (const part of chunked(pages, MAX_ENTRIES_PER_REQUEST)) {
32
+ const response = await callApiJson({
33
+ ...requestOf(options),
34
+ body: JSON.stringify({
35
+ method: "gtoken",
36
+ pagelist: part.map(([gid, pageToken, page]) => [gid, pageToken, page]),
37
+ }),
38
+ });
39
+ if (response.tokenlist === undefined) {
40
+ throw new Error("gtoken 响应缺少 tokenlist");
41
+ }
42
+ collected.push(...response.tokenlist);
43
+ }
44
+ return collected;
45
+ }
46
+ /** 取单页图片地址。上游以 error 字段报错时抛出 */
47
+ export async function showpage(input, options = {}) {
48
+ const response = await callApiJson({
49
+ ...requestOf(options),
50
+ body: JSON.stringify({
51
+ method: "showpage",
52
+ gid: input.gid,
53
+ page: input.page,
54
+ imgkey: input.imgkey,
55
+ showkey: input.showkey,
56
+ }),
57
+ });
58
+ if (response.error !== undefined) {
59
+ throw new Error(`showpage 失败:${response.error}`);
60
+ }
61
+ const imageUrl = matchGroup(response.i3 ?? "", /<img[^>]*src="([^"]+)" style/);
62
+ if (imageUrl === null) {
63
+ throw new Error("showpage 响应中未找到图片地址");
64
+ }
65
+ const origin = (response.i7 ?? "").match(/<a href="([^"]+)fullimg\.php([^"]+)">/);
66
+ const skipHathKey = matchGroup(response.i6 ?? "", /onclick="return nl\('([^)]+)'\)/);
67
+ return {
68
+ imageUrl: decodeEntities(imageUrl),
69
+ originalImageUrl: origin === null ? null : decodeEntities(`${origin[1]}fullimg.php${origin[2]}`),
70
+ skipHathKey: skipHathKey === null ? null : decodeEntities(skipHathKey),
71
+ };
72
+ }
73
+ function requestOf(options) {
74
+ const site = options.site ?? "e-hentai";
75
+ const request = { url: UPSTREAM_API[site], method: "POST", site };
76
+ if (options.cookies !== undefined) {
77
+ request.cookies = options.cookies;
78
+ }
79
+ if (options.timeoutMs !== undefined) {
80
+ request.timeoutMs = options.timeoutMs;
81
+ }
82
+ if (options.signal !== undefined) {
83
+ request.signal = options.signal;
84
+ }
85
+ return request;
86
+ }
87
+ function chunked(items, size) {
88
+ const out = [];
89
+ for (let index = 0; index < items.length; index += size) {
90
+ out.push(items.slice(index, index + size));
91
+ }
92
+ return out.length === 0 ? [] : out;
93
+ }
94
+ function matchGroup(text, pattern) {
95
+ const found = pattern.exec(text);
96
+ return found === null ? null : (found[1] ?? null);
97
+ }
98
+ /** 上游片段里的 XML 实体 */
99
+ function decodeEntities(text) {
100
+ return text
101
+ .replaceAll("&lt;", "<")
102
+ .replaceAll("&gt;", ">")
103
+ .replaceAll("&quot;", '"')
104
+ .replaceAll("&#39;", "'")
105
+ .replaceAll("&amp;", "&");
106
+ }
@@ -0,0 +1,100 @@
1
+ /*
2
+ * 归档相关接口:列表与下载地址。
3
+ *
4
+ * 流程分两步:先取列表(含各分辨率与账户资金),再按分辨率请求下载地址。
5
+ *
6
+ * 需要登录。未登录时上游返回登录页而非 JSON。
7
+ */
8
+ import { parseArchiveList } from "./html.js";
9
+ import { callApi, isLoginRequired, LoginRequiredError } from "./http.js";
10
+ import { siteOrigin } from "./urls.js";
11
+ function archiverUrl(site, gid, token) {
12
+ return `${siteOrigin(site)}/archiver.php?gid=${gid}&token=${encodeURIComponent(token)}`;
13
+ }
14
+ function requestOf(options, url) {
15
+ const base = {
16
+ url,
17
+ method: "POST",
18
+ site: options.site,
19
+ headers: { "content-type": "application/x-www-form-urlencoded" },
20
+ body: "",
21
+ };
22
+ return {
23
+ ...base,
24
+ ...(options.cookies === undefined ? {} : { cookies: options.cookies }),
25
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
26
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
27
+ };
28
+ }
29
+ /** 归档列表。解析失败时抛错,调用方据此判断是否需要登录 */
30
+ export async function fetchArchiveCatalog(gid, token, options) {
31
+ const response = await callApi(requestOf(options, archiverUrl(options.site, gid, token)));
32
+ if (isLoginRequired(response)) {
33
+ throw new LoginRequiredError("归档列表需要登录后查看");
34
+ }
35
+ const parsed = parseArchiveList(response.text);
36
+ if (parsed === null) {
37
+ throw new Error(`归档列表解析失败(HTTP ${response.status}),通常表示未登录或会话已失效`);
38
+ }
39
+ return parsed;
40
+ }
41
+ export function buildArchiveForm(resolution, viaHath) {
42
+ if (viaHath) {
43
+ // H@H 下载器:由 H@H 客户端在后台取回,不直接给下载地址
44
+ return { hathdl_xres: resolution };
45
+ }
46
+ return {
47
+ dltype: resolution,
48
+ dlcheck: resolution === "org" ? "Download Original Archive" : "Download Resample Archive",
49
+ };
50
+ }
51
+ /**
52
+ * 请求归档下载地址。普通下载返回直链;H@H 下载返回 null(由客户端另行取回)
53
+ * 服务端打包需要时间,首次可能拿不到地址,故允许重试
54
+ */
55
+ export async function resolveArchiveUrl(gid, token, resolution, viaHath, options) {
56
+ if (viaHath) {
57
+ const response = await callApi({
58
+ ...requestOf(options, archiverUrl(options.site, gid, token)),
59
+ body: new URLSearchParams(buildArchiveForm(resolution, true)).toString(),
60
+ });
61
+ if (isLoginRequired(response)) {
62
+ throw new LoginRequiredError("H@H 下载需要登录,请在账号页登录后重试");
63
+ }
64
+ if (response.status !== 200) {
65
+ throw new Error(`H@H 下载请求失败:HTTP ${response.status}`);
66
+ }
67
+ return null;
68
+ }
69
+ const body = new URLSearchParams(buildArchiveForm(resolution, false)).toString();
70
+ const response = await callApi({
71
+ ...requestOf(options, archiverUrl(options.site, gid, token)),
72
+ body,
73
+ });
74
+ if (isLoginRequired(response)) {
75
+ throw new LoginRequiredError("归档下载需要登录,请在账号页登录后重试");
76
+ }
77
+ const url = extractUrl(response.text);
78
+ if (url === null) {
79
+ throw new Error(`未取到归档地址(HTTP ${response.status}):${response.text.slice(0, 120)}`);
80
+ }
81
+ return url;
82
+ }
83
+ /** 响应可能是 JSON(含 url 字段)或直接的地址文本 */
84
+ function extractUrl(text) {
85
+ const trimmed = text.trim();
86
+ if (trimmed.startsWith("{")) {
87
+ try {
88
+ const parsed = JSON.parse(trimmed);
89
+ const candidate = parsed["url"] ?? parsed["archive_url"] ?? parsed["download"];
90
+ if (typeof candidate === "string" && candidate.startsWith("http")) {
91
+ return candidate;
92
+ }
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ return null;
98
+ }
99
+ return trimmed.startsWith("http") ? trimmed : null;
100
+ }