diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5ee781d..395c450 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -12419,7 +12419,7 @@ const OPDSConfigComponent = ({

Legado 订阅

-

输入订阅 URL 导入,支持大型书源订阅。

+

目前处于实验性阶段,仅支持部分简单订阅。

diff --git a/src/app/books/catalog/page.tsx b/src/app/books/catalog/page.tsx index b1c9faa..6f06ea5 100644 --- a/src/app/books/catalog/page.tsx +++ b/src/app/books/catalog/page.tsx @@ -72,9 +72,12 @@ export default function BooksCatalogPage() { const [error, setError] = useState(''); const [loadingMore, setLoadingMore] = useState(false); const loaderRef = useRef(null); + const sourceScrollerRef = useRef(null); const navScrollerRef = useRef(null); const loadedPageHrefsRef = useRef>(new Set()); const failedPageHrefsRef = useRef>(new Set()); + const sourceDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); + const suppressSourceClickRef = useRef(false); const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); const suppressNavClickRef = useRef(false); @@ -157,6 +160,64 @@ export default function BooksCatalogPage() { return () => observer.disconnect(); }, [data, nextHref, loadingMore, loadCatalog]); + const handleSourcePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + const node = sourceScrollerRef.current; + if (!node) return; + sourceDragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startScrollLeft: node.scrollLeft, + moved: false, + pointerType: event.pointerType, + }; + suppressSourceClickRef.current = false; + if (event.pointerType !== 'mouse') { + node.setPointerCapture?.(event.pointerId); + } + }, []); + + const handleSourcePointerMove = useCallback((event: ReactPointerEvent) => { + const node = sourceScrollerRef.current; + const dragState = sourceDragStateRef.current; + if (!node || !dragState || dragState.pointerId !== event.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; + if (Math.abs(deltaX) > moveThreshold) { + dragState.moved = true; + suppressSourceClickRef.current = true; + } + node.scrollLeft = dragState.startScrollLeft - deltaX; + }, []); + + const handleSourcePointerUp = useCallback((event: ReactPointerEvent) => { + const node = sourceScrollerRef.current; + const dragState = sourceDragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + if (dragState.moved) { + event.preventDefault(); + window.setTimeout(() => { + suppressSourceClickRef.current = false; + }, 0); + } + sourceDragStateRef.current = null; + if (dragState.pointerType !== 'mouse') { + node?.releasePointerCapture?.(event.pointerId); + } + }, []); + + const handleSourcePointerLeave = useCallback((event: ReactPointerEvent) => { + if (event.pointerType === 'mouse') return; + handleSourcePointerUp(event); + }, [handleSourcePointerUp]); + + const handleSourceWheel = useCallback((event: ReactWheelEvent) => { + const node = sourceScrollerRef.current; + if (!node) return; + const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (!delta) return; + node.scrollLeft += delta; + }, []); const handleNavPointerDown = useCallback((event: ReactPointerEvent) => { if (event.pointerType === 'mouse' && event.button !== 0) return; @@ -235,9 +296,30 @@ export default function BooksCatalogPage() { return (
-
+
{sources.map((source) => ( - + event.preventDefault()} + onClick={(event) => { + if (suppressSourceClickRef.current) { + event.preventDefault(); + suppressSourceClickRef.current = false; + } + }} + className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${source.id === sourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`} + > {source.name} ))} diff --git a/src/app/books/page.tsx b/src/app/books/page.tsx index 9aa4f90..53084cd 100644 --- a/src/app/books/page.tsx +++ b/src/app/books/page.tsx @@ -46,7 +46,6 @@ export default function BooksHomePage() {

电子书源

-

支持 OPDS 与 Legado 书源,提供搜索、书架与在线阅读。

{loading ? : null} diff --git a/src/components/books/BooksLayout.tsx b/src/components/books/BooksLayout.tsx index cd838ae..f772ea2 100644 --- a/src/components/books/BooksLayout.tsx +++ b/src/components/books/BooksLayout.tsx @@ -26,7 +26,7 @@ function getStaticMeta(pathname: string) { if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' }; if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' }; if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' }; - return { title: '电子书馆', subtitle: 'OPDS / Legado 目录、搜索、阅读与书架' }; + return { title: '电子书馆' }; } export default function BooksLayout({ children }: { children: React.ReactNode }) { diff --git a/src/lib/legado.client.ts b/src/lib/legado.client.ts index a0637d9..5723d22 100644 --- a/src/lib/legado.client.ts +++ b/src/lib/legado.client.ts @@ -224,6 +224,18 @@ function runJsSnippetRaw(code: string, context: Record, timeout = 1 hexDecodeToString: (value: unknown) => Buffer.from(String(value ?? ''), 'hex').toString('utf8'), ajax: () => '', getWebViewUA: () => 'Mozilla/5.0 (Linux; Android 10) Mobile Safari/537.36', + getElements: (selectorRule: string) => { + const raw = jsonPrimitiveToString(context.result ?? context.src ?? ''); + if (!raw) return []; + const $ = cheerio.load(raw); + let nodes = selectElements($, $.root(), selectorRule); + if (nodes.length === 0 && /#chapter-items@a/.test(selectorRule)) nodes = $('#chapter-items').find('a'); + return nodes.toArray().map((element) => { + const node = $(element); + const attrs = (element as any).attribs || {}; + return { ...attrs, text: node.text().trim(), href: attrs.href, src: attrs.src, html: node.html() || '' }; + }); + }, deviceID: () => '', androidId: () => '', longToast: () => undefined, @@ -449,6 +461,21 @@ function selectJsonItems(json: any, rule?: string): any[] { function evaluateJsListRule(rule: string | undefined, context: Record): any[] { const trimmed = (rule || '').trim(); if (!/^(?:@js:|)/i.test(trimmed)) return []; + const elementRules = Array.from(trimmed.matchAll(/java\.getElements\(\s*(['"`])([\s\S]*?)\1\s*\)/g)).map((match) => match[2]).filter(Boolean); + if (elementRules.length > 0) { + const raw = jsonPrimitiveToString(context.result ?? context.src ?? ''); + const $ = cheerio.load(raw); + for (const selectorRule of elementRules) { + let nodes = selectElements($, $.root(), selectorRule); + if (nodes.length === 0 && /#chapter-items@a/.test(selectorRule)) nodes = $('#chapter-items').find('a'); + const items = nodes.toArray().map((element) => { + const node = $(element); + const attrs = (element as any).attribs || {}; + return { ...attrs, text: node.text().trim(), href: attrs.href, src: attrs.src, html: node.html() || '' }; + }); + if (items.length > 0) return items; + } + } const value = runJsSnippetRaw(trimmed, context); if (Array.isArray(value)) return value; if (value && typeof value === 'object') return [value]; @@ -740,6 +767,12 @@ function selectXPath($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule: st function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule?: string, baseUrl?: string, jsContext?: Record): string { for (const alternative of splitAlternatives(rule)) { + const templateKind = alternative.match(/\{\{\s*@@([\s\S]*?)\}\}/); + if (templateKind) { + const value = alternative.replace(/\{\{\s*@@([\s\S]*?)\}\}/g, (_, innerRule) => readValue($, root, String(innerRule).trim(), baseUrl, jsContext)); + if (value) return value; + continue; + } if (alternative.includes('{{baseUrl}}')) { const { base, filters } = splitRuleFilters(alternative); const value = applyRuleFilters(base.replace(/\{\{baseUrl\}\}/g, baseUrl || ''), filters); @@ -747,12 +780,12 @@ function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio, rule?: str continue; } if (/^/i.test(alternative.trim())) { - const transformed = runJsSnippet(alternative, { ...(jsContext || {}), result: root.text(), src: root.text(), baseUrl }); + const transformed = runJsSnippet(alternative, { ...(jsContext || {}), result: $.html(root), src: $.html(root), baseUrl }); if (transformed) return /^(?:https?:)?\/\//i.test(transformed) || transformed.startsWith('/') ? normalizeUrl(baseUrl || '', transformed) : transformed; continue; } if (/^@js:/i.test(alternative.trim())) { - const transformed = runJsSnippet(alternative, { ...(jsContext || {}), result: root.text(), src: root.text(), baseUrl }); + const transformed = runJsSnippet(alternative, { ...(jsContext || {}), result: $.html(root), src: $.html(root), baseUrl }); if (transformed) return /^(?:https?:)?\/\//i.test(transformed) || transformed.startsWith('/') ? normalizeUrl(baseUrl || '', transformed) : transformed; continue; } @@ -871,6 +904,26 @@ function applyBookInfoInit($: cheerio.CheerioAPI, root: cheerio.Cheerio, in } function applyContentJsRule(value: string, jsRule: string): string { + const encryptedParams = value.match(/params\s*=\s*'([^']+)'/)?.[1]; + if (encryptedParams) { + try { + const encrypted = Buffer.from(encryptedParams, 'base64'); + const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from('5V&RoR%Jf@pJPydF'), encrypted.subarray(0, 16)); + const decoded = Buffer.concat([decipher.update(encrypted.subarray(16)), decipher.final()]).toString('utf8'); + const data = JSON.parse(decoded); + const images = Array.isArray(data?.chapter_images) ? data.chapter_images : []; + const imageBase = data?.chapter_domain || data?.images_domain || data?.cdnurl || 'https://six.mhpic.net'; + if (images.length > 0) { + return images + .map((src: string) => normalizeUrl(imageBase, String(src || ''))) + .filter(Boolean) + .map((src: string) => ``) + .join('\n'); + } + } catch { + // fallback to generic JS execution below + } + } if (/window\.comicInfo/.test(value)) { try { const match = value.match(/window\.comicInfo\s*=\s*(.*?)(?:,window\.hideguide|;|<\/script>)/); @@ -906,9 +959,10 @@ function contentFromRule(raw: string, rule?: string, baseUrl?: string): string { return readJsonRule(json, rule, undefined, baseUrl); } const rawRule = rule || ''; + const blockJs = rawRule.trim().match(/^([\s\S]*?)<\/js>$/i); const jsIndex = rawRule.indexOf('@js:'); - const selectorRule = jsIndex >= 0 ? rawRule.slice(0, jsIndex).trim() : rawRule; - const jsRule = jsIndex >= 0 ? rawRule.slice(jsIndex + 4).trim() : ''; + const selectorRule = blockJs ? '' : jsIndex >= 0 ? rawRule.slice(0, jsIndex).trim() : rawRule; + const jsRule = blockJs ? blockJs[1].trim() : jsIndex >= 0 ? rawRule.slice(jsIndex + 4).trim() : ''; const $ = cheerio.load(raw); if (jsRule) { const values = selectorRule ? readValues($, $.root(), selectorRule, baseUrl) : [];