# 书源 / parse-book-source / TTS

## 概览

`crates/parse-book-source`（Legado 风格书源解析引擎、规则 DSL、反爬/渲染抓取）与 `crates/novel-tts`（Kokoro TTS）的项目特有约束。重点是番茄（fanqienovel.com）这类 SPA + 签名站点的接入路线。

## 规则引擎

### 抓取分层 fetch / render

`fetch_checked` 是纯 reqwest（拿静态 HTML / 普通 API），**SPA 站点拿不到数据**（首屏是空壳，数据靠 JS 拉）。SPA 走 **render 通道**（CDP 驱动真浏览器渲染 + `interceptApi` 拦签名 API 响应），由 `fetch/browser/` 下的 fetcher + EscalatingFetcher 实现。

**render 三字段在 `Request` 上**：`render`（开渲染）/`readyFor`（等待选择器/条件）/`interceptApi`（拦哪个 API 的响应当数据源），连同 `totalPages`/`hasMore`/`pageBy`/`vars` 全部收敛在 `source::http::Request` 上。search 与 explore 因此共用同一份「列表页规格」`ListPageSpec`（见下「explore 两阶段 + 共享 ListPageSpec」），不再各自抄一份渲染字段。

**相关文件**：`crates/parse-book-source/src/fetch/browser/`、`src/source/op.rs`

### 常驻浏览器池（browser-pool）：render 复用浏览器、只开新 Page

render 路径（`render_dom`/`render_intercept`）**复用同一常驻 `Browser`、每次只开新 `Page`**，用完 `page.close()` 留 `Browser`，免去每次 launch 的秒级开销（翻页顺滑；也是 P2 点击翻页的基础）。`solve`/`login`（headful）**不**走池，仍每次 `launch_ephemeral` 临时起、用完 close。两类 headful/render 生命周期分别由 `with_ephemeral`/`with_pool_page` 收口。

几个非显然点：

- **池必须是进程级单例**（`static RENDER_POOL: Mutex<Option<Resident>>`），**不能**做成 `BrowserFetcher` 的实例字段。因为所有书源共享同一持久 profile（`~/.novel/browser-profile`）：若每个 `BrowserFetcher`（每次 `build_engine` 新建一个）各持一个常驻浏览器，多书源/路由回退栈场景下两个实例会同时存活、互抢 profile 的 `SingletonLock`（后建者 `spawn_browser` 无条件删锁）→ profile 数据竞争/「配置文件已在使用」。`BROWSER_LOCK` 只保证「同一时刻只一个浏览器在启动/渲染」，**常驻化把「存活」与「持锁」解耦后，「跨实例存活并存」不再受锁保护** —— 全局单例才能根治。
- **关 Page 不会让浏览器退出**：Chrome 经 CDP 调试连接启动后，只要调试连接（handler task）在连就存活，零标签页也不退 —— 所以「关 render 的 Page、留 Browser」成立，池化真实生效。浏览器退出只发生在 `Browser::close()`/进程崩溃。
- **headful 解挑战/登录前必须先拆常驻渲染浏览器**：`launch_ephemeral` 里先 `shutdown_render_pool().await`（优雅 `close` 释放 profile 的 `SingletonLock`）再起 headful 实例。
- **`handler.is_finished()` 不是可靠断连探活**：常驻浏览器从不 `close()`，崩溃/断连时 handler task 多半 parked 在 `Pending`、`is_finished()` 仍为 false（只有显式 `Browser::close()` 才结束）。它只是廉价乐观快路；**真正的断连兜底是 `new_pool_page` 开页失败/超时后拆掉重建**。且死浏览器的 `new_page` 不会立刻报错，要等 CDP 默认 30s 请求超时，期间还独占 `BROWSER_LOCK` 阻塞所有书源 —— 故给开页套一道短超时（8s），超时即判断连重建。
- **锁序**：render 整段持 `BROWSER_LOCK`，池的取/建/拆都在其下（`BROWSER_LOCK → RENDER_POOL`），与 `launch_ephemeral` 同序，无死锁。
- **跨进程启动也要锁 + 冲突退到临时 profile**：`BROWSER_LOCK` 只在本进程内生效；两个 TRNovel 进程同时第一次启动时，单靠 owner marker 仍可能同时跨过“marker 不存在”的窗口。`spawn_browser` 因此先用 `std::fs::File::try_lock` 锁 `~/.novel/browser-profile/.trnovel-browser.lock`，只覆盖“恢复旧 marker → 删 Singleton* → launch → 写新 marker”的临界区；文件锁随进程退出自动释放，**不负责关闭浏览器**。若主 profile 的 owner 仍活着（另一个终端正在使用），不要报错阻断，也不要抢锁；自动退到 `~/.novel/browser-sessions/<pid>` 临时 profile 继续取页，并通过 `BrowserUi::notice` 给 TUI 一个非阻断提醒（代价是该终端不共享主 profile 登录态）。
- **退出收尾走显式 `shutdown_render_pool()`，不能靠 `Drop`**：池是 static，进程退出**不触发** `Drop`；且即便给 `BrowserFetcher` 加 `Drop` 也错（任一 engine drop 会杀掉别 engine 仍在用的全局浏览器）。故 app 在最外层 `try_run` 统一收尾并监听 Ctrl+C，显式 `parse_book_source::shutdown_render_pool().await`，否则 headless 子进程被孤儿化。
- **崩溃恢复靠 owner marker，而不是无条件删 profile 锁**：`spawn_browser` 启动前检查 `~/.novel/browser-profile/.trnovel-browser.json`。若 marker 的 owner pid 仍活着且不是本进程，说明另一个 TRNovel 正占用 profile，必须报错不抢占；若 owner 已死，则按 marker 里的 browser pid 清理孤儿浏览器进程树，再删除 marker 与 `SingletonLock/SingletonSocket/SingletonCookie`。启动成功后写 marker，`shutdown_render_pool`/`with_ephemeral`/断连重建删除匹配本进程、本 browser pid 的 marker。

