补充规则

This commit is contained in:
mtvpls
2026-05-20 01:40:42 +08:00
parent da63d90f44
commit 0a8a6afa78
2 changed files with 111 additions and 10 deletions
+1
View File
@@ -74,6 +74,7 @@ export interface LegadoBookSourceRule {
enabledExplore?: boolean;
exploreUrl?: string;
header?: string | Record<string, string>;
jsLib?: string;
loginUrl?: string;
searchUrl?: string;
bookInfoUrl?: string;
+110 -10
View File
@@ -115,6 +115,31 @@ function encodeRuleParam(value: string) {
return encodeURIComponent(value).replace(/%20/g, '+');
}
function encryptTongrenKeyword(plainText: string) {
const passphrase = 'zc89s30ipHG2Dw';
const key = Buffer.alloc(32);
const iv = Buffer.alloc(16);
Buffer.from(passphrase).copy(key);
Buffer.from(passphrase).copy(iv);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
return encodeURIComponent(Buffer.concat([cipher.update(String(plainText), 'utf8'), cipher.final()]).toString('base64'));
}
function decryptTongrenOpenUrl(encryptedBase64: string, num: string, source?: LegadoBookSourceRule) {
try {
const numStr = Buffer.from(String(num || ''), 'base64').toString('utf8');
const userAgent = (asObjectHeader(source?.header)['User-Agent'] || 'Mozilla/5.0 (Linux; Android 9) Mobile Safari/537.36').toLowerCase();
const key = Buffer.from(crypto.createHash('md5').update(userAgent + numStr).digest('hex'), 'utf8');
const encryptedData = Buffer.from(String(encryptedBase64 || ''), 'base64');
const iv = encryptedData.subarray(0, 16);
const data = encryptedData.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
} catch {
return '';
}
}
function safeEvalTemplateExpression(expr: string, keyword: string, page: number) {
const key = keyword;
const searchTerms = keyword;
@@ -151,13 +176,35 @@ function runJsSnippet(code: string, context: Record<string, any>, timeout = 1000
base64Encode: (value: unknown) => Buffer.from(String(value ?? ''), 'utf8').toString('base64'),
base64Decode: (value: unknown) => Buffer.from(String(value ?? ''), 'base64').toString('utf8'),
md5Encode: (value: unknown) => crypto.createHash('md5').update(String(value ?? '')).digest('hex'),
strToBytes: (value: unknown) => Buffer.from(String(value ?? ''), 'utf8'),
base64DecodeToByteArray: (value: unknown) => Buffer.from(String(value ?? ''), 'base64'),
encodeURI: (value: unknown) => encodeURIComponent(String(value ?? '')),
encodeURIComponent: (value: unknown) => encodeURIComponent(String(value ?? '')),
put: (key: string, value: any) => { variableStore.set(key, value); return value; },
get: (key: string) => variableStore.get(key),
log: () => undefined,
htmlFormat: (value: unknown) => he.decode(String(value ?? '')).replace(/<br\s*\/?/gi, '\n').replace(/<[^>]+>/g, ''),
};
const sandbox: Record<string, any> = { ...context, java, Buffer, JSON, String, Number, Math, Array, Object, console: { log: () => undefined } };
const cookie = {
removeCookie: () => undefined,
mapToCookie: (input: any) => typeof input === 'string' ? input : Array.isArray(input) ? input.join('; ') : '',
};
const sandbox: Record<string, any> = {
...context,
java,
cookie,
Buffer,
JSON,
String,
Number,
Math,
Array,
Object,
encodeURIComponent,
encryptText: encryptTongrenKeyword,
openUrl: (encryptedText: string, num: string) => decryptTongrenOpenUrl(encryptedText, num, context.source),
console: { log: () => undefined },
};
const body = code.replace(/^@js:/, '').trim();
try {
const script = new vm.Script(`(function(){ ${body}\n})()`);
@@ -195,6 +242,14 @@ function applyPutGetRules(rule: string, value: string) {
function buildUrlFromTemplate(template: string, source: BookSource, keyword?: string, page = 1, baseOverride?: string) {
const base = baseOverride || sourceBase(source);
let raw = template || base;
if (raw.trim().startsWith('@js:')) {
if (/\/k-\{\{encryptText\(key\)\}\}-\{\{page\}\}\.html/.test(raw)) {
return `https://www.rrssk.com/k-${encryptTongrenKeyword(keyword || '')}-${page}.html`;
}
const evaluated = runJsSnippet(raw, { key: keyword || '', keyword: keyword || '', page, baseUrl: base, source: { ...(source.legado || {}), key: base } });
raw = evaluated || raw;
if (/,(\s*)\{/.test(raw)) return raw;
}
raw = renderTemplateExpressions(raw, keyword || '', page);
raw = raw
.replace(/\{searchTerms\}/g, encodeRuleParam(keyword || ''))
@@ -205,6 +260,17 @@ function buildUrlFromTemplate(template: string, source: BookSource, keyword?: st
return normalizeUrl(base, raw);
}
function stripRuleJsBlocks(rule?: string) {
return (rule || '').replace(/<js>[\s\S]*?<\/js>/gi, '').trim();
}
function applyRuleJsBlocks(raw: string, rule?: string) {
const blocks = Array.from((rule || '').matchAll(/<js>([\s\S]*?)<\/js>/gi));
for (const block of blocks) {
runJsSnippet(block[1], { src: raw, result: raw });
}
}
function parseJsonMaybe(value: string): any | null {
try {
return JSON.parse(value.trim());
@@ -569,8 +635,21 @@ function selectXPath($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule: st
return { nodes: parts.length ? root.find(parts.join(' ')) : root, attr };
}
function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string, baseUrl?: string): string {
function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string, baseUrl?: string, jsContext?: Record<string, any>): string {
for (const alternative of splitAlternatives(rule)) {
const jsIndex = alternative.indexOf('@js:');
if (jsIndex > 0) {
const selectorRule = alternative.slice(0, jsIndex).trim();
const jsRule = alternative.slice(jsIndex).trim();
const selected = readValue($, root, selectorRule, baseUrl, jsContext);
const transformed = runJsSnippet(jsRule, { ...(jsContext || {}), result: selected, src: selected, baseUrl });
if (transformed) {
if (/,(\s*)\{/.test(transformed)) return transformed;
return /^(?:https?:)?\/\//i.test(transformed) || transformed.startsWith('/') ? normalizeUrl(baseUrl || '', transformed) : transformed;
}
if (selected) return selected;
continue;
}
const normalized = stripFilters(alternative);
const getMatch = normalized.match(/^@get:\s*\{\s*([A-Za-z0-9_$-]+)\s*\}$/);
if (getMatch) {
@@ -959,12 +1038,13 @@ function getEffectiveExploreRule(rule: LegadoBookSourceRule): LegadoRuleSearch |
}
function hasExplore(rule: LegadoBookSourceRule) {
return rule.enabledExplore !== false && parseExploreUrl(rule.exploreUrl).length > 0 && !!getEffectiveExploreRule(rule);
return rule.enabledExplore !== false && (!!rule.exploreUrl?.trim().startsWith('@js:') || parseExploreUrl(rule.exploreUrl).length > 0) && !!getEffectiveExploreRule(rule);
}
function parseExploreUrl(exploreUrl?: string): Array<{ title: string; template: string }> {
const raw = (exploreUrl || '').trim();
if (!raw) return [];
if (raw.startsWith('@js:')) return [];
const json = parseJsonMaybe(raw);
if (Array.isArray(json)) {
return json
@@ -1003,6 +1083,24 @@ function buildExploreTargetUrl(source: BookSource, target: ExploreTarget) {
return buildUrlFromTemplate(target.template, source, undefined, target.page);
}
async function resolveExploreCategories(source: BookSource, rule: LegadoBookSourceRule): Promise<Array<{ title: string; template: string }>> {
const raw = (rule.exploreUrl || '').trim();
if (!raw.startsWith('@js:')) return parseExploreUrl(raw);
try {
const html = await fetchText(source, sourceBase(source));
const $ = cheerio.load(html);
const categories = $('a[href^="/list"]').toArray().map((el) => {
const title = $(el).text().trim();
const href = ($(el).attr('href') || '').replace(/\/$/, '-{{page}}/');
return title && href ? { title, template: href } : null;
}).filter(Boolean) as Array<{ title: string; template: string }>;
if (categories.length > 0) return [{ title: '全部分类', template: '__group__:全部分类' }, ...categories];
} catch {
// 动态分类首页被源站拦截时,至少不要把 @js 当成分类项显示。
}
return [];
}
export class LegadoClient {
async getSources(): Promise<BookSource[]> {
const config = await resolveLegadoConfig();
@@ -1037,13 +1135,15 @@ export class LegadoClient {
for (let page = 1; page <= DEFAULT_LEGADO_SEARCH_PAGES; page += 1) {
const targetUrl = buildUrlFromTemplate(rule.searchUrl, source, q, page);
const html = await fetchText(source, targetUrl);
applyRuleJsBlocks(html, rule.ruleSearch.bookList);
if (page === 1 && rule.ruleSearch.checkKeyWord && !html.includes(rule.ruleSearch.checkKeyWord) && !html.includes(q)) {
throw new Error('搜索结果校验失败');
}
let pageCount = 0;
const json = parseJsonMaybe(html);
if (json && ruleIsJson(rule.ruleSearch.bookList)) {
const items = selectJsonItems(json, rule.ruleSearch.bookList);
const searchBookListRule = stripRuleJsBlocks(rule.ruleSearch.bookList);
if (json && ruleIsJson(searchBookListRule)) {
const items = selectJsonItems(json, searchBookListRule);
pageCount = items.length;
items.forEach((item) => {
const detailHref = readJsonRule(item, rule.ruleSearch?.bookUrl, source, targetUrl);
@@ -1064,8 +1164,8 @@ export class LegadoClient {
tags: readJsonRule(item, rule.ruleSearch?.kind, source, targetUrl).split(/[,\s]+/).filter(Boolean),
}));
});
} else if (rule.ruleSearch.bookList.trim().startsWith(':')) {
const items = readAllInOneList(html, rule.ruleSearch.bookList);
} else if (searchBookListRule.trim().startsWith(':')) {
const items = readAllInOneList(html, searchBookListRule);
pageCount = items.length;
items.forEach((item) => {
const detailHref = readRegexItem(item, rule.ruleSearch?.bookUrl, targetUrl);
@@ -1087,11 +1187,11 @@ export class LegadoClient {
});
} else {
const $ = cheerio.load(html);
const items = selectElements($, $.root(), rule.ruleSearch.bookList);
const items = selectElements($, $.root(), searchBookListRule);
pageCount = items.length;
items.each((_, element) => {
const root = $(element);
const detailHref = readValue($, root, rule.ruleSearch?.bookUrl, targetUrl);
const detailHref = readValue($, root, rule.ruleSearch?.bookUrl, targetUrl, { source: rule });
const title = readValue($, root, rule.ruleSearch?.name, targetUrl);
if (!title && !detailHref) return;
const cover = readValue($, root, rule.ruleSearch?.coverUrl, targetUrl);
@@ -1137,7 +1237,7 @@ export class LegadoClient {
return { sourceId: source.id, sourceName: source.name, title: source.name, href: href || source.url, entries: [], navigation: [] };
}
const categories = parseExploreUrl(rule.exploreUrl);
const categories = await resolveExploreCategories(source, rule);
const target = decodeExploreTarget(href) || null;
const navigation = categories.map((item) => ({
title: item.title,