feature: rewrite season subscription extractor

This commit is contained in:
master 2025-05-02 02:23:23 +08:00
parent 4301f1dbab
commit dbded94324
51 changed files with 8181 additions and 6035 deletions

412
Cargo.lock generated
View File

@ -27,6 +27,41 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array 0.14.7",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.7.8"
@ -158,6 +193,56 @@ version = "1.0.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
[[package]]
name = "apalis"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bf157b59923258974a886572a18fe47b401daeca43b44c719b73736d8788840"
dependencies = [
"apalis-core",
"futures",
"pin-project-lite",
"serde",
"thiserror 2.0.12",
"tower",
"tracing",
"tracing-futures",
]
[[package]]
name = "apalis-core"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbbc8dc67f007145277cb5977c730c4fa7fb07244e83d69d1c5a43cb4d124fa"
dependencies = [
"futures",
"futures-timer",
"pin-project-lite",
"serde",
"serde_json",
"thiserror 2.0.12",
"tower",
"ulid",
]
[[package]]
name = "apalis-sql"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34757d9408f39656451c524ca10fe6331d59aaf25cda60bd70d677a4213efead"
dependencies = [
"apalis-core",
"async-stream",
"chrono",
"futures",
"futures-lite",
"log",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.12",
]
[[package]]
name = "arc-swap"
version = "1.7.1"
@ -648,6 +733,15 @@ dependencies = [
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -817,7 +911,7 @@ version = "13.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b"
dependencies = [
"digest",
"digest 0.10.7",
"either",
"futures",
"hex 0.4.3",
@ -829,7 +923,7 @@ dependencies = [
"serde_derive",
"serde_json",
"sha1",
"sha2",
"sha2 0.10.8",
"ssri",
"tempfile",
"thiserror 1.0.69",
@ -871,6 +965,30 @@ dependencies = [
"version_check",
]
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.40"
@ -908,6 +1026,17 @@ dependencies = [
"phf_codegen",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
name = "clap"
version = "4.5.35"
@ -948,6 +1077,22 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "cocoon"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24bf1b609cc3fcf6785a2305e450e3dae64cc0f28854ae0b83a6487a8eeaa64f"
dependencies = [
"aes-gcm",
"chacha20poly1305",
"hmac 0.11.0",
"pbkdf2",
"rand 0.8.5",
"sha2 0.9.9",
"thiserror 1.0.69",
"zeroize",
]
[[package]]
name = "colorchoice"
version = "1.0.3"
@ -1208,6 +1353,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "crypto-mac"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e"
dependencies = [
"generic-array 0.14.7",
"subtle",
]
[[package]]
name = "cssparser"
version = "0.33.0"
@ -1269,6 +1424,15 @@ version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@ -1278,7 +1442,7 @@ dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
@ -1443,13 +1607,22 @@ version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d"
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"block-buffer 0.10.4",
"const-oid",
"crypto-common",
"subtle",
@ -1604,7 +1777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
@ -1630,7 +1803,7 @@ dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"sha2 0.10.8",
"subtle",
"zeroize",
]
@ -1658,7 +1831,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"digest 0.10.7",
"ff",
"generic-array 0.14.7",
"group",
@ -1768,7 +1941,6 @@ dependencies = [
"axum",
"axum-extra",
"bytes",
"cookie",
"fastrand",
"http-cache",
"http-cache-reqwest",
@ -1780,6 +1952,7 @@ dependencies = [
"reqwest-middleware",
"reqwest-retry",
"reqwest-tracing",
"reqwest_cookie_store",
"serde",
"serde_json",
"serde_with",
@ -1972,6 +2145,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
@ -2110,6 +2296,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gimli"
version = "0.31.1"
@ -2175,7 +2371,7 @@ dependencies = [
"parking_lot 0.12.3",
"portable-atomic",
"quanta",
"rand 0.9.0",
"rand 0.9.1",
"smallvec",
"spinning_top",
"web-time",
@ -2314,7 +2510,17 @@ version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
"hmac 0.12.1",
]
[[package]]
name = "hmac"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b"
dependencies = [
"crypto-mac",
"digest 0.9.0",
]
[[package]]
@ -2323,7 +2529,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@ -2832,6 +3038,15 @@ dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "insta"
version = "1.42.2"
@ -3396,9 +3611,9 @@ dependencies = [
[[package]]
name = "lightningcss"
version = "1.0.0-alpha.65"
version = "1.0.0-alpha.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c84f971730745f4aaac013b6cf4328baf1548efc973c0d95cfd843a3c1ca07af"
checksum = "9a73ffa17de66534e4b527232f44aa0a89fad22c4f4e0735f9be35494f058e54"
dependencies = [
"ahash 0.8.11",
"bitflags 2.9.0",
@ -3573,7 +3788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@ -3686,7 +3901,7 @@ dependencies = [
"hyper",
"hyper-util",
"log",
"rand 0.9.0",
"rand 0.9.1",
"regex",
"serde_json",
"serde_urlencoded",
@ -3957,7 +4172,7 @@ dependencies = [
"serde",
"serde_json",
"serde_path_to_error",
"sha2",
"sha2 0.10.8",
"thiserror 1.0.69",
"url",
]
@ -3977,6 +4192,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "opendal"
version = "0.53.0"
@ -4013,7 +4234,7 @@ dependencies = [
"chrono",
"dyn-clone",
"ed25519-dalek",
"hmac",
"hmac 0.12.1",
"http",
"itertools 0.10.5",
"log",
@ -4028,7 +4249,7 @@ dependencies = [
"serde_path_to_error",
"serde_plain",
"serde_with",
"sha2",
"sha2 0.10.8",
"subtle",
"thiserror 1.0.69",
"url",
@ -4147,7 +4368,7 @@ dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
"sha2 0.10.8",
]
[[package]]
@ -4159,14 +4380,14 @@ dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
"sha2 0.10.8",
]
[[package]]
name = "parcel_selectors"
version = "0.28.1"
version = "0.28.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dccbc6fb560df303a44e511618256029410efbc87779018f751ef12c488271fe"
checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196"
dependencies = [
"bitflags 2.9.0",
"cssparser 0.33.0",
@ -4292,6 +4513,17 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05894bce6a1ba4be299d0c5f29563e08af2bc18bb7d48313113bed71e904739"
dependencies = [
"crypto-mac",
"hmac 0.11.0",
"sha2 0.9.9",
]
[[package]]
name = "pear"
version = "0.2.9"
@ -4382,7 +4614,7 @@ checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0"
dependencies = [
"once_cell",
"pest",
"sha2",
"sha2 0.10.8",
]
[[package]]
@ -4505,6 +4737,29 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.11.0"
@ -4714,7 +4969,7 @@ checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc"
dependencies = [
"bytes",
"getrandom 0.3.2",
"rand 0.9.0",
"rand 0.9.1",
"ring",
"rustc-hash 2.1.1",
"rustls",
@ -4787,13 +5042,12 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
"zerocopy 0.8.24",
]
[[package]]
@ -4867,6 +5121,8 @@ dependencies = [
name = "recorder"
version = "0.1.0"
dependencies = [
"apalis",
"apalis-sql",
"async-graphql",
"async-graphql-axum",
"async-stream",
@ -4877,6 +5133,7 @@ dependencies = [
"bytes",
"chrono",
"clap",
"cocoon",
"ctor",
"dotenv",
"downloader",
@ -4900,13 +5157,16 @@ dependencies = [
"opendal",
"openidconnect",
"quirks_path",
"rand 0.9.1",
"regex",
"reqwest_cookie_store",
"rss",
"rstest",
"scraper",
"sea-orm",
"sea-orm-migration",
"seaography",
"secrecy",
"serde",
"serde_json",
"serde_variant",
@ -5149,6 +5409,20 @@ dependencies = [
"tracing",
]
[[package]]
name = "reqwest_cookie_store"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b36498c7452f11b1833900f31fbb01fc46be20992a50269c88cf59d79f54e9"
dependencies = [
"bytes",
"cookie_store",
"reqwest",
"serde",
"serde_derive",
"url",
]
[[package]]
name = "retry-policies"
version = "0.4.0"
@ -5164,7 +5438,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"hmac 0.12.1",
"subtle",
]
@ -5227,7 +5501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
dependencies = [
"const-oid",
"digest",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
@ -5660,6 +5934,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "secrecy"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
dependencies = [
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@ -5908,7 +6191,7 @@ checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"digest 0.10.7",
]
[[package]]
@ -5919,7 +6202,20 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
dependencies = [
"block-buffer 0.9.0",
"cfg-if",
"cpufeatures",
"digest 0.9.0",
"opaque-debug",
]
[[package]]
@ -5930,7 +6226,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"digest 0.10.7",
]
[[package]]
@ -5963,7 +6259,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"digest 0.10.7",
"rand_core 0.6.4",
]
@ -6153,7 +6449,7 @@ dependencies = [
"rustls-pemfile",
"serde",
"serde_json",
"sha2",
"sha2 0.10.8",
"smallvec",
"thiserror 2.0.12",
"time",
@ -6193,7 +6489,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2",
"sha2 0.10.8",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@ -6218,7 +6514,7 @@ dependencies = [
"bytes",
"chrono",
"crc",
"digest",
"digest 0.10.7",
"dotenvy",
"either",
"futures-channel",
@ -6228,7 +6524,7 @@ dependencies = [
"generic-array 0.14.7",
"hex 0.4.3",
"hkdf",
"hmac",
"hmac 0.12.1",
"itoa",
"log",
"md-5",
@ -6240,7 +6536,7 @@ dependencies = [
"rust_decimal",
"serde",
"sha1",
"sha2",
"sha2 0.10.8",
"smallvec",
"sqlx-core",
"stringprep",
@ -6271,7 +6567,7 @@ dependencies = [
"futures-util",
"hex 0.4.3",
"hkdf",
"hmac",
"hmac 0.12.1",
"home",
"itoa",
"log",
@ -6283,7 +6579,7 @@ dependencies = [
"rust_decimal",
"serde",
"serde_json",
"sha2",
"sha2 0.10.8",
"smallvec",
"sqlx-core",
"stringprep",
@ -6327,12 +6623,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082"
dependencies = [
"base64 0.21.7",
"digest",
"digest 0.10.7",
"hex 0.4.3",
"miette",
"serde",
"sha-1",
"sha2",
"sha2 0.10.8",
"thiserror 1.0.69",
"xxhash-rust",
]
@ -6906,6 +7202,7 @@ dependencies = [
"pin-project-lite",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@ -6996,6 +7293,15 @@ dependencies = [
"valuable",
]
[[package]]
name = "tracing-futures"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
dependencies = [
"tracing",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
@ -7055,7 +7361,7 @@ dependencies = [
"http",
"httparse",
"log",
"rand 0.9.0",
"rand 0.9.1",
"sha1",
"thiserror 2.0.12",
"utf-8",
@ -7113,6 +7419,16 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "ulid"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
dependencies = [
"rand 0.9.1",
"web-time",
]
[[package]]
name = "uncased"
version = "0.9.10"
@ -7217,6 +7533,16 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"

View File

@ -11,7 +11,6 @@ resolver = "2"
[workspace.dependencies]
moka = "0.12"
futures = "0.3"
futures-util = "0.3"
quirks_path = "0.1"
snafu = { version = "0.8", features = ["futures"] }
testcontainers = { version = "0.23.3" }
@ -46,7 +45,6 @@ testing-torrents = { path = "./packages/testing-torrents" }
util = { path = "./packages/util" }
fetch = { path = "./packages/fetch" }
downloader = { path = "./packages/downloader" }
recorder = { path = "./apps/recorder" }
[patch.crates-io]
jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" }

View File

@ -1 +1 @@
{"filesOrder":["konobangu"],"selectedList":["konobangu"],"disabledDefalutRules":true}
{"filesOrder":["konobangu"],"selectedList":["konobangu"],"disabledDefalutRules":true,"defalutRules":""}

View File

@ -61,7 +61,7 @@ sea-orm-migration = { version = "1.1", features = ["runtime-tokio-rustls"] }
rss = "2"
fancy-regex = "0.14"
maplit = "1.0.2"
lightningcss = "1.0.0-alpha.65"
lightningcss = "1.0.0-alpha.66"
html-escape = "0.2.13"
opendal = { version = "0.53", features = ["default", "services-fs"] }
zune-image = "0.4.15"
@ -104,11 +104,16 @@ clap = "4.5.31"
ipnetwork = "0.21.1"
typed-builder = "0.21.0"
serde_yaml = "0.9.34"
apalis = { version = "0.7", features = ["limit", "tracing", "catch-panic"] }
apalis-sql = { version = "0.7", features = ["postgres"] }
cocoon = { version = "0.4.3", features = ["getrandom", "thiserror"] }
rand = "0.9.1"
downloader = { workspace = true }
util = { workspace = true }
fetch = { workspace = true }
string-interner = "0.19.0"
secrecy = "0.10.3"
reqwest_cookie_store = "0.8.0"
[dev-dependencies]
serial_test = "3"

View File

@ -1,5 +1,3 @@
use std::sync::Arc;
use clap::{Parser, command};
use super::{AppContext, core::App, env::Environment};
@ -83,9 +81,8 @@ impl AppBuilder {
)
.await?;
let app_context = Arc::new(
AppContext::new(self.environment.clone(), config, self.working_dir.clone()).await?,
);
let app_context =
AppContext::new(self.environment.clone(), config, self.working_dir.clone()).await?;
Ok(App {
context: app_context,

View File

@ -16,3 +16,7 @@ depth_limit = inf
complexity_limit = inf
[cache]
[crypto]
[task]

View File

@ -9,9 +9,9 @@ use serde::{Deserialize, Serialize};
use super::env::Environment;
use crate::{
auth::AuthConfig, cache::CacheConfig, database::DatabaseConfig, errors::RecorderResult,
extract::mikan::MikanConfig, graphql::GraphQLConfig, logger::LoggerConfig,
storage::StorageConfig, web::WebServerConfig,
auth::AuthConfig, cache::CacheConfig, crypto::CryptoConfig, database::DatabaseConfig,
errors::RecorderResult, extract::mikan::MikanConfig, graphql::GraphQLConfig,
logger::LoggerConfig, storage::StorageConfig, tasks::TaskConfig, web::WebServerConfig,
};
const DEFAULT_CONFIG_MIXIN: &str = include_str!("./default_mixin.toml");
@ -24,9 +24,11 @@ pub struct AppConfig {
pub auth: AuthConfig,
pub storage: StorageConfig,
pub mikan: MikanConfig,
pub crypto: CryptoConfig,
pub graphql: GraphQLConfig,
pub logger: LoggerConfig,
pub database: DatabaseConfig,
pub tasks: TaskConfig,
}
impl AppConfig {

View File

@ -1,11 +1,15 @@
use std::{fmt::Debug, sync::Arc};
use tokio::sync::OnceCell;
use super::{Environment, config::AppConfig};
use crate::{
auth::AuthService, cache::CacheService, database::DatabaseService, errors::RecorderResult,
extract::mikan::MikanClient, graphql::GraphQLService, logger::LoggerService,
storage::StorageService,
auth::AuthService, cache::CacheService, crypto::CryptoService, database::DatabaseService,
errors::RecorderResult, extract::mikan::MikanClient, graphql::GraphQLService,
logger::LoggerService, storage::StorageService, tasks::TaskService,
};
pub trait AppContextTrait: Send + Sync {
pub trait AppContextTrait: Send + Sync + Debug {
fn logger(&self) -> &LoggerService;
fn db(&self) -> &DatabaseService;
fn config(&self) -> &AppConfig;
@ -16,6 +20,8 @@ pub trait AppContextTrait: Send + Sync {
fn storage(&self) -> &StorageService;
fn working_dir(&self) -> &String;
fn environment(&self) -> &Environment;
fn crypto(&self) -> &CryptoService;
fn task(&self) -> &TaskService;
}
pub struct AppContext {
@ -27,8 +33,10 @@ pub struct AppContext {
auth: AuthService,
graphql: GraphQLService,
storage: StorageService,
crypto: CryptoService,
working_dir: String,
environment: Environment,
task: OnceCell<TaskService>,
}
impl AppContext {
@ -36,7 +44,7 @@ impl AppContext {
environment: Environment,
config: AppConfig,
working_dir: impl ToString,
) -> RecorderResult<Self> {
) -> RecorderResult<Arc<Self>> {
let config_cloned = config.clone();
let logger = LoggerService::from_config(config.logger).await?;
@ -45,9 +53,10 @@ impl AppContext {
let storage = StorageService::from_config(config.storage).await?;
let auth = AuthService::from_conf(config.auth).await?;
let mikan = MikanClient::from_config(config.mikan).await?;
let crypto = CryptoService::from_config(config.crypto).await?;
let graphql = GraphQLService::from_config_and_database(config.graphql, db.clone()).await?;
Ok(AppContext {
let ctx = Arc::new(AppContext {
config: config_cloned,
environment,
logger,
@ -58,9 +67,26 @@ impl AppContext {
mikan,
working_dir: working_dir.to_string(),
graphql,
})
crypto,
task: OnceCell::new(),
});
ctx.task
.get_or_try_init(async || {
TaskService::from_config_and_ctx(config.tasks, ctx.clone()).await
})
.await?;
Ok(ctx)
}
}
impl Debug for AppContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "AppContext")
}
}
impl AppContextTrait for AppContext {
fn logger(&self) -> &LoggerService {
&self.logger
@ -92,4 +118,10 @@ impl AppContextTrait for AppContext {
fn environment(&self) -> &Environment {
&self.environment
}
fn crypto(&self) -> &CryptoService {
&self.crypto
}
fn task(&self) -> &TaskService {
self.task.get().expect("task should be set")
}
}

View File

@ -0,0 +1,4 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CryptoConfig {}

View File

@ -0,0 +1,11 @@
#[derive(Debug, snafu::Snafu)]
pub enum CryptoError {
#[snafu(transparent)]
Base64DecodeError { source: base64::DecodeError },
#[snafu(display("CocoonError: {source:?}"), context(false))]
CocoonError { source: cocoon::Error },
#[snafu(transparent)]
FromUtf8Error { source: std::string::FromUtf8Error },
#[snafu(transparent)]
SerdeJsonError { source: serde_json::Error },
}

View File

@ -0,0 +1,9 @@
pub mod config;
pub mod error;
pub mod service;
pub mod userpass;
pub use config::CryptoConfig;
pub use error::CryptoError;
pub use service::CryptoService;
pub use userpass::UserPassCredential;

View File

@ -0,0 +1,65 @@
use base64::prelude::{BASE64_URL_SAFE, *};
use cocoon::Cocoon;
use rand::Rng;
use serde::{Deserialize, Serialize};
use super::CryptoConfig;
use crate::crypto::error::CryptoError;
pub struct CryptoService {
#[allow(dead_code)]
config: CryptoConfig,
}
impl CryptoService {
pub async fn from_config(config: CryptoConfig) -> Result<Self, CryptoError> {
Ok(Self { config })
}
pub fn encrypt_data(&self, data: String) -> Result<String, CryptoError> {
let key = rand::rng().random::<[u8; 32]>();
let mut cocoon = Cocoon::new(&key);
let mut data = data.into_bytes();
let detached_prefix = cocoon.encrypt(&mut data)?;
let mut combined = Vec::with_capacity(key.len() + detached_prefix.len() + data.len());
combined.extend_from_slice(&key);
combined.extend_from_slice(&detached_prefix);
combined.extend_from_slice(&data);
Ok(BASE64_URL_SAFE.encode(combined))
}
pub fn decrypt_data(&self, data: &str) -> Result<String, CryptoError> {
let decoded = BASE64_URL_SAFE.decode(data)?;
let (key, remain) = decoded.split_at(32);
let (detached_prefix, data) = remain.split_at(60);
let mut data = data.to_vec();
let cocoon = Cocoon::new(key);
cocoon.decrypt(&mut data, detached_prefix)?;
String::from_utf8(data).map_err(CryptoError::from)
}
pub fn encrypt_credentials<T: Serialize>(
&self,
credentials: &T,
) -> Result<String, CryptoError> {
let json = serde_json::to_string(credentials)?;
self.encrypt_data(json)
}
pub fn decrypt_credentials<T: for<'de> Deserialize<'de>>(
&self,
encrypted: &str,
) -> Result<T, CryptoError> {
let data = self.decrypt_data(encrypted)?;
serde_json::from_str(&data).map_err(CryptoError::from)
}
}

View File

@ -0,0 +1,19 @@
use std::fmt::Debug;
pub struct UserPassCredential {
pub username: String,
pub password: String,
pub user_agent: Option<String>,
pub cookies: Option<String>,
}
impl Debug for UserPassCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UserPassCredential")
.field("username", &"[Secret]")
.field("password", &"[Secret]")
.field("cookies", &"[Secret]")
.field("user_agent", &self.user_agent)
.finish()
}
}

View File

@ -1,8 +1,8 @@
use std::{ops::Deref, time::Duration};
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, DbBackend,
DbErr, ExecResult, QueryResult, Statement,
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, ExecResult,
QueryResult, Statement,
};
use sea_orm_migration::MigratorTrait;
@ -28,20 +28,21 @@ impl DatabaseService {
let db = Database::connect(opt).await?;
if db.get_database_backend() == DatabaseBackend::Sqlite {
db.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA mmap_size = 134217728;
PRAGMA journal_size_limit = 67108864;
PRAGMA cache_size = 2000;
",
))
.await?;
}
// only support postgres for now
// if db.get_database_backend() == DatabaseBackend::Sqlite {
// db.execute(Statement::from_string(
// DatabaseBackend::Sqlite,
// "
// PRAGMA foreign_keys = ON;
// PRAGMA journal_mode = WAL;
// PRAGMA synchronous = NORMAL;
// PRAGMA mmap_size = 134217728;
// PRAGMA journal_size_limit = 67108864;
// PRAGMA cache_size = 2000;
// ",
// ))
// .await?;
// }
if config.auto_migrate {
Migrator::up(&db, None).await?;

View File

@ -4,13 +4,14 @@ use axum::{
Json,
response::{IntoResponse, Response},
};
use fetch::{FetchError, HttpClientError};
use fetch::{FetchError, HttpClientError, reqwest, reqwest_middleware};
use http::StatusCode;
use serde::{Deserialize, Deserializer, Serialize};
use snafu::Snafu;
use crate::{
auth::AuthError,
crypto::CryptoError,
downloader::DownloaderError,
errors::{OptDynErr, response::StandardErrorResponse},
};
@ -102,6 +103,14 @@ pub enum RecorderError {
ModelEntityNotFound { entity: Cow<'static, str> },
#[snafu(transparent)]
FetchError { source: FetchError },
#[snafu(display("Credential3rdError: {source}"))]
Credential3rdError {
message: String,
#[snafu(source(from(Box<dyn std::error::Error + Send + Sync>, OptDynErr::some)))]
source: OptDynErr,
},
#[snafu(transparent)]
CryptoError { source: CryptoError },
#[snafu(display("{message}"))]
Whatever {
message: String,
@ -195,4 +204,16 @@ impl<'de> Deserialize<'de> for RecorderError {
}
}
impl From<reqwest::Error> for RecorderError {
fn from(error: reqwest::Error) -> Self {
FetchError::from(error).into()
}
}
impl From<reqwest_middleware::Error> for RecorderError {
fn from(error: reqwest_middleware::Error) -> Self {
FetchError::from(error).into()
}
}
pub type RecorderResult<T> = Result<T, RecorderError>;

View File

@ -2,7 +2,10 @@ use url::Url;
pub fn extract_image_src_from_str(image_src: &str, base_url: &Url) -> Option<Url> {
let mut image_url = base_url.join(image_src).ok()?;
image_url.set_query(None);
image_url.set_fragment(None);
if let Some((_, value)) = image_url.query_pairs().find(|(key, _)| key == "webp") {
image_url.set_query(Some(&format!("webp={}", value)));
} else {
image_url.set_query(None);
}
Some(image_url)
}

View File

@ -1,60 +1,204 @@
use std::{fmt::Debug, ops::Deref};
use std::{fmt::Debug, ops::Deref, sync::Arc};
use fetch::{HttpClient, HttpClientTrait, client::HttpClientCookiesAuth};
use fetch::{HttpClient, HttpClientTrait};
use maplit::hashmap;
use sea_orm::DbErr;
use secrecy::SecretBox;
use serde::{Deserialize, Serialize};
use url::Url;
use util::OptDynErr;
use super::MikanConfig;
use crate::errors::RecorderError;
use super::{MikanConfig, constants::MIKAN_ACCOUNT_MANAGE_PAGE_PATH};
use crate::{
app::AppContextTrait,
crypto::UserPassCredential,
errors::{RecorderError, RecorderResult},
extract::mikan::constants::{MIKAN_LOGIN_PAGE_PATH, MIKAN_LOGIN_PAGE_SEARCH},
models::credential_3rd::{self, Credential3rdType},
};
#[derive(Default, Clone, Deserialize, Serialize)]
pub struct MikanAuthSecrecy {
pub cookie: String,
pub user_agent: Option<String>,
pub struct MikanCredentialForm {
pub password: String,
pub username: String,
pub user_agent: String,
}
impl Debug for MikanAuthSecrecy {
pub type MikanAuthSecrecy = SecretBox<MikanCredentialForm>;
impl Debug for MikanCredentialForm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MikanAuthSecrecy")
.field("cookie", &String::from("[secrecy]"))
f.debug_struct("MikanCredentialForm")
.field("username", &String::from("[secrecy]"))
.field("password", &String::from("[secrecy]"))
.field("user_agent", &String::from("[secrecy]"))
.finish()
}
}
impl MikanAuthSecrecy {
pub fn into_cookie_auth(self, url: &Url) -> Result<HttpClientCookiesAuth, RecorderError> {
HttpClientCookiesAuth::from_cookies(&self.cookie, url, self.user_agent)
.map_err(RecorderError::from)
}
}
#[derive(Debug)]
pub struct MikanClient {
http_client: HttpClient,
base_url: Url,
origin_url: Url,
userpass_credential: Option<UserPassCredential>,
}
impl MikanClient {
pub async fn from_config(config: MikanConfig) -> Result<Self, RecorderError> {
let http_client = HttpClient::from_config(config.http_client)?;
let base_url = config.base_url;
let origin_url = Url::parse(&base_url.origin().unicode_serialization())?;
Ok(Self {
http_client,
base_url,
origin_url,
userpass_credential: None,
})
}
pub fn fork_with_auth(&self, secrecy: Option<MikanAuthSecrecy>) -> Result<Self, RecorderError> {
let mut fork = self.http_client.fork();
pub async fn has_login(&self) -> RecorderResult<bool> {
let account_manage_page_url = self.base_url.join(MIKAN_ACCOUNT_MANAGE_PAGE_PATH)?;
let res = self.http_client.get(account_manage_page_url).send().await?;
let status = res.status();
if status.is_success() {
Ok(true)
} else if status.is_redirection()
&& res.headers().get("location").is_some_and(|location| {
location
.to_str()
.is_ok_and(|location_str| location_str.contains(MIKAN_LOGIN_PAGE_PATH))
})
{
Ok(false)
} else {
Err(RecorderError::Credential3rdError {
message: format!("mikan account check has login failed, status = {}", status),
source: None.into(),
})
}
}
if let Some(secrecy) = secrecy {
let cookie_auth = secrecy.into_cookie_auth(&self.base_url)?;
fork = fork.attach_secrecy(cookie_auth);
pub async fn login(&self) -> RecorderResult<()> {
let userpass_credential =
self.userpass_credential
.as_ref()
.ok_or_else(|| RecorderError::Credential3rdError {
message: "mikan login failed, credential required".to_string(),
source: None.into(),
})?;
let login_page_url = {
let mut u = self.base_url.join(MIKAN_LOGIN_PAGE_PATH)?;
u.set_query(Some(MIKAN_LOGIN_PAGE_SEARCH));
u
};
// access login page to get antiforgery cookie
self.http_client
.get(login_page_url.clone())
.send()
.await
.map_err(|error| RecorderError::Credential3rdError {
message: "failed to get mikan login page".to_string(),
source: OptDynErr::some_boxed(error),
})?;
let antiforgery_cookie = {
let cookie_store_lock = self.http_client.cookie_store.clone().ok_or_else(|| {
RecorderError::Credential3rdError {
message: "failed to get cookie store".to_string(),
source: None.into(),
}
})?;
let cookie_store =
cookie_store_lock
.read()
.map_err(|_| RecorderError::Credential3rdError {
message: "failed to read cookie store".to_string(),
source: None.into(),
})?;
cookie_store
.matches(&login_page_url)
.iter()
.find(|cookie| cookie.name().starts_with(".AspNetCore.Antiforgery."))
.map(|cookie| cookie.value().to_string())
}
.ok_or_else(|| RecorderError::Credential3rdError {
message: "mikan login failed, failed to get antiforgery cookie".to_string(),
source: None.into(),
})?;
let login_post_form = hashmap! {
"__RequestVerificationToken".to_string() => antiforgery_cookie,
"UserName".to_string() => userpass_credential.username.clone(),
"Password".to_string() => userpass_credential.password.clone(),
"RememberMe".to_string() => "true".to_string(),
};
let login_post_res = self
.http_client
.post(login_page_url.clone())
.form(&login_post_form)
.send()
.await
.map_err(|err| RecorderError::Credential3rdError {
message: "mikan login failed".to_string(),
source: OptDynErr::some_boxed(err),
})?;
if login_post_res.status().is_redirection()
&& login_post_res.headers().contains_key("location")
{
Ok(())
} else {
Err(RecorderError::Credential3rdError {
message: "mikan login failed, no redirecting".to_string(),
source: None.into(),
})
}
}
pub async fn fork_with_credential(
&self,
ctx: Arc<dyn AppContextTrait>,
credential_id: Option<i32>,
) -> RecorderResult<Self> {
let mut fork = self.http_client.fork();
let mut userpass_credential_opt = None;
if let Some(credential_id) = credential_id {
let credential = credential_3rd::Model::find_by_id(ctx.clone(), credential_id).await?;
if let Some(credential) = credential {
if credential.credential_type != Credential3rdType::Mikan {
return Err(RecorderError::Credential3rdError {
message: "credential is not a mikan credential".to_string(),
source: None.into(),
});
}
let userpass_credential: UserPassCredential =
credential.try_into_userpass_credential(ctx)?;
if let Some(cookies) = userpass_credential.cookies.as_ref() {
fork = fork.attach_cookies(cookies)?;
}
if let Some(user_agent) = userpass_credential.user_agent.as_ref() {
fork = fork.attach_user_agent(user_agent);
}
userpass_credential_opt = Some(userpass_credential);
} else {
return Err(RecorderError::from_db_record_not_found(
DbErr::RecordNotFound(format!("credential={} not found", credential_id)),
));
}
}
Ok(Self {
http_client: HttpClient::from_fork(fork)?,
base_url: self.base_url.clone(),
origin_url: self.origin_url.clone(),
userpass_credential: userpass_credential_opt,
})
}

View File

@ -1,3 +1,6 @@
pub const MIKAN_BUCKET_KEY: &str = "mikan";
pub const MIKAN_UNKNOWN_FANSUB_NAME: &str = "生肉/不明字幕";
pub const MIKAN_UNKNOWN_FANSUB_ID: &str = "202";
pub const MIKAN_LOGIN_PAGE_PATH: &str = "/Account/Login";
pub const MIKAN_LOGIN_PAGE_SEARCH: &str = "?ReturnUrl=%2F";
pub const MIKAN_ACCOUNT_MANAGE_PAGE_PATH: &str = "/Account/Manage";

View File

@ -4,18 +4,20 @@ pub mod constants;
pub mod rss_extract;
pub mod web_extract;
pub use client::{MikanAuthSecrecy, MikanClient};
pub use client::{MikanClient, MikanCredentialForm};
pub use config::MikanConfig;
pub use constants::MIKAN_BUCKET_KEY;
pub use rss_extract::{
MikanBangumiAggregationRssChannel, MikanBangumiRssChannel, MikanBangumiRssLink,
MikanBangumiAggregationRssChannel, MikanBangumiRssChannel, MikanBangumiRssUrlMeta,
MikanRssChannel, MikanRssItem, MikanSubscriberAggregationRssChannel,
MikanSubscriberAggregationRssLink, build_mikan_bangumi_rss_link,
build_mikan_subscriber_aggregation_rss_link, extract_mikan_bangumi_id_from_rss_link,
MikanSubscriberAggregationRssUrlMeta, build_mikan_bangumi_rss_url,
build_mikan_subscriber_aggregation_rss_url, extract_mikan_bangumi_id_from_rss_url,
extract_mikan_rss_channel_from_rss_link, extract_mikan_subscriber_aggregation_id_from_rss_link,
};
pub use web_extract::{
MikanBangumiMeta, MikanEpisodeMeta, build_mikan_bangumi_homepage, build_mikan_episode_homepage,
MikanBangumiMeta, MikanEpisodeMeta, MikanSeasonStr, build_mikan_bangumi_homepage_url,
build_mikan_episode_homepage_url, build_mikan_season_flow_url,
extract_mikan_bangumi_indices_meta_from_season_flow_fragment,
extract_mikan_bangumi_meta_from_bangumi_homepage,
extract_mikan_episode_meta_from_episode_homepage,
};

View File

@ -12,7 +12,7 @@ use crate::{
errors::app_error::{RecorderError, RecorderResult},
extract::mikan::{
MikanClient,
web_extract::{MikanEpisodeHomepage, extract_mikan_episode_id_from_homepage},
web_extract::{MikanEpisodeHomepage, extract_mikan_episode_id_from_homepage_url},
},
};
@ -135,7 +135,7 @@ impl TryFrom<rss::Item> for MikanRssItem {
let MikanEpisodeHomepage {
mikan_episode_id, ..
} = extract_mikan_episode_id_from_homepage(&homepage).ok_or_else(|| {
} = extract_mikan_episode_id_from_homepage_url(&homepage).ok_or_else(|| {
RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("mikan_episode_id"))
})?;
@ -155,17 +155,17 @@ impl TryFrom<rss::Item> for MikanRssItem {
}
#[derive(Debug, Clone)]
pub struct MikanBangumiRssLink {
pub struct MikanBangumiRssUrlMeta {
pub mikan_bangumi_id: String,
pub mikan_fansub_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct MikanSubscriberAggregationRssLink {
pub struct MikanSubscriberAggregationRssUrlMeta {
pub mikan_aggregation_id: String,
}
pub fn build_mikan_bangumi_rss_link(
pub fn build_mikan_bangumi_rss_url(
mikan_base_url: impl IntoUrl,
mikan_bangumi_id: &str,
mikan_fansub_id: Option<&str>,
@ -181,7 +181,7 @@ pub fn build_mikan_bangumi_rss_link(
Ok(url)
}
pub fn build_mikan_subscriber_aggregation_rss_link(
pub fn build_mikan_subscriber_aggregation_rss_url(
mikan_base_url: &str,
mikan_aggregation_id: &str,
) -> RecorderResult<Url> {
@ -192,11 +192,11 @@ pub fn build_mikan_subscriber_aggregation_rss_link(
Ok(url)
}
pub fn extract_mikan_bangumi_id_from_rss_link(url: &Url) -> Option<MikanBangumiRssLink> {
pub fn extract_mikan_bangumi_id_from_rss_url(url: &Url) -> Option<MikanBangumiRssUrlMeta> {
if url.path() == "/RSS/Bangumi" {
url.query_pairs()
.find(|(k, _)| k == "bangumiId")
.map(|(_, v)| MikanBangumiRssLink {
.map(|(_, v)| MikanBangumiRssUrlMeta {
mikan_bangumi_id: v.to_string(),
mikan_fansub_id: url
.query_pairs()
@ -210,10 +210,10 @@ pub fn extract_mikan_bangumi_id_from_rss_link(url: &Url) -> Option<MikanBangumiR
pub fn extract_mikan_subscriber_aggregation_id_from_rss_link(
url: &Url,
) -> Option<MikanSubscriberAggregationRssLink> {
) -> Option<MikanSubscriberAggregationRssUrlMeta> {
if url.path() == "/RSS/MyBangumi" {
url.query_pairs().find(|(k, _)| k == "token").map(|(_, v)| {
MikanSubscriberAggregationRssLink {
MikanSubscriberAggregationRssUrlMeta {
mikan_aggregation_id: v.to_string(),
}
})
@ -233,10 +233,10 @@ pub async fn extract_mikan_rss_channel_from_rss_link(
let channel_link = Url::parse(channel.link())?;
if let Some(MikanBangumiRssLink {
if let Some(MikanBangumiRssUrlMeta {
mikan_bangumi_id,
mikan_fansub_id,
}) = extract_mikan_bangumi_id_from_rss_link(&channel_link)
}) = extract_mikan_bangumi_id_from_rss_url(&channel_link)
{
tracing::trace!(
mikan_bangumi_id,
@ -290,7 +290,7 @@ pub async fn extract_mikan_rss_channel_from_rss_link(
},
))
}
} else if let Some(MikanSubscriberAggregationRssLink {
} else if let Some(MikanSubscriberAggregationRssUrlMeta {
mikan_aggregation_id,
..
}) = extract_mikan_subscriber_aggregation_id_from_rss_link(&channel_link)

View File

@ -1,22 +1,19 @@
use std::{borrow::Cow, sync::Arc};
use std::{borrow::Cow, fmt};
use async_stream::try_stream;
use bytes::Bytes;
use fetch::{html::fetch_html, image::fetch_image};
use futures::Stream;
use itertools::Itertools;
use html_escape::decode_html_entities;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use tracing::instrument;
use url::Url;
use super::{
MIKAN_BUCKET_KEY, MikanAuthSecrecy, MikanBangumiRssLink, MikanClient,
extract_mikan_bangumi_id_from_rss_link,
MIKAN_BUCKET_KEY, MikanBangumiRssUrlMeta, MikanClient, extract_mikan_bangumi_id_from_rss_url,
};
use crate::{
app::AppContextTrait,
errors::app_error::{RecorderResult, RecorderError},
errors::app_error::{RecorderError, RecorderResult},
extract::{
html::{extract_background_image_src_from_style_attr, extract_inner_text_from_element_ref},
media::extract_image_src_from_str,
@ -24,6 +21,29 @@ use crate::{
storage::StorageContentCategory,
};
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
pub enum MikanSeasonStr {
#[serde(rename = "")]
Spring,
#[serde(rename = "")]
Summer,
#[serde(rename = "")]
Autumn,
#[serde(rename = "")]
Winter,
}
impl fmt::Display for MikanSeasonStr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Spring => write!(f, ""),
Self::Summer => write!(f, ""),
Self::Autumn => write!(f, ""),
Self::Winter => write!(f, ""),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MikanEpisodeMeta {
pub homepage: Url,
@ -36,6 +56,14 @@ pub struct MikanEpisodeMeta {
pub mikan_episode_id: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MikanBangumiIndexMeta {
pub homepage: Url,
pub origin_poster_src: Option<Url>,
pub bangumi_title: String,
pub mikan_bangumi_id: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MikanBangumiMeta {
pub homepage: Url,
@ -53,6 +81,19 @@ pub struct MikanBangumiPosterMeta {
pub poster_src: Option<String>,
}
impl From<MikanBangumiIndexMeta> for MikanBangumiMeta {
fn from(index_meta: MikanBangumiIndexMeta) -> Self {
MikanBangumiMeta {
homepage: index_meta.homepage,
origin_poster_src: index_meta.origin_poster_src,
bangumi_title: index_meta.bangumi_title,
mikan_bangumi_id: index_meta.mikan_bangumi_id,
mikan_fansub_id: None,
fansub: None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MikanEpisodeHomepage {
pub mikan_episode_id: String,
@ -64,7 +105,7 @@ pub struct MikanBangumiHomepage {
pub mikan_fansub_id: Option<String>,
}
pub fn build_mikan_bangumi_homepage(
pub fn build_mikan_bangumi_homepage_url(
mikan_base_url: Url,
mikan_bangumi_id: &str,
mikan_fansub_id: Option<&str>,
@ -75,13 +116,29 @@ pub fn build_mikan_bangumi_homepage(
url
}
pub fn build_mikan_episode_homepage(mikan_base_url: Url, mikan_episode_id: &str) -> Url {
pub fn build_mikan_season_flow_url(
mikan_base_url: Url,
year: i32,
season_str: MikanSeasonStr,
) -> Url {
let mut url = mikan_base_url;
url.set_path("/Home/BangumiCoverFlow");
url.query_pairs_mut()
.append_pair("year", &year.to_string())
.append_pair("seasonStr", &season_str.to_string());
url
}
pub fn build_mikan_episode_homepage_url(mikan_base_url: Url, mikan_episode_id: &str) -> Url {
let mut url = mikan_base_url;
url.set_path(&format!("/Home/Episode/{mikan_episode_id}"));
url
}
pub fn build_mikan_bangumi_expand_info_url(mikan_base_url: Url, mikan_bangumi_id: &str) -> Url {
pub fn build_mikan_bangumi_expand_subscribed_fragment_url(
mikan_base_url: Url,
mikan_bangumi_id: &str,
) -> Url {
let mut url = mikan_base_url;
url.set_path("/ExpandBangumi");
url.query_pairs_mut()
@ -90,7 +147,7 @@ pub fn build_mikan_bangumi_expand_info_url(mikan_base_url: Url, mikan_bangumi_id
url
}
pub fn extract_mikan_bangumi_id_from_homepage(url: &Url) -> Option<MikanBangumiHomepage> {
pub fn extract_mikan_bangumi_id_from_homepage_url(url: &Url) -> Option<MikanBangumiHomepage> {
if url.path().starts_with("/Home/Bangumi/") {
let mikan_bangumi_id = url.path().replace("/Home/Bangumi/", "");
@ -103,7 +160,7 @@ pub fn extract_mikan_bangumi_id_from_homepage(url: &Url) -> Option<MikanBangumiH
}
}
pub fn extract_mikan_episode_id_from_homepage(url: &Url) -> Option<MikanEpisodeHomepage> {
pub fn extract_mikan_episode_id_from_homepage_url(url: &Url) -> Option<MikanEpisodeHomepage> {
if url.path().starts_with("/Home/Episode/") {
let mikan_episode_id = url.path().replace("/Home/Episode/", "");
Some(MikanEpisodeHomepage { mikan_episode_id })
@ -191,7 +248,7 @@ pub async fn extract_mikan_episode_meta_from_episode_homepage(
tracing::warn!(error = %error);
})?;
let MikanBangumiRssLink {
let MikanBangumiRssUrlMeta {
mikan_bangumi_id,
mikan_fansub_id,
..
@ -200,7 +257,7 @@ pub async fn extract_mikan_episode_meta_from_episode_homepage(
.next()
.and_then(|el| el.value().attr("href"))
.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_url(&rss_link_url))
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_bangumi_id"))
})
@ -223,7 +280,7 @@ pub async fn extract_mikan_episode_meta_from_episode_homepage(
let MikanEpisodeHomepage {
mikan_episode_id, ..
} = extract_mikan_episode_id_from_homepage(&mikan_episode_homepage_url)
} = extract_mikan_episode_id_from_homepage_url(&mikan_episode_homepage_url)
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_episode_id"))
})
@ -303,9 +360,9 @@ pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
.next()
.and_then(|el| el.value().attr("href"))
.and_then(|s| mikan_bangumi_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_url(&rss_link_url))
.map(
|MikanBangumiRssLink {
|MikanBangumiRssUrlMeta {
mikan_bangumi_id, ..
}| mikan_bangumi_id,
)
@ -325,7 +382,7 @@ pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
})
});
let (mikan_fansub_id, fansub_name) = mikan_bangumi_homepage_url
let (mikan_fansub_id, fansub) = mikan_bangumi_homepage_url
.fragment()
.and_then(|id| {
html.select(
@ -341,7 +398,7 @@ pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
bangumi_title,
mikan_bangumi_id,
origin_poster_src = origin_poster_src.as_ref().map(|url| url.as_str()),
fansub_name,
fansub,
mikan_fansub_id,
"mikan bangumi meta extracted"
);
@ -351,154 +408,141 @@ pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
bangumi_title,
origin_poster_src,
mikan_bangumi_id,
fansub: fansub_name,
fansub,
mikan_fansub_id,
})
}
#[instrument(skip_all, fields(my_bangumi_page_url, auth_secrecy = ?auth_secrecy, history = history.len()))]
pub fn extract_mikan_bangumis_meta_from_my_bangumi_page(
context: Arc<dyn AppContextTrait>,
my_bangumi_page_url: Url,
auth_secrecy: Option<MikanAuthSecrecy>,
history: &[Arc<RecorderResult<MikanBangumiMeta>>],
) -> impl Stream<Item = RecorderResult<MikanBangumiMeta>> {
try_stream! {
let http_client = &context.mikan().fork_with_auth(auth_secrecy.clone())?;
#[instrument]
pub fn extract_mikan_bangumi_indices_meta_from_season_flow_fragment(
season_flow_fragment: &str,
mikan_base_url: Url,
) -> Vec<MikanBangumiIndexMeta> {
let html = Html::parse_fragment(season_flow_fragment);
let mikan_base_url = Url::parse(&my_bangumi_page_url.origin().unicode_serialization())?;
let bangumi_empty_selector = &Selector::parse(".no-subscribe-bangumi").unwrap();
let content = fetch_html(http_client, my_bangumi_page_url.clone()).await?;
if html.select(bangumi_empty_selector).next().is_some() {
return vec![];
}
let fansub_container_selector =
&Selector::parse(".js-expand_bangumi-subgroup.js-subscribed").unwrap();
let fansub_title_selector = &Selector::parse(".tag-res-name[title]").unwrap();
let fansub_id_selector =
&Selector::parse(".active[data-subtitlegroupid][data-bangumiid]").unwrap();
let bangumi_item_selector = &Selector::parse(".mine.an-box ul.an-ul>li").unwrap();
let bangumi_poster_span_selector = &Selector::parse("span[data-src][data-bangumiid]").unwrap();
let bangumi_title_a_selector = &Selector::parse(".an-info-group a.an-text[title]").unwrap();
let bangumi_items = {
let html = Html::parse_document(&content);
let mut items = vec![];
for bangumi_item in html.select(bangumi_item_selector) {
let bangumi_poster_span = bangumi_item.select(bangumi_poster_span_selector).next();
let bangumi_title_a = bangumi_item.select(bangumi_title_a_selector).next();
if let (Some(bangumi_poster_span), Some(bangumi_title_a)) =
(bangumi_poster_span, bangumi_title_a)
{
let origin_poster_src = bangumi_poster_span
.attr("data-src")
.and_then(|data_src| extract_image_src_from_str(data_src, &mikan_base_url));
let bangumi_title = bangumi_title_a
.attr("title")
.map(|title| decode_html_entities(&title).trim().to_string());
let mikan_bangumi_id = bangumi_poster_span
.attr("data-bangumiid")
.map(|id| id.to_string());
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();
html.select(bangumi_container_selector)
.filter_map(|bangumi_elem| {
let title_and_href_elem =
bangumi_elem.select(bangumi_info_selector).next();
let poster_elem = bangumi_elem.select(bangumi_poster_selector).next();
if let (Some(bangumi_home_page_url), Some(bangumi_title)) = (
title_and_href_elem.and_then(|elem| elem.attr("href")),
title_and_href_elem.and_then(|elem| elem.attr("title")),
) {
let origin_poster_src = poster_elem.and_then(|ele| {
ele.attr("data-src")
.and_then(|data_src| {
extract_image_src_from_str(data_src, &mikan_base_url)
})
.or_else(|| {
ele.attr("style").and_then(|style| {
extract_background_image_src_from_style_attr(
style,
&mikan_base_url,
)
})
})
});
let bangumi_title = bangumi_title.to_string();
let bangumi_home_page_url =
my_bangumi_page_url.join(bangumi_home_page_url).ok()?;
let MikanBangumiHomepage {
mikan_bangumi_id, ..
} = extract_mikan_bangumi_id_from_homepage(&bangumi_home_page_url)?;
if let Some(origin_poster_src) = origin_poster_src.as_ref() {
tracing::trace!(
origin_poster_src = origin_poster_src.as_str(),
bangumi_title,
mikan_bangumi_id,
"bangumi info extracted"
);
} else {
tracing::warn!(
bangumi_title,
mikan_bangumi_id,
"bangumi info extracted, but failed to extract poster_src"
);
}
let bangumi_expand_info_url = build_mikan_bangumi_expand_info_url(
mikan_base_url.clone(),
&mikan_bangumi_id,
);
Some((
if let (Some(bangumi_title), Some(mikan_bangumi_id)) = (bangumi_title, mikan_bangumi_id)
{
let homepage = build_mikan_bangumi_homepage_url(
mikan_base_url.clone(),
&mikan_bangumi_id,
None,
);
if let Some(origin_poster_src) = origin_poster_src.as_ref() {
tracing::trace!(
origin_poster_src = origin_poster_src.as_str(),
bangumi_title,
mikan_bangumi_id,
bangumi_expand_info_url,
origin_poster_src,
))
"bangumi index meta extracted"
);
} else {
tracing::warn!(
bangumi_title,
mikan_bangumi_id,
"bangumi index meta extracted, but failed to extract poster_src"
);
}
items.push(MikanBangumiIndexMeta {
homepage,
origin_poster_src,
bangumi_title,
mikan_bangumi_id,
})
}
}
}
items
}
#[instrument(skip_all, fields(mikan_bangumi_index = mikan_bangumi_index.mikan_bangumi_id.as_str()))]
pub fn extract_mikan_bangumi_meta_from_expand_subscribed_fragment(
mikan_bangumi_index: MikanBangumiIndexMeta,
expand_subscribed_fragment: &str,
mikan_base_url: Url,
) -> Option<MikanBangumiMeta> {
let html = Html::parse_fragment(expand_subscribed_fragment);
let fansub_container_selector =
&Selector::parse(".js-expand_bangumi-subgroup.js-subscribed").unwrap();
let fansub_title_selector = &Selector::parse(".tag-res-name[title]").unwrap();
let fansub_id_selector =
&Selector::parse(".active[data-subtitlegroupid][data-bangumiid]").unwrap();
if let Some((fansub_name, mikan_fansub_id)) = {
html.select(fansub_container_selector)
.next()
.and_then(|fansub_info| {
if let (Some(fansub_name), Some(mikan_fansub_id)) = (
fansub_info
.select(fansub_title_selector)
.next()
.and_then(|ele| ele.attr("title"))
.map(String::from),
fansub_info
.select(fansub_id_selector)
.next()
.and_then(|ele| ele.attr("data-subtitlegroupid"))
.map(String::from),
) {
Some((fansub_name, mikan_fansub_id))
} else {
None
}
})
.collect_vec()
};
} {
tracing::trace!(fansub_name, mikan_fansub_id, "subscribed fansub extracted");
let mikan_bangumi_id = mikan_bangumi_index.mikan_bangumi_id;
let bangumi_title = mikan_bangumi_index.bangumi_title;
let origin_poster_src = mikan_bangumi_index.origin_poster_src;
for (idx, (bangumi_title, mikan_bangumi_id, bangumi_expand_info_url, origin_poster_src)) in
bangumi_items.iter().enumerate()
{
if history.get(idx).is_some() {
continue;
} else if let Some((fansub_name, mikan_fansub_id)) = {
let bangumi_expand_info_content =
fetch_html(http_client, bangumi_expand_info_url.clone()).await?;
let bangumi_expand_info_fragment =
Html::parse_fragment(&bangumi_expand_info_content);
bangumi_expand_info_fragment
.select(fansub_container_selector)
.next()
.and_then(|fansub_info| {
if let (Some(fansub_name), Some(mikan_fansub_id)) = (
fansub_info
.select(fansub_title_selector)
.next()
.and_then(|ele| ele.attr("title"))
.map(String::from),
fansub_info
.select(fansub_id_selector)
.next()
.and_then(|ele| ele.attr("data-subtitlegroupid"))
.map(String::from),
) {
Some((fansub_name, mikan_fansub_id))
} else {
None
}
})
} {
tracing::trace!(fansub_name, mikan_fansub_id, "subscribed fansub extracted");
let item = MikanBangumiMeta {
homepage: build_mikan_bangumi_homepage(
mikan_base_url.clone(),
mikan_bangumi_id,
Some(&mikan_fansub_id),
),
bangumi_title: bangumi_title.to_string(),
mikan_bangumi_id: mikan_bangumi_id.to_string(),
mikan_fansub_id: Some(mikan_fansub_id),
fansub: Some(fansub_name),
origin_poster_src: origin_poster_src.clone(),
};
yield item;
}
}
Some(MikanBangumiMeta {
homepage: build_mikan_bangumi_homepage_url(
mikan_base_url.clone(),
&mikan_bangumi_id,
Some(&mikan_fansub_id),
),
bangumi_title: bangumi_title.to_string(),
mikan_bangumi_id: mikan_bangumi_id.to_string(),
mikan_fansub_id: Some(mikan_fansub_id),
fansub: Some(fansub_name),
origin_poster_src: origin_poster_src.clone(),
})
} else {
tracing::trace!("subscribed fansub not found");
None
}
}
#[cfg(test)]
mod test {
#![allow(unused_variables)]
use std::{fs, sync::Arc};
use futures::{TryStreamExt, pin_mut};
use http::header;
use rstest::{fixture, rstest};
@ -507,9 +551,12 @@ mod test {
use zune_image::{codecs::ImageFormat, image::Image};
use super::*;
use crate::test_utils::{
app::UnitTestAppContext, mikan::build_testing_mikan_client,
tracing::try_init_testing_tracing,
use crate::{
extract::mikan::MikanCredentialForm,
test_utils::{
app::UnitTestAppContext, mikan::build_testing_mikan_client,
tracing::try_init_testing_tracing,
},
};
#[fixture]
@ -590,7 +637,9 @@ mod test {
#[rstest]
#[tokio::test]
async fn test_extract_mikan_bangumi_meta_from_bangumi_homepage(before_each: ()) -> RecorderResult<()> {
async fn test_extract_mikan_bangumi_meta_from_bangumi_homepage(
before_each: (),
) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?;
@ -626,95 +675,217 @@ mod test {
}
#[rstest]
#[tokio::test]
async fn test_extract_mikan_bangumis_meta_from_my_bangumi_page(before_each: ()) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
#[test]
fn test_extract_mikan_bangumi_indices_meta_from_season_flow_fragment(
before_each: (),
) -> RecorderResult<()> {
let fragment =
fs::read_to_string("tests/resources/mikan/BangumiCoverFlow-2025-spring.html")?;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let my_bangumi_page_url = mikan_base_url.join("/Home/MyBangumi")?;
let context = Arc::new(
UnitTestAppContext::builder()
.mikan(build_testing_mikan_client(mikan_base_url.clone()).await?)
.build(),
let indices = extract_mikan_bangumi_indices_meta_from_season_flow_fragment(
&fragment,
Url::parse("https://mikanani.me/")?,
);
{
let my_bangumi_without_cookie_mock = mikan_server
.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;
tracing::info!("indices: {:#?}", &indices[0]);
let bangumi_metas = extract_mikan_bangumis_meta_from_my_bangumi_page(
context.clone(),
my_bangumi_page_url.clone(),
None,
&[],
);
pin_mut!(bangumi_metas);
let bangumi_metas = bangumi_metas.try_collect::<Vec<_>>().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")
.create_async()
.await;
let expand_bangumi_mock = mikan_server
.mock("GET", "/ExpandBangumi")
.match_query(mockito::Matcher::Any)
.with_body_from_file("tests/resources/mikan/ExpandBangumi.htm")
.create_async()
.await;
let auth_secrecy = Some(MikanAuthSecrecy {
cookie: String::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(
context.clone(),
my_bangumi_page_url,
auth_secrecy,
&[],
);
pin_mut!(bangumi_metas);
let bangumi_metas = bangumi_metas.try_collect::<Vec<_>>().await?;
assert!(!bangumi_metas.is_empty());
assert!(bangumi_metas[0].origin_poster_src.is_some());
assert!(my_bangumi_with_cookie_mock.matched_async().await);
expand_bangumi_mock.expect(bangumi_metas.len());
}
assert_eq!(indices.len(), 49);
let first = &indices[0];
assert_eq!(first.bangumi_title, "吉伊卡哇");
assert_eq!(first.mikan_bangumi_id, "3288");
assert_eq!(
first.homepage.to_string(),
String::from("https://mikanani.me/Home/Bangumi/3288")
);
assert_eq!(
first
.origin_poster_src
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
String::from("https://mikanani.me/images/Bangumi/202204/d8ef46c0.jpg")
);
Ok(())
}
#[rstest]
#[test]
fn test_extract_mikan_bangumi_indices_meta_from_season_flow_fragment_noauth(
before_each: (),
) -> RecorderResult<()> {
let fragment =
fs::read_to_string("tests/resources/mikan/BangumiCoverFlow-2025-spring-noauth.html")?;
let indices = extract_mikan_bangumi_indices_meta_from_season_flow_fragment(
&fragment,
Url::parse("https://mikanani.me/")?,
);
assert!(indices.is_empty());
Ok(())
}
#[rstest]
#[test]
fn test_extract_mikan_bangumi_meta_from_expand_subscribed_fragment(
before_each: (),
) -> RecorderResult<()> {
let origin_poster_src =
Url::parse("https://mikanani.me/images/Bangumi/202504/076c1094.jpg")?;
let bangumi_index = MikanBangumiIndexMeta {
homepage: Url::parse("https://mikanani.me/Home/Bangumi/3599")?,
origin_poster_src: Some(origin_poster_src.clone()),
bangumi_title: "夏日口袋".to_string(),
mikan_bangumi_id: "3599".to_string(),
};
let fragment = fs::read_to_string("tests/resources/mikan/ExpandBangumi-3599.html")?;
let bangumi = extract_mikan_bangumi_meta_from_expand_subscribed_fragment(
bangumi_index.clone(),
&fragment,
Url::parse("https://mikanani.me/")?,
)
.unwrap_or_else(|| {
panic!("bangumi should not be None");
});
assert_eq!(
bangumi.homepage,
Url::parse("https://mikanani.me/Home/Bangumi/3599#370")?
);
assert_eq!(bangumi.bangumi_title, bangumi_index.bangumi_title);
assert_eq!(bangumi.mikan_bangumi_id, bangumi_index.mikan_bangumi_id);
assert_eq!(bangumi.origin_poster_src, bangumi_index.origin_poster_src);
assert_eq!(bangumi.mikan_fansub_id, Some(String::from("370")));
assert_eq!(bangumi.fansub, Some(String::from("LoliHouse")));
Ok(())
}
#[rstest]
#[test]
fn test_extract_mikan_bangumi_meta_from_expand_subscribed_fragment_noauth(
before_each: (),
) -> RecorderResult<()> {
let origin_poster_src =
Url::parse("https://mikanani.me/images/Bangumi/202504/076c1094.jpg")?;
let bangumi_index = MikanBangumiIndexMeta {
homepage: Url::parse("https://mikanani.me/Home/Bangumi/3599")?,
origin_poster_src: Some(origin_poster_src.clone()),
bangumi_title: "夏日口袋".to_string(),
mikan_bangumi_id: "3599".to_string(),
};
let fragment = fs::read_to_string("tests/resources/mikan/ExpandBangumi-3599-noauth.html")?;
let bangumi = extract_mikan_bangumi_meta_from_expand_subscribed_fragment(
bangumi_index.clone(),
&fragment,
Url::parse("https://mikanani.me/")?,
);
assert!(bangumi.is_none());
Ok(())
}
// #[rstest]
// #[tokio::test]
// async fn test_extract_mikan_bangumis_meta_from_my_bangumi_page(
// before_each: (),
// ) -> RecorderResult<()> {
// let mut mikan_server = mockito::Server::new_async().await;
// let mikan_base_url = Url::parse(&mikan_server.url())?;
// let my_bangumi_page_url = mikan_base_url.join("/Home/MyBangumi")?;
// let context = Arc::new(
// UnitTestAppContext::builder()
//
// .mikan(build_testing_mikan_client(mikan_base_url.clone()).await?)
// .build(),
// );
// {
// let my_bangumi_without_cookie_mock = mikan_server
// .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(
// context.clone(), my_bangumi_page_url.clone(),
// None,
// &[],
// );
// pin_mut!(bangumi_metas);
// let bangumi_metas = bangumi_metas.try_collect::<Vec<_>>().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")
// .create_async()
// .await;
// let expand_bangumi_mock = mikan_server
// .mock("GET", "/ExpandBangumi")
// .match_query(mockito::Matcher::Any)
//
// .with_body_from_file("tests/resources/mikan/ExpandBangumi.htm")
// .create_async()
// .await;
// let auth_secrecy = Some(MikanCredentialForm {
// username: String::from("test_username"),
// password: String::from("test_password"),
// user_agent: 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(
// context.clone(), my_bangumi_page_url,
// auth_secrecy,
// &[],
// );
// pin_mut!(bangumi_metas);
// let bangumi_metas = bangumi_metas.try_collect::<Vec<_>>().await?;
// assert!(!bangumi_metas.is_empty());
// assert!(bangumi_metas[0].origin_poster_src.is_some());
// assert!(my_bangumi_with_cookie_mock.matched_async().await);
// expand_bangumi_mock.expect(bangumi_metas.len());
// }
// Ok(())
// }
}

View File

@ -14,6 +14,7 @@ pub use downloader;
pub mod app;
pub mod auth;
pub mod cache;
pub mod crypto;
pub mod database;
pub mod errors;
pub mod extract;

View File

@ -33,6 +33,7 @@ pub enum Subscriptions {
Category,
SourceUrl,
Enabled,
CredentialId,
}
#[derive(DeriveIden)]
@ -137,6 +138,18 @@ pub enum Auth {
AuthType,
}
#[derive(DeriveIden)]
pub enum Credential3rd {
Table,
Id,
SubscriberId,
CredentialType,
Cookies,
Username,
Password,
UserAgent,
}
macro_rules! create_postgres_enum_for_active_enum {
($manager: expr, $active_enum: expr, $($enum_value:expr),+) => {
{

View File

@ -0,0 +1,107 @@
use async_trait::async_trait;
use sea_orm_migration::{
prelude::*,
schema::{string_null, *},
};
use super::defs::{CustomSchemaManagerExt, GeneralIds, table_auto_z};
use crate::{
migrations::defs::{Credential3rd, Subscribers, Subscriptions},
models::credential_3rd::{Credential3rdType, Credential3rdTypeEnum},
};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
create_postgres_enum_for_active_enum!(
manager,
Credential3rdTypeEnum,
Credential3rdType::Mikan
)
.await?;
manager
.create_table(
table_auto_z(Credential3rd::Table)
.col(pk_auto(Credential3rd::Id))
.col(integer(Credential3rd::SubscriberId))
.col(string(Credential3rd::CredentialType))
.col(string_null(Credential3rd::Cookies))
.col(string_null(Credential3rd::Username))
.col(string_null(Credential3rd::Password))
.col(string_null(Credential3rd::UserAgent))
.foreign_key(
ForeignKey::create()
.name("fk_credential_3rd_subscriber_id")
.from(Credential3rd::Table, Credential3rd::SubscriberId)
.to(Subscribers::Table, Subscribers::Id)
.on_update(ForeignKeyAction::Cascade)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_credential_3rd_credential_type")
.table(Credential3rd::Table)
.col(Credential3rd::CredentialType)
.to_owned(),
)
.await?;
manager
.create_postgres_auto_update_ts_trigger_for_col(
Credential3rd::Table,
GeneralIds::UpdatedAt,
)
.await?;
manager
.alter_table(
Table::alter()
.table(Subscriptions::Table)
.add_column_if_not_exists(integer_null(Subscriptions::CredentialId))
.add_foreign_key(
TableForeignKey::new()
.name("fk_subscriptions_credential_id")
.from_tbl(Subscriptions::Table)
.from_col(Subscriptions::CredentialId)
.to_tbl(Credential3rd::Table)
.to_col(Credential3rd::Id)
.on_update(ForeignKeyAction::Cascade)
.on_delete(ForeignKeyAction::SetNull),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Subscriptions::Table)
.drop_column(Subscriptions::CredentialId)
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(Credential3rd::Table).to_owned())
.await?;
manager
.drop_postgres_enum_for_active_enum(Credential3rdTypeEnum)
.await?;
Ok(())
}
}

View File

@ -7,6 +7,7 @@ pub mod m20220101_000001_init;
pub mod m20240224_082543_add_downloads;
pub mod m20240225_060853_subscriber_add_downloader;
pub mod m20241231_000001_auth;
pub mod m20250501_021523_credential_3rd;
pub struct Migrator;
@ -18,6 +19,7 @@ impl MigratorTrait for Migrator {
Box::new(m20240224_082543_add_downloads::Migration),
Box::new(m20240225_060853_subscriber_add_downloader::Migration),
Box::new(m20241231_000001_auth::Migration),
Box::new(m20250501_021523_credential_3rd::Migration),
]
}
}

View File

@ -0,0 +1,143 @@
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::{ActiveValue, prelude::*};
use serde::{Deserialize, Serialize};
use crate::{
app::AppContextTrait,
crypto::UserPassCredential,
errors::{RecorderError, RecorderResult},
};
#[derive(
Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, DeriveDisplay, Serialize, Deserialize,
)]
#[sea_orm(
rs_type = "String",
db_type = "Enum",
enum_name = "credential_3rd_type"
)]
pub enum Credential3rdType {
#[sea_orm(string_value = "mikan")]
Mikan,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "credential_3rd")]
pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)]
pub id: i32,
pub subscriber_id: i32,
pub credential_type: Credential3rdType,
pub cookies: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub user_agent: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::subscribers::Entity",
from = "Column::SubscriberId",
to = "super::subscribers::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Subscriber,
#[sea_orm(has_many = "super::subscriptions::Entity")]
Subscription,
}
impl Related<super::subscribers::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscriber.def()
}
}
impl Related<super::subscriptions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscription.def()
}
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {}
impl ActiveModel {
pub async fn try_encrypt(mut self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Self> {
let crypto = ctx.crypto();
if let ActiveValue::Set(Some(username)) = self.username {
let username_enc = crypto.encrypt_credentials(&username)?;
self.username = ActiveValue::Set(Some(username_enc));
}
if let ActiveValue::Set(Some(password)) = self.password {
let password_enc = crypto.encrypt_credentials(&password)?;
self.password = ActiveValue::Set(Some(password_enc));
}
if let ActiveValue::Set(Some(cookies)) = self.cookies {
let cookies_enc = crypto.encrypt_credentials(&cookies)?;
self.cookies = ActiveValue::Set(Some(cookies_enc));
}
Ok(self)
}
}
impl Model {
pub async fn find_by_id(
ctx: Arc<dyn AppContextTrait>,
id: i32,
) -> RecorderResult<Option<Self>> {
let db = ctx.db();
let credential = Entity::find_by_id(id).one(db).await?;
Ok(credential)
}
pub fn try_into_userpass_credential(
self,
ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<UserPassCredential> {
let crypto = ctx.crypto();
let username_enc = self
.username
.ok_or_else(|| RecorderError::Credential3rdError {
message: "UserPassCredential username is required".to_string(),
source: None.into(),
})?;
let username: String = crypto.decrypt_credentials(&username_enc)?;
let password_enc = self
.password
.ok_or_else(|| RecorderError::Credential3rdError {
message: "UserPassCredential password is required".to_string(),
source: None.into(),
})?;
let password: String = crypto.decrypt_credentials(&password_enc)?;
let cookies: Option<String> = if let Some(cookies_enc) = self.cookies {
let cookies = crypto.decrypt_credentials(&cookies_enc)?;
Some(cookies)
} else {
None
};
Ok(UserPassCredential {
username,
password,
cookies,
user_agent: self.user_agent,
})
}
}

View File

@ -9,7 +9,7 @@ use crate::{
app::AppContextTrait,
errors::RecorderResult,
extract::{
mikan::{MikanEpisodeMeta, build_mikan_episode_homepage},
mikan::{MikanEpisodeMeta, build_mikan_episode_homepage_url},
rawname::parse_episode_meta_from_raw_name,
},
};
@ -200,8 +200,10 @@ impl ActiveModel {
})
.ok()
.unwrap_or_default();
let homepage =
build_mikan_episode_homepage(ctx.mikan().base_url().clone(), &item.mikan_episode_id);
let homepage = build_mikan_episode_homepage_url(
ctx.mikan().base_url().clone(),
&item.mikan_episode_id,
);
Ok(Self {
mikan_episode_id: ActiveValue::Set(Some(item.mikan_episode_id)),

View File

@ -1,5 +1,6 @@
pub mod auth;
pub mod bangumi;
pub mod credential_3rd;
pub mod downloaders;
pub mod downloads;
pub mod episodes;

View File

@ -11,7 +11,7 @@ use crate::{
errors::RecorderResult,
extract::{
mikan::{
build_mikan_bangumi_homepage, build_mikan_bangumi_rss_link,
build_mikan_bangumi_homepage_url, build_mikan_bangumi_rss_url,
extract_mikan_bangumi_meta_from_bangumi_homepage,
extract_mikan_episode_meta_from_episode_homepage,
extract_mikan_rss_channel_from_rss_link,
@ -54,6 +54,7 @@ pub struct Model {
pub category: SubscriptionCategory,
pub source_url: String,
pub enabled: bool,
pub credential_id: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@ -74,6 +75,14 @@ pub enum Relation {
SubscriptionEpisode,
#[sea_orm(has_many = "super::subscription_bangumi::Entity")]
SubscriptionBangumi,
#[sea_orm(
belongs_to = "super::credential_3rd::Entity",
from = "Column::CredentialId",
to = "super::credential_3rd::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
Credential3rd,
}
impl Related<super::subscribers::Entity> for Entity {
@ -122,6 +131,12 @@ impl Related<super::episodes::Entity> for Entity {
}
}
impl Related<super::credential_3rd::Entity> for Entity {
fn to() -> RelationDef {
Relation::Credential3rd.def()
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)]
pub enum RelatedEntity {
#[sea_orm(entity = "super::subscribers::Entity")]
@ -134,6 +149,8 @@ pub enum RelatedEntity {
SubscriptionEpisode,
#[sea_orm(entity = "super::subscription_bangumi::Entity")]
SubscriptionBangumi,
#[sea_orm(entity = "super::credential_3rd::Entity")]
Credential3rd,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -270,12 +287,12 @@ impl Model {
for ((mikan_bangumi_id, mikan_fansub_id), new_ep_metas) in new_mikan_bangumi_groups
{
let mikan_base_url = ctx.mikan().base_url();
let bgm_homepage = build_mikan_bangumi_homepage(
let bgm_homepage = build_mikan_bangumi_homepage_url(
mikan_base_url.clone(),
&mikan_bangumi_id,
Some(&mikan_fansub_id),
);
let bgm_rss_link = build_mikan_bangumi_rss_link(
let bgm_rss_link = build_mikan_bangumi_rss_url(
mikan_base_url.clone(),
&mikan_bangumi_id,
Some(&mikan_fansub_id),

View File

@ -0,0 +1,4 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskConfig {}

View File

@ -1,279 +0,0 @@
use std::{borrow::Cow, sync::Arc};
use async_stream::stream;
use futures::{Stream, StreamExt, pin_mut};
use serde::{Serialize, de::DeserializeOwned};
use tokio::sync::{RwLock, mpsc};
use crate::{
app::AppContextTrait,
errors::app_error::{RecorderError, RecorderResult},
models,
};
pub struct TaskMeta {
pub subscriber_id: i32,
pub task_id: i32,
pub task_kind: Cow<'static, str>,
}
pub struct ReplayChannel<T: Send + Sync + Clone + 'static> {
sender: mpsc::UnboundedSender<T>,
channels: Arc<RwLock<Vec<mpsc::UnboundedSender<T>>>>,
buffer: Arc<RwLock<Vec<T>>>,
}
impl<T: Send + Sync + Clone + 'static> ReplayChannel<T> {
pub fn new(history: Vec<T>) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<T>();
let channels = Arc::new(RwLock::new(Vec::<mpsc::UnboundedSender<T>>::new()));
let buffer = Arc::new(RwLock::new(history));
{
let channels = channels.clone();
let buffer = buffer.clone();
tokio::spawn(async move {
loop {
match rx.recv().await {
Some(value) => {
let mut w = buffer.write().await;
let senders = channels.read().await;
for s in senders.iter() {
if !s.is_closed() {
if let Err(err) = s.send(value.clone()) {
tracing::error!(err = %err, "replay-channel broadcast to other subscribers error");
}
}
}
w.push(value);
}
None => {
drop(rx);
let mut cs = channels.write().await;
cs.clear();
break;
}
}
}
});
}
Self {
sender: tx,
channels,
buffer,
}
}
pub fn sender(&self) -> &mpsc::UnboundedSender<T> {
&self.sender
}
pub async fn receiver(&self) -> mpsc::UnboundedReceiver<T> {
let (tx, rx) = mpsc::unbounded_channel();
let items = self.buffer.read().await;
for item in items.iter() {
if let Err(err) = tx.send(item.clone()) {
tracing::error!(err = %err, "replay-channel send replay value to other subscribers error");
}
}
if !self.sender.is_closed() {
let mut sw = self.channels.write().await;
sw.push(tx);
}
rx
}
pub async fn close(&self) {
let mut senders = self.channels.write().await;
senders.clear();
}
}
pub trait StreamTaskCoreTrait: Sized {
type Request: Serialize + DeserializeOwned;
type Item: Serialize + DeserializeOwned;
fn task_id(&self) -> i32;
fn task_kind(&self) -> &str;
fn new(meta: TaskMeta, request: Self::Request) -> Self;
fn request(&self) -> &Self::Request;
}
pub trait StreamTaskReplayLayoutTrait: StreamTaskCoreTrait {
fn history(&self) -> &[Arc<RecorderResult<Self::Item>>];
fn resume_from_model(
task: models::tasks::Model,
stream_items: Vec<models::task_stream_item::Model>,
) -> RecorderResult<Self>;
fn running_receiver(
&self,
) -> impl Future<Output = Option<mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>>>;
#[allow(clippy::type_complexity)]
fn init_receiver(
&self,
) -> impl Future<
Output = (
mpsc::UnboundedSender<Arc<RecorderResult<Self::Item>>>,
mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>,
),
>;
fn serialize_request(request: Self::Request) -> RecorderResult<serde_json::Value> {
serde_json::to_value(request).map_err(RecorderError::from)
}
fn serialize_item(item: RecorderResult<Self::Item>) -> RecorderResult<serde_json::Value> {
serde_json::to_value(item).map_err(RecorderError::from)
}
fn deserialize_request(request: serde_json::Value) -> RecorderResult<Self::Request> {
serde_json::from_value(request).map_err(RecorderError::from)
}
fn deserialize_item(item: serde_json::Value) -> RecorderResult<RecorderResult<Self::Item>> {
serde_json::from_value(item).map_err(RecorderError::from)
}
}
pub trait StreamTaskRunnerTrait: StreamTaskCoreTrait {
fn run(
context: Arc<dyn AppContextTrait>,
request: &Self::Request,
history: &[Arc<RecorderResult<Self::Item>>],
) -> impl Stream<Item = RecorderResult<Self::Item>>;
}
pub trait StreamTaskReplayRunnerTrait: StreamTaskRunnerTrait + StreamTaskReplayLayoutTrait {
fn run_shared(
&self,
context: Arc<dyn AppContextTrait>,
) -> impl Stream<Item = Arc<RecorderResult<Self::Item>>> {
stream! {
if let Some(mut receiver) = self.running_receiver().await {
while let Some(item) = receiver.recv().await {
yield item
}
} else {
let (tx, _) = self.init_receiver().await;
let stream = Self::run(context, self.request(), self.history());
pin_mut!(stream);
while let Some(item) = stream.next().await {
let item = Arc::new(item);
if let Err(err) = tx.send(item.clone()) {
tracing::error!(task_id = self.task_id(), task_kind = self.task_kind(), err = %err, "run shared send error");
}
yield item
}
};
}
}
}
pub struct StandardStreamTaskReplayLayout<Request, Item>
where
Request: Serialize + DeserializeOwned,
Item: Serialize + DeserializeOwned + Sync + Send + 'static,
{
pub meta: TaskMeta,
pub request: Request,
pub history: Vec<Arc<RecorderResult<Item>>>,
#[allow(clippy::type_complexity)]
pub channel: Arc<RwLock<Option<ReplayChannel<Arc<RecorderResult<Item>>>>>>,
}
impl<Request, Item> StreamTaskCoreTrait for StandardStreamTaskReplayLayout<Request, Item>
where
Request: Serialize + DeserializeOwned,
Item: Serialize + DeserializeOwned + Sync + Send + 'static,
{
type Request = Request;
type Item = Item;
fn task_id(&self) -> i32 {
self.meta.task_id
}
fn request(&self) -> &Self::Request {
&self.request
}
fn task_kind(&self) -> &str {
&self.meta.task_kind
}
fn new(meta: TaskMeta, request: Self::Request) -> Self {
Self {
meta,
request,
history: vec![],
channel: Arc::new(RwLock::new(None)),
}
}
}
impl<Request, Item> StreamTaskReplayLayoutTrait for StandardStreamTaskReplayLayout<Request, Item>
where
Request: Serialize + DeserializeOwned,
Item: Serialize + DeserializeOwned + Sync + Send + 'static,
{
fn history(&self) -> &[Arc<RecorderResult<Self::Item>>] {
&self.history
}
fn resume_from_model(
task: models::tasks::Model,
stream_items: Vec<models::task_stream_item::Model>,
) -> RecorderResult<Self> {
Ok(Self {
meta: TaskMeta {
task_id: task.id,
subscriber_id: task.subscriber_id,
task_kind: Cow::Owned(task.task_type),
},
request: Self::deserialize_request(task.request_data)?,
history: stream_items
.into_iter()
.map(|m| Self::deserialize_item(m.item).map(Arc::new))
.collect::<RecorderResult<Vec<_>>>()?,
channel: Arc::new(RwLock::new(None)),
})
}
async fn running_receiver(
&self,
) -> Option<mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>> {
if let Some(channel) = self.channel.read().await.as_ref() {
Some(channel.receiver().await)
} else {
None
}
}
async fn init_receiver(
&self,
) -> (
mpsc::UnboundedSender<Arc<RecorderResult<Self::Item>>>,
mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>,
) {
let channel = ReplayChannel::new(self.history.clone());
let rx = channel.receiver().await;
let sender = channel.sender().clone();
{
{
let mut w = self.channel.write().await;
*w = Some(channel);
}
}
(sender, rx)
}
}

View File

@ -1,37 +0,0 @@
use std::sync::Arc;
use futures::Stream;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
app::AppContextTrait,
errors::RecorderResult,
extract::mikan::{MikanAuthSecrecy, MikanBangumiMeta, web_extract},
tasks::core::{StandardStreamTaskReplayLayout, StreamTaskRunnerTrait},
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanBangumisMetaFromMyBangumiRequest {
pub my_bangumi_page_url: Url,
pub auth_secrecy: Option<MikanAuthSecrecy>,
}
pub type ExtractMikanBangumisMetaFromMyBangumiTask =
StandardStreamTaskReplayLayout<ExtractMikanBangumisMetaFromMyBangumiRequest, MikanBangumiMeta>;
impl StreamTaskRunnerTrait for ExtractMikanBangumisMetaFromMyBangumiTask {
fn run(
context: Arc<dyn AppContextTrait>,
request: &Self::Request,
history: &[Arc<RecorderResult<Self::Item>>],
) -> impl Stream<Item = RecorderResult<Self::Item>> {
let context = context.clone();
web_extract::extract_mikan_bangumis_meta_from_my_bangumi_page(
context,
request.my_bangumi_page_url.clone(),
request.auth_secrecy.clone(),
history,
)
}
}

View File

@ -0,0 +1,172 @@
use std::{ops::Deref, sync::Arc};
use apalis::prelude::*;
use apalis_sql::postgres::PostgresStorage;
use fetch::fetch_html;
use serde::{Deserialize, Serialize};
use snafu::OptionExt;
use crate::{
app::AppContextTrait,
errors::{RecorderError, RecorderResult},
extract::mikan::{
MikanBangumiMeta, MikanSeasonStr, build_mikan_season_flow_url,
extract_mikan_bangumi_indices_meta_from_season_flow_fragment,
web_extract::{
MikanBangumiIndexMeta, build_mikan_bangumi_expand_subscribed_fragment_url,
extract_mikan_bangumi_meta_from_expand_subscribed_fragment,
},
},
};
const TASK_NAME: &str = "mikan_extract_season_subscription";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanSeasonSubscriptionTask {
pub task_id: i32,
pub year: i32,
pub season_str: MikanSeasonStr,
pub credential_id: i32,
pub subscription_id: i32,
pub subscriber_id: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanSeasonSubscriptionFansubsTask {
pub task_id: i32,
pub year: i32,
pub season_str: MikanSeasonStr,
pub credential_id: i32,
pub subscription_id: i32,
pub subscriber_id: i32,
pub bangumi_indices: Vec<MikanBangumiIndexMeta>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanSeasonSubscriptionTaskResult {
pub task_id: i32,
pub year: i32,
pub season_str: MikanSeasonStr,
pub credential_id: i32,
pub subscription_id: i32,
pub subscriber_id: i32,
pub bangumi_metas: Vec<MikanBangumiMeta>,
}
pub async fn extract_mikan_season_subscription(
job: ExtractMikanSeasonSubscriptionTask,
data: Data<Arc<dyn AppContextTrait>>,
) -> RecorderResult<GoTo<ExtractMikanSeasonSubscriptionFansubsTask>> {
let ctx = data.deref();
let mikan_client = ctx
.mikan()
.fork_with_credential(ctx.clone(), Some(job.credential_id))
.await?;
let mikan_base_url = mikan_client.base_url().clone();
let season_flow_fragment_url =
build_mikan_season_flow_url(mikan_base_url.clone(), job.year, job.season_str);
let season_flow_fragment = fetch_html(&mikan_client, season_flow_fragment_url.clone()).await?;
let mut bangumi_indices = extract_mikan_bangumi_indices_meta_from_season_flow_fragment(
&season_flow_fragment,
mikan_base_url.clone(),
);
if bangumi_indices.is_empty() && !mikan_client.has_login().await? {
mikan_client.login().await?;
let season_flow_fragment =
fetch_html(&mikan_client, season_flow_fragment_url.clone()).await?;
bangumi_indices = extract_mikan_bangumi_indices_meta_from_season_flow_fragment(
&season_flow_fragment,
mikan_base_url.clone(),
);
}
Ok(GoTo::Next(ExtractMikanSeasonSubscriptionFansubsTask {
task_id: job.task_id,
year: job.year,
season_str: job.season_str,
credential_id: job.credential_id,
subscription_id: job.subscription_id,
subscriber_id: job.subscriber_id,
bangumi_indices,
}))
}
pub async fn extract_mikan_season_subscription_fansubs(
job: ExtractMikanSeasonSubscriptionFansubsTask,
data: Data<Arc<dyn AppContextTrait>>,
) -> RecorderResult<GoTo<ExtractMikanSeasonSubscriptionTaskResult>> {
let ctx = data.deref();
let mikan_client = ctx
.mikan()
.fork_with_credential(ctx.clone(), Some(job.credential_id))
.await?;
let bangumi_indices = job.bangumi_indices;
let mut bangumi_metas = vec![];
let mikan_base_url = mikan_client.base_url().clone();
for bangumi_index in bangumi_indices {
let bangumi_title = bangumi_index.bangumi_title.clone();
let bangumi_expand_subscribed_fragment_url =
build_mikan_bangumi_expand_subscribed_fragment_url(
mikan_base_url.clone(),
&bangumi_index.mikan_bangumi_id,
);
let bangumi_expand_subscribed_fragment =
fetch_html(&mikan_client, bangumi_expand_subscribed_fragment_url).await?;
let bangumi_meta = extract_mikan_bangumi_meta_from_expand_subscribed_fragment(
bangumi_index,
&bangumi_expand_subscribed_fragment,
mikan_base_url.clone(),
)
.with_whatever_context::<_, String, RecorderError>(|| {
format!(
"failed to extract mikan bangumi fansub of title = {}",
bangumi_title
)
})?;
bangumi_metas.push(bangumi_meta);
}
Ok(GoTo::Done(ExtractMikanSeasonSubscriptionTaskResult {
bangumi_metas,
credential_id: job.credential_id,
season_str: job.season_str,
subscriber_id: job.subscriber_id,
subscription_id: job.subscription_id,
task_id: job.task_id,
year: job.year,
}))
}
pub fn register_extract_mikan_season_subscription_task(
monitor: Monitor,
ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<(Monitor, PostgresStorage<StepRequest<serde_json::Value>>)> {
let pool = ctx.db().get_postgres_connection_pool().clone();
let storage = PostgresStorage::new(pool);
let steps = StepBuilder::new()
.step_fn(extract_mikan_season_subscription)
.step_fn(extract_mikan_season_subscription_fansubs);
let worker = WorkerBuilder::new(TASK_NAME)
.catch_panic()
.enable_tracing()
.data(ctx)
.backend(storage.clone())
.build_stepped(steps);
Ok((monitor.register(worker), storage))
}

View File

@ -1 +1,5 @@
pub mod extract_mikan_bangumis_meta_from_my_bangumi;
mod extract_season_subscription;
pub use extract_season_subscription::{
ExtractMikanSeasonSubscriptionTask, register_extract_mikan_season_subscription_task,
};

View File

@ -1,4 +1,6 @@
pub mod core;
pub mod config;
pub mod mikan;
pub mod service;
pub mod registry;
pub use config::TaskConfig;
pub use service::TaskService;

View File

@ -1 +0,0 @@

View File

@ -1,4 +1,41 @@
#[derive(Debug)]
pub struct TaskService {}
use std::{fmt::Debug, sync::Arc};
impl TaskService {}
use apalis::prelude::*;
use apalis_sql::postgres::PostgresStorage;
use tokio::sync::Mutex;
use super::{TaskConfig, mikan::register_extract_mikan_season_subscription_task};
use crate::{app::AppContextTrait, errors::RecorderResult};
pub struct TaskService {
config: TaskConfig,
#[allow(dead_code)]
monitor: Arc<Mutex<Monitor>>,
pub extract_mikan_season_subscription_task_storage:
PostgresStorage<StepRequest<serde_json::Value>>,
}
impl TaskService {
pub async fn from_config_and_ctx(
config: TaskConfig,
ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<Self> {
let monitor = Monitor::new();
let (monitor, extract_mikan_season_subscription_task_storage) =
register_extract_mikan_season_subscription_task(monitor, ctx.clone())?;
Ok(Self {
config,
monitor: Arc::new(Mutex::new(monitor)),
extract_mikan_season_subscription_task_storage,
})
}
}
impl Debug for TaskService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TaskService")
.field("config", &self.config)
.finish()
}
}

View File

@ -1,3 +1,5 @@
use std::fmt::Debug;
use typed_builder::TypedBuilder;
use crate::app::AppContextTrait;
@ -13,12 +15,20 @@ pub struct UnitTestAppContext {
auth: Option<crate::auth::AuthService>,
graphql: Option<crate::graphql::GraphQLService>,
storage: Option<crate::storage::StorageService>,
crypto: Option<crate::crypto::CryptoService>,
tasks: Option<crate::tasks::TaskService>,
#[builder(default = Some(String::from(env!("CARGO_MANIFEST_DIR"))))]
working_dir: Option<String>,
#[builder(default = crate::app::Environment::Testing, setter(!strip_option))]
environment: crate::app::Environment,
}
impl Debug for UnitTestAppContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "UnitTestAppContext")
}
}
impl AppContextTrait for UnitTestAppContext {
fn logger(&self) -> &crate::logger::LoggerService {
self.logger.as_ref().expect("should set logger")
@ -59,4 +69,12 @@ impl AppContextTrait for UnitTestAppContext {
fn working_dir(&self) -> &String {
self.working_dir.as_ref().expect("should set working_dir")
}
fn crypto(&self) -> &crate::crypto::CryptoService {
self.crypto.as_ref().expect("should set crypto")
}
fn task(&self) -> &crate::tasks::TaskService {
self.tasks.as_ref().expect("should set tasks")
}
}

View File

@ -0,0 +1,28 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,840 @@
<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="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202204/d8ef46c0.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3288" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2024/08/09 更新</div>
<a href="/Home/Bangumi/3288" target="_blank" class="an-text"
title="&#x5409;&#x4F0A;&#x5361;&#x54C7;">&#x5409;&#x4F0A;&#x5361;&#x54C7;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3288"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202407/997f06af.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3383" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/08 更新</div>
<a href="/Home/Bangumi/3383" target="_blank" class="an-text"
title="&#x4F60;&#x4E0E;&#x6211;&#x6700;&#x540E;&#x7684;&#x6218;&#x573A;&#xFF0C;&#x4EA6;&#x6216;&#x662F;&#x4E16;&#x754C;&#x8D77;&#x59CB;&#x7684;&#x5723;&#x6218; &#x7B2C;&#x4E8C;&#x5B63;">&#x4F60;&#x4E0E;&#x6211;&#x6700;&#x540E;&#x7684;&#x6218;&#x573A;&#xFF0C;&#x4EA6;&#x6216;&#x662F;&#x4E16;&#x754C;&#x8D77;&#x59CB;&#x7684;&#x5723;&#x6218;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3383"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202501/c63dd1b9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3515" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/04/20 更新</div>
<a href="/Home/Bangumi/3515" target="_blank" class="an-text"
title="&#x51E0;&#x5206;&#x949F;&#x7684;&#x6B22;&#x547C;">&#x51E0;&#x5206;&#x949F;&#x7684;&#x6B22;&#x547C;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3515"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202501/d5a4b73b.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3526" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3526" target="_blank" class="an-text"
title="&#x6211;&#x5BB6;&#x6709;&#x4E2A;&#x963F;&#x5B85;&#x5973;&#x5FCD;&#x8005;">&#x6211;&#x5BB6;&#x6709;&#x4E2A;&#x963F;&#x5B85;&#x5973;&#x5FCD;&#x8005;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3526"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202501/2e430a10.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3530" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3530" target="_blank" class="an-text"
title="&#x836F;&#x5C4B;&#x5C11;&#x5973;&#x7684;&#x5462;&#x5583; &#x7B2C;&#x4E8C;&#x5B63;">&#x836F;&#x5C4B;&#x5C11;&#x5973;&#x7684;&#x5462;&#x5583;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3530"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202501/424750fe.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3556" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3556" target="_blank" class="an-text"
title="&#x8131;&#x79BB;&#x4E86;A&#x7EA7;&#x961F;&#x4F0D;&#x7684;&#x6211;&#xFF0C;&#x548C;&#x4ECE;&#x524D;&#x7684;&#x5F92;&#x5F1F;&#x4EEC;&#x524D;&#x5F80;&#x8FF7;&#x5BAB;&#x6DF1;&#x5904;&#x3002;">&#x8131;&#x79BB;&#x4E86;A&#x7EA7;&#x961F;&#x4F0D;&#x7684;&#x6211;&#xFF0C;&#x548C;&#x4ECE;&#x524D;&#x7684;&#x5F92;&#x5F1F;&#x4EEC;&#x524D;&#x5F80;&#x8FF7;&#x5BAB;&#x6DF1;&#x5904;&#x3002;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3556"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/c0832100.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3573" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/05 更新</div>
<a href="/Home/Bangumi/3573" target="_blank" class="an-text"
title="&#x4E00;&#x8D77;&#x52A0;&#x6CB9;&#x5427;">&#x4E00;&#x8D77;&#x52A0;&#x6CB9;&#x5427;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3573"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/f86c46e8.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3582" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3582" target="_blank" class="an-text"
title="&#x5B89;&#x59AE;&#xB7;&#x96EA;&#x8389;">&#x5B89;&#x59AE;&#xB7;&#x96EA;&#x8389;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3582"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/3e3fe89e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3583" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">6</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3583" target="_blank" class="an-text"
title="&#x76F4;&#x81F3;&#x9B54;&#x5973;&#x6D88;&#x901D;">&#x76F4;&#x81F3;&#x9B54;&#x5973;&#x6D88;&#x901D;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3583"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/d92cb519.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3584" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3584" target="_blank" class="an-text"
title="&#x5FCD;&#x8005;&#x4E0E;&#x6740;&#x624B;&#x4E8C;&#x4EBA;&#x7EC4;&#x7684;&#x65E5;&#x5E38;&#x751F;&#x6D3B;">&#x5FCD;&#x8005;&#x4E0E;&#x6740;&#x624B;&#x4E8C;&#x4EBA;&#x7EC4;&#x7684;&#x65E5;&#x5E38;&#x751F;&#x6D3B;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3584"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/e33a7226.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3585" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3585" target="_blank" class="an-text"
title="&#x673A;&#x52A8;&#x6218;&#x58EB;&#x9AD8;&#x8FBE; GQuuuuuuX">&#x673A;&#x52A8;&#x6218;&#x58EB;&#x9AD8;&#x8FBE;
GQuuuuuuX</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3585"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/a5d55f1e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3586" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3586" target="_blank" class="an-text"
title="&#x6253;&#x4E86;300&#x5E74;&#x7684;&#x53F2;&#x83B1;&#x59C6;&#xFF0C;&#x4E0D;&#x77E5;&#x4E0D;&#x89C9;&#x5C31;&#x7EC3;&#x5230;&#x4E86;&#x6EE1;&#x7EA7; &#xFF5E;&#x5176;&#x4E8C;&#xFF5E;">&#x6253;&#x4E86;300&#x5E74;&#x7684;&#x53F2;&#x83B1;&#x59C6;&#xFF0C;&#x4E0D;&#x77E5;&#x4E0D;&#x89C9;&#x5C31;&#x7EC3;&#x5230;&#x4E86;&#x6EE1;&#x7EA7;
&#xFF5E;&#x5176;&#x4E8C;&#xFF5E;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3586"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/ff5c2429.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3587" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3587" target="_blank" class="an-text"
title="&#x9B54;&#x5973;&#x5B88;&#x62A4;&#x8005;">&#x9B54;&#x5973;&#x5B88;&#x62A4;&#x8005;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3587"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/81ebb5a9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3591" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3591" target="_blank" class="an-text"
title="&#x4E61;&#x4E0B;&#x5927;&#x53D4;&#x6210;&#x4E3A;&#x5251;&#x5723;">&#x4E61;&#x4E0B;&#x5927;&#x53D4;&#x6210;&#x4E3A;&#x5251;&#x5723;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3591"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/d233a952.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3592" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3592" target="_blank" class="an-text"
title="&#x6211;&#x662F;&#x661F;&#x9645;&#x56FD;&#x5BB6;&#x7684;&#x6076;&#x5FB7;&#x9886;&#x4E3B;&#xFF01;">&#x6211;&#x662F;&#x661F;&#x9645;&#x56FD;&#x5BB6;&#x7684;&#x6076;&#x5FB7;&#x9886;&#x4E3B;&#xFF01;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3592"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/44e224ed.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3593" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/09 更新</div>
<a href="/Home/Bangumi/3593" target="_blank" class="an-text"
title="&#x77AC;&#x95F4;&#x6CBB;&#x7597;&#x5374;&#x88AB;&#x89C6;&#x4E3A;&#x65E0;&#x7528;&#x800C;&#x88AB;&#x6D41;&#x653E;&#x7684;&#x5929;&#x624D;&#x6CBB;&#x7597;&#x5E08;&#xFF0C;&#x4EE5;&#x6697;&#x9ED1;&#x6CBB;&#x7597;&#x5E08;&#x7684;&#x8EAB;&#x4EFD;&#x5E78;&#x798F;&#x5730;&#x751F;&#x6D3B;&#x7740;">&#x77AC;&#x95F4;&#x6CBB;&#x7597;&#x5374;&#x88AB;&#x89C6;&#x4E3A;&#x65E0;&#x7528;&#x800C;&#x88AB;&#x6D41;&#x653E;&#x7684;&#x5929;&#x624D;&#x6CBB;&#x7597;&#x5E08;&#xFF0C;&#x4EE5;&#x6697;&#x9ED1;&#x6CBB;&#x7597;&#x5E08;&#x7684;&#x8EAB;&#x4EFD;&#x5E78;&#x798F;&#x5730;&#x751F;&#x6D3B;&#x7740;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3593"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/85659a01.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3594" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3594" target="_blank" class="an-text"
title="&#x5916;&#x661F;&#x4EBA;&#x59C6;&#x59C6;">&#x5916;&#x661F;&#x4EBA;&#x59C6;&#x59C6;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3594"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/95afd8e9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3598" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3598" target="_blank" class="an-text"
title="&#x968F;&#x5174;&#x65C5;-That&#x27;s Journey-">&#x968F;&#x5174;&#x65C5;-That&#x27;s
Journey-</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3598"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/076c1094.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3599" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3599" target="_blank" class="an-text"
title="&#x590F;&#x65E5;&#x53E3;&#x888B;">&#x590F;&#x65E5;&#x53E3;&#x888B;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3599"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/18802e7d.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3600" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3600" target="_blank" class="an-text"
title="&#x672B;&#x65E5;&#x540E;&#x9152;&#x5E97;">&#x672B;&#x65E5;&#x540E;&#x9152;&#x5E97;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3600"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/2e4096c4.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3601" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3601" target="_blank" class="an-text"
title="&#x4E2D;&#x7985;&#x5BFA;&#x8001;&#x5E08;&#x7684;&#x7075;&#x602A;&#x8BB2;&#x4E49;&#x5B9E;&#x5F55; &#x8001;&#x5E08;&#x4F1A;&#x628A;&#x8C1C;&#x9898;&#x5168;&#x90FD;&#x89E3;&#x5F00;&#x7684;&#x3002;">&#x4E2D;&#x7985;&#x5BFA;&#x8001;&#x5E08;&#x7684;&#x7075;&#x602A;&#x8BB2;&#x4E49;&#x5B9E;&#x5F55;
&#x8001;&#x5E08;&#x4F1A;&#x628A;&#x8C1C;&#x9898;&#x5168;&#x90FD;&#x89E3;&#x5F00;&#x7684;&#x3002;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3601"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/2e97d6d8.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3602" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3602" target="_blank" class="an-text"
title="&#x7537;&#x5973;&#x4E4B;&#x95F4;&#x7684;&#x53CB;&#x60C5;&#x5B58;&#x5728;&#x5417;&#xFF1F;&#xFF08;&#x4E0D;&#xFF0C;&#x4E0D;&#x5B58;&#x5728;!!&#xFF09;">&#x7537;&#x5973;&#x4E4B;&#x95F4;&#x7684;&#x53CB;&#x60C5;&#x5B58;&#x5728;&#x5417;&#xFF1F;&#xFF08;&#x4E0D;&#xFF0C;&#x4E0D;&#x5B58;&#x5728;!!&#xFF09;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3602"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/e30f1861.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3603" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3603" target="_blank" class="an-text"
title="&#x62DC;&#x6258;&#x8BF7;&#x7A7F;&#x4E0A;&#xFF0C;&#x9E70;&#x5CF0;&#x540C;&#x5B66;">&#x62DC;&#x6258;&#x8BF7;&#x7A7F;&#x4E0A;&#xFF0C;&#x9E70;&#x5CF0;&#x540C;&#x5B66;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3603"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/a2f98c24.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3604" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3604" target="_blank" class="an-text"
title="&#x8D5B;&#x9A6C;&#x5A18; &#x82A6;&#x6BDB;&#x7070;&#x59D1;&#x5A18;">&#x8D5B;&#x9A6C;&#x5A18;
&#x82A6;&#x6BDB;&#x7070;&#x59D1;&#x5A18;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3604"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/80d05785.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3605" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3605" target="_blank" class="an-text"
title="&#x63A8;&#x7406;&#x8981;&#x5728;&#x665A;&#x9910;&#x540E;">&#x63A8;&#x7406;&#x8981;&#x5728;&#x665A;&#x9910;&#x540E;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3605"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/6f86415a.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3606" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3606" target="_blank" class="an-text"
title="&#x6447;&#x6EDA;&#x4E43;&#x662F;&#x6DD1;&#x5973;&#x7684;&#x7231;&#x597D;">&#x6447;&#x6EDA;&#x4E43;&#x662F;&#x6DD1;&#x5973;&#x7684;&#x7231;&#x597D;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3606"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/d731d60f.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3607" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3607" target="_blank" class="an-text"
title="&#x4E5D;&#x9F99;&#x5927;&#x4F17;&#x6D6A;&#x6F2B;">&#x4E5D;&#x9F99;&#x5927;&#x4F17;&#x6D6A;&#x6F2B;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3607"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/0bf31724.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3609" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/10 更新</div>
<a href="/Home/Bangumi/3609" target="_blank" class="an-text"
title="&#x5723;&#x5973;&#x56E0;&#x592A;&#x8FC7;&#x5B8C;&#x7F8E;&#x4E0D;&#x591F;&#x53EF;&#x7231;&#x800C;&#x88AB;&#x5E9F;&#x9664;&#x5A5A;&#x7EA6;&#x5E76;&#x5356;&#x5230;&#x90BB;&#x56FD;">&#x5723;&#x5973;&#x56E0;&#x592A;&#x8FC7;&#x5B8C;&#x7F8E;&#x4E0D;&#x591F;&#x53EF;&#x7231;&#x800C;&#x88AB;&#x5E9F;&#x9664;&#x5A5A;&#x7EA6;&#x5E76;&#x5356;&#x5230;&#x90BB;&#x56FD;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3609"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/b41afc59.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3611" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3611" target="_blank" class="an-text"
title="&#x708E;&#x708E;&#x6D88;&#x9632;&#x961F; &#x53C1;&#x4E4B;&#x7AE0;">&#x708E;&#x708E;&#x6D88;&#x9632;&#x961F;
&#x53C1;&#x4E4B;&#x7AE0;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3611"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/fc6eaf43.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3612" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3612" target="_blank" class="an-text"
title="&#x6D4B;&#x4E0D;&#x51C6;&#x7684;&#x963F;&#x6CE2;&#x8FDE;&#x540C;&#x5B66; &#x7B2C;&#x4E8C;&#x5B63;">&#x6D4B;&#x4E0D;&#x51C6;&#x7684;&#x963F;&#x6CE2;&#x8FDE;&#x540C;&#x5B66;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3612"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/3c0bd08c.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3613" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3613" target="_blank" class="an-text"
title="&#x9633;&#x5149;&#x9A6C;&#x8FBE;&#x68D2;&#x7403;&#x573A;&#xFF01;">&#x9633;&#x5149;&#x9A6C;&#x8FBE;&#x68D2;&#x7403;&#x573A;&#xFF01;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3613"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/18f01f9e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3614" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">14</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3614" target="_blank" class="an-text"
title="&#x7D2B;&#x4E91;&#x5BFA;&#x5BB6;&#x7684;&#x5144;&#x5F1F;&#x59D0;&#x59B9;">&#x7D2B;&#x4E91;&#x5BFA;&#x5BB6;&#x7684;&#x5144;&#x5F1F;&#x59D0;&#x59B9;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3614"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/0c4c9df3.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3615" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3615" target="_blank" class="an-text"
title="&#x7EC8;&#x672B;&#x8D77;&#x70B9;">&#x7EC8;&#x672B;&#x8D77;&#x70B9;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3615"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/096c19d0.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3617" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3617" target="_blank" class="an-text"
title="&#x7231;&#x6709;&#x4E9B;&#x6C89;&#x91CD;&#x7684;&#x9ED1;&#x6697;&#x7CBE;&#x7075;&#x4ECE;&#x5F02;&#x4E16;&#x754C;&#x8FFD;&#x8FC7;&#x6765;&#x4E86;">&#x7231;&#x6709;&#x4E9B;&#x6C89;&#x91CD;&#x7684;&#x9ED1;&#x6697;&#x7CBE;&#x7075;&#x4ECE;&#x5F02;&#x4E16;&#x754C;&#x8FFD;&#x8FC7;&#x6765;&#x4E86;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3617"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/143326f9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3618" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/10 更新</div>
<a href="/Home/Bangumi/3618" target="_blank" class="an-text"
title="&#x795E;&#x7EDF;&#x8BB0;">&#x795E;&#x7EDF;&#x8BB0;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3618"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/c59f3187.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3620" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3620" target="_blank" class="an-text"
title="&#x8BB0;&#x5FC6;&#x7F1D;&#x7EBF;">&#x8BB0;&#x5FC6;&#x7F1D;&#x7EBF;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3620"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/2b2ea518.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3621" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3621" target="_blank" class="an-text"
title="&#x5FEB;&#x85CF;&#x8D77;&#x6765;&#xFF01;&#x739B;&#x742A;&#x5A1C;&#x540C;&#x5B66;!!">&#x5FEB;&#x85CF;&#x8D77;&#x6765;&#xFF01;&#x739B;&#x742A;&#x5A1C;&#x540C;&#x5B66;!!</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3621"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/fddd0557.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3623" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3623" target="_blank" class="an-text"
title="&#x65F6;&#x5149;&#x6D41;&#x901D;&#xFF0C;&#x996D;&#x83DC;&#x4F9D;&#x65E7;&#x7F8E;&#x5473;">&#x65F6;&#x5149;&#x6D41;&#x901D;&#xFF0C;&#x996D;&#x83DC;&#x4F9D;&#x65E7;&#x7F8E;&#x5473;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3623"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/cf6e83ba.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3624" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3624" target="_blank" class="an-text"
title="&#x62C9;&#x6492;&#x8DEF;">&#x62C9;&#x6492;&#x8DEF;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3624"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/17a31d67.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3627" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3627" target="_blank" class="an-text"
title="&#x641E;&#x7B11;&#x6F2B;&#x753B;&#x65E5;&#x548C;GO">&#x641E;&#x7B11;&#x6F2B;&#x753B;&#x65E5;&#x548C;GO</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3627"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/ca36fa9e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3628" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3628" target="_blank" class="an-text"
title="mono&#x5973;&#x5B69;">mono&#x5973;&#x5B69;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3628"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/af9a6139.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3632" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3632" target="_blank" class="an-text"
title="&#x5E72;&#x6742;&#x6D3B;&#x6211;&#x4E43;&#x6700;&#x5F3A;&#xFF5E;&#x5173;&#x4E8E;&#x539F;&#x82F1;&#x96C4;&#x961F;&#x4F0D;&#x7684;&#x6742;&#x5F79;&#x4EBA;&#x5458;&#xFF0C;&#x5B9E;&#x9645;&#x4E0A;&#x9664;&#x4E86;&#x6218;&#x6597;&#x80FD;&#x529B;&#x5916;&#x5168;&#x662F;SSS&#x7684;&#x6545;&#x4E8B;&#xFF5E;">&#x5E72;&#x6742;&#x6D3B;&#x6211;&#x4E43;&#x6700;&#x5F3A;&#xFF5E;&#x5173;&#x4E8E;&#x539F;&#x82F1;&#x96C4;&#x961F;&#x4F0D;&#x7684;&#x6742;&#x5F79;&#x4EBA;&#x5458;&#xFF0C;&#x5B9E;&#x9645;&#x4E0A;&#x9664;&#x4E86;&#x6218;&#x6597;&#x80FD;&#x529B;&#x5916;&#x5168;&#x662F;SSS&#x7684;&#x6545;&#x4E8B;&#xFF5E;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3632"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/7ea16a61.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3633" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/04/03 更新</div>
<a href="/Home/Bangumi/3633" target="_blank" class="an-text"
title="&#x8FDB;&#x5165;&#x82B1;&#x56ED;">&#x8FDB;&#x5165;&#x82B1;&#x56ED;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3633"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/3400d7c6.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3635" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3635" target="_blank" class="an-text"
title="&#x5C0F;&#x5E02;&#x6C11;&#x7CFB;&#x5217; &#x7B2C;&#x4E8C;&#x5B63;">&#x5C0F;&#x5E02;&#x6C11;&#x7CFB;&#x5217;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3635"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/62a30c3f.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3636" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/04/05 更新</div>
<a href="/Home/Bangumi/3636" target="_blank" class="an-text"
title="The Star Seekers &#x661F;&#x3092;&#x8FFD;&#x3046;&#x5C11;&#x5E74;&#x305F;&#x3061;">The
Star Seekers &#x661F;&#x3092;&#x8FFD;&#x3046;&#x5C11;&#x5E74;&#x305F;&#x3061;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3636"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/ffce0184.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3641" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3641" target="_blank" class="an-text"
title="&#x518D;&#x89C1;&#xFF0C;&#x5730;&#x7403; &#x7B2C;&#x4E8C;&#x5B63;">&#x518D;&#x89C1;&#xFF0C;&#x5730;&#x7403;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3641"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/716c2ff5.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3642" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3642" target="_blank" class="an-text"
title="&#x6BCF;&#x65E5;&#x7537;&#x516C;&#x5173;">&#x6BCF;&#x65E5;&#x7537;&#x516C;&#x5173;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3642"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/052595b4.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3644" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3644" target="_blank" class="an-text"
title="&#x8389;&#x53EF;&#x4E3D;&#x4E1D;&#xFF1A;&#x53CB;&#x8C0A;&#x662F;&#x65F6;&#x95F4;&#x7684;&#x7A83;&#x8D3C;">&#x8389;&#x53EF;&#x4E3D;&#x4E1D;&#xFF1A;&#x53CB;&#x8C0A;&#x662F;&#x65F6;&#x95F4;&#x7684;&#x7A83;&#x8D3C;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3644"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/be7f8aef.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3646" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3646" target="_blank" class="an-text"
title="&#x6218;&#x961F;&#x5927;&#x5931;&#x683C; &#x7B2C;&#x4E8C;&#x5B63;">&#x6218;&#x961F;&#x5927;&#x5931;&#x683C;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3646"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
</div>

After

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,641 +0,0 @@
<!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 - &#x6211;&#x7684;&#x756A;&#x7EC4;</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>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<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">&nbsp;&nbsp;&nbsp;</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 &#x51AC;&#x5B63;&#x756A;&#x7EC4; <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="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</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="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</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>&nbsp;&nbsp;搜索站内
</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>

File diff suppressed because it is too large Load Diff

View File

@ -16,9 +16,7 @@ axum-extra = { workspace = true }
async-trait = { workspace = true }
moka = { workspace = true }
reqwest = { workspace = true }
leaky-bucket = "1.1"
cookie = "0.18"
http-cache-reqwest = { version = "0.15", features = [
"manager-cacache",
"manager-moka",
@ -33,3 +31,4 @@ http-cache = { version = "0.20", features = [
"manager-cacache",
"manager-moka",
], default-features = false }
reqwest_cookie_store = { version = "0.8.0", features = ["serde"] }

View File

@ -6,7 +6,8 @@ use http_cache_reqwest::{
Cache, CacheManager, CacheMode, HttpCache, HttpCacheOptions, MokaManager,
};
use leaky_bucket::RateLimiter;
use reqwest::{ClientBuilder, Request, Response};
use reqwest::{self, ClientBuilder, Request, Response};
use reqwest_cookie_store::{CookieStore, CookieStoreRwLock};
use reqwest_middleware::{
ClientBuilder as ClientWithMiddlewareBuilder, ClientWithMiddleware, Middleware, Next,
};
@ -16,7 +17,6 @@ use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use snafu::Snafu;
use super::HttpClientSecrecyDataTrait;
use crate::get_random_mobile_ua;
pub struct RateLimiterMiddleware {
@ -109,6 +109,8 @@ pub enum HttpClientError {
ReqwestMiddlewareError { source: reqwest_middleware::Error },
#[snafu(transparent)]
HttpError { source: http::Error },
#[snafu(display("Failed to parse cookies: {}", source))]
ParseCookiesError { source: serde_json::Error },
}
pub trait HttpClientTrait: Deref<Target = ClientWithMiddleware> + Debug {}
@ -117,17 +119,29 @@ pub struct HttpClientFork {
pub client_builder: ClientBuilder,
pub middleware_stack: Vec<Arc<dyn Middleware>>,
pub config: HttpClientConfig,
pub cookie_store: Option<Arc<CookieStoreRwLock>>,
}
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 fn attach_cookies(mut self, cookies: &str) -> Result<Self, HttpClientError> {
let cookie_store: CookieStore = serde_json::from_str(cookies)
.map_err(|err| HttpClientError::ParseCookiesError { source: err })?;
let cookies_store = Arc::new(CookieStoreRwLock::new(cookie_store));
self.cookie_store = Some(cookies_store.clone());
self.client_builder = self.client_builder.cookie_provider(cookies_store);
Ok(self)
}
pub fn attach_user_agent(mut self, user_agent: &str) -> Self {
self.client_builder = self.client_builder.user_agent(user_agent);
self
}
}
pub struct HttpClient {
pub cookie_store: Option<Arc<CookieStoreRwLock>>,
client: ClientWithMiddleware,
middleware_stack: Vec<Arc<dyn Middleware>>,
pub config: HttpClientConfig,
@ -268,6 +282,7 @@ impl HttpClient {
client: reqwest_with_middleware,
middleware_stack,
config,
cookie_store: None,
})
}
@ -287,6 +302,7 @@ impl HttpClient {
client_builder: reqwest_client_builder,
middleware_stack: self.middleware_stack.clone(),
config: self.config.clone(),
cookie_store: self.cookie_store.clone(),
}
}
@ -295,6 +311,7 @@ impl HttpClient {
client_builder,
middleware_stack,
config,
cookie_store,
} = fork;
let reqwest_client = client_builder.build()?;
let mut reqwest_with_middleware_builder = ClientWithMiddlewareBuilder::new(reqwest_client);
@ -309,6 +326,7 @@ impl HttpClient {
client: reqwest_with_middleware,
middleware_stack,
config,
cookie_store,
})
}
}

View File

@ -1,9 +1,6 @@
pub mod core;
pub mod secrecy;
pub use core::{
HttpClient, HttpClientCacheBackendConfig, HttpClientCachePresetConfig, HttpClientConfig,
HttpClientError, HttpClientTrait,
};
pub use secrecy::{HttpClientCookiesAuth, HttpClientSecrecyDataTrait};

View File

@ -1,47 +0,0 @@
use std::sync::Arc;
use cookie::Cookie;
use reqwest::{ClientBuilder, cookie::Jar};
use url::Url;
use crate::FetchError;
pub trait HttpClientSecrecyDataTrait {
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, FetchError> {
let cookie_jar = Arc::new(Jar::default());
for cookie in Cookie::split_parse(cookies).collect::<Result<Vec<Cookie<'_>>, _>>()? {
cookie_jar.add_cookie_str(&cookie.to_string(), url);
}
Ok(Self {
cookie_jar,
user_agent,
})
}
}
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
}
}

View File

@ -3,8 +3,6 @@ use snafu::Snafu;
#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum FetchError {
#[snafu(transparent)]
CookieParseError { source: cookie::ParseError },
#[snafu(transparent)]
ReqwestError { source: reqwest::Error },
#[snafu(transparent)]

View File

@ -9,10 +9,7 @@ pub mod test_util;
pub use core::get_random_mobile_ua;
pub use bytes::fetch_bytes;
pub use client::{
HttpClient, HttpClientConfig, HttpClientCookiesAuth, HttpClientError,
HttpClientSecrecyDataTrait, HttpClientTrait,
};
pub use client::{HttpClient, HttpClientConfig, HttpClientError, HttpClientTrait};
pub use errors::FetchError;
pub use html::fetch_html;
pub use image::fetch_image;