367 lines
13 KiB
JavaScript
367 lines
13 KiB
JavaScript
import { execSync } from 'child_process';
|
||
import * as cheerio from 'cheerio';
|
||
import { getCache, setCache, getConfig } from './cache.js';
|
||
import chalk from 'chalk';
|
||
import {getLang} from "./i18n.js";
|
||
|
||
// ─── 通用请求函数(JAVBUS / JavLibrary 使用)────────────
|
||
function fetchHtml(url, cookie = '') {
|
||
const cookieHeader = cookie ? `-H "Cookie: ${cookie}"` : '';
|
||
const result = execSync(
|
||
`curl -sL "${url}" \
|
||
-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" \
|
||
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
|
||
-H "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8" \
|
||
${cookieHeader}`,
|
||
{ timeout: 15000, maxBuffer: 1024 * 1024 * 10 }
|
||
);
|
||
return result.toString();
|
||
}
|
||
|
||
// ─── 数据源一:JAVBUS ────────────────────────────────────
|
||
function searchJavbus(id) {
|
||
try {
|
||
const url = `https://www.javbus.com/${id}`;
|
||
const html = fetchHtml(url);
|
||
const $ = cheerio.load(html);
|
||
|
||
if ($('title').text().includes('404') || !$('.container .row').length) {
|
||
return null;
|
||
}
|
||
|
||
const result = {
|
||
id,
|
||
source: 'JAVBUS',
|
||
title: $('h3').first().text().trim(),
|
||
actresses: [],
|
||
actors: [],
|
||
releaseDate: '',
|
||
duration: '',
|
||
studio: '',
|
||
label: '',
|
||
director: '',
|
||
series: '',
|
||
tags: [],
|
||
coverUrl: $('.screencap img').attr('src') || '',
|
||
score: '',
|
||
};
|
||
|
||
$('.info p').each((_, el) => {
|
||
const text = $(el).text().trim();
|
||
const label = $(el).find('span.header').text().trim();
|
||
if (/發行日期|发行日期/.test(label)) {
|
||
result.releaseDate = text.replace(label, '').trim();
|
||
} else if (/長度|长度/.test(label)) {
|
||
result.duration = text.replace(label, '').trim();
|
||
} else if (/導演|导演/.test(label)) {
|
||
result.director = $(el).find('a').text().trim();
|
||
} else if (/製作商|制作商/.test(label)) {
|
||
result.studio = $(el).find('a').text().trim();
|
||
} else if (/發行商|发行商/.test(label)) {
|
||
result.label = $(el).find('a').text().trim();
|
||
} else if (/系列/.test(label)) {
|
||
result.series = $(el).find('a').text().trim();
|
||
}
|
||
});
|
||
|
||
$('span.genre a').each((_, el) => {
|
||
const tag = $(el).text().trim();
|
||
if (tag) result.tags.push(tag);
|
||
});
|
||
|
||
$('.star-name a').each((_, el) => {
|
||
const name = $(el).text().trim();
|
||
if (name) result.actresses.push(name);
|
||
});
|
||
|
||
return result.title ? result : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ─── 数据源二:JavLibrary ────────────────────────────────
|
||
function searchJavlibrary(id) {
|
||
try {
|
||
const searchUrl = `https://www.javlibrary.com/cn/vl_searchbyid.php?keyword=${encodeURIComponent(id)}`;
|
||
const searchHtml = fetchHtml(searchUrl);
|
||
const $ = cheerio.load(searchHtml);
|
||
|
||
let detailHtml = searchHtml;
|
||
const firstResult = $('.videos .video a').first();
|
||
if (firstResult.length) {
|
||
const detailPath = firstResult.attr('href');
|
||
detailHtml = fetchHtml(`https://www.javlibrary.com/cn/${detailPath}`);
|
||
}
|
||
|
||
const $d = cheerio.load(detailHtml);
|
||
if (!$d('#video_id .text').length) return null;
|
||
|
||
const foundId = $d('#video_id .text').text().trim().toUpperCase();
|
||
if (foundId !== id.toUpperCase()) return null;
|
||
|
||
const result = {
|
||
id,
|
||
source: 'JavLibrary',
|
||
title: $d('#video_title h3').text().trim(),
|
||
actresses: [],
|
||
actors: [],
|
||
releaseDate: $d('#video_date .text').text().trim(),
|
||
duration: $d('#video_length .text').text().trim(),
|
||
studio: $d('#video_maker .text a').text().trim(),
|
||
label: $d('#video_label .text a').text().trim(),
|
||
director: $d('#video_director .text a').text().trim(),
|
||
series: $d('#video_series .text a').text().trim(),
|
||
tags: [],
|
||
coverUrl: $d('#video_jacket_img').attr('src') || '',
|
||
score: $d('#video_review .score').text().trim(),
|
||
};
|
||
|
||
$d('#video_genres .genre a').each((_, el) => {
|
||
const tag = $d(el).text().trim();
|
||
if (tag) result.tags.push(tag);
|
||
});
|
||
|
||
$d('#video_cast .cast .star a').each((_, el) => {
|
||
const name = $d(el).text().trim();
|
||
if (name) result.actresses.push(name);
|
||
});
|
||
|
||
return result.title ? result : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ─── 数据源三:JAVDB(需要Cookie,仅Linux/Mac)──────────
|
||
async function searchJavdb(id) {
|
||
try {
|
||
const config = getConfig();
|
||
if (!config.session) return null;
|
||
|
||
// Windows上Cloudflare封锁了非OpenSSL的TLS请求,跳过
|
||
if (process.platform === 'win32') return null;
|
||
|
||
const session = decodeURIComponent(config.session);
|
||
const cookie = `locale=zh; over18=1; _jdb_session=${session}`;
|
||
|
||
function fetchJavdbHtml(url) {
|
||
return execSync(
|
||
`curl -sL "${url}" \
|
||
-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" \
|
||
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
|
||
-H "Accept-Language: zh-CN,zh;q=0.9" \
|
||
-H "Cookie: ${cookie}"`,
|
||
{ timeout: 15000, maxBuffer: 1024 * 1024 * 10 }
|
||
).toString();
|
||
}
|
||
|
||
const searchUrl = `https://javdb.com/search?q=${encodeURIComponent(id)}&f=all`;
|
||
const searchHtml = fetchJavdbHtml(searchUrl);
|
||
|
||
const $ = cheerio.load(searchHtml);
|
||
let detailPath = null;
|
||
|
||
$('.movie-list .item a.box').each((_, el) => {
|
||
const foundId = $(el).find('.video-title strong').text().trim().toUpperCase();
|
||
const normalize = s => s.replace(/[-_]/g, '');
|
||
if (normalize(foundId) === normalize(id.toUpperCase())) {
|
||
detailPath = $(el).attr('href');
|
||
return false;
|
||
}
|
||
});
|
||
|
||
if (!detailPath) detailPath = $('.movie-list .item a.box').first().attr('href');
|
||
if (!detailPath) return null;
|
||
|
||
// 详情页最多重试 3 次,每次间隔 2 秒
|
||
let detailHtml = '';
|
||
for (let i = 0; i < 3; i++) {
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
detailHtml = fetchJavdbHtml(`https://javdb.com${detailPath}`);
|
||
if (detailHtml.length > 1000) break;
|
||
}
|
||
|
||
if (detailHtml.length < 1000) return null;
|
||
return parseJavdb(detailHtml, id);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ─── 数据源四:NJAV(无需Cookie,覆盖率高)────────────
|
||
function searchNjav(id) {
|
||
try {
|
||
const url = `https://www.njav.com/zh/xvideos/${id.toLowerCase()}`;
|
||
const html = fetchHtml(url);
|
||
const $ = cheerio.load(html);
|
||
|
||
// 确认页面包含正确番号
|
||
const pageTitle = $('title').text();
|
||
if (!pageTitle.includes(id.toUpperCase())) return null;
|
||
|
||
const result = {
|
||
id,
|
||
source: 'NJAV',
|
||
title: '',
|
||
actresses: [],
|
||
actors: [],
|
||
releaseDate: '',
|
||
duration: '',
|
||
studio: '',
|
||
label: '',
|
||
director: '',
|
||
series: '',
|
||
tags: [],
|
||
coverUrl: `https://static.javcdn.vip/images/${id.toLowerCase()}/thumb_h.webp`,
|
||
score: '',
|
||
};
|
||
|
||
// 优先用 JSON-LD 解析,最稳定
|
||
const jsonLd = $('script[type="application/ld+json"]').text();
|
||
if (jsonLd) {
|
||
try {
|
||
const data = JSON.parse(jsonLd);
|
||
result.title = data.name || '';
|
||
result.releaseDate = data.uploadDate?.substring(0, 10) || '';
|
||
// 解析时长 PT3H22M51S → 3:22:51
|
||
if (data.duration) {
|
||
const m = data.duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||
if (m) {
|
||
const h = m[1] || '0';
|
||
const min = m[2] ? m[2].padStart(2, '0') : '00';
|
||
const sec = m[3] ? m[3].padStart(2, '0') : '00';
|
||
result.duration = `${h}:${min}:${sec}`;
|
||
}
|
||
}
|
||
if (Array.isArray(data.actor)) {
|
||
result.actresses = data.actor.map(a => a.name).filter(Boolean);
|
||
}
|
||
if (Array.isArray(data.genre)) {
|
||
result.tags = data.genre;
|
||
}
|
||
if (data.partOfSeries?.name) {
|
||
result.series = data.partOfSeries.name;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// 补充 HTML 里的字段(JSON-LD 没有的)
|
||
$('.detail-item div').each((_, el) => {
|
||
const label = $(el).find('span').first().text().trim();
|
||
const value = $(el).find('span').last().text().trim();
|
||
if (/片商|制作|製作|Studio|Maker/i.test(label)) {
|
||
result.studio = value;
|
||
} else if (/導演|Director/i.test(label)) {
|
||
result.director = value;
|
||
}
|
||
});
|
||
|
||
return result.title ? result : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function parseJavdb(html, queryId) {
|
||
const $ = cheerio.load(html);
|
||
const result = {
|
||
id: queryId,
|
||
source: 'JAVDB',
|
||
title: $('strong.current-title').text().trim(),
|
||
actresses: [],
|
||
actors: [],
|
||
releaseDate: '',
|
||
duration: '',
|
||
studio: '',
|
||
label: '',
|
||
director: '',
|
||
series: '',
|
||
tags: [],
|
||
coverUrl: $('.video-cover img').attr('src') || '',
|
||
score: $('.score .value').first().text().trim(),
|
||
scoreCount: ($('.score .value').first().text().match(/由(\d+)人/) || [])[1] || '',
|
||
wantCount: ($('.panel-block:last .is-size-7').text().match(/(\d+)人想看/) || [])[1] || '',
|
||
};
|
||
|
||
$('.movie-panel-info .panel-block').each((_, el) => {
|
||
const label = $(el).find('strong').first().text().replace(/:|:/g, '').trim();
|
||
const valueEl = $(el).find('.value');
|
||
const valueText = valueEl.text().trim();
|
||
|
||
if (/日期/.test(label)) {
|
||
const m = valueText.match(/\d{4}-\d{2}-\d{2}/);
|
||
result.releaseDate = m ? m[0] : valueText;
|
||
} else if (/時長|时长/.test(label)) {
|
||
result.duration = valueText;
|
||
} else if (/導演|导演/.test(label)) {
|
||
result.director = valueText;
|
||
} else if (/片商/.test(label)) {
|
||
result.studio = valueText;
|
||
} else if (/發行商|发行商/.test(label)) {
|
||
result.label = valueText;
|
||
} else if (/系列/.test(label)) {
|
||
result.series = valueText;
|
||
} else if (/類別|类别/.test(label)) {
|
||
valueEl.find('a').each((_, a) => {
|
||
const t = $(a).text().trim();
|
||
if (t) result.tags.push(t);
|
||
});
|
||
} else if (/演員|演员/.test(label)) {
|
||
valueEl.find('a').each((_, a) => {
|
||
const name = $(a).text().trim();
|
||
if (name) result.actresses.push(name);
|
||
});
|
||
} else if (/男優|男优/.test(label)) {
|
||
valueEl.find('a').each((_, a) => {
|
||
const name = $(a).text().trim();
|
||
if (name) result.actors.push(name);
|
||
});
|
||
}
|
||
});
|
||
|
||
return result.title ? result : null;
|
||
}
|
||
|
||
// ─── 主入口:依次尝试数据源 ─────────────────────────
|
||
export async function search(id, lang = 'zh') {
|
||
const t = getLang(lang);
|
||
|
||
// FC2 格式识别:031926-100 → 031926_100
|
||
let searchId = id;
|
||
if (/^\d{5,6}-\d+$/.test(id)) {
|
||
searchId = id.replace(/-/g, '_');
|
||
console.log(chalk.gray(` ${t.fc2Detected}: ${id} → ${searchId}`));
|
||
}
|
||
|
||
const cached = getCache(id);
|
||
if (cached) return cached;
|
||
|
||
const MAX_RETRY = 3;
|
||
|
||
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
||
let result = null;
|
||
|
||
// 第一优先:JAVBUS(最快,直接番号拼URL)
|
||
result = searchJavbus(searchId);
|
||
if (result) { setCache(id, result); return result; }
|
||
|
||
// 第二优先:NJAV(无需Cookie,覆盖率高,能查到JUR等小众番号)
|
||
result = searchNjav(searchId);
|
||
if (result) { setCache(id, result); return result; }
|
||
|
||
// 第三优先:JavLibrary(补充数据)
|
||
result = searchJavlibrary(searchId);
|
||
if (result) { setCache(id, result); return result; }
|
||
|
||
// 第四优先:JAVDB(需要Cookie,仅Linux)
|
||
result = await searchJavdb(searchId);
|
||
if (result) { setCache(id, result); return result; }
|
||
|
||
if (attempt < MAX_RETRY) {
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
}
|
||
}
|
||
|
||
return null;
|
||
} |