补充规则以及修改电子书馆来源选项卡为单行

This commit is contained in:
mtvpls
2026-05-20 11:48:09 +08:00
parent 3583366ea6
commit beb51ce1c0
5 changed files with 144 additions and 9 deletions
+1 -1
View File
@@ -12419,7 +12419,7 @@ const OPDSConfigComponent = ({
<div className='mb-3 flex items-center justify-between gap-3'> <div className='mb-3 flex items-center justify-between gap-3'>
<div> <div>
<h4 className='text-sm font-medium text-amber-900 dark:text-amber-100'>Legado </h4> <h4 className='text-sm font-medium text-amber-900 dark:text-amber-100'>Legado </h4>
<p className='mt-1 text-xs text-amber-800 dark:text-amber-200'> URL </p> <p className='mt-1 text-xs text-amber-800 dark:text-amber-200'></p>
</div> </div>
<button type='button' onClick={importLegadoSubscription} disabled={!legadoSubscriptionUrl.trim() || isLoading('importLegadoSubscription')} className={buttonStyles.primarySmall}>{isLoading('importLegadoSubscription') ? '导入中...' : '导入订阅'}</button> <button type='button' onClick={importLegadoSubscription} disabled={!legadoSubscriptionUrl.trim() || isLoading('importLegadoSubscription')} className={buttonStyles.primarySmall}>{isLoading('importLegadoSubscription') ? '导入中...' : '导入订阅'}</button>
</div> </div>
+84 -2
View File
@@ -72,9 +72,12 @@ export default function BooksCatalogPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const loaderRef = useRef<HTMLDivElement | null>(null); const loaderRef = useRef<HTMLDivElement | null>(null);
const sourceScrollerRef = useRef<HTMLDivElement | null>(null);
const navScrollerRef = useRef<HTMLDivElement | null>(null); const navScrollerRef = useRef<HTMLDivElement | null>(null);
const loadedPageHrefsRef = useRef<Set<string>>(new Set()); const loadedPageHrefsRef = useRef<Set<string>>(new Set());
const failedPageHrefsRef = useRef<Set<string>>(new Set()); const failedPageHrefsRef = useRef<Set<string>>(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 navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null);
const suppressNavClickRef = useRef(false); const suppressNavClickRef = useRef(false);
@@ -157,6 +160,64 @@ export default function BooksCatalogPage() {
return () => observer.disconnect(); return () => observer.disconnect();
}, [data, nextHref, loadingMore, loadCatalog]); }, [data, nextHref, loadingMore, loadCatalog]);
const handleSourcePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (event.pointerType === 'mouse') return;
handleSourcePointerUp(event);
}, [handleSourcePointerUp]);
const handleSourceWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => { const handleNavPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button !== 0) return; if (event.pointerType === 'mouse' && event.button !== 0) return;
@@ -235,9 +296,30 @@ export default function BooksCatalogPage() {
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<div className='flex flex-wrap gap-2'> <div
ref={sourceScrollerRef}
className='flex flex-nowrap gap-2 overflow-x-auto pb-1 cursor-grab select-none touch-pan-x active:cursor-grabbing'
onPointerDown={handleSourcePointerDown}
onPointerMove={handleSourcePointerMove}
onPointerUp={handleSourcePointerUp}
onPointerCancel={handleSourcePointerUp}
onPointerLeave={handleSourcePointerLeave}
onWheel={handleSourceWheel}
>
{sources.map((source) => ( {sources.map((source) => (
<Link key={source.id} href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} className={`rounded-full px-4 py-2 text-sm ${source.id === sourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}> <Link
key={source.id}
href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`}
draggable={false}
onDragStart={(event) => 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} {source.name}
</Link> </Link>
))} ))}
-1
View File
@@ -46,7 +46,6 @@ export default function BooksHomePage() {
<div className='space-y-6'> <div className='space-y-6'>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<h1 className='text-lg font-semibold'></h1> <h1 className='text-lg font-semibold'></h1>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'> OPDS Legado 线</p>
</section> </section>
{loading ? <BooksHomeSkeleton /> : null} {loading ? <BooksHomeSkeleton /> : null}
+1 -1
View File
@@ -26,7 +26,7 @@ function getStaticMeta(pathname: string) {
if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' }; if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' };
if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' }; if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' };
if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' }; if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' };
return { title: '电子书馆', subtitle: 'OPDS / Legado 目录、搜索、阅读与书架' }; return { title: '电子书馆' };
} }
export default function BooksLayout({ children }: { children: React.ReactNode }) { export default function BooksLayout({ children }: { children: React.ReactNode }) {
+58 -4
View File
@@ -224,6 +224,18 @@ function runJsSnippetRaw(code: string, context: Record<string, any>, timeout = 1
hexDecodeToString: (value: unknown) => Buffer.from(String(value ?? ''), 'hex').toString('utf8'), hexDecodeToString: (value: unknown) => Buffer.from(String(value ?? ''), 'hex').toString('utf8'),
ajax: () => '', ajax: () => '',
getWebViewUA: () => 'Mozilla/5.0 (Linux; Android 10) Mobile Safari/537.36', 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: () => '', deviceID: () => '',
androidId: () => '', androidId: () => '',
longToast: () => undefined, longToast: () => undefined,
@@ -449,6 +461,21 @@ function selectJsonItems(json: any, rule?: string): any[] {
function evaluateJsListRule(rule: string | undefined, context: Record<string, any>): any[] { function evaluateJsListRule(rule: string | undefined, context: Record<string, any>): any[] {
const trimmed = (rule || '').trim(); const trimmed = (rule || '').trim();
if (!/^(?:@js:|<js>)/i.test(trimmed)) return []; if (!/^(?:@js:|<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); const value = runJsSnippetRaw(trimmed, context);
if (Array.isArray(value)) return value; if (Array.isArray(value)) return value;
if (value && typeof value === 'object') return [value]; if (value && typeof value === 'object') return [value];
@@ -740,6 +767,12 @@ function selectXPath($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule: st
function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string, baseUrl?: string, jsContext?: Record<string, any>): string { function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string, baseUrl?: string, jsContext?: Record<string, any>): string {
for (const alternative of splitAlternatives(rule)) { 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}}')) { if (alternative.includes('{{baseUrl}}')) {
const { base, filters } = splitRuleFilters(alternative); const { base, filters } = splitRuleFilters(alternative);
const value = applyRuleFilters(base.replace(/\{\{baseUrl\}\}/g, baseUrl || ''), filters); const value = applyRuleFilters(base.replace(/\{\{baseUrl\}\}/g, baseUrl || ''), filters);
@@ -747,12 +780,12 @@ function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: str
continue; continue;
} }
if (/^<js>/i.test(alternative.trim())) { 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; if (transformed) return /^(?:https?:)?\/\//i.test(transformed) || transformed.startsWith('/') ? normalizeUrl(baseUrl || '', transformed) : transformed;
continue; continue;
} }
if (/^@js:/i.test(alternative.trim())) { 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; if (transformed) return /^(?:https?:)?\/\//i.test(transformed) || transformed.startsWith('/') ? normalizeUrl(baseUrl || '', transformed) : transformed;
continue; continue;
} }
@@ -871,6 +904,26 @@ function applyBookInfoInit($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, in
} }
function applyContentJsRule(value: string, jsRule: string): string { 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) => `<img src="${src}" style="max-width:100%; display:block;" referrerpolicy="no-referrer">`)
.join('\n');
}
} catch {
// fallback to generic JS execution below
}
}
if (/window\.comicInfo/.test(value)) { if (/window\.comicInfo/.test(value)) {
try { try {
const match = value.match(/window\.comicInfo\s*=\s*(.*?)(?:,window\.hideguide|;|<\/script>)/); 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); return readJsonRule(json, rule, undefined, baseUrl);
} }
const rawRule = rule || ''; const rawRule = rule || '';
const blockJs = rawRule.trim().match(/^<js>([\s\S]*?)<\/js>$/i);
const jsIndex = rawRule.indexOf('@js:'); const jsIndex = rawRule.indexOf('@js:');
const selectorRule = jsIndex >= 0 ? rawRule.slice(0, jsIndex).trim() : rawRule; const selectorRule = blockJs ? '' : jsIndex >= 0 ? rawRule.slice(0, jsIndex).trim() : rawRule;
const jsRule = jsIndex >= 0 ? rawRule.slice(jsIndex + 4).trim() : ''; const jsRule = blockJs ? blockJs[1].trim() : jsIndex >= 0 ? rawRule.slice(jsIndex + 4).trim() : '';
const $ = cheerio.load(raw); const $ = cheerio.load(raw);
if (jsRule) { if (jsRule) {
const values = selectorRule ? readValues($, $.root(), selectorRule, baseUrl) : []; const values = selectorRule ? readValues($, $.root(), selectorRule, baseUrl) : [];