补充规则以及修改电子书馆来源选项卡为单行
This commit is contained in:
@@ -12419,7 +12419,7 @@ const OPDSConfigComponent = ({
|
||||
<div className='mb-3 flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<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>
|
||||
<button type='button' onClick={importLegadoSubscription} disabled={!legadoSubscriptionUrl.trim() || isLoading('importLegadoSubscription')} className={buttonStyles.primarySmall}>{isLoading('importLegadoSubscription') ? '导入中...' : '导入订阅'}</button>
|
||||
</div>
|
||||
|
||||
@@ -72,9 +72,12 @@ export default function BooksCatalogPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const loaderRef = useRef<HTMLDivElement | null>(null);
|
||||
const sourceScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const navScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadedPageHrefsRef = 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 suppressNavClickRef = useRef(false);
|
||||
|
||||
@@ -157,6 +160,64 @@ export default function BooksCatalogPage() {
|
||||
return () => observer.disconnect();
|
||||
}, [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>) => {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
@@ -235,9 +296,30 @@ export default function BooksCatalogPage() {
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<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}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -46,7 +46,6 @@ export default function BooksHomePage() {
|
||||
<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'>
|
||||
<h1 className='text-lg font-semibold'>电子书源</h1>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>支持 OPDS 与 Legado 书源,提供搜索、书架与在线阅读。</p>
|
||||
</section>
|
||||
|
||||
{loading ? <BooksHomeSkeleton /> : null}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -224,6 +224,18 @@ function runJsSnippetRaw(code: string, context: Record<string, any>, 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<string, any>): any[] {
|
||||
const trimmed = (rule || '').trim();
|
||||
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);
|
||||
if (Array.isArray(value)) 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 {
|
||||
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<any>, rule?: str
|
||||
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;
|
||||
}
|
||||
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<any>, 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) => `<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)) {
|
||||
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(/^<js>([\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) : [];
|
||||
|
||||
مرجع در شماره جدید
Block a user