@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.
Files changed (182) hide show
  1. package/AGENTS.md +10 -19
  2. package/README.md +17 -11
  3. package/README.zh-CN.md +17 -11
  4. package/dist/adapters/anilist/web.d.ts +11 -0
  5. package/dist/adapters/anilist/web.d.ts.map +1 -0
  6. package/dist/adapters/anilist/web.js +284 -0
  7. package/dist/adapters/anilist/web.js.map +1 -0
  8. package/dist/adapters/bangumi/web.d.ts +14 -0
  9. package/dist/adapters/bangumi/web.d.ts.map +1 -0
  10. package/dist/adapters/bangumi/web.js +257 -0
  11. package/dist/adapters/bangumi/web.js.map +1 -0
  12. package/dist/adapters/dlsite/web.d.ts +31 -0
  13. package/dist/adapters/dlsite/web.d.ts.map +1 -0
  14. package/dist/adapters/dlsite/web.js +455 -0
  15. package/dist/adapters/dlsite/web.js.map +1 -0
  16. package/dist/adapters/ehentai/web.d.ts +66 -0
  17. package/dist/adapters/ehentai/web.d.ts.map +1 -0
  18. package/dist/adapters/ehentai/web.js +608 -0
  19. package/dist/adapters/ehentai/web.js.map +1 -0
  20. package/dist/adapters/jikan/web.d.ts +9 -0
  21. package/dist/adapters/jikan/web.d.ts.map +1 -0
  22. package/dist/adapters/jikan/web.js +154 -0
  23. package/dist/adapters/jikan/web.js.map +1 -0
  24. package/dist/adapters/kitsu/web.d.ts +9 -0
  25. package/dist/adapters/kitsu/web.d.ts.map +1 -0
  26. package/dist/adapters/kitsu/web.js +97 -0
  27. package/dist/adapters/kitsu/web.js.map +1 -0
  28. package/dist/adapters/mangadex/web.d.ts +10 -0
  29. package/dist/adapters/mangadex/web.d.ts.map +1 -0
  30. package/dist/adapters/mangadex/web.js +188 -0
  31. package/dist/adapters/mangadex/web.js.map +1 -0
  32. package/dist/adapters/moegirl/web.d.ts +23 -0
  33. package/dist/adapters/moegirl/web.d.ts.map +1 -0
  34. package/dist/adapters/moegirl/web.js +269 -0
  35. package/dist/adapters/moegirl/web.js.map +1 -0
  36. package/dist/adapters/safebooru/web.d.ts +10 -0
  37. package/dist/adapters/safebooru/web.d.ts.map +1 -0
  38. package/dist/adapters/safebooru/web.js +120 -0
  39. package/dist/adapters/safebooru/web.js.map +1 -0
  40. package/dist/adapters/vndb/web.d.ts +10 -0
  41. package/dist/adapters/vndb/web.d.ts.map +1 -0
  42. package/dist/adapters/vndb/web.js +321 -0
  43. package/dist/adapters/vndb/web.js.map +1 -0
  44. package/dist/agents/codex-pack.d.ts +62 -0
  45. package/dist/agents/codex-pack.d.ts.map +1 -0
  46. package/dist/agents/codex-pack.js +163 -0
  47. package/dist/agents/codex-pack.js.map +1 -0
  48. package/dist/commands/agents.d.ts.map +1 -1
  49. package/dist/commands/agents.js +6 -43
  50. package/dist/commands/agents.js.map +1 -1
  51. package/dist/commands/browser/adapter.d.ts.map +1 -1
  52. package/dist/commands/browser/adapter.js +17 -3
  53. package/dist/commands/browser/adapter.js.map +1 -1
  54. package/dist/commands/describe.d.ts.map +1 -1
  55. package/dist/commands/describe.js +6 -7
  56. package/dist/commands/describe.js.map +1 -1
  57. package/dist/commands/dispatch.d.ts +1 -1
  58. package/dist/commands/dispatch.d.ts.map +1 -1
  59. package/dist/commands/dispatch.js +4 -2
  60. package/dist/commands/dispatch.js.map +1 -1
  61. package/dist/commands/mcp.d.ts +1 -1
  62. package/dist/commands/mcp.d.ts.map +1 -1
  63. package/dist/commands/mcp.js +10 -5
  64. package/dist/commands/mcp.js.map +1 -1
  65. package/dist/core/command-contract-lint.d.ts +10 -0
  66. package/dist/core/command-contract-lint.d.ts.map +1 -0
  67. package/dist/core/command-contract-lint.js +41 -0
  68. package/dist/core/command-contract-lint.js.map +1 -0
  69. package/dist/core/command-contract.d.ts +100 -0
  70. package/dist/core/command-contract.d.ts.map +1 -0
  71. package/dist/core/command-contract.js +174 -0
  72. package/dist/core/command-contract.js.map +1 -0
  73. package/dist/core/index.d.ts +2 -0
  74. package/dist/core/index.d.ts.map +1 -1
  75. package/dist/core/index.js +2 -0
  76. package/dist/core/index.js.map +1 -1
  77. package/dist/discovery/aliases.d.ts +2 -2
  78. package/dist/discovery/aliases.d.ts.map +1 -1
  79. package/dist/discovery/aliases.js +464 -6
  80. package/dist/discovery/aliases.js.map +1 -1
  81. package/dist/discovery/search.d.ts.map +1 -1
  82. package/dist/discovery/search.js +147 -2
  83. package/dist/discovery/search.js.map +1 -1
  84. package/dist/engine/args.d.ts.map +1 -1
  85. package/dist/engine/args.js +18 -1
  86. package/dist/engine/args.js.map +1 -1
  87. package/dist/engine/artifact-validation.d.ts +29 -0
  88. package/dist/engine/artifact-validation.d.ts.map +1 -0
  89. package/dist/engine/artifact-validation.js +211 -0
  90. package/dist/engine/artifact-validation.js.map +1 -0
  91. package/dist/engine/browser/diagnostics.d.ts +38 -0
  92. package/dist/engine/browser/diagnostics.d.ts.map +1 -0
  93. package/dist/engine/browser/diagnostics.js +40 -0
  94. package/dist/engine/browser/diagnostics.js.map +1 -0
  95. package/dist/engine/invoke.d.ts +1 -0
  96. package/dist/engine/invoke.d.ts.map +1 -1
  97. package/dist/engine/invoke.js +1 -0
  98. package/dist/engine/invoke.js.map +1 -1
  99. package/dist/engine/kernel/errors.d.ts +11 -0
  100. package/dist/engine/kernel/errors.d.ts.map +1 -0
  101. package/dist/engine/kernel/errors.js +15 -0
  102. package/dist/engine/kernel/errors.js.map +1 -0
  103. package/dist/engine/kernel/execute.d.ts +7 -18
  104. package/dist/engine/kernel/execute.d.ts.map +1 -1
  105. package/dist/engine/kernel/execute.js +25 -410
  106. package/dist/engine/kernel/execute.js.map +1 -1
  107. package/dist/engine/kernel/stages.d.ts +44 -0
  108. package/dist/engine/kernel/stages.d.ts.map +1 -0
  109. package/dist/engine/kernel/stages.js +428 -0
  110. package/dist/engine/kernel/stages.js.map +1 -0
  111. package/dist/engine/kernel/types.d.ts +21 -1
  112. package/dist/engine/kernel/types.d.ts.map +1 -1
  113. package/dist/engine/steps/download.d.ts +1 -0
  114. package/dist/engine/steps/download.d.ts.map +1 -1
  115. package/dist/engine/steps/download.js +10 -6
  116. package/dist/engine/steps/download.js.map +1 -1
  117. package/dist/fast-path/render.js +1 -1
  118. package/dist/fast-path/render.js.map +1 -1
  119. package/dist/manifest-compact.txt +3 -3
  120. package/dist/manifest-search.json +1 -1
  121. package/dist/manifest.json +3074 -3
  122. package/dist/mcp/handler.d.ts.map +1 -1
  123. package/dist/mcp/handler.js +11 -1
  124. package/dist/mcp/handler.js.map +1 -1
  125. package/dist/mcp/server.d.ts +1 -1
  126. package/dist/mcp/server.js +1 -1
  127. package/dist/mcp/tools.d.ts.map +1 -1
  128. package/dist/mcp/tools.js +18 -10
  129. package/dist/mcp/tools.js.map +1 -1
  130. package/dist/output/error-map.d.ts.map +1 -1
  131. package/dist/output/error-map.js +1 -1
  132. package/dist/output/error-map.js.map +1 -1
  133. package/dist/registry.d.ts.map +1 -1
  134. package/dist/registry.js +2 -1
  135. package/dist/registry.js.map +1 -1
  136. package/package.json +2 -2
  137. package/server.json +3 -3
  138. package/skills/unicli/SKILL.md +1 -1
  139. package/skills/unicli-claude-code/SKILL.md +1 -1
  140. package/skills/unicli-hermes/SKILL.md +1 -1
  141. package/src/adapters/anilist/web.test.ts +93 -0
  142. package/src/adapters/anilist/web.ts +341 -0
  143. package/src/adapters/arxiv/download.yaml +53 -0
  144. package/src/adapters/bangumi/web.test.ts +109 -0
  145. package/src/adapters/bangumi/web.ts +295 -0
  146. package/src/adapters/danbooru/artists.yaml +44 -0
  147. package/src/adapters/danbooru/comments.yaml +45 -0
  148. package/src/adapters/danbooru/detail.yaml +78 -0
  149. package/src/adapters/danbooru/download.yaml +51 -0
  150. package/src/adapters/danbooru/pools.yaml +56 -0
  151. package/src/adapters/danbooru/search.yaml +69 -0
  152. package/src/adapters/danbooru/tags.yaml +42 -0
  153. package/src/adapters/danbooru/wiki.yaml +44 -0
  154. package/src/adapters/dlsite/web.test.ts +132 -0
  155. package/src/adapters/dlsite/web.ts +557 -0
  156. package/src/adapters/ehentai/web.test.ts +157 -0
  157. package/src/adapters/ehentai/web.ts +750 -0
  158. package/src/adapters/jikan/web.test.ts +50 -0
  159. package/src/adapters/jikan/web.ts +177 -0
  160. package/src/adapters/kitsu/web.test.ts +29 -0
  161. package/src/adapters/kitsu/web.ts +109 -0
  162. package/src/adapters/konachan/detail.yaml +62 -0
  163. package/src/adapters/konachan/download.yaml +55 -0
  164. package/src/adapters/konachan/search.yaml +65 -0
  165. package/src/adapters/konachan/tags.yaml +40 -0
  166. package/src/adapters/mangadex/web.test.ts +46 -0
  167. package/src/adapters/mangadex/web.ts +210 -0
  168. package/src/adapters/moegirl/web.test.ts +87 -0
  169. package/src/adapters/moegirl/web.ts +343 -0
  170. package/src/adapters/pdf/read.yaml +49 -0
  171. package/src/adapters/pixiv/download.yaml +15 -2
  172. package/src/adapters/safebooru/detail.yaml +63 -0
  173. package/src/adapters/safebooru/download.yaml +58 -0
  174. package/src/adapters/safebooru/search.yaml +69 -0
  175. package/src/adapters/safebooru/web.test.ts +60 -0
  176. package/src/adapters/safebooru/web.ts +130 -0
  177. package/src/adapters/vndb/web.test.ts +86 -0
  178. package/src/adapters/vndb/web.ts +393 -0
  179. package/src/adapters/yandere/detail.yaml +61 -0
  180. package/src/adapters/yandere/download.yaml +56 -0
  181. package/src/adapters/yandere/search.yaml +67 -0
  182. package/src/adapters/yandere/tags.yaml +41 -0
