feat(reader): add selection annotations and server-side book search

This commit is contained in:
hectorqin committed 2026-09-24 16:03:35 +08:00
1 parent 9c83e3e013
commit 1d86efab34
18 files changed
+369 -23

No files matched your search

+22
View File
@@ -17,6 +17,8 @@ import {
import { sendAssetPayload } from '../assets.ts';
import { FilePublications } from '../../publications/files.ts';
import { withSignal } from '../request-signal.ts';
import { searchBookPage } from '../../services/book-search.ts';
import { ReadingOverrideService } from '../../services/reading-overrides.ts';
/**
* The handler that owns a book, resolved from its recorded format.
@@ -314,6 +316,26 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
* route stable as formats evolve and lets each handler decide how to address
* its own contents.
*/
app.get('/api/v1/books/:id/search', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request), { id } = request.params as { id: string };
const book = ctx.shelf.get(user.id, id), query = request.query as Record<string, string | undefined>;
if (typeof query.q !== 'string' || !query.q.trim() || query.q.length > 200) throw badRequest('关键词须为 1–200 个字符');
if (query.cursor !== undefined && typeof query.cursor !== 'string') throw badRequest('无效的搜索游标');
const manifest = await contentManifest(ctx, user.id, id, book.format);
if (!manifest) throw badRequest('此格式暂不支持正文搜索', 'UNSUPPORTED_FORMAT');
const overrides = new ReadingOverrideService(ctx.db).get(user.id,id);
if (overrides.headingPrefix) throw badRequest('自定义 TXT 目录使用本地搜索','SEARCH_LOCAL_LAYOUT');
const chapters = ctx.sources?.chapters.has(id) ? ctx.sources.chapters : undefined;
const handler = chapters ? null : resolveHandler(ctx,id,book.format);
reply.header('cache-control','no-store');
return withSignal(request,reply,signal => searchBookPage(manifest,query.q!.trim(),query.cursor,overrides,async (item, chapterSignal) => {
const ref = item.resourceRef ?? (item.format === 'html' && item.href.startsWith('chapter:') ? item.href.replace('chapter:','chapter-full:') : item.href);
if (chapters) return chapters.asset(user.id,id,ref,chapterSignal);
if (!handler) throw badRequest('此格式暂不支持正文搜索','UNSUPPORTED_FORMAT');
return handler.asset({...sourceContext(ctx,id),bookId:id},{ref});
},signal,chapters ? 1 : 8));
});
app.get('/api/v1/books/:id/assets', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request);
const { id } = request.params as { id: string };
+89
View File
@@ -0,0 +1,89 @@
import { Parser } from 'htmlparser2';
import { createHash } from 'node:crypto';
import type { Manifest, AssetPayload, ContentItem } from '../indexer/formats/registry.ts';
import type { ReadingOverrides } from './reading-overrides.ts';
import { badRequest, conflict } from '../lib/errors.ts';
// Match the plain-text rendition's paragraph and heading boundaries so returned
// offsets describe what the reader sees, including the separating newlines.
export function plainSearchText(input: string): string {
let text = input.replace(/\r\n?/g, '\n'), heading = '';
const lines = text.split('\n'), first = lines.findIndex(line => line.trim());
const line = (lines[first] ?? '').trim();
if (line.length <= 60 && [/^第\s*[0-90-9零一二三四五六七八九十百千万两]+\s*[章回节卷部篇集]/, /^(?:序章|序言|楔子|引子|前言|后记|尾声|终章|番外|附录)/, /^[卷部]\s*[0-90-9零一二三四五六七八九十百千万两]+/, /^Chapter\s*\d+/i, /^#{1,3}\s+.+/].some(pattern => pattern.test(line))) {
heading = line + '\n'; text = lines.slice(first + 1).join('\n');
}
const paragraphs = text.split(/\n{2,}/).flatMap(block => {
const parts = block.split('\n'); let current = parts.shift() ?? ''; const out: string[] = [];
for (const part of parts) {
const content = part.replace(/^[\s\u3000\u00a0]+/, '');
if (/[。!?…”』】]$/.test(current.trimEnd()) && content && !/^[,。!?、;:”』】)\u3001-\u303f\uff01-\uff5e]/.test(content)) { out.push(current); current = part; }
else current += content;
}
out.push(current); return out.map(value => value.trim()).filter(Boolean);
});
return '\n' + heading + paragraphs.join('\n') + '\n\n';
}
export function htmlSearchText(html: string): string {
let result = ''; const skipped: string[] = [];
const parser = new Parser({
onopentag(name) { if (skipped.length || ['head','script','style','noscript'].includes(name)) skipped.push(name); },
onclosetag(name) { if (skipped.at(-1) === name) skipped.pop(); },
ontext(text) { if (!skipped.length) result += text; },
}, { decodeEntities: true });
parser.end(html); return result;
}
export function correctedSearchText(text: string, sectionId: string, overrides: ReadingOverrides): string {
for (const { anchor, replacement } of overrides.corrections) {
if (anchor.sectionId.replace(/^xhtml:/,'') !== sectionId.replace(/^xhtml:/,'')) continue;
let start = anchor.start;
if (text.slice(start, start + anchor.quote.length) !== anchor.quote) {
const context = anchor.prefix + anchor.quote + anchor.suffix, at = text.indexOf(context);
if (at >= 0 && text.indexOf(context, at + 1) < 0) start = at + anchor.prefix.length;
else { start = text.indexOf(anchor.quote); if (start < 0 || text.indexOf(anchor.quote, start + 1) >= 0) continue; }
}
text = text.slice(0,start) + replacement + text.slice(start + anchor.quote.length);
}
return text;
}
export async function searchBookPage(manifest: Manifest, query: string, cursor: string | undefined, overrides: ReadingOverrides,
load: (item: ContentItem, signal: AbortSignal) => Promise<AssetPayload>, signal: AbortSignal, batchSize = 8) {
if (!query.trim() || query.length > 200) throw badRequest('关键词须为 1–200 个字符');
if (!['text','reflowable'].includes(manifest.kind)) throw badRequest('此格式暂不支持正文搜索','UNSUPPORTED_FORMAT');
const signature = createHash('sha256').update(JSON.stringify([manifest.revision, manifest.items.map(i=>[i.href,i.resourceRef]), query, overrides.version])).digest('hex').slice(0,24);
let index = 0;
if (cursor) {
const match = /^(\d+)\.([a-f0-9]{24})$/.exec(cursor);
if (!match) throw badRequest('无效的搜索游标');
if (match[2] !== signature) throw conflict('书籍或搜索条件已变化,请重新搜索');
index = Number(match[1]); if (!Number.isSafeInteger(index) || index > manifest.items.length) throw badRequest('无效的搜索游标');
}
const hits = [], failures: Array<{title: string; code: string}> = [];
const end = Math.min(index + batchSize, manifest.items.length);
let limited = false;
for (; index < end; index++) {
signal.throwIfAborted(); const item = manifest.items[index]!;
let payload: AssetPayload | undefined;
try {
const chapterSignal = AbortSignal.any([signal, AbortSignal.timeout(12000)]);
payload = await load(item, chapterSignal); chapterSignal.throwIfAborted();
if (!payload.data || payload.data.length > 8 * 1024 * 1024) throw badRequest('章节超过搜索大小限制','SEARCH_CHAPTER_TOO_LARGE');
const raw = payload.data.toString('utf8');
const text = correctedSearchText(/html|xml/i.test(payload.contentType) ? htmlSearchText(raw) : plainSearchText(raw), item.href, overrides);
let start = text.indexOf(query);
while (start >= 0 && hits.length < 200) {
const stop = start + query.length;
hits.push({title:item.title,excerpt:text.slice(Math.max(0,start-35),stop+65),anchor:{sectionId:item.href,start,end:stop,quote:query,prefix:text.slice(Math.max(0,start-32),start),suffix:text.slice(stop,stop+32)}});
start = text.indexOf(query,stop);
}
if (hits.length >= 200) { limited = true; index++; break; }
} catch (error) {
signal.throwIfAborted();
const code = typeof error === 'object' && error && 'code' in error ? String(error.code) : 'CHAPTER_UNAVAILABLE';
failures.push({title:item.title,code});
} finally { payload?.stream?.destroy(); }
await new Promise(resolve => setImmediate(resolve));
}
return { hits, failures, scanned:index, total:manifest.items.length, limited,
...(index < manifest.items.length && !limited ? { nextCursor:index+'.'+signature } : {}) };
}