feat(reader): P2 收尾——冒烟 CLI、书架正文链路、替换净化规则

- SmokeChain 结构化全链路冒烟(搜索→详情→目录→正文,分阶段失败定位),
  SmokeSource 支持不落库直测;Debug 接口改为结构化日志输出
- cmd/reader-smoke:批量书源冒烟 CLI(-file/-url 导入、并发、
  各阶段通过率汇总、-json 完整报告)
- GetContentForBook:书架维度正文(章节缓存 + 书源 replaceRegex +
  用户替换净化规则),前端阅读器切换到该接口
- 替换净化规则 CRUD(后端 + /reader/replace 前端页),用户规则
  按 scope/excludeScope 书名过滤、正则带匹配超时防回溯挂死
- rule.ApplyUserReplace 导出(regexp2 MatchTimeout)
This commit is contained in:
truewhile
2026-09-30 16:55:52 +08:00
parent 5976a8d310
commit 9cc21a47c5
11 changed files with 828 additions and 65 deletions
+25 -1
View File
@@ -152,8 +152,32 @@ export const readerAPI = {
listChapters: (id: string) =>
api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters),
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
bookContent: (id: string, chapter: number) =>
api
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
.then((r) => r.data),
// ── 替换规则 ──
// ── 替换净化规则 ──
listReplaceRules: () =>
api.get<{ rules: ReaderReplaceRule[] }>('/reader/replace-rules').then((r) => r.data.rules),
createReplaceRule: (body: ReplaceRuleInput) =>
api.post<ReaderReplaceRule>('/reader/replace-rules', body).then((r) => r.data),
updateReplaceRule: (id: string, body: ReplaceRuleInput) => api.patch(`/reader/replace-rules/${id}`, body),
deleteReplaceRule: (id: string) => api.delete(`/reader/replace-rules/${id}`),
}
export interface ReplaceRuleInput {
name: string
group: string
pattern: string
replacement: string
scope: string
scope_title: boolean
scope_content: boolean
exclude_scope: string
is_enabled: boolean
is_regex: boolean
timeout_millisecond: number
order: number
}
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import { ArrowLeft, Loader2, Plus, Trash2 } from 'lucide-react'
import { readerAPI, type ReaderReplaceRule, type ReplaceRuleInput } from '../../api/reader'
// 替换净化规则页(仿 legado ReplaceRuleActivity:列表 + 启停 + 编辑)。
const emptyInput: ReplaceRuleInput = {
name: '',
group: '',
pattern: '',
replacement: '',
scope: '',
scope_title: false,
scope_content: true,
exclude_scope: '',
is_enabled: true,
is_regex: true,
timeout_millisecond: 3000,
order: 0,
}
export default function ReaderReplacePage() {
const navigate = useNavigate()
const [rules, setRules] = useState<ReaderReplaceRule[] | null>(null)
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState<ReplaceRuleInput>(emptyInput)
const [saving, setSaving] = useState(false)
const load = () => {
readerAPI
.listReplaceRules()
.then(setRules)
.catch(() => toast.error('加载替换规则失败'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(load, [])
const save = async () => {
if (!form.pattern.trim()) {
toast.error('替换规则不能为空')
return
}
setSaving(true)
try {
await readerAPI.createReplaceRule(form)
toast.success('已添加')
setForm(emptyInput)
setShowForm(false)
load()
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败')
} finally {
setSaving(false)
}
}
const toggle = async (r: ReaderReplaceRule) => {
try {
await readerAPI.updateReplaceRule(r.id, {
name: r.name,
group: r.group,
pattern: r.pattern,
replacement: r.replacement,
scope: r.scope,
scope_title: r.scope_title,
scope_content: r.scope_content,
exclude_scope: r.exclude_scope,
is_enabled: !r.is_enabled,
is_regex: r.is_regex,
timeout_millisecond: 3000,
order: r.order,
})
setRules((prev) => prev?.map((x) => (x.id === r.id ? { ...x, is_enabled: !x.is_enabled } : x)) ?? null)
} catch {
toast.error('更新失败')
}
}
const remove = async (r: ReaderReplaceRule) => {
if (!window.confirm(`删除规则「${r.name || r.pattern}」?`)) return
try {
await readerAPI.deleteReplaceRule(r.id)
setRules((prev) => prev?.filter((x) => x.id !== r.id) ?? null)
} catch {
toast.error('删除失败')
}
}
return (
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate(-1)}
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<ArrowLeft size={18} />
</button>
<h1 className="flex-1 font-display text-lg text-ink-600">替换净化</h1>
<button type="button" onClick={() => setShowForm((v) => !v)} className="btn-primary text-xs">
<Plus size={13} className="mr-1 inline" /> 添加规则
</button>
</div>
{showForm && (
<div className="mt-4 space-y-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4">
<div className="grid gap-3 sm:grid-cols-2">
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="规则名(可选)"
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
/>
<input
value={form.group}
onChange={(e) => setForm({ ...form, group: e.target.value })}
placeholder="分组(可选)"
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
/>
</div>
<textarea
value={form.pattern}
onChange={(e) => setForm({ ...form, pattern: e.target.value })}
rows={2}
placeholder="替换规则(正则或原文)"
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-text)] outline-none"
/>
<textarea
value={form.replacement}
onChange={(e) => setForm({ ...form, replacement: e.target.value })}
rows={2}
placeholder="替换为(留空即删除匹配内容)"
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-text)] outline-none"
/>
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--app-muted)]">
<label className="flex items-center gap-1.5">
<input type="checkbox" checked={form.is_regex} onChange={(e) => setForm({ ...form, is_regex: e.target.checked })} />
正则
</label>
<label className="flex items-center gap-1.5">
<input type="checkbox" checked={form.scope_content} onChange={(e) => setForm({ ...form, scope_content: e.target.checked })} />
作用于正文
</label>
<input
value={form.scope}
onChange={(e) => setForm({ ...form, scope: e.target.value })}
placeholder="作用范围(书名包含,可选)"
className="w-44 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5 text-xs text-[var(--app-text)] outline-none"
/>
<input
value={form.exclude_scope}
onChange={(e) => setForm({ ...form, exclude_scope: e.target.value })}
placeholder="排除范围(可选)"
className="w-44 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5 text-xs text-[var(--app-text)] outline-none"
/>
<button type="button" onClick={save} disabled={saving} className="btn-primary ml-auto text-xs disabled:opacity-50">
{saving ? <Loader2 size={13} className="inline animate-spin" /> : '保存'}
</button>
</div>
</div>
)}
<div className="mt-6 space-y-2">
{rules === null && (
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={22} />
</div>
)}
{rules !== null && rules.length === 0 && (
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-12 text-center text-xs text-[var(--app-muted)]">
还没有替换规则。规则按顺序作用于所有书籍正文,可用来去除广告、修正错字。
</div>
)}
{rules?.map((r) => (
<div key={r.id} className="flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-[var(--app-text)]">
{r.name || '(未命名规则)'}
{r.is_regex && <span className="ml-2 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">正则</span>}
{r.group && <span className="ml-2 text-2xs font-normal text-[var(--app-muted)]">{r.group}</span>}
</p>
<p className="mt-0.5 truncate font-mono text-2xs text-[var(--app-muted)]">
{r.pattern} → {r.replacement || '(删除)'}
</p>
</div>
<button
type="button"
title="删除"
onClick={() => remove(r)}
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-red-500"
>
<Trash2 size={16} />
</button>
<button
type="button"
role="switch"
aria-checked={r.is_enabled}
onClick={() => toggle(r)}
className={`relative h-5 w-9 shrink-0 rounded-full transition ${r.is_enabled ? 'bg-brand-500' : 'bg-[var(--app-hover)]'}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
r.is_enabled ? 'left-[18px]' : 'left-0.5'
}`}
/>
</button>
</div>
))}
</div>
</div>
)
}
+2
View File
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import ReaderBookPage from './ReaderBookPage'
import ReaderHomePage from './ReaderHomePage'
import ReaderReplacePage from './ReaderReplacePage'
import ReaderSearchPage from './ReaderSearchPage'
import ReaderSourcesPage from './ReaderSourcesPage'
import ReaderViewPage from './ReaderViewPage'
@@ -13,6 +14,7 @@ export default function ReaderRoutes() {
<Route index element={<ReaderHomePage />} />
<Route path="search" element={<ReaderSearchPage />} />
<Route path="sources" element={<ReaderSourcesPage />} />
<Route path="replace" element={<ReaderReplacePage />} />
<Route path="book" element={<ReaderBookPage />} />
<Route path="view/:bookId" element={<ReaderViewPage />} />
<Route path="*" element={<Navigate to="/reader" replace />} />
+4 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import { ArrowLeft, Bug, Download, Loader2, Play, Trash2 } from 'lucide-react'
@@ -102,6 +102,9 @@ export default function ReaderSourcesPage() {
<ArrowLeft size={18} />
</button>
<h1 className="flex-1 font-display text-lg text-ink-600">书源管理</h1>
<Link to="/reader/replace" className="btn-outline mr-2 text-xs">
替换净化
</Link>
<button type="button" onClick={() => setShowImport((v) => !v)} className="btn-primary text-xs">
<Download size={13} className="mr-1 inline" /> 导入书源
</button>
+8 -13
View File
@@ -112,14 +112,11 @@ export default function ReaderViewPage() {
setContent(null)
setLoadingStage('content')
try {
let ct = contentCache.current.get(ch.url)
const cacheKey = String(chapterIndex)
let ct = contentCache.current.get(cacheKey)
if (!ct) {
ct = await readerAPI.content({
source_url: book.origin,
book_url: book.book_url,
chapter_url: ch.url,
})
contentCache.current.set(ch.url, ct)
ct = await readerAPI.bookContent(book.id, chapterIndex)
contentCache.current.set(cacheKey, ct)
}
if (cancelled) return
setContentType(ct.type)
@@ -131,11 +128,10 @@ export default function ReaderViewPage() {
.saveProgress(book.id, { chapter_index: chapterIndex, pos: pendingPosRef.current, chapter_title: ch.title })
.catch(() => undefined)
// 预取下一章
const next = chapters[chapterIndex + 1]
if (next && !contentCache.current.has(next.url)) {
if (!contentCache.current.has(String(chapterIndex + 1))) {
readerAPI
.content({ source_url: book.origin, book_url: book.book_url, chapter_url: next.url })
.then((c) => contentCache.current.set(next.url, c))
.bookContent(book.id, chapterIndex + 1)
.then((c) => contentCache.current.set(String(chapterIndex + 1), c))
.catch(() => undefined)
}
} catch (e) {
@@ -359,8 +355,7 @@ export default function ReaderViewPage() {
onClick={() => {
setError('')
if (chapterIndex !== null) {
const ch = chapters[chapterIndex]
if (ch) contentCache.current.delete(ch.url)
contentCache.current.delete(String(chapterIndex))
setChapterIndex(chapterIndex)
}
}}