**不要**：让 render 复用同一个 `Page`（SPA 状态/cookie 跨页串味，design 已否决）—— `new_page` 比 `launch` 快几个数量级，每次新 Page 足够。

**相关文件**：`crates/parse-book-source/src/fetch/browser/fetcher.rs`（`RENDER_POOL`/`new_pool_page`/`with_pool_page`/`with_ephemeral`/`shutdown_render_pool`/owner marker/profile lock）、`src/lib.rs`（统一退出钩子 + Ctrl+C）、`openspec/changes/browser-pool/`

### render 双源（render-dual-source）：拦 API 的同时也能读渲染 DOM

render 的 `interceptApi` 会话**本来就在渲染一张真实页面**，拦 API（数据）之外可顺手抓渲染后 DOM（`outerHTML`），让 `via:css`/`xpath` 规则对 DOM 求值——典型用途：分页器的**精确总页数**（番茄 API 的 `total_count` 是占位 10000 不可靠，真数只在 DOM 分页器）。`explore`/`search` 返回类型因此从 `Vec<BookListItem>` 改为 `BookList { items, total_pages }`，UI 显示「第 N / M 页」。

- **信号 = `interceptApi` + `ready_for` 共存**：二者过去是「二选一」，现放宽为可同时给——`interceptApi` 取 body，`ready_for` 作 DOM 就绪闸（分页器在 API 之后才渲染，得等它出现再抓）。只配 `interceptApi`（无 `ready_for`）则不抓 DOM、`dom_html=None`，逐字节同现状。
- **路由 = 按规则 `via`（`Rule::primary_via` + `pick_source`）**：`via:css`/`xpath` 的规则打渲染 DOM（没抓到则退 body），其余（json/regex/raw）打 body。`Rule` 是枚举、组合规则（`firstOf`/`concat`）取首个子规则的主 via。**关键**：这让 `has_more`（番茄 `via:json` → API body）与 `total_pages`（`via:css` → DOM）**同会话共存正确**——别用「抓到 DOM 就一律打 DOM」的 dom-presence 路由，那会把 has_more 也错误地丢给 DOM（json 解析 HTML 失败)。
- **传输**：`FetchResponse.dom_html: Option<String>`，仅 render+intercept+要 DOM 时有值；`run_request_full`/`send_templated_full` 透传，普通取页仍用只回 body 的 `run_request`/`send_templated`。
- **番茄分页器选择器**（agent-browser 实测，explore 书库 + search 同一字节 `byte-pagination` 组件）：总页数 = `.byte-pagination-item:not(.byte-pagination-item-icon):not(.byte-pagination-item-jumper)` 取 `index:-1`（末数字项；过滤掉前后箭头 icon 与 `...` jumper；少页/单页也成立）。`parse_total_pages` 再从结果抽首段数字。