@@ -0,0 +1,557 @@
1
+ /**
2
+ * @owner src/adapters/dlsite/web.ts
3
+ * @does Register DLsite public search and work detail commands for doujin games, manga, CG, voice, and video.
4
+ * @needs DLsite public search/detail HTML structure and stable product_id URLs.
5
+ * @feeds ACG market research, tag/type search, time/hot/rating sorted discovery.
6
+ * @breaks DLsite search-result card markup drift can hide works.
7
+ */
8
+
9
+ import { USER_AGENT } from "../../constants.js";
10
+ import { cli, Strategy } from "../../registry.js";
11
+
12
+ const DLSITE_ORIGIN = "https://www.dlsite.com";
13
+ const DLSITE_SERVICE = "maniax";
14
+
15
+ interface DlsiteSearchRow {
16
+ rank: number;
17
+ product_id: string;
18
+ title: string;
19
+ maker: string;
20
+ maker_id: string;
21
+ work_type: string;
22
+ age: string;
23
+ price_jpy: string;
24
+ sales: string;
25
+ rating: string;
26
+ reviews: string;
27
+ thumb: string;
28
+ url: string;
29
+ }
30
+
31
+ function str(value: unknown): string {
32
+ return value === undefined || value === null ? "" : String(value);
33
+ }
34
+
35
+ export function decodeDlsiteHtml(value: unknown): string {
36
+ return str(value)
37
+ .replace(/&/g, "&")
38
+ .replace(/&lt;/g, "<")
39
+ .replace(/&gt;/g, ">")
40
+ .replace(/&quot;/g, '"')
41
+ .replace(/&#039;/g, "'")
42
+ .replace(/&#39;/g, "'")
43
+ .replace(/<[^>]+>/g, " ")
44
+ .replace(/\s+/g, " ")
45
+ .trim();
46
+ }
47
+
48
+ function requireLimit(value: unknown, fallback = 20): number {
49
+ if (value === undefined || value === null || value === "") return fallback;
50
+ const n = Number(value);
51
+ if (!Number.isInteger(n) || n < 1 || n > 100) {
52
+ throw new Error("DLsite limit must be an integer in [1, 100].");
53
+ }
54
+ return n;
55
+ }
56
+
57
+ function requireQuery(value: unknown): string {
58
+ const query = str(value).trim();
59
+ if (!query) throw new Error("DLsite query cannot be empty.");
60
+ return query;
61
+ }
62
+
63
+ function normalizeProductId(value: unknown): string {
64
+ const raw = str(value).trim().toUpperCase();
65
+ const match = raw.match(/[A-Z]{2}\d+/);
66
+ if (!match) throw new Error("DLsite product id must look like RJ005751.");
67
+ return match[0];
68
+ }
69
+
70
+ function normalizeService(value: unknown): string {
71
+ const service = str(value || DLSITE_SERVICE)
72
+ .trim()
73
+ .toLowerCase();
74
+ if (!/^[a-z0-9_-]+$/.test(service)) {
75
+ throw new Error("DLsite service must be a simple path segment.");
76
+ }
77
+ return service;
78
+ }
79
+
80
+ function normalizeMakerId(value: unknown): string {
81
+ const raw = str(value).trim().toUpperCase();
82
+ const match = raw.match(/[A-Z]{2}\d+/);
83
+ if (!match) throw new Error("DLsite maker id must look like RG01012594.");
84
+ return match[0];
85
+ }
86
+
87
+ function normalizeGenreId(value: unknown): string {
88
+ const raw = str(value).trim();
89
+ if (!/^\d{3}$/.test(raw)) {
90
+ throw new Error("DLsite genre id must be a three-digit code like 001.");
91
+ }
92
+ return raw;
93
+ }
94
+
95
+ function normalizeSort(value: unknown): string {
96
+ const key = str(value || "release")
97
+ .toLowerCase()
98
+ .replace(/[\s_-]+/g, "");
99
+ const map: Record<string, string> = {
100
+ release: "release",
101
+ time: "release",
102
+ newest: "release",
103
+ hot: "dl_d",
104
+ popular: "dl_d",
105
+ sales: "dl_d",
106
+ rating: "rate",
107
+ rate: "rate",
108
+ reviews: "review",
109
+ review: "review",
110
+ price: "price",
111
+ title: "title_d",
112
+ };
113
+ const sort = map[key];
114
+ if (!sort) {
115
+ throw new Error(
116
+ `Unsupported DLsite sort: ${value}. Supported: release, hot, rating, reviews, price, title.`,
117
+ );
118
+ }
119
+ return sort;
120
+ }
121
+
122
+ function normalizeType(value: unknown): string {
123
+ const key = str(value)
124
+ .trim()
125
+ .toLowerCase()
126
+ .replace(/[\s_-]+/g, "");
127
+ if (!key || key === "all") return "";
128
+ const map: Record<string, string> = {
129
+ manga: "MNG",
130
+ comic: "MNG",
131
+ cg: "ICG",
132
+ illustration: "ICG",
133
+ game: "ADV",
134
+ adv: "ADV",
135
+ novel: "DNV",
136
+ digitalnovel: "DNV",
137
+ voice: "SOU",
138
+ audio: "SOU",
139
+ video: "MOV",
140
+ movie: "MOV",
141
+ };
142
+ const type = map[key];
143
+ if (!type) {
144
+ throw new Error(`Unsupported DLsite work type: ${value}.`);
145
+ }
146
+ return type;
147
+ }
148
+
149
+ export function dlsiteSearchUrl(kwargs: Record<string, unknown>): string {
150
+ const query = encodeURIComponent(requireQuery(kwargs.query));
151
+ const sort = normalizeSort(kwargs.sort);
152
+ const page = Number(kwargs.page ?? 1);
153
+ if (!Number.isInteger(page) || page < 1) {
154
+ throw new Error("DLsite page must be a positive integer.");
155
+ }
156
+ const type = normalizeType(kwargs.type);
157
+ const typePath = type ? `work_type/${type}/` : "";
158
+ return `${DLSITE_ORIGIN}/${DLSITE_SERVICE}/fsr/=/${typePath}keyword/${query}/order/${sort}/page/${page}`;
159
+ }
160
+
161
+ function dlsiteListingUrl(
162
+ path: string,
163
+ kwargs: Record<string, unknown>,
164
+ ): string {
165
+ const sort = normalizeSort(kwargs.sort);
166
+ const page = Number(kwargs.page ?? 1);
167
+ if (!Number.isInteger(page) || page < 1) {
168
+ throw new Error("DLsite page must be a positive integer.");
169
+ }
170
+ return `${DLSITE_ORIGIN}/${DLSITE_SERVICE}/fsr/=/${path}/order/${sort}/page/${page}`;
171
+ }
172
+
173
+ export function dlsiteMakerUrl(kwargs: Record<string, unknown>): string {
174
+ const sort = normalizeSort(kwargs.sort);
175
+ const page = Number(kwargs.page ?? 1);
176
+ if (!Number.isInteger(page) || page < 1) {
177
+ throw new Error("DLsite page must be a positive integer.");
178
+ }
179
+ return `${DLSITE_ORIGIN}/${DLSITE_SERVICE}/circle/profile/=/page/${page}/maker_id/${normalizeMakerId(kwargs.maker_id)}.html/order/${sort}`;
180
+ }
181
+
182
+ export function dlsiteCreatorUrl(kwargs: Record<string, unknown>): string {
183
+ const creator = requireQuery(kwargs.creator);
184
+ return dlsiteListingUrl(
185
+ `keyword_creater/${encodeURIComponent(`"${creator}"`)}/ana_flg/all`,
186
+ kwargs,
187
+ );
188
+ }
189
+
190
+ export function dlsiteGenreUrl(kwargs: Record<string, unknown>): string {
191
+ return dlsiteListingUrl(`genre/${normalizeGenreId(kwargs.genre)}`, kwargs);
192
+ }
193
+
194
+ async function fetchText(url: string): Promise<string> {
195
+ const response = await fetch(url, {
196
+ headers: {
197
+ Accept: "text/html,application/xhtml+xml",
198
+ "User-Agent": USER_AGENT,
199
+ },
200
+ });
201
+ if (!response.ok) {
202
+ throw new Error(`DLsite request failed with HTTP ${response.status}.`);
203
+ }
204
+ return response.text();
205
+ }
206
+
207
+ function firstMatch(value: string, re: RegExp): string {
208
+ const match = value.match(re);
209
+ return match ? decodeDlsiteHtml(match[1]) : "";
210
+ }
211
+
212
+ function rawMatch(value: string, re: RegExp): string {
213
+ const match = value.match(re);
214
+ return match ? match[1].replace(/&amp;/g, "&") : "";
215
+ }
216
+
217
+ function normalizeUrl(value: string): string {
218
+ if (!value) return "";
219
+ if (value.startsWith("//")) return `https:${value}`;
220
+ if (value.startsWith("/")) return `${DLSITE_ORIGIN}${value}`;
221
+ return value;
222
+ }
223
+
224
+ export function parseDlsiteSearchHtml(
225
+ html: string,
226
+ limit: number,
227
+ ): DlsiteSearchRow[] {
228
+ const rows: DlsiteSearchRow[] = [];
229
+ const starts = [
230
+ ...html.matchAll(
231
+ /<([a-z][a-z0-9-]*)\b(?=[^>]*\bdata-list_item_product_id="([^"]+)")[^>]*>/gi,
232
+ ),
233
+ ];
234
+ for (let i = 0; i < starts.length; i += 1) {
235
+ const start = starts[i];
236
+ const productId = decodeDlsiteHtml(start[2]);
237
+ const begin = start.index ?? 0;
238
+ const end = starts[i + 1]?.index ?? html.length;
239
+ const chunk = html.slice(begin, end);
240
+ const title = firstMatch(
241
+ chunk,
242
+ /<dd class="work_name"[\s\S]*?<a[^>]+title="([^"]+)"/,
243
+ );
244
+ if (!productId || !title) continue;
245
+ const maker = firstMatch(
246
+ chunk,
247
+ /<dd class="maker_name"[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/,
248
+ );
249
+ const makerId = rawMatch(chunk, /maker_id\/([A-Z]{2}\d+)\.html/i);
250
+ const workType = firstMatch(
251
+ chunk,
252
+ /<div class="work_category[^"]*"[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/,
253
+ );
254
+ const age = firstMatch(chunk, /<span class="icon_[^"]+" title="([^"]+)"/);
255
+ const price = firstMatch(
256
+ chunk,
257
+ /<span class="work_price_base">([^<]+)<\/span>/,
258
+ );
259
+ const sales = firstMatch(
260
+ chunk,
261
+ /<dd class="work_dl">[\s\S]*?<span[^>]*>([^<]+)<\/span>/,
262
+ );
263
+ const rating = firstMatch(chunk, /<div class="star_rating\s+([^"\s]+)/);
264
+ const reviews = firstMatch(
265
+ chunk,
266
+ /<div class="star_rating[^"]*"[^>]*>\(([^)]+)\)/,
267
+ );
268
+ const thumb = normalizeUrl(
269
+ rawMatch(chunk, /thumb-candidates="\['([^']+)'/) ||
270
+ rawMatch(chunk, /\bdata-src="([^"]+)"/),
271
+ );
272
+ const url = normalizeUrl(rawMatch(chunk, /\blink="([^"]+)"/));
273
+ rows.push({
274
+ rank: rows.length + 1,
275
+ product_id: productId,
276
+ title,
277
+ maker,
278
+ maker_id: makerId.toUpperCase(),
279
+ work_type: workType,
280
+ age,
281
+ price_jpy: price,
282
+ sales,
283
+ rating,
284
+ reviews,
285
+ thumb,
286
+ url:
287
+ url ||
288
+ `${DLSITE_ORIGIN}/${DLSITE_SERVICE}/work/=/product_id/${productId}.html`,
289
+ });
290
+ if (rows.length >= limit) break;
291
+ }
292
+ return rows;
293
+ }
294
+
295
+ function outlineValue(html: string, label: string): string {
296
+ const re = new RegExp(`<th>${label}</th>\\s*<td>([\\s\\S]*?)</td>`, "i");
297
+ return firstMatch(html, re);
298
+ }
299
+
300
+ export function parseDlsiteDetailHtml(
301
+ html: string,
302
+ productId: string,
303
+ service = DLSITE_SERVICE,
304
+ ): Record<string, unknown> {
305
+ const event = rawMatch(
306
+ html,
307
+ new RegExp(`<div hidden class="ga4_event_item_${productId}"([^>]*)>`, "i"),
308
+ );
309
+ const eventField = (name: string) =>
310
+ firstMatch(event, new RegExp(`data-${name}="([^"]*)"`));
311
+ return {
312
+ product_id: productId,
313
+ title:
314
+ firstMatch(html, /<h1[^>]+id="work_name"[^>]*>([\s\S]*?)<\/h1>/) ||
315
+ firstMatch(html, /<meta property="og:title" content="([^"]+)"/),
316
+ maker:
317
+ firstMatch(html, /class="maker_name"[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/) ||
318
+ eventField("maker_id"),
319
+ maker_id: eventField("maker_id"),
320
+ work_type: eventField("work_type") || outlineValue(html, "作品形式"),
321
+ release_date: outlineValue(html, "販売日"),
322
+ age: outlineValue(html, "年齢指定"),
323
+ file_format: outlineValue(html, "ファイル形式"),
324
+ pages: outlineValue(html, "ページ数"),
325
+ file_size: outlineValue(html, "ファイル容量"),
326
+ price_jpy: eventField("price"),
327
+ image: normalizeUrl(
328
+ rawMatch(html, /<meta property="og:image" content="([^"]+)"/),
329
+ ),
330
+ description: firstMatch(
331
+ html,
332
+ /<div itemprop="description" class="work_parts_container">([\s\S]*?)<\/div>\s*<\/div>/,
333
+ ).slice(0, 1000),
334
+ url: `${DLSITE_ORIGIN}/${service}/work/=/product_id/${productId}.html`,
335
+ };
336
+ }
337
+
338
+ async function runSearch(kwargs: Record<string, unknown>) {
339
+ const rows = parseDlsiteSearchHtml(
340
+ await fetchText(dlsiteSearchUrl(kwargs)),
341
+ requireLimit(kwargs.limit),
342
+ );
343
+ if (rows.length === 0) throw new Error("No DLsite works found.");
344
+ return rows;
345
+ }
346
+
347
+ async function runListing(url: string, kwargs: Record<string, unknown>) {
348
+ const rows = parseDlsiteSearchHtml(
349
+ await fetchText(url),
350
+ requireLimit(kwargs.limit),
351
+ );
352
+ if (rows.length === 0) throw new Error("No DLsite works found.");
353
+ return rows;
354
+ }
355
+
356
+ const SEARCH_ARGS = [
357
+ { name: "query", type: "str" as const, required: true, positional: true },
358
+ { name: "limit", type: "int" as const, default: 20 },
359
+ { name: "page", type: "int" as const, default: 1 },
360
+ {
361
+ name: "sort",
362
+ type: "str" as const,
363
+ default: "release",
364
+ choices: ["release", "hot", "rating", "reviews", "price", "title"],
365
+ description: "release, hot, rating, reviews, price, title",
366
+ },
367
+ {
368
+ name: "type",
369
+ type: "str" as const,
370
+ choices: ["all", "manga", "cg", "game", "novel", "voice", "video"],
371
+ description: "all, manga, cg, game, novel, voice, video",
372
+ },
373
+ ];
374
+
375
+ const SEARCH_COLUMNS = [
376
+ "rank",
377
+ "product_id",
378
+ "title",
379
+ "maker",
380
+ "maker_id",
381
+ "work_type",
382
+ "age",
383
+ "price_jpy",
384
+ "sales",
385
+ "rating",
386
+ "reviews",
387
+ "url",
388
+ ];
389
+
390
+ cli({
391
+ site: "dlsite",
392
+ name: "search",
393
+ description: "Search DLsite doujin works by keyword, type, and sort order",
394
+ domain: "www.dlsite.com",
395
+ strategy: Strategy.PUBLIC,
396
+ browser: false,
397
+ args: SEARCH_ARGS,
398
+ columns: SEARCH_COLUMNS,
399
+ func: async (_page, kwargs) => runSearch(kwargs),
400
+ });
401
+
402
+ cli({
403
+ site: "dlsite",
404
+ name: "manga",
405
+ description: "Search DLsite manga works",
406
+ domain: "www.dlsite.com",
407
+ strategy: Strategy.PUBLIC,
408
+ browser: false,
409
+ args: SEARCH_ARGS.filter((arg) => arg.name !== "type"),
410
+ columns: SEARCH_COLUMNS,
411
+ func: async (_page, kwargs) => runSearch({ ...kwargs, type: "manga" }),
412
+ });
413
+
414
+ cli({
415
+ site: "dlsite",
416
+ name: "cg",
417
+ description: "Search DLsite CG and illustration works",
418
+ domain: "www.dlsite.com",
419
+ strategy: Strategy.PUBLIC,
420
+ browser: false,
421
+ args: SEARCH_ARGS.filter((arg) => arg.name !== "type"),
422
+ columns: SEARCH_COLUMNS,
423
+ func: async (_page, kwargs) => runSearch({ ...kwargs, type: "cg" }),
424
+ });
425
+
426
+ cli({
427
+ site: "dlsite",
428
+ name: "game",
429
+ description: "Search DLsite game and ADV works",
430
+ domain: "www.dlsite.com",
431
+ strategy: Strategy.PUBLIC,
432
+ browser: false,
433
+ args: SEARCH_ARGS.filter((arg) => arg.name !== "type"),
434
+ columns: SEARCH_COLUMNS,
435
+ func: async (_page, kwargs) => runSearch({ ...kwargs, type: "game" }),
436
+ });
437
+
438
+ cli({
439
+ site: "dlsite",
440
+ name: "maker",
441
+ description:
442
+ "Search DLsite works from a circle or maker id such as RG01012594 or VG02994",
443
+ domain: "www.dlsite.com",
444
+ strategy: Strategy.PUBLIC,
445
+ browser: false,
446
+ args: [
447
+ {
448
+ name: "maker_id",
449
+ type: "str" as const,
450
+ required: true,
451
+ positional: true,
452
+ },
453
+ { name: "limit", type: "int" as const, default: 20 },
454
+ { name: "page", type: "int" as const, default: 1 },
455
+ {
456
+ name: "sort",
457
+ type: "str" as const,
458
+ default: "release",
459
+ choices: ["release", "hot", "rating", "reviews", "price", "title"],
460
+ description: "release, hot, rating, reviews, price, title",
461
+ },
462
+ ],
463
+ columns: SEARCH_COLUMNS,
464
+ func: async (_page, kwargs) => runListing(dlsiteMakerUrl(kwargs), kwargs),
465
+ });
466
+
467
+ cli({
468
+ site: "dlsite",
469
+ name: "creator",
470
+ description:
471
+ "Search DLsite works by creator, author, illustrator, or voice actor name",
472
+ domain: "www.dlsite.com",
473
+ strategy: Strategy.PUBLIC,
474
+ browser: false,
475
+ args: [
476
+ { name: "creator", type: "str" as const, required: true, positional: true },
477
+ { name: "limit", type: "int" as const, default: 20 },
478
+ { name: "page", type: "int" as const, default: 1 },
479
+ {
480
+ name: "sort",
481
+ type: "str" as const,
482
+ default: "release",
483
+ choices: ["release", "hot", "rating", "reviews", "price", "title"],
484
+ description: "release, hot, rating, reviews, price, title",
485
+ },
486
+ ],
487
+ columns: SEARCH_COLUMNS,
488
+ func: async (_page, kwargs) => runListing(dlsiteCreatorUrl(kwargs), kwargs),
489
+ });
490
+
491
+ cli({
492
+ site: "dlsite",
493
+ name: "genre",
494
+ description: "Search DLsite works by DLsite genre tag id",
495
+ domain: "www.dlsite.com",
496
+ strategy: Strategy.PUBLIC,
497
+ browser: false,
498
+ args: [
499
+ { name: "genre", type: "str" as const, required: true, positional: true },
500
+ { name: "limit", type: "int" as const, default: 20 },
501
+ { name: "page", type: "int" as const, default: 1 },
502
+ {
503
+ name: "sort",
504
+ type: "str" as const,
505
+ default: "release",
506
+ choices: ["release", "hot", "rating", "reviews", "price", "title"],
507
+ description: "release, hot, rating, reviews, price, title",
508
+ },
509
+ ],
510
+ columns: SEARCH_COLUMNS,
511
+ func: async (_page, kwargs) => runListing(dlsiteGenreUrl(kwargs), kwargs),
512
+ });
513
+
514
+ cli({
515
+ site: "dlsite",
516
+ name: "work",
517
+ description: "Get DLsite public work detail by product id",
518
+ domain: "www.dlsite.com",
519
+ strategy: Strategy.PUBLIC,
520
+ browser: false,
521
+ args: [
522
+ { name: "id", type: "str", required: true, positional: true },
523
+ {
524
+ name: "service",
525
+ type: "str",
526
+ default: DLSITE_SERVICE,
527
+ description: "DLsite service path, for example maniax or books",
528
+ },
529
+ ],
530
+ columns: [
531
+ "product_id",
532
+ "title",
533
+ "maker",
534
+ "maker_id",
535
+ "work_type",
536
+ "release_date",
537
+ "age",
538
+ "file_format",
539
+ "pages",
540
+ "file_size",
541
+ "price_jpy",
542
+ "url",
543
+ ],
544
+ func: async (_page, kwargs) => {
545
+ const productId = normalizeProductId(kwargs.id);
546
+ const service = normalizeService(kwargs.service);
547
+ return [
548
+ parseDlsiteDetailHtml(
549
+ await fetchText(
550
+ `${DLSITE_ORIGIN}/${service}/work/=/product_id/${productId}.html`,
551
+ ),
552
+ productId,
553
+ service,
554
+ ),
555
+ ];
556
+ },
557
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveCommand } from "../../registry.js";
3
+ import {
4
+ buildEhentaiSearchQuery,
5
+ decodeEhentaiHtml,
6
+ ehentaiCategoryMask,
7
+ ehentaiSearchUrl,
8
+ mapEhentaiTorrents,
9
+ parseEhentaiGallery,
10
+ parseEhentaiGalleryPages,
11
+ parseEhentaiSearchHtml,
12
+ requireEhentaiLimit,
13
+ } from "./web.js";
14
+
15
+ describe("ehentai public gallery commands", () => {
16
+ it("registers search, tag, artist, gallery, pages, and torrents", () => {
17
+ expect(
18
+ Object.keys(resolveCommand("ehentai", "search")!.adapter.commands),
19
+ ).toEqual(
20
+ expect.arrayContaining([
21
+ "search",
22
+ "tag",
23
+ "artist",
24
+ "gallery",
25
+ "pages",
26
+ "torrents",
27
+ ]),
28
+ );
29
+ });
30
+
31
+ it("builds accurate structured search URLs for category and tag filters", () => {
32
+ expect(ehentaiCategoryMask("artistcg")).toBe(1015);
33
+ expect(ehentaiCategoryMask("cg")).toBe(999);
34
+ expect(ehentaiCategoryMask("manga,doujinshi")).toBe(1017);
35
+ expect(
36
+ buildEhentaiSearchQuery({
37
+ artist: "Tony Taka",
38
+ language: "chinese",
39
+ other: "full color",
40
+ }),
41
+ ).toBe("artist:tony_taka$ language:chinese$ other:full_color$");
42
+ expect(
43
+ buildEhentaiSearchQuery({
44
+ tags: "artist:Tony Taka,language:chinese",
45
+ exact_tags: false,
46
+ }),
47
+ ).toBe("artist:tony_taka language:chinese");
48
+ expect(() => buildEhentaiSearchQuery({ tags: "tony taka" })).toThrow(
49
+ "namespaced",
50
+ );
51
+ expect(
52
+ ehentaiSearchUrl({
53
+ query: "artist:tony_taka$",
54
+ page: 0,
55
+ cursor: "",
56
+ categoryMask: 1015,
57
+ requireTorrent: true,
58
+ includeExpunged: false,
59
+ minPages: "",
60
+ maxPages: "",
61
+ minRating: "",
62
+ }),
63
+ ).toBe(
64
+ "https://e-hentai.org/?f_search=artist%3Atony_taka%24&f_cats=1015&advsearch=1&f_sto=on",
65
+ );
66
+ });
67
+
68
+ it("validates gallery identity and limits", () => {
69
+ expect(parseEhentaiGallery("3316027/8c4fbe8822")).toEqual({
70
+ gid: 3316027,
71
+ token: "8c4fbe8822",
72
+ url: "https://e-hentai.org/g/3316027/8c4fbe8822/",
73
+ });
74
+ expect(
75
+ parseEhentaiGallery("https://e-hentai.org/g/3316027/8c4fbe8822/"),
76
+ ).toEqual({
77
+ gid: 3316027,
78
+ token: "8c4fbe8822",
79
+ url: "https://e-hentai.org/g/3316027/8c4fbe8822/",
80
+ });
81
+ expect(() =>
82
+ parseEhentaiGallery("https://example.com/g/1/abcdef1234/"),
83
+ ).toThrow("e-hentai.org");
84
+ expect(requireEhentaiLimit(undefined)).toBe(20);
85
+ expect(() => requireEhentaiLimit(101)).toThrow("integer");
86
+ expect(decodeEhentaiHtml("A &amp; B &#39; C")).toBe("A & B ' C");
87
+ });
88
+
89
+ it("parses search rows from compact gallery table markup", () => {
90
+ const rows = parseEhentaiSearchHtml(
91
+ `<table><tr><td class="gl1c glcat"><div class="cn ct9">Non-H</div></td>
92
+ <td class="gl2c"><div><div id="posted_3316027">2025-04-15 08:13</div>
93
+ <div>8 pages</div><a href="https://e-hentai.org/gallerytorrents.php?gid=3316027&amp;t=8c4fbe8822">T</a></div>
94
+ <img alt="thumb" data-src="https://ehgt.org/thumb.webp" /></td>
95
+ <td class="gl3c glname"><a href="https://e-hentai.org/g/3316027/8c4fbe8822/">
96
+ <div class="glink">Copyright free landscape</div><div><div class="gt" title="language:english">english</div></div></a></td>
97
+ <td class="gl4c glhide"><div><a>7qweij</a></div><div>8 pages</div></td></tr></table>`,
98
+ 10,
99
+ );
100
+ expect(rows).toEqual([
101
+ {
102
+ rank: 1,
103
+ gid: 3316027,
104
+ token: "8c4fbe8822",
105
+ title: "Copyright free landscape",
106
+ category: "Non-H",
107
+ published: "2025-04-15 08:13",
108
+ pages: "8 pages",
109
+ uploader: "7qweij",
110
+ thumb: "https://ehgt.org/thumb.webp",
111
+ torrent_available: true,
112
+ tags: "language:english",
113
+ url: "https://e-hentai.org/g/3316027/8c4fbe8822/",
114
+ },
115
+ ]);
116
+ });
117
+
118
+ it("maps torrent metadata and gallery pages", () => {
119
+ expect(
120
+ mapEhentaiTorrents({
121
+ gid: 3316027,
122
+ title: "Gallery",
123
+ torrents: [
124
+ { hash: "abc", added: "1", name: "g.zip", tsize: "2", fsize: "3" },
125
+ ],
126
+ }),
127
+ ).toEqual([
128
+ {
129
+ rank: 1,
130
+ gid: 3316027,
131
+ title: "Gallery",
132
+ hash: "abc",
133
+ added: "1",
134
+ name: "g.zip",
135
+ tsize: "2",
136
+ fsize: "3",
137
+ },
138
+ ]);
139
+ expect(
140
+ parseEhentaiGalleryPages(
141
+ `<h1 id="gn">Gallery</h1><div id="gdt"><a href="https://e-hentai.org/s/f7ee85eea1/3316027-1"><div title="Page 1: 123.png" style="background:transparent url(https://thumb.webp) -0px 0 no-repeat"></div></a></div>`,
142
+ parseEhentaiGallery("3316027/8c4fbe8822"),
143
+ 5,
144
+ ),
145
+ ).toEqual([
146
+ {
147
+ page: 1,
148
+ gid: 3316027,
149
+ title: "Gallery",
150
+ filename: "123.png",
151
+ page_url: "https://e-hentai.org/s/f7ee85eea1/3316027-1",
152
+ thumb_sprite_url: "https://thumb.webp",
153
+ gallery_url: "https://e-hentai.org/g/3316027/8c4fbe8822/",
154
+ },
155
+ ]);
156
+ });
157
+ });