@zenalexa/unicli 0.220.0 → 0.220.1
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/AGENTS.md +10 -19
- package/README.md +17 -11
- package/README.zh-CN.md +17 -11
- package/dist/adapters/anilist/web.d.ts +11 -0
- package/dist/adapters/anilist/web.d.ts.map +1 -0
- package/dist/adapters/anilist/web.js +284 -0
- package/dist/adapters/anilist/web.js.map +1 -0
- package/dist/adapters/bangumi/web.d.ts +14 -0
- package/dist/adapters/bangumi/web.d.ts.map +1 -0
- package/dist/adapters/bangumi/web.js +257 -0
- package/dist/adapters/bangumi/web.js.map +1 -0
- package/dist/adapters/dlsite/web.d.ts +31 -0
- package/dist/adapters/dlsite/web.d.ts.map +1 -0
- package/dist/adapters/dlsite/web.js +455 -0
- package/dist/adapters/dlsite/web.js.map +1 -0
- package/dist/adapters/ehentai/web.d.ts +66 -0
- package/dist/adapters/ehentai/web.d.ts.map +1 -0
- package/dist/adapters/ehentai/web.js +608 -0
- package/dist/adapters/ehentai/web.js.map +1 -0
- package/dist/adapters/jikan/web.d.ts +9 -0
- package/dist/adapters/jikan/web.d.ts.map +1 -0
- package/dist/adapters/jikan/web.js +154 -0
- package/dist/adapters/jikan/web.js.map +1 -0
- package/dist/adapters/kitsu/web.d.ts +9 -0
- package/dist/adapters/kitsu/web.d.ts.map +1 -0
- package/dist/adapters/kitsu/web.js +97 -0
- package/dist/adapters/kitsu/web.js.map +1 -0
- package/dist/adapters/mangadex/web.d.ts +10 -0
- package/dist/adapters/mangadex/web.d.ts.map +1 -0
- package/dist/adapters/mangadex/web.js +188 -0
- package/dist/adapters/mangadex/web.js.map +1 -0
- package/dist/adapters/moegirl/web.d.ts +23 -0
- package/dist/adapters/moegirl/web.d.ts.map +1 -0
- package/dist/adapters/moegirl/web.js +269 -0
- package/dist/adapters/moegirl/web.js.map +1 -0
- package/dist/adapters/safebooru/web.d.ts +10 -0
- package/dist/adapters/safebooru/web.d.ts.map +1 -0
- package/dist/adapters/safebooru/web.js +120 -0
- package/dist/adapters/safebooru/web.js.map +1 -0
- package/dist/adapters/vndb/web.d.ts +10 -0
- package/dist/adapters/vndb/web.d.ts.map +1 -0
- package/dist/adapters/vndb/web.js +321 -0
- package/dist/adapters/vndb/web.js.map +1 -0
- package/dist/agents/codex-pack.d.ts +62 -0
- package/dist/agents/codex-pack.d.ts.map +1 -0
- package/dist/agents/codex-pack.js +163 -0
- package/dist/agents/codex-pack.js.map +1 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +6 -43
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/browser/adapter.d.ts.map +1 -1
- package/dist/commands/browser/adapter.js +17 -3
- package/dist/commands/browser/adapter.js.map +1 -1
- package/dist/commands/describe.d.ts.map +1 -1
- package/dist/commands/describe.js +6 -7
- package/dist/commands/describe.js.map +1 -1
- package/dist/commands/dispatch.d.ts +1 -1
- package/dist/commands/dispatch.d.ts.map +1 -1
- package/dist/commands/dispatch.js +4 -2
- package/dist/commands/dispatch.js.map +1 -1
- package/dist/commands/mcp.d.ts +1 -1
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +10 -5
- package/dist/commands/mcp.js.map +1 -1
- package/dist/core/command-contract-lint.d.ts +10 -0
- package/dist/core/command-contract-lint.d.ts.map +1 -0
- package/dist/core/command-contract-lint.js +41 -0
- package/dist/core/command-contract-lint.js.map +1 -0
- package/dist/core/command-contract.d.ts +100 -0
- package/dist/core/command-contract.d.ts.map +1 -0
- package/dist/core/command-contract.js +174 -0
- package/dist/core/command-contract.js.map +1 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/discovery/aliases.d.ts +2 -2
- package/dist/discovery/aliases.d.ts.map +1 -1
- package/dist/discovery/aliases.js +464 -6
- package/dist/discovery/aliases.js.map +1 -1
- package/dist/discovery/search.d.ts.map +1 -1
- package/dist/discovery/search.js +147 -2
- package/dist/discovery/search.js.map +1 -1
- package/dist/engine/args.d.ts.map +1 -1
- package/dist/engine/args.js +18 -1
- package/dist/engine/args.js.map +1 -1
- package/dist/engine/artifact-validation.d.ts +29 -0
- package/dist/engine/artifact-validation.d.ts.map +1 -0
- package/dist/engine/artifact-validation.js +211 -0
- package/dist/engine/artifact-validation.js.map +1 -0
- package/dist/engine/browser/diagnostics.d.ts +38 -0
- package/dist/engine/browser/diagnostics.d.ts.map +1 -0
- package/dist/engine/browser/diagnostics.js +40 -0
- package/dist/engine/browser/diagnostics.js.map +1 -0
- package/dist/engine/invoke.d.ts +1 -0
- package/dist/engine/invoke.d.ts.map +1 -1
- package/dist/engine/invoke.js +1 -0
- package/dist/engine/invoke.js.map +1 -1
- package/dist/engine/kernel/errors.d.ts +11 -0
- package/dist/engine/kernel/errors.d.ts.map +1 -0
- package/dist/engine/kernel/errors.js +15 -0
- package/dist/engine/kernel/errors.js.map +1 -0
- package/dist/engine/kernel/execute.d.ts +7 -18
- package/dist/engine/kernel/execute.d.ts.map +1 -1
- package/dist/engine/kernel/execute.js +25 -410
- package/dist/engine/kernel/execute.js.map +1 -1
- package/dist/engine/kernel/stages.d.ts +44 -0
- package/dist/engine/kernel/stages.d.ts.map +1 -0
- package/dist/engine/kernel/stages.js +428 -0
- package/dist/engine/kernel/stages.js.map +1 -0
- package/dist/engine/kernel/types.d.ts +21 -1
- package/dist/engine/kernel/types.d.ts.map +1 -1
- package/dist/engine/steps/download.d.ts +1 -0
- package/dist/engine/steps/download.d.ts.map +1 -1
- package/dist/engine/steps/download.js +10 -6
- package/dist/engine/steps/download.js.map +1 -1
- package/dist/fast-path/render.js +1 -1
- package/dist/fast-path/render.js.map +1 -1
- package/dist/manifest-compact.txt +3 -3
- package/dist/manifest-search.json +1 -1
- package/dist/manifest.json +3074 -3
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +11 -1
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +18 -10
- package/dist/mcp/tools.js.map +1 -1
- package/dist/output/error-map.d.ts.map +1 -1
- package/dist/output/error-map.js +1 -1
- package/dist/output/error-map.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +2 -1
- package/dist/registry.js.map +1 -1
- package/package.json +2 -2
- package/server.json +3 -3
- package/skills/unicli/SKILL.md +1 -1
- package/skills/unicli-claude-code/SKILL.md +1 -1
- package/skills/unicli-hermes/SKILL.md +1 -1
- package/src/adapters/anilist/web.test.ts +93 -0
- package/src/adapters/anilist/web.ts +341 -0
- package/src/adapters/arxiv/download.yaml +53 -0
- package/src/adapters/bangumi/web.test.ts +109 -0
- package/src/adapters/bangumi/web.ts +295 -0
- package/src/adapters/danbooru/artists.yaml +44 -0
- package/src/adapters/danbooru/comments.yaml +45 -0
- package/src/adapters/danbooru/detail.yaml +78 -0
- package/src/adapters/danbooru/download.yaml +51 -0
- package/src/adapters/danbooru/pools.yaml +56 -0
- package/src/adapters/danbooru/search.yaml +69 -0
- package/src/adapters/danbooru/tags.yaml +42 -0
- package/src/adapters/danbooru/wiki.yaml +44 -0
- package/src/adapters/dlsite/web.test.ts +132 -0
- package/src/adapters/dlsite/web.ts +557 -0
- package/src/adapters/ehentai/web.test.ts +157 -0
- package/src/adapters/ehentai/web.ts +750 -0
- package/src/adapters/jikan/web.test.ts +50 -0
- package/src/adapters/jikan/web.ts +177 -0
- package/src/adapters/kitsu/web.test.ts +29 -0
- package/src/adapters/kitsu/web.ts +109 -0
- package/src/adapters/konachan/detail.yaml +62 -0
- package/src/adapters/konachan/download.yaml +55 -0
- package/src/adapters/konachan/search.yaml +65 -0
- package/src/adapters/konachan/tags.yaml +40 -0
- package/src/adapters/mangadex/web.test.ts +46 -0
- package/src/adapters/mangadex/web.ts +210 -0
- package/src/adapters/moegirl/web.test.ts +87 -0
- package/src/adapters/moegirl/web.ts +343 -0
- package/src/adapters/pdf/read.yaml +49 -0
- package/src/adapters/pixiv/download.yaml +15 -2
- package/src/adapters/safebooru/detail.yaml +63 -0
- package/src/adapters/safebooru/download.yaml +58 -0
- package/src/adapters/safebooru/search.yaml +69 -0
- package/src/adapters/safebooru/web.test.ts +60 -0
- package/src/adapters/safebooru/web.ts +130 -0
- package/src/adapters/vndb/web.test.ts +86 -0
- package/src/adapters/vndb/web.ts +393 -0
- package/src/adapters/yandere/detail.yaml +61 -0
- package/src/adapters/yandere/download.yaml +56 -0
- package/src/adapters/yandere/search.yaml +67 -0
- package/src/adapters/yandere/tags.yaml +41 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @owner src/adapters/ehentai/web.ts
|
|
3
|
+
* @does Register E-Hentai public search, gallery metadata, page listing, and torrent metadata commands.
|
|
4
|
+
* @needs E-Hentai search HTML, official api.e-hentai.org gdata endpoint, conservative gallery URL parsing.
|
|
5
|
+
* @feeds surface coverage ledger, gallery research workflows, torrent metadata discovery.
|
|
6
|
+
* @breaks Search table markup drift, gdata envelope drift, or gallery token parsing errors hide gallery results.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { USER_AGENT } from "../../constants.js";
|
|
10
|
+
import { cli, Strategy } from "../../registry.js";
|
|
11
|
+
|
|
12
|
+
const EHENTAI_ORIGIN = "https://e-hentai.org";
|
|
13
|
+
const EHENTAI_API = "https://api.e-hentai.org/api.php";
|
|
14
|
+
const GALLERY_RE = /\/g\/(\d+)\/([0-9a-f]{10})\/?/i;
|
|
15
|
+
const GID_TOKEN_RE = /^(\d+)[/:]([0-9a-f]{10})$/i;
|
|
16
|
+
const EHENTAI_CATEGORY_BITS = {
|
|
17
|
+
misc: 1,
|
|
18
|
+
doujinshi: 2,
|
|
19
|
+
manga: 4,
|
|
20
|
+
artistcg: 8,
|
|
21
|
+
gamecg: 16,
|
|
22
|
+
imageset: 32,
|
|
23
|
+
cosplay: 64,
|
|
24
|
+
asianporn: 128,
|
|
25
|
+
nonh: 256,
|
|
26
|
+
western: 512,
|
|
27
|
+
} as const;
|
|
28
|
+
const EHENTAI_ALL_CATEGORIES = 1023;
|
|
29
|
+
const EHENTAI_TAG_NAMESPACES = new Set([
|
|
30
|
+
"artist",
|
|
31
|
+
"character",
|
|
32
|
+
"female",
|
|
33
|
+
"group",
|
|
34
|
+
"language",
|
|
35
|
+
"male",
|
|
36
|
+
"mixed",
|
|
37
|
+
"other",
|
|
38
|
+
"parody",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
interface EhentaiGalleryIdentity {
|
|
42
|
+
gid: number;
|
|
43
|
+
token: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface EhentaiTorrent {
|
|
48
|
+
hash?: unknown;
|
|
49
|
+
added?: unknown;
|
|
50
|
+
name?: unknown;
|
|
51
|
+
tsize?: unknown;
|
|
52
|
+
fsize?: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface EhentaiMetadata {
|
|
56
|
+
gid?: unknown;
|
|
57
|
+
token?: unknown;
|
|
58
|
+
title?: unknown;
|
|
59
|
+
title_jpn?: unknown;
|
|
60
|
+
category?: unknown;
|
|
61
|
+
thumb?: unknown;
|
|
62
|
+
uploader?: unknown;
|
|
63
|
+
posted?: unknown;
|
|
64
|
+
filecount?: unknown;
|
|
65
|
+
filesize?: unknown;
|
|
66
|
+
expunged?: unknown;
|
|
67
|
+
rating?: unknown;
|
|
68
|
+
torrentcount?: unknown;
|
|
69
|
+
torrents?: unknown;
|
|
70
|
+
tags?: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface EhentaiGdataResponse {
|
|
74
|
+
gmetadata?: EhentaiMetadata[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface EhentaiSearchRow {
|
|
78
|
+
rank: number;
|
|
79
|
+
gid: number;
|
|
80
|
+
token: string;
|
|
81
|
+
title: string;
|
|
82
|
+
category: string;
|
|
83
|
+
published: string;
|
|
84
|
+
pages: string;
|
|
85
|
+
uploader: string;
|
|
86
|
+
thumb: string;
|
|
87
|
+
torrent_available: boolean;
|
|
88
|
+
tags: string;
|
|
89
|
+
url: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface EhentaiSearchOptions {
|
|
93
|
+
query: string;
|
|
94
|
+
page: number;
|
|
95
|
+
cursor: string;
|
|
96
|
+
categoryMask?: number;
|
|
97
|
+
requireTorrent: boolean;
|
|
98
|
+
includeExpunged: boolean;
|
|
99
|
+
minPages: string;
|
|
100
|
+
maxPages: string;
|
|
101
|
+
minRating: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function str(value: unknown): string {
|
|
105
|
+
return value === undefined || value === null ? "" : String(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function decodeEhentaiHtml(value: unknown): string {
|
|
109
|
+
return str(value)
|
|
110
|
+
.replace(/&/g, "&")
|
|
111
|
+
.replace(/</g, "<")
|
|
112
|
+
.replace(/>/g, ">")
|
|
113
|
+
.replace(/"/g, '"')
|
|
114
|
+
.replace(/'/g, "'")
|
|
115
|
+
.replace(/'/g, "'")
|
|
116
|
+
.replace(/&#(\d+);/g, (_m, dec: string) =>
|
|
117
|
+
String.fromCodePoint(Number.parseInt(dec, 10)),
|
|
118
|
+
)
|
|
119
|
+
.trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function requireEhentaiLimit(value: unknown, fallback = 20): number {
|
|
123
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
124
|
+
const n = Number(value);
|
|
125
|
+
if (!Number.isInteger(n) || n < 1 || n > 100) {
|
|
126
|
+
throw new Error("ehentai limit must be an integer in [1, 100].");
|
|
127
|
+
}
|
|
128
|
+
return n;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function requireEhentaiQuery(value: unknown): string {
|
|
132
|
+
const query = str(value).trim();
|
|
133
|
+
if (!query) throw new Error("ehentai search query cannot be empty.");
|
|
134
|
+
return query;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function splitCommaList(value: unknown): string[] {
|
|
138
|
+
return str(value)
|
|
139
|
+
.split(",")
|
|
140
|
+
.map((part) => part.trim())
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeEhentaiTagValue(value: unknown): string {
|
|
145
|
+
return str(value)
|
|
146
|
+
.trim()
|
|
147
|
+
.replace(/\$$/, "")
|
|
148
|
+
.replace(/\s+/g, "_")
|
|
149
|
+
.toLowerCase();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function namespacedTag(
|
|
153
|
+
namespace: string,
|
|
154
|
+
value: unknown,
|
|
155
|
+
exact: boolean,
|
|
156
|
+
): string {
|
|
157
|
+
const tag = normalizeEhentaiTagValue(value);
|
|
158
|
+
if (!tag) return "";
|
|
159
|
+
return `${namespace}:${tag}${exact ? "$" : ""}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeCategory(value: string): keyof typeof EHENTAI_CATEGORY_BITS {
|
|
163
|
+
const token = value.toLowerCase().replace(/[\s_-]+/g, "");
|
|
164
|
+
if (token === "artistcg" || token === "artist") return "artistcg";
|
|
165
|
+
if (token === "gamecg" || token === "game") return "gamecg";
|
|
166
|
+
if (token === "imageset" || token === "image") return "imageset";
|
|
167
|
+
if (token === "asianporn" || token === "asian") return "asianporn";
|
|
168
|
+
if (token === "nonh" || token === "non") return "nonh";
|
|
169
|
+
if (token === "comic") return "manga";
|
|
170
|
+
if (token in EHENTAI_CATEGORY_BITS) {
|
|
171
|
+
return token as keyof typeof EHENTAI_CATEGORY_BITS;
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`Unsupported E-Hentai category: ${value}.`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function ehentaiCategoryMask(value: unknown): number | undefined {
|
|
177
|
+
const raw = splitCommaList(value);
|
|
178
|
+
if (raw.length === 0) return undefined;
|
|
179
|
+
const expanded = raw.flatMap((item) => {
|
|
180
|
+
const token = item.toLowerCase().replace(/[\s_-]+/g, "");
|
|
181
|
+
if (token === "all" || token === "*") {
|
|
182
|
+
return Object.keys(EHENTAI_CATEGORY_BITS);
|
|
183
|
+
}
|
|
184
|
+
if (token === "cg") return ["artistcg", "gamecg"];
|
|
185
|
+
return [item];
|
|
186
|
+
});
|
|
187
|
+
const includeMask = expanded.reduce(
|
|
188
|
+
(mask, item) => mask | EHENTAI_CATEGORY_BITS[normalizeCategory(item)],
|
|
189
|
+
0,
|
|
190
|
+
);
|
|
191
|
+
return EHENTAI_ALL_CATEGORIES ^ includeMask;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function structuredTags(kwargs: Record<string, unknown>): string[] {
|
|
195
|
+
const exact = kwargs.exact_tags !== false;
|
|
196
|
+
const out: string[] = [];
|
|
197
|
+
for (const namespace of EHENTAI_TAG_NAMESPACES) {
|
|
198
|
+
for (const value of splitCommaList(kwargs[namespace])) {
|
|
199
|
+
const tag = namespacedTag(namespace, value, exact);
|
|
200
|
+
if (tag) out.push(tag);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const raw of splitCommaList(kwargs.tags)) {
|
|
204
|
+
const [namespace, value] = raw.split(/:(.+)/, 2);
|
|
205
|
+
if (!value || !EHENTAI_TAG_NAMESPACES.has(namespace.toLowerCase())) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`E-Hentai tags must be namespaced, for example artist:tony taka or language:chinese. Received: ${raw}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
out.push(namespacedTag(namespace.toLowerCase(), value, exact));
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function buildEhentaiSearchQuery(
|
|
216
|
+
kwargs: Record<string, unknown>,
|
|
217
|
+
): string {
|
|
218
|
+
const parts = [str(kwargs.query).trim(), ...structuredTags(kwargs)].filter(
|
|
219
|
+
Boolean,
|
|
220
|
+
);
|
|
221
|
+
const query = parts.join(" ").trim();
|
|
222
|
+
if (!query) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
"E-Hentai search needs a query or at least one structured tag filter.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return query;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function parseEhentaiGallery(value: unknown): EhentaiGalleryIdentity {
|
|
231
|
+
const raw = str(value).trim();
|
|
232
|
+
const direct = raw.match(GID_TOKEN_RE);
|
|
233
|
+
if (direct) {
|
|
234
|
+
const gid = Number(direct[1]);
|
|
235
|
+
return {
|
|
236
|
+
gid,
|
|
237
|
+
token: direct[2].toLowerCase(),
|
|
238
|
+
url: `${EHENTAI_ORIGIN}/g/${gid}/${direct[2].toLowerCase()}/`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let url: URL;
|
|
243
|
+
try {
|
|
244
|
+
url = new URL(raw);
|
|
245
|
+
} catch {
|
|
246
|
+
throw new Error(
|
|
247
|
+
"E-Hentai gallery must be a gallery URL or gid/token pair.",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (url.hostname !== "e-hentai.org" && url.hostname !== "exhentai.org") {
|
|
251
|
+
throw new Error(
|
|
252
|
+
"E-Hentai gallery URL must use e-hentai.org or exhentai.org.",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const match = url.pathname.match(GALLERY_RE);
|
|
256
|
+
if (!match) {
|
|
257
|
+
throw new Error("E-Hentai gallery URL must have /g/<gid>/<token>/ shape.");
|
|
258
|
+
}
|
|
259
|
+
const gid = Number(match[1]);
|
|
260
|
+
return {
|
|
261
|
+
gid,
|
|
262
|
+
token: match[2].toLowerCase(),
|
|
263
|
+
url: `${url.origin}/g/${gid}/${match[2].toLowerCase()}/`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function ehentaiSearchUrl(options: EhentaiSearchOptions): string {
|
|
268
|
+
const url = new URL(EHENTAI_ORIGIN);
|
|
269
|
+
if (options.query) url.searchParams.set("f_search", options.query);
|
|
270
|
+
if (options.categoryMask !== undefined && options.categoryMask !== 0) {
|
|
271
|
+
url.searchParams.set("f_cats", String(options.categoryMask));
|
|
272
|
+
}
|
|
273
|
+
if (options.cursor) {
|
|
274
|
+
url.searchParams.set("next", options.cursor);
|
|
275
|
+
} else if (options.page > 0) {
|
|
276
|
+
url.searchParams.set("page", String(options.page));
|
|
277
|
+
}
|
|
278
|
+
if (
|
|
279
|
+
options.requireTorrent ||
|
|
280
|
+
options.includeExpunged ||
|
|
281
|
+
options.minPages ||
|
|
282
|
+
options.maxPages ||
|
|
283
|
+
options.minRating
|
|
284
|
+
) {
|
|
285
|
+
url.searchParams.set("advsearch", "1");
|
|
286
|
+
}
|
|
287
|
+
if (options.requireTorrent) url.searchParams.set("f_sto", "on");
|
|
288
|
+
if (options.includeExpunged) url.searchParams.set("f_sh", "on");
|
|
289
|
+
if (options.minPages) url.searchParams.set("f_spf", options.minPages);
|
|
290
|
+
if (options.maxPages) url.searchParams.set("f_spt", options.maxPages);
|
|
291
|
+
if (options.minRating) url.searchParams.set("f_srdd", options.minRating);
|
|
292
|
+
return url.toString();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function requireEhentaiSearchOptions(
|
|
296
|
+
kwargs: Record<string, unknown>,
|
|
297
|
+
): EhentaiSearchOptions {
|
|
298
|
+
const page = Number(kwargs.page ?? 0);
|
|
299
|
+
if (!Number.isInteger(page) || page < 0) {
|
|
300
|
+
throw new Error("ehentai page must be a non-negative integer.");
|
|
301
|
+
}
|
|
302
|
+
const minRating = str(kwargs.min_rating).trim();
|
|
303
|
+
if (minRating && !["2", "3", "4", "5"].includes(minRating)) {
|
|
304
|
+
throw new Error("ehentai min_rating must be one of 2, 3, 4, or 5.");
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
query: buildEhentaiSearchQuery(kwargs),
|
|
308
|
+
page,
|
|
309
|
+
cursor: str(kwargs.cursor).trim(),
|
|
310
|
+
categoryMask: ehentaiCategoryMask(kwargs.category),
|
|
311
|
+
requireTorrent: kwargs.require_torrent === true,
|
|
312
|
+
includeExpunged: kwargs.include_expunged === true,
|
|
313
|
+
minPages: str(kwargs.min_pages).trim(),
|
|
314
|
+
maxPages: str(kwargs.max_pages).trim(),
|
|
315
|
+
minRating,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function runEhentaiSearch(
|
|
320
|
+
kwargs: Record<string, unknown>,
|
|
321
|
+
): Promise<EhentaiSearchRow[]> {
|
|
322
|
+
const limit = requireEhentaiLimit(kwargs.limit);
|
|
323
|
+
const rows = parseEhentaiSearchHtml(
|
|
324
|
+
await fetchText(ehentaiSearchUrl(requireEhentaiSearchOptions(kwargs))),
|
|
325
|
+
limit,
|
|
326
|
+
);
|
|
327
|
+
if (rows.length === 0) throw new Error("No E-Hentai galleries found.");
|
|
328
|
+
return rows;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const EHENTAI_SEARCH_COLUMNS = [
|
|
332
|
+
"rank",
|
|
333
|
+
"gid",
|
|
334
|
+
"token",
|
|
335
|
+
"title",
|
|
336
|
+
"category",
|
|
337
|
+
"published",
|
|
338
|
+
"pages",
|
|
339
|
+
"uploader",
|
|
340
|
+
"torrent_available",
|
|
341
|
+
"tags",
|
|
342
|
+
"url",
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
const EHENTAI_SEARCH_FILTER_ARGS = [
|
|
346
|
+
{ name: "limit", type: "int" as const, default: 20 },
|
|
347
|
+
{ name: "page", type: "int" as const, default: 0 },
|
|
348
|
+
{
|
|
349
|
+
name: "cursor",
|
|
350
|
+
type: "str" as const,
|
|
351
|
+
description: "Next cursor GID from E-Hentai navigation URLs",
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: "category",
|
|
355
|
+
type: "str" as const,
|
|
356
|
+
description:
|
|
357
|
+
"Comma-separated categories: doujinshi,manga,artistcg,gamecg,cg,imageset,cosplay,asianporn,nonh,western,misc",
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "tags",
|
|
361
|
+
type: "str" as const,
|
|
362
|
+
description:
|
|
363
|
+
"Comma-separated namespaced tags, for example artist:tony taka,language:chinese,other:full color",
|
|
364
|
+
},
|
|
365
|
+
{ name: "artist", type: "str" as const },
|
|
366
|
+
{ name: "group", type: "str" as const },
|
|
367
|
+
{ name: "parody", type: "str" as const },
|
|
368
|
+
{ name: "character", type: "str" as const },
|
|
369
|
+
{ name: "language", type: "str" as const },
|
|
370
|
+
{ name: "female", type: "str" as const },
|
|
371
|
+
{ name: "male", type: "str" as const },
|
|
372
|
+
{ name: "mixed", type: "str" as const },
|
|
373
|
+
{ name: "other", type: "str" as const },
|
|
374
|
+
{ name: "exact_tags", type: "bool" as const, default: true },
|
|
375
|
+
{ name: "require_torrent", type: "bool" as const, default: false },
|
|
376
|
+
{ name: "include_expunged", type: "bool" as const, default: false },
|
|
377
|
+
{ name: "min_pages", type: "int" as const },
|
|
378
|
+
{ name: "max_pages", type: "int" as const },
|
|
379
|
+
{ name: "min_rating", type: "int" as const },
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
const EHENTAI_RESULT_FILTER_ARGS = EHENTAI_SEARCH_FILTER_ARGS.filter(
|
|
383
|
+
(arg) =>
|
|
384
|
+
![
|
|
385
|
+
"artist",
|
|
386
|
+
"character",
|
|
387
|
+
"female",
|
|
388
|
+
"group",
|
|
389
|
+
"language",
|
|
390
|
+
"male",
|
|
391
|
+
"mixed",
|
|
392
|
+
"other",
|
|
393
|
+
"parody",
|
|
394
|
+
"tags",
|
|
395
|
+
].includes(arg.name),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
function requireEhentaiTagNamespace(value: unknown): string {
|
|
399
|
+
const namespace = str(value).trim().toLowerCase();
|
|
400
|
+
if (!EHENTAI_TAG_NAMESPACES.has(namespace)) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`E-Hentai tag namespace must be one of ${[...EHENTAI_TAG_NAMESPACES].join(", ")}.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return namespace;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function fetchText(url: string): Promise<string> {
|
|
409
|
+
const response = await fetch(url, {
|
|
410
|
+
headers: {
|
|
411
|
+
Accept: "text/html,application/xhtml+xml",
|
|
412
|
+
"User-Agent": USER_AGENT,
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
if (!response.ok) {
|
|
416
|
+
throw new Error(`E-Hentai request failed with HTTP ${response.status}.`);
|
|
417
|
+
}
|
|
418
|
+
return response.text();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function fetchGdata(
|
|
422
|
+
identity: EhentaiGalleryIdentity,
|
|
423
|
+
): Promise<EhentaiMetadata> {
|
|
424
|
+
const response = await fetch(EHENTAI_API, {
|
|
425
|
+
method: "POST",
|
|
426
|
+
headers: {
|
|
427
|
+
Accept: "application/json",
|
|
428
|
+
"Content-Type": "application/json",
|
|
429
|
+
"User-Agent": USER_AGENT,
|
|
430
|
+
},
|
|
431
|
+
body: JSON.stringify({
|
|
432
|
+
method: "gdata",
|
|
433
|
+
gidlist: [[identity.gid, identity.token]],
|
|
434
|
+
namespace: 1,
|
|
435
|
+
}),
|
|
436
|
+
});
|
|
437
|
+
if (!response.ok) {
|
|
438
|
+
throw new Error(`E-Hentai API returned HTTP ${response.status}.`);
|
|
439
|
+
}
|
|
440
|
+
const body = (await response.json()) as EhentaiGdataResponse;
|
|
441
|
+
const metadata = body.gmetadata?.[0];
|
|
442
|
+
if (!metadata || metadata.gid === undefined) {
|
|
443
|
+
throw new Error("E-Hentai API returned no gallery metadata.");
|
|
444
|
+
}
|
|
445
|
+
return metadata;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function firstMatch(value: string, re: RegExp): string {
|
|
449
|
+
const match = value.match(re);
|
|
450
|
+
return match ? decodeEhentaiHtml(match[1]) : "";
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function allMatches(value: string, re: RegExp): string[] {
|
|
454
|
+
const out: string[] = [];
|
|
455
|
+
let match: RegExpExecArray | null;
|
|
456
|
+
while ((match = re.exec(value)) !== null)
|
|
457
|
+
out.push(decodeEhentaiHtml(match[1]));
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function parseEhentaiSearchHtml(
|
|
462
|
+
html: string,
|
|
463
|
+
limit: number,
|
|
464
|
+
): EhentaiSearchRow[] {
|
|
465
|
+
const rows: EhentaiSearchRow[] = [];
|
|
466
|
+
const chunks = html.split(/<tr><td class="gl1c/).slice(1);
|
|
467
|
+
for (const chunk of chunks) {
|
|
468
|
+
const section = `<tr><td class="gl1c${chunk}`;
|
|
469
|
+
const gallery = section.match(
|
|
470
|
+
/href="https:\/\/e-hentai\.org\/g\/(\d+)\/([0-9a-f]{10})\/"/i,
|
|
471
|
+
);
|
|
472
|
+
if (!gallery) continue;
|
|
473
|
+
const title =
|
|
474
|
+
firstMatch(section, /<div class="glink">([\s\S]*?)<\/div>/) ||
|
|
475
|
+
firstMatch(section, /<img[^>]+alt="([^"]*)"/);
|
|
476
|
+
if (!title) continue;
|
|
477
|
+
const thumb =
|
|
478
|
+
firstMatch(section, /\bdata-src="([^"]+)"/) ||
|
|
479
|
+
firstMatch(section, /\bsrc="(https:\/\/[^"]+)"/);
|
|
480
|
+
const tags = allMatches(section, /<div class="gt" title="([^"]+)">/g);
|
|
481
|
+
rows.push({
|
|
482
|
+
rank: rows.length + 1,
|
|
483
|
+
gid: Number(gallery[1]),
|
|
484
|
+
token: gallery[2],
|
|
485
|
+
title,
|
|
486
|
+
category: firstMatch(
|
|
487
|
+
section,
|
|
488
|
+
/<div class="cn [^"]*"[^>]*>([^<]+)<\/div>/,
|
|
489
|
+
),
|
|
490
|
+
published: firstMatch(section, /id="posted(?:pop)?_\d+">([^<]+)<\/div>/),
|
|
491
|
+
pages: firstMatch(section, /<div>(\d+\s+pages)<\/div>/),
|
|
492
|
+
uploader: firstMatch(
|
|
493
|
+
section,
|
|
494
|
+
/<td class="gl4c[^"]*"><div><a(?:\s[^>]*)?>([^<]+)<\/a>/,
|
|
495
|
+
),
|
|
496
|
+
thumb,
|
|
497
|
+
torrent_available: /gallerytorrents\.php/.test(section),
|
|
498
|
+
tags: tags.join(", "),
|
|
499
|
+
url: `${EHENTAI_ORIGIN}/g/${gallery[1]}/${gallery[2]}/`,
|
|
500
|
+
});
|
|
501
|
+
if (rows.length >= limit) break;
|
|
502
|
+
}
|
|
503
|
+
return rows;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function metadataRow(metadata: EhentaiMetadata): Record<string, unknown> {
|
|
507
|
+
const gid = Number(metadata.gid);
|
|
508
|
+
const token = str(metadata.token);
|
|
509
|
+
const tags = Array.isArray(metadata.tags) ? metadata.tags.map(String) : [];
|
|
510
|
+
return {
|
|
511
|
+
gid,
|
|
512
|
+
token,
|
|
513
|
+
title: str(metadata.title),
|
|
514
|
+
title_jpn: str(metadata.title_jpn),
|
|
515
|
+
category: str(metadata.category),
|
|
516
|
+
uploader: str(metadata.uploader),
|
|
517
|
+
posted: str(metadata.posted),
|
|
518
|
+
filecount: str(metadata.filecount),
|
|
519
|
+
filesize: metadata.filesize ?? "",
|
|
520
|
+
expunged: Boolean(metadata.expunged),
|
|
521
|
+
rating: str(metadata.rating),
|
|
522
|
+
torrentcount: str(metadata.torrentcount),
|
|
523
|
+
tags: tags.join(", "),
|
|
524
|
+
thumb: str(metadata.thumb),
|
|
525
|
+
url: `${EHENTAI_ORIGIN}/g/${gid}/${token}/`,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function mapEhentaiTorrents(
|
|
530
|
+
metadata: EhentaiMetadata,
|
|
531
|
+
): Record<string, unknown>[] {
|
|
532
|
+
const torrents = Array.isArray(metadata.torrents)
|
|
533
|
+
? (metadata.torrents as EhentaiTorrent[])
|
|
534
|
+
: [];
|
|
535
|
+
return torrents.map((torrent, index) => ({
|
|
536
|
+
rank: index + 1,
|
|
537
|
+
gid: Number(metadata.gid),
|
|
538
|
+
title: str(metadata.title),
|
|
539
|
+
hash: str(torrent.hash),
|
|
540
|
+
added: str(torrent.added),
|
|
541
|
+
name: str(torrent.name),
|
|
542
|
+
tsize: str(torrent.tsize),
|
|
543
|
+
fsize: str(torrent.fsize),
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function parseEhentaiGalleryPages(
|
|
548
|
+
html: string,
|
|
549
|
+
gallery: EhentaiGalleryIdentity,
|
|
550
|
+
limit: number,
|
|
551
|
+
): Record<string, unknown>[] {
|
|
552
|
+
const title = firstMatch(html, /<h1 id="gn">([\s\S]*?)<\/h1>/);
|
|
553
|
+
const out: Record<string, unknown>[] = [];
|
|
554
|
+
const re =
|
|
555
|
+
/<a href="(https:\/\/e-hentai\.org\/s\/[^"]+)"><div title="Page\s+(\d+):\s*([^"]*)"[\s\S]*?background:transparent url\(([^)]+)\)/g;
|
|
556
|
+
let match: RegExpExecArray | null;
|
|
557
|
+
while ((match = re.exec(html)) !== null) {
|
|
558
|
+
out.push({
|
|
559
|
+
page: Number(match[2]),
|
|
560
|
+
gid: gallery.gid,
|
|
561
|
+
title,
|
|
562
|
+
filename: decodeEhentaiHtml(match[3]),
|
|
563
|
+
page_url: decodeEhentaiHtml(match[1]),
|
|
564
|
+
thumb_sprite_url: decodeEhentaiHtml(match[4]),
|
|
565
|
+
gallery_url: gallery.url,
|
|
566
|
+
});
|
|
567
|
+
if (out.length >= limit) break;
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
cli({
|
|
573
|
+
site: "ehentai",
|
|
574
|
+
name: "search",
|
|
575
|
+
description:
|
|
576
|
+
"Search public E-Hentai galleries with category and structured tag filters",
|
|
577
|
+
domain: "e-hentai.org",
|
|
578
|
+
strategy: Strategy.PUBLIC,
|
|
579
|
+
browser: false,
|
|
580
|
+
args: [
|
|
581
|
+
{
|
|
582
|
+
name: "query",
|
|
583
|
+
type: "str",
|
|
584
|
+
positional: true,
|
|
585
|
+
description:
|
|
586
|
+
"Free text search. Can be omitted when structured tag filters are supplied.",
|
|
587
|
+
},
|
|
588
|
+
...EHENTAI_SEARCH_FILTER_ARGS,
|
|
589
|
+
],
|
|
590
|
+
columns: EHENTAI_SEARCH_COLUMNS,
|
|
591
|
+
func: async (_page, kwargs) => runEhentaiSearch(kwargs),
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
cli({
|
|
595
|
+
site: "ehentai",
|
|
596
|
+
name: "artist",
|
|
597
|
+
description: "Search public E-Hentai galleries by exact artist tag",
|
|
598
|
+
domain: "e-hentai.org",
|
|
599
|
+
strategy: Strategy.PUBLIC,
|
|
600
|
+
browser: false,
|
|
601
|
+
args: [
|
|
602
|
+
{
|
|
603
|
+
name: "artist",
|
|
604
|
+
type: "str",
|
|
605
|
+
required: true,
|
|
606
|
+
positional: true,
|
|
607
|
+
description: "Artist tag, for example tony taka",
|
|
608
|
+
},
|
|
609
|
+
...EHENTAI_RESULT_FILTER_ARGS,
|
|
610
|
+
],
|
|
611
|
+
columns: EHENTAI_SEARCH_COLUMNS,
|
|
612
|
+
func: async (_page, kwargs) => runEhentaiSearch(kwargs),
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
cli({
|
|
616
|
+
site: "ehentai",
|
|
617
|
+
name: "tag",
|
|
618
|
+
description:
|
|
619
|
+
"Search public E-Hentai galleries by exact namespaced tag such as artist, group, parody, language, or character",
|
|
620
|
+
domain: "e-hentai.org",
|
|
621
|
+
strategy: Strategy.PUBLIC,
|
|
622
|
+
browser: false,
|
|
623
|
+
args: [
|
|
624
|
+
{
|
|
625
|
+
name: "namespace",
|
|
626
|
+
type: "str",
|
|
627
|
+
required: true,
|
|
628
|
+
positional: true,
|
|
629
|
+
description:
|
|
630
|
+
"Tag namespace: artist, group, parody, character, language, female, male, mixed, or other",
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
name: "name",
|
|
634
|
+
type: "str",
|
|
635
|
+
required: true,
|
|
636
|
+
positional: true,
|
|
637
|
+
description: "Tag value; spaces are normalized to underscores",
|
|
638
|
+
},
|
|
639
|
+
...EHENTAI_RESULT_FILTER_ARGS,
|
|
640
|
+
],
|
|
641
|
+
columns: EHENTAI_SEARCH_COLUMNS,
|
|
642
|
+
func: async (_page, kwargs) =>
|
|
643
|
+
runEhentaiSearch({
|
|
644
|
+
...kwargs,
|
|
645
|
+
tags: `${requireEhentaiTagNamespace(kwargs.namespace)}:${str(kwargs.name)}`,
|
|
646
|
+
}),
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
cli({
|
|
650
|
+
site: "ehentai",
|
|
651
|
+
name: "gallery",
|
|
652
|
+
description: "Get E-Hentai gallery metadata through the official API",
|
|
653
|
+
domain: "api.e-hentai.org",
|
|
654
|
+
strategy: Strategy.PUBLIC,
|
|
655
|
+
browser: false,
|
|
656
|
+
args: [
|
|
657
|
+
{
|
|
658
|
+
name: "input",
|
|
659
|
+
type: "str",
|
|
660
|
+
required: true,
|
|
661
|
+
positional: true,
|
|
662
|
+
description: "Gallery URL or gid/token pair",
|
|
663
|
+
},
|
|
664
|
+
],
|
|
665
|
+
columns: [
|
|
666
|
+
"gid",
|
|
667
|
+
"token",
|
|
668
|
+
"title",
|
|
669
|
+
"title_jpn",
|
|
670
|
+
"category",
|
|
671
|
+
"uploader",
|
|
672
|
+
"posted",
|
|
673
|
+
"filecount",
|
|
674
|
+
"filesize",
|
|
675
|
+
"rating",
|
|
676
|
+
"torrentcount",
|
|
677
|
+
"tags",
|
|
678
|
+
"thumb",
|
|
679
|
+
"url",
|
|
680
|
+
],
|
|
681
|
+
func: async (_page, kwargs) => [
|
|
682
|
+
metadataRow(await fetchGdata(parseEhentaiGallery(kwargs.input))),
|
|
683
|
+
],
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
cli({
|
|
687
|
+
site: "ehentai",
|
|
688
|
+
name: "torrents",
|
|
689
|
+
description: "List E-Hentai gallery torrent metadata from the official API",
|
|
690
|
+
domain: "api.e-hentai.org",
|
|
691
|
+
strategy: Strategy.PUBLIC,
|
|
692
|
+
browser: false,
|
|
693
|
+
args: [
|
|
694
|
+
{
|
|
695
|
+
name: "input",
|
|
696
|
+
type: "str",
|
|
697
|
+
required: true,
|
|
698
|
+
positional: true,
|
|
699
|
+
description: "Gallery URL or gid/token pair",
|
|
700
|
+
},
|
|
701
|
+
],
|
|
702
|
+
columns: ["rank", "gid", "title", "hash", "added", "name", "tsize", "fsize"],
|
|
703
|
+
func: async (_page, kwargs) => {
|
|
704
|
+
const rows = mapEhentaiTorrents(
|
|
705
|
+
await fetchGdata(parseEhentaiGallery(kwargs.input)),
|
|
706
|
+
);
|
|
707
|
+
if (rows.length === 0) throw new Error("No torrents found for gallery.");
|
|
708
|
+
return rows;
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
cli({
|
|
713
|
+
site: "ehentai",
|
|
714
|
+
name: "pages",
|
|
715
|
+
description:
|
|
716
|
+
"List public E-Hentai gallery image page URLs and thumbnail sprites",
|
|
717
|
+
domain: "e-hentai.org",
|
|
718
|
+
strategy: Strategy.PUBLIC,
|
|
719
|
+
browser: false,
|
|
720
|
+
args: [
|
|
721
|
+
{
|
|
722
|
+
name: "input",
|
|
723
|
+
type: "str",
|
|
724
|
+
required: true,
|
|
725
|
+
positional: true,
|
|
726
|
+
description: "Gallery URL or gid/token pair",
|
|
727
|
+
},
|
|
728
|
+
{ name: "limit", type: "int", default: 40 },
|
|
729
|
+
],
|
|
730
|
+
columns: [
|
|
731
|
+
"page",
|
|
732
|
+
"gid",
|
|
733
|
+
"title",
|
|
734
|
+
"filename",
|
|
735
|
+
"page_url",
|
|
736
|
+
"thumb_sprite_url",
|
|
737
|
+
"gallery_url",
|
|
738
|
+
],
|
|
739
|
+
func: async (_page, kwargs) => {
|
|
740
|
+
const gallery = parseEhentaiGallery(kwargs.input);
|
|
741
|
+
const limit = requireEhentaiLimit(kwargs.limit, 40);
|
|
742
|
+
const rows = parseEhentaiGalleryPages(
|
|
743
|
+
await fetchText(gallery.url),
|
|
744
|
+
gallery,
|
|
745
|
+
limit,
|
|
746
|
+
);
|
|
747
|
+
if (rows.length === 0) throw new Error("No E-Hentai gallery pages found.");
|
|
748
|
+
return rows;
|
|
749
|
+
},
|
|
750
|
+
});
|