javdict 1.3.1 → 1.3.2
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/.github/workflows/ci.yml +35 -0
- package/.github/workflows/release.yml +46 -0
- package/LICENSE +21 -21
- package/index.js +93 -93
- package/lib/cache.js +81 -81
- package/lib/display.js +61 -61
- package/lib/fetcher.js +366 -366
- package/lib/i18n.js +175 -175
- package/package.json +30 -30
- package/test/cache.test.js +81 -81
- package/test/display.test.js +54 -54
- package/test/fetcher.test.js +102 -102
package/lib/fetcher.js
CHANGED
|
@@ -1,367 +1,367 @@
|
|
|
1
|
-
import { execSync } from 'child_process';
|
|
2
|
-
import * as cheerio from 'cheerio';
|
|
3
|
-
import { getCache, setCache, getConfig } from './cache.js';
|
|
4
|
-
import chalk from 'chalk';
|
|
5
|
-
import {getLang} from "./i18n.js";
|
|
6
|
-
|
|
7
|
-
// ─── 通用请求函数(JAVBUS / JavLibrary 使用)────────────
|
|
8
|
-
function fetchHtml(url, cookie = '') {
|
|
9
|
-
const cookieHeader = cookie ? `-H "Cookie: ${cookie}"` : '';
|
|
10
|
-
const result = execSync(
|
|
11
|
-
`curl -sL "${url}" \
|
|
12
|
-
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" \
|
|
13
|
-
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
|
|
14
|
-
-H "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8" \
|
|
15
|
-
${cookieHeader}`,
|
|
16
|
-
{ timeout: 15000, maxBuffer: 1024 * 1024 * 10 }
|
|
17
|
-
);
|
|
18
|
-
return result.toString();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// ─── 数据源一:JAVBUS ────────────────────────────────────
|
|
22
|
-
function searchJavbus(id) {
|
|
23
|
-
try {
|
|
24
|
-
const url = `https://www.javbus.com/${id}`;
|
|
25
|
-
const html = fetchHtml(url);
|
|
26
|
-
const $ = cheerio.load(html);
|
|
27
|
-
|
|
28
|
-
if ($('title').text().includes('404') || !$('.container .row').length) {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const result = {
|
|
33
|
-
id,
|
|
34
|
-
source: 'JAVBUS',
|
|
35
|
-
title: $('h3').first().text().trim(),
|
|
36
|
-
actresses: [],
|
|
37
|
-
actors: [],
|
|
38
|
-
releaseDate: '',
|
|
39
|
-
duration: '',
|
|
40
|
-
studio: '',
|
|
41
|
-
label: '',
|
|
42
|
-
director: '',
|
|
43
|
-
series: '',
|
|
44
|
-
tags: [],
|
|
45
|
-
coverUrl: $('.screencap img').attr('src') || '',
|
|
46
|
-
score: '',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
$('.info p').each((_, el) => {
|
|
50
|
-
const text = $(el).text().trim();
|
|
51
|
-
const label = $(el).find('span.header').text().trim();
|
|
52
|
-
if (/發行日期|发行日期/.test(label)) {
|
|
53
|
-
result.releaseDate = text.replace(label, '').trim();
|
|
54
|
-
} else if (/長度|长度/.test(label)) {
|
|
55
|
-
result.duration = text.replace(label, '').trim();
|
|
56
|
-
} else if (/導演|导演/.test(label)) {
|
|
57
|
-
result.director = $(el).find('a').text().trim();
|
|
58
|
-
} else if (/製作商|制作商/.test(label)) {
|
|
59
|
-
result.studio = $(el).find('a').text().trim();
|
|
60
|
-
} else if (/發行商|发行商/.test(label)) {
|
|
61
|
-
result.label = $(el).find('a').text().trim();
|
|
62
|
-
} else if (/系列/.test(label)) {
|
|
63
|
-
result.series = $(el).find('a').text().trim();
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
$('span.genre a').each((_, el) => {
|
|
68
|
-
const tag = $(el).text().trim();
|
|
69
|
-
if (tag) result.tags.push(tag);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
$('.star-name a').each((_, el) => {
|
|
73
|
-
const name = $(el).text().trim();
|
|
74
|
-
if (name) result.actresses.push(name);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
return result.title ? result : null;
|
|
78
|
-
} catch {
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ─── 数据源二:JavLibrary ────────────────────────────────
|
|
84
|
-
function searchJavlibrary(id) {
|
|
85
|
-
try {
|
|
86
|
-
const searchUrl = `https://www.javlibrary.com/cn/vl_searchbyid.php?keyword=${encodeURIComponent(id)}`;
|
|
87
|
-
const searchHtml = fetchHtml(searchUrl);
|
|
88
|
-
const $ = cheerio.load(searchHtml);
|
|
89
|
-
|
|
90
|
-
let detailHtml = searchHtml;
|
|
91
|
-
const firstResult = $('.videos .video a').first();
|
|
92
|
-
if (firstResult.length) {
|
|
93
|
-
const detailPath = firstResult.attr('href');
|
|
94
|
-
detailHtml = fetchHtml(`https://www.javlibrary.com/cn/${detailPath}`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const $d = cheerio.load(detailHtml);
|
|
98
|
-
if (!$d('#video_id .text').length) return null;
|
|
99
|
-
|
|
100
|
-
const foundId = $d('#video_id .text').text().trim().toUpperCase();
|
|
101
|
-
if (foundId !== id.toUpperCase()) return null;
|
|
102
|
-
|
|
103
|
-
const result = {
|
|
104
|
-
id,
|
|
105
|
-
source: 'JavLibrary',
|
|
106
|
-
title: $d('#video_title h3').text().trim(),
|
|
107
|
-
actresses: [],
|
|
108
|
-
actors: [],
|
|
109
|
-
releaseDate: $d('#video_date .text').text().trim(),
|
|
110
|
-
duration: $d('#video_length .text').text().trim(),
|
|
111
|
-
studio: $d('#video_maker .text a').text().trim(),
|
|
112
|
-
label: $d('#video_label .text a').text().trim(),
|
|
113
|
-
director: $d('#video_director .text a').text().trim(),
|
|
114
|
-
series: $d('#video_series .text a').text().trim(),
|
|
115
|
-
tags: [],
|
|
116
|
-
coverUrl: $d('#video_jacket_img').attr('src') || '',
|
|
117
|
-
score: $d('#video_review .score').text().trim(),
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
$d('#video_genres .genre a').each((_, el) => {
|
|
121
|
-
const tag = $d(el).text().trim();
|
|
122
|
-
if (tag) result.tags.push(tag);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
$d('#video_cast .cast .star a').each((_, el) => {
|
|
126
|
-
const name = $d(el).text().trim();
|
|
127
|
-
if (name) result.actresses.push(name);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
return result.title ? result : null;
|
|
131
|
-
} catch {
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ─── 数据源三:JAVDB(需要Cookie,仅Linux/Mac)──────────
|
|
137
|
-
async function searchJavdb(id) {
|
|
138
|
-
try {
|
|
139
|
-
const config = getConfig();
|
|
140
|
-
if (!config.session) return null;
|
|
141
|
-
|
|
142
|
-
// Windows上Cloudflare封锁了非OpenSSL的TLS请求,跳过
|
|
143
|
-
if (process.platform === 'win32') return null;
|
|
144
|
-
|
|
145
|
-
const session = decodeURIComponent(config.session);
|
|
146
|
-
const cookie = `locale=zh; over18=1; _jdb_session=${session}`;
|
|
147
|
-
|
|
148
|
-
function fetchJavdbHtml(url) {
|
|
149
|
-
return execSync(
|
|
150
|
-
`curl -sL "${url}" \
|
|
151
|
-
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" \
|
|
152
|
-
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
|
|
153
|
-
-H "Accept-Language: zh-CN,zh;q=0.9" \
|
|
154
|
-
-H "Cookie: ${cookie}"`,
|
|
155
|
-
{ timeout: 15000, maxBuffer: 1024 * 1024 * 10 }
|
|
156
|
-
).toString();
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const searchUrl = `https://javdb.com/search?q=${encodeURIComponent(id)}&f=all`;
|
|
160
|
-
const searchHtml = fetchJavdbHtml(searchUrl);
|
|
161
|
-
|
|
162
|
-
const $ = cheerio.load(searchHtml);
|
|
163
|
-
let detailPath = null;
|
|
164
|
-
|
|
165
|
-
$('.movie-list .item a.box').each((_, el) => {
|
|
166
|
-
const foundId = $(el).find('.video-title strong').text().trim().toUpperCase();
|
|
167
|
-
const normalize = s => s.replace(/[-_]/g, '');
|
|
168
|
-
if (normalize(foundId) === normalize(id.toUpperCase())) {
|
|
169
|
-
detailPath = $(el).attr('href');
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
if (!detailPath) detailPath = $('.movie-list .item a.box').first().attr('href');
|
|
175
|
-
if (!detailPath) return null;
|
|
176
|
-
|
|
177
|
-
// 详情页最多重试 3 次,每次间隔 2 秒
|
|
178
|
-
let detailHtml = '';
|
|
179
|
-
for (let i = 0; i < 3; i++) {
|
|
180
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
181
|
-
detailHtml = fetchJavdbHtml(`https://javdb.com${detailPath}`);
|
|
182
|
-
if (detailHtml.length > 1000) break;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (detailHtml.length < 1000) return null;
|
|
186
|
-
return parseJavdb(detailHtml, id);
|
|
187
|
-
} catch {
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ─── 数据源四:NJAV(无需Cookie,覆盖率高)────────────
|
|
193
|
-
function searchNjav(id) {
|
|
194
|
-
try {
|
|
195
|
-
const url = `https://www.njav.com/zh/xvideos/${id.toLowerCase()}`;
|
|
196
|
-
const html = fetchHtml(url);
|
|
197
|
-
const $ = cheerio.load(html);
|
|
198
|
-
|
|
199
|
-
// 确认页面包含正确番号
|
|
200
|
-
const pageTitle = $('title').text();
|
|
201
|
-
if (!pageTitle.includes(id.toUpperCase())) return null;
|
|
202
|
-
|
|
203
|
-
const result = {
|
|
204
|
-
id,
|
|
205
|
-
source: 'NJAV',
|
|
206
|
-
title: '',
|
|
207
|
-
actresses: [],
|
|
208
|
-
actors: [],
|
|
209
|
-
releaseDate: '',
|
|
210
|
-
duration: '',
|
|
211
|
-
studio: '',
|
|
212
|
-
label: '',
|
|
213
|
-
director: '',
|
|
214
|
-
series: '',
|
|
215
|
-
tags: [],
|
|
216
|
-
coverUrl: `https://static.javcdn.vip/images/${id.toLowerCase()}/thumb_h.webp`,
|
|
217
|
-
score: '',
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
// 优先用 JSON-LD 解析,最稳定
|
|
221
|
-
const jsonLd = $('script[type="application/ld+json"]').text();
|
|
222
|
-
if (jsonLd) {
|
|
223
|
-
try {
|
|
224
|
-
const data = JSON.parse(jsonLd);
|
|
225
|
-
result.title = data.name || '';
|
|
226
|
-
result.releaseDate = data.uploadDate?.substring(0, 10) || '';
|
|
227
|
-
// 解析时长 PT3H22M51S → 3:22:51
|
|
228
|
-
if (data.duration) {
|
|
229
|
-
const m = data.duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
|
230
|
-
if (m) {
|
|
231
|
-
const h = m[1] || '0';
|
|
232
|
-
const min = m[2] ? m[2].padStart(2, '0') : '00';
|
|
233
|
-
const sec = m[3] ? m[3].padStart(2, '0') : '00';
|
|
234
|
-
result.duration = `${h}:${min}:${sec}`;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
if (Array.isArray(data.actor)) {
|
|
238
|
-
result.actresses = data.actor.map(a => a.name).filter(Boolean);
|
|
239
|
-
}
|
|
240
|
-
if (Array.isArray(data.genre)) {
|
|
241
|
-
result.tags = data.genre;
|
|
242
|
-
}
|
|
243
|
-
if (data.partOfSeries?.name) {
|
|
244
|
-
result.series = data.partOfSeries.name;
|
|
245
|
-
}
|
|
246
|
-
} catch {}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// 补充 HTML 里的字段(JSON-LD 没有的)
|
|
250
|
-
$('.detail-item div').each((_, el) => {
|
|
251
|
-
const label = $(el).find('span').first().text().trim();
|
|
252
|
-
const value = $(el).find('span').last().text().trim();
|
|
253
|
-
if (/片商|制作|製作|Studio|Maker/i.test(label)) {
|
|
254
|
-
result.studio = value;
|
|
255
|
-
} else if (/導演|Director/i.test(label)) {
|
|
256
|
-
result.director = value;
|
|
257
|
-
}
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
return result.title ? result : null;
|
|
261
|
-
} catch {
|
|
262
|
-
return null;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function parseJavdb(html, queryId) {
|
|
267
|
-
const $ = cheerio.load(html);
|
|
268
|
-
const result = {
|
|
269
|
-
id: queryId,
|
|
270
|
-
source: 'JAVDB',
|
|
271
|
-
title: $('strong.current-title').text().trim(),
|
|
272
|
-
actresses: [],
|
|
273
|
-
actors: [],
|
|
274
|
-
releaseDate: '',
|
|
275
|
-
duration: '',
|
|
276
|
-
studio: '',
|
|
277
|
-
label: '',
|
|
278
|
-
director: '',
|
|
279
|
-
series: '',
|
|
280
|
-
tags: [],
|
|
281
|
-
coverUrl: $('.video-cover img').attr('src') || '',
|
|
282
|
-
score: $('.score .value').first().text().trim(),
|
|
283
|
-
scoreCount: ($('.score .value').first().text().match(/由(\d+)人/) || [])[1] || '',
|
|
284
|
-
wantCount: ($('.panel-block:last .is-size-7').text().match(/(\d+)人想看/) || [])[1] || '',
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
$('.movie-panel-info .panel-block').each((_, el) => {
|
|
288
|
-
const label = $(el).find('strong').first().text().replace(/:|:/g, '').trim();
|
|
289
|
-
const valueEl = $(el).find('.value');
|
|
290
|
-
const valueText = valueEl.text().trim();
|
|
291
|
-
|
|
292
|
-
if (/日期/.test(label)) {
|
|
293
|
-
const m = valueText.match(/\d{4}-\d{2}-\d{2}/);
|
|
294
|
-
result.releaseDate = m ? m[0] : valueText;
|
|
295
|
-
} else if (/時長|时长/.test(label)) {
|
|
296
|
-
result.duration = valueText;
|
|
297
|
-
} else if (/導演|导演/.test(label)) {
|
|
298
|
-
result.director = valueText;
|
|
299
|
-
} else if (/片商/.test(label)) {
|
|
300
|
-
result.studio = valueText;
|
|
301
|
-
} else if (/發行商|发行商/.test(label)) {
|
|
302
|
-
result.label = valueText;
|
|
303
|
-
} else if (/系列/.test(label)) {
|
|
304
|
-
result.series = valueText;
|
|
305
|
-
} else if (/類別|类别/.test(label)) {
|
|
306
|
-
valueEl.find('a').each((_, a) => {
|
|
307
|
-
const t = $(a).text().trim();
|
|
308
|
-
if (t) result.tags.push(t);
|
|
309
|
-
});
|
|
310
|
-
} else if (/演員|演员/.test(label)) {
|
|
311
|
-
valueEl.find('a').each((_, a) => {
|
|
312
|
-
const name = $(a).text().trim();
|
|
313
|
-
if (name) result.actresses.push(name);
|
|
314
|
-
});
|
|
315
|
-
} else if (/男優|男优/.test(label)) {
|
|
316
|
-
valueEl.find('a').each((_, a) => {
|
|
317
|
-
const name = $(a).text().trim();
|
|
318
|
-
if (name) result.actors.push(name);
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
return result.title ? result : null;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// ─── 主入口:依次尝试数据源 ─────────────────────────
|
|
327
|
-
export async function search(id, lang = 'zh') {
|
|
328
|
-
const t = getLang(lang);
|
|
329
|
-
|
|
330
|
-
// FC2 格式识别:031926-100 → 031926_100
|
|
331
|
-
let searchId = id;
|
|
332
|
-
if (/^\d{5,6}-\d+$/.test(id)) {
|
|
333
|
-
searchId = id.replace(/-/g, '_');
|
|
334
|
-
console.log(chalk.gray(` ${t.fc2Detected}: ${id} → ${searchId}`));
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const cached = getCache(id);
|
|
338
|
-
if (cached) return cached;
|
|
339
|
-
|
|
340
|
-
const MAX_RETRY = 3;
|
|
341
|
-
|
|
342
|
-
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
|
343
|
-
let result = null;
|
|
344
|
-
|
|
345
|
-
// 第一优先:JAVBUS(最快,直接番号拼URL)
|
|
346
|
-
result = searchJavbus(searchId);
|
|
347
|
-
if (result) { setCache(id, result); return result; }
|
|
348
|
-
|
|
349
|
-
// 第二优先:NJAV(无需Cookie,覆盖率高,能查到JUR等小众番号)
|
|
350
|
-
result = searchNjav(searchId);
|
|
351
|
-
if (result) { setCache(id, result); return result; }
|
|
352
|
-
|
|
353
|
-
// 第三优先:JavLibrary(补充数据)
|
|
354
|
-
result = searchJavlibrary(searchId);
|
|
355
|
-
if (result) { setCache(id, result); return result; }
|
|
356
|
-
|
|
357
|
-
// 第四优先:JAVDB(需要Cookie,仅Linux)
|
|
358
|
-
result = await searchJavdb(searchId);
|
|
359
|
-
if (result) { setCache(id, result); return result; }
|
|
360
|
-
|
|
361
|
-
if (attempt < MAX_RETRY) {
|
|
362
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
return null;
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import * as cheerio from 'cheerio';
|
|
3
|
+
import { getCache, setCache, getConfig } from './cache.js';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import {getLang} from "./i18n.js";
|
|
6
|
+
|
|
7
|
+
// ─── 通用请求函数(JAVBUS / JavLibrary 使用)────────────
|
|
8
|
+
function fetchHtml(url, cookie = '') {
|
|
9
|
+
const cookieHeader = cookie ? `-H "Cookie: ${cookie}"` : '';
|
|
10
|
+
const result = execSync(
|
|
11
|
+
`curl -sL "${url}" \
|
|
12
|
+
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" \
|
|
13
|
+
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
|
|
14
|
+
-H "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8" \
|
|
15
|
+
${cookieHeader}`,
|
|
16
|
+
{ timeout: 15000, maxBuffer: 1024 * 1024 * 10 }
|
|
17
|
+
);
|
|
18
|
+
return result.toString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── 数据源一:JAVBUS ────────────────────────────────────
|
|
22
|
+
function searchJavbus(id) {
|
|
23
|
+
try {
|
|
24
|
+
const url = `https://www.javbus.com/${id}`;
|
|
25
|
+
const html = fetchHtml(url);
|
|
26
|
+
const $ = cheerio.load(html);
|
|
27
|
+
|
|
28
|
+
if ($('title').text().includes('404') || !$('.container .row').length) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = {
|
|
33
|
+
id,
|
|
34
|
+
source: 'JAVBUS',
|
|
35
|
+
title: $('h3').first().text().trim(),
|
|
36
|
+
actresses: [],
|
|
37
|
+
actors: [],
|
|
38
|
+
releaseDate: '',
|
|
39
|
+
duration: '',
|
|
40
|
+
studio: '',
|
|
41
|
+
label: '',
|
|
42
|
+
director: '',
|
|
43
|
+
series: '',
|
|
44
|
+
tags: [],
|
|
45
|
+
coverUrl: $('.screencap img').attr('src') || '',
|
|
46
|
+
score: '',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
$('.info p').each((_, el) => {
|
|
50
|
+
const text = $(el).text().trim();
|
|
51
|
+
const label = $(el).find('span.header').text().trim();
|
|
52
|
+
if (/發行日期|发行日期/.test(label)) {
|
|
53
|
+
result.releaseDate = text.replace(label, '').trim();
|
|
54
|
+
} else if (/長度|长度/.test(label)) {
|
|
55
|
+
result.duration = text.replace(label, '').trim();
|
|
56
|
+
} else if (/導演|导演/.test(label)) {
|
|
57
|
+
result.director = $(el).find('a').text().trim();
|
|
58
|
+
} else if (/製作商|制作商/.test(label)) {
|
|
59
|
+
result.studio = $(el).find('a').text().trim();
|
|
60
|
+
} else if (/發行商|发行商/.test(label)) {
|
|
61
|
+
result.label = $(el).find('a').text().trim();
|
|
62
|
+
} else if (/系列/.test(label)) {
|
|
63
|
+
result.series = $(el).find('a').text().trim();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
$('span.genre a').each((_, el) => {
|
|
68
|
+
const tag = $(el).text().trim();
|
|
69
|
+
if (tag) result.tags.push(tag);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
$('.star-name a').each((_, el) => {
|
|
73
|
+
const name = $(el).text().trim();
|
|
74
|
+
if (name) result.actresses.push(name);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return result.title ? result : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── 数据源二:JavLibrary ────────────────────────────────
|
|
84
|
+
function searchJavlibrary(id) {
|
|
85
|
+
try {
|
|
86
|
+
const searchUrl = `https://www.javlibrary.com/cn/vl_searchbyid.php?keyword=${encodeURIComponent(id)}`;
|
|
87
|
+
const searchHtml = fetchHtml(searchUrl);
|
|
88
|
+
const $ = cheerio.load(searchHtml);
|
|
89
|
+
|
|
90
|
+
let detailHtml = searchHtml;
|
|
91
|
+
const firstResult = $('.videos .video a').first();
|
|
92
|
+
if (firstResult.length) {
|
|
93
|
+
const detailPath = firstResult.attr('href');
|
|
94
|
+
detailHtml = fetchHtml(`https://www.javlibrary.com/cn/${detailPath}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const $d = cheerio.load(detailHtml);
|
|
98
|
+
if (!$d('#video_id .text').length) return null;
|
|
99
|
+
|
|
100
|
+
const foundId = $d('#video_id .text').text().trim().toUpperCase();
|
|
101
|
+
if (foundId !== id.toUpperCase()) return null;
|
|
102
|
+
|
|
103
|
+
const result = {
|
|
104
|
+
id,
|
|
105
|
+
source: 'JavLibrary',
|
|
106
|
+
title: $d('#video_title h3').text().trim(),
|
|
107
|
+
actresses: [],
|
|
108
|
+
actors: [],
|
|
109
|
+
releaseDate: $d('#video_date .text').text().trim(),
|
|
110
|
+
duration: $d('#video_length .text').text().trim(),
|
|
111
|
+
studio: $d('#video_maker .text a').text().trim(),
|
|
112
|
+
label: $d('#video_label .text a').text().trim(),
|
|
113
|
+
director: $d('#video_director .text a').text().trim(),
|
|
114
|
+
series: $d('#video_series .text a').text().trim(),
|
|
115
|
+
tags: [],
|
|
116
|
+
coverUrl: $d('#video_jacket_img').attr('src') || '',
|
|
117
|
+
score: $d('#video_review .score').text().trim(),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
$d('#video_genres .genre a').each((_, el) => {
|
|
121
|
+
const tag = $d(el).text().trim();
|
|
122
|
+
if (tag) result.tags.push(tag);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
$d('#video_cast .cast .star a').each((_, el) => {
|
|
126
|
+
const name = $d(el).text().trim();
|
|
127
|
+
if (name) result.actresses.push(name);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return result.title ? result : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── 数据源三:JAVDB(需要Cookie,仅Linux/Mac)──────────
|
|
137
|
+
async function searchJavdb(id) {
|
|
138
|
+
try {
|
|
139
|
+
const config = getConfig();
|
|
140
|
+
if (!config.session) return null;
|
|
141
|
+
|
|
142
|
+
// Windows上Cloudflare封锁了非OpenSSL的TLS请求,跳过
|
|
143
|
+
if (process.platform === 'win32') return null;
|
|
144
|
+
|
|
145
|
+
const session = decodeURIComponent(config.session);
|
|
146
|
+
const cookie = `locale=zh; over18=1; _jdb_session=${session}`;
|
|
147
|
+
|
|
148
|
+
function fetchJavdbHtml(url) {
|
|
149
|
+
return execSync(
|
|
150
|
+
`curl -sL "${url}" \
|
|
151
|
+
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" \
|
|
152
|
+
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
|
|
153
|
+
-H "Accept-Language: zh-CN,zh;q=0.9" \
|
|
154
|
+
-H "Cookie: ${cookie}"`,
|
|
155
|
+
{ timeout: 15000, maxBuffer: 1024 * 1024 * 10 }
|
|
156
|
+
).toString();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const searchUrl = `https://javdb.com/search?q=${encodeURIComponent(id)}&f=all`;
|
|
160
|
+
const searchHtml = fetchJavdbHtml(searchUrl);
|
|
161
|
+
|
|
162
|
+
const $ = cheerio.load(searchHtml);
|
|
163
|
+
let detailPath = null;
|
|
164
|
+
|
|
165
|
+
$('.movie-list .item a.box').each((_, el) => {
|
|
166
|
+
const foundId = $(el).find('.video-title strong').text().trim().toUpperCase();
|
|
167
|
+
const normalize = s => s.replace(/[-_]/g, '');
|
|
168
|
+
if (normalize(foundId) === normalize(id.toUpperCase())) {
|
|
169
|
+
detailPath = $(el).attr('href');
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (!detailPath) detailPath = $('.movie-list .item a.box').first().attr('href');
|
|
175
|
+
if (!detailPath) return null;
|
|
176
|
+
|
|
177
|
+
// 详情页最多重试 3 次,每次间隔 2 秒
|
|
178
|
+
let detailHtml = '';
|
|
179
|
+
for (let i = 0; i < 3; i++) {
|
|
180
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
181
|
+
detailHtml = fetchJavdbHtml(`https://javdb.com${detailPath}`);
|
|
182
|
+
if (detailHtml.length > 1000) break;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (detailHtml.length < 1000) return null;
|
|
186
|
+
return parseJavdb(detailHtml, id);
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── 数据源四:NJAV(无需Cookie,覆盖率高)────────────
|
|
193
|
+
function searchNjav(id) {
|
|
194
|
+
try {
|
|
195
|
+
const url = `https://www.njav.com/zh/xvideos/${id.toLowerCase()}`;
|
|
196
|
+
const html = fetchHtml(url);
|
|
197
|
+
const $ = cheerio.load(html);
|
|
198
|
+
|
|
199
|
+
// 确认页面包含正确番号
|
|
200
|
+
const pageTitle = $('title').text();
|
|
201
|
+
if (!pageTitle.includes(id.toUpperCase())) return null;
|
|
202
|
+
|
|
203
|
+
const result = {
|
|
204
|
+
id,
|
|
205
|
+
source: 'NJAV',
|
|
206
|
+
title: '',
|
|
207
|
+
actresses: [],
|
|
208
|
+
actors: [],
|
|
209
|
+
releaseDate: '',
|
|
210
|
+
duration: '',
|
|
211
|
+
studio: '',
|
|
212
|
+
label: '',
|
|
213
|
+
director: '',
|
|
214
|
+
series: '',
|
|
215
|
+
tags: [],
|
|
216
|
+
coverUrl: `https://static.javcdn.vip/images/${id.toLowerCase()}/thumb_h.webp`,
|
|
217
|
+
score: '',
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// 优先用 JSON-LD 解析,最稳定
|
|
221
|
+
const jsonLd = $('script[type="application/ld+json"]').text();
|
|
222
|
+
if (jsonLd) {
|
|
223
|
+
try {
|
|
224
|
+
const data = JSON.parse(jsonLd);
|
|
225
|
+
result.title = data.name || '';
|
|
226
|
+
result.releaseDate = data.uploadDate?.substring(0, 10) || '';
|
|
227
|
+
// 解析时长 PT3H22M51S → 3:22:51
|
|
228
|
+
if (data.duration) {
|
|
229
|
+
const m = data.duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
|
230
|
+
if (m) {
|
|
231
|
+
const h = m[1] || '0';
|
|
232
|
+
const min = m[2] ? m[2].padStart(2, '0') : '00';
|
|
233
|
+
const sec = m[3] ? m[3].padStart(2, '0') : '00';
|
|
234
|
+
result.duration = `${h}:${min}:${sec}`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (Array.isArray(data.actor)) {
|
|
238
|
+
result.actresses = data.actor.map(a => a.name).filter(Boolean);
|
|
239
|
+
}
|
|
240
|
+
if (Array.isArray(data.genre)) {
|
|
241
|
+
result.tags = data.genre;
|
|
242
|
+
}
|
|
243
|
+
if (data.partOfSeries?.name) {
|
|
244
|
+
result.series = data.partOfSeries.name;
|
|
245
|
+
}
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 补充 HTML 里的字段(JSON-LD 没有的)
|
|
250
|
+
$('.detail-item div').each((_, el) => {
|
|
251
|
+
const label = $(el).find('span').first().text().trim();
|
|
252
|
+
const value = $(el).find('span').last().text().trim();
|
|
253
|
+
if (/片商|制作|製作|Studio|Maker/i.test(label)) {
|
|
254
|
+
result.studio = value;
|
|
255
|
+
} else if (/導演|Director/i.test(label)) {
|
|
256
|
+
result.director = value;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return result.title ? result : null;
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function parseJavdb(html, queryId) {
|
|
267
|
+
const $ = cheerio.load(html);
|
|
268
|
+
const result = {
|
|
269
|
+
id: queryId,
|
|
270
|
+
source: 'JAVDB',
|
|
271
|
+
title: $('strong.current-title').text().trim(),
|
|
272
|
+
actresses: [],
|
|
273
|
+
actors: [],
|
|
274
|
+
releaseDate: '',
|
|
275
|
+
duration: '',
|
|
276
|
+
studio: '',
|
|
277
|
+
label: '',
|
|
278
|
+
director: '',
|
|
279
|
+
series: '',
|
|
280
|
+
tags: [],
|
|
281
|
+
coverUrl: $('.video-cover img').attr('src') || '',
|
|
282
|
+
score: $('.score .value').first().text().trim(),
|
|
283
|
+
scoreCount: ($('.score .value').first().text().match(/由(\d+)人/) || [])[1] || '',
|
|
284
|
+
wantCount: ($('.panel-block:last .is-size-7').text().match(/(\d+)人想看/) || [])[1] || '',
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
$('.movie-panel-info .panel-block').each((_, el) => {
|
|
288
|
+
const label = $(el).find('strong').first().text().replace(/:|:/g, '').trim();
|
|
289
|
+
const valueEl = $(el).find('.value');
|
|
290
|
+
const valueText = valueEl.text().trim();
|
|
291
|
+
|
|
292
|
+
if (/日期/.test(label)) {
|
|
293
|
+
const m = valueText.match(/\d{4}-\d{2}-\d{2}/);
|
|
294
|
+
result.releaseDate = m ? m[0] : valueText;
|
|
295
|
+
} else if (/時長|时长/.test(label)) {
|
|
296
|
+
result.duration = valueText;
|
|
297
|
+
} else if (/導演|导演/.test(label)) {
|
|
298
|
+
result.director = valueText;
|
|
299
|
+
} else if (/片商/.test(label)) {
|
|
300
|
+
result.studio = valueText;
|
|
301
|
+
} else if (/發行商|发行商/.test(label)) {
|
|
302
|
+
result.label = valueText;
|
|
303
|
+
} else if (/系列/.test(label)) {
|
|
304
|
+
result.series = valueText;
|
|
305
|
+
} else if (/類別|类别/.test(label)) {
|
|
306
|
+
valueEl.find('a').each((_, a) => {
|
|
307
|
+
const t = $(a).text().trim();
|
|
308
|
+
if (t) result.tags.push(t);
|
|
309
|
+
});
|
|
310
|
+
} else if (/演員|演员/.test(label)) {
|
|
311
|
+
valueEl.find('a').each((_, a) => {
|
|
312
|
+
const name = $(a).text().trim();
|
|
313
|
+
if (name) result.actresses.push(name);
|
|
314
|
+
});
|
|
315
|
+
} else if (/男優|男优/.test(label)) {
|
|
316
|
+
valueEl.find('a').each((_, a) => {
|
|
317
|
+
const name = $(a).text().trim();
|
|
318
|
+
if (name) result.actors.push(name);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
return result.title ? result : null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── 主入口:依次尝试数据源 ─────────────────────────
|
|
327
|
+
export async function search(id, lang = 'zh') {
|
|
328
|
+
const t = getLang(lang);
|
|
329
|
+
|
|
330
|
+
// FC2 格式识别:031926-100 → 031926_100
|
|
331
|
+
let searchId = id;
|
|
332
|
+
if (/^\d{5,6}-\d+$/.test(id)) {
|
|
333
|
+
searchId = id.replace(/-/g, '_');
|
|
334
|
+
console.log(chalk.gray(` ${t.fc2Detected}: ${id} → ${searchId}`));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const cached = getCache(id);
|
|
338
|
+
if (cached) return cached;
|
|
339
|
+
|
|
340
|
+
const MAX_RETRY = 3;
|
|
341
|
+
|
|
342
|
+
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
|
343
|
+
let result = null;
|
|
344
|
+
|
|
345
|
+
// 第一优先:JAVBUS(最快,直接番号拼URL)
|
|
346
|
+
result = searchJavbus(searchId);
|
|
347
|
+
if (result) { setCache(id, result); return result; }
|
|
348
|
+
|
|
349
|
+
// 第二优先:NJAV(无需Cookie,覆盖率高,能查到JUR等小众番号)
|
|
350
|
+
result = searchNjav(searchId);
|
|
351
|
+
if (result) { setCache(id, result); return result; }
|
|
352
|
+
|
|
353
|
+
// 第三优先:JavLibrary(补充数据)
|
|
354
|
+
result = searchJavlibrary(searchId);
|
|
355
|
+
if (result) { setCache(id, result); return result; }
|
|
356
|
+
|
|
357
|
+
// 第四优先:JAVDB(需要Cookie,仅Linux)
|
|
358
|
+
result = await searchJavdb(searchId);
|
|
359
|
+
if (result) { setCache(id, result); return result; }
|
|
360
|
+
|
|
361
|
+
if (attempt < MAX_RETRY) {
|
|
362
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return null;
|
|
367
367
|
}
|