jav-manager 0.1.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.
- package/README.ja.md +188 -0
- package/README.ko.md +188 -0
- package/README.md +173 -0
- package/README.zh-CN.md +188 -0
- package/bin/jav-manager.js +3 -0
- package/dist/cli.js +774 -0
- package/dist/config.js +324 -0
- package/dist/context.js +2 -0
- package/dist/data/cache.js +201 -0
- package/dist/data/curlImpersonateFetcher.js +499 -0
- package/dist/data/everything.js +130 -0
- package/dist/data/javdb.js +646 -0
- package/dist/data/qbittorrent.js +214 -0
- package/dist/gui.js +417 -0
- package/dist/index.js +81 -0
- package/dist/interfaces.js +2 -0
- package/dist/localization.js +114 -0
- package/dist/models.js +15 -0
- package/dist/services.js +526 -0
- package/dist/utils/appInfo.js +24 -0
- package/dist/utils/appPaths.js +31 -0
- package/dist/utils/cliDisplay.js +551 -0
- package/dist/utils/curlBinaryResolver.js +154 -0
- package/dist/utils/httpHelper.js +78 -0
- package/dist/utils/platformShell.js +18 -0
- package/dist/utils/sizeParser.js +48 -0
- package/dist/utils/telemetryEndpoints.js +44 -0
- package/dist/utils/torrentNameParser.js +44 -0
- package/dist/utils/weightCalculator.js +38 -0
- package/package.json +41 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.JavDbWebScraper = void 0;
|
|
37
|
+
const cheerio = __importStar(require("cheerio"));
|
|
38
|
+
const models_1 = require("../models");
|
|
39
|
+
const torrentNameParser_1 = require("../utils/torrentNameParser");
|
|
40
|
+
const curlImpersonateFetcher_1 = require("./curlImpersonateFetcher");
|
|
41
|
+
const MaxAttemptsPerUrl = 4;
|
|
42
|
+
const MaxUrlCycles = 2;
|
|
43
|
+
const RetryBaseDelayMs = 1000;
|
|
44
|
+
const PreferredLocale = "zh";
|
|
45
|
+
class JavDbWebScraper {
|
|
46
|
+
config;
|
|
47
|
+
userAgents;
|
|
48
|
+
cookieJar = new Map();
|
|
49
|
+
curlFetcher;
|
|
50
|
+
constructor(config) {
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.userAgents = buildUserAgentCandidates(config);
|
|
53
|
+
this.curlFetcher = new curlImpersonateFetcher_1.CurlImpersonateFetcher(config.curlImpersonate);
|
|
54
|
+
}
|
|
55
|
+
get serviceName() {
|
|
56
|
+
return "JavDB";
|
|
57
|
+
}
|
|
58
|
+
async search(javId) {
|
|
59
|
+
const candidates = await this.searchCandidates(javId);
|
|
60
|
+
if (candidates.length === 0) {
|
|
61
|
+
return emptySearchResult(javId);
|
|
62
|
+
}
|
|
63
|
+
const selected = chooseBestCandidate(candidates, javId);
|
|
64
|
+
const detail = await this.getDetail(selected.detailUrl);
|
|
65
|
+
if (!detail.javId) {
|
|
66
|
+
detail.javId = javId;
|
|
67
|
+
}
|
|
68
|
+
return detail;
|
|
69
|
+
}
|
|
70
|
+
async searchCandidates(javId) {
|
|
71
|
+
const urls = buildBaseUrls(this.config);
|
|
72
|
+
let lastError = "";
|
|
73
|
+
for (let cycle = 0; cycle < MaxUrlCycles; cycle += 1) {
|
|
74
|
+
for (const baseUrl of urls) {
|
|
75
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
76
|
+
this.seedCookiesForUrl(trimmed);
|
|
77
|
+
const homeUrl = withLocale(trimmed, PreferredLocale);
|
|
78
|
+
const home = await this.getWithRetry(homeUrl, null, MaxAttemptsPerUrl);
|
|
79
|
+
if (!isSuccessStatus(home.status)) {
|
|
80
|
+
lastError = home.error ?? `HTTP ${home.status}`;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const searchUrl = withLocale(`${trimmed}/search?q=${encodeURIComponent(javId)}&f=all`, PreferredLocale);
|
|
84
|
+
const search = await this.getWithRetry(searchUrl, homeUrl, MaxAttemptsPerUrl);
|
|
85
|
+
if (!isSuccessStatus(search.status)) {
|
|
86
|
+
lastError = search.error ?? `HTTP ${search.status}`;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const results = parseSearchResults(search.body);
|
|
90
|
+
for (const result of results) {
|
|
91
|
+
if (result.detailUrl && !/^https?:\/\//i.test(result.detailUrl)) {
|
|
92
|
+
result.detailUrl = `${trimmed}${result.detailUrl}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const deduped = dedupeResults(results);
|
|
96
|
+
if (deduped.length === 0) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
return deduped;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`JavDB request failed: ${lastError}`);
|
|
103
|
+
}
|
|
104
|
+
async getDetail(detailUrl) {
|
|
105
|
+
const baseUrl = this.config.baseUrl.replace(/\/+$/, "");
|
|
106
|
+
let url = detailUrl;
|
|
107
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
108
|
+
url = `${baseUrl}${detailUrl}`;
|
|
109
|
+
}
|
|
110
|
+
url = withLocale(url, PreferredLocale);
|
|
111
|
+
const referer = url.startsWith(baseUrl) ? withLocale(baseUrl, PreferredLocale) : withLocale(new URL(url).origin, PreferredLocale);
|
|
112
|
+
this.seedCookiesForUrl(referer);
|
|
113
|
+
const response = await this.getWithRetry(url, referer, MaxAttemptsPerUrl);
|
|
114
|
+
if (!isSuccessStatus(response.status)) {
|
|
115
|
+
throw new Error(response.error ?? `HTTP ${response.status}`);
|
|
116
|
+
}
|
|
117
|
+
const detail = parseDetailPage(response.body);
|
|
118
|
+
detail.detailUrl = url;
|
|
119
|
+
detail.torrents = parseTorrentLinks(response.body);
|
|
120
|
+
return detail;
|
|
121
|
+
}
|
|
122
|
+
async checkHealth() {
|
|
123
|
+
const urls = buildBaseUrls(this.config);
|
|
124
|
+
if (urls.length === 0) {
|
|
125
|
+
return { serviceName: this.serviceName, isHealthy: false, message: "No JavDB base URL configured" };
|
|
126
|
+
}
|
|
127
|
+
for (const url of urls) {
|
|
128
|
+
const trimmed = url.replace(/\/+$/, "");
|
|
129
|
+
this.seedCookiesForUrl(trimmed);
|
|
130
|
+
const response = await this.getWithRetry(trimmed, null, 2, 3000);
|
|
131
|
+
if (isSuccessStatus(response.status)) {
|
|
132
|
+
return { serviceName: this.serviceName, isHealthy: true, message: "OK", url: trimmed };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { serviceName: this.serviceName, isHealthy: false, message: "JavDB unreachable", url: this.config.baseUrl };
|
|
136
|
+
}
|
|
137
|
+
async getWithRetry(url, referer, maxAttempts, timeoutMs) {
|
|
138
|
+
let lastError = "";
|
|
139
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
140
|
+
if (attempt > 0) {
|
|
141
|
+
await delay(getRetryDelay(attempt));
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
await delay(100 + Math.floor(Math.random() * 300));
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const result = await this.sendRequest(url, referer, attempt, timeoutMs);
|
|
148
|
+
if (isSuccessStatus(result.status)) {
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
lastError = result.error ?? `HTTP ${result.status}`;
|
|
152
|
+
if (!isRetryableStatus(result.status)) {
|
|
153
|
+
return { ...result, error: lastError };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
lastError = error instanceof Error ? error.message : "Unknown error";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { status: 0, body: "", error: lastError };
|
|
161
|
+
}
|
|
162
|
+
async sendRequest(url, referer, attemptIndex, timeoutMs) {
|
|
163
|
+
const cookieHeader = this.getCookieHeader(url);
|
|
164
|
+
const timeout = timeoutMs ?? this.config.requestTimeout;
|
|
165
|
+
// Try curl-impersonate first if enabled and available
|
|
166
|
+
if (this.curlFetcher.isAvailable()) {
|
|
167
|
+
try {
|
|
168
|
+
const result = await this.curlFetcher.get(url, referer, cookieHeader, timeout);
|
|
169
|
+
if (isSuccessStatus(result.status)) {
|
|
170
|
+
return { status: result.status, body: result.body };
|
|
171
|
+
}
|
|
172
|
+
// If curl-impersonate fails, fall back to standard fetch
|
|
173
|
+
console.warn(`curl-impersonate failed: ${result.error}, falling back to standard fetch`);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
console.warn(`curl-impersonate error: ${error instanceof Error ? error.message : "Unknown error"}, falling back to standard fetch`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Fallback to standard fetch
|
|
180
|
+
const userAgent = this.userAgents[attemptIndex % this.userAgents.length] ?? defaultUserAgent;
|
|
181
|
+
const headers = buildChromeHeaders(userAgent, referer);
|
|
182
|
+
if (cookieHeader) {
|
|
183
|
+
headers.Cookie = cookieHeader;
|
|
184
|
+
}
|
|
185
|
+
const controller = new AbortController();
|
|
186
|
+
const timeoutTimer = setTimeout(() => controller.abort(), timeout);
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch(url, {
|
|
189
|
+
method: "GET",
|
|
190
|
+
headers,
|
|
191
|
+
signal: controller.signal,
|
|
192
|
+
});
|
|
193
|
+
const body = await response.text();
|
|
194
|
+
this.captureCookies(url, response.headers);
|
|
195
|
+
return { status: response.status, body };
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
clearTimeout(timeoutTimer);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
seedCookiesForUrl(baseUrl) {
|
|
202
|
+
if (!baseUrl) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const host = new URL(baseUrl).host;
|
|
206
|
+
const jar = this.cookieJar.get(host) ?? new Map();
|
|
207
|
+
jar.set("over18", "1");
|
|
208
|
+
jar.set("locale", "zh");
|
|
209
|
+
this.cookieJar.set(host, jar);
|
|
210
|
+
}
|
|
211
|
+
getCookieHeader(url) {
|
|
212
|
+
const host = new URL(url).host;
|
|
213
|
+
const jar = this.cookieJar.get(host);
|
|
214
|
+
if (!jar || jar.size === 0) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return Array.from(jar.entries())
|
|
218
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
219
|
+
.join("; ");
|
|
220
|
+
}
|
|
221
|
+
captureCookies(url, headers) {
|
|
222
|
+
const host = new URL(url).host;
|
|
223
|
+
const jar = this.cookieJar.get(host) ?? new Map();
|
|
224
|
+
const setCookies = typeof headers.getSetCookie === "function"
|
|
225
|
+
? headers.getSetCookie()
|
|
226
|
+
: (headers.get("set-cookie") ? [headers.get("set-cookie")] : []);
|
|
227
|
+
for (const raw of setCookies) {
|
|
228
|
+
const [pair] = raw.split(";", 1);
|
|
229
|
+
const [name, value] = pair.split("=", 2);
|
|
230
|
+
if (name && value) {
|
|
231
|
+
jar.set(name.trim(), value.trim());
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
this.cookieJar.set(host, jar);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.JavDbWebScraper = JavDbWebScraper;
|
|
238
|
+
function buildBaseUrls(config) {
|
|
239
|
+
const urls = [config.baseUrl, ...config.mirrorUrls]
|
|
240
|
+
.map((url) => url.trim())
|
|
241
|
+
.filter((url) => url.length > 0);
|
|
242
|
+
return Array.from(new Set(urls));
|
|
243
|
+
}
|
|
244
|
+
function buildUserAgentCandidates(config) {
|
|
245
|
+
const candidates = [];
|
|
246
|
+
if (config.userAgent?.trim()) {
|
|
247
|
+
candidates.push(config.userAgent.trim());
|
|
248
|
+
}
|
|
249
|
+
candidates.push(defaultUserAgent);
|
|
250
|
+
candidates.push("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36");
|
|
251
|
+
candidates.push("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36");
|
|
252
|
+
return Array.from(new Set(candidates));
|
|
253
|
+
}
|
|
254
|
+
function buildChromeHeaders(userAgent, referer) {
|
|
255
|
+
const chromeMajor = parseChromeMajorVersion(userAgent);
|
|
256
|
+
const platform = getPlatformFromUserAgent(userAgent);
|
|
257
|
+
const mobile = userAgent.toLowerCase().includes("mobile") ? "?1" : "?0";
|
|
258
|
+
const headers = {
|
|
259
|
+
Connection: "keep-alive",
|
|
260
|
+
"Cache-Control": "max-age=0",
|
|
261
|
+
"sec-ch-ua": `"Google Chrome";v="${chromeMajor}", "Chromium";v="${chromeMajor}", "Not_A Brand";v="24"`,
|
|
262
|
+
"sec-ch-ua-mobile": mobile,
|
|
263
|
+
"sec-ch-ua-platform": `"${platform}"`,
|
|
264
|
+
DNT: "1",
|
|
265
|
+
"Upgrade-Insecure-Requests": "1",
|
|
266
|
+
"User-Agent": userAgent,
|
|
267
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
268
|
+
"Sec-Fetch-Site": referer ? "same-origin" : "none",
|
|
269
|
+
"Sec-Fetch-Mode": "navigate",
|
|
270
|
+
"Sec-Fetch-User": "?1",
|
|
271
|
+
"Sec-Fetch-Dest": "document",
|
|
272
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
273
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,ja;q=0.7",
|
|
274
|
+
};
|
|
275
|
+
if (referer) {
|
|
276
|
+
headers.Referer = referer;
|
|
277
|
+
}
|
|
278
|
+
return headers;
|
|
279
|
+
}
|
|
280
|
+
function parseChromeMajorVersion(userAgent) {
|
|
281
|
+
const match = userAgent.match(/Chrome\/(\d+)/);
|
|
282
|
+
return match?.[1] ?? "131";
|
|
283
|
+
}
|
|
284
|
+
function getPlatformFromUserAgent(userAgent) {
|
|
285
|
+
const ua = userAgent.toLowerCase();
|
|
286
|
+
if (ua.includes("windows"))
|
|
287
|
+
return "Windows";
|
|
288
|
+
if (ua.includes("mac os x") || ua.includes("macintosh"))
|
|
289
|
+
return "macOS";
|
|
290
|
+
if (ua.includes("linux"))
|
|
291
|
+
return "Linux";
|
|
292
|
+
return "Windows";
|
|
293
|
+
}
|
|
294
|
+
function isSuccessStatus(status) {
|
|
295
|
+
return status >= 200 && status < 300;
|
|
296
|
+
}
|
|
297
|
+
function isRetryableStatus(status) {
|
|
298
|
+
return [0, 403, 408, 425, 429, 500, 502, 503, 520, 522, 524].includes(status);
|
|
299
|
+
}
|
|
300
|
+
function getRetryDelay(attemptIndex) {
|
|
301
|
+
const base = RetryBaseDelayMs * Math.pow(1.5, attemptIndex);
|
|
302
|
+
const jitter = Math.floor(Math.random() * 800) - 300;
|
|
303
|
+
return Math.max(500, base + jitter);
|
|
304
|
+
}
|
|
305
|
+
function delay(ms) {
|
|
306
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
307
|
+
}
|
|
308
|
+
function withLocale(url, locale) {
|
|
309
|
+
try {
|
|
310
|
+
const u = new URL(url);
|
|
311
|
+
if (!u.searchParams.has("locale")) {
|
|
312
|
+
u.searchParams.set("locale", locale);
|
|
313
|
+
}
|
|
314
|
+
return u.toString();
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return url;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function parseSearchResults(html) {
|
|
321
|
+
const results = [];
|
|
322
|
+
const $ = cheerio.load(html);
|
|
323
|
+
const items = $("div.item").has("a.box");
|
|
324
|
+
items.each((_, element) => {
|
|
325
|
+
const link = $(element).find("a.box").first();
|
|
326
|
+
if (!link.length) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const detailUrl = link.attr("href") ?? "";
|
|
330
|
+
const titleAttr = link.attr("title") ?? "";
|
|
331
|
+
const titleText = normalizeInlineText(link.text());
|
|
332
|
+
const title = titleAttr || titleText;
|
|
333
|
+
const coverNode = $(element).find("img.video-cover").first();
|
|
334
|
+
const coverUrl = coverNode.attr("data-src") || coverNode.attr("src") || "";
|
|
335
|
+
const idNode = $(element).find(".uid, .video-id, .video-uid, .video_id").first();
|
|
336
|
+
const idFromNode = extractJavIdFromText(normalizeInlineText(idNode.text()));
|
|
337
|
+
const idFromText = extractJavIdFromText(normalizeInlineText($(element).text()));
|
|
338
|
+
const idFromTitle = extractJavIdFromText(title);
|
|
339
|
+
const javId = idFromNode ?? idFromText ?? idFromTitle ?? "";
|
|
340
|
+
results.push({
|
|
341
|
+
javId,
|
|
342
|
+
title,
|
|
343
|
+
coverUrl,
|
|
344
|
+
detailUrl,
|
|
345
|
+
releaseDate: undefined,
|
|
346
|
+
duration: 0,
|
|
347
|
+
director: "",
|
|
348
|
+
maker: "",
|
|
349
|
+
publisher: "",
|
|
350
|
+
series: "",
|
|
351
|
+
actors: [],
|
|
352
|
+
categories: [],
|
|
353
|
+
torrents: [],
|
|
354
|
+
dataSource: "Remote",
|
|
355
|
+
cachedAt: undefined,
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
return results;
|
|
359
|
+
}
|
|
360
|
+
function parseDetailPage(html) {
|
|
361
|
+
const $ = cheerio.load(html);
|
|
362
|
+
const title = normalizeInlineText($("h2.title").first().text());
|
|
363
|
+
let javId = normalizeInlineText($("span.current-title").first().text());
|
|
364
|
+
if (!javId) {
|
|
365
|
+
javId = extractJavIdFromText(title) ?? "";
|
|
366
|
+
}
|
|
367
|
+
const coverNode = $("img.video-cover").first();
|
|
368
|
+
const coverUrl = coverNode.attr("data-src") || coverNode.attr("src") || "";
|
|
369
|
+
const releaseDate = parseMetaField($, ["發行日期", "发行日期", "發布日期", "発売日", "Released Date", "Release Date", "日期"]);
|
|
370
|
+
const duration = parseDuration(parseMetaField($, ["時長", "时长", "片長", "片长", "収録時間", "Duration"]));
|
|
371
|
+
const director = parseMetaField($, ["導演", "导演", "監督", "Director"]);
|
|
372
|
+
const maker = parseMetaField($, ["片商", "製作商", "制作商", "メーカー", "Maker", "Studio"]);
|
|
373
|
+
const publisher = parseMetaField($, ["發行", "发行", "レーベル", "Publisher", "Label"]);
|
|
374
|
+
const series = parseMetaField($, ["系列", "シリーズ", "Series"]);
|
|
375
|
+
const actors = parseList($, [
|
|
376
|
+
"div.video-meta-panel strong:contains('演員') ~ span a",
|
|
377
|
+
"div.video-meta-panel strong:contains('演员') ~ span a",
|
|
378
|
+
"div.video-meta-panel strong:contains('出演者') ~ span a",
|
|
379
|
+
"div.panel-block strong:contains('演員') ~ span a",
|
|
380
|
+
"div.panel-block strong:contains('演员') ~ span a",
|
|
381
|
+
"div.panel-block strong:contains('出演者') ~ span a",
|
|
382
|
+
"div.panel-block strong:contains('演員') ~ a",
|
|
383
|
+
"div.panel-block strong:contains('演员') ~ a",
|
|
384
|
+
"div.panel-block strong:contains('出演者') ~ a",
|
|
385
|
+
"div.panel-block a[href*='/actors/']",
|
|
386
|
+
]);
|
|
387
|
+
const categories = parseList($, [
|
|
388
|
+
"div.video-meta-panel strong:contains('Tags') ~ span a",
|
|
389
|
+
"div.panel-block strong:contains('Tags') ~ span a",
|
|
390
|
+
"div.video-meta-panel strong:contains('類別') ~ span a",
|
|
391
|
+
"div.video-meta-panel strong:contains('类别') ~ span a",
|
|
392
|
+
"div.video-meta-panel strong:contains('ジャンル') ~ span a",
|
|
393
|
+
"div.panel-block strong:contains('類別') ~ span a",
|
|
394
|
+
"div.panel-block strong:contains('类别') ~ span a",
|
|
395
|
+
"div.panel-block strong:contains('ジャンル') ~ span a",
|
|
396
|
+
"div.panel-block strong:contains('類別') ~ a",
|
|
397
|
+
"div.panel-block strong:contains('类别') ~ a",
|
|
398
|
+
"div.panel-block strong:contains('ジャンル') ~ a",
|
|
399
|
+
"div.video-meta-panel a[href^='/tags']",
|
|
400
|
+
"div.panel-block a[href^='/tags']",
|
|
401
|
+
]);
|
|
402
|
+
return {
|
|
403
|
+
javId,
|
|
404
|
+
title,
|
|
405
|
+
coverUrl,
|
|
406
|
+
releaseDate: releaseDate || undefined,
|
|
407
|
+
duration,
|
|
408
|
+
director,
|
|
409
|
+
maker,
|
|
410
|
+
publisher,
|
|
411
|
+
series,
|
|
412
|
+
actors,
|
|
413
|
+
categories,
|
|
414
|
+
torrents: [],
|
|
415
|
+
detailUrl: "",
|
|
416
|
+
dataSource: "Remote",
|
|
417
|
+
cachedAt: undefined,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function parseMetaField($, keywords) {
|
|
421
|
+
for (const keyword of keywords) {
|
|
422
|
+
const selectors = [
|
|
423
|
+
`div.video-meta-panel strong:contains('${keyword}') ~ span`,
|
|
424
|
+
`div.video-meta-panel span:contains('${keyword}') ~ span`,
|
|
425
|
+
`div.video-meta-panel span:contains('${keyword}') ~ a`,
|
|
426
|
+
`div.panel-block strong:contains('${keyword}') ~ span`,
|
|
427
|
+
`div.panel-block strong:contains('${keyword}') ~ a`,
|
|
428
|
+
];
|
|
429
|
+
for (const selector of selectors) {
|
|
430
|
+
const nodes = $(selector);
|
|
431
|
+
for (const node of nodes.toArray()) {
|
|
432
|
+
const text = normalizeInlineText($(node).text());
|
|
433
|
+
if (isMeaningfulMetaText(text)) {
|
|
434
|
+
return text;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return "";
|
|
440
|
+
}
|
|
441
|
+
function isMeaningfulMetaText(text) {
|
|
442
|
+
if (!text) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
const normalized = text.trim().toLowerCase();
|
|
446
|
+
return normalized !== "n/a" && normalized !== "-" && normalized !== "--";
|
|
447
|
+
}
|
|
448
|
+
function parseDuration(text) {
|
|
449
|
+
if (!text) {
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
const match = text.match(/(\d+)\s*(?:分鐘|分钟|min)/i);
|
|
453
|
+
if (match) {
|
|
454
|
+
return Number(match[1]);
|
|
455
|
+
}
|
|
456
|
+
const asNumber = Number(text.trim());
|
|
457
|
+
return Number.isNaN(asNumber) ? 0 : asNumber;
|
|
458
|
+
}
|
|
459
|
+
function parseList($, selectors) {
|
|
460
|
+
for (const selector of selectors) {
|
|
461
|
+
const nodes = $(selector);
|
|
462
|
+
if (nodes.length) {
|
|
463
|
+
const items = [];
|
|
464
|
+
nodes.each((_, element) => {
|
|
465
|
+
const text = normalizeInlineText($(element).text());
|
|
466
|
+
if (text && !items.includes(text)) {
|
|
467
|
+
items.push(text);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
return items;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
function parseTorrentLinks(html) {
|
|
476
|
+
const $ = cheerio.load(html);
|
|
477
|
+
const torrents = [];
|
|
478
|
+
const seenMagnets = new Set();
|
|
479
|
+
$("div.magnet-name").each((_, element) => {
|
|
480
|
+
const magnetNode = $(element).find("a[href^='magnet:']").first();
|
|
481
|
+
const magnetLink = magnetNode.attr("href") ?? "";
|
|
482
|
+
if (!magnetLink.startsWith("magnet:")) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const hash = extractMagnetHash(magnetLink);
|
|
486
|
+
if (hash && seenMagnets.has(hash)) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (hash) {
|
|
490
|
+
seenMagnets.add(hash);
|
|
491
|
+
}
|
|
492
|
+
const title = normalizeInlineText($(element).find("span.name").first().text());
|
|
493
|
+
const meta = normalizeInlineText($(element).find("span.meta").first().text());
|
|
494
|
+
let size = extractSizeFromMagnet(magnetLink);
|
|
495
|
+
if (!size) {
|
|
496
|
+
size = parseSizeBytes(meta) ?? 0;
|
|
497
|
+
}
|
|
498
|
+
const tags = $(element).find("span.tag");
|
|
499
|
+
let hasSubtitle = false;
|
|
500
|
+
let hasUncensored = false;
|
|
501
|
+
let hasHd = false;
|
|
502
|
+
tags.each((_, tag) => {
|
|
503
|
+
const tagText = normalizeInlineText($(tag).text());
|
|
504
|
+
if (!tagText) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (tagText.includes("字幕") || tagText.includes("中文") || tagText.includes("中文字幕")) {
|
|
508
|
+
hasSubtitle = true;
|
|
509
|
+
}
|
|
510
|
+
if (tagText.includes("無碼") || tagText.includes("无码") || tagText.includes("破解")) {
|
|
511
|
+
hasUncensored = true;
|
|
512
|
+
}
|
|
513
|
+
if (tagText.includes("高清") || /HD|1080|720|4K/i.test(tagText)) {
|
|
514
|
+
hasHd = true;
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
if (!hasHd && /HD|1080|720|4K/i.test(title)) {
|
|
518
|
+
hasHd = true;
|
|
519
|
+
}
|
|
520
|
+
const { uncensoredType } = (0, torrentNameParser_1.parseTorrentName)(title);
|
|
521
|
+
hasUncensored = hasUncensored || uncensoredType !== models_1.UncensoredMarkerType.None;
|
|
522
|
+
const uncensoredMarkerType = hasUncensored
|
|
523
|
+
? hasSubtitle
|
|
524
|
+
? models_1.UncensoredMarkerType.UC
|
|
525
|
+
: models_1.UncensoredMarkerType.U
|
|
526
|
+
: models_1.UncensoredMarkerType.None;
|
|
527
|
+
torrents.push({
|
|
528
|
+
title,
|
|
529
|
+
magnetLink,
|
|
530
|
+
size,
|
|
531
|
+
hasSubtitle,
|
|
532
|
+
hasUncensoredMarker: hasUncensored,
|
|
533
|
+
uncensoredMarkerType,
|
|
534
|
+
hasHd,
|
|
535
|
+
seeders: 0,
|
|
536
|
+
leechers: 0,
|
|
537
|
+
sourceSite: "JavDB",
|
|
538
|
+
progress: undefined,
|
|
539
|
+
state: undefined,
|
|
540
|
+
dlSpeed: 0,
|
|
541
|
+
eta: 0,
|
|
542
|
+
weightScore: 0,
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
return torrents;
|
|
546
|
+
}
|
|
547
|
+
function normalizeInlineText(text) {
|
|
548
|
+
return text.replace(/\s+/g, " ").trim();
|
|
549
|
+
}
|
|
550
|
+
function extractMagnetHash(magnetLink) {
|
|
551
|
+
const match = magnetLink.match(/btih:([a-fA-F0-9]+)/);
|
|
552
|
+
return match?.[1]?.toLowerCase() ?? "";
|
|
553
|
+
}
|
|
554
|
+
function extractSizeFromMagnet(magnetLink) {
|
|
555
|
+
const match = magnetLink.match(/[?&]xl=(\d+)/i);
|
|
556
|
+
if (!match) {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
const value = Number(match[1]);
|
|
560
|
+
return Number.isNaN(value) ? null : value;
|
|
561
|
+
}
|
|
562
|
+
function parseSizeBytes(text) {
|
|
563
|
+
if (!text) {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
const match = text.match(/(\d+(?:\.\d+)?)\s*(KiB|MiB|GiB|TiB|KB|MB|GB|TB|B)\b/i);
|
|
567
|
+
if (!match) {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
const value = Number(match[1]);
|
|
571
|
+
if (Number.isNaN(value)) {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
const unit = match[2].toUpperCase();
|
|
575
|
+
const multiplier = {
|
|
576
|
+
B: 1,
|
|
577
|
+
KB: 1024,
|
|
578
|
+
KIB: 1024,
|
|
579
|
+
MB: 1024 * 1024,
|
|
580
|
+
MIB: 1024 * 1024,
|
|
581
|
+
GB: 1024 * 1024 * 1024,
|
|
582
|
+
GIB: 1024 * 1024 * 1024,
|
|
583
|
+
TB: 1024 * 1024 * 1024 * 1024,
|
|
584
|
+
TIB: 1024 * 1024 * 1024 * 1024,
|
|
585
|
+
};
|
|
586
|
+
const factor = multiplier[unit];
|
|
587
|
+
return factor ? Math.floor(value * factor) : null;
|
|
588
|
+
}
|
|
589
|
+
function extractJavIdFromText(text) {
|
|
590
|
+
if (!text) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
const match = text.match(/([A-Z0-9]+-\d+)/i);
|
|
594
|
+
return match ? match[1].toUpperCase() : null;
|
|
595
|
+
}
|
|
596
|
+
function dedupeResults(results) {
|
|
597
|
+
const seen = new Set();
|
|
598
|
+
const deduped = [];
|
|
599
|
+
for (const result of results) {
|
|
600
|
+
const key = result.detailUrl || `${result.title}|${result.javId}`;
|
|
601
|
+
if (seen.has(key)) {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
seen.add(key);
|
|
605
|
+
deduped.push(result);
|
|
606
|
+
}
|
|
607
|
+
return deduped;
|
|
608
|
+
}
|
|
609
|
+
function chooseBestCandidate(candidates, query) {
|
|
610
|
+
if (candidates.length === 1) {
|
|
611
|
+
return candidates[0];
|
|
612
|
+
}
|
|
613
|
+
const normalizedQuery = (0, torrentNameParser_1.normalizeJavId)(query);
|
|
614
|
+
for (const candidate of candidates) {
|
|
615
|
+
const id = (0, torrentNameParser_1.normalizeJavId)(candidate.javId || candidate.title);
|
|
616
|
+
if ((0, torrentNameParser_1.isValidJavId)(id) && id.toLowerCase() === normalizedQuery.toLowerCase()) {
|
|
617
|
+
return candidate;
|
|
618
|
+
}
|
|
619
|
+
const idFromTitle = (0, torrentNameParser_1.normalizeJavId)(candidate.title);
|
|
620
|
+
if ((0, torrentNameParser_1.isValidJavId)(idFromTitle) && idFromTitle.toLowerCase() === normalizedQuery.toLowerCase()) {
|
|
621
|
+
return candidate;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const match = candidates.find((candidate) => candidate.title.toLowerCase().includes(normalizedQuery.toLowerCase()));
|
|
625
|
+
return match ?? candidates[0];
|
|
626
|
+
}
|
|
627
|
+
function emptySearchResult(javId) {
|
|
628
|
+
return {
|
|
629
|
+
javId,
|
|
630
|
+
title: "",
|
|
631
|
+
coverUrl: "",
|
|
632
|
+
releaseDate: undefined,
|
|
633
|
+
duration: 0,
|
|
634
|
+
director: "",
|
|
635
|
+
maker: "",
|
|
636
|
+
publisher: "",
|
|
637
|
+
series: "",
|
|
638
|
+
actors: [],
|
|
639
|
+
categories: [],
|
|
640
|
+
torrents: [],
|
|
641
|
+
detailUrl: "",
|
|
642
|
+
dataSource: "Remote",
|
|
643
|
+
cachedAt: undefined,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36";
|