规则引擎兼容更多且代理图片

This commit is contained in:
mtvpls
2026-05-19 21:40:30 +08:00
parent 4626b14a39
commit 11f603a10c
3 changed files with 113 additions and 5 deletions
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { legadoClient } from '@/lib/legado.client';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
function asObjectHeader(value?: string | Record<string, string>): Record<string, string> {
if (!value) return {};
if (typeof value === 'object') return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return value.split('\n').reduce<Record<string, string>>((headers, line) => {
const index = line.indexOf(':');
if (index > 0) headers[line.slice(0, index).trim()] = line.slice(index + 1).trim();
return headers;
}, {});
}
}
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId') || '';
const url = searchParams.get('url') || '';
if (!sourceId || !url) return NextResponse.json({ error: '缺少 sourceId 或 url' }, { status: 400 });
if (!(await validateProxyUrlServerSide(url))) return NextResponse.json({ error: '图片地址未通过安全校验' }, { status: 400 });
const source = await legadoClient.getSourceById(sourceId);
const headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
Referer: source.legado?.bookSourceUrl || source.url,
...asObjectHeader(source.legado?.header),
};
delete headers.Host;
delete headers.host;
const res = await fetch(url, { headers, cache: 'no-store' });
if (!res.ok) return NextResponse.json({ error: `图片请求失败: ${res.status}` }, { status: res.status });
return new NextResponse(res.body, {
headers: {
'Content-Type': res.headers.get('content-type') || 'image/jpeg',
'Cache-Control': 'public, max-age=86400',
},
});
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : '图片代理失败' }, { status: 500 });
}
}
+1
View File
@@ -48,6 +48,7 @@ export interface LegadoBookSourceRule {
bookSourceName?: string;
bookSourceUrl?: string;
bookSourceGroup?: string;
bookSourceType?: number;
enabled?: boolean;
enabledExplore?: boolean;
exploreUrl?: string;
+58 -5
View File
@@ -221,6 +221,26 @@ function splitAlternatives(rule?: string): string[] {
return (rule || '').split('||').map((item) => item.trim()).filter(Boolean);
}
function splitRuleFilters(rule: string) {
const parts = (rule || '').split('##');
return { base: (parts.shift() || '').trim(), filters: parts };
}
function applyRuleFilters(value: string, filters: string[]) {
let result = value;
for (let index = 0; index < filters.length; index += 2) {
const pattern = filters[index];
const replacement = filters[index + 1] ?? '';
if (!pattern) continue;
try {
result = result.replace(new RegExp(pattern, 'g'), replacement);
} catch {
result = result.split(pattern).join(replacement);
}
}
return result;
}
function isLegadoAttrToken(value: string) {
return /^(href|src|title|alt|text|textNodes|html|content|value|data-[\w-]+)$/i.test(value.trim());
}
@@ -262,7 +282,7 @@ function parseStep(step: string): { selector: string; attr: string } {
}
function stripFilters(rule: string) {
return rule.split('##')[0].trim();
return splitRuleFilters(rule).base;
}
function selectElements($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string): cheerio.Cheerio<any> {
@@ -301,6 +321,7 @@ function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: str
else value = node.attr(attr) || '';
value = he.decode(value || '').replace(/\u00a0/g, ' ').trim();
if ((normalizedAttr === 'href' || normalizedAttr === 'src') && value && baseUrl) value = normalizeUrl(baseUrl, value);
value = applyRuleFilters(value, splitRuleFilters(alternative).filters);
if (value) return value;
}
return '';
@@ -381,6 +402,17 @@ function cleanContent(value: string) {
.join('\n\n');
}
function proxyChapterImages(content: string, source: BookSource) {
if (!/<img\b/i.test(content)) return content;
const rule = source.legado;
if (rule?.bookSourceType !== 2 && source.legado?.bookSourceType !== 2) return content;
return content.replace(/<img\b([^>]*?)\bsrc=(['"])(.*?)\2([^>]*)>/gi, (match, before, quote, src, after) => {
if (!src || src.startsWith('/api/books/image')) return match;
const proxied = `/api/books/image?sourceId=${encodeURIComponent(source.id)}&url=${encodeURIComponent(src)}`;
return `<img${before}src=${quote}${proxied}${quote}${after}>`;
});
}
async function resolveLegadoConfig(): Promise<ResolvedLegadoConfig> {
let enabled = process.env.OPDS_ENABLED === 'true' || process.env.LEGADO_ENABLED === 'true';
let sources: BookSource[] = [];
@@ -474,7 +506,16 @@ async function fetchText(source: BookSource, url: string): Promise<string> {
if (!response.ok) throw new Error(`请求失败: ${response.status}`);
const contentLength = Number(response.headers.get('content-length') || '0');
if (contentLength > MAX_TEXT_BYTES) throw new Error('响应内容过大');
const text = await response.text();
const buffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || '';
const charset = contentType.match(/charset=([^;]+)/i)?.[1]?.trim().replace(/^['"]|['"]$/g, '');
const decoderName = /gbk|gb2312|gb18030/i.test(charset || '') ? 'gb18030' : (charset || 'utf-8');
let text: string;
try {
text = new TextDecoder(decoderName).decode(buffer);
} catch {
text = new TextDecoder('utf-8').decode(buffer);
}
if (text.length > MAX_TEXT_BYTES) throw new Error('响应内容过大');
textCache.set(cacheKey, { data: text, expiresAt: Date.now() + cacheTTL });
return text;
@@ -880,15 +921,27 @@ export class LegadoClient {
const cached = chapterCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.data;
const html = await fetchText(source, targetUrl);
const rawContent = contentFromRule(html, rule.ruleContent.content, targetUrl);
let pageUrl = targetUrl;
const parts: string[] = [];
const visited = new Set<string>();
for (let page = 0; page < 8 && pageUrl && !visited.has(pageUrl); page += 1) {
visited.add(pageUrl);
const html = await fetchText(source, pageUrl);
const part = contentFromRule(html, rule.ruleContent.content, pageUrl);
if (part) parts.push(part);
const next = rule.ruleContent.nextContentUrl ? contentFromRule(html, rule.ruleContent.nextContentUrl, pageUrl) : '';
const normalizedNext = next ? normalizeUrl(pageUrl, next) : '';
if (!normalizedNext || normalizedNext === pageUrl || visited.has(normalizedNext)) break;
pageUrl = normalizedNext;
}
const rawContent = parts.join('\\n\\n');
const chapters = tocHref ? await this.getChapters(sourceId, tocHref).catch(() => []) : [];
const index = chapters.findIndex((item) => item.href === targetUrl || item.href === chapterHref);
const content: BookChapterContent = {
id: stableId(`${source.id}|${targetUrl}`),
title: index >= 0 ? chapters[index].title : '',
href: targetUrl,
content: cleanContent(rawContent),
content: proxyChapterImages(cleanContent(rawContent), source),
previousHref: index > 0 ? chapters[index - 1].href : undefined,
nextHref: index >= 0 && index + 1 < chapters.length ? chapters[index + 1].href : undefined,
};