Skip to main content

parse_book_source/source/
http.rs

1//! HTTP 配置与请求类型:字符集/重试/限速/取页模式、登录表单、请求与多步编排(前置链 + 命名捕获)。
2
3use super::rule::{Rule, UrlOrRule};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, HashMap};
6
7/// 字符集。
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11pub enum Charset {
12    #[default]
13    Auto,
14    Utf8,
15    Gbk,
16    Gb18030,
17    Big5,
18}
19
20/// 重试策略。
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase", deny_unknown_fields)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24pub struct Retry {
25    #[serde(default)]
26    pub max: u32,
27    #[serde(default)]
28    pub backoff_ms: u64,
29}
30
31/// 速率限制。
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct RateLimit {
36    pub max_count: u64,
37    pub per_ms: u64,
38}
39
40/// 点击驱动翻页策略(`search-click-pagination`):URL 不认页码的 SPA(如番茄 search)翻页只能点
41/// 分页器「下一页」,不能靠 `{{page}}` 改 URL。配在可渲染 + 拦截(`render` + `interceptApi`)的
42/// `Request` 上,`page > 1` 时引擎在**一张活页**内点 `click` 选择器 `page-1` 次翻到目标页。
43/// 用 `{ click: ... }` 形(留 `by` 扩展位、语义自描述),当前只 `click` 一支。
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47pub struct PageBy {
48    /// 「下一页」控件的 CSS 选择器(如番茄 `.byte-pagination .byte-pagination-item-icon:has(.byte-icon-right)`)。
49    pub click: String,
50}
51
52/// 取页模式:是否动用浏览器解反爬挑战。
53/// 真正是否开浏览器还需 app/用户级授权(两级取交集,见 OpenSpec change `browser-fetcher` D12)。
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57pub enum FetchMode {
58    /// 默认:平时 reqwest,撞挑战才升级浏览器。
59    #[default]
60    Auto,
61    /// 永不开浏览器,撞挑战即降级。
62    Reqwest,
63    /// 整站强制走浏览器(首请求即被挑战 / 整页 JS 渲染)。
64    Browser,
65}
66
67/// HTTP 配置块。
68#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
71pub struct Http {
72    #[serde(default)]
73    pub headers: HashMap<String, String>,
74    /// 静态 cookie;也是运行时注入 clearance cookie 的落点。
75    #[serde(default)]
76    pub cookies: HashMap<String, String>,
77    /// 先 GET 这些页以预热会话 cookie。
78    #[serde(default)]
79    pub warmup: Vec<String>,
80    #[serde(default)]
81    pub charset: Charset,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub timeout: Option<u64>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub retry: Option<Retry>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub rate_limit: Option<RateLimit>,
88    /// 取页模式(auto|reqwest|browser);默认 auto。
89    #[serde(default)]
90    pub fetcher: FetchMode,
91}
92
93/// 声明式登录表单项类型。
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub enum RowUiType {
98    /// 单行文本。
99    #[default]
100    Text,
101    /// 密码(TUI 掩码显示)。
102    Password,
103    /// 下拉选择(配合 `options`)。
104    Select,
105    /// 布尔开关。
106    Toggle,
107}
108
109/// 声明式登录表单的一行(TUI 渲染对应控件,收集值加密存为 loginInfo)。
110#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase", deny_unknown_fields)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113pub struct RowUi {
114    /// 字段名(也是 loginInfo 中的 key)。
115    pub name: String,
116    #[serde(rename = "type", default)]
117    pub ui_type: RowUiType,
118    /// `select` 类型的候选项。
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub options: Vec<String>,
121}
122
123/// HTTP 方法。
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "UPPERCASE")]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub enum Method {
128    #[default]
129    Get,
130    Post,
131}
132
133/// 多步编排:捕获变量的作用域(三级级联 章节→书籍→书源,见 design D7-bis)。
134/// 默认 `Chapter`——最短寿命、零持久、零跨书外溢;`get` 时按 章节→书籍→书源 取第一个非空。
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Hash)]
136#[serde(rename_all = "lowercase")]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub enum VarScope {
139    /// 仅本次 op 调用(search/explore/bookInfo/toc/content)内存活,调用结束消亡(默认)。
140    /// 一次性 csrf/sign/cursor 用它。
141    #[default]
142    Chapter,
143    /// 随 per-book 快照持久化(由 app 注入·导出);如详情/列表 token 复用到 toc/content。
144    /// 注意:**search/explore 的 `prelude` 阶段尚无 per-book 载体**(用户选书后才建 per-book 引擎),
145    /// 在那里用 `book` 会写进会被丢弃的实例 → 静默无效;search 阶段的 token 请用 `source`/`chapter`。
146    Book,
147    /// 书源级长存(引擎内跨 op 共享);如全站 API host/版本/全站 csrf。
148    /// 跨会话持久仅在 app 接线落盘时保证(默认构建为进程内)。
149    Source,
150}
151
152/// 一条结构化命名捕获:对**所属请求的响应**用 `value` 规则求一个字符串,
153/// 写入 `scope` 指定的作用域层,后续步骤/抽取规则以 `{{name}}` 引用。
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase", deny_unknown_fields)]
156#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
157pub struct Capture {
158    /// 变量名(后续以 `{{name}}` 引用,走现有模板插值)。
159    pub name: String,
160    /// 对响应求值的规则(复用 `Rule` AST,产物是字符串)。
161    pub value: Rule,
162    /// 写入哪一层作用域;默认 `chapter`(本次调用临时)。
163    #[serde(default)]
164    pub scope: VarScope,
165}
166
167/// 前置请求链中的一步:一个请求 + 其响应上的有序命名捕获(见 design D7-bis)。
168/// 本步 url/headers/body 可引用更早步骤捕获的 `{{name}}`。显式列字段(**不**用 `#[serde(flatten)]`
169/// 内嵌 [`Request`]:`Rule` 为 untagged 兜底,flatten 会令 `deny_unknown_fields` 校验失效)。
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase", deny_unknown_fields)]
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173pub struct PreStep {
174    pub url: UrlOrRule,
175    #[serde(default)]
176    pub method: Method,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub body: Option<UrlOrRule>,
179    /// 请求头(值支持 `{{name}}` 模板,便于带 `Authorization: Bearer {{token}}`)。
180    #[serde(default)]
181    pub headers: HashMap<String, String>,
182    /// 本步响应上的有序命名捕获(按数组顺序求值;`capture[i]` 可引用 `capture[0..i]`)。
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub capture: Vec<Capture>,
185    /// 惰性短路:列出的 key 在作用域内**全部非空**则跳过本步(token 复用,避免每章重抓)。
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub skip_if_present: Vec<String>,
188}
189
190/// 单个请求。
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
194pub struct Request {
195    pub url: UrlOrRule,
196    #[serde(default)]
197    pub method: Method,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub body: Option<UrlOrRule>,
200    #[serde(default)]
201    pub headers: HashMap<String, String>,
202    /// 命名捕获:对**本请求响应**每个 `(name, Rule)` 求值,写入 `chapter` 层(等价 `scope=chapter`
203    /// 的 [`Capture`]),使本 op 的 list/item 与后续步骤以 `{{name}}` 引用。见 design D7-bis。
204    /// 各条**独立**对响应求值(可引用 `base`/`key`/`page` 与前置 `prelude` 捕获,但**勿互相引用**:
205    /// 需有序依赖请用 `prelude` 链)。用 `BTreeMap` 保证迭代/落点顺序确定(免哈希随机化幽灵 bug)。
206    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207    pub vars: BTreeMap<String, Rule>,
208    /// 渲染型取页(`render-fetcher`):为真则用受控浏览器渲染本请求 URL、跑站点自身 JS,
209    /// 而非 reqwest 直取——用于 SPA 站点(结果由 JS 渲染 / 签名 API 返回)。默认 false = 现状。
210    /// 仅 `browser` feature 且浏览器可用时生效;否则该 op 优雅降级。
211    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
212    pub render: bool,
213    /// 渲染就绪等待选择器。两种用法:
214    /// - **无 `interceptApi`**:渲染后轮询该选择器出现,取渲染后 DOM 交 CSS 规则(方式 A);
215    /// - **与 `interceptApi` 共存**(`render-dual-source`):拦 API 取 body 之外,等该选择器出现后
216    ///   另抓渲染 DOM,供 `via:css` 的 `totalPages` 等对 DOM 求值(如分页器总页数)。
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub ready_for: Option<String>,
219    /// CDP 拦截:渲染时拦截 URL 含此子串的响应体作为取页 body(交 `via:"json"` 规则)。
220    /// 用于「结果只在签名 API、DOM 无关键字段」的站点(典型:番茄搜索 `search_book/v1`)。
221    /// 可与 `ready_for` 共存(见上)。
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub intercept_api: Option<String>,
224    /// 精确总页数规则(`render-dual-source`):对取页结果求值得「共 M 页」(search 翻页进度)。
225    /// `via:json` 对 API body 求值;`via:css`/`xpath` 对渲染 DOM 求值(需 `interceptApi` +
226    /// `ready_for` 共存,抓到 DOM 才有源)。空 = 不返回总数(现状)。
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub total_pages: Option<Rule>,
229    /// 翻页边界规则(`list-has-more`):对取页结果求值「是否还有下一页」(非空且非 `false`/`0` → 有)。
230    /// 源路由同 `total_pages`(按 `via`)。空 = 不提供边界(UI 不限制)。
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub has_more: Option<Rule>,
233    /// 点击驱动翻页(`search-click-pagination`):URL 不认页码的 SPA(如番茄 search)靠点分页器
234    /// 「下一页」递增页码。配 `render` + `interceptApi` 且 `page > 1` 时,引擎在一张活页内点
235    /// `pageBy.click` 选择器 `page-1` 次翻到目标页、拦该页 API 响应。空 = 现状(单拦截 /
236    /// `{{page}}` URL 模板翻页),逐字节不变。
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub page_by: Option<PageBy>,
239}