**相关文件**：`crates/parse-book-source/src/fetch/{mod.rs,browser/{fetcher.rs,escalating.rs}}`、`src/engine/{mod.rs,internal.rs}`（`eval_total_pages`）、`src/model.rs`（`BookList`）、`fanqie-web.v2.json`、`openspec/changes/render-dual-source/`

## 番茄（fanqienovel.com）接入

### 签名不可破解，render 让浏览器自己签

番茄 API 带字节跳动 secsdk 签名：`a_bogus`（每请求级，依赖 DOM/BOM/Worker 运行时算）+ `msToken`（会话级）。**boa 跑不了**这段 JS（需要浏览器环境），别想着把签名算法搬进 boa/Rust 复刻——验证过，不可行。唯一可行路线是 **render**：驱动真浏览器，让它自己带签名发请求，我们只 `interceptApi` 拦响应 JSON。

**不要做**：试图逆向 `a_bogus`/`msToken` 算法在 Rust 侧复刻。

### 两套 API：网页端 vs App 端

- **网页端**（fanqienovel.com）：无签名负担的部分有限——只前 ~10 章完整,其余试读 / 需会员。
- **App 端**：全本免费但需签名 + AES 解密。
- 字体解密是两端通用能力。

### 番茄有 ≥3 套混淆字体，各需独立 fontMap（content / search / explore 不通用）

番茄不同页面/接口用**不同的**字体反爬字体,码点（PUA E3xx–E5xx）范围重叠但**映射不同**——拿 `search` 的 map 去解 explore 书库的书名会得到一串乱码（「时停起手」被解成「美停它从」）。故 `fontMaps` 里 `content`（阅读正文,class 见 reader）、`search`（搜索结果）、`explore`（书库 `book_list/v0` + DOM class `font-fKts9tCXDjS49UhH`,字体文件 `…/awesome-font/c/e26e946d8b2ccb7.woff2`）**各是一套**,书源每个 op 的 item 规则按来源挂对应 `clean:[{fontMap:"…"}]`。

- **生成新字体的 map**：`cargo run -- gen-fontmap "<woff2 URL 或路径>" --out /tmp/x.json`（字形位图相似度匹配,自动下 Noto 基准;纯 Rust 零 C 依赖)。字体 URL 从页面 `@font-face` 的 `src` 取（`document.styleSheets` 里 `r.type===5` 的规则）。低置信（<0.55）项会标注、个别字可能错（与现有 content/search map 同等质量）。
- **怎么定位用哪套**：渲染该页,`TreeWalker(SHOW_TEXT)` 扫含 PUA（0xE000–0xF8FF）的短文本节点,看 `parentElement.className` 的 `font-xxx`,再比对各 map 解码是否通顺。
- **稳定性假设**：这些字体哈希在番茄基础设施上**相对稳定**（content/search map 静态内联已长期可用),故 explore map 也静态内联;若哪天轮换导致解码变乱码,重跑 `gen-fontmap` 更新即可。

**相关文件**：`fanqie-web.v2.json`（`fontMaps.{content,search,explore}`）、`src/gen_fontmap.rs`、`dev-notes/blog/font-anti-scraping-and-fontmap.md`

### explore 是 URL 驱动，search 是点击驱动（已落地 `search-click-pagination`）

- **explore 书库** `/library/all/page_N`：URL 驱动——翻页 URL 变,直接导航该 URL 即渲染第 N 页。API `book_list/v0`（`page_index=N-1` 0 基）。**`by:url` 翻页可用**（`{{page}}` 进 URL 模板,不需任何点击配置）。
- **search** `/search/{词}`：SPA **不认 URL 页码**（agent-browser 实测 6 种直达变体 `/page_2`=404、`?page_index=1`、`?page=2`、`#/page/2` 等**全部回第 1 页**——对抗性反驳失败,by:url 对 search 不可行）,只能点分页器「下一页」触发 `page_index` 递增。API `search_book/v1`,`a_bogus`+`msToken` 每请求重签。

