use super::{Engine, RenderArgs};
use crate::error::{BookSourceError, Result};
use crate::eval::{Vars, eval_list, eval_value, interpolate};
use crate::fetch::cookie::{
merge_login_into_headers, registrable_domain, request_registrable_domain,
};
use crate::fetch::{FetchRequest, FetchResponse};
use crate::model::{BookInfo, BookList, BookListItem, ExploreEntry};
use crate::source::{
BookRules, Capture, EntrySource, FetchEntrySource, ListPageSpec, Method, PreStep, Rule,
UrlOrRule, VarScope,
};
use std::collections::{BTreeMap, HashMap};
impl Engine {
pub(super) fn base_vars(&self) -> Vars {
let mut v = Vars::new();
v.insert(
"base".into(),
self.source.url.trim_end_matches('/').to_string(),
);
v
}
pub(super) fn get_req(&self, url: impl Into<String>) -> FetchRequest {
let mut req = FetchRequest::get(url);
let url = req.url.clone();
self.apply_auth(&url, &mut req.headers);
req
}
pub(super) fn request_domain(&self, url: &str) -> String {
request_registrable_domain(url, ®istrable_domain(&self.source.url))
}
pub(super) fn apply_auth(&self, url: &str, headers: &mut HashMap<String, String>) {
let source_domain = registrable_domain(&self.source.url);
let domain = request_registrable_domain(url, &source_domain);
let jar_cookie = self
.cookies
.read()
.ok()
.and_then(|j| j.cookie_header(&domain));
merge_login_into_headers(
&self.login_header,
&source_domain,
&domain,
jar_cookie.as_deref(),
headers,
);
}
pub(super) async fn run_request_full(&self, req: FetchRequest) -> Result<FetchResponse> {
let domain = self.request_domain(&req.url);
let is_render = req.render;
let resp = self.fetcher.fetch_full(req).await?;
if self.source.enabled_cookie_jar
&& let Some(set_cookie) = resp.headers.get("set-cookie")
&& let Ok(mut jar) = self.cookies.write()
{
jar.absorb_set_cookie(&domain, set_cookie);
}
if !is_render {
self.check_login(&resp.body)?;
}
Ok(resp)
}
pub(super) async fn run_request(&self, req: FetchRequest) -> Result<String> {
Ok(self.run_request_full(req).await?.body)
}
pub(super) async fn fetch_checked(&self, url: impl Into<String>) -> Result<String> {
self.run_request(self.get_req(url)).await
}
pub(super) fn check_login(&self, response: &str) -> Result<()> {
let js = self.source.login_check_js.trim();
if js.is_empty() {
return Ok(());
}
#[cfg(feature = "js")]
{
let vars = self.base_vars();
let verdict = eval_value(&Rule::Js { js: js.to_string() }, response, &vars)?;
if matches!(verdict.trim(), "" | "false" | "0") {
return Err(BookSourceError::LoginExpired);
}
}
let _ = response;
Ok(())
}
pub(super) fn flatten(&self, chapter: &Vars) -> Vars {
let mut out = Vars::new();
if let Ok(g) = self.source_vars.read() {
out.extend(g.iter().map(|(k, v)| (k.clone(), v.clone())));
}
if let Ok(g) = self.book_vars.read() {
out.extend(g.iter().map(|(k, v)| (k.clone(), v.clone())));
}
out.extend(chapter.iter().map(|(k, v)| (k.clone(), v.clone())));
out
}
pub(super) async fn run_prelude(&self, steps: &[PreStep], chapter: &mut Vars) -> Result<()> {
for step in steps {
if !step.skip_if_present.is_empty() {
let flat = self.flatten(chapter);
if step
.skip_if_present
.iter()
.all(|k| flat.get(k).is_some_and(|v| !v.is_empty()))
{
continue;
}
}
let flat = self.flatten(chapter);
let resp = self
.send_templated(
&step.url,
step.method,
step.body.as_ref(),
&step.headers,
&flat,
RenderArgs::default(),
)
.await?;
self.capture_into(&step.capture, &resp, chapter)?;
}
Ok(())
}
pub(super) async fn send_templated_full(
&self,
url: &UrlOrRule,
method: Method,
body: Option<&UrlOrRule>,
headers: &HashMap<String, String>,
vars: &Vars,
args: RenderArgs<'_>,
) -> Result<FetchResponse> {
let url = self.resolve_url(url, vars)?;
let body = match body {
Some(b) => Some(self.resolve_url(b, vars)?),
None => None,
};
let mut hdrs = HashMap::with_capacity(headers.len());
for (k, v) in headers {
hdrs.insert(k.clone(), interpolate(v, vars));
}
self.apply_auth(&url, &mut hdrs);
self.run_request_full(FetchRequest {
url,
method,
body,
headers: hdrs,
render: args.render,
ready_for: args.ready_for.map(str::to_string),
intercept_api: args.intercept_api.map(str::to_string),
page: args.page,
page_by: args.page_by.map(str::to_string),
})
.await
}
pub(super) async fn send_templated(
&self,
url: &UrlOrRule,
method: Method,
body: Option<&UrlOrRule>,
headers: &HashMap<String, String>,
vars: &Vars,
args: RenderArgs<'_>,
) -> Result<String> {
Ok(self
.send_templated_full(url, method, body, headers, vars, args)
.await?
.body)
}
pub(super) fn capture_into(
&self,
caps: &[Capture],
body: &str,
chapter: &mut Vars,
) -> Result<()> {
for cap in caps {
let v = eval_value(&cap.value, body, &self.flatten(chapter))?;
if v.is_empty() {
continue;
}
match cap.scope {
VarScope::Chapter => {
chapter.insert(cap.name.clone(), v);
}
VarScope::Book => {
if let Ok(mut g) = self.book_vars.write() {
g.insert(cap.name.clone(), v);
}
}
VarScope::Source => {
if let Ok(mut g) = self.source_vars.write() {
g.insert(cap.name.clone(), v);
}
}
}
}
Ok(())
}
pub(super) async fn fetch_pages(
&self,
start: &str,
next_page: Option<&Rule>,
max_pages: u32,
vars: &Vars,
) -> Result<Vec<String>> {
let mut pages = Vec::new();
let mut url = start.to_string();
for _ in 0..max_pages.max(1) {
let html = self.fetch_checked(url.clone()).await?;
let next = match next_page {
Some(r) => eval_value(r, &html, vars)?,
None => String::new(),
};
pages.push(html);
if next.trim().is_empty() {
break;
}
url = next;
}
Ok(pages)
}
pub(super) fn eval_list_items(
&self,
list: &Rule,
item: &BookRules,
html: &str,
vars: &Vars,
) -> Result<Vec<BookListItem>> {
let mut out = Vec::new();
for ctx in eval_list(list, html)? {
let info = self.eval_book_info(item, &ctx, vars)?;
let book_url = opt_eval(item.book_url.as_ref(), &ctx, vars)?;
out.push(BookListItem { info, book_url });
}
Ok(out)
}
pub(super) fn eval_total_pages(
&self,
rule: Option<&Rule>,
body: &str,
dom: Option<&str>,
vars: &Vars,
) -> Option<u32> {
let rule = rule?;
let s = eval_value(rule, pick_source(rule, body, dom), vars).ok()?;
parse_total_pages(&s)
}
pub(super) fn eval_has_more(
&self,
rule: Option<&Rule>,
body: &str,
dom: Option<&str>,
vars: &Vars,
) -> Option<bool> {
let rule = rule?;
match eval_value(rule, pick_source(rule, body, dom), vars) {
Ok(s) => Some(!matches!(s.trim(), "" | "false" | "0")),
Err(_) => None,
}
}
pub(super) fn eval_book_info(&self, r: &BookRules, ctx: &str, vars: &Vars) -> Result<BookInfo> {
Ok(BookInfo {
name: opt_eval(r.name.as_ref(), ctx, vars)?,
author: opt_eval(r.author.as_ref(), ctx, vars)?,
cover: opt_eval(r.cover.as_ref(), ctx, vars)?,
intro: opt_eval(r.intro.as_ref(), ctx, vars)?,
kind: opt_eval(r.kind.as_ref(), ctx, vars)?,
last_chapter: opt_eval(r.last_chapter.as_ref(), ctx, vars)?,
toc_url: opt_eval(r.toc_url.as_ref(), ctx, vars)?,
word_count: opt_eval(r.word_count.as_ref(), ctx, vars)?,
})
}
pub(super) fn resolve_url(&self, u: &UrlOrRule, vars: &Vars) -> Result<String> {
Ok(match u {
UrlOrRule::Str(s) => eval_value(
&Rule::Template {
template: s.clone(),
},
"",
vars,
)?,
UrlOrRule::Rule(r) => eval_value(r, "", vars)?,
})
}
pub(super) async fn run_list_page(
&self,
spec: &ListPageSpec,
kind: char,
extra_vars: &BTreeMap<String, String>,
page: u32,
page_size: u32,
) -> Result<BookList> {
let req = &spec.request;
let cache_key = req
.render
.then(|| list_cache_key(kind, extra_vars, page, page_size));
if let Some(hit) = cache_key.as_deref().and_then(|k| self.cached_page(k)) {
return Ok(hit);
}
let mut chapter = self.base_vars();
chapter.extend(extra_vars.iter().map(|(k, v)| (k.clone(), v.clone())));
chapter.insert("page".into(), page.to_string());
chapter.insert("pageSize".into(), page_size.to_string());
self.run_prelude(&spec.prelude, &mut chapter).await?;
let vars = self.flatten(&chapter);
let resp = self
.send_templated_full(
&req.url,
req.method,
req.body.as_ref(),
&req.headers,
&vars,
RenderArgs {
render: req.render,
ready_for: req.ready_for.as_deref(),
intercept_api: req.intercept_api.as_deref(),
page,
page_by: req.page_by.as_ref().map(|p| p.click.as_str()),
},
)
.await?;
let html = &resp.body;
let flat = self.flatten(&chapter);
for (name, rule) in &req.vars {
let v = eval_value(rule, html, &flat)?;
if !v.is_empty() {
chapter.insert(name.clone(), v);
}
}
let vars = self.flatten(&chapter);
let items = self.eval_list_items(&spec.list, &spec.item, html, &vars)?;
let dom = resp.dom_html.as_deref();
let total_pages = self.eval_total_pages(req.total_pages.as_ref(), html, dom, &vars);
let has_more = self.eval_has_more(req.has_more.as_ref(), html, dom, &vars);
let result = BookList {
items,
total_pages,
has_more,
};
if let Some(k) = cache_key {
self.cache_page(k, &result);
}
Ok(result)
}
pub(super) async fn load_entry_source(&self, src: &EntrySource) -> Result<Vec<ExploreEntry>> {
match src {
EntrySource::Static { static_entries } => Ok(static_entries
.iter()
.map(|e| ExploreEntry {
title: e.title.clone(),
vars: e.vars.clone(),
})
.collect()),
EntrySource::Fetch { fetch } => self.load_fetch_entries(fetch).await,
}
}
pub(super) async fn load_fetch_entries(
&self,
f: &FetchEntrySource,
) -> Result<Vec<ExploreEntry>> {
let default = [BTreeMap::new()];
let loops: &[BTreeMap<String, String>] = if f.for_each.is_empty() {
&default
} else {
&f.for_each
};
let req = &f.request;
let mut out = Vec::new();
for loop_vars in loops {
let mut chapter = self.base_vars();
chapter.extend(loop_vars.iter().map(|(k, v)| (k.clone(), v.clone())));
let vars = self.flatten(&chapter);
let resp = self
.send_templated_full(
&req.url,
req.method,
req.body.as_ref(),
&req.headers,
&vars,
RenderArgs {
render: req.render,
ready_for: req.ready_for.as_deref(),
intercept_api: req.intercept_api.as_deref(),
..Default::default()
},
)
.await?;
let html = &resp.body;
for item_ctx in eval_list(&f.list, html)? {
let title = eval_value(&f.item.title, &item_ctx, &vars)?;
let mut evars = BTreeMap::new();
for (name, rule) in &f.item.vars {
evars.insert(name.clone(), eval_value(rule, &item_ctx, &vars)?);
}
out.push(ExploreEntry { title, vars: evars });
}
}
Ok(out)
}
}
fn list_cache_key(
kind: char,
extra: &BTreeMap<String, String>,
page: u32,
page_size: u32,
) -> String {
let mut k = String::from(kind);
for (name, val) in extra {
k.push('\u{0}');
k.push_str(name);
k.push('=');
k.push_str(val);
}
k.push('\u{0}');
k.push_str(&page.to_string());
k.push('\u{0}');
k.push_str(&page_size.to_string());
k
}
pub(super) fn opt_eval(rule: Option<&Rule>, ctx: &str, vars: &Vars) -> Result<String> {
Ok(match rule {
Some(r) => eval_value(r, ctx, vars)?,
None => String::new(),
})
}
fn pick_source<'a>(rule: &Rule, body: &'a str, dom: Option<&'a str>) -> &'a str {
use crate::source::Via;
match rule.primary_via() {
Some(Via::Css | Via::Xpath) => dom.unwrap_or(body),
_ => body,
}
}
fn parse_total_pages(s: &str) -> Option<u32> {
let s = s.trim();
let start = s.find(|c: char| c.is_ascii_digit())?;
s[start..]
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.ok()
}