diff --git a/src/app/api/books/image/route.ts b/src/app/api/books/image/route.ts new file mode 100644 index 0000000..a9a735c --- /dev/null +++ b/src/app/api/books/image/route.ts @@ -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): Record { + 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>((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 = { + '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 }); + } +} diff --git a/src/lib/book.types.ts b/src/lib/book.types.ts index f091186..5ae0c27 100644 --- a/src/lib/book.types.ts +++ b/src/lib/book.types.ts @@ -48,6 +48,7 @@ export interface LegadoBookSourceRule { bookSourceName?: string; bookSourceUrl?: string; bookSourceGroup?: string; + bookSourceType?: number; enabled?: boolean; enabledExplore?: boolean; exploreUrl?: string; diff --git a/src/lib/legado.client.ts b/src/lib/legado.client.ts index 163522e..f503175 100644 --- a/src/lib/legado.client.ts +++ b/src/lib/legado.client.ts @@ -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, rule?: string): cheerio.Cheerio { @@ -301,6 +321,7 @@ function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio, 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 (!/]*?)\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 ``; + }); +} + async function resolveLegadoConfig(): Promise { 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 { 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(); + 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, };