### 点击驱动翻页 `pageBy.click`（render 拦截源,URL 不认页码时）

书源在 `request` 上配 `pageBy: { click: "<next 选择器>" }`;`page > 1` 时引擎在**一张活页**内点 `page-1` 次翻到目标页、拦该页 API 响应。配置极简、只补 click 一种（by:url 用 `{{page}}` URL 模板已覆盖,不复活完整 enum）。`pageBy` 缺席 = 现状单拦截,翻页行为逐字节不变。

- **番茄 search 分页器选择器**（`byte-pagination` 组件,逐字节实测）：NEXT = `.byte-pagination .byte-pagination-item-icon:has(.byte-icon-right)`（永远 list 末 `<li>`、翻页不漂移）;PREV = `:has(.byte-icon-left)`。**prev/next 无 aria/text,唯一判别是子 svg `byte-icon-right` vs `byte-icon-left`**。末页 NEXT 加 `disabled` **class**（无 aria-disabled/disabled 属性）、点了不发请求。当前页 = `.byte-pagination-item-active[data-active=true]`。
- **四摩擦**（点击翻页 `intercept` 必须处理,否则一定踩）：
  1. **点击投递**：NEXT 渲染在折叠下方（实测 y≈2534）,CDP/CLI 单击「成功」但页没翻;**`scrollIntoView` + evaluate 派发真 `MouseEvent`(mousedown/mouseup/click)才稳翻**(触发站点 React)。
  2. **`page_index` 强制相关性**：前进判据 = 等「点击之后到达、URL 含 `interceptApi`、且 `page_index == 目标页-1`」的响应（`url_page_index` 解析）。**必须按 page_index 对齐**——软封锁 reload 会注入残留 `page_index=0`,纯 substring 匹配会误采。监听流（`EventResponseReceived`）**跨整个点击循环持有**,自然只认「点击后到达」的。
  3. **软封锁 reload-once**：冷启/stale 签名 → `search_book/v1` 回 **HTTP 200 空 body** + `.muye-search-empty`「共 0 项」+ `verify.zijieapi.com` 滑块 iframe;**拦到空 body → reload 当前页一次**即恢复。此恢复也接进现有单页 `intercept_body`（第 1 页偶发软封锁同样修;空 body 本是失败态,严格改善、不改成功结果）。
  4. **page-N DOM 落定**：点击后 DOM **异步于网络响应**更新（新请求 ~1s）,抓第 N 页 `outerHTML`（给 `via:css` 的 totalPages）前**重等就绪闸**（`wait_ready(readyFor)`),免抓到半截分页器。注:番茄 totalPages 选择器取末数字项「30」,各页恒定,故 staleness 对它影响小,但仍重等保险。
