规则引擎兼容更多且代理图片
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export interface LegadoBookSourceRule {
|
||||
bookSourceName?: string;
|
||||
bookSourceUrl?: string;
|
||||
bookSourceGroup?: string;
|
||||
bookSourceType?: number;
|
||||
enabled?: boolean;
|
||||
enabledExplore?: boolean;
|
||||
exploreUrl?: string;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user