feat: add mikan cookie support
This commit is contained in:
parent
cac0d37e53
commit
6887b2a069
54
Cargo.lock
generated
54
Cargo.lock
generated
@ -1183,6 +1183,24 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie_store"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
||||||
|
dependencies = [
|
||||||
|
"cookie",
|
||||||
|
"document-features",
|
||||||
|
"idna",
|
||||||
|
"log",
|
||||||
|
"publicsuffix",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@ -1656,6 +1674,15 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenv"
|
name = "dotenv"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
@ -3539,6 +3566,12 @@ version = "0.7.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
|
checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@ -4760,6 +4793,12 @@ dependencies = [
|
|||||||
"yansi",
|
"yansi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psl-types"
|
||||||
|
version = "2.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psm"
|
name = "psm"
|
||||||
version = "0.1.25"
|
version = "0.1.25"
|
||||||
@ -4789,6 +4828,16 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "publicsuffix"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||||
|
dependencies = [
|
||||||
|
"idna",
|
||||||
|
"psl-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qbit-rs"
|
name = "qbit-rs"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@ -5017,12 +5066,14 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
|
"cookie",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"fancy-regex",
|
"fancy-regex",
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"figment",
|
"figment",
|
||||||
"futures",
|
"futures",
|
||||||
"html-escape",
|
"html-escape",
|
||||||
|
"http",
|
||||||
"http-cache",
|
"http-cache",
|
||||||
"http-cache-reqwest",
|
"http-cache-reqwest",
|
||||||
"http-cache-semantics",
|
"http-cache-semantics",
|
||||||
@ -5212,6 +5263,8 @@ checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"cookie_store",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@ -5915,6 +5968,7 @@ version = "0.10.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
|
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -48,6 +48,7 @@ reqwest = { version = "0.12", features = [
|
|||||||
"json",
|
"json",
|
||||||
"macos-system-configuration",
|
"macos-system-configuration",
|
||||||
"rustls-tls",
|
"rustls-tls",
|
||||||
|
"cookies",
|
||||||
] }
|
] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
rss = "2"
|
rss = "2"
|
||||||
@ -113,7 +114,9 @@ http-cache = { version = "0.20.0", features = [
|
|||||||
http-cache-semantics = "2.1.0"
|
http-cache-semantics = "2.1.0"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
nom = "8.0.0"
|
nom = "8.0.0"
|
||||||
secrecy = "0.10.3"
|
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||||
|
http = "1.2.0"
|
||||||
|
cookie = "0.18.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serial_test = "3"
|
serial_test = "3"
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
use figment::{
|
use figment::{
|
||||||
providers::{Format, Json, Yaml},
|
|
||||||
Figment,
|
Figment,
|
||||||
|
providers::{Format, Json, Yaml},
|
||||||
};
|
};
|
||||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AppAuthConfig, dal::config::AppDalConfig, extract::mikan::AppMikanConfig,
|
auth::AppAuthConfig, dal::config::AppDalConfig, errors::RecorderError,
|
||||||
graphql::config::AppGraphQLConfig,
|
extract::mikan::AppMikanConfig, graphql::config::AppGraphQLConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_APP_SETTINGS_MIXIN: &str = include_str!("./settings_mixin.yaml");
|
const DEFAULT_APP_SETTINGS_MIXIN: &str = include_str!("./settings_mixin.yaml");
|
||||||
@ -51,7 +51,7 @@ pub fn deserialize_key_path_from_app_config<T: DeserializeOwned>(
|
|||||||
pub trait AppConfigExt {
|
pub trait AppConfigExt {
|
||||||
fn get_root_conf(&self) -> &loco_rs::config::Config;
|
fn get_root_conf(&self) -> &loco_rs::config::Config;
|
||||||
|
|
||||||
fn get_app_conf(&self) -> loco_rs::Result<AppConfig> {
|
fn get_app_conf(&self) -> Result<AppConfig, RecorderError> {
|
||||||
let settings_str = self
|
let settings_str = self
|
||||||
.get_root_conf()
|
.get_root_conf()
|
||||||
.settings
|
.settings
|
||||||
@ -61,8 +61,7 @@ pub trait AppConfigExt {
|
|||||||
|
|
||||||
let app_config = Figment::from(Json::string(&settings_str))
|
let app_config = Figment::from(Json::string(&settings_str))
|
||||||
.merge(Yaml::string(DEFAULT_APP_SETTINGS_MIXIN))
|
.merge(Yaml::string(DEFAULT_APP_SETTINGS_MIXIN))
|
||||||
.extract()
|
.extract()?;
|
||||||
.map_err(loco_rs::Error::wrap)?;
|
|
||||||
|
|
||||||
Ok(app_config)
|
Ok(app_config)
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ use url::Url;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::AppDalConfig;
|
use super::AppDalConfig;
|
||||||
use crate::{app::App, config::AppConfigExt};
|
use crate::{app::App, config::AppConfigExt, errors::RecorderError};
|
||||||
|
|
||||||
// TODO: wait app-context-trait to integrate
|
// TODO: wait app-context-trait to integrate
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@ -85,7 +85,7 @@ impl AppDalClient {
|
|||||||
bucket: Option<&str>,
|
bucket: Option<&str>,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
data: Bytes,
|
data: Bytes,
|
||||||
) -> color_eyre::eyre::Result<DalStoredUrl> {
|
) -> Result<DalStoredUrl, RecorderError> {
|
||||||
match content_category {
|
match content_category {
|
||||||
DalContentCategory::Image => {
|
DalContentCategory::Image => {
|
||||||
let fullname = [
|
let fullname = [
|
||||||
@ -122,7 +122,7 @@ impl AppDalClient {
|
|||||||
subscriber_id: i32,
|
subscriber_id: i32,
|
||||||
bucket: Option<&str>,
|
bucket: Option<&str>,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
) -> color_eyre::eyre::Result<Option<DalStoredUrl>> {
|
) -> Result<Option<DalStoredUrl>, RecorderError> {
|
||||||
match content_category {
|
match content_category {
|
||||||
DalContentCategory::Image => {
|
DalContentCategory::Image => {
|
||||||
let fullname = [
|
let fullname = [
|
||||||
|
@ -1,13 +1,29 @@
|
|||||||
use std::{borrow::Cow, error::Error as StdError};
|
use std::{borrow::Cow, error::Error as StdError};
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error as ThisError;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
use crate::fetch::HttpClientError;
|
||||||
pub enum ExtractError {
|
|
||||||
#[error("Extract bangumi season error: {0}")]
|
#[derive(ThisError, Debug)]
|
||||||
BangumiSeasonError(#[from] std::num::ParseIntError),
|
pub enum RecorderError {
|
||||||
#[error("Extract file url error: {0}")]
|
#[error(transparent)]
|
||||||
FileUrlError(#[from] url::ParseError),
|
CookieParseError(#[from] cookie::ParseError),
|
||||||
|
#[error(transparent)]
|
||||||
|
FigmentError(#[from] figment::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
SerdeJsonError(#[from] serde_json::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
ReqwestMiddlewareError(#[from] reqwest_middleware::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
ReqwestError(#[from] reqwest::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
ParseUrlError(#[from] url::ParseError),
|
||||||
|
#[error(transparent)]
|
||||||
|
OpenDALError(#[from] opendal::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
InvalidHeaderValueError(#[from] http::header::InvalidHeaderValue),
|
||||||
|
#[error(transparent)]
|
||||||
|
HttpClientError(#[from] HttpClientError),
|
||||||
#[error("Extract {desc} with mime error, expected {expected}, but got {found}")]
|
#[error("Extract {desc} with mime error, expected {expected}, but got {found}")]
|
||||||
MimeError {
|
MimeError {
|
||||||
desc: String,
|
desc: String,
|
||||||
@ -30,7 +46,7 @@ pub enum ExtractError {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExtractError {
|
impl RecorderError {
|
||||||
pub fn from_mikan_meta_missing_field(field: Cow<'static, str>) -> Self {
|
pub fn from_mikan_meta_missing_field(field: Cow<'static, str>) -> Self {
|
||||||
Self::MikanMetaMissingFieldError {
|
Self::MikanMetaMissingFieldError {
|
||||||
field,
|
field,
|
||||||
@ -55,3 +71,9 @@ impl ExtractError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<RecorderError> for loco_rs::Error {
|
||||||
|
fn from(error: RecorderError) -> Self {
|
||||||
|
Self::wrap(error)
|
||||||
|
}
|
||||||
|
}
|
@ -4,16 +4,30 @@ use async_trait::async_trait;
|
|||||||
use loco_rs::app::{AppContext, Initializer};
|
use loco_rs::app::{AppContext, Initializer};
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use reqwest_middleware::ClientWithMiddleware;
|
use reqwest_middleware::ClientWithMiddleware;
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use super::AppMikanConfig;
|
use super::AppMikanConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::AppConfigExt,
|
config::AppConfigExt,
|
||||||
fetch::{HttpClient, HttpClientTrait},
|
errors::RecorderError,
|
||||||
|
fetch::{HttpClient, HttpClientTrait, client::HttpClientCookiesAuth},
|
||||||
};
|
};
|
||||||
|
|
||||||
static APP_MIKAN_CLIENT: OnceCell<AppMikanClient> = OnceCell::new();
|
static APP_MIKAN_CLIENT: OnceCell<AppMikanClient> = OnceCell::new();
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct MikanAuthSecrecy {
|
||||||
|
pub cookie: SecretString,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MikanAuthSecrecy {
|
||||||
|
pub fn into_cookie_auth(self, url: &Url) -> Result<HttpClientCookiesAuth, RecorderError> {
|
||||||
|
HttpClientCookiesAuth::from_cookies(self.cookie.expose_secret(), url, self.user_agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AppMikanClient {
|
pub struct AppMikanClient {
|
||||||
http_client: HttpClient,
|
http_client: HttpClient,
|
||||||
@ -21,9 +35,8 @@ pub struct AppMikanClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppMikanClient {
|
impl AppMikanClient {
|
||||||
pub fn new(config: AppMikanConfig) -> loco_rs::Result<Self> {
|
pub fn new(config: AppMikanConfig) -> Result<Self, RecorderError> {
|
||||||
let http_client =
|
let http_client = HttpClient::from_config(config.http_client)?;
|
||||||
HttpClient::from_config(config.http_client).map_err(loco_rs::Error::wrap)?;
|
|
||||||
let base_url = config.base_url;
|
let base_url = config.base_url;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
http_client,
|
http_client,
|
||||||
@ -31,6 +44,16 @@ impl AppMikanClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fork_with_auth(&self, secrecy: MikanAuthSecrecy) -> Result<Self, RecorderError> {
|
||||||
|
let cookie_auth = secrecy.into_cookie_auth(&self.base_url)?;
|
||||||
|
let fork = self.http_client.fork().attach_secrecy(cookie_auth);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
http_client: HttpClient::from_fork(fork)?,
|
||||||
|
base_url: self.base_url.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn app_instance() -> &'static AppMikanClient {
|
pub fn app_instance() -> &'static AppMikanClient {
|
||||||
APP_MIKAN_CLIENT
|
APP_MIKAN_CLIENT
|
||||||
.get()
|
.get()
|
||||||
@ -40,6 +63,10 @@ impl AppMikanClient {
|
|||||||
pub fn base_url(&self) -> &Url {
|
pub fn base_url(&self) -> &Url {
|
||||||
&self.base_url
|
&self.base_url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn client(&self) -> &HttpClient {
|
||||||
|
&self.http_client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deref for AppMikanClient {
|
impl Deref for AppMikanClient {
|
||||||
|
@ -4,7 +4,7 @@ pub mod constants;
|
|||||||
pub mod rss_extract;
|
pub mod rss_extract;
|
||||||
pub mod web_extract;
|
pub mod web_extract;
|
||||||
|
|
||||||
pub use client::{AppMikanClient, AppMikanClientInitializer};
|
pub use client::{AppMikanClient, AppMikanClientInitializer, MikanAuthSecrecy};
|
||||||
pub use config::AppMikanConfig;
|
pub use config::AppMikanConfig;
|
||||||
pub use constants::MIKAN_BUCKET_KEY;
|
pub use constants::MIKAN_BUCKET_KEY;
|
||||||
pub use rss_extract::{
|
pub use rss_extract::{
|
||||||
|
@ -9,12 +9,10 @@ use tracing::instrument;
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
extract::{
|
errors::RecorderError,
|
||||||
errors::ExtractError,
|
extract::mikan::{
|
||||||
mikan::{
|
|
||||||
AppMikanClient,
|
AppMikanClient,
|
||||||
web_extract::{MikanEpisodeHomepage, parse_mikan_episode_id_from_homepage},
|
web_extract::{MikanEpisodeHomepage, extract_mikan_episode_id_from_homepage},
|
||||||
},
|
|
||||||
},
|
},
|
||||||
fetch::bytes::fetch_bytes,
|
fetch::bytes::fetch_bytes,
|
||||||
sync::core::BITTORRENT_MIME_TYPE,
|
sync::core::BITTORRENT_MIME_TYPE,
|
||||||
@ -103,16 +101,16 @@ impl MikanRssChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<rss::Item> for MikanRssItem {
|
impl TryFrom<rss::Item> for MikanRssItem {
|
||||||
type Error = ExtractError;
|
type Error = RecorderError;
|
||||||
|
|
||||||
fn try_from(item: rss::Item) -> Result<Self, Self::Error> {
|
fn try_from(item: rss::Item) -> Result<Self, Self::Error> {
|
||||||
let enclosure = item.enclosure.ok_or_else(|| {
|
let enclosure = item.enclosure.ok_or_else(|| {
|
||||||
ExtractError::from_mikan_rss_invalid_field(Cow::Borrowed("enclosure"))
|
RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("enclosure"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mime_type = enclosure.mime_type;
|
let mime_type = enclosure.mime_type;
|
||||||
if mime_type != BITTORRENT_MIME_TYPE {
|
if mime_type != BITTORRENT_MIME_TYPE {
|
||||||
return Err(ExtractError::MimeError {
|
return Err(RecorderError::MimeError {
|
||||||
expected: String::from(BITTORRENT_MIME_TYPE),
|
expected: String::from(BITTORRENT_MIME_TYPE),
|
||||||
found: mime_type.to_string(),
|
found: mime_type.to_string(),
|
||||||
desc: String::from("MikanRssItem"),
|
desc: String::from("MikanRssItem"),
|
||||||
@ -120,11 +118,11 @@ impl TryFrom<rss::Item> for MikanRssItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let title = item.title.ok_or_else(|| {
|
let title = item.title.ok_or_else(|| {
|
||||||
ExtractError::from_mikan_rss_invalid_field(Cow::Borrowed("title:title"))
|
RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("title:title"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let enclosure_url = Url::parse(&enclosure.url).map_err(|inner| {
|
let enclosure_url = Url::parse(&enclosure.url).map_err(|inner| {
|
||||||
ExtractError::from_mikan_rss_invalid_field_and_source(
|
RecorderError::from_mikan_rss_invalid_field_and_source(
|
||||||
Cow::Borrowed("enclosure_url:enclosure.link"),
|
Cow::Borrowed("enclosure_url:enclosure.link"),
|
||||||
Box::new(inner),
|
Box::new(inner),
|
||||||
)
|
)
|
||||||
@ -134,13 +132,13 @@ impl TryFrom<rss::Item> for MikanRssItem {
|
|||||||
.link
|
.link
|
||||||
.and_then(|link| Url::parse(&link).ok())
|
.and_then(|link| Url::parse(&link).ok())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ExtractError::from_mikan_rss_invalid_field(Cow::Borrowed("homepage:link"))
|
RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("homepage:link"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let MikanEpisodeHomepage {
|
let MikanEpisodeHomepage {
|
||||||
mikan_episode_id, ..
|
mikan_episode_id, ..
|
||||||
} = parse_mikan_episode_id_from_homepage(&homepage).ok_or_else(|| {
|
} = extract_mikan_episode_id_from_homepage(&homepage).ok_or_else(|| {
|
||||||
ExtractError::from_mikan_rss_invalid_field(Cow::Borrowed("mikan_episode_id"))
|
RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("mikan_episode_id"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(MikanRssItem {
|
Ok(MikanRssItem {
|
||||||
@ -329,7 +327,7 @@ pub async fn extract_mikan_rss_channel_from_rss_link(
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
Err(ExtractError::MikanRssInvalidFormatError)
|
Err(RecorderError::MikanRssInvalidFormatError)
|
||||||
.inspect_err(|error| {
|
.inspect_err(|error| {
|
||||||
tracing::warn!(error = %error);
|
tracing::warn!(error = %error);
|
||||||
})
|
})
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use color_eyre::eyre;
|
use itertools::Itertools;
|
||||||
use loco_rs::app::AppContext;
|
use loco_rs::app::AppContext;
|
||||||
use reqwest::IntoUrl;
|
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@ -14,8 +13,8 @@ use super::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppContextExt,
|
app::AppContextExt,
|
||||||
dal::DalContentCategory,
|
dal::DalContentCategory,
|
||||||
|
errors::RecorderError,
|
||||||
extract::{
|
extract::{
|
||||||
errors::ExtractError,
|
|
||||||
html::{extract_background_image_src_from_style_attr, extract_inner_text_from_element_ref},
|
html::{extract_background_image_src_from_style_attr, extract_inner_text_from_element_ref},
|
||||||
media::extract_image_src_from_str,
|
media::extract_image_src_from_str,
|
||||||
},
|
},
|
||||||
@ -63,38 +62,32 @@ pub struct MikanBangumiHomepage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_mikan_bangumi_homepage(
|
pub fn build_mikan_bangumi_homepage(
|
||||||
mikan_base_url: impl IntoUrl,
|
mikan_base_url: Url,
|
||||||
mikan_bangumi_id: &str,
|
mikan_bangumi_id: &str,
|
||||||
mikan_fansub_id: Option<&str>,
|
mikan_fansub_id: Option<&str>,
|
||||||
) -> eyre::Result<Url> {
|
) -> Url {
|
||||||
let mut url = mikan_base_url.into_url()?;
|
let mut url = mikan_base_url;
|
||||||
url.set_path(&format!("/Home/Bangumi/{mikan_bangumi_id}"));
|
url.set_path(&format!("/Home/Bangumi/{mikan_bangumi_id}"));
|
||||||
url.set_fragment(mikan_fansub_id);
|
url.set_fragment(mikan_fansub_id);
|
||||||
Ok(url)
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_mikan_episode_homepage(
|
pub fn build_mikan_episode_homepage(mikan_base_url: Url, mikan_episode_id: &str) -> Url {
|
||||||
mikan_base_url: impl IntoUrl,
|
let mut url = mikan_base_url;
|
||||||
mikan_episode_id: &str,
|
|
||||||
) -> eyre::Result<Url> {
|
|
||||||
let mut url = mikan_base_url.into_url()?;
|
|
||||||
url.set_path(&format!("/Home/Episode/{mikan_episode_id}"));
|
url.set_path(&format!("/Home/Episode/{mikan_episode_id}"));
|
||||||
Ok(url)
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_mikan_bangumi_expand_info_url(
|
pub fn build_mikan_bangumi_expand_info_url(mikan_base_url: Url, mikan_bangumi_id: &str) -> Url {
|
||||||
mikan_base_url: impl IntoUrl,
|
let mut url = mikan_base_url;
|
||||||
mikan_bangumi_id: &str,
|
|
||||||
) -> eyre::Result<Url> {
|
|
||||||
let mut url = mikan_base_url.into_url()?;
|
|
||||||
url.set_path("/ExpandBangumi");
|
url.set_path("/ExpandBangumi");
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut()
|
||||||
.append_pair("bangumiId", mikan_bangumi_id)
|
.append_pair("bangumiId", mikan_bangumi_id)
|
||||||
.append_pair("showSubscribed", "true");
|
.append_pair("showSubscribed", "true");
|
||||||
Ok(url)
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_mikan_bangumi_id_from_homepage(url: &Url) -> Option<MikanBangumiHomepage> {
|
pub fn extract_mikan_bangumi_id_from_homepage(url: &Url) -> Option<MikanBangumiHomepage> {
|
||||||
if url.path().starts_with("/Home/Bangumi/") {
|
if url.path().starts_with("/Home/Bangumi/") {
|
||||||
let mikan_bangumi_id = url.path().replace("/Home/Bangumi/", "");
|
let mikan_bangumi_id = url.path().replace("/Home/Bangumi/", "");
|
||||||
|
|
||||||
@ -107,7 +100,7 @@ pub fn parse_mikan_bangumi_id_from_homepage(url: &Url) -> Option<MikanBangumiHom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_mikan_episode_id_from_homepage(url: &Url) -> Option<MikanEpisodeHomepage> {
|
pub fn extract_mikan_episode_id_from_homepage(url: &Url) -> Option<MikanEpisodeHomepage> {
|
||||||
if url.path().starts_with("/Home/Episode/") {
|
if url.path().starts_with("/Home/Episode/") {
|
||||||
let mikan_episode_id = url.path().replace("/Home/Episode/", "");
|
let mikan_episode_id = url.path().replace("/Home/Episode/", "");
|
||||||
Some(MikanEpisodeHomepage { mikan_episode_id })
|
Some(MikanEpisodeHomepage { mikan_episode_id })
|
||||||
@ -119,7 +112,7 @@ pub fn parse_mikan_episode_id_from_homepage(url: &Url) -> Option<MikanEpisodeHom
|
|||||||
pub async fn extract_mikan_poster_meta_from_src(
|
pub async fn extract_mikan_poster_meta_from_src(
|
||||||
http_client: &AppMikanClient,
|
http_client: &AppMikanClient,
|
||||||
origin_poster_src_url: Url,
|
origin_poster_src_url: Url,
|
||||||
) -> eyre::Result<MikanBangumiPosterMeta> {
|
) -> Result<MikanBangumiPosterMeta, RecorderError> {
|
||||||
let poster_data = fetch_image(http_client, origin_poster_src_url.clone()).await?;
|
let poster_data = fetch_image(http_client, origin_poster_src_url.clone()).await?;
|
||||||
Ok(MikanBangumiPosterMeta {
|
Ok(MikanBangumiPosterMeta {
|
||||||
origin_poster_src: origin_poster_src_url,
|
origin_poster_src: origin_poster_src_url,
|
||||||
@ -132,7 +125,7 @@ pub async fn extract_mikan_bangumi_poster_meta_from_src_with_cache(
|
|||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
origin_poster_src_url: Url,
|
origin_poster_src_url: Url,
|
||||||
subscriber_id: i32,
|
subscriber_id: i32,
|
||||||
) -> eyre::Result<MikanBangumiPosterMeta> {
|
) -> Result<MikanBangumiPosterMeta, RecorderError> {
|
||||||
let dal_client = ctx.get_dal_client();
|
let dal_client = ctx.get_dal_client();
|
||||||
let mikan_client = ctx.get_mikan_client();
|
let mikan_client = ctx.get_mikan_client();
|
||||||
if let Some(poster_src) = dal_client
|
if let Some(poster_src) = dal_client
|
||||||
@ -174,7 +167,7 @@ pub async fn extract_mikan_bangumi_poster_meta_from_src_with_cache(
|
|||||||
pub async fn extract_mikan_episode_meta_from_episode_homepage(
|
pub async fn extract_mikan_episode_meta_from_episode_homepage(
|
||||||
http_client: &AppMikanClient,
|
http_client: &AppMikanClient,
|
||||||
mikan_episode_homepage_url: Url,
|
mikan_episode_homepage_url: Url,
|
||||||
) -> eyre::Result<MikanEpisodeMeta> {
|
) -> Result<MikanEpisodeMeta, RecorderError> {
|
||||||
let mikan_base_url = Url::parse(&mikan_episode_homepage_url.origin().unicode_serialization())?;
|
let mikan_base_url = Url::parse(&mikan_episode_homepage_url.origin().unicode_serialization())?;
|
||||||
let content = fetch_html(http_client, mikan_episode_homepage_url.as_str()).await?;
|
let content = fetch_html(http_client, mikan_episode_homepage_url.as_str()).await?;
|
||||||
|
|
||||||
@ -190,7 +183,7 @@ pub async fn extract_mikan_episode_meta_from_episode_homepage(
|
|||||||
.select(bangumi_title_selector)
|
.select(bangumi_title_selector)
|
||||||
.next()
|
.next()
|
||||||
.map(extract_inner_text_from_element_ref)
|
.map(extract_inner_text_from_element_ref)
|
||||||
.ok_or_else(|| ExtractError::from_mikan_meta_missing_field(Cow::Borrowed("bangumi_title")))
|
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("bangumi_title")))
|
||||||
.inspect_err(|error| {
|
.inspect_err(|error| {
|
||||||
tracing::warn!(error = %error);
|
tracing::warn!(error = %error);
|
||||||
})?;
|
})?;
|
||||||
@ -206,13 +199,13 @@ pub async fn extract_mikan_episode_meta_from_episode_homepage(
|
|||||||
.and_then(|s| mikan_episode_homepage_url.join(s).ok())
|
.and_then(|s| mikan_episode_homepage_url.join(s).ok())
|
||||||
.and_then(|rss_link_url| extract_mikan_bangumi_id_from_rss_link(&rss_link_url))
|
.and_then(|rss_link_url| extract_mikan_bangumi_id_from_rss_link(&rss_link_url))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ExtractError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_bangumi_id"))
|
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_bangumi_id"))
|
||||||
})
|
})
|
||||||
.inspect_err(|error| tracing::error!(error = %error))?;
|
.inspect_err(|error| tracing::error!(error = %error))?;
|
||||||
|
|
||||||
let mikan_fansub_id = mikan_fansub_id
|
let mikan_fansub_id = mikan_fansub_id
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ExtractError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_fansub_id"))
|
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_fansub_id"))
|
||||||
})
|
})
|
||||||
.inspect_err(|error| tracing::error!(error = %error))?;
|
.inspect_err(|error| tracing::error!(error = %error))?;
|
||||||
|
|
||||||
@ -220,16 +213,16 @@ pub async fn extract_mikan_episode_meta_from_episode_homepage(
|
|||||||
.select(&Selector::parse("title").unwrap())
|
.select(&Selector::parse("title").unwrap())
|
||||||
.next()
|
.next()
|
||||||
.map(extract_inner_text_from_element_ref)
|
.map(extract_inner_text_from_element_ref)
|
||||||
.ok_or_else(|| ExtractError::from_mikan_meta_missing_field(Cow::Borrowed("episode_title")))
|
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("episode_title")))
|
||||||
.inspect_err(|error| {
|
.inspect_err(|error| {
|
||||||
tracing::warn!(error = %error);
|
tracing::warn!(error = %error);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let MikanEpisodeHomepage {
|
let MikanEpisodeHomepage {
|
||||||
mikan_episode_id, ..
|
mikan_episode_id, ..
|
||||||
} = parse_mikan_episode_id_from_homepage(&mikan_episode_homepage_url)
|
} = extract_mikan_episode_id_from_homepage(&mikan_episode_homepage_url)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ExtractError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_episode_id"))
|
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_episode_id"))
|
||||||
})
|
})
|
||||||
.inspect_err(|error| {
|
.inspect_err(|error| {
|
||||||
tracing::warn!(error = %error);
|
tracing::warn!(error = %error);
|
||||||
@ -242,7 +235,7 @@ pub async fn extract_mikan_episode_meta_from_episode_homepage(
|
|||||||
)
|
)
|
||||||
.next()
|
.next()
|
||||||
.map(extract_inner_text_from_element_ref)
|
.map(extract_inner_text_from_element_ref)
|
||||||
.ok_or_else(|| ExtractError::from_mikan_meta_missing_field(Cow::Borrowed("fansub_name")))
|
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("fansub_name")))
|
||||||
.inspect_err(|error| {
|
.inspect_err(|error| {
|
||||||
tracing::warn!(error = %error);
|
tracing::warn!(error = %error);
|
||||||
})?;
|
})?;
|
||||||
@ -285,7 +278,7 @@ pub async fn extract_mikan_episode_meta_from_episode_homepage(
|
|||||||
pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
|
pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
|
||||||
http_client: &AppMikanClient,
|
http_client: &AppMikanClient,
|
||||||
mikan_bangumi_homepage_url: Url,
|
mikan_bangumi_homepage_url: Url,
|
||||||
) -> eyre::Result<MikanBangumiMeta> {
|
) -> Result<MikanBangumiMeta, RecorderError> {
|
||||||
let mikan_base_url = Url::parse(&mikan_bangumi_homepage_url.origin().unicode_serialization())?;
|
let mikan_base_url = Url::parse(&mikan_bangumi_homepage_url.origin().unicode_serialization())?;
|
||||||
let content = fetch_html(http_client, mikan_bangumi_homepage_url.as_str()).await?;
|
let content = fetch_html(http_client, mikan_bangumi_homepage_url.as_str()).await?;
|
||||||
let html = Html::parse_document(&content);
|
let html = Html::parse_document(&content);
|
||||||
@ -299,7 +292,7 @@ pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
|
|||||||
.select(bangumi_title_selector)
|
.select(bangumi_title_selector)
|
||||||
.next()
|
.next()
|
||||||
.map(extract_inner_text_from_element_ref)
|
.map(extract_inner_text_from_element_ref)
|
||||||
.ok_or_else(|| ExtractError::from_mikan_meta_missing_field(Cow::Borrowed("bangumi_title")))
|
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("bangumi_title")))
|
||||||
.inspect_err(|error| tracing::warn!(error = %error))?;
|
.inspect_err(|error| tracing::warn!(error = %error))?;
|
||||||
|
|
||||||
let mikan_bangumi_id = html
|
let mikan_bangumi_id = html
|
||||||
@ -314,7 +307,7 @@ pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
|
|||||||
}| mikan_bangumi_id,
|
}| mikan_bangumi_id,
|
||||||
)
|
)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ExtractError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_bangumi_id"))
|
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_bangumi_id"))
|
||||||
})
|
})
|
||||||
.inspect_err(|error| tracing::error!(error = %error))?;
|
.inspect_err(|error| tracing::error!(error = %error))?;
|
||||||
|
|
||||||
@ -367,26 +360,28 @@ pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
|
|||||||
pub async fn extract_mikan_bangumis_meta_from_my_bangumi_page(
|
pub async fn extract_mikan_bangumis_meta_from_my_bangumi_page(
|
||||||
http_client: &AppMikanClient,
|
http_client: &AppMikanClient,
|
||||||
my_bangumi_page_url: Url,
|
my_bangumi_page_url: Url,
|
||||||
) -> eyre::Result<Vec<MikanBangumiMeta>> {
|
) -> Result<Vec<MikanBangumiMeta>, RecorderError> {
|
||||||
let mikan_base_url = Url::parse(&my_bangumi_page_url.origin().unicode_serialization())?;
|
let mikan_base_url = Url::parse(&my_bangumi_page_url.origin().unicode_serialization())?;
|
||||||
|
|
||||||
let content = fetch_html(http_client, my_bangumi_page_url.clone()).await?;
|
let content = fetch_html(http_client, my_bangumi_page_url.clone()).await?;
|
||||||
|
|
||||||
let bangumi_container_selector = &Selector::parse(".sk-bangumi .an-ul>li").unwrap();
|
|
||||||
let bangumi_info_selector = &Selector::parse(".an-info a.an-text").unwrap();
|
|
||||||
let bangumi_poster_selector =
|
|
||||||
&Selector::parse("span[data-src][data-bangumiid], span[data-bangumiid][style]").unwrap();
|
|
||||||
let fansub_container_selector =
|
let fansub_container_selector =
|
||||||
&Selector::parse(".js-expand_bangumi-subgroup.js-subscribed").unwrap();
|
&Selector::parse(".js-expand_bangumi-subgroup.js-subscribed").unwrap();
|
||||||
let fansub_title_selector = &Selector::parse(".tag-res-name[title]").unwrap();
|
let fansub_title_selector = &Selector::parse(".tag-res-name[title]").unwrap();
|
||||||
let fansub_id_selector =
|
let fansub_id_selector =
|
||||||
&Selector::parse(".active[data-subtitlegroupid][data-bangumiid]").unwrap();
|
&Selector::parse(".active[data-subtitlegroupid][data-bangumiid]").unwrap();
|
||||||
|
|
||||||
|
let bangumi_iters = {
|
||||||
|
let bangumi_container_selector = &Selector::parse(".sk-bangumi .an-ul>li").unwrap();
|
||||||
|
|
||||||
|
let bangumi_info_selector = &Selector::parse(".an-info a.an-text").unwrap();
|
||||||
|
let bangumi_poster_selector =
|
||||||
|
&Selector::parse("span[data-src][data-bangumiid], span[data-bangumiid][style]")
|
||||||
|
.unwrap();
|
||||||
let html = Html::parse_document(&content);
|
let html = Html::parse_document(&content);
|
||||||
|
|
||||||
let mut bangumi_list = vec![];
|
html.select(bangumi_container_selector)
|
||||||
|
.filter_map(|bangumi_elem| {
|
||||||
for bangumi_elem in html.select(bangumi_container_selector) {
|
|
||||||
let title_and_href_elem = bangumi_elem.select(bangumi_info_selector).next();
|
let title_and_href_elem = bangumi_elem.select(bangumi_info_selector).next();
|
||||||
let poster_elem = bangumi_elem.select(bangumi_poster_selector).next();
|
let poster_elem = bangumi_elem.select(bangumi_poster_selector).next();
|
||||||
if let (Some(bangumi_home_page_url), Some(bangumi_title)) = (
|
if let (Some(bangumi_home_page_url), Some(bangumi_title)) = (
|
||||||
@ -395,19 +390,24 @@ pub async fn extract_mikan_bangumis_meta_from_my_bangumi_page(
|
|||||||
) {
|
) {
|
||||||
let origin_poster_src = poster_elem.and_then(|ele| {
|
let origin_poster_src = poster_elem.and_then(|ele| {
|
||||||
ele.attr("data-src")
|
ele.attr("data-src")
|
||||||
.and_then(|data_src| extract_image_src_from_str(data_src, &mikan_base_url))
|
.and_then(|data_src| {
|
||||||
|
extract_image_src_from_str(data_src, &mikan_base_url)
|
||||||
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
ele.attr("style").and_then(|style| {
|
ele.attr("style").and_then(|style| {
|
||||||
extract_background_image_src_from_style_attr(style, &mikan_base_url)
|
extract_background_image_src_from_style_attr(
|
||||||
|
style,
|
||||||
|
&mikan_base_url,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let bangumi_home_page_url = my_bangumi_page_url.join(bangumi_home_page_url)?;
|
let bangumi_title = bangumi_title.to_string();
|
||||||
if let Some(MikanBangumiHomepage {
|
let bangumi_home_page_url =
|
||||||
ref mikan_bangumi_id,
|
my_bangumi_page_url.join(bangumi_home_page_url).ok()?;
|
||||||
..
|
let MikanBangumiHomepage {
|
||||||
}) = parse_mikan_bangumi_id_from_homepage(&bangumi_home_page_url)
|
mikan_bangumi_id, ..
|
||||||
{
|
} = extract_mikan_bangumi_id_from_homepage(&bangumi_home_page_url)?;
|
||||||
if let Some(origin_poster_src) = origin_poster_src.as_ref() {
|
if let Some(origin_poster_src) = origin_poster_src.as_ref() {
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
origin_poster_src = origin_poster_src.as_str(),
|
origin_poster_src = origin_poster_src.as_str(),
|
||||||
@ -422,12 +422,30 @@ pub async fn extract_mikan_bangumis_meta_from_my_bangumi_page(
|
|||||||
"bangumi info extracted, but failed to extract poster_src"
|
"bangumi info extracted, but failed to extract poster_src"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let bangumi_expand_info_url =
|
let bangumi_expand_info_url = build_mikan_bangumi_expand_info_url(
|
||||||
build_mikan_bangumi_expand_info_url(mikan_base_url.clone(), mikan_bangumi_id)?;
|
mikan_base_url.clone(),
|
||||||
let bangumi_expand_info_content =
|
&mikan_bangumi_id,
|
||||||
fetch_html(http_client, bangumi_expand_info_url).await?;
|
);
|
||||||
let bangumi_expand_info_fragment =
|
Some((
|
||||||
Html::parse_fragment(&bangumi_expand_info_content);
|
bangumi_title,
|
||||||
|
mikan_bangumi_id,
|
||||||
|
bangumi_expand_info_url,
|
||||||
|
origin_poster_src,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut bangumi_list = vec![];
|
||||||
|
|
||||||
|
for (bangumi_title, mikan_bangumi_id, bangumi_expand_info_url, origin_poster_src) in
|
||||||
|
bangumi_iters
|
||||||
|
{
|
||||||
|
let bangumi_expand_info_content = fetch_html(http_client, bangumi_expand_info_url).await?;
|
||||||
|
let bangumi_expand_info_fragment = Html::parse_fragment(&bangumi_expand_info_content);
|
||||||
for fansub_info in bangumi_expand_info_fragment.select(fansub_container_selector) {
|
for fansub_info in bangumi_expand_info_fragment.select(fansub_container_selector) {
|
||||||
if let (Some(fansub_name), Some(mikan_fansub_id)) = (
|
if let (Some(fansub_name), Some(mikan_fansub_id)) = (
|
||||||
fansub_info
|
fansub_info
|
||||||
@ -449,7 +467,7 @@ pub async fn extract_mikan_bangumis_meta_from_my_bangumi_page(
|
|||||||
mikan_base_url.clone(),
|
mikan_base_url.clone(),
|
||||||
mikan_bangumi_id.as_str(),
|
mikan_bangumi_id.as_str(),
|
||||||
Some(mikan_fansub_id),
|
Some(mikan_fansub_id),
|
||||||
)?,
|
),
|
||||||
bangumi_title: bangumi_title.to_string(),
|
bangumi_title: bangumi_title.to_string(),
|
||||||
mikan_bangumi_id: mikan_bangumi_id.to_string(),
|
mikan_bangumi_id: mikan_bangumi_id.to_string(),
|
||||||
mikan_fansub_id: Some(mikan_fansub_id.to_string()),
|
mikan_fansub_id: Some(mikan_fansub_id.to_string()),
|
||||||
@ -459,8 +477,6 @@ pub async fn extract_mikan_bangumis_meta_from_my_bangumi_page(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(bangumi_list)
|
Ok(bangumi_list)
|
||||||
}
|
}
|
||||||
@ -469,14 +485,18 @@ pub async fn extract_mikan_bangumis_meta_from_my_bangumi_page(
|
|||||||
mod test {
|
mod test {
|
||||||
#![allow(unused_variables)]
|
#![allow(unused_variables)]
|
||||||
use color_eyre::eyre;
|
use color_eyre::eyre;
|
||||||
|
use http::header;
|
||||||
use rstest::{fixture, rstest};
|
use rstest::{fixture, rstest};
|
||||||
|
use secrecy::SecretString;
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use zune_image::{codecs::ImageFormat, image::Image};
|
use zune_image::{codecs::ImageFormat, image::Image};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
extract::mikan::web_extract::extract_mikan_bangumis_meta_from_my_bangumi_page,
|
extract::mikan::{
|
||||||
|
MikanAuthSecrecy, web_extract::extract_mikan_bangumis_meta_from_my_bangumi_page,
|
||||||
|
},
|
||||||
test_utils::{mikan::build_testing_mikan_client, tracing::init_testing_tracing},
|
test_utils::{mikan::build_testing_mikan_client, tracing::init_testing_tracing},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -604,12 +624,40 @@ mod test {
|
|||||||
|
|
||||||
let mikan_base_url = Url::parse(&mikan_server.url())?;
|
let mikan_base_url = Url::parse(&mikan_server.url())?;
|
||||||
|
|
||||||
let mikan_client = build_testing_mikan_client(mikan_base_url.clone())?;
|
|
||||||
|
|
||||||
let my_bangumi_page_url = mikan_base_url.join("/Home/MyBangumi")?;
|
let my_bangumi_page_url = mikan_base_url.join("/Home/MyBangumi")?;
|
||||||
|
|
||||||
let my_bangumi_mock = mikan_server
|
let mikan_client = build_testing_mikan_client(mikan_base_url.clone())?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let my_bangumi_without_cookie_mock = mikan_server
|
||||||
.mock("GET", my_bangumi_page_url.path())
|
.mock("GET", my_bangumi_page_url.path())
|
||||||
|
.match_header(header::COOKIE, mockito::Matcher::Missing)
|
||||||
|
.with_body_from_file("tests/resources/mikan/MyBangumi-noauth.htm")
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let bangumi_metas = extract_mikan_bangumis_meta_from_my_bangumi_page(
|
||||||
|
&mikan_client,
|
||||||
|
my_bangumi_page_url.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert!(bangumi_metas.is_empty());
|
||||||
|
|
||||||
|
assert!(my_bangumi_without_cookie_mock.matched_async().await);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let my_bangumi_with_cookie_mock = mikan_server
|
||||||
|
.mock("GET", my_bangumi_page_url.path())
|
||||||
|
.match_header(
|
||||||
|
header::COOKIE,
|
||||||
|
mockito::Matcher::AllOf(vec![
|
||||||
|
mockito::Matcher::Regex(String::from(".*\\.AspNetCore\\.Antiforgery.*")),
|
||||||
|
mockito::Matcher::Regex(String::from(
|
||||||
|
".*\\.AspNetCore\\.Identity\\.Application.*",
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
)
|
||||||
.with_body_from_file("tests/resources/mikan/MyBangumi.htm")
|
.with_body_from_file("tests/resources/mikan/MyBangumi.htm")
|
||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
@ -621,16 +669,30 @@ mod test {
|
|||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let bangumi_metas =
|
let mikan_client_with_cookie = mikan_client.fork_with_auth(MikanAuthSecrecy {
|
||||||
extract_mikan_bangumis_meta_from_my_bangumi_page(&mikan_client, my_bangumi_page_url)
|
cookie: SecretString::from(
|
||||||
|
"mikan-announcement=1; .AspNetCore.Antiforgery.abc=abc; \
|
||||||
|
.AspNetCore.Identity.Application=abc; ",
|
||||||
|
),
|
||||||
|
user_agent: Some(String::from(
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like \
|
||||||
|
Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0",
|
||||||
|
)),
|
||||||
|
})?;
|
||||||
|
let bangumi_metas = extract_mikan_bangumis_meta_from_my_bangumi_page(
|
||||||
|
&mikan_client_with_cookie,
|
||||||
|
my_bangumi_page_url,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
assert!(!bangumi_metas.is_empty());
|
assert!(!bangumi_metas.is_empty());
|
||||||
|
|
||||||
assert!(bangumi_metas[0].origin_poster_src.is_some());
|
assert!(bangumi_metas[0].origin_poster_src.is_some());
|
||||||
|
|
||||||
my_bangumi_mock.expect(1);
|
assert!(my_bangumi_with_cookie_mock.matched_async().await);
|
||||||
|
|
||||||
expand_bangumi_mock.expect(bangumi_metas.len());
|
expand_bangumi_mock.expect(bangumi_metas.len());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
pub mod defs;
|
pub mod defs;
|
||||||
pub mod errors;
|
|
||||||
pub mod html;
|
pub mod html;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
@ -2,11 +2,12 @@ use bytes::Bytes;
|
|||||||
use reqwest::IntoUrl;
|
use reqwest::IntoUrl;
|
||||||
|
|
||||||
use super::client::HttpClientTrait;
|
use super::client::HttpClientTrait;
|
||||||
|
use crate::errors::RecorderError;
|
||||||
|
|
||||||
pub async fn fetch_bytes<T: IntoUrl, H: HttpClientTrait>(
|
pub async fn fetch_bytes<T: IntoUrl, H: HttpClientTrait>(
|
||||||
client: &H,
|
client: &H,
|
||||||
url: T,
|
url: T,
|
||||||
) -> color_eyre::eyre::Result<Bytes> {
|
) -> Result<Bytes, RecorderError> {
|
||||||
let bytes = client
|
let bytes = client
|
||||||
.get(url)
|
.get(url)
|
||||||
.send()
|
.send()
|
||||||
|
@ -1,244 +0,0 @@
|
|||||||
use std::{fmt::Debug, ops::Deref, sync::Arc, time::Duration};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use axum::http::{self, Extensions};
|
|
||||||
use http_cache_reqwest::{
|
|
||||||
CACacheManager, Cache, CacheManager, CacheMode, HttpCache, HttpCacheOptions, MokaManager,
|
|
||||||
};
|
|
||||||
use leaky_bucket::RateLimiter;
|
|
||||||
use reqwest::{ClientBuilder, Request, Response};
|
|
||||||
use reqwest_middleware::{
|
|
||||||
ClientBuilder as ClientWithMiddlewareBuilder, ClientWithMiddleware, Next,
|
|
||||||
};
|
|
||||||
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
|
|
||||||
use reqwest_tracing::TracingMiddleware;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_with::serde_as;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
use super::get_random_mobile_ua;
|
|
||||||
use crate::app::App;
|
|
||||||
|
|
||||||
pub struct RateLimiterMiddleware {
|
|
||||||
rate_limiter: RateLimiter,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl reqwest_middleware::Middleware for RateLimiterMiddleware {
|
|
||||||
async fn handle(
|
|
||||||
&self,
|
|
||||||
req: Request,
|
|
||||||
extensions: &'_ mut Extensions,
|
|
||||||
next: Next<'_>,
|
|
||||||
) -> reqwest_middleware::Result<Response> {
|
|
||||||
self.rate_limiter.acquire_one().await;
|
|
||||||
next.run(req, extensions).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case", tag = "type")]
|
|
||||||
pub enum HttpClientCacheBackendConfig {
|
|
||||||
Moka { cache_size: u64 },
|
|
||||||
CACache { cache_path: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum HttpClientCachePresetConfig {
|
|
||||||
#[serde(rename = "rfc7234")]
|
|
||||||
RFC7234,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for HttpClientCachePresetConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::RFC7234
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
pub struct HttpClientConfig {
|
|
||||||
pub exponential_backoff_max_retries: Option<u32>,
|
|
||||||
pub leaky_bucket_max_tokens: Option<u32>,
|
|
||||||
pub leaky_bucket_initial_tokens: Option<u32>,
|
|
||||||
pub leaky_bucket_refill_tokens: Option<u32>,
|
|
||||||
#[serde_as(as = "Option<serde_with::DurationMilliSeconds>")]
|
|
||||||
pub leaky_bucket_refill_interval: Option<Duration>,
|
|
||||||
pub user_agent: Option<String>,
|
|
||||||
pub cache_backend: Option<HttpClientCacheBackendConfig>,
|
|
||||||
pub cache_preset: Option<HttpClientCachePresetConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CacheBackend(Box<dyn CacheManager>);
|
|
||||||
|
|
||||||
impl CacheBackend {
|
|
||||||
fn new<T: CacheManager>(backend: T) -> Self {
|
|
||||||
Self(Box::new(backend))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl CacheManager for CacheBackend {
|
|
||||||
async fn get(
|
|
||||||
&self,
|
|
||||||
cache_key: &str,
|
|
||||||
) -> http_cache::Result<Option<(http_cache::HttpResponse, http_cache_semantics::CachePolicy)>>
|
|
||||||
{
|
|
||||||
self.0.get(cache_key).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to cache a response and related policy.
|
|
||||||
async fn put(
|
|
||||||
&self,
|
|
||||||
cache_key: String,
|
|
||||||
res: http_cache::HttpResponse,
|
|
||||||
policy: http_cache_semantics::CachePolicy,
|
|
||||||
) -> http_cache::Result<http_cache::HttpResponse> {
|
|
||||||
self.0.put(cache_key, res, policy).await
|
|
||||||
}
|
|
||||||
/// Attempts to remove a record from cache.
|
|
||||||
async fn delete(&self, cache_key: &str) -> http_cache::Result<()> {
|
|
||||||
self.0.delete(cache_key).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum HttpClientError {
|
|
||||||
#[error(transparent)]
|
|
||||||
ReqwestError(#[from] reqwest::Error),
|
|
||||||
#[error(transparent)]
|
|
||||||
ReqwestMiddlewareError(#[from] reqwest_middleware::Error),
|
|
||||||
#[error(transparent)]
|
|
||||||
HttpError(#[from] http::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait HttpClientTrait: Deref<Target = ClientWithMiddleware> {}
|
|
||||||
|
|
||||||
pub struct HttpClient {
|
|
||||||
client: ClientWithMiddleware,
|
|
||||||
pub config: HttpClientConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Debug for HttpClient {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("HttpClient")
|
|
||||||
.field("config", &self.config)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<HttpClient> for ClientWithMiddleware {
|
|
||||||
fn from(val: HttpClient) -> Self {
|
|
||||||
val.client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for HttpClient {
|
|
||||||
type Target = ClientWithMiddleware;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HttpClient {
|
|
||||||
pub fn from_config(config: HttpClientConfig) -> Result<Self, HttpClientError> {
|
|
||||||
let reqwest_client_builder = ClientBuilder::new().user_agent(
|
|
||||||
config
|
|
||||||
.user_agent
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or_else(|| get_random_mobile_ua()),
|
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
let reqwest_client_builder =
|
|
||||||
reqwest_client_builder.redirect(reqwest::redirect::Policy::none());
|
|
||||||
|
|
||||||
let reqwest_client = reqwest_client_builder.build()?;
|
|
||||||
|
|
||||||
let mut reqwest_with_middleware_builder =
|
|
||||||
ClientWithMiddlewareBuilder::new(reqwest_client).with(TracingMiddleware::default());
|
|
||||||
|
|
||||||
if let Some(ref x) = config.exponential_backoff_max_retries {
|
|
||||||
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(*x);
|
|
||||||
|
|
||||||
reqwest_with_middleware_builder = reqwest_with_middleware_builder
|
|
||||||
.with(RetryTransientMiddleware::new_with_policy(retry_policy));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let (None, None, None, None) = (
|
|
||||||
config.leaky_bucket_initial_tokens.as_ref(),
|
|
||||||
config.leaky_bucket_refill_tokens.as_ref(),
|
|
||||||
config.leaky_bucket_refill_interval.as_ref(),
|
|
||||||
config.leaky_bucket_max_tokens.as_ref(),
|
|
||||||
) {
|
|
||||||
} else {
|
|
||||||
let mut rate_limiter_builder = RateLimiter::builder();
|
|
||||||
|
|
||||||
if let Some(ref x) = config.leaky_bucket_max_tokens {
|
|
||||||
rate_limiter_builder.max(*x as usize);
|
|
||||||
}
|
|
||||||
if let Some(ref x) = config.leaky_bucket_initial_tokens {
|
|
||||||
rate_limiter_builder.initial(*x as usize);
|
|
||||||
}
|
|
||||||
if let Some(ref x) = config.leaky_bucket_refill_tokens {
|
|
||||||
rate_limiter_builder.refill(*x as usize);
|
|
||||||
}
|
|
||||||
if let Some(ref x) = config.leaky_bucket_refill_interval {
|
|
||||||
rate_limiter_builder.interval(*x);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rate_limiter = rate_limiter_builder.build();
|
|
||||||
|
|
||||||
reqwest_with_middleware_builder =
|
|
||||||
reqwest_with_middleware_builder.with(RateLimiterMiddleware { rate_limiter });
|
|
||||||
}
|
|
||||||
|
|
||||||
if let (None, None) = (config.cache_backend.as_ref(), config.cache_preset.as_ref()) {
|
|
||||||
} else {
|
|
||||||
let cache_preset = config.cache_preset.as_ref().cloned().unwrap_or_default();
|
|
||||||
let cache_backend = config
|
|
||||||
.cache_backend
|
|
||||||
.as_ref()
|
|
||||||
.map(|b| match b {
|
|
||||||
HttpClientCacheBackendConfig::CACache { cache_path } => {
|
|
||||||
let path = std::path::PathBuf::from(
|
|
||||||
App::get_working_root().join(cache_path).as_str(),
|
|
||||||
);
|
|
||||||
CacheBackend::new(CACacheManager { path })
|
|
||||||
}
|
|
||||||
HttpClientCacheBackendConfig::Moka { cache_size } => {
|
|
||||||
CacheBackend::new(MokaManager {
|
|
||||||
cache: Arc::new(moka::future::Cache::new(u64::max(*cache_size, 1))),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| CacheBackend::new(MokaManager::default()));
|
|
||||||
|
|
||||||
let http_cache = match cache_preset {
|
|
||||||
HttpClientCachePresetConfig::RFC7234 => HttpCache {
|
|
||||||
mode: CacheMode::Default,
|
|
||||||
manager: cache_backend,
|
|
||||||
options: HttpCacheOptions::default(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
reqwest_with_middleware_builder =
|
|
||||||
reqwest_with_middleware_builder.with(Cache(http_cache));
|
|
||||||
}
|
|
||||||
|
|
||||||
let reqwest_with_middleware = reqwest_with_middleware_builder.build();
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
client: reqwest_with_middleware,
|
|
||||||
config,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for HttpClient {
|
|
||||||
fn default() -> Self {
|
|
||||||
HttpClient::from_config(Default::default()).expect("Failed to create default HttpClient")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HttpClientTrait for HttpClient {}
|
|
329
apps/recorder/src/fetch/client/core.rs
Normal file
329
apps/recorder/src/fetch/client/core.rs
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
use std::{fmt::Debug, ops::Deref, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::http::{self, Extensions};
|
||||||
|
use http_cache_reqwest::{
|
||||||
|
CACacheManager, Cache, CacheManager, CacheMode, HttpCache, HttpCacheOptions, MokaManager,
|
||||||
|
};
|
||||||
|
use leaky_bucket::RateLimiter;
|
||||||
|
use reqwest::{ClientBuilder, Request, Response};
|
||||||
|
use reqwest_middleware::{
|
||||||
|
ClientBuilder as ClientWithMiddlewareBuilder, ClientWithMiddleware, Middleware, Next,
|
||||||
|
};
|
||||||
|
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
|
||||||
|
use reqwest_tracing::TracingMiddleware;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::serde_as;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use super::HttpClientSecrecyDataTrait;
|
||||||
|
use crate::{app::App, fetch::get_random_mobile_ua};
|
||||||
|
|
||||||
|
pub struct RateLimiterMiddleware {
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Middleware for RateLimiterMiddleware {
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
req: Request,
|
||||||
|
extensions: &'_ mut Extensions,
|
||||||
|
next: Next<'_>,
|
||||||
|
) -> reqwest_middleware::Result<Response> {
|
||||||
|
self.rate_limiter.acquire_one().await;
|
||||||
|
next.run(req, extensions).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "type")]
|
||||||
|
pub enum HttpClientCacheBackendConfig {
|
||||||
|
Moka { cache_size: u64 },
|
||||||
|
CACache { cache_path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum HttpClientCachePresetConfig {
|
||||||
|
#[serde(rename = "rfc7234")]
|
||||||
|
RFC7234,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpClientCachePresetConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::RFC7234
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct HttpClientConfig {
|
||||||
|
pub exponential_backoff_max_retries: Option<u32>,
|
||||||
|
pub leaky_bucket_max_tokens: Option<u32>,
|
||||||
|
pub leaky_bucket_initial_tokens: Option<u32>,
|
||||||
|
pub leaky_bucket_refill_tokens: Option<u32>,
|
||||||
|
#[serde_as(as = "Option<serde_with::DurationMilliSeconds>")]
|
||||||
|
pub leaky_bucket_refill_interval: Option<Duration>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
pub cache_backend: Option<HttpClientCacheBackendConfig>,
|
||||||
|
pub cache_preset: Option<HttpClientCachePresetConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct CacheBackend(Box<dyn CacheManager>);
|
||||||
|
|
||||||
|
impl CacheBackend {
|
||||||
|
pub(crate) fn new<T: CacheManager>(backend: T) -> Self {
|
||||||
|
Self(Box::new(backend))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl CacheManager for CacheBackend {
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
cache_key: &str,
|
||||||
|
) -> http_cache::Result<Option<(http_cache::HttpResponse, http_cache_semantics::CachePolicy)>>
|
||||||
|
{
|
||||||
|
self.0.get(cache_key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to cache a response and related policy.
|
||||||
|
async fn put(
|
||||||
|
&self,
|
||||||
|
cache_key: String,
|
||||||
|
res: http_cache::HttpResponse,
|
||||||
|
policy: http_cache_semantics::CachePolicy,
|
||||||
|
) -> http_cache::Result<http_cache::HttpResponse> {
|
||||||
|
self.0.put(cache_key, res, policy).await
|
||||||
|
}
|
||||||
|
/// Attempts to remove a record from cache.
|
||||||
|
async fn delete(&self, cache_key: &str) -> http_cache::Result<()> {
|
||||||
|
self.0.delete(cache_key).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum HttpClientError {
|
||||||
|
#[error(transparent)]
|
||||||
|
ReqwestError(#[from] reqwest::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
ReqwestMiddlewareError(#[from] reqwest_middleware::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
HttpError(#[from] http::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait HttpClientTrait: Deref<Target = ClientWithMiddleware> + Debug {}
|
||||||
|
|
||||||
|
pub struct HttpClientFork {
|
||||||
|
pub client_builder: ClientBuilder,
|
||||||
|
pub middleware_stack: Vec<Arc<dyn Middleware>>,
|
||||||
|
pub config: HttpClientConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClientFork {
|
||||||
|
pub fn attach_secrecy<S: HttpClientSecrecyDataTrait>(self, secrecy: S) -> Self {
|
||||||
|
let mut fork = self;
|
||||||
|
fork.client_builder = secrecy.attach_secrecy_to_client(fork.client_builder);
|
||||||
|
fork
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HttpClient {
|
||||||
|
client: ClientWithMiddleware,
|
||||||
|
middleware_stack: Vec<Arc<dyn Middleware>>,
|
||||||
|
pub config: HttpClientConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for HttpClient {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("HttpClient")
|
||||||
|
.field("config", &self.config)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HttpClient> for ClientWithMiddleware {
|
||||||
|
fn from(val: HttpClient) -> Self {
|
||||||
|
val.client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for HttpClient {
|
||||||
|
type Target = ClientWithMiddleware;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClient {
|
||||||
|
pub fn from_config(config: HttpClientConfig) -> Result<Self, HttpClientError> {
|
||||||
|
let mut middleware_stack: Vec<Arc<dyn Middleware>> = vec![];
|
||||||
|
let reqwest_client_builder = ClientBuilder::new().user_agent(
|
||||||
|
config
|
||||||
|
.user_agent
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| get_random_mobile_ua()),
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let reqwest_client_builder =
|
||||||
|
reqwest_client_builder.redirect(reqwest::redirect::Policy::none());
|
||||||
|
|
||||||
|
let reqwest_client = reqwest_client_builder.build()?;
|
||||||
|
let mut reqwest_with_middleware_builder = ClientWithMiddlewareBuilder::new(reqwest_client);
|
||||||
|
|
||||||
|
{
|
||||||
|
let tracing_middleware = Arc::new(TracingMiddleware::default());
|
||||||
|
|
||||||
|
middleware_stack.push(tracing_middleware.clone());
|
||||||
|
|
||||||
|
reqwest_with_middleware_builder =
|
||||||
|
reqwest_with_middleware_builder.with_arc(tracing_middleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(ref x) = config.exponential_backoff_max_retries {
|
||||||
|
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(*x);
|
||||||
|
|
||||||
|
let retry_transient_middleware =
|
||||||
|
Arc::new(RetryTransientMiddleware::new_with_policy(retry_policy));
|
||||||
|
|
||||||
|
middleware_stack.push(retry_transient_middleware.clone());
|
||||||
|
|
||||||
|
reqwest_with_middleware_builder =
|
||||||
|
reqwest_with_middleware_builder.with_arc(retry_transient_middleware);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
if let (None, None, None, None) = (
|
||||||
|
config.leaky_bucket_initial_tokens.as_ref(),
|
||||||
|
config.leaky_bucket_refill_tokens.as_ref(),
|
||||||
|
config.leaky_bucket_refill_interval.as_ref(),
|
||||||
|
config.leaky_bucket_max_tokens.as_ref(),
|
||||||
|
) {
|
||||||
|
} else {
|
||||||
|
let mut rate_limiter_builder = RateLimiter::builder();
|
||||||
|
|
||||||
|
if let Some(ref x) = config.leaky_bucket_max_tokens {
|
||||||
|
rate_limiter_builder.max(*x as usize);
|
||||||
|
}
|
||||||
|
if let Some(ref x) = config.leaky_bucket_initial_tokens {
|
||||||
|
rate_limiter_builder.initial(*x as usize);
|
||||||
|
}
|
||||||
|
if let Some(ref x) = config.leaky_bucket_refill_tokens {
|
||||||
|
rate_limiter_builder.refill(*x as usize);
|
||||||
|
}
|
||||||
|
if let Some(ref x) = config.leaky_bucket_refill_interval {
|
||||||
|
rate_limiter_builder.interval(*x);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rate_limiter = rate_limiter_builder.build();
|
||||||
|
|
||||||
|
let rate_limiter_middleware = Arc::new(RateLimiterMiddleware { rate_limiter });
|
||||||
|
|
||||||
|
middleware_stack.push(rate_limiter_middleware.clone());
|
||||||
|
|
||||||
|
reqwest_with_middleware_builder =
|
||||||
|
reqwest_with_middleware_builder.with_arc(rate_limiter_middleware);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
if let (None, None) = (config.cache_backend.as_ref(), config.cache_preset.as_ref()) {
|
||||||
|
} else {
|
||||||
|
let cache_preset = config.cache_preset.as_ref().cloned().unwrap_or_default();
|
||||||
|
let cache_backend = config
|
||||||
|
.cache_backend
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| match b {
|
||||||
|
HttpClientCacheBackendConfig::CACache { cache_path } => {
|
||||||
|
let path = std::path::PathBuf::from(
|
||||||
|
App::get_working_root().join(cache_path).as_str(),
|
||||||
|
);
|
||||||
|
CacheBackend::new(CACacheManager { path })
|
||||||
|
}
|
||||||
|
HttpClientCacheBackendConfig::Moka { cache_size } => {
|
||||||
|
CacheBackend::new(MokaManager {
|
||||||
|
cache: Arc::new(moka::future::Cache::new(*cache_size)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| CacheBackend::new(MokaManager::default()));
|
||||||
|
|
||||||
|
let http_cache = match cache_preset {
|
||||||
|
HttpClientCachePresetConfig::RFC7234 => HttpCache {
|
||||||
|
mode: CacheMode::Default,
|
||||||
|
manager: cache_backend,
|
||||||
|
options: HttpCacheOptions::default(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let http_cache_middleware = Arc::new(Cache(http_cache));
|
||||||
|
|
||||||
|
middleware_stack.push(http_cache_middleware.clone());
|
||||||
|
|
||||||
|
reqwest_with_middleware_builder =
|
||||||
|
reqwest_with_middleware_builder.with_arc(http_cache_middleware);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let reqwest_with_middleware = reqwest_with_middleware_builder.build();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client: reqwest_with_middleware,
|
||||||
|
middleware_stack,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fork(&self) -> HttpClientFork {
|
||||||
|
let reqwest_client_builder = ClientBuilder::new().user_agent(
|
||||||
|
self.config
|
||||||
|
.user_agent
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| get_random_mobile_ua()),
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let reqwest_client_builder =
|
||||||
|
reqwest_client_builder.redirect(reqwest::redirect::Policy::none());
|
||||||
|
|
||||||
|
HttpClientFork {
|
||||||
|
client_builder: reqwest_client_builder,
|
||||||
|
middleware_stack: self.middleware_stack.clone(),
|
||||||
|
config: self.config.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_fork(fork: HttpClientFork) -> Result<Self, HttpClientError> {
|
||||||
|
let HttpClientFork {
|
||||||
|
client_builder,
|
||||||
|
middleware_stack,
|
||||||
|
config,
|
||||||
|
} = fork;
|
||||||
|
let reqwest_client = client_builder.build()?;
|
||||||
|
let mut reqwest_with_middleware_builder = ClientWithMiddlewareBuilder::new(reqwest_client);
|
||||||
|
|
||||||
|
for m in &middleware_stack {
|
||||||
|
reqwest_with_middleware_builder = reqwest_with_middleware_builder.with_arc(m.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let reqwest_with_middleware = reqwest_with_middleware_builder.build();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client: reqwest_with_middleware,
|
||||||
|
middleware_stack,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpClient {
|
||||||
|
fn default() -> Self {
|
||||||
|
HttpClient::from_config(Default::default()).expect("Failed to create default HttpClient")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClientTrait for HttpClient {}
|
9
apps/recorder/src/fetch/client/mod.rs
Normal file
9
apps/recorder/src/fetch/client/mod.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
pub mod core;
|
||||||
|
pub mod secrecy;
|
||||||
|
|
||||||
|
pub use core::{
|
||||||
|
HttpClient, HttpClientCacheBackendConfig, HttpClientCachePresetConfig, HttpClientConfig,
|
||||||
|
HttpClientError, HttpClientTrait,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use secrecy::{HttpClientCookiesAuth, HttpClientSecrecyDataTrait};
|
55
apps/recorder/src/fetch/client/secrecy.rs
Normal file
55
apps/recorder/src/fetch/client/secrecy.rs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use cookie::Cookie;
|
||||||
|
use reqwest::{ClientBuilder, cookie::Jar};
|
||||||
|
use secrecy::zeroize::Zeroize;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::errors::RecorderError;
|
||||||
|
|
||||||
|
pub trait HttpClientSecrecyDataTrait: Zeroize {
|
||||||
|
fn attach_secrecy_to_client(&self, client_builder: ClientBuilder) -> ClientBuilder {
|
||||||
|
client_builder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct HttpClientCookiesAuth {
|
||||||
|
pub cookie_jar: Arc<Jar>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClientCookiesAuth {
|
||||||
|
pub fn from_cookies(
|
||||||
|
cookies: &str,
|
||||||
|
url: &Url,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
) -> Result<Self, RecorderError> {
|
||||||
|
let cookie_jar = Arc::new(Jar::default());
|
||||||
|
for cookie in Cookie::split_parse(cookies).try_collect::<Vec<_>>()? {
|
||||||
|
cookie_jar.add_cookie_str(&cookie.to_string(), url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
cookie_jar,
|
||||||
|
user_agent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Zeroize for HttpClientCookiesAuth {
|
||||||
|
fn zeroize(&mut self) {
|
||||||
|
self.cookie_jar = Arc::new(Jar::default());
|
||||||
|
self.user_agent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClientSecrecyDataTrait for HttpClientCookiesAuth {
|
||||||
|
fn attach_secrecy_to_client(&self, client_builder: ClientBuilder) -> ClientBuilder {
|
||||||
|
let mut client_builder = client_builder.cookie_provider(self.cookie_jar.clone());
|
||||||
|
if let Some(ref user_agent) = self.user_agent {
|
||||||
|
client_builder = client_builder.user_agent(user_agent);
|
||||||
|
}
|
||||||
|
client_builder
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,12 @@
|
|||||||
use reqwest::IntoUrl;
|
use reqwest::IntoUrl;
|
||||||
|
|
||||||
use super::client::HttpClientTrait;
|
use super::client::HttpClientTrait;
|
||||||
|
use crate::errors::RecorderError;
|
||||||
|
|
||||||
pub async fn fetch_html<T: IntoUrl, H: HttpClientTrait>(
|
pub async fn fetch_html<T: IntoUrl, H: HttpClientTrait>(
|
||||||
client: &H,
|
client: &H,
|
||||||
url: T,
|
url: T,
|
||||||
) -> color_eyre::eyre::Result<String> {
|
) -> Result<String, RecorderError> {
|
||||||
let content = client
|
let content = client
|
||||||
.get(url)
|
.get(url)
|
||||||
.send()
|
.send()
|
||||||
|
@ -2,10 +2,11 @@ use bytes::Bytes;
|
|||||||
use reqwest::IntoUrl;
|
use reqwest::IntoUrl;
|
||||||
|
|
||||||
use super::{bytes::fetch_bytes, client::HttpClientTrait};
|
use super::{bytes::fetch_bytes, client::HttpClientTrait};
|
||||||
|
use crate::errors::RecorderError;
|
||||||
|
|
||||||
pub async fn fetch_image<T: IntoUrl, H: HttpClientTrait>(
|
pub async fn fetch_image<T: IntoUrl, H: HttpClientTrait>(
|
||||||
client: &H,
|
client: &H,
|
||||||
url: T,
|
url: T,
|
||||||
) -> color_eyre::eyre::Result<Bytes> {
|
) -> Result<Bytes, RecorderError> {
|
||||||
fetch_bytes(client, url).await
|
fetch_bytes(client, url).await
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,9 @@ pub mod oidc;
|
|||||||
pub use core::get_random_mobile_ua;
|
pub use core::get_random_mobile_ua;
|
||||||
|
|
||||||
pub use bytes::fetch_bytes;
|
pub use bytes::fetch_bytes;
|
||||||
pub use client::{HttpClient, HttpClientConfig, HttpClientError, HttpClientTrait};
|
pub use client::{
|
||||||
|
HttpClient, HttpClientConfig, HttpClientCookiesAuth, HttpClientError,
|
||||||
|
HttpClientSecrecyDataTrait, HttpClientTrait,
|
||||||
|
};
|
||||||
pub use html::fetch_html;
|
pub use html::fetch_html;
|
||||||
pub use image::fetch_image;
|
pub use image::fetch_image;
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
duration_constructors,
|
duration_constructors,
|
||||||
assert_matches,
|
assert_matches,
|
||||||
unboxed_closures,
|
unboxed_closures,
|
||||||
impl_trait_in_bindings
|
impl_trait_in_bindings,
|
||||||
|
iterator_try_collect
|
||||||
)]
|
)]
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
@ -10,6 +11,7 @@ pub mod auth;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod controllers;
|
pub mod controllers;
|
||||||
pub mod dal;
|
pub mod dal;
|
||||||
|
pub mod errors;
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
pub mod fetch;
|
pub mod fetch;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
|
@ -203,7 +203,7 @@ impl ActiveModel {
|
|||||||
let homepage = build_mikan_episode_homepage(
|
let homepage = build_mikan_episode_homepage(
|
||||||
ctx.get_mikan_client().base_url().clone(),
|
ctx.get_mikan_client().base_url().clone(),
|
||||||
&item.mikan_episode_id,
|
&item.mikan_episode_id,
|
||||||
)?;
|
);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
mikan_episode_id: ActiveValue::Set(Some(item.mikan_episode_id)),
|
mikan_episode_id: ActiveValue::Set(Some(item.mikan_episode_id)),
|
||||||
|
@ -274,7 +274,7 @@ impl Model {
|
|||||||
mikan_base_url.clone(),
|
mikan_base_url.clone(),
|
||||||
&mikan_bangumi_id,
|
&mikan_bangumi_id,
|
||||||
Some(&mikan_fansub_id),
|
Some(&mikan_fansub_id),
|
||||||
)?;
|
);
|
||||||
let bgm_rss_link = build_mikan_bangumi_rss_link(
|
let bgm_rss_link = build_mikan_bangumi_rss_link(
|
||||||
mikan_base_url.clone(),
|
mikan_base_url.clone(),
|
||||||
&mikan_bangumi_id,
|
&mikan_bangumi_id,
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
use crate::{
|
||||||
|
app::AppContextExt,
|
||||||
|
extract::mikan::{
|
||||||
|
MikanAuthSecrecy, web_extract::extract_mikan_bangumis_meta_from_my_bangumi_page,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct CreateMikanRSSFromMyBangumiTask {
|
pub struct CreateMikanRSSFromMyBangumiTask {
|
||||||
subscriber_id: i32,
|
subscriber_id: i32,
|
||||||
task_id: String,
|
task_id: String,
|
||||||
cookie: SecretString,
|
auth_secrecy: MikanAuthSecrecy,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@ -20,7 +26,21 @@ impl Task for CreateMikanRSSFromMyBangumiTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&self, _app_context: &AppContext, _vars: &task::Vars) -> Result<()> {
|
async fn run(&self, app_context: &AppContext, _vars: &task::Vars) -> Result<()> {
|
||||||
|
let mikan_client = app_context
|
||||||
|
.get_mikan_client()
|
||||||
|
.fork_with_auth(self.auth_secrecy.clone())?;
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
let _bangumi_metas = extract_mikan_bangumis_meta_from_my_bangumi_page(
|
||||||
|
&mikan_client,
|
||||||
|
mikan_client
|
||||||
|
.base_url()
|
||||||
|
.join("/Home/MyBangumi")
|
||||||
|
.map_err(loco_rs::Error::wrap)?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,10 +3,8 @@ use tracing_subscriber::EnvFilter;
|
|||||||
|
|
||||||
pub fn init_testing_tracing(level: Level) {
|
pub fn init_testing_tracing(level: Level) {
|
||||||
let crate_name = env!("CARGO_PKG_NAME");
|
let crate_name = env!("CARGO_PKG_NAME");
|
||||||
let filter = EnvFilter::new(format!(
|
let level = level.as_str().to_lowercase();
|
||||||
"{}[]={}",
|
let filter = EnvFilter::new(format!("{}[]={}", crate_name, level))
|
||||||
crate_name,
|
.add_directive(format!("mockito[]={}", level).parse().unwrap());
|
||||||
level.as_str().to_lowercase()
|
|
||||||
));
|
|
||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
}
|
}
|
||||||
|
641
apps/recorder/tests/resources/mikan/MyBangumi-noauth.htm
Normal file
641
apps/recorder/tests/resources/mikan/MyBangumi-noauth.htm
Normal file
@ -0,0 +1,641 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="keywords" content="新番,动漫,动漫下載,新番下载,animation,bangumi,动画,蜜柑计划,Mikan Project" />
|
||||||
|
<meta name="description" content="蜜柑计划:新一代的动漫下载站" />
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<!-- 若用户有Google Chrome Frame,那么ie浏览时让IE使用chrome内核 -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
|
|
||||||
|
<!-- 若是双核浏览器,默认webkit渲染(chrome) -->
|
||||||
|
<meta name="renderer" content="webkit">
|
||||||
|
<title>Mikan Project - 我的番组</title>
|
||||||
|
|
||||||
|
<!-- here put import css lib -->
|
||||||
|
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.css?v=7s5uDGW3AHqw6xtJmNNtr-OBRJUlgkNJEo78P4b0yRw" />
|
||||||
|
<link rel="stylesheet" href="/lib/font-awesome/css/font-awesome.min.css?v=3dkvEK0WLHRJ7_Csr0BZjAWxERc5WH7bdeUya2aXxdU" />
|
||||||
|
<link rel="stylesheet" href="/css/thirdparty.min.css?v=c2SZy6n-55iljz60XCAALXejEZvjc43kgwamU5DAYUU" />
|
||||||
|
<link rel="stylesheet" href="/css/animate.min.css?v=w_eXqGX0NdMPQ0LZNhdQ8B-DQMYAxelvLoIP39dzmus" />
|
||||||
|
<link rel="stylesheet" href="/css/mikan.min.css?v=aupBMgBgKRB5chTb5fl8lvHpN3OqX67_gKg3lXZewRw" />
|
||||||
|
|
||||||
|
<script src="/lib/jquery/dist/jquery.min.js?v=BbhdlvQf_xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44"></script>
|
||||||
|
<script src="/lib/bootstrap/dist/js/bootstrap.min.js?v=KXn5puMvxCw-dAYznun-drMdG1IFl3agK0p_pqT9KAo"></script>
|
||||||
|
<script src="/js/thirdparty.min.js?v=NsK_w5fw7Nm4ZPm4eZDgsivasZNgT6ArhIjmj-bRnR0"></script>
|
||||||
|
<script src="/js/darkreader.min.js?v=Lr_8XODLEDSPtT6LqaeLKzREs4jocJUzV8HvQPItIic"></script>
|
||||||
|
<script src="/js/ScrollMagic.min.js?v=1xuIM3UJWEZX_wWN9zrA8W7CWukfsMaEqb759CeHo3U"></script>
|
||||||
|
<script src="/js/jquery.ScrollMagic.min.js?v=SyygQh9gWWfvyS13QwI0SKGAQyHDachlaigiK4X59iw"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<link rel="icon" href="/images/favicon.ico?v=2" />
|
||||||
|
<link rel="apple-touch-icon" href="\Images\apple-touch-icon.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="\Images\apple-touch-icon-152x152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="\Images\apple-touch-icon-180x180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="144x144" href="\Images\apple-touch-icon-144x144.png">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function (i, s, o, g, r, a, m) {
|
||||||
|
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
|
||||||
|
(i[r].q = i[r].q || []).push(arguments)
|
||||||
|
}, i[r].l = 1 * new Date(); a = s.createElement(o),
|
||||||
|
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
|
||||||
|
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
|
||||||
|
|
||||||
|
ga('create', 'UA-8911610-8', 'auto');
|
||||||
|
ga('send', 'pageview');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="main">
|
||||||
|
<div id="sk-header" class="hidden-xs hidden-sm">
|
||||||
|
<div id="sk-top-nav" class="container">
|
||||||
|
<a id="logo" href="/" style="width:205px;"><img id="mikan-pic" src="/images/mikan-pic.png" /><img src="/images/mikan-text.svg" style="height:30px;" /></a>
|
||||||
|
<div id="nav-list">
|
||||||
|
<ul class="list-inline nav-ul">
|
||||||
|
<li class="">
|
||||||
|
<div class="sk-col"><a href="/"><i class="fa fa-home fa-lg"></i>主页</a></div>
|
||||||
|
</li>
|
||||||
|
<li class="active">
|
||||||
|
<div class="sk-col"><a href="/Home/MyBangumi"><i class="fa fa-rss fa-lg"></i>订阅</a></div>
|
||||||
|
</li>
|
||||||
|
<li class="">
|
||||||
|
<div class="sk-col"><a href="/Home/Classic"><i class="fa fa-slack fa-lg"></i>列表</a></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="search-form">
|
||||||
|
<form method="get" action="/Home/Search">
|
||||||
|
<div class="form-group has-feedback">
|
||||||
|
<label for="search" class="sr-only">搜索</label>
|
||||||
|
<input type="text" class="form-control input-sm" name="searchstr" id="header-search" placeholder="搜索">
|
||||||
|
<span class="glyphicon glyphicon-search form-control-feedback"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<section id="login">
|
||||||
|
<div id="user-login" class="pull-right">
|
||||||
|
<a href="/Account/Register" class="text-right">注册</a>
|
||||||
|
<a onclick="ToggleActive(this)" class="text-right" data-toggle="popover-x" data-target="#login-popover" data-placement="bottom bottom-right" rel="popover">登录</a>
|
||||||
|
<form action="/Account/Login?ReturnUrl=%2FHome%2FMyBangumi" class="form-vertical" method="post"> <div id="login-popover" class="popover popover-default">
|
||||||
|
<div class="arrow"></div>
|
||||||
|
<div id="login-popover-conent">
|
||||||
|
<div id="login-popover-input">
|
||||||
|
<div id="login-popover-div-username">
|
||||||
|
<img src="/images/user-name_login_icon.png" />
|
||||||
|
<input type="text" placeholder="用户名" id="login-popover-input-username" name="UserName" />
|
||||||
|
</div>
|
||||||
|
<div id="login-popover-div-password">
|
||||||
|
<img src="/images/password_login_icon.png" style="margin-left:3px;" />
|
||||||
|
<input type="password" placeholder="密码" id="login-popover-input-password" name="Password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="login-popover-submit" type="submit" class="btn">登 录</button>
|
||||||
|
<div class="checkbox" id="login-popover-password">
|
||||||
|
<label id="login-popover-remember-password"><input type="checkbox" value="true" name="RememberMe"><input type="hidden" value="false" name="RememberMe">记住密码</label>
|
||||||
|
<div id="login-popover-forget-password" class="pull-right"><a href="/Account/ForgotPassword" class="forget-password">忘记密码</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a id="login-popover-create-account">还没有账号?赶紧来注册一个吧~</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8MyNMqFNaC9JmJW13PvY-93GWOqu6SxfgR0S2fsWVSk41_SQEknnNNxKBZ6pMaRs2yMRKWarMeS2av33qkf82qO9KD9P_gm2ovTbvQyHwNtlRoBJBF1JSt_ZAdLZnvfTjfJ8q_Ih0ADcAt3lwJXdPxQ" /></form> </div>
|
||||||
|
<script>
|
||||||
|
var AdvancedSubscriptionEnabled = false;
|
||||||
|
</script>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="ribbon">
|
||||||
|
<span class="ribbon-color1"></span>
|
||||||
|
<span class="ribbon-color2"></span>
|
||||||
|
<span class="ribbon-color3"></span>
|
||||||
|
<span class="ribbon-color4"></span>
|
||||||
|
<span class="ribbon-color5"></span>
|
||||||
|
<span class="ribbon-color6"></span>
|
||||||
|
<span class="ribbon-color7"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-home-nav hidden-lg hidden-md" id="sk-mobile-header">
|
||||||
|
<div class="m-home-tool-left clickable" data-toggle="modal" data-target="#modal-nav">
|
||||||
|
<i class="fa fa-bars" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<div class="m-home-tool-left"></div>
|
||||||
|
<div style="text-align: center; height:100%;flex:1;">
|
||||||
|
<a href="/" style="text-decoration:none">
|
||||||
|
<img src="/images/mikan-pic.png" style="height: 3rem;margin-top: 0.5rem;">
|
||||||
|
<img src="/images/mikan-text.png" style="height: 1.5rem;margin-top: 0.5rem;">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="m-home-tool-right clickable" data-toggle="modal" data-target="#modal-login">
|
||||||
|
<i class="fa fa-user" aria-hidden="true" style="margin-right: 1rem;"></i>
|
||||||
|
</div>
|
||||||
|
<div class="m-home-tool-right clickable" onclick="ShowNavSearch()">
|
||||||
|
<i class="fa fa-search" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-nav-search" style="width: 100%;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<form method="get" action="/Home/Search">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-addon" id="sizing-addon1" style="border: none;background-color: white;">
|
||||||
|
<i class="fa fa-search" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<input type="text" class="form-control" placeholder="搜索" name="searchstr" aria-describedby="sizing-addon1" style="border: none;font-size:16px;">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div style="width: 4rem;" onclick="HideNavSearch()">
|
||||||
|
<span style="font-size: 1.25rem;">取消</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="sk-container" class="container hidden-sm hidden-xs">
|
||||||
|
<section class="main-content">
|
||||||
|
|
||||||
|
<div id="an-list">
|
||||||
|
<div id="an-list-nav">
|
||||||
|
<div class="sk-col my-rss">我的字幕组订阅更新</div>
|
||||||
|
<div class="sk-col my-rss-date js-episode-update active" data-predate="1" data-enddate="1" data-maximumitems="10">昨天至今</div>
|
||||||
|
<div class="sk-col my-rss-date js-episode-update" data-predate="0" data-enddate="1" data-maximumitems="10">今天</div>
|
||||||
|
<div class="sk-col my-rss-date js-episode-update" data-predate="1" data-enddate="0" data-maximumitems="10">昨天</div>
|
||||||
|
<div class="sk-col my-rss-date js-episode-update" data-predate="2" data-enddate="1" data-maximumitems="10">近三天</div>
|
||||||
|
<div class="sk-col my-rss-date js-episode-update" data-predate="-1" data-enddate="-1" data-maximumitems="10">OVA/剧场版 (beta)</div>
|
||||||
|
|
||||||
|
<div class="sk-col my-rss-date indent-btn active" onclick="ToggleEpisodeUpdates(this)"><i class="fa fa-angle-down fa-2x"></i></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="an-episode-updates">
|
||||||
|
|
||||||
|
<div class="no-episode-update">
|
||||||
|
<img src="/images/mikan-pic.png" style="height:150px;" /><img src="/images/no-episode-update.png" style="height:60px;margin-left:30px;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div style="margin-top: 10px; margin-bottom: -10px;">
|
||||||
|
|
||||||
|
|
||||||
|
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-xs hidden-sm">
|
||||||
|
<a href="https://shop119340084.taobao.com/?mm_sycmid=1_150417_dba461f2e2f73a9ea2a8fa11f33a1aee" onclick="ga('send', 'event', 'sswj_lg', 'clicked', 'ad');">
|
||||||
|
<img src="/images/SSWJ/sswj8_lg.jpg" style='height: 100%; width: 100%; object-fit: contain' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-lg hidden-md">
|
||||||
|
<a href="https://m.tb.cn/h.g0X5kru9wgYTRsp?mm_sycmid=1_150416_5914d148315f48d5297c751b84bac595" onclick="ga('send', 'event', 'sswj_sm', 'clicked', 'ad');">
|
||||||
|
<img src="/images/SSWJ/sswj8_sm.jpg" style='height: 100%; width: 100%; object-fit: contain' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="date-select-row row" style="padding-left:35px">
|
||||||
|
|
||||||
|
<ul class="navbar-nav date-select">
|
||||||
|
<li class="sk-col dropdown date-btn">
|
||||||
|
<div class="dropdown-toggle btn btn-default dropdown-custom" data-toggle="dropdown">
|
||||||
|
<div class="sk-col glyphicon glyphicon-calendar"></div>
|
||||||
|
<div class="sk-col date-text"> 2025 冬季番组 <span class="caret"></span> </div>
|
||||||
|
</div>
|
||||||
|
<ul class="dropdown-menu" role="menu">
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2025</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2025" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2024</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2023</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2022</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2021</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2020</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2019</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2018</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2017</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2016</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2015</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2014</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="秋">秋季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="夏">夏季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="春">春季番组</a></li>
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="冬">冬季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="dropdown-submenu">
|
||||||
|
<a class="default-cursor">2013</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2013" data-season="秋">秋季番组</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sk-body">
|
||||||
|
|
||||||
|
<svg width="0" height="0">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip-triangle-1">
|
||||||
|
<polygon points="0 15,80 15,100 0,120 15,1120 15,1120 370,0 370"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip-triangle-2">
|
||||||
|
<polygon points="0 15,310 15,330 0,350 15,1120 15,1120 370,0 370"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip-triangle-3">
|
||||||
|
<polygon points="0 15,540 15,560 0,580 15,1120 15,1120 370,0 370"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip-triangle-4">
|
||||||
|
<polygon points="0 15,770 15,790 0,810 15,1120 15,1120 370,0 370"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip-triangle-5">
|
||||||
|
<polygon points="0 15,995 15,1015 0,1035 15,1120 15,1120 370,0 370"/>
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient id="row-bg-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#e4dedf" />
|
||||||
|
<stop offset="100%" stop-color="#cbbcc4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="sk-bangumi">
|
||||||
|
|
||||||
|
<div class="no-subscribe-bangumi"> >_< 您还没有订阅任何番组,快去<a href="/">首页</a>添加订阅吧</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sk-mobile-container" class="m-home hidden-lg hidden-md">
|
||||||
|
<div style="height:16rem;">
|
||||||
|
<!-- Indicators -->
|
||||||
|
<div id="myCarousel" class="carousel slide carousel-fade" data-ride="carousel" style="padding-top:3.9rem;">
|
||||||
|
<!-- Wrapper for slides -->
|
||||||
|
<div class="carousel-inner" role="listbox">
|
||||||
|
<div class="item active carousel-bg" style="background-image:url('/images/Promotion/202501/2025_01_bangumi_3519.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3519', '_self');">
|
||||||
|
</div>
|
||||||
|
<div class="item carousel-bg" style="background-image: url('/images/Promotion/202501/2025_01_bangumi_3530.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3530', '_self');">
|
||||||
|
</div>
|
||||||
|
<div class="item carousel-bg" style="background-image: url('/images/Promotion/202501/2025_01_bangumi_3518.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3518', '_self');">
|
||||||
|
</div>
|
||||||
|
<div class="item carousel-bg" style="background-image: url('/images/Promotion/202501/2025_01_bangumi_3539.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3539', '_self');">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-indicators" style="z-index:2;">
|
||||||
|
<img src="./../images/mikan-subscribe.png" style="border-radius: 5rem;width: 5rem;height: 5rem;margin-top: -2.5rem;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="m-home-subscribe">
|
||||||
|
<img src="./../images/mikan-subscribe.png" style="border-radius: 5rem;width: 5rem;height: 5rem;margin-top: -2.5rem;">
|
||||||
|
<div class="m-title">
|
||||||
|
<span class="title">我的订阅更新</span>
|
||||||
|
<div>
|
||||||
|
<div class="dropdown material-dropdown">
|
||||||
|
<button class="dropdown-toggle material-dropdown__btn" data-toggle="dropdown">
|
||||||
|
<span style="color: #3bc0c3;font-size: 1.25rem;" class="js-mobile-episode-update-date">昨天至今</span>
|
||||||
|
<span><i class="fa fa-angle-down" aria-hidden="true"></i></span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu material-dropdown-menu">
|
||||||
|
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="1" data-enddate="1" data-maximumitems="6">昨天至今</a></li>
|
||||||
|
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="0" data-enddate="1" data-maximumitems="6">今天</a></li>
|
||||||
|
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="1" data-enddate="0" data-maximumitems="6">昨天</a></li>
|
||||||
|
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="2" data-enddate="1" data-maximumitems="6">近三天</a></li>
|
||||||
|
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="6" data-enddate="1" data-maximumitems="6">最近一周</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-subscribe-list">
|
||||||
|
<div style="height: 3rem;color: #888;border: none;font-size: 1.25rem;">
|
||||||
|
暂无更新,添加更多订阅吧!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div style="margin-top: 0px; margin-bottom: 10px;">
|
||||||
|
|
||||||
|
|
||||||
|
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-xs hidden-sm">
|
||||||
|
<a href="https://shop119340084.taobao.com/?mm_sycmid=1_150417_dba461f2e2f73a9ea2a8fa11f33a1aee" onclick="ga('send', 'event', 'sswj_lg', 'clicked', 'ad');">
|
||||||
|
<img src="/images/SSWJ/sswj7_lg.jpg" style='height: 100%; width: 100%; object-fit: contain' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-lg hidden-md">
|
||||||
|
<a href="https://m.tb.cn/h.g0X5kru9wgYTRsp?mm_sycmid=1_150416_5914d148315f48d5297c751b84bac595" onclick="ga('send', 'event', 'sswj_sm', 'clicked', 'ad');">
|
||||||
|
<img src="/images/SSWJ/sswj7_sm.jpg" style='height: 100%; width: 100%; object-fit: contain' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-home-week">
|
||||||
|
|
||||||
|
<div class="m-home-week-item" style="background-color:white;">
|
||||||
|
<div class="title">
|
||||||
|
<span class="monday">我的订阅</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/css/bootstrap-toggle-round.css?v=ZHx5lTKQuvWMGeQuWNqkSQvuRt2u8x7w0URRg4MhfUo" />
|
||||||
|
<div class="modal modal-fullscreen fade" id="modal-nav" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true" style="background-color:#3bc0c3;">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-body" style="margin: auto;width:100%;">
|
||||||
|
<div class="m-tool">
|
||||||
|
<span class="m-close clickable"><i class="fa fa-times" aria-hidden="true" data-toggle="modal" data-target="#modal-nav"></i></span>
|
||||||
|
<div class="m-tool-toolbar">
|
||||||
|
<img src="/images/mikan-pic.png" style="width: 3rem;">
|
||||||
|
<img src="/images/mikan-text.png" style="width: 7rem;">
|
||||||
|
</div>
|
||||||
|
<div class="m-tool-list">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="link">主页</a></li>
|
||||||
|
<li class="m-tool-search-change"><a href="/Home/MyBangumi" class="link">订阅</a></li>
|
||||||
|
<li onclick="tool.clickSearch()" class="m-tool-search-change">
|
||||||
|
<i class="fa fa-search" aria-hidden="true"></i> 搜索站内
|
||||||
|
</li>
|
||||||
|
<li class="m-tool-search-input">
|
||||||
|
<form method="get" action="/Home/Search">
|
||||||
|
<div style="display: flex;height: 100%;">
|
||||||
|
<input type="text" class="form-control" name="searchstr" style="font-size:16px;" />
|
||||||
|
<span style="width: 5rem;" onclick="tool.resetSearch()">取消</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal modal-fullscreen fade" id="modal-login" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true" style="background-color:#edf1f2;">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-body" style="margin: auto;width:100%;height:85vh;">
|
||||||
|
<div class="m-login">
|
||||||
|
<span class="m-left clickable"><i class="fa fa-angle-left" aria-hidden="true" data-toggle="modal" data-target="#modal-login"></i></span>
|
||||||
|
|
||||||
|
<div class="m-tool-title">
|
||||||
|
登陆mikan账号
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center;margin-top: 2rem;">
|
||||||
|
<img src="/images/mikan-pic.png" style="width: 6rem;">
|
||||||
|
</div>
|
||||||
|
<form action="/Account/Login?ReturnUrl=%2FHome%2FMyBangumi" method="post"> <div>
|
||||||
|
<input type="text" class="form-control" aria-label="..." placeholder="用户名" name="UserName">
|
||||||
|
<input type="password" class="form-control" aria-label="..." placeholder="密码" name="Password">
|
||||||
|
</div>
|
||||||
|
<button class="form-control" type="submit">登录</button>
|
||||||
|
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8MyNMqFNaC9JmJW13PvY-93GWOqu6SxfgR0S2fsWVSk41_SQEknnNNxKBZ6pMaRs2yMRKWarMeS2av33qkf82qO9KD9P_gm2ovTbvQyHwNtlRoBJBF1JSt_ZAdLZnvfTjfJ8q_Ih0ADcAt3lwJXdPxQ" /></form> <div class="m-goto-registry">
|
||||||
|
<a href="/Account/Register" class="w-other-c" style="color:#3bc0c3">立即注册</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="footer hidden-xs hidden-sm">
|
||||||
|
<div id="sk-footer" class="container text-center">
|
||||||
|
<div>Powered by Mikan Project <a href="/Home/Contact" target="_blank">联系我们</a></div>
|
||||||
|
<div>Cooperate by PlaymateCat@Lisa</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var tool = {};
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
var inputPEl = $('.m-tool-search-input');
|
||||||
|
var inputEl = inputPEl.find('input');
|
||||||
|
var changeEl = $('.m-tool-search-change');
|
||||||
|
inputPEl.hide();
|
||||||
|
tool.clickSearch = clickSearch;
|
||||||
|
tool.resetSearch = resetSearch;
|
||||||
|
|
||||||
|
function clickSearch() {
|
||||||
|
changeEl.hide();
|
||||||
|
inputPEl.show();
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
changeEl.show();
|
||||||
|
inputPEl.hide();
|
||||||
|
inputEl.val('');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var pageUtil;
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
pageUtil = {
|
||||||
|
isMobile: isMobile
|
||||||
|
};
|
||||||
|
|
||||||
|
function isMobile() {
|
||||||
|
var check = false;
|
||||||
|
(function (a) {
|
||||||
|
if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true;
|
||||||
|
})(navigator.userAgent || navigator.vendor || window.opera);
|
||||||
|
return check;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
//detect if page is mobile
|
||||||
|
if (pageUtil.isMobile()) {
|
||||||
|
document.getElementsByTagName('html')[0].style['font-size'] = window.innerWidth / 32 + 'px';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!-- here put your own javascript -->
|
||||||
|
|
||||||
|
|
||||||
|
<script src="/js/mikan.min.js?v=7USd_hfRE7KH46vQBdF29boa3ENWKMVFRTyD9a8XEDg"></script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</html>
|
Loading…
Reference in New Issue
Block a user