- **统一的 `intercept`(单页 + 点击翻页一个方法)**：`render_intercept(url, api, timeout, headless, dom_ready, paging: Option<(目标页, next选择器)>)` 一个公开入口——`paging=None` 单页、`Some` 点击翻页(escalating 据 `pageBy`+`page>1` 计算 `paging` 单次调用)。核心 `intercept` 内:arm 监听(`responseReceived`/`loadingFinished`,**先挂再 goto**)→ 首页取页 + reload-once → 可选点击循环 → 可选抓 DOM,**只写一遍**(早期 `intercept_body`/`intercept_page`/`intercept_paged` 三方法的 arm/reload/DOM 重复已合并)。等响应抽成泛型 `wait_matching_body<R,F>`(持两个事件流、按 api 子串 + 可选 page_index 对齐等下一个匹配 body;匹配到但空 body 返回 `Ok("")` 作软封锁信号、非 Err)。
- **到头停翻 vs 点击失败(消歧很关键)**：点 NEXT 返回 `missing`/`disabled`（控件缺失/禁用）= **真到头** → 停、返回当前(末)页（D6 结构快路;`page > 实际总页` 由此自然收尾）。点了但目标页响应超时未到 = **点击失败/拥塞(非到头)** → **重试一次**（`CLICK_RETRY`,spec SHALL）;重试耗尽仍未达目标页 → **报错传播**（交上层 `RENDER_RETRY` 整页重试 / 优雅降级），**绝不静默返回更早的页冒充第 N 页**（CDP back-pressure 下尤其要紧）。两者别混:返回 `Ok(当前页)` 只在真到头时发生。
- **软封锁的「空 body」信号**:`wait_matching_body` 对「匹配到响应但 body 空」返回 **`Ok("")`**(非 `Err`),`Err` 只留给「完全没匹配到响应」。否则 `response_body` 把空 body 映射成 `None` → 函数返回 `Err` → `.await?` 短路,reload-once 守卫成**死代码**(评审实测发现)。空串 = 软封锁精确信号,交调用方 reload。
- **`BROWSER_LOCK` 占用**：`with_pool_page` 整个闭包持全局 `BROWSER_LOCK`;一次多页 search 现持锁 ≈ N 个 render 时长,期间其它书源 render / solve-login 全被串行阻塞（深页/软封锁可达数十秒）。search 现实 N≤5 可接受;真实 env 验深页线性翻稳定性 + 其它 op 不被饿死（`search-click-pagination` tasks 4.3 gating）。
- **CDP back-pressure**:独立复核实测 secsdk SPA 上 CDP 通道会 `os error 35`、停顿 1-2min → 每页响应超时要宽,勿把「慢但在途」误判成到头/软封锁。
- **回翻/重访的成本与缓解(渲染结果缓存)**:点击翻页是**无状态重点击**——UI 翻到第 N 页 = 开新活页从第 1 页点 N-1 次,**上一页也一样**(`target_page=page-1`,从头少点几次,根本不点 PREV)。故回翻/重访已看过的页本会重付 O(N) 点击。`Engine` 加了 **`page_cache`**(`Arc<RwLock<HashMap<键, BookList>>>`,随 Clone 共享、per-source 会话级)缓解:键 = `操作\0词或分类模板\0页\0页大小`,**仅 render 路径缓存**(reqwest 便宜且缓存会跳过 cookie 回灌/命名捕获副作用),命中即返回、不再驱动浏览器。回翻/重访已取页**即时**,只首访某页付点击成本。注:缓存的是「页数据」,explore 用静态分类 URL 模板作键(`UrlOrRule::Str`;`Rule` 形不缓存)。

**相关文件**：`crates/parse-book-source/src/fetch/browser/fetcher.rs`（`render_intercept`/`intercept`/`wait_matching_body`/`click_next`/`url_page_index`）、`src/source/http.rs`（`PageBy`）、`src/fetch/mod.rs`（`FetchRequest.page/page_by`）、`src/fetch/browser/escalating.rs`（render 分支路由,据 `pageBy`+`page>1` 算 `paging`）、`src/engine/{mod.rs,internal.rs}`（`page_cache` 渲染结果缓存、`RenderArgs` 参数束）、`fanqie-web.v2.json`（search.request.pageBy）、`openspec/changes/search-click-pagination/`

### explore 两阶段 + 共享 ListPageSpec（dynamic-explore-entries）

explore 不再是「分类 URL + 内联列表字段」，而是两阶段：`entries` 生成可选择的入口，`page` 用选中入口的变量取一页书。入口身份 = **标题 + 变量**（`ExploreEntry { title, vars }`，运行时类型在 `model.rs`），不再是固定 URL——取页 URL 由 `explore.page.request.url` 用入口变量 + `{{page}}` 模板生成。

