feat: 检查修复错误,添加必要的注释

This commit is contained in:
amtoaer
2024-03-21 00:21:12 +08:00
parent 8af5350772
commit 3757c4f917
8 changed files with 254 additions and 67 deletions

151
Cargo.lock generated
View File

@@ -19,9 +19,9 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "aho-corasick"
version = "1.1.2"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
@@ -112,6 +112,15 @@ version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.15.4"
@@ -203,6 +212,15 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
[[package]]
name = "cpufeatures"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -239,6 +257,7 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
@@ -394,9 +413,9 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
[[package]]
name = "h2"
version = "0.3.25"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fbd2820c5e49886948654ab546d0688ff24530286bdcf8fca3cefb16d4618eb"
checksum = "51ee2dd2e4f378392eeff5d51618cd9a63166a2513846bbc55f21cfacd9199d4"
dependencies = [
"bytes",
"fnv",
@@ -437,9 +456,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "0.2.12"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
dependencies = [
"bytes",
"fnv",
@@ -448,12 +467,24 @@ dependencies = [
[[package]]
name = "http-body"
version = "0.4.6"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
@@ -463,47 +494,60 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "0.14.28"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80"
checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2",
"smallvec",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"pin-project-lite",
"socket2",
"tokio",
"tower",
"tower-service",
"tracing",
]
[[package]]
@@ -810,6 +854,26 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "pin-project"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pin-project-lite"
version = "0.2.13"
@@ -965,9 +1029,9 @@ checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "reqwest"
version = "0.11.26"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bf93c4af7a8bb7d879d51cebe797356ff10ae8516ace542b5182d9dcac10b2"
checksum = "58b48d98d932f4ee75e541614d32a7f44c889b72bd9c2e04d95edd135989df88"
dependencies = [
"base64",
"bytes",
@@ -979,8 +1043,10 @@ dependencies = [
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
@@ -1021,6 +1087,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core",
"sha2",
"signature",
"spki",
"subtle",
@@ -1148,6 +1215,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.1"
@@ -1386,6 +1464,28 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"pin-project",
"pin-project-lite",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
[[package]]
name = "tower-service"
version = "0.3.2"
@@ -1398,6 +1498,7 @@ version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]

View File

@@ -6,14 +6,14 @@ edition = "2021"
[dependencies]
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["json", "stream", "cookies"] }
cookie = { version = "0.18.0", features = ["percent-encode"]}
reqwest = { version = "0.12.0", features = ["json", "stream", "cookies"] }
cookie = { version = "0.18.0", features = ["percent-encode"] }
tokio = { version = "1", features = ["full"] }
strum = { version = "0.26", features = ["derive"] }
async-stream = "0.3.5"
futures-core = "0.3"
futures-util = "0.3.30"
rsa = "0.9.6"
rsa = { version = "0.9.6", features = ["sha2"] }
hex = "0.4.3"
rand = "0.8.5"
regex = "1.10.3"

View File

@@ -38,6 +38,7 @@ pub enum VideoCodecs {
AV1,
}
// 视频流的筛选偏好
pub struct FilterOption {
pub video_max_quality: VideoQuality,
pub video_min_quality: VideoQuality,
@@ -50,6 +51,7 @@ pub struct FilterOption {
pub no_hires: bool,
}
// 上游项目中的五种流类型,不过目测应该只有 Flv、DashVideo、DashAudio 三种会被用到
#[derive(Debug, PartialEq, PartialOrd)]
pub enum Stream {
Flv(String),
@@ -66,6 +68,7 @@ pub enum Stream {
},
}
// 通用的获取流链接的方法,交由 Downloader 使用
impl Stream {
pub fn url(&self) -> &str {
match self {
@@ -78,6 +81,9 @@ impl Stream {
}
}
// 用于获取视频流的最佳筛选结果,有两种可能:
// 1. 单个混合流,作为 Mixed 返回
// 2. 视频、音频分离,作为 VideoAudio 返回,其中音频流可能不存在(对于无声视频,如 BV1J7411H7KQ
#[derive(Debug)]
pub enum BestStream {
VideoAudio {
@@ -139,18 +145,23 @@ impl PageAnalyzer {
let video_stream_quality =
VideoQuality::from_repr(video_data["id"].as_u64().unwrap() as usize)
.ok_or("invalid video stream quality")?;
if (video_stream_quality == VideoQuality::QualityHdr && filter_option.no_hdr) // NO HDR
|| (video_stream_quality == VideoQuality::QualityDolby && filter_option.no_dolby_video) // NO DOLBY
if (video_stream_quality == VideoQuality::QualityHdr && filter_option.no_hdr)
|| (video_stream_quality == VideoQuality::QualityDolby
&& filter_option.no_dolby_video)
|| (video_stream_quality != VideoQuality::QualityDolby
&& video_stream_quality != VideoQuality::QualityHdr
&& (video_stream_quality < filter_option.video_min_quality
|| video_stream_quality > filter_option.video_max_quality))
// NOT IN RANGE
// 此处过滤包含三种情况:
// 1. HDR 视频,但指定不需要 HDR
// 2. 杜比视界视频,但指定不需要杜比视界
// 3. 视频质量不在指定范围内
{
continue;
}
let video_codecs = video_data["codecs"].as_str().unwrap();
let video_codecs = video_data["codecs"].as_str().unwrap();
// 从视频流的 codecs 字段中获取编码格式,此处并非精确匹配而是判断包含,比如 codecs 是 av1.42c01e,需要匹配为 av1
let video_codecs = vec![VideoCodecs::HEV, VideoCodecs::AVC, VideoCodecs::AV1]
.into_iter()
.find(|c| video_codecs.contains(c.to_string().as_str()));
@@ -158,7 +169,6 @@ impl PageAnalyzer {
let Some(video_codecs) = video_codecs else {
continue;
};
if !filter_option.codecs.contains(&video_codecs) {
continue;
}
@@ -188,6 +198,7 @@ impl PageAnalyzer {
}
}
if !(filter_option.no_hires || flac_data["audio"].is_null()) {
// 允许 hires 且存在 flac 音频流才会进来
let flac_stream_url = flac_data["audio"]["baseUrl"].as_str().unwrap().to_string();
let flac_stream_quality =
AudioQuality::from_repr(flac_data["audio"]["id"].as_u64().unwrap() as usize)
@@ -198,6 +209,7 @@ impl PageAnalyzer {
});
}
if !(filter_option.no_dolby_audio || dolby_data["audio"].is_null()) {
// 同理,允许杜比音频且存在杜比音频流才会进来
let dolby_stream_data = dolby_data["audio"].as_array().and_then(|v| v.first());
if dolby_stream_data.is_some() {
let dolby_stream_data = dolby_stream_data.unwrap();
@@ -217,13 +229,14 @@ impl PageAnalyzer {
pub fn best_stream(&mut self, filter_option: &FilterOption) -> Result<BestStream> {
let streams = self.streams(filter_option)?;
if self.is_flv_stream() || self.is_html5_mp4_stream() || self.is_episode_try_mp4_stream() {
return Ok(BestStream::Mixed(
streams.into_iter().next().ok_or("no stream found")?,
));
// 按照 streams 中的假设,符合这三种情况的流只有一个,直接取
return Ok(BestStream::Mixed(streams.into_iter().next().unwrap()));
}
// 将视频流和音频流拆分,分别做排序
let (mut video_streams, mut audio_streams): (Vec<_>, Vec<_>) = streams
.into_iter()
.partition(|s| matches!(s, Stream::DashVideo { .. }));
// 因为该处的排序与筛选选项有关,因此不能在外面实现 PartialOrd trait只能在这里写闭包
video_streams.sort_by(|a, b| match (a, b) {
(
Stream::DashVideo {
@@ -252,13 +265,14 @@ impl PageAnalyzer {
if a_quality != b_quality {
return a_quality.partial_cmp(b_quality).unwrap();
}
// 如果视频质量相同,按照偏好的编码优先级排序
filter_option
.codecs
.iter()
.position(|c| c == b_codecs)
.cmp(&filter_option.codecs.iter().position(|c| c == a_codecs))
}
_ => std::cmp::Ordering::Equal,
_ => unreachable!(),
});
audio_streams.sort_by(|a, b| match (a, b) {
(
@@ -277,14 +291,14 @@ impl PageAnalyzer {
}
a_quality.partial_cmp(b_quality).unwrap()
}
_ => std::cmp::Ordering::Equal,
_ => unreachable!(),
});
if video_streams.is_empty() {
return Err("no stream found".into());
}
Ok(BestStream::VideoAudio {
video: video_streams.remove(video_streams.len() - 1),
// audio stream may be empty, representing no audio for the video
// 音频流可能为空,因此直接使用 pop 返回 Option
audio: audio_streams.pop(),
})
}

View File

@@ -3,10 +3,12 @@ use reqwest::{header, Method};
use crate::bilibili::Credential;
use crate::Result;
// 一个对 reqwest::Client 的简单封装,用于 Bilibili 请求
pub struct Client(reqwest::Client);
impl Client {
pub fn new() -> Self {
// 正常访问 api 所必须的 header作为默认 header 添加到每个请求中
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
@@ -23,6 +25,7 @@ impl Client {
)
}
// a wrapper of reqwest::Client::request to add credential to the request
pub fn request(
&self,
method: Method,
@@ -30,6 +33,7 @@ impl Client {
credential: Option<&Credential>,
) -> reqwest::RequestBuilder {
let mut req = self.0.request(method, url);
// 如果有 credential会将其转换成 cookie 添加到请求的 header 中
if let Some(credential) = credential {
req = req
.header(header::COOKIE, format!("SESSDATA={}", credential.sessdata))
@@ -48,6 +52,7 @@ impl Client {
}
}
// clippy 建议实现 Default trait
impl Default for Client {
fn default() -> Self {
Self::new()
@@ -71,11 +76,9 @@ impl BiliClient {
pub async fn check_refresh(&mut self) -> Result<()> {
let Some(credential) = self.credential.as_mut() else {
// no credential, just ignore it
return Ok(());
};
if credential.check(&self.client).await? {
// is valid, no need to refresh
return Ok(());
}
credential.refresh(&self.client).await

View File

@@ -5,7 +5,8 @@ use cookie::Cookie;
use regex::Regex;
use reqwest::{header, Method};
use rsa::pkcs8::DecodePublicKey;
use rsa::{Pkcs1v15Encrypt, RsaPublicKey};
use rsa::sha2::Sha256;
use rsa::{Oaep, RsaPublicKey};
use crate::bilibili::Client;
use crate::Result;
@@ -36,6 +37,7 @@ impl Credential {
}
}
/// 检查凭据是否有效
pub async fn check(&self, client: &Client) -> Result<bool> {
let res = client
.request(
@@ -56,14 +58,13 @@ impl Credential {
let correspond_path = Self::get_correspond_path();
let csrf = self.get_refresh_csrf(client, correspond_path).await?;
let new_credential = self.get_new_credential(client, &csrf).await?;
self.sessdata = new_credential.sessdata;
self.bili_jct = new_credential.bili_jct;
self.dedeuserid = new_credential.dedeuserid;
self.confirm_refresh(client, &new_credential).await?;
*self = new_credential;
Ok(())
}
fn get_correspond_path() -> String {
// maybe as a static value
// 调用频率很低,让 key 在函数内部构造影响不大
let key = RsaPublicKey::from_public_key_pem(
"-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDLgd2OAkcGVtoE3ThUREbio0Eg
@@ -79,7 +80,7 @@ impl Credential {
.as_millis();
let data = format!("refresh_{}", ts).into_bytes();
let mut rng = rand::rngs::OsRng;
let encrypted = key.encrypt(&mut rng, Pkcs1v15Encrypt, &data).unwrap();
let encrypted = key.encrypt(&mut rng, Oaep::new::<Sha256>(), &data).unwrap();
hex::encode(encrypted)
}
@@ -94,13 +95,15 @@ impl Credential {
.send()
.await?;
if !res.status().is_success() {
return Err("error get csrf".into());
return match res.status().as_u16() {
404 => Err("correspond path is wrong or expired".into()),
_ => Err("get csrf failed".into()),
};
}
let re = Regex::new("<div id=\"1-name\">(.+?)</div>").unwrap();
if let Some(res) = re.find(&res.text().await?) {
return Ok(res.as_str().to_string());
}
Err("error get csrf".into())
regex_find(
r#"<div id="1-name">(.+?)</div>"#,
res.text().await?.as_str(),
)
}
async fn get_new_credential(&self, client: &Client, csrf: &str) -> Result<Credential> {
@@ -111,21 +114,24 @@ impl Credential {
Some(self),
)
.header(header::COOKIE, "Domain=.bilibili.com")
.json(&serde_json::json!({
"csrf": self.bili_jct,
"refresh_csrf": csrf,
"refresh_token": self.ac_time_value,
"source": "main_web",
}))
.form(&[
// 这里不是 json而是 form data
("csrf", self.bili_jct.as_str()),
("refresh_csrf", csrf),
("refresh_token", self.ac_time_value.as_str()),
("source", "main_web"),
])
.send()
.await?;
let set_cookie = res
.headers()
.get(header::SET_COOKIE)
.ok_or("error refresh credential")?
.to_str()
.unwrap();
let mut credential = Credential::default();
.ok_or("no set_cookie header")?
.to_str()?;
let mut credential = Credential {
buvid3: self.buvid3.clone(),
..Default::default()
};
let required_cookies = HashSet::from(["SESSDATA", "bili_jct", "DedeUserID"]);
let cookies: Vec<Cookie> = Cookie::split_parse_encoded(set_cookie)
.filter(|x| {
@@ -134,19 +140,76 @@ impl Credential {
})
.map(|x| x.unwrap())
.collect();
if cookies.len() != required_cookies.len() {
return Err("not all required cookies found".into());
}
for cookie in cookies {
match cookie.name() {
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
"bili_jct" => credential.bili_jct = cookie.value().to_string(),
"DedeUserID" => credential.dedeuserid = cookie.value().to_string(),
_ => continue,
_ => unreachable!(),
}
}
let json = res.json::<serde_json::Value>().await?;
if !json["data"]["refresh_token"].is_string() {
return Err("error refresh credential".into());
return Err("refresh_token not found".into());
}
credential.ac_time_value = json["data"]["refresh_token"].as_str().unwrap().to_string();
Ok(credential)
}
async fn confirm_refresh(&self, client: &Client, new_credential: &Credential) -> Result<()> {
let res = client
.request(
Method::POST,
"https://passport.bilibili.com/x/passport-login/web/confirm/refresh",
// 此处用的是新的凭证
Some(new_credential),
)
.form(&[
("csrf", new_credential.bili_jct.as_str()),
("refresh_token", self.ac_time_value.as_str()),
])
.send()
.await?
.json::<serde_json::Value>()
.await?;
if res["code"] != 0 {
return Err(format!("confirm refresh failed: {}", res["message"]).into());
}
Ok(())
}
}
// 用指定的 pattern 正则表达式在 doc 中查找,返回第一个匹配的捕获组
fn regex_find(pattern: &str, doc: &str) -> Result<String> {
let re = Regex::new(pattern)?;
Ok(re
.captures(doc)
.ok_or("pattern not match")?
.get(1)
.unwrap()
.as_str()
.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_and_find() {
let doc = r#"
<html lang="zh-Hans">
<body>
<div id="1-name">b0cc8411ded2f9db2cff2edb3123acac</div>
</body>
</html>
"#;
assert_eq!(
regex_find(r#"<div id="1-name">(.+?)</div>"#, doc).unwrap(),
"b0cc8411ded2f9db2cff2edb3123acac",
);
}
}

View File

@@ -81,6 +81,7 @@ impl FavoriteList {
Ok(res)
}
// 拿到收藏夹的所有权,返回一个收藏夹下的视频流
pub fn into_video_stream(self) -> impl Stream<Item = VideoInfo> {
stream! {
let mut page = 1;
@@ -88,7 +89,9 @@ impl FavoriteList {
let Ok(mut videos) = self.get_videos(page).await else{
break;
};
let videos_info: Vec<VideoInfo> = serde_json::from_value(videos["data"]["medias"].take()).unwrap();
let Ok(videos_info) = serde_json::from_value::<Vec<VideoInfo>>(videos["data"]["medias"].take()) else{
break;
};
for video_info in videos_info.into_iter(){
yield video_info;
}

View File

@@ -35,7 +35,7 @@ pub struct Page {
#[serde(rename = "part")]
name: String,
#[serde(default = "String::new")]
first_frame: String, // may not exist
first_frame: String, // 可能不存在,默认填充为空
}
impl Video {

View File

@@ -12,6 +12,9 @@ pub struct Downloader {
}
impl Downloader {
// Downloader 使用带有默认 Header 的 Client 构建
// 拿到 url 后下载文件不需要任何 cookie 作为身份凭证
// 但如果不设置默认 Header下载时会遇到 403 Forbidden 错误
pub fn new(client: Client) -> Self {
Self { client }
}