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,89 @@
1
+ /*
2
+ * 登录与通行证获取。
3
+ * 登录在论坛域完成(表单 POST),igneous 由画廊站在登录后下发。
4
+ * 出口节点会影响 igneous 是否下发:拿不到时通常需要更换节点后重新登录。
5
+ */
6
+ import { cookiesOf, hasLoginCookies, isIgneousValue, pickLoginCookies, toCookieMap, } from "./cookies.js";
7
+ import { callApi } from "./http.js";
8
+ const FORUM_ORIGIN = "https://forums.e-hentai.org";
9
+ const FORUM_LOGIN_URL = `${FORUM_ORIGIN}/index.php?act=Login&CODE=01`;
10
+ const FORUM_LOGIN_REFERER = `${FORUM_ORIGIN}/index.php?act=Login&CODE=00`;
11
+ /** 表单登录论坛。失败时上游不下发凭据 Cookie,此处据此抛错 */
12
+ export async function forumLogin(credentials) {
13
+ const body = new URLSearchParams({
14
+ referer: FORUM_LOGIN_REFERER,
15
+ b: "",
16
+ bt: "",
17
+ UserName: credentials.username,
18
+ PassWord: credentials.password,
19
+ CookieDate: "1",
20
+ }).toString();
21
+ const response = await callApi({
22
+ url: FORUM_LOGIN_URL,
23
+ method: "POST",
24
+ body,
25
+ headers: {
26
+ "content-type": "application/x-www-form-urlencoded",
27
+ referer: FORUM_LOGIN_REFERER,
28
+ origin: FORUM_ORIGIN,
29
+ },
30
+ });
31
+ const picked = pickLoginCookies(cookiesOf(response));
32
+ if (!hasLoginCookies(picked)) {
33
+ throw new Error("登录失败:未获得凭据 Cookie。账号密码可能有误,或当前出口节点被上游拒绝");
34
+ }
35
+ const result = {
36
+ ipbMemberId: picked.ipbMemberId ?? "",
37
+ ipbPassHash: picked.ipbPassHash ?? "",
38
+ };
39
+ if (picked.ipbSessionId !== undefined) {
40
+ result.ipbSessionId = picked.ipbSessionId;
41
+ }
42
+ return result;
43
+ }
44
+ /**
45
+ * 用凭据 Cookie 访问画廊站以取得 igneous
46
+ * 外站与里站各试一次:不同出口节点下发的域不同
47
+ * 里站一次仅作兜底,连不上(代理不放行、节点不稳)不影响整体流程,因此逐个 try:
48
+ * 两个站点都问不到时返回 null,由调用方决定如何提示
49
+ */
50
+ export async function fetchIgneous(jar, options = {}) {
51
+ const cookies = toCookieMap(jar);
52
+ const targets = [
53
+ { url: "https://e-hentai.org/", site: "e-hentai" },
54
+ { url: "https://exhentai.org/", site: "exhentai" },
55
+ ];
56
+ for (const target of targets) {
57
+ let response;
58
+ try {
59
+ response = await callApi({
60
+ url: target.url,
61
+ site: target.site,
62
+ cookies,
63
+ followRedirects: false,
64
+ });
65
+ }
66
+ catch (error) {
67
+ options.onFailure?.(target.site, error);
68
+ continue;
69
+ }
70
+ const found = cookiesOf(response)["igneous"];
71
+ if (found !== undefined && isIgneousValue(found)) {
72
+ return found;
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+ /** 里站是否可访问:未登录或 igneous 无效时会被 302 到 remoteapi */
78
+ export async function probeExAccess(jar) {
79
+ const response = await callApi({
80
+ url: "https://exhentai.org/",
81
+ site: "exhentai",
82
+ cookies: toCookieMap(jar),
83
+ followRedirects: false,
84
+ });
85
+ if (response.status >= 300 && response.status < 400) {
86
+ return false;
87
+ }
88
+ return response.status === 200 && !response.text.includes("sadpanda");
89
+ }
@@ -0,0 +1,65 @@
1
+ /*
2
+ * Cookie 处理。上游使用 IPB 论坛体系,字段名固定。
3
+ * 仅在服务端使用;解析结果是纯数据,可直接写入 auth_setting。
4
+ */
5
+ /** Set-Cookie 逐条解析为键值。只取第一段,忽略 expires / path 等属性 */
6
+ export function parseSetCookie(lines) {
7
+ const out = {};
8
+ for (const line of lines) {
9
+ const first = line.split(";", 1)[0] ?? "";
10
+ const separator = first.indexOf("=");
11
+ if (separator <= 0) {
12
+ continue;
13
+ }
14
+ const name = first.slice(0, separator).trim();
15
+ const value = first.slice(separator + 1).trim();
16
+ if (name !== "") {
17
+ out[name] = value;
18
+ }
19
+ }
20
+ return out;
21
+ }
22
+ export function cookiesOf(response) {
23
+ return parseSetCookie(response.setCookie);
24
+ }
25
+ /** 只保留对接上游需要的字段,session_id、uconfig 之类丢弃 */
26
+ export function pickLoginCookies(cookies) {
27
+ const picked = {};
28
+ for (const [source, target] of [
29
+ ["ipb_member_id", "ipbMemberId"],
30
+ ["ipb_pass_hash", "ipbPassHash"],
31
+ ["igneous", "igneous"],
32
+ ["ipb_session_id", "ipbSessionId"],
33
+ ]) {
34
+ const value = cookies[source];
35
+ if (value !== undefined) {
36
+ picked[target] = value;
37
+ }
38
+ }
39
+ return picked;
40
+ }
41
+ /** 拿到 member id 与 pass hash 才算登录成功;igneous 可能稍后才下发 */
42
+ export function hasLoginCookies(cookies) {
43
+ return (cookies.ipbMemberId ?? "") !== "" && (cookies.ipbPassHash ?? "") !== "";
44
+ }
45
+ /** 拼成请求头用的对象,空值不发送 */
46
+ export function toCookieMap(jar) {
47
+ const map = {};
48
+ if (jar.ipbMemberId !== undefined && jar.ipbMemberId !== "") {
49
+ map["ipb_member_id"] = jar.ipbMemberId;
50
+ }
51
+ if (jar.ipbPassHash !== undefined && jar.ipbPassHash !== "") {
52
+ map["ipb_pass_hash"] = jar.ipbPassHash;
53
+ }
54
+ if (jar.igneous !== undefined && jar.igneous !== "") {
55
+ map["igneous"] = jar.igneous;
56
+ }
57
+ if (jar.ipbSessionId !== undefined && jar.ipbSessionId !== "") {
58
+ map["ipb_session_id"] = jar.ipbSessionId;
59
+ }
60
+ return map;
61
+ }
62
+ /** igneous 约十几位;旧的 9 位版本已被上游作废,空值与 mystery 均视为无效 */
63
+ export function isIgneousValue(value) {
64
+ return value !== "" && value !== "mystery" && value.length >= 12;
65
+ }
@@ -0,0 +1,94 @@
1
+ /*
2
+ * 收藏相关接口。
3
+ * 收藏与取消收藏走 gallerypopups.php 的表单提交:favcat >= 0 放进对应分类,-1 表示取消。
4
+ * 分类名与数量、分类里的画廊都从 favorites.php 取;需要登录,未登录时上游返回登录页。
5
+ * 页面解析用宽松匹配:上游结构变化时只会解析不到(界面上表现为空),不会中断整个流程。
6
+ */
7
+ import { isLoginRequired, LoginRequiredError, callApi } from "./http.js";
8
+ import { siteOrigin } from "./urls.js";
9
+ function requestOf(options, url, body = "") {
10
+ return {
11
+ url,
12
+ method: "POST",
13
+ site: options.site,
14
+ headers: { "content-type": "application/x-www-form-urlencoded" },
15
+ body,
16
+ ...(options.cookies === undefined ? {} : { cookies: options.cookies }),
17
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
18
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
19
+ };
20
+ }
21
+ function favoritesUrl(site, query = "") {
22
+ return `${siteOrigin(site)}/favorites.php${query}`;
23
+ }
24
+ /**
25
+ * 收藏或取消收藏。
26
+ * 上游返回的是一个 HTML 弹窗;这里只看状态码与是否被重定向到登录页。
27
+ */
28
+ export async function setFavorite(gid, token, slot, options) {
29
+ const url = `${siteOrigin(options.site)}/gallerypopups.php?gid=${gid}` +
30
+ `&t=${encodeURIComponent(token)}&act=addfav`;
31
+ const body = `favcat=${slot}&favnote=&submit=Apply+Changes`;
32
+ const response = await callApi(requestOf(options, url, body));
33
+ if (isLoginRequired(response)) {
34
+ throw new LoginRequiredError("收藏需要登录后操作");
35
+ }
36
+ if (response.status >= 400) {
37
+ throw new Error(`收藏接口返回 HTTP ${response.status}`);
38
+ }
39
+ }
40
+ /**
41
+ * 云端收藏夹的名称与数量。
42
+ * 默认名是 Favorites 0~9,用户改过名字的按页面上的文字;解析不到时返回空列表。
43
+ */
44
+ export async function fetchFavoriteCategories(options) {
45
+ const response = await callApi(requestOf(options, favoritesUrl(options.site)));
46
+ if (isLoginRequired(response)) {
47
+ throw new LoginRequiredError("收藏需要登录后操作");
48
+ }
49
+ if (response.status >= 400) {
50
+ return [];
51
+ }
52
+ const found = new Map();
53
+ // 形如 href="?favcat=0">名称</a> (12)
54
+ const pattern = /favcat=(\d+)[^>]*>([^<]{0,64})<\/a>[\s\S]{0,80}?\((\d{1,7})\)/g;
55
+ for (const matched of response.text.matchAll(pattern)) {
56
+ const slot = Number(matched[1]);
57
+ const raw = matched[2]?.trim() ?? "";
58
+ if (!Number.isInteger(slot) || slot < 0 || slot > 9 || found.has(slot)) {
59
+ continue;
60
+ }
61
+ found.set(slot, {
62
+ slot,
63
+ name: raw === "" ? `Favorites ${slot}` : raw.replaceAll("&amp;", "&"),
64
+ count: Number(matched[3]),
65
+ });
66
+ }
67
+ return [...found.values()].sort((a, b) => a.slot - b.slot);
68
+ }
69
+ /**
70
+ * 某个分类里的画廊。
71
+ * 只取第一页:收藏页同样分页,这里先返回第一页的内容。
72
+ */
73
+ export async function fetchFavoriteGalleryPairs(slot, options) {
74
+ const response = await callApi(requestOf(options, favoritesUrl(options.site, `?favcat=${slot}`)));
75
+ if (isLoginRequired(response)) {
76
+ throw new LoginRequiredError("收藏需要登录后操作");
77
+ }
78
+ if (response.status >= 400) {
79
+ return [];
80
+ }
81
+ const pairs = [];
82
+ const seen = new Set();
83
+ const pattern = /\/g\/(\d+)\/([0-9a-fA-F]{8,})\//g;
84
+ for (const matched of response.text.matchAll(pattern)) {
85
+ const gid = Number(matched[1]);
86
+ const token = matched[2] ?? "";
87
+ if (!Number.isFinite(gid) || token === "" || seen.has(gid)) {
88
+ continue;
89
+ }
90
+ seen.add(gid);
91
+ pairs.push([gid, token]);
92
+ }
93
+ return pairs;
94
+ }
@@ -0,0 +1,158 @@
1
+ /*
2
+ * 站点 HTML 解析。上游只提供 api.php 的元数据接口,列表、详情与图片页需自行解析。
3
+ *
4
+ * 选择器按 2026-09 的页面结构编写。上游改版时本文件会最先失效,因此解析失败一律返回空值,不抛错。
5
+ */
6
+ /** 搜索页返回 451 属正常:上游对部分出口 IP 打该状态码但仍给出结果 */
7
+ export function isUsablePage(status) {
8
+ return status === 200 || status === 451;
9
+ }
10
+ /**
11
+ * 从缩略图格子的 style 里取出精灵图与偏移
12
+ * 形如 width:200px;height:300px;background:transparent url(<图>) -200px 0 no-repeat
13
+ * 旧版上游一页一张图,此时没有偏移,按 0 处理
14
+ */
15
+ function parseThumbCell(inner) {
16
+ const url = /url\((['"]?)([^'")]+)\1\)/.exec(inner)?.[2] ?? "";
17
+ const size = /width:\s*(\d+)px;\s*height:\s*(\d+)px/.exec(inner);
18
+ // 第二个数值常常省略单位,形如 -200px 0
19
+ const offset = /\)\s*(-?\d+)px\s+(-?\d+)(?:px)?/.exec(inner);
20
+ return {
21
+ thumbUrl: decodeEntities(url),
22
+ width: size === null ? 0 : Number(size[1]),
23
+ height: size === null ? 0 : Number(size[2]),
24
+ offsetX: offset === null ? 0 : Number(offset[1]),
25
+ offsetY: offset === null ? 0 : Number(offset[2]),
26
+ };
27
+ }
28
+ export function parseGalleryPage(html, gid) {
29
+ const tagGroups = new Map();
30
+ for (const found of html.matchAll(/<a[^>]*id="td_([a-z]+):[^"]*"[^>]*>([^<]*)<\/a>/g)) {
31
+ const namespace = found[1] ?? "";
32
+ const name = decodeEntities((found[2] ?? "").trim());
33
+ if (namespace === "" || name === "") {
34
+ continue;
35
+ }
36
+ const bucket = tagGroups.get(namespace) ?? [];
37
+ bucket.push(name);
38
+ tagGroups.set(namespace, bucket);
39
+ }
40
+ const previews = [];
41
+ const pageTokens = new Map();
42
+ // 缩略图没有 img 标签:一格是一个 200x300 的 div,背景是整张精灵图,通过 offset 定位到自身那一格
43
+ for (const found of html.matchAll(/<a[^>]*href="https?:\/\/[^"]*\/s\/([0-9a-f]{10})\/(\d+)-(\d+)"[^>]*>([\s\S]{0,600}?)<\/a>/g)) {
44
+ const token = found[1] ?? "";
45
+ const hrefGid = Number(found[2]);
46
+ const page = Number(found[3]);
47
+ const inner = found[4] ?? "";
48
+ if (token === "" || !Number.isInteger(page) || hrefGid !== gid) {
49
+ continue;
50
+ }
51
+ const cell = parseThumbCell(inner);
52
+ if (!pageTokens.has(page)) {
53
+ pageTokens.set(page, token);
54
+ }
55
+ previews.push({ page, ...cell });
56
+ }
57
+ const ratingLabel = textOf(html, /<td[^>]*id="rating_label"[^>]*>([\s\S]{0,200}?)<\/td>/);
58
+ const ratingMatch = /([0-9]+(?:\.[0-9]+)?)/.exec(ratingLabel);
59
+ return {
60
+ title: decodeEntities(textOf(html, /<h1[^>]*id="gn"[^>]*>([\s\S]*?)<\/h1>/)),
61
+ titleJpn: decodeEntities(textOf(html, /<h1[^>]*id="gj"[^>]*>([\s\S]*?)<\/h1>/)),
62
+ category: decodeEntities(textOf(html, /<div[^>]*id="gdc"[^>]*>[\s\S]{0,200}?<a[^>]*>([^<]+)<\/a>/)),
63
+ uploader: decodeEntities(textOf(html, /<div[^>]*id="gdn"[^>]*>([\s\S]{0,200}?)<\/div>/).replace(/\s+<a[\s\S]*$/, "")),
64
+ postedText: textOf(html, /Posted:<\/td>\s*<td[^>]*>([^<]*)</),
65
+ pageCount: toInteger(textOf(html, /(?:Length|File Count):<\/td>\s*<td[^>]*>([^<]*)</)),
66
+ sizeText: textOf(html, /File Size:<\/td>\s*<td[^>]*>([^<]*)</),
67
+ rating: ratingMatch === null ? null : Number(ratingMatch[1]),
68
+ ratingCount: toInteger(textOf(html, /<span[^>]*id="rating_count"[^>]*>([\s\S]{0,80}?)</)) ?? 0,
69
+ favoriteCount: toInteger(textOf(html, /<td[^>]*id="favcount"[^>]*>([\s\S]{0,80}?)</)) ?? 0,
70
+ tagGroups: [...tagGroups].map(([namespace, tags]) => ({ namespace, tags })),
71
+ previewPages: previews,
72
+ archiveAvailable: html.includes("archiver.php"),
73
+ pageTokens,
74
+ };
75
+ }
76
+ export function parseImagePage(html, gid) {
77
+ return {
78
+ showkey: /showkey="([^"]+)"/.exec(html)?.[1] ?? null,
79
+ previousToken: pageTokenOf(html, "prev", gid),
80
+ nextToken: pageTokenOf(html, "next", gid),
81
+ };
82
+ }
83
+ /** 搜索页只取 (gid, token) 对,其余字段由 gdata 补全。结果行的 class 在内层 div 上,故只按 href 匹配 */
84
+ export function parseSearchResults(html) {
85
+ const out = [];
86
+ const seen = new Set();
87
+ for (const found of html.matchAll(/<a[^>]*href="https?:\/\/[^"]*\/g\/(\d+)\/([0-9a-f]{10})\/"/g)) {
88
+ const gid = Number(found[1]);
89
+ const token = found[2] ?? "";
90
+ if (!Number.isInteger(gid) || token === "" || seen.has(gid)) {
91
+ continue;
92
+ }
93
+ seen.add(gid);
94
+ out.push({ gid, token });
95
+ }
96
+ return out;
97
+ }
98
+ export function parseArchiveList(text) {
99
+ let parsed;
100
+ try {
101
+ parsed = JSON.parse(text);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ if (parsed === null || typeof parsed !== "object") {
107
+ return null;
108
+ }
109
+ const record = parsed;
110
+ const list = Array.isArray(record["archives"]) ? record["archives"] : [];
111
+ const options = [];
112
+ for (const item of list) {
113
+ if (item === null || typeof item !== "object") {
114
+ continue;
115
+ }
116
+ const entry = item;
117
+ const resolution = String(entry["res"] ?? entry["dltype"] ?? "");
118
+ if (resolution === "") {
119
+ continue;
120
+ }
121
+ options.push({
122
+ resolution,
123
+ label: String(entry["name"] ?? resolution),
124
+ sizeText: String(entry["size"] ?? ""),
125
+ costText: String(entry["cost"] ?? ""),
126
+ viaHath: entry["hath"] === true || entry["hathdl_xres"] !== undefined,
127
+ });
128
+ }
129
+ return { options, fundsText: String(record["funds"] ?? "") };
130
+ }
131
+ function pageTokenOf(html, direction, gid) {
132
+ const pattern = new RegExp(`<a[^>]*id="${direction}"[^>]*href="https?://[^"]*/s/([0-9a-f]{10})/${gid}-\\d+"`);
133
+ return pattern.exec(html)?.[1] ?? null;
134
+ }
135
+ function textOf(html, pattern) {
136
+ const found = pattern.exec(html);
137
+ if (found === null) {
138
+ return "";
139
+ }
140
+ return (found[1] ?? "")
141
+ .replace(/<[^>]*>/g, "")
142
+ .replace(/&nbsp;/g, " ")
143
+ .trim();
144
+ }
145
+ function toInteger(text) {
146
+ const digits = text.replace(/[^0-9]/g, "");
147
+ return digits === "" ? null : Number(digits);
148
+ }
149
+ function decodeEntities(text) {
150
+ return text
151
+ .replaceAll("&lt;", "<")
152
+ .replaceAll("&gt;", ">")
153
+ .replaceAll("&quot;", '"')
154
+ .replaceAll("&#39;", "'")
155
+ .replaceAll("&amp;", "&")
156
+ .replace(/\s+/g, " ")
157
+ .trim();
158
+ }
@@ -0,0 +1,214 @@
1
+ /*
2
+ * 上游 HTTP 传输层。负责代理、Cookie 注入、请求头、超时与请求节流。
3
+ * 仅依赖 undici 与 Node 内置模块,不引用 api / config / services。
4
+ *
5
+ * 使用 undici 自带的 fetch 而非全局 fetch:Node 内置的是 undici 7,
6
+ * 与本项目安装的 undici 8 之间 handler 接口不一致,外部 dispatcher 会被拒绝
7
+ * (UND_ERR_INVALID_ARG: invalid onRequestStart method)。
8
+ *
9
+ * ProxyAgent 只支持 HTTP/HTTPS 代理,SOCKS5 会直接报错。
10
+ */
11
+ import { ProxyAgent, fetch as undiciFetch } from "undici";
12
+ import { errorCode } from "../platform/errors.js";
13
+ const SITE_ORIGINS = {
14
+ "e-hentai": "https://e-hentai.org",
15
+ exhentai: "https://exhentai.org",
16
+ };
17
+ const DEFAULT_TIMEOUT_MS = 30_000;
18
+ /** 连发上限的兜底值:与配置默认值一致,配置未下发时也按「连发 5 次再等」处理 */
19
+ const DEFAULT_BURST_LIMIT = 5;
20
+ let dispatcher = null;
21
+ let minIntervalMs = 0;
22
+ /** 一批里最多连发几次;发满后等待 minIntervalMs 再开始下一批 */
23
+ let burstLimit = DEFAULT_BURST_LIMIT;
24
+ /** 当前这一批已经发了几次 */
25
+ let sentInBurst = 0;
26
+ let lastStartedAt = 0;
27
+ let queue = Promise.resolve();
28
+ /** 设置代理,参数形如 http://127.0.0.1:7897 */
29
+ export function setProxyAgent(server, username, password) {
30
+ const url = new URL(server);
31
+ if (url.protocol === "socks5:" || url.protocol === "socks:") {
32
+ throw new Error(`ProxyAgent 不支持 SOCKS5 代理:${server}`);
33
+ }
34
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
35
+ throw new Error(`代理协议不支持:${url.protocol}`);
36
+ }
37
+ const options = {
38
+ uri: `${url.protocol}//${url.host}`,
39
+ };
40
+ if (username !== undefined && username !== "") {
41
+ const credentials = Buffer.from(`${username}:${password ?? ""}`).toString("base64");
42
+ options.token = `Basic ${credentials}`;
43
+ }
44
+ dispatcher = new ProxyAgent(options);
45
+ }
46
+ /** 清除代理,恢复直连 */
47
+ export function clearProxyAgent() {
48
+ dispatcher = null;
49
+ }
50
+ /** 当前代理通道;未配置代理为 null。归档下载与本模块共用同一条通道 */
51
+ export function getDispatcher() {
52
+ return dispatcher;
53
+ }
54
+ export function hasProxyAgent() {
55
+ return dispatcher !== null;
56
+ }
57
+ /**
58
+ * 设置限流:连续 burst 次请求之后等 intervalMs 再继续,0 间隔表示不限制
59
+ * 上游建议连续 4~5 次后等待约 5 秒,因此这里限制的是一批之后的那次等待,
60
+ * 而不是每次请求之间都等。每次请求之间都等时,一次操作要发好几条请求(详情要 gdata + 画廊页),
61
+ * 多开几个页面就会排成长队
62
+ */
63
+ export function setRequestInterval(ms, burst = DEFAULT_BURST_LIMIT) {
64
+ minIntervalMs = Math.max(0, ms);
65
+ burstLimit = Math.max(1, Math.trunc(burst));
66
+ }
67
+ /** 发起一次上游请求,经串行队列与最小间隔节流 */
68
+ export async function callApi(request) {
69
+ return schedule(() => send(request));
70
+ }
71
+ /** 同上,并将响应解析为 JSON */
72
+ export async function callApiJson(request) {
73
+ const response = await callApi(request);
74
+ try {
75
+ return JSON.parse(response.text);
76
+ }
77
+ catch (error) {
78
+ throw new Error(`上游返回的不是 JSON(HTTP ${response.status}):${response.text.slice(0, 200)}`, { cause: error });
79
+ }
80
+ }
81
+ /** 跳过空值,拼为 Cookie 头 */
82
+ export function buildCookieHeader(cookies) {
83
+ return Object.entries(cookies)
84
+ .filter(([, value]) => value !== "")
85
+ .map(([key, value]) => `${key}=${value}`)
86
+ .join("; ");
87
+ }
88
+ /** 里站未登录时返回 302 指向 remoteapi,继续跟随会形成重定向循环 */
89
+ export function isExAccessDenied(response) {
90
+ const location = response.headers["location"] ?? "";
91
+ if (response.status >= 300 && response.status < 400) {
92
+ return location.includes("remoteapi.php") || location.includes("?poni=no");
93
+ }
94
+ return response.url.includes("?poni=no");
95
+ }
96
+ /** 需要登录的页面(归档、种子直链等)未登录时 302 到登录页,正文亦为登录表单 */
97
+ export function isLoginRequired(response) {
98
+ const location = response.headers["location"] ?? "";
99
+ if (response.status >= 300 && response.status < 400) {
100
+ return /login|bounce/i.test(location);
101
+ }
102
+ return /<title>[^<]*Login/i.test(response.text.slice(0, 2048));
103
+ }
104
+ /** 登录态不足导致的失败。服务端据此回 401 语义,而非上游不可达 */
105
+ export class LoginRequiredError extends Error {
106
+ constructor(message) {
107
+ super(message);
108
+ this.name = "LoginRequiredError";
109
+ }
110
+ }
111
+ /**
112
+ * 连接阶段失败的重试次数
113
+ * 代理节点不稳时常见「TCP 已连接、TLS 握手被重置」,这类失败意味着请求没有送达上游,
114
+ * 重发是安全的;收到响应之后再出错则不在这里重试
115
+ */
116
+ const CONNECT_ATTEMPTS = 3;
117
+ const RETRY_DELAY_MS = 800;
118
+ /** 只认「连接未建立」这类失败:域名解析、连接被拒、握手被重置、socket 被关闭 */
119
+ function isConnectFailure(error) {
120
+ const code = errorCode(error);
121
+ if (code === "ECONNRESET" ||
122
+ code === "ECONNREFUSED" ||
123
+ code === "ENOTFOUND" ||
124
+ code === "EAI_AGAIN" ||
125
+ code === "EPIPE" ||
126
+ code === "UND_ERR_SOCKET") {
127
+ return true;
128
+ }
129
+ const message = error instanceof Error ? error.message : "";
130
+ return /disconnected before secure TLS|socket hang up|other side closed/i.test(message);
131
+ }
132
+ /** 建连失败时重试;其余错误直接抛出 */
133
+ async function connect(url, init, signal) {
134
+ let lastError = new Error("请求失败");
135
+ for (let attempt = 1; attempt <= CONNECT_ATTEMPTS; attempt += 1) {
136
+ try {
137
+ return await undiciFetch(url, init);
138
+ }
139
+ catch (error) {
140
+ lastError = error;
141
+ if (signal?.aborted === true || !isConnectFailure(error)) {
142
+ throw error;
143
+ }
144
+ if (attempt < CONNECT_ATTEMPTS) {
145
+ await sleep(RETRY_DELAY_MS * attempt);
146
+ }
147
+ }
148
+ }
149
+ throw lastError;
150
+ }
151
+ async function send(request) {
152
+ const site = request.site ?? "e-hentai";
153
+ const headers = {
154
+ referer: `${SITE_ORIGINS[site]}/`,
155
+ origin: SITE_ORIGINS[site],
156
+ ...request.headers,
157
+ };
158
+ if (request.cookies !== undefined) {
159
+ headers["cookie"] = buildCookieHeader(request.cookies);
160
+ }
161
+ if (request.body !== undefined && headers["content-type"] === undefined) {
162
+ headers["content-type"] = "application/json";
163
+ }
164
+ const response = await connect(request.url, {
165
+ method: request.method ?? "GET",
166
+ headers,
167
+ body: request.body,
168
+ redirect: request.followRedirects === true ? "follow" : "manual",
169
+ signal: combineSignals(request.signal, request.timeoutMs ?? DEFAULT_TIMEOUT_MS),
170
+ dispatcher: dispatcher ?? undefined,
171
+ }, request.signal);
172
+ return {
173
+ status: response.status,
174
+ url: response.url,
175
+ headers: Object.fromEntries(response.headers),
176
+ setCookie: response.headers.getSetCookie(),
177
+ text: await response.text(),
178
+ };
179
+ }
180
+ /** 取消信号与超时信号合取。无取消信号时只保留超时 */
181
+ function combineSignals(signal, timeoutMs) {
182
+ const timeout = AbortSignal.timeout(timeoutMs);
183
+ if (signal === undefined) {
184
+ return timeout;
185
+ }
186
+ return AbortSignal.any([signal, timeout]);
187
+ }
188
+ /**
189
+ * 串行队列与连发上限。上游的限流建议是「连续 4~5 次后等待约 5 秒」,
190
+ * 因此不是每次请求之间都等一个间隔:先连发最多 maxSequentialRequests 次,发完这批再等 requestIntervalMs。
191
+ * 等待从「上一批最后一次开始」算起,队列空闲一段时间之后自然不再等待
192
+ */
193
+ function schedule(task) {
194
+ const run = async () => {
195
+ if (sentInBurst >= burstLimit) {
196
+ const wait = minIntervalMs - (Date.now() - lastStartedAt);
197
+ if (wait > 0) {
198
+ await sleep(wait);
199
+ }
200
+ sentInBurst = 0;
201
+ }
202
+ lastStartedAt = Date.now();
203
+ sentInBurst += 1;
204
+ return task();
205
+ };
206
+ const next = queue.then(run, run);
207
+ queue = next.then(() => undefined, () => undefined);
208
+ return next;
209
+ }
210
+ function sleep(ms) {
211
+ return new Promise((resolve) => {
212
+ setTimeout(resolve, ms);
213
+ });
214
+ }
@@ -0,0 +1,13 @@
1
+ /*
2
+ * 上游客户端。
3
+ * http.ts 为传输层,api.ts 为接口封装,types.ts 为上游结构。
4
+ */
5
+ export * from "./http.js";
6
+ export * from "./api.js";
7
+ export * from "./urls.js";
8
+ export * from "./html.js";
9
+ export * from "./cookies.js";
10
+ export * from "./auth.js";
11
+ export * from "./archives.js";
12
+ export * from "./favorites.js";
13
+ export { MAX_ENTRIES_PER_REQUEST, UPSTREAM_API } from "./types.js";
@@ -0,0 +1,11 @@
1
+ /*
2
+ * 上游接口返回的原始结构。字段名与上游一致,归一化由服务层完成
3
+ * 上游常把数字以字符串返回(posted、filecount、rating),此处保留原始类型
4
+ */
5
+ /** 接口地址:外站在 api 子域,里站在 s 子域 */
6
+ export const UPSTREAM_API = {
7
+ "e-hentai": "https://api.e-hentai.org/api.php",
8
+ exhentai: "https://s.exhentai.org/api.php",
9
+ };
10
+ /** 单次请求的条目上限,两个接口一致 */
11
+ export const MAX_ENTRIES_PER_REQUEST = 25;