- **`ExploreOp { entries: Vec<EntrySource>, page: ListPageSpec }`**。`entries` 是入口源数组，按声明顺序合并（**没有独立 chain 类型**——「按序合并」就是数组遍历本身；包成枚举只会多一层嵌套）。
- **`EntrySource`**（`untagged` + 唯一键判别，同 `Rule`）：`static`（固定入口列表 `{title, vars: BTreeMap<String,String>}`）/ `fetch`（远端抓取，`Box` 包裹避免大变体）。`fetch` 含 `forEach`（多组变量重复请求并合并，空=一次）、`request`（**复用 `Request`**，白送 render/intercept/charset/headers）、`list` 抽项、`item: {title: Rule, vars: BTreeMap<String,Rule>}`。
- **`item` 规则的求值上下文**：ctx = 当前数据项（`via:json` 读其字段），vars = base + 当前 `forEach` 循环变量（`{{name}}` 引用循环变量）。JS 规则里 `result` 是数据项 **JSON 字符串**，需 `JSON.parse(result).field`（不是已解析对象）。
- **`SearchOp = ListPageSpec`**（`pub type` 别名，裸用不包 `page` 层，search JSON 形状不变）。`ListPageSpec { prelude, request, list, item }` 就是历史 `SearchOp` 的形状——前序 change 已把全部取页旋钮收敛到 `Request`，故 search/explore 共用一个 runner。
- **引擎收敛到 `run_list_page(spec, kind, extra_vars, page, page_size)`**（`engine/internal.rs`）：search 传 `{key}`、explore 传入口变量;渲染结果缓存键含 `kind`('s'/'e') + 有序 `extra_vars` + 页 + 页大小（不同入口因变量段不同不串缓存）。explore 收敛后**白捡** search 已有的「响应命名捕获 `request.vars`」能力。
- **`Engine::explore_entries().await -> Result<Vec<ExploreEntry>>`** 替代旧同步 `explore_categories()`。**部分成功**：某动态源失败时保留已成功入口（含静态源），不阻断；仅当零入口产出且有源报错才返回 Err。**仅完全成功才缓存**（`entries_cache`，per-source 会话级），失败的动态源下次进入可重试。
- **UI**（`select_books/mod.rs`）：入口加载本就在 `use_init_state(async move{})` 内，改 `explore_entries().await?` 即可；`ExploreListItem(ExploreEntry)`，取页把整个 entry 交给 `engine.explore(&entry, page, size)`。
- **番茄迁移**：静态入口「书库·最热/最新」（`vars: {filter:"all", sort:"hottest"|"newest"}`）+ page URL `{{base}}/library/{{filter}}/page_{{page}}?sort={{sort}}`，render/intercept/totalPages/hasMore/explore fontMap 全部挪到 `page.request`/`page.item`，对这两个入口字节等价。**动态分类入口**（按 gender forEach 调 `category_list/v0`）的活体正确性需 `trn doctor` 对站点验证;即便动态源失配，部分成功也会退化到静态入口、explore 仍可用。

**相关文件**：`crates/parse-book-source/src/source/op.rs`（`ListPageSpec`/`EntrySource`/`StaticEntry`/`FetchEntrySource`/`FetchEntryItem`/`ExploreOp`）、`src/model.rs`（`ExploreEntry`）、`src/engine/{mod.rs,internal.rs}`（`explore_entries`/`run_list_page`/`load_entry_source`/`load_fetch_entries`）、`fanqie-web.v2.json`、`openspec/changes/dynamic-explore-entries/`

### explore 单页 + UI 递增 page（不引擎批量翻页）

引擎的 `explore`/`search` 是**单页**纯 async fn（无 async closure，Send-safe）。**翻页由 UI 主动递增 `page`**——用户翻一页才取一页。**不要**让引擎一次批量翻 N 页（早期 `by:url` 批量版会一次开 5 个浏览器、UI 卡"加载中"，已回退删除 `paginate_by_url`）。

边界信号走 `has_more`（book_list/v0 响应 data 顶层有 `has_more` bool；`total_count` 实测恒为 10000 占位，**不可靠，别用作总页数**）。

**相关文件**：`crates/parse-book-source/src/engine/mod.rs`、`fanqie-web.v2.json`、`openspec/changes/list-has-more/`

## 反爬实测

### bilixs / Cloudflare

- bilixs 只锁搜索接口；headful 浏览器能解 CF managed 挑战。
- `cf_clearance` cookie **不绑 TLS 指纹**，可从浏览器交接给 reqwest 复用；但**绑 UA**——交接时 UA 必须一致。

**相关文件**：`crates/parse-book-source/src/fetch/`、记忆 `booksource-anti-scraping-findings`

### chromiumoxide 默认 `--enable-automation` 会让 CF 解挑战死循环卡死

