补充规则以及修改电子书馆来源选项卡为单行
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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) : [];
|
||||||
|
|||||||
Reference in New Issue
Block a user