chromiumoxide 的 `DEFAULT_ARGS` 强制带 `--enable-automation`，它让 Chrome 显示「受自动化控制」并改 `window.chrome`，是 Cloudflare managed challenge 识别 CDP 自动化的经典信号。实测：用户点过 Turnstile、CF reload 后会**反复重新挑战**，`cf_clearance` 永不签发，`solve` 轮询卡死到超时（诊断时 cookie 一直卡在 `cf_chl_*`，从不出现 `cf_clearance`）。

**正确做法**（仅 headful 解挑战路径）：
```rust
builder = builder.disable_default_args().hide().with_head();
```
- `disable_default_args()` 拔掉含 `--enable-automation` 的全部默认参数（其余多为噪声/性能项）；
- `.hide()` 补回 `--disable-blink-features=AutomationControlled`——现代 Chrome 里这个 blink 特征**原生**就把 `navigator.webdriver` 设为 false（无需再用 JS `Object.defineProperty` 覆盖，冗余）。

**不要**：
- 用 `enable_stealth_mode()` 全套——它伪造 WebGL（`NVIDIA GTX 1050` Windows D3D11）+ 插件，与本机真实环境（如 macOS）矛盾，UA↔WebGL 不一致反而是更易被指纹识别的信号。
- 动 headless 渲染路径（番茄流）——它保留默认参数已验证可用，解挑战的改动只加在 `if !headless` 分支。

**相关文件**：`crates/parse-book-source/src/fetch/browser/fetcher.rs`（`spawn_browser`）

### `disable_default_args()` 是「全有或全无」，会连带拔掉抑制首次运行体验(FRE)的参数 → Edge 弹欢迎登录模态卡死解挑战

上一条为去 `--enable-automation` 调了 `disable_default_args()`，但 chromiumoxide 这个开关**不能只去一个默认参数**——它把 `DEFAULT_ARGS`（24 项）**全部**拔掉。其中 `--disable-sync`/`--disable-default-apps`/`--disable-client-side-phishing-detection` 等是抑制浏览器**首次运行体验(FRE)**的关键。实测 Windows 上探测到的浏览器是 **Edge** 时，headful 解挑战会弹出「欢迎使用 Microsoft Edge / 同步登录(是，继续 / 否，注销我)」模态**挡住挑战页**，用户无从点「确认真人」→ 解挑战拿不到 `cf_clearance` → 下游 reqwest 重试 `HTTP 403`。headless 渲染路径(番茄)保留了默认参数，故无此问题。

**正确做法**：headful 分支 `disable_default_args()` 后，手动补回「`DEFAULT_ARGS` 去掉 `--enable-automation`」的等价集（常量 `HEADFUL_DEFAULT_ARGS`，关键是 `--disable-sync`）：
```rust
builder = builder.disable_default_args().hide().with_head();
for &arg in HEADFUL_DEFAULT_ARGS { builder = builder.arg(arg); }
```
- 补回的都是环境/性能/FRE 抑制项，**没有**自动化指纹信号（CF 只认 `--enable-automation`），故不破坏上一条的 CF 修复。
- 略去 `--lang=en_US`（保用户原生 UI 语言）与 `--enable-blink-features=IdleDetection`。
- `HEADFUL_DEFAULT_ARGS` 是 chromiumoxide **0.9.1** 的 `DEFAULT_ARGS` 镜像，**升级该依赖时需复核**这份列表。

**不要**：以为 `--no-first-run` 就够了——它只压住「首次运行」那一道，Edge 的同步登录 FRE 模态要靠 `--disable-sync` 才压得住。

**相关文件**：`crates/parse-book-source/src/fetch/browser/fetcher.rs`（`HEADFUL_DEFAULT_ARGS`、`spawn_browser`）

## novel-tts

### 模型钉版见 toolchain

kokoro-tts `0.3.1` 勿升（rc.12 砍 Intel Mac），见 [toolchain.md](toolchain.md)。模型文件（`kokoro-v1.1-zh.onnx`、`voices-v1.1-zh.bin`）自动从 GitHub 下载到 `~/.novel-tts/kokoro/`，HTTP Range 断点续传，`CancellationToken` 取消。

<!-- 随开发补充:新规则 DSL 前缀、新站点接入坑等 -->
