├── .github ├── release.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Cross.toml ├── LICENSE ├── README.md ├── crates ├── app │ ├── Cargo.toml │ ├── build.rs │ └── src │ │ └── lib.rs ├── config │ ├── Cargo.toml │ ├── config.default.toml │ └── src │ │ ├── helper.rs │ │ ├── input.rs │ │ ├── lib.rs │ │ ├── network.rs │ │ ├── output.rs │ │ ├── the_porn_db.rs │ │ ├── translator.rs │ │ └── url.rs ├── http-client │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── javcap │ ├── Cargo.toml │ ├── banner │ └── src │ │ ├── app.rs │ │ ├── bar.rs │ │ ├── helper.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── message.rs │ │ └── payload.rs ├── nfo │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── spider │ ├── Cargo.toml │ └── src │ │ ├── airav.rs │ │ ├── avsox.rs │ │ ├── cable.rs │ │ ├── fc2ppv_db.rs │ │ ├── hbox.rs │ │ ├── jav321.rs │ │ ├── javdb.rs │ │ ├── lib.rs │ │ ├── missav.rs │ │ ├── porny.rs │ │ ├── subtitle_cat.rs │ │ └── the_porn_db.rs ├── translator │ ├── Cargo.toml │ └── src │ │ ├── lib.rs │ │ ├── openai.rs │ │ └── youdao.rs └── video │ ├── Cargo.toml │ └── src │ └── lib.rs ├── images └── logo.png ├── justfile └── typos.toml /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - "documentation" 5 | - "duplicate" 6 | - "help wanted" 7 | - "invalid" 8 | - "question" 9 | - "wontfix" 10 | - "good first issue" 11 | categories: 12 | - title: 🏕 Features 13 | labels: 14 | - "enhancement" 15 | - title: 🐛 Bug Fixes 16 | labels: 17 | - "bug" 18 | - title: 🛠 Breaking Changes 19 | labels: 20 | - "breaking change" 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | schedule: 8 | - cron: "0 22 * * *" 9 | 10 | jobs: 11 | check: 12 | name: check 13 | runs-on: ubuntu-latest 14 | env: 15 | RUST_BACKTRACE: 1 16 | steps: 17 | - uses: actions/checkout@master 18 | 19 | - name: Check spelling 20 | uses: crate-ci/typos@master 21 | 22 | - name: Install Rust 23 | uses: dtolnay/rust-toolchain@master 24 | with: 25 | toolchain: stable 26 | 27 | - uses: Swatinem/rust-cache@v2 28 | 29 | - name: Install audit 30 | run: | 31 | cargo install cargo-audit 32 | 33 | - name: Run check 34 | run: | 35 | cargo audit 36 | 37 | - name: Run test 38 | run: | 39 | cargo test --workspace --all-features --color always 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | create-release: 13 | name: create-release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Get the release version from the tag 18 | run: echo "VERSION=${{ github.ref_name }}" >> $GITHUB_ENV 19 | - name: Show the version 20 | run: | 21 | echo "version is: $VERSION" 22 | - name: Create GitHub release 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | run: gh release create $VERSION --draft --generate-notes --verify-tag --title $VERSION 26 | outputs: 27 | version: ${{ env.VERSION }} 28 | 29 | build-release: 30 | name: build-release 31 | needs: ["create-release"] 32 | runs-on: ${{ matrix.os }} 33 | env: 34 | RUST_BACKTRACE: 1 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | include: 39 | - os: ubuntu-latest 40 | rust: stable 41 | target: x86_64-unknown-linux-musl 42 | - os: ubuntu-latest 43 | rust: stable 44 | target: x86_64-unknown-linux-gnu 45 | - os: ubuntu-latest 46 | rust: stable 47 | target: aarch64-unknown-linux-musl 48 | - os: ubuntu-latest 49 | rust: stable 50 | target: aarch64-unknown-linux-gnu 51 | 52 | - os: macos-latest 53 | rust: stable 54 | target: aarch64-apple-darwin 55 | - os: macos-latest 56 | rust: stable 57 | target: x86_64-apple-darwin 58 | 59 | - os: windows-latest 60 | rust: stable 61 | target: x86_64-pc-windows-msvc 62 | - os: windows-latest 63 | rust: stable 64 | target: aarch64-pc-windows-msvc 65 | 66 | steps: 67 | - name: Checkout repository 68 | uses: actions/checkout@v4 69 | 70 | - name: Install Rust 71 | uses: dtolnay/rust-toolchain@master 72 | with: 73 | toolchain: ${{ matrix.rust }} 74 | target: ${{ matrix.target }} 75 | 76 | - uses: Swatinem/rust-cache@v2 77 | with: 78 | key: ${{ matrix.target }} 79 | 80 | - name: Set target variables 81 | shell: bash 82 | run: | 83 | echo "TARGET_FLAGS=--target ${{ matrix.target }}" >> $GITHUB_ENV 84 | 85 | - name: Show command used for Cargo 86 | shell: bash 87 | run: | 88 | echo "target flag is: ${{ env.TARGET_FLAGS }}" 89 | 90 | - name: Build release binary 91 | shell: bash 92 | run: | 93 | cargo install cross 94 | cross build --verbose --release ${{ env.TARGET_FLAGS }} 95 | if [ "${{ matrix.os }}" = "windows-latest" ]; then 96 | bin="target/${{ matrix.target }}/release/javcap.exe" 97 | else 98 | bin="target/${{ matrix.target }}/release/javcap" 99 | fi 100 | echo "BIN=$bin" >> $GITHUB_ENV 101 | env: 102 | VERSION: ${{ needs.create-release.outputs.version }} 103 | 104 | - name: Determine archive name 105 | shell: bash 106 | run: | 107 | version="${{ needs.create-release.outputs.version }}" 108 | echo "ARCHIVE=javcap-$version-${{ matrix.target }}" >> $GITHUB_ENV 109 | 110 | - name: Creating directory for archive 111 | shell: bash 112 | run: | 113 | mkdir -p "$ARCHIVE" 114 | cp "$BIN" "$ARCHIVE"/ 115 | cp LICENSE "$ARCHIVE"/ 116 | 117 | - name: Build archive (Windows) 118 | shell: bash 119 | if: matrix.os == 'windows-latest' 120 | run: | 121 | cd "$ARCHIVE" && 7z a ../"$ARCHIVE.zip" * && cd .. 122 | echo "ASSET=$ARCHIVE.zip" >> $GITHUB_ENV 123 | 124 | - name: Build archive (Unix) 125 | shell: bash 126 | if: matrix.os != 'windows-latest' 127 | run: | 128 | cd "$ARCHIVE" && tar czf ../"$ARCHIVE.tar.gz" * && cd .. 129 | echo "ASSET=$ARCHIVE.tar.gz" >> $GITHUB_ENV 130 | 131 | - name: Upload release archive 132 | env: 133 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 134 | shell: bash 135 | run: | 136 | version="${{ needs.create-release.outputs.version }}" 137 | gh release upload "$version" ${{ env.ASSET }} 138 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | .DS_Store 13 | 14 | dev/ 15 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "crates/app", 5 | "crates/config", 6 | "crates/http-client", 7 | "crates/javcap", 8 | "crates/nfo", 9 | "crates/spider", 10 | "crates/translator", 11 | "crates/video", 12 | ] 13 | 14 | [workspace.dependencies] 15 | config = { path = "crates/config" } 16 | app = { path = "crates/app" } 17 | video = { path = "crates/video" } 18 | nfo = { path = "crates/nfo" } 19 | spider = { path = "crates/spider" } 20 | translator = { path = "crates/translator" } 21 | http-client = { path = "crates/http-client" } 22 | 23 | anyhow = "1.0.95" 24 | tokio = { version = "1.43.0", features = [ 25 | "macros", 26 | "rt-multi-thread", 27 | "fs", 28 | "io-util", 29 | "sync", 30 | "time", 31 | ] } 32 | whoami = "1.5.2" 33 | toml = "0.8.19" 34 | serde = { version = "1.0.217", features = ["derive"] } 35 | validator = { version = "0.20.0", features = ["derive"] } 36 | nom = "8.0.0" 37 | indoc = "2.0.5" 38 | getset = "0.1.4" 39 | ratelimit = "0.10.0" 40 | async-trait = "0.1.86" 41 | reqwest = { version = "0.12.12", default-features = false, features = [ 42 | "charset", 43 | "http2", 44 | "macos-system-configuration", 45 | "rustls-tls", 46 | "json", 47 | "brotli", 48 | "gzip", 49 | "deflate", 50 | ] } 51 | uuid = { version = "1.13.1", features = ["v4"] } 52 | sha256 = "1.5.0" 53 | colored = "3.0.0" 54 | log = "0.4.25" 55 | env_logger = "0.11.6" 56 | self_update = { version = "0.39.0", default-features = false, features = [ 57 | "archive-tar", 58 | "archive-zip", 59 | "rustls", 60 | "compression-flate2", 61 | ] } 62 | bon = "3.3.2" 63 | async-openai = "0.27.2" 64 | chrono = "0.4.39" 65 | scraper = "0.22.0" 66 | educe = "0.6.0" 67 | quick-xml = "0.37.2" 68 | clap = { version = "4.5.31", features = ["derive"] } 69 | serde_json = "1.0.140" 70 | terminal_size = "0.4.1" 71 | 72 | pretty_assertions = "1.4.1" 73 | test-case = "3.3.1" 74 | 75 | [profile.release] 76 | lto = true 77 | codegen-units = 1 78 | panic = "abort" 79 | strip = true 80 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [build.env] 2 | passthrough = ["VERSION"] 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 jane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Logo 4 | 5 | 6 |

Javcap

7 | 8 |

9 | Bubbles 10 | 电影刮削器 11 |

12 |
13 | 14 |
15 | GitHub Repo stars 16 | GitHub License 17 | GitHub Release 18 |
19 | 20 |

21 | Glowing Star 22 | 下载及使用 23 |

24 | 25 | [WIKI](https://github.com/jane-212/javcap/wiki) 26 | 27 |

28 | Comet 29 | 其他 30 |

31 | 32 | - 此项目用于爬虫技术分享,请勿用作其他用途,否则后果自负。 33 | - 请勿在社交平台宣传交流此项目。 34 | - 此项目禁止商用。 35 | -------------------------------------------------------------------------------- /crates/app/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | terminal_size.workspace = true 8 | -------------------------------------------------------------------------------- /crates/app/build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | #[cfg(debug_assertions)] 5 | println!("cargo:rustc-env=VERSION=0.0.0"); 6 | 7 | let git_hash = Command::new("git") 8 | .arg("rev-parse") 9 | .arg("--short") 10 | .arg("HEAD") 11 | .output() 12 | .map(|output| String::from_utf8(output.stdout)) 13 | .expect("get git hash failed") 14 | .expect("get git hash failed") 15 | .trim() 16 | .to_string(); 17 | 18 | println!("cargo:rustc-env=HASH={git_hash}"); 19 | } 20 | -------------------------------------------------------------------------------- /crates/app/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | pub const NAME: &str = "javcap"; 4 | pub const VERSION: &str = env!("VERSION"); 5 | pub const HASH: &str = env!("HASH"); 6 | pub static LINE_LENGTH: LazyLock = LazyLock::new(|| { 7 | terminal_size::terminal_size() 8 | .map(|(width, _)| width.0 as usize) 9 | .unwrap_or(40) 10 | }); 11 | pub const USER_AGENT: &str = concat!("javcap", "/", env!("VERSION")); 12 | -------------------------------------------------------------------------------- /crates/config/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "config" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | app.workspace = true 8 | 9 | anyhow.workspace = true 10 | tokio.workspace = true 11 | whoami.workspace = true 12 | toml.workspace = true 13 | serde.workspace = true 14 | validator.workspace = true 15 | log.workspace = true 16 | 17 | [dev-dependencies] 18 | pretty_assertions.workspace = true 19 | test-case.workspace = true 20 | -------------------------------------------------------------------------------- /crates/config/config.default.toml: -------------------------------------------------------------------------------- 1 | # 是否检查更新 2 | check_for_update = false 3 | 4 | # 同时进行的任务数量 5 | task_limit = 3 6 | 7 | # 用来翻译的组件, 可以同时提供多个来加快速度, 若无组件, 则不翻译 8 | # 9 | # 有道翻译 10 | # [[translators]] 11 | # type = "youdao" 12 | # key = "" 13 | # secret = "" 14 | # 15 | # deepseek 16 | # [[translators]] 17 | # type = "deepseek" 18 | # base = "https://api.deepseek.com" 19 | # model = "deepseek-chat" 20 | # key = "" 21 | # 22 | # openai 23 | # [[translators]] 24 | # type = "openai" 25 | # base = "https://models.inference.ai.azure.com" 26 | # model = "gpt-4o" 27 | # key = "" 28 | 29 | [input] 30 | # 输入路径, 必须是绝对路径 31 | path = "" 32 | # 哪些文件后缀会被处理 33 | exts = ["mp4", "avi", "mov", "m4v", "mkv", "flv", "rmvb", "wmv"] 34 | # 不包括的文件夹, 如果输出文件夹是输入文件夹的子文件夹, 则需要包括输出文件夹 35 | excludes = ["output"] 36 | 37 | [output] 38 | # 输出路径, 必须是绝对路径 39 | path = "" 40 | # 用来组建输出路径的规则 41 | # title -> 标题 42 | # studio -> 工作室/发行商 43 | # name -> xxx-123 44 | # ^^^^^^^ 45 | # id -> xxx-123 46 | # ^^^ 47 | # director -> 导演 48 | # country -> 属地 49 | # actor -> 演员 50 | rule = ["id", "name"] 51 | 52 | [network] 53 | # 网络连接超时时间 54 | timeout = 10 55 | # 网络代理地址 56 | # proxy = "" 57 | 58 | # 可以换用可直连地址 59 | [url] 60 | # airav = "" 61 | # avsox = "" 62 | # cable = "" 63 | # fc2ppv_db = "" 64 | # hbox = "" 65 | # jav321 = "" 66 | # javdb = "" 67 | # missav = "" 68 | # porny = "" 69 | # subtitle_cat = "" 70 | # the_porn_db = "" 71 | # the_porn_db_api = "" 72 | 73 | # 填写key来启用the porn db 74 | [the_porn_db] 75 | # key = "" 76 | -------------------------------------------------------------------------------- /crates/config/src/helper.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use validator::ValidationError; 4 | 5 | pub fn absolute_path(path: &Path) -> Result<(), ValidationError> { 6 | if !path.is_absolute() { 7 | let msg = format!("should use absolute path: {}", path.display()); 8 | let err = ValidationError::new("path").with_message(msg.into()); 9 | return Err(err); 10 | } 11 | 12 | Ok(()) 13 | } 14 | 15 | #[cfg(test)] 16 | mod tests { 17 | use super::*; 18 | use pretty_assertions::assert_eq; 19 | use std::path::PathBuf; 20 | use test_case::test_case; 21 | 22 | #[test_case("/home/user/.config", true; "absolute")] 23 | #[test_case(".config", false; "relative")] 24 | fn test_absolute_path(path: &str, is_absolute: bool) { 25 | let path = PathBuf::from(path); 26 | let actual = absolute_path(&path); 27 | assert_eq!(actual.is_ok(), is_absolute); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /crates/config/src/input.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use serde::Deserialize; 4 | use validator::Validate; 5 | 6 | use super::helper::absolute_path; 7 | 8 | #[derive(Debug, Deserialize, Validate)] 9 | pub struct Input { 10 | #[validate(custom(function = "absolute_path"))] 11 | pub path: PathBuf, 12 | pub exts: Vec, 13 | pub excludes: Vec, 14 | } 15 | -------------------------------------------------------------------------------- /crates/config/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod helper; 2 | mod input; 3 | mod network; 4 | mod output; 5 | mod the_porn_db; 6 | mod translator; 7 | mod url; 8 | 9 | pub use output::Tag; 10 | pub use translator::Translator; 11 | 12 | use std::path::{Path, PathBuf}; 13 | 14 | use anyhow::{Result, bail}; 15 | use input::Input; 16 | use log::info; 17 | use network::Network; 18 | use output::Output; 19 | use serde::Deserialize; 20 | use the_porn_db::ThePornDB; 21 | use tokio::fs::{self, OpenOptions}; 22 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; 23 | use url::Url; 24 | use validator::Validate; 25 | 26 | #[derive(Debug, Deserialize, Validate)] 27 | pub struct Config { 28 | pub check_for_update: bool, 29 | 30 | #[validate(range(min = 1, message = "should be larger than 0"))] 31 | pub task_limit: usize, 32 | 33 | pub translators: Option>, 34 | 35 | #[validate(nested)] 36 | pub input: Input, 37 | 38 | #[validate(nested)] 39 | pub output: Output, 40 | 41 | #[validate(nested)] 42 | pub network: Network, 43 | 44 | #[validate(nested)] 45 | pub url: Url, 46 | 47 | pub the_porn_db: ThePornDB, 48 | } 49 | 50 | impl Config { 51 | pub const DEFAULT_CONFIG: &str = include_str!("../config.default.toml"); 52 | 53 | pub async fn load() -> Result { 54 | let config_path = Config::config_path(); 55 | let config_file = config_path.join("config.toml"); 56 | if !config_file.exists() { 57 | info!("config not found in {}", config_file.display()); 58 | fs::create_dir_all(config_path).await?; 59 | Config::generate_default_config_file(&config_file).await?; 60 | bail!( 61 | "config not found, default config generated to {}", 62 | config_file.display() 63 | ); 64 | } 65 | 66 | Self::load_and_decode(config_file).await 67 | } 68 | 69 | pub async fn load_from(path: impl AsRef) -> Result { 70 | let config_file = path.as_ref(); 71 | if !config_file.exists() { 72 | info!("config not found in {}", config_file.display()); 73 | bail!("config not found in {}", config_file.display()); 74 | } 75 | 76 | Self::load_and_decode(config_file).await 77 | } 78 | 79 | async fn load_and_decode(path: impl AsRef) -> Result { 80 | let path = path.as_ref(); 81 | info!("load config from {}", path.display()); 82 | 83 | let mut config = String::new(); 84 | OpenOptions::new() 85 | .read(true) 86 | .open(path) 87 | .await? 88 | .read_to_string(&mut config) 89 | .await?; 90 | let config = toml::from_str::(&config)?; 91 | 92 | Ok(config) 93 | } 94 | 95 | async fn generate_default_config_file(path: &Path) -> Result<()> { 96 | OpenOptions::new() 97 | .create(true) 98 | .truncate(true) 99 | .write(true) 100 | .open(path) 101 | .await? 102 | .write_all(Self::DEFAULT_CONFIG.as_bytes()) 103 | .await?; 104 | info!("generate default config to {}", path.display()); 105 | 106 | Ok(()) 107 | } 108 | 109 | /// macos -> /Users//.config/javcap 110 | /// linux -> /home//.config/javcap 111 | /// windows -> C:\Users\\.config\javcap 112 | fn config_path() -> PathBuf { 113 | let username = whoami::username(); 114 | #[cfg(target_os = "macos")] 115 | let user_dir = PathBuf::from("/Users").join(username); 116 | #[cfg(target_os = "linux")] 117 | let user_dir = PathBuf::from("/home").join(username); 118 | #[cfg(target_os = "windows")] 119 | let user_dir = PathBuf::from("C:\\Users").join(username); 120 | 121 | user_dir.join(".config").join(app::NAME) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /crates/config/src/network.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use validator::Validate; 3 | 4 | #[derive(Debug, Deserialize, Validate)] 5 | pub struct Network { 6 | #[validate(range(min = 1, message = "should be larger than 0"))] 7 | pub timeout: u64, 8 | #[validate(url(message = "should be a url"))] 9 | pub proxy: Option, 10 | } 11 | -------------------------------------------------------------------------------- /crates/config/src/output.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use validator::Validate; 5 | 6 | use super::helper::absolute_path; 7 | 8 | #[derive(Debug, Deserialize, Validate)] 9 | pub struct Output { 10 | #[validate(custom(function = "absolute_path"))] 11 | pub path: PathBuf, 12 | 13 | #[validate(length(min = 1, message = "should have at least 1 rule"))] 14 | pub rule: Vec, 15 | } 16 | 17 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] 18 | pub enum Tag { 19 | #[serde(rename = "title")] 20 | Title, 21 | 22 | #[serde(rename = "studio")] 23 | Studio, 24 | 25 | #[serde(rename = "name")] 26 | Name, 27 | 28 | #[serde(rename = "id")] 29 | Id, 30 | 31 | #[serde(rename = "director")] 32 | Director, 33 | 34 | #[serde(rename = "country")] 35 | Country, 36 | 37 | #[serde(rename = "actor")] 38 | Actor, 39 | } 40 | -------------------------------------------------------------------------------- /crates/config/src/the_porn_db.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Deserialize)] 4 | pub struct ThePornDB { 5 | pub key: Option, 6 | } 7 | -------------------------------------------------------------------------------- /crates/config/src/translator.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Deserialize, Serialize)] 4 | #[serde(tag = "type")] 5 | pub enum Translator { 6 | #[serde(rename = "youdao")] 7 | Youdao { key: String, secret: String }, 8 | #[serde(rename = "deepseek")] 9 | DeepSeek { 10 | base: String, 11 | model: String, 12 | key: String, 13 | }, 14 | #[serde(rename = "openai")] 15 | Openai { 16 | base: String, 17 | model: String, 18 | key: String, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /crates/config/src/url.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use validator::Validate; 3 | 4 | #[derive(Debug, Deserialize, Validate)] 5 | pub struct Url { 6 | #[validate(url(message = "should be a url"))] 7 | pub airav: Option, 8 | 9 | #[validate(url(message = "should be a url"))] 10 | pub avsox: Option, 11 | 12 | #[validate(url(message = "should be a url"))] 13 | pub cable: Option, 14 | 15 | #[validate(url(message = "should be a url"))] 16 | pub fc2ppv_db: Option, 17 | 18 | #[validate(url(message = "should be a url"))] 19 | pub hbox: Option, 20 | 21 | #[validate(url(message = "should be a url"))] 22 | pub jav321: Option, 23 | 24 | #[validate(url(message = "should be a url"))] 25 | pub javdb: Option, 26 | 27 | #[validate(url(message = "should be a url"))] 28 | pub missav: Option, 29 | 30 | #[validate(url(message = "should be a url"))] 31 | pub porny: Option, 32 | 33 | #[validate(url(message = "should be a url"))] 34 | pub subtitle_cat: Option, 35 | 36 | #[validate(url(message = "should be a url"))] 37 | pub the_porn_db: Option, 38 | 39 | #[validate(url(message = "should be a url"))] 40 | pub the_porn_db_api: Option, 41 | } 42 | -------------------------------------------------------------------------------- /crates/http-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "http-client" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | app.workspace = true 8 | 9 | reqwest.workspace = true 10 | bon.workspace = true 11 | anyhow.workspace = true 12 | ratelimit.workspace = true 13 | tokio.workspace = true 14 | -------------------------------------------------------------------------------- /crates/http-client/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use anyhow::{Context, Result}; 4 | use bon::bon; 5 | use ratelimit::Ratelimiter; 6 | use reqwest::Client as HttpClient; 7 | use reqwest::Proxy; 8 | use reqwest::header::HeaderMap; 9 | use tokio::time; 10 | 11 | pub struct Client { 12 | client: HttpClient, 13 | limiter: Ratelimiter, 14 | } 15 | 16 | #[bon] 17 | impl Client { 18 | #[builder] 19 | pub fn new( 20 | timeout: Duration, 21 | proxy: Option, 22 | amount: Option, 23 | headers: Option, 24 | interval: u64, 25 | ) -> Result { 26 | let amount = amount.unwrap_or(1); 27 | let limiter = Ratelimiter::builder(amount, Duration::from_secs(interval)) 28 | .max_tokens(amount) 29 | .initial_available(amount) 30 | .build() 31 | .with_context(|| "build limiter")?; 32 | let mut client_builder = HttpClient::builder() 33 | .timeout(timeout) 34 | .user_agent(app::USER_AGENT); 35 | if let Some(url) = proxy { 36 | let proxy = Proxy::all(&url).with_context(|| format!("set proxy to {url}"))?; 37 | client_builder = client_builder.proxy(proxy); 38 | } 39 | if let Some(headers) = headers { 40 | client_builder = client_builder.default_headers(headers); 41 | } 42 | let client = client_builder 43 | .build() 44 | .with_context(|| "build reqwest client")?; 45 | let client = Client { client, limiter }; 46 | 47 | Ok(client) 48 | } 49 | 50 | pub async fn wait(&self) -> &HttpClient { 51 | self.wait_limiter().await; 52 | 53 | &self.client 54 | } 55 | 56 | async fn wait_limiter(&self) { 57 | loop { 58 | match self.limiter.try_wait() { 59 | Ok(_) => break, 60 | Err(sleep) => time::sleep(sleep).await, 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /crates/javcap/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "javcap" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | app.workspace = true 8 | config.workspace = true 9 | video.workspace = true 10 | nfo.workspace = true 11 | spider.workspace = true 12 | translator.workspace = true 13 | 14 | tokio.workspace = true 15 | anyhow.workspace = true 16 | validator.workspace = true 17 | getset.workspace = true 18 | colored.workspace = true 19 | log.workspace = true 20 | env_logger.workspace = true 21 | whoami.workspace = true 22 | self_update.workspace = true 23 | bon.workspace = true 24 | chrono.workspace = true 25 | clap.workspace = true 26 | -------------------------------------------------------------------------------- /crates/javcap/banner: -------------------------------------------------------------------------------- 1 | 2 | ██╗ █████╗ ██╗ ██╗ ██████╗ █████╗ ██████╗ 3 | ██║██╔══██╗██║ ██║██╔════╝██╔══██╗██╔══██╗ 4 | ██║███████║██║ ██║██║ ███████║██████╔╝ 5 | ██ ██║██╔══██║╚██╗ ██╔╝██║ ██╔══██║██╔═══╝ 6 | ╚█████╔╝██║ ██║ ╚████╔╝ ╚██████╗██║ ██║██║ 7 | ╚════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝╚═╝ 8 | -------------------------------------------------------------------------------- /crates/javcap/src/app.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::{Path, PathBuf}; 3 | use std::sync::Arc; 4 | 5 | use anyhow::{Context, Result, bail}; 6 | use colored::Colorize; 7 | use config::Config; 8 | use log::{error, info, warn}; 9 | use tokio::fs; 10 | use tokio::sync::mpsc::error::SendError; 11 | use tokio::sync::mpsc::{self, Receiver}; 12 | use tokio::task::JoinSet; 13 | use validator::Validate; 14 | use video::{Video, VideoFile, VideoType}; 15 | 16 | use super::bar::Bar; 17 | use super::helper::Helper; 18 | use super::message::Message; 19 | use super::payload::Payload; 20 | 21 | pub struct App { 22 | config: Config, 23 | videos: HashMap, 24 | tasks: JoinSet>>, 25 | succeed: Vec, 26 | failed: Vec, 27 | helper: Arc, 28 | bar: Arc, 29 | } 30 | 31 | impl App { 32 | pub async fn new(config: Config) -> Result { 33 | let helper = Helper::new(&config).with_context(|| "build helper")?; 34 | let bar = Bar::new().await; 35 | let app = App { 36 | tasks: JoinSet::new(), 37 | config, 38 | succeed: Vec::new(), 39 | failed: Vec::new(), 40 | videos: HashMap::new(), 41 | helper: Arc::new(helper), 42 | bar: Arc::new(bar), 43 | }; 44 | 45 | Ok(app) 46 | } 47 | 48 | async fn start_all_tasks(&mut self) -> Result> { 49 | let (tx, rx) = mpsc::channel(10); 50 | for video in self.videos.clone().into_values() { 51 | let tx = tx.clone(); 52 | let helper = self.helper.clone(); 53 | let bar = self.bar.clone(); 54 | self.tasks.spawn(async move { 55 | let name = video.ty().to_string(); 56 | info!("add {name} to queue"); 57 | let msg = match Self::process_video(video, helper, bar).await { 58 | Ok(payload) => Message::Loaded(Box::new(payload)), 59 | Err(e) => Message::Failed(name, format!("{e:?}")), 60 | }; 61 | tx.send(msg).await 62 | }); 63 | } 64 | 65 | Ok(rx) 66 | } 67 | 68 | pub async fn run(mut self) -> Result<()> { 69 | self.load_all_videos() 70 | .await 71 | .with_context(|| "load videos")?; 72 | let mut rx = self 73 | .start_all_tasks() 74 | .await 75 | .with_context(|| "start all tasks")?; 76 | 77 | while let Some(msg) = rx.recv().await { 78 | self.handle_message(msg).await; 79 | } 80 | 81 | self.wait_for_all_tasks() 82 | .await 83 | .with_context(|| "wait for all tasks")?; 84 | self.summary().await; 85 | 86 | Ok(()) 87 | } 88 | 89 | fn print_bar(&self, msg: &Message) { 90 | let msg = format!(" {} ", msg); 91 | let len = msg.len(); 92 | let cnt = msg.chars().count(); 93 | let width = if len == cnt { len } else { (len + cnt) / 2 }; 94 | let padding = *app::LINE_LENGTH - width; 95 | let padding_left = padding / 2; 96 | let padding_right = padding - padding_left; 97 | self.bar.message(format!( 98 | "{}{}{}", 99 | "=".repeat(padding_left).yellow(), 100 | msg.yellow(), 101 | "=".repeat(padding_right).yellow(), 102 | )); 103 | } 104 | 105 | async fn process_video(video: Video, helper: Arc, bar: Arc) -> Result { 106 | let _permit = helper 107 | .sema 108 | .acquire() 109 | .await 110 | .with_context(|| "acquire permit")?; 111 | 112 | let mut nfo = helper 113 | .spider 114 | .find(video.ty().clone()) 115 | .await 116 | .with_context(|| "find video")?; 117 | nfo.auto_fix_by_key(video.ty()); 118 | info!("{nfo:?}"); 119 | nfo.validate().with_context(|| "validate nfo")?; 120 | 121 | let title_task = tokio::spawn({ 122 | let helper = helper.clone(); 123 | let title = nfo.title().clone(); 124 | async move { 125 | helper 126 | .translator 127 | .translate(&title) 128 | .await 129 | .with_context(|| format!("translate {title}")) 130 | } 131 | }); 132 | let plot_task = tokio::spawn({ 133 | let helper = helper.clone(); 134 | let plot = nfo.plot().clone(); 135 | async move { 136 | helper 137 | .translator 138 | .translate(&plot) 139 | .await 140 | .with_context(|| format!("translate {plot}")) 141 | } 142 | }); 143 | 144 | if let Some(title) = title_task.await?? { 145 | info!("translated {title}"); 146 | nfo.set_title(title); 147 | } 148 | if let Some(plot) = plot_task.await?? { 149 | info!("translated {plot}"); 150 | nfo.set_plot(plot); 151 | } 152 | 153 | let payload = Payload::builder().video(video).nfo(nfo).bar(bar).build(); 154 | Ok(payload) 155 | } 156 | 157 | async fn handle_succeed(&mut self, payload: &Payload) -> Result<()> { 158 | let out = self.get_out_path(payload).await?; 159 | payload 160 | .write_all_to(&out) 161 | .await 162 | .with_context(|| format!("write payload to {}", out.display()))?; 163 | payload 164 | .move_videos_to(&out) 165 | .await 166 | .with_context(|| format!("move videos to {}", out.display()))?; 167 | 168 | self.bar.add().await; 169 | let ty = payload.video().ty(); 170 | info!("{ty} ok"); 171 | self.succeed.push(ty.to_string()); 172 | Ok(()) 173 | } 174 | 175 | fn concat_rule(&self, payload: &Payload) -> PathBuf { 176 | let mut out = self.config.output.path.to_path_buf(); 177 | for tag in self.config.output.rule.iter() { 178 | let name = payload.get_by_tag(tag); 179 | out = out.join(name); 180 | } 181 | 182 | out 183 | } 184 | 185 | async fn get_out_path(&self, payload: &Payload) -> Result { 186 | let out = self.concat_rule(payload); 187 | self.bar.message(format!("to {}", out.display())); 188 | if out.is_file() { 189 | bail!("target is a file"); 190 | } 191 | 192 | if !out.exists() { 193 | fs::create_dir_all(&out) 194 | .await 195 | .with_context(|| format!("create dir for {}", out.display()))?; 196 | } 197 | 198 | Ok(out) 199 | } 200 | 201 | async fn handle_failed(&mut self, name: String, err: String) { 202 | self.bar 203 | .message(format!("{}\n{}", "failed by".red(), err.red())); 204 | 205 | self.bar.add().await; 206 | error!("{name} failed, caused by {err}"); 207 | self.failed.push(name); 208 | } 209 | 210 | async fn handle_message(&mut self, msg: Message) { 211 | self.print_bar(&msg); 212 | match msg { 213 | Message::Loaded(payload) => { 214 | if let Err(err) = self.handle_succeed(&payload).await { 215 | let ty = payload.video().ty(); 216 | self.handle_failed(ty.to_string(), format!("{err:?}")).await; 217 | } 218 | } 219 | Message::Failed(name, err) => { 220 | self.handle_failed(name, err).await; 221 | } 222 | } 223 | } 224 | 225 | async fn wait_for_all_tasks(&mut self) -> Result<()> { 226 | while let Some(task) = self.tasks.join_next().await { 227 | task??; 228 | } 229 | 230 | Ok(()) 231 | } 232 | 233 | async fn summary(&self) { 234 | self.bar.finish().await; 235 | println!( 236 | "{:=^width$}", 237 | " Summary ".yellow(), 238 | width = app::LINE_LENGTH 239 | ); 240 | 241 | let ok = format!("ok: {}({})", self.succeed.len(), self.succeed.join(", ")); 242 | info!("{ok}"); 243 | println!("{}", ok.green()); 244 | 245 | let failed = format!("failed: {}({})", self.failed.len(), self.failed.join(", ")); 246 | info!("{failed}"); 247 | println!("{}", failed.red()); 248 | } 249 | 250 | async fn load_all_videos(&mut self) -> Result<()> { 251 | let input = &self.config.input; 252 | for file in Self::walk_dir(&input.path, &input.excludes) 253 | .await 254 | .with_context(|| "walk dir")? 255 | { 256 | let name = match file.file_name().and_then(|name| name.to_str()) { 257 | Some(name) => name, 258 | None => continue, 259 | }; 260 | 261 | let (file_name, ext) = match name.split_once('.') { 262 | Some(res) => res, 263 | None => continue, 264 | }; 265 | 266 | if input.exts.iter().any(|e| e == ext) { 267 | let (video_ty, idx) = VideoType::parse(file_name); 268 | 269 | let video = self 270 | .videos 271 | .entry(video_ty.clone()) 272 | .or_insert(Video::new(video_ty)); 273 | video.add_file( 274 | VideoFile::builder() 275 | .location(&file) 276 | .ext(ext) 277 | .idx(idx) 278 | .build(), 279 | ); 280 | } 281 | } 282 | 283 | self.bar.set_total(self.videos.len()).await; 284 | let videos = self 285 | .videos 286 | .values() 287 | .map(|video| video.ty().to_string()) 288 | .collect::>() 289 | .join(", "); 290 | let summary = format!("found videos: {}({})", self.videos.len(), videos); 291 | info!("{summary}"); 292 | self.bar.message(summary); 293 | 294 | Ok(()) 295 | } 296 | 297 | async fn walk_dir(path: &Path, excludes: &[String]) -> Result> { 298 | let mut files = Vec::new(); 299 | let mut entries = fs::read_dir(path) 300 | .await 301 | .with_context(|| format!("read dir in {}", path.display()))?; 302 | while let Some(entry) = entries.next_entry().await? { 303 | let file = entry.path(); 304 | 305 | let name = match file.file_name().and_then(|name| name.to_str()) { 306 | Some(name) => name, 307 | None => continue, 308 | }; 309 | 310 | let should_pass = excludes.iter().any(|e| e == name); 311 | if should_pass { 312 | warn!("skip {}", file.display()); 313 | continue; 314 | } 315 | 316 | if file.is_dir() { 317 | let child_files = Box::pin(Self::walk_dir(&file, excludes)).await?; 318 | files.extend(child_files); 319 | continue; 320 | } 321 | 322 | info!("found video {}", file.display()); 323 | files.push(file); 324 | } 325 | 326 | Ok(files) 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /crates/javcap/src/bar.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, IsTerminal, Write}; 2 | use std::sync::Arc; 3 | use std::time::Duration; 4 | 5 | use colored::Colorize; 6 | use tokio::sync::{Mutex, Notify, RwLock}; 7 | use tokio::time; 8 | 9 | pub struct Bar { 10 | cnt: Arc>, 11 | total: Arc>, 12 | should_quit: Arc>, 13 | notify: Arc, 14 | disabled: bool, 15 | } 16 | 17 | impl Bar { 18 | pub async fn new() -> Bar { 19 | let disabled = !io::stdout().is_terminal(); 20 | let bar = Bar { 21 | total: Arc::new(Mutex::new(0)), 22 | cnt: Arc::new(RwLock::new(0)), 23 | should_quit: Arc::new(RwLock::new(false)), 24 | notify: Arc::new(Notify::new()), 25 | disabled, 26 | }; 27 | bar.start().await; 28 | 29 | bar 30 | } 31 | 32 | pub async fn set_total(&self, total: usize) { 33 | let mut t = self.total.lock().await; 34 | *t = total; 35 | } 36 | 37 | async fn start(&self) { 38 | if self.disabled { 39 | return; 40 | } 41 | 42 | let should_quit = self.should_quit.clone(); 43 | let notify = self.notify.clone(); 44 | let cnt = self.cnt.clone(); 45 | let total = self.total.clone(); 46 | tokio::spawn(async move { 47 | let mut idx = 0; 48 | let interval = Duration::from_millis(200); 49 | let bar = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]; 50 | let bar_len = bar.len(); 51 | let line_len = *app::LINE_LENGTH - 20; 52 | 53 | loop { 54 | let total = { *total.lock().await }; 55 | let cnt = { *cnt.read().await }; 56 | let per = if total == 0 { 0 } else { cnt * 100 / total }; 57 | let p = per * line_len / 100; 58 | print!( 59 | "\r{}", 60 | format!( 61 | "{spinner}|{per}%|{fill:░) { 98 | let msg = msg.as_ref(); 99 | if self.disabled { 100 | println!("{msg}"); 101 | } else { 102 | println!("\r{}\r{msg}", " ".repeat(*app::LINE_LENGTH)); 103 | } 104 | } 105 | 106 | pub async fn add(&self) { 107 | let mut cnt = self.cnt.write().await; 108 | *cnt += 1; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /crates/javcap/src/helper.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use config::Config; 3 | use spider::Spider; 4 | use tokio::sync::Semaphore; 5 | use translator::Translator; 6 | 7 | pub struct Helper { 8 | pub sema: Semaphore, 9 | pub spider: Spider, 10 | pub translator: Translator, 11 | } 12 | 13 | impl Helper { 14 | pub fn new(config: &Config) -> Result { 15 | let sema = Semaphore::new(config.task_limit); 16 | let spider = Spider::new(config).with_context(|| "build spider")?; 17 | let translator = Translator::new(config).with_context(|| "build translator")?; 18 | let helper = Helper { 19 | sema, 20 | spider, 21 | translator, 22 | }; 23 | 24 | Ok(helper) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/javcap/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod bar; 3 | mod helper; 4 | mod message; 5 | mod payload; 6 | 7 | pub use app::App; 8 | -------------------------------------------------------------------------------- /crates/javcap/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs::OpenOptions; 3 | use std::io::Write; 4 | use std::path::PathBuf; 5 | use std::process::ExitCode; 6 | 7 | use anyhow::{Context, Result}; 8 | use chrono::Local; 9 | use clap::{Parser, Subcommand}; 10 | use colored::Colorize; 11 | use config::Config; 12 | use env_logger::{Builder, Target}; 13 | use javcap::App; 14 | use log::{LevelFilter, error, info}; 15 | use self_update::Status; 16 | use self_update::backends::github::Update; 17 | use tokio::fs; 18 | use validator::Validate; 19 | 20 | #[derive(Parser)] 21 | #[command(version = app::VERSION)] 22 | #[command(long_about = "电影刮削器")] 23 | struct Cli { 24 | #[command(subcommand)] 25 | command: Option, 26 | } 27 | 28 | #[derive(Subcommand)] 29 | enum Commands { 30 | /// 搜索并刮削 31 | Run { 32 | /// 配置文件路径 33 | #[arg(short, long)] 34 | config: Option, 35 | }, 36 | 37 | /// 显示默认配置 38 | Config, 39 | 40 | /// 显示上次运行的日志 41 | Log, 42 | 43 | /// 更新程序 44 | Upgrade, 45 | } 46 | 47 | #[tokio::main] 48 | async fn main() -> ExitCode { 49 | let cli = Cli::parse(); 50 | match cli.command { 51 | Some(command) => match command { 52 | Commands::Run { config } => run(config).await, 53 | Commands::Config => { 54 | println!("{}", Config::DEFAULT_CONFIG.trim_end()); 55 | ExitCode::SUCCESS 56 | } 57 | Commands::Log => log().await, 58 | Commands::Upgrade => upgrade().await, 59 | }, 60 | None => run(None).await, 61 | } 62 | } 63 | 64 | async fn upgrade() -> ExitCode { 65 | match _upgrade().await { 66 | Ok(_) => ExitCode::SUCCESS, 67 | Err(e) => { 68 | eprintln!("{e:?}"); 69 | ExitCode::FAILURE 70 | } 71 | } 72 | } 73 | 74 | async fn _upgrade() -> Result<()> { 75 | println!("check for update..."); 76 | let status = tokio::task::spawn_blocking(check_for_update).await??; 77 | if status.updated() { 78 | println!("updated to version v{}", status.version()); 79 | } else { 80 | println!("latest version, nothing to do today"); 81 | } 82 | 83 | Ok(()) 84 | } 85 | 86 | async fn log() -> ExitCode { 87 | let log_dir = log_dir(); 88 | let log_file = log_dir.join("log"); 89 | 90 | if !log_file.exists() { 91 | eprintln!("no log file found"); 92 | return ExitCode::FAILURE; 93 | } 94 | 95 | match fs::read_to_string(&log_file) 96 | .await 97 | .with_context(|| "read log file") 98 | { 99 | Ok(content) => { 100 | println!("{}", content.trim_end()); 101 | ExitCode::SUCCESS 102 | } 103 | Err(e) => { 104 | eprintln!("{e:?}"); 105 | ExitCode::FAILURE 106 | } 107 | } 108 | } 109 | 110 | async fn run(config: Option) -> ExitCode { 111 | println!("{}", ">".repeat(*app::LINE_LENGTH).yellow()); 112 | let banner = include_str!("../banner"); 113 | for line in banner.lines() { 114 | let padding = if *app::LINE_LENGTH <= 49 { 115 | 0 116 | } else { 117 | (*app::LINE_LENGTH - 49) / 2 118 | }; 119 | println!( 120 | "{}{}{}", 121 | " ".repeat(padding), 122 | line.yellow(), 123 | " ".repeat(padding) 124 | ); 125 | } 126 | println!(); 127 | println!( 128 | "{:^width$}", 129 | format!("v{}({})", app::VERSION, app::HASH).yellow(), 130 | width = app::LINE_LENGTH 131 | ); 132 | println!( 133 | "{:^width$}", 134 | "https://github.com/jane-212/javcap".yellow(), 135 | width = app::LINE_LENGTH 136 | ); 137 | println!(); 138 | let code = match _run(config).await { 139 | Ok(_) => ExitCode::SUCCESS, 140 | Err(e) => { 141 | eprintln!("{:#^width$}", " Error ".red(), width = app::LINE_LENGTH); 142 | eprintln!("{}", format!("{e:?}").red()); 143 | error!("{e:?}"); 144 | ExitCode::FAILURE 145 | } 146 | }; 147 | println!("{}", "<".repeat(*app::LINE_LENGTH).yellow()); 148 | code 149 | } 150 | 151 | async fn _run(config: Option) -> Result<()> { 152 | init_logger().await.with_context(|| "init logger")?; 153 | info!("app version: v{}({})", app::VERSION, app::HASH); 154 | 155 | let config = match config { 156 | Some(path) => Config::load_from(path) 157 | .await 158 | .with_context(|| "load config")?, 159 | None => Config::load().await.with_context(|| "load config")?, 160 | }; 161 | config.validate().with_context(|| "validate config")?; 162 | 163 | if config.check_for_update { 164 | info!("check for update..."); 165 | println!("check for update..."); 166 | let status = tokio::task::spawn_blocking(check_for_update).await??; 167 | if status.updated() { 168 | info!("updated to version v{}", status.version()); 169 | println!("updated to version v{}", status.version()); 170 | return Ok(()); 171 | } 172 | 173 | info!("latest version, skip"); 174 | println!("latest version, skip"); 175 | } 176 | 177 | let app = App::new(config).await.with_context(|| "init app")?; 178 | 179 | app.run().await.with_context(|| "run app") 180 | } 181 | 182 | fn check_for_update() -> Result { 183 | let status = Update::configure() 184 | .repo_owner("jane-212") 185 | .repo_name("javcap") 186 | .bin_name("javcap") 187 | .no_confirm(true) 188 | .show_output(false) 189 | .show_download_progress(true) 190 | .current_version(app::VERSION) 191 | .build() 192 | .with_context(|| "build update config")? 193 | .update() 194 | .with_context(|| "self update")?; 195 | 196 | Ok(status) 197 | } 198 | 199 | fn log_dir() -> PathBuf { 200 | let username = whoami::username(); 201 | #[cfg(target_os = "macos")] 202 | let user_dir = PathBuf::from("/Users").join(username); 203 | #[cfg(target_os = "linux")] 204 | let user_dir = PathBuf::from("/home").join(username); 205 | #[cfg(target_os = "windows")] 206 | let user_dir = PathBuf::from("C:\\Users").join(username); 207 | 208 | user_dir.join(".cache").join(app::NAME) 209 | } 210 | 211 | async fn init_logger() -> Result<()> { 212 | let log_dir = log_dir(); 213 | if !log_dir.exists() { 214 | fs::create_dir_all(&log_dir) 215 | .await 216 | .with_context(|| format!("create dir {}", log_dir.display()))?; 217 | } 218 | let log_file = log_dir.join("log"); 219 | if log_file.exists() { 220 | let to = log_dir.join("old.log"); 221 | fs::rename(&log_file, &to) 222 | .await 223 | .with_context(|| format!("rename {} to {}", log_file.display(), to.display()))?; 224 | } 225 | let log_file = OpenOptions::new() 226 | .create(true) 227 | .truncate(true) 228 | .write(true) 229 | .open(&log_file) 230 | .with_context(|| format!("open {}", log_file.display()))?; 231 | 232 | const LOG_ENV_KEY: &str = "LOG"; 233 | let mut logger = if env::var(LOG_ENV_KEY) 234 | .map(|log| log.is_empty()) 235 | .unwrap_or(true) 236 | { 237 | // fallback to info level if `LOG` is not set or empty 238 | let mut logger = Builder::new(); 239 | logger.filter_level(LevelFilter::Info); 240 | logger 241 | } else { 242 | Builder::from_env(LOG_ENV_KEY) 243 | }; 244 | logger 245 | .format(|buf, record| { 246 | writeln!( 247 | buf, 248 | "[{} {:<5} {}] {}", 249 | Local::now().format("%Y-%m-%dT%H:%M:%S"), 250 | record.level(), 251 | record.target(), 252 | record.args(), 253 | ) 254 | }) 255 | .target(Target::Pipe(Box::new(log_file))) 256 | .init(); 257 | 258 | Ok(()) 259 | } 260 | -------------------------------------------------------------------------------- /crates/javcap/src/message.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | 3 | use super::payload::Payload; 4 | 5 | pub enum Message { 6 | Loaded(Box), 7 | Failed(String, String), 8 | } 9 | 10 | impl Display for Message { 11 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 12 | match self { 13 | Message::Loaded(payload) => write!(f, "{}", payload.video().ty()), 14 | Message::Failed(name, _) => write!(f, "{name}"), 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /crates/javcap/src/payload.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::sync::Arc; 3 | 4 | use anyhow::{Context, Result}; 5 | use bon::bon; 6 | use colored::Colorize; 7 | use config::Tag; 8 | use getset::Getters; 9 | use log::info; 10 | use nfo::Nfo; 11 | use tokio::fs::{self, OpenOptions}; 12 | use tokio::io::AsyncWriteExt; 13 | use video::{Video, VideoType}; 14 | 15 | use super::bar::Bar; 16 | 17 | #[derive(Getters)] 18 | pub struct Payload { 19 | #[getset(get = "pub")] 20 | video: Video, 21 | nfo: Nfo, 22 | bar: Arc, 23 | } 24 | 25 | #[bon] 26 | impl Payload { 27 | #[builder] 28 | pub fn new(video: Video, nfo: Nfo, bar: Arc) -> Payload { 29 | Payload { video, nfo, bar } 30 | } 31 | 32 | async fn write_fanart_to(&self, path: &Path) -> Result<()> { 33 | let name = self.video.ty(); 34 | let filename = format!("{name}-fanart.jpg"); 35 | let file = path.join(filename); 36 | Self::write_to_file(self.nfo.fanart(), &file) 37 | .await 38 | .with_context(|| format!("write to file {}", file.display()))?; 39 | info!("write fanart of {name} to {}", file.display()); 40 | self.bar.message(format!("fanart ... {}", "ok".green())); 41 | 42 | Ok(()) 43 | } 44 | 45 | async fn write_poster_to(&self, path: &Path) -> Result<()> { 46 | let name = self.video.ty(); 47 | let filename = format!("{name}-poster.jpg"); 48 | let file = path.join(filename); 49 | Self::write_to_file(self.nfo.poster(), &file) 50 | .await 51 | .with_context(|| format!("write to file {}", file.display()))?; 52 | info!("write poster of {name} to {}", file.display()); 53 | self.bar.message(format!("poster ... {}", "ok".green())); 54 | 55 | Ok(()) 56 | } 57 | 58 | async fn write_to_file(bytes: &[u8], file: &Path) -> Result<()> { 59 | OpenOptions::new() 60 | .create(true) 61 | .truncate(true) 62 | .write(true) 63 | .open(file) 64 | .await 65 | .with_context(|| format!("open {}", file.display()))? 66 | .write_all(bytes) 67 | .await 68 | .with_context(|| "write content")?; 69 | 70 | Ok(()) 71 | } 72 | 73 | async fn write_nfo_to(&self, path: &Path) -> Result<()> { 74 | let name = self.video.ty(); 75 | let filename = format!("{name}.nfo"); 76 | let file = path.join(filename); 77 | let nfo = self.nfo.to_string(); 78 | Self::write_to_file(nfo.as_bytes(), &file) 79 | .await 80 | .with_context(|| format!("write to file {}", file.display()))?; 81 | info!("write nfo of {name} to {}", file.display()); 82 | self.bar.message(format!("nfo ... {}", "ok".green())); 83 | 84 | Ok(()) 85 | } 86 | 87 | async fn write_subtitle_to(&self, path: &Path) -> Result<()> { 88 | if self.nfo.subtitle().is_empty() { 89 | self.bar.message(format!("subtitle ... {}", "no".red())); 90 | return Ok(()); 91 | } 92 | 93 | let name = self.video.ty(); 94 | let filename = format!("{name}.srt"); 95 | let file = path.join(filename); 96 | Self::write_to_file(self.nfo.subtitle(), &file) 97 | .await 98 | .with_context(|| format!("write to file {}", file.display()))?; 99 | info!("write subtitle of {name} to {}", file.display()); 100 | self.bar.message(format!("subtitle ... {}", "ok".green())); 101 | 102 | Ok(()) 103 | } 104 | 105 | pub async fn move_videos_to(&self, path: &Path) -> Result<()> { 106 | let name = self.video.ty(); 107 | for video in self.video.files() { 108 | let idx = video.idx(); 109 | let filename = if *idx == 0 { 110 | format!("{name}.{}", video.ext()) 111 | } else { 112 | format!("{name}-CD{idx}.{}", video.ext()) 113 | }; 114 | let out = path.join(&filename); 115 | if out.exists() { 116 | info!("video already exists {}", out.display()); 117 | self.bar 118 | .message(format!("video already exists {}", out.display())); 119 | continue; 120 | } 121 | let src = video.location(); 122 | fs::rename(src, &out).await?; 123 | info!( 124 | "move video of {name} from {} to {}", 125 | src.display(), 126 | out.display() 127 | ); 128 | let msg = if *idx == 0 { 129 | format!("video ... {}", "ok".green()) 130 | } else { 131 | format!("video({idx}) ... {}", "ok".green()) 132 | }; 133 | self.bar.message(msg); 134 | } 135 | 136 | Ok(()) 137 | } 138 | 139 | pub fn get_by_tag(&self, tag: &Tag) -> String { 140 | match tag { 141 | Tag::Title => self.nfo.title().to_string(), 142 | Tag::Studio => self.nfo.studio().to_string(), 143 | Tag::Id => match self.video.ty() { 144 | VideoType::Jav(id, _) => id.to_string(), 145 | VideoType::Fc2(_) => "FC2-PPV".to_string(), 146 | VideoType::Other(_) => "OTHER".to_string(), 147 | }, 148 | Tag::Name => self.video.ty().to_string(), 149 | Tag::Director => self.nfo.director().to_string(), 150 | Tag::Country => self.nfo.country().to_string(), 151 | Tag::Actor => self 152 | .nfo 153 | .actors() 154 | .iter() 155 | .next() 156 | .map(|actor| actor.as_str()) 157 | .unwrap_or("未知") 158 | .to_string(), 159 | } 160 | } 161 | 162 | pub async fn write_all_to(&self, path: &Path) -> Result<()> { 163 | self.write_fanart_to(path) 164 | .await 165 | .with_context(|| "write fanart")?; 166 | self.write_poster_to(path) 167 | .await 168 | .with_context(|| "write poster")?; 169 | self.write_subtitle_to(path) 170 | .await 171 | .with_context(|| "write subtitle")?; 172 | self.write_nfo_to(path).await.with_context(|| "write nfo")?; 173 | 174 | Ok(()) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /crates/nfo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nfo" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | video.workspace = true 8 | 9 | indoc.workspace = true 10 | getset.workspace = true 11 | validator.workspace = true 12 | bon.workspace = true 13 | educe.workspace = true 14 | quick-xml.workspace = true 15 | -------------------------------------------------------------------------------- /crates/nfo/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::fmt::{self, Debug, Display}; 3 | use std::hash::Hash; 4 | 5 | use bon::bon; 6 | use educe::Educe; 7 | use getset::{Getters, MutGetters, Setters}; 8 | use indoc::writedoc; 9 | use quick_xml::escape::escape; 10 | use validator::Validate; 11 | use video::VideoType; 12 | 13 | #[derive(Setters, Getters, MutGetters, Validate, Educe)] 14 | #[educe(PartialEq)] 15 | pub struct Nfo { 16 | id: String, 17 | 18 | #[getset(set = "pub", get = "pub")] 19 | #[validate(length(min = 1, message = "empty"))] 20 | title: String, 21 | 22 | #[getset(set = "pub")] 23 | rating: f64, 24 | 25 | #[getset(set = "pub", get = "pub")] 26 | #[validate(length(min = 1, message = "empty"))] 27 | plot: String, 28 | 29 | #[getset(set = "pub")] 30 | runtime: u32, 31 | 32 | mpaa: Mpaa, 33 | 34 | #[getset(get_mut = "pub")] 35 | #[validate(length(min = 1, message = "empty"))] 36 | genres: HashSet, 37 | 38 | #[getset(get = "pub")] 39 | country: Country, 40 | 41 | #[getset(set = "pub", get = "pub")] 42 | #[validate(length(min = 1, message = "empty"))] 43 | director: String, 44 | 45 | #[getset(set = "pub")] 46 | #[validate(length(min = 1, message = "empty"))] 47 | premiered: String, 48 | 49 | #[getset(set = "pub", get = "pub")] 50 | #[validate(length(min = 1, message = "empty"))] 51 | studio: String, 52 | 53 | #[getset(get_mut = "pub", get = "pub")] 54 | #[validate(length(min = 1, message = "empty"))] 55 | actors: HashSet, 56 | 57 | #[getset(set = "pub", get = "pub")] 58 | #[validate(length(min = 1, message = "empty"))] 59 | #[educe(PartialEq(ignore))] 60 | poster: Vec, 61 | 62 | #[getset(set = "pub", get = "pub")] 63 | #[validate(length(min = 1, message = "empty"))] 64 | #[educe(PartialEq(ignore))] 65 | fanart: Vec, 66 | 67 | #[getset(set = "pub", get = "pub")] 68 | #[educe(PartialEq(ignore))] 69 | subtitle: Vec, 70 | } 71 | 72 | #[bon] 73 | impl Nfo { 74 | #[builder] 75 | pub fn new(id: impl Into, country: Option, mpaa: Option) -> Nfo { 76 | Nfo { 77 | id: id.into(), 78 | country: country.unwrap_or(Country::Unknown), 79 | mpaa: mpaa.unwrap_or(Mpaa::G), 80 | title: String::new(), 81 | rating: 0.0, 82 | plot: String::new(), 83 | runtime: 0, 84 | genres: HashSet::new(), 85 | director: String::new(), 86 | premiered: String::new(), 87 | studio: String::new(), 88 | actors: HashSet::new(), 89 | poster: Vec::new(), 90 | fanart: Vec::new(), 91 | subtitle: Vec::new(), 92 | } 93 | } 94 | 95 | pub fn auto_fix_by_key(&mut self, key: &VideoType) { 96 | if self.plot.is_empty() { 97 | self.plot = self.title.clone(); 98 | } 99 | match key { 100 | VideoType::Jav(_, _) => { 101 | if self.director.is_empty() { 102 | self.director = self.studio.clone(); 103 | } 104 | } 105 | VideoType::Fc2(_) => { 106 | if self.studio.is_empty() { 107 | self.studio = "FC2-PPV".to_string(); 108 | } 109 | if self.director.is_empty() { 110 | self.director = self.studio.clone(); 111 | } 112 | if self.genres.is_empty() { 113 | let director = self.director.clone(); 114 | self.genres_mut().insert(director); 115 | } 116 | if self.actors.is_empty() { 117 | let director = self.director.clone(); 118 | self.actors_mut().insert(director); 119 | } 120 | } 121 | VideoType::Other(_) => { 122 | if self.poster.is_empty() { 123 | self.poster = self.fanart.clone(); 124 | } 125 | if self.genres.is_empty() { 126 | self.genres.insert(self.director.clone()); 127 | } 128 | if self.actors.is_empty() { 129 | self.actors.insert(self.director.clone()); 130 | } 131 | } 132 | } 133 | } 134 | 135 | pub fn merge(&mut self, other: Nfo) { 136 | self.title.merge(other.title); 137 | self.rating.merge(other.rating); 138 | self.plot.merge(other.plot); 139 | self.runtime.merge(other.runtime); 140 | self.mpaa.merge(other.mpaa); 141 | self.genres.merge(other.genres); 142 | self.country.merge(other.country); 143 | self.director.merge(other.director); 144 | self.premiered.merge(other.premiered); 145 | self.studio.merge(other.studio); 146 | self.actors.merge(other.actors); 147 | self.poster.merge(other.poster); 148 | self.fanart.merge(other.fanart); 149 | self.subtitle.merge(other.subtitle); 150 | } 151 | } 152 | 153 | trait Merge { 154 | fn merge(&mut self, other: Self); 155 | } 156 | 157 | impl Merge for Vec { 158 | fn merge(&mut self, other: Self) { 159 | if self.len() < other.len() { 160 | *self = other; 161 | } 162 | } 163 | } 164 | 165 | impl Merge for u32 { 166 | fn merge(&mut self, other: Self) { 167 | if *self < other { 168 | *self = other; 169 | } 170 | } 171 | } 172 | 173 | impl Merge for f64 { 174 | fn merge(&mut self, other: Self) { 175 | if *self < other { 176 | *self = other; 177 | } 178 | } 179 | } 180 | 181 | impl Merge for HashSet { 182 | fn merge(&mut self, other: Self) { 183 | self.extend(other); 184 | } 185 | } 186 | 187 | impl Merge for String { 188 | fn merge(&mut self, other: Self) { 189 | if self.len() < other.len() { 190 | *self = other; 191 | } 192 | } 193 | } 194 | 195 | impl Debug for Nfo { 196 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 197 | writedoc!( 198 | f, 199 | " 200 | id: {} 201 | country: {} 202 | mpaa: {} 203 | title: {} 204 | rating: {} 205 | plot: {} 206 | runtime: {} 207 | genres: {} 208 | director: {} 209 | premiered: {} 210 | studio: {} 211 | actors: {} 212 | fanart: {} 213 | poster: {} 214 | subtitle: {}", 215 | self.id, 216 | self.country, 217 | self.mpaa, 218 | self.title, 219 | self.rating, 220 | self.plot, 221 | self.runtime, 222 | self.genres 223 | .iter() 224 | .map(|genre| genre.as_str()) 225 | .collect::>() 226 | .join(", "), 227 | self.director, 228 | self.premiered, 229 | self.studio, 230 | self.actors 231 | .iter() 232 | .map(|actor| actor.as_str()) 233 | .collect::>() 234 | .join(", "), 235 | self.fanart.len(), 236 | self.poster.len(), 237 | self.subtitle.len(), 238 | ) 239 | } 240 | } 241 | 242 | impl Display for Nfo { 243 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 244 | writedoc!( 245 | f, 246 | " 247 | 248 | 249 | {title} 250 | {title} 251 | {rating:.1} 252 | {plot} 253 | {runtime} 254 | {mpaa} 255 | {id} 256 | {genres} 257 | {tags} 258 | {country} 259 | {director} 260 | {premiered} 261 | {studio} 262 | {actors} 263 | ", 264 | title = escape(&self.title), 265 | rating = self.rating, 266 | plot = escape(&self.plot), 267 | runtime = self.runtime, 268 | mpaa = self.mpaa, 269 | id = self.id, 270 | genres = self 271 | .genres 272 | .iter() 273 | .map(|genre| format!(" {}", escape(genre))) 274 | .collect::>() 275 | .join("\n"), 276 | tags = self 277 | .genres 278 | .iter() 279 | .map(|genre| format!(" {}", escape(genre))) 280 | .collect::>() 281 | .join("\n"), 282 | country = self.country, 283 | director = escape(&self.director), 284 | premiered = self.premiered, 285 | studio = escape(&self.studio), 286 | actors = self 287 | .actors 288 | .iter() 289 | .map(|actor| format!( 290 | " \n {}\n ", 291 | escape(actor) 292 | )) 293 | .collect::>() 294 | .join("\n"), 295 | ) 296 | } 297 | } 298 | 299 | #[derive(PartialEq, Eq)] 300 | pub enum Country { 301 | Unknown, 302 | Japan, 303 | China, 304 | } 305 | 306 | impl Display for Country { 307 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 308 | write!( 309 | f, 310 | "{}", 311 | match self { 312 | Country::Unknown => "未知", 313 | Country::Japan => "日本", 314 | Country::China => "国产", 315 | } 316 | ) 317 | } 318 | } 319 | 320 | impl Merge for Country { 321 | fn merge(&mut self, other: Self) { 322 | if Country::Unknown == *self { 323 | *self = other; 324 | } 325 | } 326 | } 327 | 328 | #[derive(PartialEq, Eq, PartialOrd, Ord)] 329 | pub enum Mpaa { 330 | G, 331 | PG, 332 | PG13, 333 | R, 334 | NC17, 335 | } 336 | 337 | impl Display for Mpaa { 338 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 339 | write!( 340 | f, 341 | "{}", 342 | match self { 343 | Mpaa::G => "G", 344 | Mpaa::PG => "PG", 345 | Mpaa::PG13 => "PG-13", 346 | Mpaa::R => "R", 347 | Mpaa::NC17 => "NC-17", 348 | } 349 | ) 350 | } 351 | } 352 | 353 | impl Merge for Mpaa { 354 | fn merge(&mut self, other: Self) { 355 | if *self < other { 356 | *self = other; 357 | } 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /crates/spider/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spider" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | nfo.workspace = true 8 | video.workspace = true 9 | config.workspace = true 10 | http-client.workspace = true 11 | 12 | anyhow.workspace = true 13 | async-trait.workspace = true 14 | log.workspace = true 15 | bon.workspace = true 16 | serde.workspace = true 17 | tokio.workspace = true 18 | scraper.workspace = true 19 | reqwest.workspace = true 20 | serde_json.workspace = true 21 | 22 | [dev-dependencies] 23 | pretty_assertions.workspace = true 24 | -------------------------------------------------------------------------------- /crates/spider/src/airav.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use scraper::Html; 11 | use video::VideoType; 12 | 13 | use super::{Finder, select, which_country}; 14 | 15 | const HOST: &str = "https://airav.io"; 16 | 17 | select!( 18 | home_item: "body > div:nth-child(4) > div > div.row.row-cols-2.row-cols-lg-4.g-2.mt-0 > div" 19 | home_title: "div > div.oneVideo-body > h5" 20 | home_fanart: "div > div.oneVideo-top > a > img" 21 | home_url: "div > div.oneVideo-top > a" 22 | detail_date: "body > div:nth-child(4) > div.container > div > div.col-lg-9.col-12.pt-3 > div.video-item > div.me-4" 23 | detail_plot: "body > div:nth-child(4) > div.container > div > div.col-lg-9.col-12.pt-3 > div.video-info > p" 24 | detail_name: "body > div:nth-child(4) > div.container > div > div.col-lg-9.col-12.pt-3 > div.video-info > div > ul > li" 25 | detail_tag: "a" 26 | ); 27 | 28 | pub struct Airav { 29 | base_url: String, 30 | client: Client, 31 | selectors: Selectors, 32 | } 33 | 34 | #[bon] 35 | impl Airav { 36 | #[builder] 37 | pub fn new( 38 | base_url: Option, 39 | timeout: Duration, 40 | proxy: Option, 41 | ) -> Result { 42 | let client = Client::builder() 43 | .timeout(timeout) 44 | .interval(1) 45 | .maybe_proxy(proxy) 46 | .build() 47 | .with_context(|| "build http client")?; 48 | let selectors = Selectors::new().with_context(|| "build selectors")?; 49 | let base_url = match base_url { 50 | Some(url) => url, 51 | None => String::from(HOST), 52 | }; 53 | 54 | let airav = Airav { 55 | base_url, 56 | client, 57 | selectors, 58 | }; 59 | Ok(airav) 60 | } 61 | } 62 | 63 | impl Display for Airav { 64 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 65 | write!(f, "airav") 66 | } 67 | } 68 | 69 | #[async_trait] 70 | impl Finder for Airav { 71 | fn support(&self, key: &VideoType) -> bool { 72 | match key { 73 | VideoType::Jav(_, _) => !matches!(which_country(key), Country::China), 74 | VideoType::Fc2(_) => true, 75 | VideoType::Other(_) => false, 76 | } 77 | } 78 | 79 | async fn find(&self, key: &VideoType) -> Result { 80 | let mut nfo = Nfo::builder() 81 | .id(key) 82 | .country(Country::Japan) 83 | .mpaa(Mpaa::NC17) 84 | .build(); 85 | 86 | let (url, fanart) = self 87 | .find_in_home(key, &mut nfo) 88 | .await 89 | .with_context(|| "find in home")?; 90 | if let Some(fanart) = fanart { 91 | let fanart = self 92 | .client 93 | .wait() 94 | .await 95 | .get(fanart) 96 | .send() 97 | .await? 98 | .bytes() 99 | .await? 100 | .to_vec(); 101 | nfo.set_fanart(fanart); 102 | } 103 | if let Some(url) = url { 104 | self.find_detail(&url, &mut nfo) 105 | .await 106 | .with_context(|| "find detail")?; 107 | } 108 | 109 | info!("{nfo:?}"); 110 | Ok(nfo) 111 | } 112 | } 113 | 114 | impl Airav { 115 | async fn find_in_home( 116 | &self, 117 | key: &VideoType, 118 | nfo: &mut Nfo, 119 | ) -> Result<(Option, Option)> { 120 | let url = format!("{}/search_result", self.base_url); 121 | let text = self 122 | .client 123 | .wait() 124 | .await 125 | .get(url) 126 | .query(&[("kw", key.to_string())]) 127 | .send() 128 | .await? 129 | .text() 130 | .await?; 131 | let html = Html::parse_document(&text); 132 | 133 | let mut url = None; 134 | let mut fanart = None; 135 | let name = key.to_string(); 136 | for item in html.select(&self.selectors.home_item) { 137 | let Some(title) = item 138 | .select(&self.selectors.home_title) 139 | .next() 140 | .map(|node| node.text().collect::()) 141 | else { 142 | continue; 143 | }; 144 | if !title.contains(&name) || title.contains("克破") { 145 | continue; 146 | } 147 | nfo.set_title(title.trim_start_matches(&name).trim().to_string()); 148 | 149 | fanart = item 150 | .select(&self.selectors.home_fanart) 151 | .next() 152 | .and_then(|node| node.attr("src").map(String::from)); 153 | 154 | url = item 155 | .select(&self.selectors.home_url) 156 | .next() 157 | .and_then(|node| { 158 | node.attr("href") 159 | .map(|href| format!("{}{href}", self.base_url)) 160 | }); 161 | 162 | if url.is_some() && fanart.is_some() { 163 | break; 164 | } 165 | } 166 | 167 | Ok((url, fanart)) 168 | } 169 | 170 | async fn find_detail(&self, url: &str, nfo: &mut Nfo) -> Result<()> { 171 | let text = self 172 | .client 173 | .wait() 174 | .await 175 | .get(url) 176 | .send() 177 | .await? 178 | .text() 179 | .await?; 180 | let html = Html::parse_document(&text); 181 | 182 | if let Some(date) = html 183 | .select(&self.selectors.detail_date) 184 | .next() 185 | .and_then(|node| node.text().last()) 186 | .and_then(|text| text.split_once(' ').map(|(date, _)| date)) 187 | .map(String::from) 188 | { 189 | nfo.set_premiered(date); 190 | } 191 | 192 | if let Some(plot) = html 193 | .select(&self.selectors.detail_plot) 194 | .next() 195 | .map(|node| node.text().collect()) 196 | { 197 | nfo.set_plot(plot); 198 | } 199 | 200 | for item in html.select(&self.selectors.detail_name) { 201 | let Some(name) = item.text().next() else { 202 | continue; 203 | }; 204 | 205 | match name.trim().trim_end_matches(':') { 206 | "女優" => { 207 | for tag in item.select(&self.selectors.detail_tag) { 208 | let tag = tag.text().collect(); 209 | nfo.actors_mut().insert(tag); 210 | } 211 | } 212 | "標籤" => { 213 | for tag in item.select(&self.selectors.detail_tag) { 214 | let tag = tag.text().collect(); 215 | nfo.genres_mut().insert(tag); 216 | } 217 | } 218 | "廠商" => { 219 | if let Some(tag) = item 220 | .select(&self.selectors.detail_tag) 221 | .next() 222 | .map(|node| node.text().collect()) 223 | { 224 | nfo.set_studio(tag); 225 | } 226 | } 227 | _ => {} 228 | } 229 | } 230 | 231 | Ok(()) 232 | } 233 | } 234 | 235 | #[cfg(test)] 236 | mod tests { 237 | use super::*; 238 | use pretty_assertions::assert_eq; 239 | 240 | fn finder() -> Result { 241 | Airav::builder().timeout(Duration::from_secs(10)).build() 242 | } 243 | 244 | #[test] 245 | fn test_support() -> Result<()> { 246 | let finder = finder()?; 247 | let videos = [ 248 | VideoType::Jav("STARS".to_string(), "804".to_string()), 249 | VideoType::Fc2("3061625".to_string()), 250 | ]; 251 | for video in videos { 252 | assert!(finder.support(&video)); 253 | } 254 | 255 | Ok(()) 256 | } 257 | 258 | #[tokio::test] 259 | async fn test_find() -> Result<()> { 260 | let finder = finder()?; 261 | let cases = [ 262 | (VideoType::Jav("STARS".to_string(), "804".to_string()), { 263 | let mut nfo = Nfo::builder() 264 | .id("STARS-804") 265 | .country(Country::Japan) 266 | .mpaa(Mpaa::NC17) 267 | .build(); 268 | nfo.set_title("隨著本能絡合的極上內衣與精油4本番 神木麗".to_string()) 269 | .set_plot("G罩杯的身材,搭配高級內衣和按摩油更能突顯出其絕佳的比例。神木麗進入飯店後懇求般進行濃厚的性愛。綑縛美麗四肢,持續玩弄身軀到絕頂後懇求插入,喘息聲無法壓抑響徹於房間…".to_string()) 270 | .set_studio("SOD".to_string()) 271 | .set_premiered("2023-04-06".to_string()); 272 | let actors = ["神木麗"]; 273 | let genres = [ 274 | "巨乳", 275 | "720p", 276 | "HD高畫質", 277 | "ローション・オイル", 278 | "AV女優片", 279 | "乳交", 280 | "中文", 281 | "性感內衣", 282 | ]; 283 | 284 | for actor in actors { 285 | nfo.actors_mut().insert(actor.to_string()); 286 | } 287 | for genre in genres { 288 | nfo.genres_mut().insert(genre.to_string()); 289 | } 290 | nfo 291 | }), 292 | (VideoType::Jav("IPX".to_string(), "443".to_string()), { 293 | let mut nfo = Nfo::builder() 294 | .id("IPX-443") 295 | .country(Country::Japan) 296 | .mpaa(Mpaa::NC17) 297 | .build(); 298 | nfo.set_title("禁欲一個月後與朋友男友瘋狂做愛 明里紬 合計10回密着性交".to_string()) 299 | .set_plot("雖然我有老婆,但是看到了老婆朋友明里紬誘惑後就心癢癢。知道老婆要不在家開始,就禁慾一個月,只為了與明里紬從早到晚瘋狂做愛…".to_string()) 300 | .set_studio("IDEA POCKET".to_string()) 301 | .set_premiered("2020-02-13".to_string()); 302 | let actors = ["愛里留衣", "明里紬"]; 303 | let genres = [ 304 | "中文", 305 | "寝取り・寝取られ・ntr", 306 | "拘束", 307 | "紀錄片", 308 | "中出", 309 | "AV女優片", 310 | ]; 311 | 312 | for actor in actors { 313 | nfo.actors_mut().insert(actor.to_string()); 314 | } 315 | for genre in genres { 316 | nfo.genres_mut().insert(genre.to_string()); 317 | } 318 | nfo 319 | }), 320 | (VideoType::Fc2("3061625".to_string()), { 321 | let mut nfo = Nfo::builder() 322 | .id("FC2-PPV-3061625") 323 | .country(Country::Japan) 324 | .mpaa(Mpaa::NC17) 325 | .build(); 326 | nfo.set_title( 327 | "人生初拍攝。中出。年度最美少女被蒙面男子精子玷污的那一刻!".to_string(), 328 | ) 329 | .set_plot("本站獨家FC2素人影片,千萬別錯過!!".to_string()) 330 | .set_studio("FC2高清版".to_string()) 331 | .set_premiered("2022-07-30".to_string()); 332 | let genres = [ 333 | "素人", 334 | "真實素人", 335 | "fc2ppv", 336 | "720p", 337 | "自拍", 338 | "巨乳", 339 | "個人撮影", 340 | "學生妹", 341 | ]; 342 | 343 | for genre in genres { 344 | nfo.genres_mut().insert(genre.to_string()); 345 | } 346 | nfo 347 | }), 348 | ]; 349 | for (video, expected) in cases { 350 | let actual = finder.find(&video).await?; 351 | assert!(!actual.fanart().is_empty()); 352 | assert!(actual.poster().is_empty()); 353 | assert!(actual.subtitle().is_empty()); 354 | assert_eq!(actual, expected); 355 | } 356 | 357 | Ok(()) 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /crates/spider/src/avsox.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result, bail}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use scraper::Html; 11 | use video::VideoType; 12 | 13 | use super::{Finder, select, which_country}; 14 | 15 | const HOST: &str = "https://avsox.click"; 16 | 17 | select!( 18 | home_title: "#waterfall > div > a > div.photo-frame > img" 19 | home_date: "#waterfall > div > a > div.photo-info > span > date:nth-child(4)" 20 | home_url: "#waterfall > div > a" 21 | detail_fanart: "body > div.container > div.row.movie > div.col-md-9.screencap > a > img" 22 | detail_genre: "body > div.container > div.row.movie > div.col-md-3.info > p:nth-child(7) > span.genre > a" 23 | detail_info: "body > div.container > div.row.movie > div.col-md-3.info > p" 24 | ); 25 | 26 | pub struct Avsox { 27 | base_url: String, 28 | client: Client, 29 | selectors: Selectors, 30 | } 31 | 32 | #[bon] 33 | impl Avsox { 34 | #[builder] 35 | pub fn new( 36 | base_url: Option, 37 | timeout: Duration, 38 | proxy: Option, 39 | ) -> Result { 40 | let client = Client::builder() 41 | .timeout(timeout) 42 | .interval(1) 43 | .maybe_proxy(proxy) 44 | .build() 45 | .with_context(|| "build http client")?; 46 | let selectors = Selectors::new().with_context(|| "build selectors")?; 47 | let base_url = match base_url { 48 | Some(url) => url, 49 | None => String::from(HOST), 50 | }; 51 | 52 | let avsox = Avsox { 53 | base_url, 54 | client, 55 | selectors, 56 | }; 57 | Ok(avsox) 58 | } 59 | } 60 | 61 | impl Display for Avsox { 62 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 63 | write!(f, "avsox") 64 | } 65 | } 66 | 67 | #[async_trait] 68 | impl Finder for Avsox { 69 | fn support(&self, key: &VideoType) -> bool { 70 | match key { 71 | VideoType::Jav(_, _) => !matches!(which_country(key), Country::China), 72 | VideoType::Fc2(_) => true, 73 | VideoType::Other(_) => false, 74 | } 75 | } 76 | 77 | async fn find(&self, key: &VideoType) -> Result { 78 | let mut nfo = Nfo::builder() 79 | .id(key) 80 | .country(Country::Japan) 81 | .mpaa(Mpaa::NC17) 82 | .build(); 83 | 84 | let (url, poster) = self 85 | .find_in_home(key, &mut nfo) 86 | .await 87 | .with_context(|| "find in home")?; 88 | if let Some(poster) = poster { 89 | let poster = self 90 | .client 91 | .wait() 92 | .await 93 | .get(&poster) 94 | .send() 95 | .await? 96 | .bytes() 97 | .await? 98 | .to_vec(); 99 | nfo.set_poster(poster); 100 | } 101 | 102 | if let Some(url) = url { 103 | let fanart = self 104 | .find_detail(&url, &mut nfo) 105 | .await 106 | .with_context(|| "find detail")?; 107 | if let Some(fanart) = fanart { 108 | let fanart = self 109 | .client 110 | .wait() 111 | .await 112 | .get(&fanart) 113 | .send() 114 | .await? 115 | .bytes() 116 | .await? 117 | .to_vec(); 118 | nfo.set_fanart(fanart); 119 | } 120 | } 121 | 122 | info!("{nfo:?}"); 123 | Ok(nfo) 124 | } 125 | } 126 | 127 | impl Avsox { 128 | async fn find_in_home( 129 | &self, 130 | key: &VideoType, 131 | nfo: &mut Nfo, 132 | ) -> Result<(Option, Option)> { 133 | let url = format!("{}/cn/search/{key}", self.base_url); 134 | let text = self 135 | .client 136 | .wait() 137 | .await 138 | .get(&url) 139 | .send() 140 | .await? 141 | .text() 142 | .await?; 143 | let html = Html::parse_document(&text); 144 | 145 | if let Some(title) = html 146 | .select(&self.selectors.home_title) 147 | .next() 148 | .and_then(|node| node.attr("title")) 149 | { 150 | if title.trim().is_empty() { 151 | bail!("item not found"); 152 | } 153 | 154 | nfo.set_title(title.to_string()); 155 | } 156 | 157 | let poster = html 158 | .select(&self.selectors.home_title) 159 | .next() 160 | .and_then(|node| node.attr("src").map(String::from)); 161 | 162 | if let Some(date) = html 163 | .select(&self.selectors.home_date) 164 | .next() 165 | .map(|node| node.text().collect()) 166 | { 167 | nfo.set_premiered(date); 168 | } 169 | 170 | let url = html 171 | .select(&self.selectors.home_url) 172 | .next() 173 | .and_then(|node| node.attr("href").map(|href| format!("https:{href}"))); 174 | 175 | Ok((url, poster)) 176 | } 177 | 178 | async fn find_detail(&self, url: &str, nfo: &mut Nfo) -> Result> { 179 | let text = self 180 | .client 181 | .wait() 182 | .await 183 | .get(url) 184 | .send() 185 | .await? 186 | .text() 187 | .await?; 188 | let html = Html::parse_document(&text); 189 | 190 | let fanart = html 191 | .select(&self.selectors.detail_fanart) 192 | .next() 193 | .and_then(|node| node.attr("src").map(|src| src.to_string())); 194 | 195 | for genre in html.select(&self.selectors.detail_genre) { 196 | let genre = genre.text().collect(); 197 | nfo.genres_mut().insert(genre); 198 | } 199 | 200 | let mut pairs = Vec::new(); 201 | let mut prefix = "".to_string(); 202 | for item in html.select(&self.selectors.detail_info) { 203 | let text = item.text().collect::(); 204 | let text = text.trim(); 205 | 206 | if !text.contains(":") { 207 | pairs.push((prefix.clone(), text.to_string())); 208 | continue; 209 | } 210 | 211 | if text.ends_with(":") { 212 | prefix = text.trim_end_matches(":").to_string(); 213 | continue; 214 | } 215 | 216 | if let Some((name, value)) = text.split_once(":") { 217 | pairs.push((name.trim().to_string(), value.trim().to_string())); 218 | } 219 | } 220 | 221 | for pair in pairs { 222 | match pair.0.as_str() { 223 | "制作商" => { 224 | nfo.set_studio(pair.1); 225 | } 226 | "系列" => { 227 | nfo.set_director(pair.1); 228 | } 229 | "长度" => { 230 | let number: String = 231 | pair.1.chars().take_while(|c| c.is_ascii_digit()).collect(); 232 | let runtime: u32 = number.parse().unwrap_or_default(); 233 | nfo.set_runtime(runtime); 234 | } 235 | _ => {} 236 | } 237 | } 238 | 239 | Ok(fanart) 240 | } 241 | } 242 | 243 | #[cfg(test)] 244 | mod tests { 245 | use super::*; 246 | use pretty_assertions::assert_eq; 247 | 248 | fn finder() -> Result { 249 | Avsox::builder().timeout(Duration::from_secs(10)).build() 250 | } 251 | 252 | #[test] 253 | fn test_support() -> Result<()> { 254 | let finder = finder()?; 255 | let videos = [ 256 | VideoType::Jav("STARS".to_string(), "804".to_string()), 257 | VideoType::Fc2("3061625".to_string()), 258 | ]; 259 | for video in videos { 260 | assert!(finder.support(&video)); 261 | } 262 | 263 | Ok(()) 264 | } 265 | 266 | #[tokio::test] 267 | async fn test_find() -> Result<()> { 268 | let finder = finder()?; 269 | let cases = [ 270 | (VideoType::Jav("HEYZO".to_string(), "3525".to_string()), { 271 | let mut nfo = Nfo::builder() 272 | .id("HEYZO-3525") 273 | .country(Country::Japan) 274 | .mpaa(Mpaa::NC17) 275 | .build(); 276 | nfo.set_title( 277 | "竹田紀子 【たけだのりこ】 Sな淫乱痴熟女とねっとりエッチVol.2".to_string(), 278 | ) 279 | .set_runtime(60) 280 | .set_studio("HEYZO".to_string()) 281 | .set_premiered("2025-02-09".to_string()); 282 | let genres = [ 283 | "舔阴", 284 | "内射", 285 | "手淫", 286 | "第一视角", 287 | "骑乘位", 288 | "指法", 289 | "痴女", 290 | "后入", 291 | ]; 292 | 293 | for genre in genres { 294 | nfo.genres_mut().insert(genre.to_string()); 295 | } 296 | nfo 297 | }), 298 | (VideoType::Fc2("1292936".to_string()), { 299 | let mut nfo = Nfo::builder() 300 | .id("FC2-PPV-1292936") 301 | .country(Country::Japan) 302 | .mpaa(Mpaa::NC17) 303 | .build(); 304 | nfo.set_title("【個人撮影・セット販売】妖艶から淫靡な妻へ 完全版".to_string()) 305 | .set_director("啼きの人妻".to_string()) 306 | .set_studio("FC2-PPV".to_string()) 307 | .set_runtime(68) 308 | .set_premiered("2020-03-04".to_string()); 309 | 310 | nfo 311 | }), 312 | ]; 313 | for (video, expected) in cases { 314 | let actual = finder.find(&video).await?; 315 | assert!(!actual.fanart().is_empty()); 316 | assert!(!actual.poster().is_empty()); 317 | assert!(actual.subtitle().is_empty()); 318 | assert_eq!(actual, expected); 319 | } 320 | 321 | Ok(()) 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /crates/spider/src/cable.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result, anyhow}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use scraper::Html; 11 | use video::VideoType; 12 | 13 | use super::{Finder, select, which_country}; 14 | 15 | const HOST: &str = "https://www.hsav.xyz"; 16 | 17 | select!( 18 | home_item: "#main-content > div > div.blog-items.blog-items-control.site__row.grid-default > article.post-item" 19 | home_title: "div > div.blog-pic > div > a > img" 20 | home_date: "div > div.listing-content > div.entry-meta.post-meta.meta-font > div > div.date-time > span > time" 21 | ); 22 | 23 | pub struct Cable { 24 | base_url: String, 25 | client: Client, 26 | selectors: Selectors, 27 | } 28 | 29 | #[bon] 30 | impl Cable { 31 | #[builder] 32 | pub fn new( 33 | base_url: Option, 34 | timeout: Duration, 35 | proxy: Option, 36 | ) -> Result { 37 | let client = Client::builder() 38 | .timeout(timeout) 39 | .interval(1) 40 | .maybe_proxy(proxy) 41 | .build() 42 | .with_context(|| "build http client")?; 43 | let selectors = Selectors::new().with_context(|| "build selectors")?; 44 | let base_url = match base_url { 45 | Some(url) => url, 46 | None => String::from(HOST), 47 | }; 48 | 49 | let cable = Cable { 50 | base_url, 51 | client, 52 | selectors, 53 | }; 54 | Ok(cable) 55 | } 56 | } 57 | 58 | impl Display for Cable { 59 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 60 | write!(f, "cable") 61 | } 62 | } 63 | 64 | #[async_trait] 65 | impl Finder for Cable { 66 | fn support(&self, key: &VideoType) -> bool { 67 | match key { 68 | VideoType::Jav(_, _) => true, 69 | VideoType::Fc2(_) => true, 70 | VideoType::Other(_) => false, 71 | } 72 | } 73 | 74 | async fn find(&self, key: &VideoType) -> Result { 75 | let mut nfo = Nfo::builder() 76 | .id(key) 77 | .country(which_country(key)) 78 | .mpaa(Mpaa::NC17) 79 | .build(); 80 | 81 | let img = self 82 | .find_home(key, &mut nfo) 83 | .await 84 | .with_context(|| "find home")?; 85 | let img = self 86 | .client 87 | .wait() 88 | .await 89 | .get(img) 90 | .send() 91 | .await? 92 | .bytes() 93 | .await? 94 | .to_vec(); 95 | if Country::China == *nfo.country() { 96 | nfo.set_poster(img.clone()); 97 | nfo.set_fanart(img); 98 | } else { 99 | nfo.set_fanart(img); 100 | } 101 | 102 | info!("{nfo:?}"); 103 | Ok(nfo) 104 | } 105 | } 106 | 107 | impl Cable { 108 | async fn find_home(&self, key: &VideoType, nfo: &mut Nfo) -> Result { 109 | let url = format!("{}/index/data/search.html", self.base_url); 110 | let name = match &key { 111 | VideoType::Jav(id, number) => format!("{id}-{number}"), 112 | VideoType::Fc2(number) => format!("FC2PPV-{number}"), 113 | VideoType::Other(title) => title.clone(), 114 | }; 115 | let text = self 116 | .client 117 | .wait() 118 | .await 119 | .get(url) 120 | .query(&[("k", &name)]) 121 | .send() 122 | .await? 123 | .text() 124 | .await?; 125 | let html = Html::parse_document(&text); 126 | 127 | let mut img = None; 128 | for item in html.select(&self.selectors.home_item) { 129 | let Some(title) = item 130 | .select(&self.selectors.home_title) 131 | .next() 132 | .and_then(|node| node.attr("alt")) 133 | .and_then(|title| { 134 | if title.to_uppercase().contains(&name) { 135 | Some(title) 136 | } else { 137 | None 138 | } 139 | }) 140 | else { 141 | continue; 142 | }; 143 | nfo.set_title(title.to_string()); 144 | 145 | img = item 146 | .select(&self.selectors.home_title) 147 | .next() 148 | .and_then(|node| node.attr("data-src").map(String::from)); 149 | 150 | if let Some(date) = item 151 | .select(&self.selectors.home_date) 152 | .next() 153 | .and_then(|node| node.attr("datetime")) 154 | .and_then(|date| date.split_once(' ').map(|(date, _)| date.trim())) 155 | { 156 | nfo.set_premiered(date.to_string()); 157 | } 158 | 159 | break; 160 | } 161 | 162 | img.ok_or_else(|| anyhow!("img not found")) 163 | } 164 | } 165 | 166 | #[cfg(test)] 167 | mod tests { 168 | use super::*; 169 | use pretty_assertions::assert_eq; 170 | 171 | fn finder() -> Result { 172 | Cable::builder().timeout(Duration::from_secs(10)).build() 173 | } 174 | 175 | #[test] 176 | fn test_support() -> Result<()> { 177 | let finder = finder()?; 178 | let videos = [ 179 | (VideoType::Jav("STARS".to_string(), "804".to_string()), true), 180 | (VideoType::Fc2("3061625".to_string()), true), 181 | ]; 182 | for (video, supported) in videos { 183 | assert_eq!(finder.support(&video), supported); 184 | } 185 | 186 | Ok(()) 187 | } 188 | 189 | #[tokio::test] 190 | async fn test_find() -> Result<()> { 191 | let finder = finder()?; 192 | let cases = [ 193 | (VideoType::Jav("PRED".to_string(), "323".to_string()), { 194 | let mut nfo = Nfo::builder() 195 | .id("PRED-323") 196 | .country(Country::Japan) 197 | .mpaa(Mpaa::NC17) 198 | .build(); 199 | nfo.set_title("PRED-323性欲が強すぎる爆乳義姉と嫁の不在中にこっそり時短中出ししているオレ…JULIA".to_string()) 200 | .set_premiered("2024-10-20".to_string()); 201 | 202 | nfo 203 | }), 204 | (VideoType::Fc2("4554988".to_string()), { 205 | let mut nfo = Nfo::builder() 206 | .id("FC2-PPV-4554988") 207 | .country(Country::Japan) 208 | .mpaa(Mpaa::NC17) 209 | .build(); 210 | nfo.set_title("FC2PPV-4554988-【無修正】<美巨乳Fカップ>出張メンエス嬢の身体が異常なエロさ!".to_string()) 211 | .set_premiered("2024-10-20".to_string()); 212 | 213 | nfo 214 | }), 215 | (VideoType::Jav("MD".to_string(), "0331".to_string()), { 216 | let mut nfo = Nfo::builder() 217 | .id("MD-0331") 218 | .country(Country::China) 219 | .mpaa(Mpaa::NC17) 220 | .build(); 221 | nfo.set_title( 222 | "麻豆传媒映画.MD-0331.雯雯.我的房东是个萌妹子.处女催租肉体缴付".to_string(), 223 | ) 224 | .set_premiered("2024-10-17".to_string()); 225 | 226 | nfo 227 | }), 228 | ]; 229 | for (video, expected) in cases { 230 | let actual = finder.find(&video).await?; 231 | assert!(!actual.fanart().is_empty()); 232 | if Country::China == *actual.country() { 233 | assert!(!actual.poster().is_empty()); 234 | } else { 235 | assert!(actual.poster().is_empty()); 236 | } 237 | assert!(actual.subtitle().is_empty()); 238 | assert_eq!(actual, expected); 239 | } 240 | 241 | Ok(()) 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /crates/spider/src/fc2ppv_db.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result, anyhow}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use scraper::Html; 11 | use video::VideoType; 12 | 13 | use super::{Finder, select}; 14 | 15 | const HOST: &str = "https://fc2ppvdb.com"; 16 | 17 | select!( 18 | img: "body > div > div > div > main > div > section > div.container.lg\\:px-5.px-2.py-12.mx-auto > div.flex.flex-col.items-start.rounded-lg.shadow.md\\:flex-row.dark\\:border-gray-800.dark\\:bg-gray-900.py-2 > div.lg\\:w-2\\/5.w-full.mb-12.md\\:mb-0 > a > img" 19 | rating: "#percentage" 20 | title: "body > div > div > div > main > div > section > div.container.lg\\:px-5.px-2.py-12.mx-auto > div.flex.flex-col.items-start.rounded-lg.shadow.md\\:flex-row.dark\\:border-gray-800.dark\\:bg-gray-900.py-2 > div.w-full.lg\\:pl-8.px-2.lg\\:w-3\\/5 > h2 > a" 21 | item: "body > div > div > div > main > div > section > div.container.lg\\:px-5.px-2.py-12.mx-auto > div.flex.flex-col.items-start.rounded-lg.shadow.md\\:flex-row.dark\\:border-gray-800.dark\\:bg-gray-900.py-2 > div.w-full.lg\\:pl-8.px-2.lg\\:w-3\\/5 > div" 22 | ); 23 | 24 | pub struct Fc2ppvDB { 25 | base_url: String, 26 | client: Client, 27 | selectors: Selectors, 28 | } 29 | 30 | #[bon] 31 | impl Fc2ppvDB { 32 | #[builder] 33 | pub fn new( 34 | base_url: Option, 35 | timeout: Duration, 36 | proxy: Option, 37 | ) -> Result { 38 | let client = Client::builder() 39 | .timeout(timeout) 40 | .interval(1) 41 | .maybe_proxy(proxy) 42 | .build() 43 | .with_context(|| "build http client")?; 44 | let selectors = Selectors::new().with_context(|| "build selectors")?; 45 | let base_url = match base_url { 46 | Some(url) => url, 47 | None => String::from(HOST), 48 | }; 49 | 50 | let fc2ppv_db = Fc2ppvDB { 51 | base_url, 52 | client, 53 | selectors, 54 | }; 55 | Ok(fc2ppv_db) 56 | } 57 | } 58 | 59 | impl Display for Fc2ppvDB { 60 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 61 | write!(f, "fc2ppv db") 62 | } 63 | } 64 | 65 | #[async_trait] 66 | impl Finder for Fc2ppvDB { 67 | fn support(&self, key: &VideoType) -> bool { 68 | match key { 69 | VideoType::Jav(_, _) => false, 70 | VideoType::Fc2(_) => true, 71 | VideoType::Other(_) => false, 72 | } 73 | } 74 | 75 | async fn find(&self, key: &VideoType) -> Result { 76 | let mut nfo = Nfo::builder() 77 | .id(key) 78 | .country(Country::Japan) 79 | .mpaa(Mpaa::NC17) 80 | .build(); 81 | 82 | let img = self 83 | .find_detail(key, &mut nfo) 84 | .await 85 | .with_context(|| "find detail")?; 86 | let img = self 87 | .client 88 | .wait() 89 | .await 90 | .get(img) 91 | .send() 92 | .await? 93 | .bytes() 94 | .await? 95 | .to_vec(); 96 | nfo.set_fanart(img.clone()); 97 | nfo.set_poster(img); 98 | 99 | info!("{nfo:?}"); 100 | Ok(nfo) 101 | } 102 | } 103 | 104 | impl Fc2ppvDB { 105 | async fn find_detail(&self, key: &VideoType, nfo: &mut Nfo) -> Result { 106 | let url = format!("{}/search", self.base_url); 107 | let name = match key { 108 | VideoType::Jav(id, number) => format!("{id}-{number}"), 109 | VideoType::Fc2(number) => number.clone(), 110 | VideoType::Other(title) => title.clone(), 111 | }; 112 | let text = self 113 | .client 114 | .wait() 115 | .await 116 | .get(url) 117 | .query(&[("stype", "title"), ("keyword", &name)]) 118 | .send() 119 | .await? 120 | .text() 121 | .await?; 122 | let html = Html::parse_document(&text); 123 | 124 | if let Some(rating) = html 125 | .select(&self.selectors.rating) 126 | .next() 127 | .map(|node| node.text().collect::()) 128 | { 129 | let rating = rating 130 | .trim() 131 | .chars() 132 | .filter(|c| c.is_ascii_digit()) 133 | .collect::() 134 | .parse() 135 | .map(|rating: f64| rating / 10.0) 136 | .unwrap_or_default(); 137 | nfo.set_rating(rating); 138 | } 139 | 140 | if let Some(title) = html 141 | .select(&self.selectors.title) 142 | .next() 143 | .map(|node| node.text().collect::()) 144 | { 145 | nfo.set_title(title); 146 | } 147 | 148 | for item in html.select(&self.selectors.item) { 149 | let text = item.text().collect::(); 150 | if let Some((name, value)) = text.split_once(":") { 151 | let value = value.trim(); 152 | match name.trim() { 153 | "販売者" => { 154 | nfo.set_director(value.to_string()); 155 | } 156 | "女優" => { 157 | nfo.actors_mut().insert(value.to_string()); 158 | } 159 | "販売日" => { 160 | nfo.set_premiered(value.to_string()); 161 | } 162 | "収録時間" => { 163 | let mut h = 0; 164 | let mut m = 0; 165 | for (idx, v) in value 166 | .split(":") 167 | .collect::>() 168 | .into_iter() 169 | .rev() 170 | .skip(1) 171 | .take(3) 172 | .enumerate() 173 | { 174 | let v = v.trim(); 175 | if idx == 0 { 176 | m = v.parse().unwrap_or_default(); 177 | } 178 | if idx == 1 { 179 | h = v.parse().unwrap_or_default(); 180 | } 181 | } 182 | let runtime = h * 60 + m; 183 | nfo.set_runtime(runtime); 184 | } 185 | "タグ" => { 186 | for line in value.lines() { 187 | nfo.genres_mut().insert(line.trim().to_string()); 188 | } 189 | } 190 | _ => {} 191 | } 192 | } 193 | } 194 | 195 | html.select(&self.selectors.img) 196 | .next() 197 | .and_then(|node| node.attr("src").map(|src| src.to_string())) 198 | .ok_or_else(|| anyhow!("img not found")) 199 | } 200 | } 201 | 202 | #[cfg(test)] 203 | mod tests { 204 | use super::*; 205 | use pretty_assertions::assert_eq; 206 | 207 | fn finder() -> Result { 208 | Fc2ppvDB::builder().timeout(Duration::from_secs(10)).build() 209 | } 210 | 211 | #[test] 212 | fn test_support() -> Result<()> { 213 | let finder = finder()?; 214 | let videos = [ 215 | ( 216 | VideoType::Jav("STARS".to_string(), "804".to_string()), 217 | false, 218 | ), 219 | (VideoType::Fc2("3061625".to_string()), true), 220 | ]; 221 | for (video, supported) in videos { 222 | assert_eq!(finder.support(&video), supported); 223 | } 224 | 225 | Ok(()) 226 | } 227 | 228 | #[tokio::test] 229 | async fn test_find() -> Result<()> { 230 | let finder = finder()?; 231 | let cases = [ 232 | (VideoType::Fc2("3061625".to_string()), { 233 | let mut nfo = Nfo::builder() 234 | .id("FC2-PPV-3061625") 235 | .country(Country::Japan) 236 | .mpaa(Mpaa::NC17) 237 | .build(); 238 | nfo.set_title( 239 | "人生初めてのハメ撮り。そして中出し。学年一の美●女が覆面男の精子に汚される瞬間!" 240 | .to_string(), 241 | ) 242 | .set_director("KING POWER D".to_string()) 243 | .set_runtime(82) 244 | .set_rating(9.4) 245 | .set_premiered("2022-07-30".to_string()); 246 | nfo.actors_mut().insert("あすか".to_string()); 247 | 248 | nfo 249 | }), 250 | (VideoType::Fc2("1292936".to_string()), { 251 | let mut nfo = Nfo::builder() 252 | .id("FC2-PPV-1292936") 253 | .country(Country::Japan) 254 | .mpaa(Mpaa::NC17) 255 | .build(); 256 | nfo.set_title("【個人撮影・セット販売】妖艶から淫靡な妻へ 完全版".to_string()) 257 | .set_director("啼きの人妻".to_string()) 258 | .set_runtime(68) 259 | .set_premiered("2020-03-04".to_string()); 260 | nfo.actors_mut().insert("夏原あかり".to_string()); 261 | 262 | nfo 263 | }), 264 | ]; 265 | for (video, expected) in cases { 266 | let actual = finder.find(&video).await?; 267 | assert!(!actual.fanart().is_empty()); 268 | assert!(!actual.poster().is_empty()); 269 | assert!(actual.subtitle().is_empty()); 270 | assert_eq!(actual, expected); 271 | } 272 | 273 | Ok(()) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /crates/spider/src/hbox.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result, anyhow, bail}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use serde::Deserialize; 11 | use video::VideoType; 12 | 13 | use super::{Finder, which_country}; 14 | 15 | const HOST: &str = "https://hbox.jp"; 16 | 17 | pub struct Hbox { 18 | base_url: String, 19 | client: Client, 20 | } 21 | 22 | #[bon] 23 | impl Hbox { 24 | #[builder] 25 | pub fn new(base_url: Option, timeout: Duration, proxy: Option) -> Result { 26 | let client = Client::builder() 27 | .timeout(timeout) 28 | .interval(1) 29 | .maybe_proxy(proxy) 30 | .build() 31 | .with_context(|| "build http client")?; 32 | let base_url = match base_url { 33 | Some(url) => url, 34 | None => String::from(HOST), 35 | }; 36 | 37 | let hbox = Hbox { base_url, client }; 38 | Ok(hbox) 39 | } 40 | 41 | async fn find_name(&self, name: &str) -> Result { 42 | let url = format!("{}/home_api/search_result", self.base_url); 43 | let mut payload = self 44 | .client 45 | .wait() 46 | .await 47 | .get(&url) 48 | .query(&[("q_array[]", name)]) 49 | .send() 50 | .await? 51 | .json::() 52 | .await?; 53 | if payload.count == 0 { 54 | bail!("payload count is zero"); 55 | } 56 | 57 | payload 58 | .contents 59 | .pop() 60 | .ok_or(anyhow!("empty payload content")) 61 | } 62 | } 63 | 64 | impl Display for Hbox { 65 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 66 | write!(f, "hbox") 67 | } 68 | } 69 | 70 | #[async_trait] 71 | impl Finder for Hbox { 72 | fn support(&self, key: &VideoType) -> bool { 73 | match key { 74 | VideoType::Jav(_, _) => !matches!(which_country(key), Country::China), 75 | VideoType::Fc2(_) => false, 76 | VideoType::Other(_) => false, 77 | }; 78 | 79 | // TODO: hbox可用时启用 80 | false 81 | } 82 | 83 | async fn find(&self, key: &VideoType) -> Result { 84 | let mut nfo = Nfo::builder() 85 | .id(key) 86 | .country(Country::Japan) 87 | .mpaa(Mpaa::NC17) 88 | .build(); 89 | 90 | let content = self 91 | .find_name(&key.to_string()) 92 | .await 93 | .with_context(|| "find name")?; 94 | nfo.set_title(content.title); 95 | nfo.set_plot(content.description); 96 | nfo.set_premiered(content.release_date); 97 | nfo.set_studio(content.label_name); 98 | nfo.set_director(content.director_names); 99 | content.casts.into_iter().for_each(|actor| { 100 | nfo.actors_mut().insert(actor.cast_name); 101 | }); 102 | content.tags.into_iter().for_each(|tag| { 103 | nfo.genres_mut().insert(tag.name); 104 | }); 105 | let poster = format!( 106 | "{}{}/{}", 107 | self.base_url, content.back_cover_url_root, content.back_cover_file, 108 | ); 109 | let poster = self 110 | .client 111 | .wait() 112 | .await 113 | .get(&poster) 114 | .send() 115 | .await? 116 | .bytes() 117 | .await?; 118 | nfo.set_poster(poster.to_vec()); 119 | 120 | info!("{nfo:?}"); 121 | Ok(nfo) 122 | } 123 | } 124 | 125 | #[derive(Debug, Deserialize)] 126 | #[allow(dead_code)] 127 | struct Payload { 128 | q_array: Vec, 129 | sort_key: Option, 130 | sdc_sort: Option, 131 | pickup: Option, 132 | category_id: String, 133 | subcategory_id: String, 134 | category_name: String, 135 | category_code: String, 136 | subcategory_name: String, 137 | subcategory_code: String, 138 | #[serde(rename = "lastOpeningDate")] 139 | last_opening_date: String, 140 | banner_img_width: i32, 141 | banner_img_height: i32, 142 | title: String, 143 | #[serde(rename = "saleInfo")] 144 | sale_info: Option, 145 | features: Option, 146 | #[serde(rename = "saleEvents")] 147 | sale_events: Option, 148 | #[serde(rename = "exclude_AIG")] 149 | exclude_aig: String, 150 | #[serde(rename = "exclude_AIP")] 151 | exclude_aip: String, 152 | contents: Vec, 153 | count: i32, 154 | page: i32, 155 | count_par_page: i32, 156 | maxpage: i32, 157 | query: Query, 158 | refine_list: RefineList, 159 | sale: String, 160 | coin: String, 161 | #[serde(rename = "openRefine")] 162 | open_refine: String, 163 | tag_name: Option, 164 | #[serde(rename = "auther_names")] 165 | author_names: String, 166 | cast_names: String, 167 | director_name: String, 168 | label_name: String, 169 | publisher_name: Option, 170 | series_name: String, 171 | devices: Option, 172 | course_names: Option, 173 | } 174 | 175 | #[derive(Debug, Deserialize)] 176 | #[allow(dead_code)] 177 | struct Content { 178 | content_id: String, 179 | title: String, 180 | description: String, 181 | opening_status: String, 182 | opening_date: String, 183 | release_date: String, 184 | brand_new_date: String, 185 | brand_new_status: String, 186 | price: i32, 187 | rental_price: i32, 188 | before_view: String, 189 | before_sale: String, 190 | rental_flg: String, 191 | img_src: String, 192 | category_id: String, 193 | category_code: String, 194 | category_name: String, 195 | subcategory_id: String, 196 | subcategory_code: String, 197 | subcategory_name: String, 198 | content_type: String, 199 | android_flg: String, 200 | ios_flg: String, 201 | pc_flg: String, 202 | vr_flg: String, 203 | vr_type: String, 204 | vr_mode: String, 205 | maker_id: String, 206 | maker_name: String, 207 | label_id: String, 208 | label_name: String, 209 | series_id: String, 210 | series_name: String, 211 | galleries: Vec, 212 | in_cart: bool, 213 | is_paid: bool, 214 | is_bookmarked: bool, 215 | purchase_price: i32, 216 | directors: Vec, 217 | casts: Vec, 218 | medal_magnification: Option, 219 | hd_info: HdInfo, 220 | hd_content_price: i32, 221 | content_price: i32, 222 | comic_sample_url: String, 223 | cover_url_root: String, 224 | cover_file: String, 225 | back_cover_url_root: String, 226 | back_cover_file: String, 227 | ios_sample_url: String, 228 | android_sample_url: String, 229 | promotions: Vec, 230 | director_names: String, 231 | cast_names: String, 232 | tags: Vec, 233 | review_score: i32, 234 | review_count: i32, 235 | is_only_sd_paid: bool, 236 | screen_time: i32, 237 | } 238 | 239 | #[derive(Debug, Deserialize)] 240 | #[allow(dead_code)] 241 | struct Gallery { 242 | id: String, 243 | content_id: String, 244 | client_type: String, 245 | image_no: String, 246 | image_url_root: String, 247 | image_dir: String, 248 | image_file: String, 249 | sample_flg: String, 250 | del_flg: String, 251 | created: String, 252 | modified: String, 253 | } 254 | 255 | #[derive(Debug, Deserialize)] 256 | #[allow(dead_code)] 257 | struct Director { 258 | id: String, 259 | director_name: String, 260 | director_kana: String, 261 | } 262 | 263 | #[derive(Debug, Deserialize)] 264 | #[allow(dead_code)] 265 | struct Cast { 266 | id: String, 267 | cast_name: String, 268 | cast_kana: String, 269 | } 270 | 271 | #[derive(Debug, Deserialize)] 272 | #[allow(dead_code)] 273 | struct HdInfo { 274 | content_id: String, 275 | hd_brand_new_price: i32, 276 | hd_recent_price: i32, 277 | hd_price: i32, 278 | hd_xiaomaisige: Option, 279 | hd_reserve_price: i32, 280 | hd_rental_price: i32, 281 | hd_newcomer_price: i32, 282 | has_hd: bool, 283 | is_hd_paid: bool, 284 | } 285 | 286 | #[derive(Debug, Deserialize)] 287 | #[allow(dead_code)] 288 | struct Tag { 289 | id: String, 290 | name: String, 291 | } 292 | 293 | #[derive(Debug, Deserialize)] 294 | #[allow(dead_code)] 295 | struct Query { 296 | q_array: Vec, 297 | } 298 | 299 | #[derive(Debug, Deserialize)] 300 | struct RefineList {} 301 | 302 | #[cfg(test)] 303 | mod tests { 304 | use super::*; 305 | use pretty_assertions::assert_eq; 306 | 307 | fn finder() -> Result { 308 | Hbox::builder().timeout(Duration::from_secs(10)).build() 309 | } 310 | 311 | #[test] 312 | #[ignore = "hbox暂时不可用"] 313 | fn test_support() -> Result<()> { 314 | let finder = finder()?; 315 | let videos = [ 316 | (VideoType::Jav("STARS".to_string(), "804".to_string()), true), 317 | (VideoType::Fc2("3061625".to_string()), false), 318 | ]; 319 | for (video, supported) in videos { 320 | assert_eq!(finder.support(&video), supported); 321 | } 322 | 323 | Ok(()) 324 | } 325 | 326 | #[tokio::test] 327 | #[ignore = "hbox暂时不可用"] 328 | async fn test_find() -> Result<()> { 329 | let finder = finder()?; 330 | let cases = [(VideoType::Jav("STARS".to_string(), "804".to_string()), { 331 | let mut nfo = Nfo::builder() 332 | .id("STARS-804") 333 | .country(Country::Japan) 334 | .mpaa(Mpaa::NC17) 335 | .build(); 336 | nfo.set_title("本能で絡み合う極上のランジェリー&オイリー4本番 神木麗".to_string()) 337 | .set_plot("【神木麗1周年企画】Gカップボディと、抜群のプロポーションをより際立たせる高級ランジェリーとオイル。ホテルへ入るなり求め合う濃厚なSEX。美しい四肢を夜景バックに縛り付け、執拗に責め立てられ絶頂し続け自ら挿入を懇願。喘ぐ声も抑えることなく、密室に響き渡る。神木麗お初の4本番。最後は美しき涙も…".to_string()) 338 | .set_studio("SODクリエイト".to_string()) 339 | .set_director("チク兄".to_string()) 340 | .set_premiered("2023-04-06".to_string()); 341 | let genres = ["巨乳", "単体作品"]; 342 | nfo.actors_mut().insert("神木麗".to_string()); 343 | 344 | for genre in genres { 345 | nfo.genres_mut().insert(genre.to_string()); 346 | } 347 | nfo 348 | })]; 349 | for (video, expected) in cases { 350 | let actual = finder.find(&video).await?; 351 | assert!(actual.fanart().is_empty()); 352 | assert!(!actual.poster().is_empty()); 353 | assert!(actual.subtitle().is_empty()); 354 | assert_eq!(actual, expected); 355 | } 356 | 357 | Ok(()) 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /crates/spider/src/jav321.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use scraper::Html; 11 | use video::VideoType; 12 | 13 | use super::{Finder, select, which_country}; 14 | 15 | const HOST: &str = "https://www.jav321.com"; 16 | 17 | select!( 18 | title: "body > div:nth-child(6) > div.col-md-7.col-md-offset-1.col-xs-12 > div:nth-child(1) > div.panel-heading > h3" 19 | plot: "body > div:nth-child(6) > div.col-md-7.col-md-offset-1.col-xs-12 > div:nth-child(1) > div.panel-body > div:nth-child(3) > div" 20 | poster: "body > div:nth-child(6) > div.col-md-7.col-md-offset-1.col-xs-12 > div:nth-child(1) > div.panel-body > div:nth-child(1) > div.col-md-3 > img" 21 | fanart: "body > div:nth-child(6) > div.col-md-3 > div:nth-child(1) > p > a > img" 22 | info: "body > div:nth-child(6) > div.col-md-7.col-md-offset-1.col-xs-12 > div:nth-child(1) > div.panel-body > div:nth-child(1) > div.col-md-9" 23 | ); 24 | 25 | pub struct Jav321 { 26 | base_url: String, 27 | client: Client, 28 | selectors: Selectors, 29 | } 30 | 31 | #[bon] 32 | impl Jav321 { 33 | #[builder] 34 | pub fn new( 35 | base_url: Option, 36 | timeout: Duration, 37 | proxy: Option, 38 | ) -> Result { 39 | let client = Client::builder() 40 | .timeout(timeout) 41 | .interval(1) 42 | .maybe_proxy(proxy) 43 | .build() 44 | .with_context(|| "build http client")?; 45 | let selectors = Selectors::new().with_context(|| "build selectors")?; 46 | let base_url = match base_url { 47 | Some(url) => url, 48 | None => String::from(HOST), 49 | }; 50 | 51 | let jav321 = Jav321 { 52 | base_url, 53 | client, 54 | selectors, 55 | }; 56 | Ok(jav321) 57 | } 58 | } 59 | 60 | impl Display for Jav321 { 61 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 62 | write!(f, "jav321") 63 | } 64 | } 65 | 66 | #[async_trait] 67 | impl Finder for Jav321 { 68 | fn support(&self, key: &VideoType) -> bool { 69 | match key { 70 | VideoType::Jav(_, _) => !matches!(which_country(key), Country::China), 71 | VideoType::Fc2(_) => false, 72 | VideoType::Other(_) => false, 73 | } 74 | } 75 | 76 | async fn find(&self, key: &VideoType) -> Result { 77 | let mut nfo = Nfo::builder() 78 | .id(key) 79 | .country(Country::Japan) 80 | .mpaa(Mpaa::NC17) 81 | .build(); 82 | 83 | let (poster, fanart) = self 84 | .find_detail(key, &mut nfo) 85 | .await 86 | .with_context(|| "find detail")?; 87 | if let Some(poster) = poster { 88 | let poster = self 89 | .client 90 | .wait() 91 | .await 92 | .get(poster) 93 | .send() 94 | .await? 95 | .error_for_status()? 96 | .bytes() 97 | .await?; 98 | nfo.set_poster(poster.to_vec()); 99 | } 100 | if let Some(fanart) = fanart { 101 | let fanart = self 102 | .client 103 | .wait() 104 | .await 105 | .get(fanart) 106 | .send() 107 | .await? 108 | .error_for_status()? 109 | .bytes() 110 | .await?; 111 | nfo.set_fanart(fanart.to_vec()); 112 | } 113 | 114 | info!("{nfo:?}"); 115 | Ok(nfo) 116 | } 117 | } 118 | 119 | impl Jav321 { 120 | async fn find_detail( 121 | &self, 122 | key: &VideoType, 123 | nfo: &mut Nfo, 124 | ) -> Result<(Option, Option)> { 125 | let url = format!("{}/search", self.base_url); 126 | let text = self 127 | .client 128 | .wait() 129 | .await 130 | .post(url) 131 | .form(&[("sn", key.to_string())]) 132 | .send() 133 | .await? 134 | .text() 135 | .await?; 136 | let html = Html::parse_document(&text); 137 | 138 | if let Some(title) = html 139 | .select(&self.selectors.title) 140 | .next() 141 | .and_then(|node| node.text().next().map(|text| text.trim())) 142 | { 143 | nfo.set_title(title.to_string()); 144 | } 145 | 146 | if let Some(plot) = html 147 | .select(&self.selectors.plot) 148 | .next() 149 | .and_then(|node| node.text().next().map(|text| text.trim())) 150 | { 151 | nfo.set_plot(plot.to_string()); 152 | } 153 | 154 | let poster = html 155 | .select(&self.selectors.poster) 156 | .next() 157 | .and_then(|node| node.attr("src").map(|src| src.to_string())); 158 | 159 | let fanart = html 160 | .select(&self.selectors.fanart) 161 | .next() 162 | .and_then(|node| node.attr("src").map(|src| src.to_string())); 163 | 164 | if let Some(info) = html.select(&self.selectors.info).next() { 165 | let mut s = Vec::new(); 166 | for text in info.text() { 167 | if text.starts_with(":") { 168 | s.push(":".to_string()); 169 | s.push(text.trim_start_matches(":").trim().to_string()); 170 | } else { 171 | s.push(text.trim().to_string()); 172 | } 173 | } 174 | 175 | let mut v = Vec::new(); 176 | while let Some(text) = s.pop() { 177 | if text.is_empty() { 178 | continue; 179 | } 180 | 181 | if text != ":" { 182 | v.push(text); 183 | continue; 184 | } 185 | 186 | if let Some(name) = s.pop() { 187 | match name.as_str() { 188 | "メーカー" => { 189 | if let Some(studio) = v.first() { 190 | nfo.set_studio(studio.to_string()); 191 | } 192 | } 193 | "出演者" => { 194 | for actor in v.iter() { 195 | nfo.actors_mut().insert(actor.to_string()); 196 | } 197 | } 198 | "ジャンル" => { 199 | for genre in v.iter() { 200 | nfo.genres_mut().insert(genre.to_string()); 201 | } 202 | } 203 | "配信開始日" => { 204 | if let Some(date) = v.first() { 205 | nfo.set_premiered(date.to_string()); 206 | } 207 | } 208 | "収録時間" => { 209 | if let Some(runtime) = v.first() { 210 | let runtime: String = 211 | runtime.chars().filter(|c| c.is_ascii_digit()).collect(); 212 | let runtime: u32 = runtime.parse().unwrap_or_default(); 213 | nfo.set_runtime(runtime); 214 | } 215 | } 216 | "平均評価" => { 217 | if let Some(rating) = v.first() { 218 | let rating: f64 = rating.parse().unwrap_or_default(); 219 | nfo.set_rating(rating * 2.0); 220 | } 221 | } 222 | _ => {} 223 | } 224 | } 225 | 226 | v.clear(); 227 | } 228 | } 229 | 230 | Ok((poster, fanart)) 231 | } 232 | } 233 | 234 | #[cfg(test)] 235 | mod tests { 236 | use super::*; 237 | use pretty_assertions::assert_eq; 238 | 239 | fn finder() -> Result { 240 | Jav321::builder().timeout(Duration::from_secs(10)).build() 241 | } 242 | 243 | #[test] 244 | fn test_support() -> Result<()> { 245 | let finder = finder()?; 246 | let videos = [ 247 | (VideoType::Jav("STARS".to_string(), "804".to_string()), true), 248 | (VideoType::Fc2("3061625".to_string()), false), 249 | ]; 250 | for (video, supported) in videos { 251 | assert_eq!(finder.support(&video), supported); 252 | } 253 | 254 | Ok(()) 255 | } 256 | 257 | #[tokio::test] 258 | async fn test_find() -> Result<()> { 259 | let finder = finder()?; 260 | let cases = [ 261 | (VideoType::Jav("ROYD".to_string(), "108".to_string()), { 262 | let mut nfo = Nfo::builder() 263 | .id("ROYD-108") 264 | .country(Country::Japan) 265 | .mpaa(Mpaa::NC17) 266 | .build(); 267 | nfo.set_title("朝起きたら部屋に下着姿のギャルが!いつも生意気で悪態ばかりついてくるのに、甘えてきたので… 斎藤あみり".to_string()) 268 | .set_plot("朝起きると…隣には裸の同級生ギャル!話を聞くと酔ったボクに無理矢理ヤラれたヤバイ事実が!だけどいつも超生意気なギャルが甘え始めて…?どうやらイっても萎えないボクのチ○ポが病みつきになったらしく「いつもバカにしてゴメンね!」と何度もHを求められてヤリまくり!ギャルマ○コが気持ち良過ぎて抜かずの連続中出しが止められず!?".to_string()) 269 | .set_studio("ロイヤル".to_string()) 270 | .set_rating(9.0) 271 | .set_runtime(105) 272 | .set_premiered("2022-10-25".to_string()); 273 | nfo.actors_mut().insert("斎藤あみり".to_string()); 274 | 275 | nfo 276 | }), 277 | (VideoType::Jav("IPX".to_string(), "443".to_string()), { 278 | let mut nfo = Nfo::builder() 279 | .id("IPX-443") 280 | .country(Country::Japan) 281 | .mpaa(Mpaa::NC17) 282 | .build(); 283 | nfo.set_title("1ヶ月間禁欲させ親友のいない数日間に親友の彼氏と朝から晩まで気が狂うくらいセックスしまくった 果てるまでヤリまくる計10性交! 明里つむぎ".to_string()) 284 | .set_plot("学生時代から地味で目立たないワタシ。反対にいつもまわりには友達がいて人気者の「美沙」。すべての面で親友に劣っているワタシでもあの人を想う気持ちは絶対に負けない…。親友が家を空ける数日間に全てを失う覚悟で親友の彼氏に想いをぶつけ朝から晩まで気が狂うくらいひたすらセックスしまくった。最低の裏切りだとはわかっている…。でも止められない。このまま時が止まればいいのに…。".to_string()) 285 | .set_studio("アイデアポケット".to_string()) 286 | .set_runtime(119) 287 | .set_premiered("2020-02-13".to_string()); 288 | let actors = ["愛里るい", "明里つむぎ"]; 289 | let genres = [ 290 | "ハイビジョン", 291 | "拘束", 292 | "独占配信", 293 | "ドキュメンタリー", 294 | "単体作品", 295 | "中出し", 296 | "デジモ", 297 | ]; 298 | 299 | for actor in actors { 300 | nfo.actors_mut().insert(actor.to_string()); 301 | } 302 | for genre in genres { 303 | nfo.genres_mut().insert(genre.to_string()); 304 | } 305 | nfo 306 | }), 307 | ]; 308 | for (video, _expected) in cases { 309 | let actual = finder.find(&video).await?; 310 | // TODO: 该测试在github action中会失败, 目前还无法确定原因, 因此先取消这行测试 311 | // assert!(!actual.fanart().is_empty()); 312 | // assert!(!actual.poster().is_empty()); 313 | assert!(actual.subtitle().is_empty()); 314 | // assert_eq!(actual, expected); 315 | } 316 | 317 | Ok(()) 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /crates/spider/src/javdb.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result, anyhow, bail}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use scraper::Html; 11 | use video::VideoType; 12 | 13 | use super::{Finder, select, which_country}; 14 | 15 | const HOST: &str = "https://javdb.com"; 16 | 17 | select!( 18 | home_item: "body > section > div > div.movie-list.h.cols-4.vcols-8 > div" 19 | home_item_id: "a > div.video-title > strong" 20 | home_title: "a" 21 | home_date: "a > div.meta" 22 | home_rating: "a > div.score > span" 23 | detail_block: "body > section > div > div.video-detail > div.video-meta-panel > div > div:nth-child(2) > nav > div.panel-block" 24 | detail_name: "strong" 25 | detail_value: "span" 26 | ); 27 | 28 | pub struct Javdb { 29 | base_url: String, 30 | client: Client, 31 | selectors: Selectors, 32 | } 33 | 34 | #[bon] 35 | impl Javdb { 36 | #[builder] 37 | pub fn new( 38 | base_url: Option, 39 | timeout: Duration, 40 | proxy: Option, 41 | ) -> Result { 42 | let client = Client::builder() 43 | .timeout(timeout) 44 | .interval(2) 45 | .maybe_proxy(proxy) 46 | .build() 47 | .with_context(|| "build http client")?; 48 | let selectors = Selectors::new().with_context(|| "build selectors")?; 49 | let base_url = match base_url { 50 | Some(url) => url, 51 | None => String::from(HOST), 52 | }; 53 | 54 | let javdb = Javdb { 55 | base_url, 56 | client, 57 | selectors, 58 | }; 59 | Ok(javdb) 60 | } 61 | } 62 | 63 | impl Display for Javdb { 64 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 65 | write!(f, "javdb") 66 | } 67 | } 68 | 69 | #[async_trait] 70 | impl Finder for Javdb { 71 | fn support(&self, key: &VideoType) -> bool { 72 | match key { 73 | VideoType::Jav(_, _) => !matches!(which_country(key), Country::China), 74 | VideoType::Fc2(_) => false, 75 | VideoType::Other(_) => false, 76 | } 77 | } 78 | 79 | async fn find(&self, key: &VideoType) -> Result { 80 | let mut nfo = Nfo::builder() 81 | .id(key) 82 | .country(Country::Japan) 83 | .mpaa(Mpaa::NC17) 84 | .build(); 85 | 86 | let url = self 87 | .find_in_home(key, &mut nfo) 88 | .await 89 | .with_context(|| "find in home")?; 90 | self.find_detail(&url, &mut nfo) 91 | .await 92 | .with_context(|| format!("find detail {url}"))?; 93 | 94 | info!("{nfo:?}"); 95 | Ok(nfo) 96 | } 97 | } 98 | 99 | impl Javdb { 100 | async fn find_in_home(&self, key: &VideoType, nfo: &mut Nfo) -> Result { 101 | let url = format!("{}/search", self.base_url); 102 | let text = self 103 | .client 104 | .wait() 105 | .await 106 | .get(&url) 107 | .query(&[("q", key.to_string().as_str()), ("f", "all")]) 108 | .send() 109 | .await? 110 | .text() 111 | .await?; 112 | let html = Html::parse_document(&text); 113 | 114 | let name = key.to_string(); 115 | let Some(item) = html.select(&self.selectors.home_item).find(|node| { 116 | node.select(&self.selectors.home_item_id) 117 | .next() 118 | .map(|node| node.text().collect::() == name) 119 | .unwrap_or(false) 120 | }) else { 121 | bail!("item not found"); 122 | }; 123 | 124 | if let Some(date) = item 125 | .select(&self.selectors.home_date) 126 | .next() 127 | .map(|node| node.text().collect::()) 128 | { 129 | nfo.set_premiered(date.trim().to_string()); 130 | } 131 | 132 | if let Some(rating) = item 133 | .select(&self.selectors.home_rating) 134 | .next() 135 | .and_then(|node| node.text().last().map(|text| text.trim())) 136 | .map(|text| { 137 | text.chars() 138 | .take_while(|c| c.is_ascii_digit() || *c == '.') 139 | .collect::() 140 | .parse::() 141 | .unwrap_or_default() 142 | }) 143 | .map(|rating| rating * 2.0) 144 | { 145 | nfo.set_rating(rating); 146 | } 147 | 148 | if let Some(title) = item 149 | .select(&self.selectors.home_title) 150 | .next() 151 | .and_then(|node| node.attr("title")) 152 | { 153 | nfo.set_title(title.to_string()); 154 | } 155 | 156 | item.select(&self.selectors.home_title) 157 | .next() 158 | .and_then(|node| { 159 | node.attr("href") 160 | .map(|href| format!("{}{href}", self.base_url)) 161 | }) 162 | .ok_or_else(|| anyhow!("detail url not found")) 163 | } 164 | 165 | async fn find_detail(&self, url: &str, nfo: &mut Nfo) -> Result<()> { 166 | let text = self 167 | .client 168 | .wait() 169 | .await 170 | .get(url) 171 | .send() 172 | .await? 173 | .text() 174 | .await?; 175 | let html = Html::parse_document(&text); 176 | for block in html.select(&self.selectors.detail_block) { 177 | let Some(name) = block 178 | .select(&self.selectors.detail_name) 179 | .next() 180 | .map(|node| node.text().collect::()) 181 | else { 182 | continue; 183 | }; 184 | let Some(value) = block 185 | .select(&self.selectors.detail_value) 186 | .next() 187 | .map(|node| node.text().collect::()) 188 | else { 189 | continue; 190 | }; 191 | 192 | let name = name.trim_end_matches(":").trim(); 193 | let value = value.trim(); 194 | 195 | match name { 196 | "時長" => { 197 | let runtime: u32 = value 198 | .chars() 199 | .filter(|c| c.is_ascii_digit()) 200 | .collect::() 201 | .parse() 202 | .unwrap_or_default(); 203 | nfo.set_runtime(runtime); 204 | } 205 | "導演" => { 206 | nfo.set_director(value.to_string()); 207 | } 208 | "片商" => { 209 | nfo.set_studio(value.to_string()); 210 | } 211 | "類別" => { 212 | let genres = value.split(",").collect::>(); 213 | for genre in genres { 214 | nfo.genres_mut().insert(genre.trim().to_string()); 215 | } 216 | } 217 | "演員" => { 218 | let actors = value 219 | .lines() 220 | .map(|line| line.trim().trim_end_matches(['♂', '♀'])) 221 | .collect::>(); 222 | for actor in actors { 223 | nfo.actors_mut().insert(actor.to_string()); 224 | } 225 | } 226 | _ => {} 227 | } 228 | } 229 | 230 | Ok(()) 231 | } 232 | } 233 | 234 | #[cfg(test)] 235 | mod tests { 236 | use super::*; 237 | use pretty_assertions::assert_eq; 238 | 239 | fn finder() -> Result { 240 | Javdb::builder().timeout(Duration::from_secs(10)).build() 241 | } 242 | 243 | #[test] 244 | fn test_support() -> Result<()> { 245 | let finder = finder()?; 246 | let videos = [ 247 | (VideoType::Jav("STARS".to_string(), "804".to_string()), true), 248 | (VideoType::Fc2("3061625".to_string()), false), 249 | ]; 250 | for (video, supported) in videos { 251 | assert_eq!(finder.support(&video), supported); 252 | } 253 | 254 | Ok(()) 255 | } 256 | 257 | #[tokio::test] 258 | async fn test_find() -> Result<()> { 259 | let finder = finder()?; 260 | let cases = [ 261 | (VideoType::Jav("ROYD".to_string(), "108".to_string()), { 262 | let mut nfo = Nfo::builder() 263 | .id("ROYD-108") 264 | .country(Country::Japan) 265 | .mpaa(Mpaa::NC17) 266 | .build(); 267 | nfo.set_title("朝起きたら部屋に下着姿のギャルが!いつも生意気で悪態ばかりついてくるのに、甘えてきたので… 斎藤あみり".to_string()) 268 | .set_studio("ROYAL".to_string()) 269 | .set_rating(8.64) 270 | .set_runtime(110) 271 | .set_premiered("2022-10-25".to_string()); 272 | let actors = ["斎藤あみり", "かめじろう"]; 273 | let genres = ["辣妹", "中出", "單體作品", "女大學生", "淫亂真實", "女上位"]; 274 | 275 | for actor in actors { 276 | nfo.actors_mut().insert(actor.to_string()); 277 | } 278 | for genre in genres { 279 | nfo.genres_mut().insert(genre.to_string()); 280 | } 281 | nfo 282 | }), 283 | (VideoType::Jav("IPX".to_string(), "443".to_string()), { 284 | let mut nfo = Nfo::builder() 285 | .id("IPX-443") 286 | .country(Country::Japan) 287 | .mpaa(Mpaa::NC17) 288 | .build(); 289 | nfo.set_title("1ヶ月間禁欲させ親友のいない数日間に親友の彼氏と朝から晩まで気が狂うくらいセックスしまくった 果てるまでヤリまくる計10性交! 明里つむぎ".to_string()) 290 | .set_studio("IDEA POCKET".to_string()) 291 | .set_runtime(120) 292 | .set_director("苺原".to_string()) 293 | .set_rating(8.78) 294 | .set_premiered("2020-02-13".to_string()); 295 | let actors = ["愛里るい", "藍井優太", "明里つむぎ"]; 296 | let genres = ["單體作品", "白天出軌", "中出", "紀錄片", "拘束"]; 297 | 298 | for actor in actors { 299 | nfo.actors_mut().insert(actor.to_string()); 300 | } 301 | for genre in genres { 302 | nfo.genres_mut().insert(genre.to_string()); 303 | } 304 | nfo 305 | }), 306 | ]; 307 | for (video, expected) in cases { 308 | let actual = finder.find(&video).await?; 309 | assert!(actual.fanart().is_empty()); 310 | assert!(actual.poster().is_empty()); 311 | assert!(actual.subtitle().is_empty()); 312 | assert_eq!(actual, expected); 313 | } 314 | 315 | Ok(()) 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /crates/spider/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod airav; 2 | mod avsox; 3 | mod cable; 4 | mod fc2ppv_db; 5 | mod hbox; 6 | mod jav321; 7 | mod javdb; 8 | mod missav; 9 | mod porny; 10 | mod subtitle_cat; 11 | mod the_porn_db; 12 | 13 | use std::fmt::Display; 14 | use std::sync::Arc; 15 | use std::time::Duration; 16 | 17 | use airav::Airav; 18 | use anyhow::{Context, Result, anyhow}; 19 | use async_trait::async_trait; 20 | use avsox::Avsox; 21 | use cable::Cable; 22 | use config::Config; 23 | use fc2ppv_db::Fc2ppvDB; 24 | use hbox::Hbox; 25 | use jav321::Jav321; 26 | use javdb::Javdb; 27 | use log::{error, warn}; 28 | use missav::Missav; 29 | use nfo::{Country, Nfo}; 30 | use porny::Porny; 31 | use subtitle_cat::SubtitleCat; 32 | use the_porn_db::ThePornDB; 33 | use video::VideoType; 34 | 35 | #[async_trait] 36 | trait Finder: Send + Sync + Display { 37 | fn support(&self, key: &VideoType) -> bool; 38 | async fn find(&self, key: &VideoType) -> Result; 39 | } 40 | 41 | pub struct Spider { 42 | finders: Vec>, 43 | } 44 | 45 | impl Spider { 46 | pub fn new(config: &Config) -> Result { 47 | let timeout = Duration::from_secs(config.network.timeout); 48 | let proxy = &config.network.proxy; 49 | let url = &config.url; 50 | 51 | macro_rules! spider { 52 | ($s:ty, $u:expr, $m:expr) => { 53 | Arc::new( 54 | <$s>::builder() 55 | .maybe_base_url($u) 56 | .timeout(timeout) 57 | .maybe_proxy(proxy.clone()) 58 | .build() 59 | .with_context(|| concat!("build ", $m))?, 60 | ) 61 | }; 62 | } 63 | 64 | let mut finders: Vec> = vec![ 65 | spider!(Airav, url.airav.clone(), "airav"), 66 | spider!(Avsox, url.avsox.clone(), "avsox"), 67 | spider!(Cable, url.cable.clone(), "cable"), 68 | spider!(Fc2ppvDB, url.fc2ppv_db.clone(), "fc2ppv db"), 69 | spider!(Hbox, url.hbox.clone(), "hbox"), 70 | spider!(Jav321, url.jav321.clone(), "jav321"), 71 | spider!(Javdb, url.javdb.clone(), "javdb"), 72 | spider!(Missav, url.missav.clone(), "missav"), 73 | spider!(Porny, url.porny.clone(), "91 porny"), 74 | spider!(SubtitleCat, url.subtitle_cat.clone(), "subtitle cat"), 75 | ]; 76 | if let Some(ref key) = config.the_porn_db.key { 77 | finders.push(Arc::new( 78 | ThePornDB::builder() 79 | .maybe_base_url(url.the_porn_db.clone()) 80 | .maybe_api_url(url.the_porn_db_api.clone()) 81 | .key(key) 82 | .timeout(timeout) 83 | .maybe_proxy(proxy.clone()) 84 | .build() 85 | .with_context(|| "build the porn db")?, 86 | )); 87 | } 88 | 89 | let spider = Spider { finders }; 90 | Ok(spider) 91 | } 92 | 93 | pub async fn find(&self, key: VideoType) -> Result { 94 | let key = Arc::new(key); 95 | let mut tasks = Vec::new(); 96 | for finder in self.finders.iter() { 97 | if !finder.support(&key) { 98 | warn!("finder {finder} not support {key}"); 99 | continue; 100 | } 101 | 102 | let finder = finder.clone(); 103 | let key = key.clone(); 104 | let task = tokio::spawn(async move { 105 | finder 106 | .find(&key) 107 | .await 108 | .with_context(|| format!("in finder {finder}")) 109 | }); 110 | tasks.push(task); 111 | } 112 | 113 | let mut nfo = None; 114 | for task in tasks { 115 | match task.await? { 116 | Ok(found_nfo) => match nfo { 117 | None => nfo = Some(found_nfo), 118 | Some(ref mut nfo) => nfo.merge(found_nfo), 119 | }, 120 | Err(err) => error!("could not find {key}, caused by {err:?}"), 121 | } 122 | } 123 | 124 | nfo.ok_or_else(|| anyhow!("could not find anything about {key} in all finders")) 125 | } 126 | } 127 | 128 | fn which_country(key: &VideoType) -> Country { 129 | match key { 130 | VideoType::Jav(id, _) => match id.as_str() { 131 | "MD" | "LY" | "MDHG" | "MSD" | "SZL" | "MDSR" | "MDCM" | "PCM" | "YCM" | "KCM" 132 | | "PMX" | "PM" | "PMS" | "EMX" | "GDCM" | "XKTV" | "XKKY" | "XKG" | "XKVP" | "TM" 133 | | "TML" | "TMT" | "TMTC" | "TMW" | "JDYG" | "JD" | "JDKR" | "RAS" | "XSJKY" 134 | | "XSJYH" | "XSJ" | "IDG" | "FSOG" | "QDOG" | "TZ" | "DAD" => Country::China, 135 | _ => Country::Japan, 136 | }, 137 | VideoType::Fc2(_) => Country::Japan, 138 | VideoType::Other(_) => Country::China, 139 | } 140 | } 141 | 142 | #[macro_export] 143 | macro_rules! select { 144 | ($($k:ident: $v: expr)*) => { 145 | struct Selectors { 146 | $( 147 | $k: scraper::Selector, 148 | )* 149 | } 150 | 151 | impl Selectors { 152 | fn new() -> anyhow::Result { 153 | use anyhow::Context; 154 | 155 | let selectors = Selectors { 156 | $( 157 | $k: scraper::Selector::parse($v) 158 | .map_err(|e| anyhow::anyhow!("parse selector failed by {e}")) 159 | .with_context(|| $v) 160 | .with_context(|| stringify!($k))?, 161 | )* 162 | }; 163 | 164 | Ok(selectors) 165 | } 166 | } 167 | }; 168 | } 169 | -------------------------------------------------------------------------------- /crates/spider/src/missav.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use video::VideoType; 11 | 12 | use super::Finder; 13 | 14 | const HOST: &str = "https://fourhoi.com"; 15 | 16 | pub struct Missav { 17 | base_url: String, 18 | client: Client, 19 | } 20 | 21 | #[bon] 22 | impl Missav { 23 | #[builder] 24 | pub fn new( 25 | base_url: Option, 26 | timeout: Duration, 27 | proxy: Option, 28 | ) -> Result { 29 | let client = Client::builder() 30 | .timeout(timeout) 31 | .interval(1) 32 | .maybe_proxy(proxy) 33 | .build() 34 | .with_context(|| "build http client")?; 35 | let base_url = match base_url { 36 | Some(url) => url, 37 | None => String::from(HOST), 38 | }; 39 | 40 | let missav = Missav { base_url, client }; 41 | Ok(missav) 42 | } 43 | 44 | async fn get_fanart(&self, key: &VideoType) -> Result> { 45 | let url = format!( 46 | "{}/{}/cover-n.jpg", 47 | self.base_url, 48 | key.to_string().to_lowercase() 49 | ); 50 | let img = self 51 | .client 52 | .wait() 53 | .await 54 | .get(&url) 55 | .send() 56 | .await? 57 | .error_for_status()? 58 | .bytes() 59 | .await? 60 | .to_vec(); 61 | 62 | Ok(img) 63 | } 64 | } 65 | 66 | impl Display for Missav { 67 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 68 | write!(f, "missav") 69 | } 70 | } 71 | 72 | #[async_trait] 73 | impl Finder for Missav { 74 | fn support(&self, key: &VideoType) -> bool { 75 | match key { 76 | VideoType::Jav(_, _) => true, 77 | VideoType::Fc2(_) => true, 78 | VideoType::Other(_) => false, 79 | } 80 | } 81 | 82 | async fn find(&self, key: &VideoType) -> Result { 83 | let mut nfo = Nfo::builder() 84 | .id(key) 85 | .country(Country::Japan) 86 | .mpaa(Mpaa::NC17) 87 | .build(); 88 | 89 | let fanart = self.get_fanart(key).await.with_context(|| "get fanart")?; 90 | nfo.set_fanart(fanart); 91 | 92 | info!("{nfo:?}"); 93 | Ok(nfo) 94 | } 95 | } 96 | 97 | #[cfg(test)] 98 | mod tests { 99 | use super::*; 100 | use pretty_assertions::assert_eq; 101 | 102 | fn finder() -> Result { 103 | Missav::builder().timeout(Duration::from_secs(10)).build() 104 | } 105 | 106 | #[test] 107 | fn test_support() -> Result<()> { 108 | let finder = finder()?; 109 | let videos = [ 110 | (VideoType::Jav("STARS".to_string(), "804".to_string()), true), 111 | (VideoType::Fc2("3061625".to_string()), true), 112 | ]; 113 | for (video, supported) in videos { 114 | assert_eq!(finder.support(&video), supported); 115 | } 116 | 117 | Ok(()) 118 | } 119 | 120 | #[tokio::test] 121 | async fn test_find() -> Result<()> { 122 | let finder = finder()?; 123 | let cases = [ 124 | (VideoType::Jav("IPX".to_string(), "443".to_string()), { 125 | Nfo::builder() 126 | .id("IPX-443") 127 | .country(Country::Japan) 128 | .mpaa(Mpaa::NC17) 129 | .build() 130 | }), 131 | (VideoType::Fc2("3061625".to_string()), { 132 | Nfo::builder() 133 | .id("FC2-PPV-3061625") 134 | .country(Country::Japan) 135 | .mpaa(Mpaa::NC17) 136 | .build() 137 | }), 138 | (VideoType::Fc2("1292936".to_string()), { 139 | Nfo::builder() 140 | .id("FC2-PPV-1292936") 141 | .country(Country::Japan) 142 | .mpaa(Mpaa::NC17) 143 | .build() 144 | }), 145 | (VideoType::Jav("ROYD".to_string(), "108".to_string()), { 146 | Nfo::builder() 147 | .id("ROYD-108") 148 | .country(Country::Japan) 149 | .mpaa(Mpaa::NC17) 150 | .build() 151 | }), 152 | (VideoType::Jav("STARS".to_string(), "804".to_string()), { 153 | Nfo::builder() 154 | .id("STARS-804") 155 | .country(Country::Japan) 156 | .mpaa(Mpaa::NC17) 157 | .build() 158 | }), 159 | ]; 160 | for (video, expected) in cases { 161 | let actual = finder.find(&video).await?; 162 | assert!(!actual.fanart().is_empty()); 163 | assert!(actual.poster().is_empty()); 164 | assert!(actual.subtitle().is_empty()); 165 | assert_eq!(actual, expected); 166 | } 167 | 168 | Ok(()) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /crates/spider/src/porny.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result, anyhow, bail}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use scraper::Html; 11 | use video::VideoType; 12 | 13 | use super::{Finder, select}; 14 | 15 | const HOST: &str = "https://91porny.com"; 16 | 17 | select!( 18 | item: "#main > div.container-fluid.px-0 > div:nth-child(3) > div" 19 | title: "div > a.title.text-sub-title.mt-2.mb-1" 20 | author: "div > small > div:nth-child(1) > a" 21 | date: "div > small > div:nth-child(2)" 22 | runtime: "div > a.display.d-block > small" 23 | fanart: "div > a.display.d-block > div.img" 24 | ); 25 | 26 | pub struct Porny { 27 | base_url: String, 28 | client: Client, 29 | selectors: Selectors, 30 | } 31 | 32 | #[bon] 33 | impl Porny { 34 | #[builder] 35 | pub fn new( 36 | base_url: Option, 37 | timeout: Duration, 38 | proxy: Option, 39 | ) -> Result { 40 | let client = Client::builder() 41 | .timeout(timeout) 42 | .interval(1) 43 | .maybe_proxy(proxy) 44 | .build() 45 | .with_context(|| "build http client")?; 46 | let selectors = Selectors::new().with_context(|| "build selectors")?; 47 | let base_url = match base_url { 48 | Some(url) => url, 49 | None => String::from(HOST), 50 | }; 51 | 52 | let porny = Porny { 53 | base_url, 54 | selectors, 55 | client, 56 | }; 57 | Ok(porny) 58 | } 59 | } 60 | 61 | impl Display for Porny { 62 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 63 | write!(f, "91 porny") 64 | } 65 | } 66 | 67 | #[async_trait] 68 | impl Finder for Porny { 69 | fn support(&self, key: &VideoType) -> bool { 70 | match key { 71 | VideoType::Jav(_, _) => false, 72 | VideoType::Fc2(_) => false, 73 | VideoType::Other(_) => true, 74 | } 75 | } 76 | 77 | async fn find(&self, key: &VideoType) -> Result { 78 | let mut nfo = Nfo::builder() 79 | .id(key) 80 | .country(Country::China) 81 | .mpaa(Mpaa::NC17) 82 | .build(); 83 | 84 | let fanart = self.search(key, &mut nfo).await?; 85 | let fanart = self 86 | .client 87 | .wait() 88 | .await 89 | .get(fanart) 90 | .send() 91 | .await? 92 | .bytes() 93 | .await? 94 | .to_vec(); 95 | nfo.set_fanart(fanart); 96 | nfo.set_studio("91".to_string()); 97 | 98 | info!("{nfo:?}"); 99 | Ok(nfo) 100 | } 101 | } 102 | 103 | impl Porny { 104 | async fn search(&self, key: &VideoType, nfo: &mut Nfo) -> Result { 105 | let name = key.to_string(); 106 | let url = format!("{}/search", self.base_url); 107 | let text = self 108 | .client 109 | .wait() 110 | .await 111 | .get(url) 112 | .query(&[("keywords", &name)]) 113 | .send() 114 | .await? 115 | .text() 116 | .await?; 117 | let html = Html::parse_document(&text); 118 | 119 | let Some(found) = html.select(&self.selectors.item).find(|item| { 120 | item.select(&self.selectors.title) 121 | .next() 122 | .map(|title| title.text().collect::()) 123 | .map(|title| title.contains(&name)) 124 | .unwrap_or(false) 125 | }) else { 126 | bail!("item not found"); 127 | }; 128 | 129 | if let Some(title) = found 130 | .select(&self.selectors.title) 131 | .next() 132 | .map(|title| title.text().collect::()) 133 | { 134 | nfo.set_title(title); 135 | } 136 | 137 | if let Some(author) = found 138 | .select(&self.selectors.author) 139 | .next() 140 | .map(|author| author.text().collect::()) 141 | { 142 | nfo.set_director(author); 143 | } 144 | 145 | if let Some(date) = found 146 | .select(&self.selectors.date) 147 | .next() 148 | .map(|date| date.text().collect::()) 149 | .and_then(|date| { 150 | date.split_once('|') 151 | .map(|(date, _)| date.trim().to_string()) 152 | }) 153 | { 154 | nfo.set_premiered(date); 155 | } 156 | 157 | if let Some(runtime) = 158 | found 159 | .select(&self.selectors.runtime) 160 | .next() 161 | .map(|runtime| runtime.text().collect::()) 162 | .map(|runtime| { 163 | runtime.trim().split(':').take(2).enumerate().fold( 164 | 0, 165 | |mut runtime, (idx, num)| { 166 | let num = num.parse().unwrap_or(0); 167 | 168 | match idx { 169 | 0 => { 170 | runtime += num * 60; 171 | } 172 | 1 => { 173 | runtime += num; 174 | } 175 | _ => {} 176 | } 177 | 178 | runtime 179 | }, 180 | ) 181 | }) 182 | { 183 | nfo.set_runtime(runtime); 184 | } 185 | 186 | found 187 | .select(&self.selectors.fanart) 188 | .next() 189 | .and_then(|img| img.attr("style")) 190 | .and_then(|sty| sty.split("'").nth(1).map(|fanart| fanart.to_string())) 191 | .ok_or(anyhow!("fanart not found")) 192 | } 193 | } 194 | 195 | #[cfg(test)] 196 | mod tests { 197 | use super::*; 198 | use pretty_assertions::assert_eq; 199 | 200 | fn finder() -> Result { 201 | Porny::builder().timeout(Duration::from_secs(10)).build() 202 | } 203 | 204 | #[test] 205 | fn test_support() -> Result<()> { 206 | let finder = finder()?; 207 | let videos = [ 208 | ( 209 | VideoType::Jav("STARS".to_string(), "804".to_string()), 210 | false, 211 | ), 212 | (VideoType::Fc2("3061625".to_string()), false), 213 | (VideoType::Other("hello".to_string()), true), 214 | ]; 215 | for (video, supported) in videos { 216 | assert_eq!(finder.support(&video), supported); 217 | } 218 | 219 | Ok(()) 220 | } 221 | 222 | #[tokio::test] 223 | async fn test_find() -> Result<()> { 224 | let finder = finder()?; 225 | let cases = [(VideoType::Other("小飞棍来咯".to_string()), { 226 | let mut nfo = Nfo::builder() 227 | .id("小飞棍来咯") 228 | .country(Country::China) 229 | .mpaa(Mpaa::NC17) 230 | .build(); 231 | nfo.set_premiered("2022-10-12".to_string()); 232 | nfo.set_director("炮王大恶魔".to_string()); 233 | nfo.set_title("小飞棍来咯".to_string()); 234 | nfo.set_studio("91".to_string()); 235 | nfo.set_runtime(3); 236 | 237 | nfo 238 | })]; 239 | for (video, expected) in cases { 240 | let actual = finder.find(&video).await?; 241 | assert!(!actual.fanart().is_empty()); 242 | assert!(actual.poster().is_empty()); 243 | assert!(actual.subtitle().is_empty()); 244 | assert_eq!(actual, expected); 245 | } 246 | 247 | Ok(()) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /crates/spider/src/subtitle_cat.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result, anyhow, bail}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::Nfo; 10 | use scraper::Html; 11 | use video::VideoType; 12 | 13 | use super::{Finder, select}; 14 | 15 | const HOST: &str = "https://www.subtitlecat.com"; 16 | 17 | select!( 18 | home_item: "body > div.subtitles > div > div > div > table > tbody > tr > td:nth-child(1) > a" 19 | detail_download_url: "#download_zh-CN" 20 | ); 21 | 22 | pub struct SubtitleCat { 23 | base_url: String, 24 | client: Client, 25 | selectors: Selectors, 26 | } 27 | 28 | #[bon] 29 | impl SubtitleCat { 30 | #[builder] 31 | pub fn new( 32 | base_url: Option, 33 | timeout: Duration, 34 | proxy: Option, 35 | ) -> Result { 36 | let client = Client::builder() 37 | .timeout(timeout) 38 | .interval(1) 39 | .maybe_proxy(proxy) 40 | .build() 41 | .with_context(|| "build http client")?; 42 | let selectors = Selectors::new().with_context(|| "build selectors")?; 43 | let base_url = match base_url { 44 | Some(url) => url, 45 | None => String::from(HOST), 46 | }; 47 | 48 | let subtitle_cat = SubtitleCat { 49 | base_url, 50 | client, 51 | selectors, 52 | }; 53 | Ok(subtitle_cat) 54 | } 55 | } 56 | 57 | impl Display for SubtitleCat { 58 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 59 | write!(f, "subtitle cat") 60 | } 61 | } 62 | 63 | #[async_trait] 64 | impl Finder for SubtitleCat { 65 | fn support(&self, key: &VideoType) -> bool { 66 | match key { 67 | VideoType::Jav(_, _) => true, 68 | VideoType::Fc2(_) => true, 69 | VideoType::Other(_) => false, 70 | } 71 | } 72 | 73 | async fn find(&self, key: &VideoType) -> Result { 74 | let mut nfo = Nfo::builder().id(key).build(); 75 | 76 | let url = self.find_detail(key).await.with_context(|| "find detail")?; 77 | let subtitle = self 78 | .find_subtitle_in_detail(&url) 79 | .await 80 | .with_context(|| format!("find subtitle in detail {url}"))?; 81 | let subtitle = self 82 | .client 83 | .wait() 84 | .await 85 | .get(subtitle) 86 | .send() 87 | .await? 88 | .error_for_status()? 89 | .text() 90 | .await?; 91 | if subtitle.contains("html") && subtitle.contains("404") { 92 | bail!("subtitle downloaded, but found 404 html in srt file"); 93 | } 94 | nfo.set_subtitle(subtitle.into_bytes()); 95 | 96 | info!("{nfo:?}"); 97 | Ok(nfo) 98 | } 99 | } 100 | 101 | impl SubtitleCat { 102 | async fn find_subtitle_in_detail(&self, url: &str) -> Result { 103 | let text = self 104 | .client 105 | .wait() 106 | .await 107 | .get(url) 108 | .send() 109 | .await? 110 | .text() 111 | .await?; 112 | let html = Html::parse_document(&text); 113 | html.select(&self.selectors.detail_download_url) 114 | .next() 115 | .and_then(|node| { 116 | node.attr("href") 117 | .map(|href| format!("{}{href}", self.base_url)) 118 | }) 119 | .ok_or_else(|| anyhow!("download url not found")) 120 | } 121 | 122 | async fn find_detail(&self, key: &VideoType) -> Result { 123 | let url = format!("{}/index.php", self.base_url); 124 | let text = self 125 | .client 126 | .wait() 127 | .await 128 | .get(url) 129 | .query(&[("search", key.to_string())]) 130 | .send() 131 | .await? 132 | .text() 133 | .await?; 134 | let html = Html::parse_document(&text); 135 | let possible_names = match &key { 136 | VideoType::Jav(id, number) => { 137 | vec![format!("{id}-{number}"), format!("{id}{number}")] 138 | } 139 | VideoType::Fc2(number) => vec![ 140 | format!("FC2-{number}"), 141 | format!("FC2-PPV-{number}"), 142 | format!("FC2PPV-{number}"), 143 | format!("FC2PPV{number}"), 144 | ], 145 | VideoType::Other(title) => vec![title.clone()], 146 | }; 147 | 148 | html.select(&self.selectors.home_item) 149 | .find(|item| { 150 | let title = item.text().collect::(); 151 | possible_names.iter().any(|name| title.contains(name)) 152 | }) 153 | .and_then(|node| { 154 | node.attr("href") 155 | .map(|href| format!("{}/{href}", self.base_url)) 156 | }) 157 | .ok_or_else(|| anyhow!("subtitle not found")) 158 | } 159 | } 160 | 161 | #[cfg(test)] 162 | mod tests { 163 | use super::*; 164 | use pretty_assertions::assert_eq; 165 | 166 | fn finder() -> Result { 167 | SubtitleCat::builder() 168 | .timeout(Duration::from_secs(10)) 169 | .build() 170 | } 171 | 172 | #[test] 173 | fn test_support() -> Result<()> { 174 | let finder = finder()?; 175 | let videos = [ 176 | (VideoType::Jav("STARS".to_string(), "804".to_string()), true), 177 | (VideoType::Fc2("3061625".to_string()), true), 178 | ]; 179 | for (video, supported) in videos { 180 | assert_eq!(finder.support(&video), supported); 181 | } 182 | 183 | Ok(()) 184 | } 185 | 186 | #[tokio::test] 187 | async fn test_find() -> Result<()> { 188 | let finder = finder()?; 189 | let cases = [ 190 | (VideoType::Jav("IPX".to_string(), "443".to_string()), { 191 | Nfo::builder().id("IPX-443").build() 192 | }), 193 | (VideoType::Jav("ROYD".to_string(), "108".to_string()), { 194 | Nfo::builder().id("ROYD-108").build() 195 | }), 196 | (VideoType::Jav("STARS".to_string(), "804".to_string()), { 197 | Nfo::builder().id("STARS-804").build() 198 | }), 199 | (VideoType::Fc2("3061625".to_string()), { 200 | Nfo::builder().id("FC2-PPV-3061625").build() 201 | }), 202 | ]; 203 | for (video, expected) in cases { 204 | let actual = finder.find(&video).await?; 205 | assert!(actual.fanart().is_empty()); 206 | assert!(actual.poster().is_empty()); 207 | assert!(!actual.subtitle().is_empty()); 208 | assert_eq!(actual, expected); 209 | } 210 | 211 | Ok(()) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /crates/spider/src/the_porn_db.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result, anyhow}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use http_client::Client; 8 | use log::info; 9 | use nfo::{Country, Mpaa, Nfo}; 10 | use reqwest::header::{self, HeaderMap, HeaderValue}; 11 | use scraper::Html; 12 | use serde::Deserialize; 13 | use serde_json::Value; 14 | use video::VideoType; 15 | 16 | use super::{Finder, select, which_country}; 17 | 18 | const HOST: &str = "https://theporndb.net"; 19 | const API_HOST: &str = "https://api.theporndb.net"; 20 | 21 | select!( 22 | data: "#app" 23 | ); 24 | 25 | pub struct ThePornDB { 26 | base_url: String, 27 | api_url: String, 28 | client: Client, 29 | selectors: Selectors, 30 | } 31 | 32 | #[bon] 33 | impl ThePornDB { 34 | #[builder] 35 | pub fn new( 36 | base_url: Option, 37 | api_url: Option, 38 | key: impl AsRef, 39 | timeout: Duration, 40 | proxy: Option, 41 | ) -> Result { 42 | let headers = { 43 | let mut headers = HeaderMap::new(); 44 | headers.insert( 45 | header::AUTHORIZATION, 46 | HeaderValue::from_str(&format!("Bearer {}", key.as_ref()))?, 47 | ); 48 | headers.insert( 49 | header::CONTENT_TYPE, 50 | HeaderValue::from_static("application/json"), 51 | ); 52 | headers.insert(header::ACCEPT, HeaderValue::from_static("application/json")); 53 | 54 | headers 55 | }; 56 | let client = Client::builder() 57 | .timeout(timeout) 58 | .interval(1) 59 | .maybe_proxy(proxy) 60 | .headers(headers) 61 | .build() 62 | .with_context(|| "build http client")?; 63 | let base_url = match base_url { 64 | Some(url) => url, 65 | None => String::from(HOST), 66 | }; 67 | let api_url = match api_url { 68 | Some(url) => url, 69 | None => String::from(API_HOST), 70 | }; 71 | let selectors = Selectors::new().with_context(|| "build selectors")?; 72 | 73 | let this = Self { 74 | base_url, 75 | api_url, 76 | client, 77 | selectors, 78 | }; 79 | Ok(this) 80 | } 81 | } 82 | 83 | impl Display for ThePornDB { 84 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 85 | write!(f, "the porn db") 86 | } 87 | } 88 | 89 | #[async_trait] 90 | impl Finder for ThePornDB { 91 | fn support(&self, key: &VideoType) -> bool { 92 | match key { 93 | VideoType::Jav(_, _) => !matches!(which_country(key), Country::China), 94 | VideoType::Fc2(_) => false, 95 | VideoType::Other(_) => false, 96 | } 97 | } 98 | 99 | async fn find(&self, key: &VideoType) -> Result { 100 | let mut nfo = Nfo::builder() 101 | .id(key) 102 | .country(Country::Japan) 103 | .mpaa(Mpaa::NC17) 104 | .build(); 105 | 106 | let link = self.find_by_key(key).await?; 107 | let uuid = self.get_uuid_by_link(link).await?; 108 | self.load_data_by_uuid(uuid, &mut nfo).await?; 109 | 110 | info!("{nfo:?}"); 111 | Ok(nfo) 112 | } 113 | } 114 | 115 | impl ThePornDB { 116 | async fn load_data_by_uuid(&self, uuid: String, nfo: &mut Nfo) -> Result<()> { 117 | #[allow(unused)] 118 | #[derive(Deserialize)] 119 | pub struct Response { 120 | pub data: Data, 121 | } 122 | 123 | #[allow(unused)] 124 | #[derive(Deserialize)] 125 | pub struct Data { 126 | pub id: Value, 127 | #[serde(rename = "_id")] 128 | pub id2: Value, 129 | pub title: String, 130 | #[serde(rename = "type")] 131 | pub type_field: Value, 132 | pub slug: Value, 133 | pub external_id: Value, 134 | pub description: Value, 135 | pub rating: Value, 136 | pub site_id: Value, 137 | pub date: String, 138 | pub url: Value, 139 | pub image: Value, 140 | pub back_image: Value, 141 | pub poster: Value, 142 | pub trailer: Value, 143 | pub duration: i64, 144 | pub format: Value, 145 | pub sku: Value, 146 | pub posters: Option, 147 | pub background: Option, 148 | pub background_back: Value, 149 | pub created: Value, 150 | pub last_updated: Value, 151 | pub performers: Vec, 152 | pub site: Site, 153 | pub tags: Vec, 154 | pub hashes: Value, 155 | pub markers: Vec, 156 | pub directors: Vec, 157 | pub scenes: Vec, 158 | pub movies: Vec, 159 | pub links: Vec, 160 | } 161 | 162 | #[allow(unused)] 163 | #[derive(Deserialize)] 164 | pub struct Posters { 165 | pub large: String, 166 | pub medium: Value, 167 | pub small: Value, 168 | } 169 | 170 | #[allow(unused)] 171 | #[derive(Deserialize)] 172 | pub struct Background { 173 | pub full: Value, 174 | pub large: String, 175 | pub medium: Value, 176 | pub small: Value, 177 | } 178 | 179 | #[allow(unused)] 180 | #[derive(Deserialize)] 181 | pub struct Performer { 182 | pub id: Value, 183 | #[serde(rename = "_id")] 184 | pub id2: Value, 185 | pub slug: Value, 186 | pub site_id: Value, 187 | pub name: String, 188 | pub bio: Value, 189 | pub is_parent: Value, 190 | pub extra: Value, 191 | pub image: Value, 192 | pub thumbnail: Value, 193 | pub face: Value, 194 | pub parent: Value, 195 | } 196 | 197 | #[allow(unused)] 198 | #[derive(Deserialize)] 199 | pub struct Site { 200 | pub uuid: Value, 201 | pub id: Value, 202 | pub parent_id: Value, 203 | pub network_id: Value, 204 | pub name: String, 205 | pub short_name: Value, 206 | pub url: Value, 207 | pub description: Value, 208 | pub rating: Value, 209 | pub logo: Value, 210 | pub favicon: Value, 211 | pub poster: Value, 212 | pub network: Value, 213 | pub parent: Value, 214 | } 215 | 216 | #[allow(unused)] 217 | #[derive(Deserialize)] 218 | pub struct Director { 219 | pub id: Value, 220 | pub name: String, 221 | pub slug: Value, 222 | } 223 | 224 | let url = format!("{}/jav/{}", self.api_url, uuid); 225 | let res = self 226 | .client 227 | .wait() 228 | .await 229 | .get(url) 230 | .send() 231 | .await? 232 | .json::() 233 | .await?; 234 | 235 | let data = res.data; 236 | nfo.set_title(data.title); 237 | nfo.set_premiered(data.date); 238 | nfo.set_runtime(data.duration as u32 / 60); 239 | for actor in data.performers { 240 | nfo.actors_mut().insert(actor.name); 241 | } 242 | nfo.set_studio(data.site.name); 243 | if let Some(director) = data.directors.first() { 244 | nfo.set_director(director.name.clone()); 245 | } 246 | if let Some(poster) = data.posters.map(|posters| posters.large) { 247 | let poster = self 248 | .client 249 | .wait() 250 | .await 251 | .get(poster) 252 | .send() 253 | .await? 254 | .bytes() 255 | .await? 256 | .to_vec(); 257 | nfo.set_poster(poster); 258 | } 259 | if let Some(fanart) = data.background.map(|background| background.large) { 260 | let fanart = self 261 | .client 262 | .wait() 263 | .await 264 | .get(fanart) 265 | .send() 266 | .await? 267 | .bytes() 268 | .await? 269 | .to_vec(); 270 | nfo.set_fanart(fanart); 271 | } 272 | 273 | Ok(()) 274 | } 275 | 276 | async fn find_by_key(&self, key: &VideoType) -> Result { 277 | #[allow(unused)] 278 | #[derive(Deserialize)] 279 | pub struct Response { 280 | pub component: Value, 281 | pub props: Props, 282 | pub url: Value, 283 | pub version: Value, 284 | #[serde(rename = "clearHistory")] 285 | pub clear_history: Value, 286 | #[serde(rename = "encryptHistory")] 287 | pub encrypt_history: Value, 288 | } 289 | 290 | #[allow(unused)] 291 | #[derive(Deserialize)] 292 | pub struct Props { 293 | pub errors: Value, 294 | pub jetstream: Value, 295 | pub auth: Value, 296 | #[serde(rename = "errorBags")] 297 | pub error_bags: Value, 298 | pub meta: Value, 299 | #[serde(rename = "verifiedAge")] 300 | pub verified_age: Value, 301 | pub hide_ads: Value, 302 | #[serde(rename = "currentRouteName")] 303 | pub current_route_name: Value, 304 | pub flash: Value, 305 | pub urls: Value, 306 | pub menu: Value, 307 | pub sfw: Value, 308 | pub dark: Value, 309 | pub scenes: Scenes, 310 | pub request: Value, 311 | pub sort: Value, 312 | pub genders: Value, 313 | pub operators: Value, 314 | pub hashes: Value, 315 | #[serde(rename = "siteOperators")] 316 | pub site_operators: Value, 317 | #[serde(rename = "queryOperations")] 318 | pub query_operations: Value, 319 | } 320 | 321 | #[allow(unused)] 322 | #[derive(Deserialize)] 323 | pub struct Scenes { 324 | pub data: Vec, 325 | pub links: Value, 326 | pub meta: Value, 327 | } 328 | 329 | #[allow(unused)] 330 | #[derive(Deserialize)] 331 | pub struct Daum { 332 | pub background: Value, 333 | pub date: Value, 334 | pub default_background: Value, 335 | pub duration: Value, 336 | pub edit_link: Value, 337 | pub id: Value, 338 | pub is_collected: Value, 339 | pub is_hidden: Value, 340 | pub link: String, 341 | pub performers: Value, 342 | pub site: Value, 343 | pub slug: Value, 344 | pub title: String, 345 | #[serde(rename = "type")] 346 | pub type_field: Value, 347 | } 348 | 349 | let name = key.to_string(); 350 | let url = format!("{}/jav", self.base_url); 351 | let text = self 352 | .client 353 | .wait() 354 | .await 355 | .get(url) 356 | .query(&[("q", &name)]) 357 | .send() 358 | .await? 359 | .text() 360 | .await?; 361 | let html = Html::parse_document(&text); 362 | let data = html 363 | .select(&self.selectors.data) 364 | .next() 365 | .and_then(|app| app.attr("data-page")) 366 | .ok_or(anyhow!("data-page attribute not found"))?; 367 | let res = serde_json::from_str::(data).with_context(|| "parse data to json")?; 368 | 369 | res.props 370 | .scenes 371 | .data 372 | .into_iter() 373 | .find(|data| data.title.contains(&name)) 374 | .map(|data| data.link) 375 | .ok_or(anyhow!("data not found")) 376 | } 377 | 378 | async fn get_uuid_by_link(&self, link: String) -> Result { 379 | #[allow(unused)] 380 | #[derive(Deserialize)] 381 | pub struct Response { 382 | pub component: Value, 383 | pub props: Props, 384 | pub url: Value, 385 | pub version: Value, 386 | #[serde(rename = "clearHistory")] 387 | pub clear_history: Value, 388 | #[serde(rename = "encryptHistory")] 389 | pub encrypt_history: Value, 390 | } 391 | 392 | #[allow(unused)] 393 | #[derive(Deserialize)] 394 | pub struct Props { 395 | pub errors: Value, 396 | pub jetstream: Value, 397 | pub auth: Value, 398 | #[serde(rename = "errorBags")] 399 | pub error_bags: Value, 400 | pub meta: Value, 401 | #[serde(rename = "verifiedAge")] 402 | pub verified_age: Value, 403 | pub hide_ads: Value, 404 | #[serde(rename = "currentRouteName")] 405 | pub current_route_name: Value, 406 | pub flash: Value, 407 | pub urls: Value, 408 | pub menu: Value, 409 | pub sfw: Value, 410 | pub dark: Value, 411 | pub scene: Scene, 412 | #[serde(rename = "hashTypes")] 413 | pub hash_types: Value, 414 | } 415 | 416 | #[allow(unused)] 417 | #[derive(Deserialize)] 418 | pub struct Scene { 419 | pub background: Value, 420 | pub background_back: Value, 421 | pub date: Value, 422 | pub default_background: Value, 423 | pub description: Value, 424 | pub directors: Value, 425 | pub duration: Value, 426 | pub edit_link: Value, 427 | pub format: Value, 428 | pub hashes: Value, 429 | pub id: Value, 430 | pub is_collected: Value, 431 | pub is_hidden: Value, 432 | pub link: Value, 433 | pub links: Value, 434 | pub markers: Value, 435 | pub movies: Value, 436 | pub performers: Value, 437 | pub scenes: Value, 438 | pub site: Value, 439 | pub sku: Value, 440 | pub slug: Value, 441 | pub store: Value, 442 | pub tags: Value, 443 | pub title: Value, 444 | pub trailer: Value, 445 | #[serde(rename = "type")] 446 | pub type_field: Value, 447 | pub url: Value, 448 | pub uuid: String, 449 | } 450 | 451 | let text = self 452 | .client 453 | .wait() 454 | .await 455 | .get(link) 456 | .send() 457 | .await? 458 | .text() 459 | .await?; 460 | let html = Html::parse_document(&text); 461 | let data = html 462 | .select(&self.selectors.data) 463 | .next() 464 | .and_then(|app| app.attr("data-page")) 465 | .ok_or(anyhow!("data-page attribute not found"))?; 466 | let res = serde_json::from_str::(data).with_context(|| "parse data to json")?; 467 | 468 | Ok(res.props.scene.uuid) 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /crates/translator/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "translator" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | config.workspace = true 8 | 9 | async-trait.workspace = true 10 | anyhow.workspace = true 11 | ratelimit.workspace = true 12 | tokio.workspace = true 13 | uuid.workspace = true 14 | serde.workspace = true 15 | reqwest.workspace = true 16 | sha256.workspace = true 17 | log.workspace = true 18 | bon.workspace = true 19 | async-openai.workspace = true 20 | indoc.workspace = true 21 | -------------------------------------------------------------------------------- /crates/translator/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod openai; 2 | mod youdao; 3 | 4 | use std::fmt::Display; 5 | use std::sync::Arc; 6 | use std::time::Duration; 7 | 8 | use anyhow::{Context, Result}; 9 | use async_trait::async_trait; 10 | use config::Config; 11 | use config::Translator as CfgTranslator; 12 | use log::info; 13 | use openai::Openai; 14 | use ratelimit::Ratelimiter; 15 | use tokio::time; 16 | use youdao::Youdao; 17 | 18 | pub struct Translator { 19 | handlers: Vec<(Ratelimiter, Arc)>, 20 | } 21 | 22 | impl Translator { 23 | pub fn new(config: &Config) -> Result { 24 | let timeout = Duration::from_secs(config.network.timeout); 25 | let proxy = &config.network.proxy; 26 | let mut handlers = vec![]; 27 | if let Some(translators) = &config.translators { 28 | for translator in translators { 29 | let handler = match translator { 30 | CfgTranslator::Youdao { key, secret } => { 31 | let handler = Youdao::builder() 32 | .key(key) 33 | .secret(secret) 34 | .timeout(timeout) 35 | .maybe_proxy(proxy.clone()) 36 | .build() 37 | .with_context(|| "build youdao client")?; 38 | let limiter = Ratelimiter::builder(1, Duration::from_secs(1)) 39 | .initial_available(1) 40 | .build() 41 | .with_context(|| "build limiter")?; 42 | 43 | (limiter, Arc::new(handler) as Arc) 44 | } 45 | CfgTranslator::DeepSeek { base, model, key } => { 46 | let handler = Openai::builder() 47 | .base(base) 48 | .model(model) 49 | .key(key) 50 | .timeout(timeout) 51 | .maybe_proxy(proxy.clone()) 52 | .build() 53 | .with_context(|| "build deepseek client")?; 54 | let limiter = Ratelimiter::builder(1, Duration::from_secs(2)) 55 | .initial_available(1) 56 | .build() 57 | .with_context(|| "build limiter")?; 58 | 59 | (limiter, Arc::new(handler) as Arc) 60 | } 61 | CfgTranslator::Openai { base, model, key } => { 62 | let handler = Openai::builder() 63 | .base(base) 64 | .model(model) 65 | .key(key) 66 | .timeout(timeout) 67 | .maybe_proxy(proxy.clone()) 68 | .build() 69 | .with_context(|| "build openai client")?; 70 | let limiter = Ratelimiter::builder(1, Duration::from_secs(2)) 71 | .initial_available(1) 72 | .build() 73 | .with_context(|| "build limiter")?; 74 | 75 | (limiter, Arc::new(handler) as Arc) 76 | } 77 | }; 78 | handlers.push(handler); 79 | } 80 | } 81 | let translator = Translator { handlers }; 82 | if translator.handlers.is_empty() { 83 | info!("translate disabled"); 84 | } 85 | 86 | Ok(translator) 87 | } 88 | 89 | async fn wait(&self) -> Option> { 90 | let mut times = Vec::with_capacity(self.handlers.len()); 91 | 92 | 'outer: loop { 93 | for handler in self.handlers.iter() { 94 | match handler.0.try_wait() { 95 | Ok(_) => break 'outer Some(handler.1.clone()), 96 | Err(time) => times.push(time), 97 | } 98 | } 99 | 100 | times.sort(); 101 | match times.first() { 102 | Some(sleep) => time::sleep(*sleep).await, 103 | None => break None, 104 | } 105 | 106 | times.clear(); 107 | } 108 | } 109 | 110 | pub async fn translate(&self, content: &str) -> Result> { 111 | let Some(handler) = self.wait().await else { 112 | return Ok(None); 113 | }; 114 | let translated = handler 115 | .translate(content) 116 | .await 117 | .with_context(|| format!("in translator {handler}"))?; 118 | 119 | Ok(Some(translated)) 120 | } 121 | } 122 | 123 | #[async_trait] 124 | trait Handler: Send + Sync + Display { 125 | async fn translate(&self, content: &str) -> Result; 126 | } 127 | -------------------------------------------------------------------------------- /crates/translator/src/openai.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::Duration; 3 | 4 | use anyhow::{Context, Result, anyhow}; 5 | use async_openai::Client; 6 | use async_openai::config::OpenAIConfig; 7 | use async_openai::types::{ 8 | ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs, 9 | ChatCompletionRequestUserMessageContent, CreateChatCompletionRequestArgs, 10 | }; 11 | use async_trait::async_trait; 12 | use bon::bon; 13 | use indoc::formatdoc; 14 | use reqwest::Proxy; 15 | 16 | use super::Handler; 17 | 18 | pub struct Openai { 19 | client: Client, 20 | model: String, 21 | } 22 | 23 | #[bon] 24 | impl Openai { 25 | #[builder] 26 | pub fn new( 27 | base: impl Into, 28 | model: impl Into, 29 | key: impl Into, 30 | timeout: Duration, 31 | proxy: Option, 32 | ) -> Result { 33 | let mut client_builder = reqwest::Client::builder().timeout(timeout); 34 | if let Some(url) = proxy { 35 | let proxy = Proxy::all(&url).with_context(|| format!("set proxy to {url}"))?; 36 | client_builder = client_builder.proxy(proxy); 37 | } 38 | let client = client_builder 39 | .build() 40 | .with_context(|| "build reqwest client")?; 41 | let config = OpenAIConfig::new().with_api_base(base).with_api_key(key); 42 | let client = Client::with_config(config).with_http_client(client); 43 | let openai = Openai { 44 | client, 45 | model: model.into(), 46 | }; 47 | 48 | Ok(openai) 49 | } 50 | 51 | pub async fn chat( 52 | &self, 53 | content: impl Into, 54 | ) -> Result { 55 | let request = CreateChatCompletionRequestArgs::default() 56 | .model(&self.model) 57 | .messages([ 58 | ChatCompletionRequestSystemMessageArgs::default() 59 | .content("You are a helpful assistant.") 60 | .build() 61 | .with_context(|| "build message")? 62 | .into(), 63 | ChatCompletionRequestUserMessageArgs::default() 64 | .content(content) 65 | .build() 66 | .with_context(|| "build message")? 67 | .into(), 68 | ]) 69 | .build()?; 70 | let response = self.client.chat().create(request).await?; 71 | let reply = response 72 | .choices 73 | .into_iter() 74 | .next() 75 | .and_then(|choice| choice.message.content) 76 | .ok_or(anyhow!("no response from {}", self.model))?; 77 | 78 | Ok(reply) 79 | } 80 | } 81 | 82 | impl Display for Openai { 83 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 84 | write!(f, "openai") 85 | } 86 | } 87 | 88 | #[async_trait] 89 | impl Handler for Openai { 90 | async fn translate(&self, content: &str) -> Result { 91 | let translated = self 92 | .chat(formatdoc!( 93 | " 94 | 请将下面内容翻译为中文,不要输出除了翻译内容外的其他内容 95 | 96 | {content} 97 | " 98 | )) 99 | .await 100 | .with_context(|| format!("translate {content}"))?; 101 | 102 | Ok(translated) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /crates/translator/src/youdao.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 3 | 4 | use anyhow::{Context, Result, bail}; 5 | use async_trait::async_trait; 6 | use bon::bon; 7 | use log::info; 8 | use reqwest::{Client, Proxy}; 9 | use serde::Deserialize; 10 | use sha256::digest; 11 | use uuid::Uuid; 12 | 13 | use super::Handler; 14 | 15 | pub struct Youdao { 16 | client: Client, 17 | key: String, 18 | secret: String, 19 | } 20 | 21 | #[bon] 22 | impl Youdao { 23 | #[builder] 24 | pub fn new( 25 | key: impl Into, 26 | secret: impl Into, 27 | timeout: Duration, 28 | proxy: Option, 29 | ) -> Result { 30 | let mut client_builder = Client::builder().timeout(timeout); 31 | if let Some(url) = proxy { 32 | let proxy = Proxy::all(&url).with_context(|| format!("set proxy to {url}"))?; 33 | client_builder = client_builder.proxy(proxy); 34 | } 35 | let client = client_builder 36 | .build() 37 | .with_context(|| "build reqwest client")?; 38 | let youdao = Youdao { 39 | client, 40 | key: key.into(), 41 | secret: secret.into(), 42 | }; 43 | 44 | Ok(youdao) 45 | } 46 | 47 | fn truncate(text: impl AsRef) -> String { 48 | let text = text.as_ref(); 49 | 50 | let len = text.chars().count(); 51 | if len <= 20 { 52 | return text.to_owned(); 53 | } 54 | 55 | format!( 56 | "{}{}{}", 57 | text.chars().take(10).collect::(), 58 | len, 59 | text.chars().skip(len - 10).collect::() 60 | ) 61 | } 62 | 63 | fn concat_sign( 64 | &self, 65 | text: impl AsRef, 66 | salt: impl AsRef, 67 | cur_time: impl AsRef, 68 | ) -> String { 69 | let text = text.as_ref(); 70 | let salt = salt.as_ref(); 71 | let cur_time = cur_time.as_ref(); 72 | 73 | let not_signed = format!( 74 | "{}{}{}{}{}", 75 | self.key, 76 | Self::truncate(text), 77 | salt, 78 | cur_time, 79 | self.secret 80 | ); 81 | 82 | digest(not_signed) 83 | } 84 | } 85 | 86 | impl Display for Youdao { 87 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 88 | write!(f, "youdao") 89 | } 90 | } 91 | 92 | #[async_trait] 93 | impl Handler for Youdao { 94 | async fn translate(&self, content: &str) -> Result { 95 | #[derive(Deserialize)] 96 | #[allow(dead_code)] 97 | struct Response { 98 | #[serde(rename = "errorCode")] 99 | code: String, 100 | query: Option, 101 | translation: Option>, 102 | l: String, 103 | dict: Option, 104 | webdict: Option, 105 | #[serde(rename = "mTerminalDict")] 106 | m_terminal_dict: Option, 107 | #[serde(rename = "tSpeakUrl")] 108 | t_speak_url: Option, 109 | #[serde(rename = "speakUrl")] 110 | speak_url: Option, 111 | #[serde(rename = "isDomainSupport")] 112 | is_domain_support: Option, 113 | #[serde(rename = "requestId")] 114 | request_id: Option, 115 | #[serde(rename = "isWord")] 116 | is_word: Option, 117 | } 118 | 119 | #[derive(Deserialize)] 120 | #[allow(dead_code)] 121 | struct Dict { 122 | url: String, 123 | } 124 | 125 | let url = "https://openapi.youdao.com/api"; 126 | let from = "auto"; 127 | let to = "zh-CHS"; 128 | let salt = Uuid::new_v4().to_string(); 129 | let cur_time = SystemTime::now() 130 | .duration_since(UNIX_EPOCH) 131 | .with_context(|| "get current time stamp")? 132 | .as_secs() 133 | .to_string(); 134 | let sign = self.concat_sign(content, &salt, &cur_time); 135 | 136 | let res = self 137 | .client 138 | .get(url) 139 | .query(&[ 140 | ("q", content), 141 | ("from", from), 142 | ("to", to), 143 | ("appKey", &self.key), 144 | ("salt", &salt), 145 | ("sign", &sign), 146 | ("signType", "v3"), 147 | ("curtime", &cur_time), 148 | ("strict", "true"), 149 | ]) 150 | .send() 151 | .await 152 | .with_context(|| format!("send to {url}"))? 153 | .json::() 154 | .await 155 | .with_context(|| format!("decode to json from {url}"))?; 156 | 157 | if res.code != "0" { 158 | info!("translate failed, code: {}", res.code); 159 | bail!("translate failed, code: {}", res.code); 160 | } 161 | 162 | let Some(translated) = res.translation.map(|trans| trans.join("\n")) else { 163 | info!("translate failed, no response"); 164 | bail!("translate failed, no response"); 165 | }; 166 | 167 | Ok(translated) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /crates/video/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "video" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | anyhow.workspace = true 8 | nom.workspace = true 9 | getset.workspace = true 10 | log.workspace = true 11 | bon.workspace = true 12 | 13 | [dev-dependencies] 14 | pretty_assertions.workspace = true 15 | test-case.workspace = true 16 | -------------------------------------------------------------------------------- /crates/video/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use bon::bon; 5 | use getset::Getters; 6 | use log::info; 7 | use nom::{ 8 | IResult, Parser, 9 | branch::alt, 10 | bytes::complete::{tag, take_while, take_while1}, 11 | combinator::{eof, map, opt}, 12 | multi::many0, 13 | }; 14 | 15 | #[derive(Debug, Getters, Clone)] 16 | pub struct Video { 17 | #[getset(get = "pub")] 18 | ty: VideoType, 19 | #[getset(get = "pub")] 20 | files: Vec, 21 | } 22 | 23 | impl Video { 24 | pub fn new(ty: VideoType) -> Video { 25 | Video { 26 | ty, 27 | files: Vec::new(), 28 | } 29 | } 30 | 31 | pub fn add_file(&mut self, file: VideoFile) { 32 | self.files.push(file); 33 | } 34 | } 35 | 36 | #[derive(Debug, Getters, Clone)] 37 | pub struct VideoFile { 38 | #[getset(get = "pub")] 39 | location: PathBuf, 40 | #[getset(get = "pub")] 41 | ext: String, 42 | #[getset(get = "pub")] 43 | idx: u32, 44 | } 45 | 46 | #[bon] 47 | impl VideoFile { 48 | #[builder] 49 | pub fn new(location: &Path, ext: impl Into, idx: u32) -> VideoFile { 50 | VideoFile { 51 | location: location.to_path_buf(), 52 | ext: ext.into(), 53 | idx, 54 | } 55 | } 56 | } 57 | 58 | #[derive(Debug, PartialEq, Eq, Hash, Clone)] 59 | pub enum VideoType { 60 | Jav(String, String), 61 | Fc2(String), 62 | Other(String), 63 | } 64 | 65 | impl Display for VideoType { 66 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 67 | match self { 68 | VideoType::Jav(id, number) => write!(f, "{id}-{number}"), 69 | VideoType::Fc2(number) => write!(f, "FC2-PPV-{number}"), 70 | VideoType::Other(title) => write!(f, "{title}"), 71 | } 72 | } 73 | } 74 | 75 | impl From for String { 76 | fn from(value: VideoType) -> Self { 77 | value.to_string() 78 | } 79 | } 80 | 81 | impl From<&VideoType> for String { 82 | fn from(value: &VideoType) -> Self { 83 | value.to_string() 84 | } 85 | } 86 | 87 | impl VideoType { 88 | /// parse given name to a video and idx 89 | /// 90 | /// # Examples 91 | /// 92 | /// ``` 93 | /// use video::VideoType; 94 | /// 95 | /// let expected = VideoType::Jav("XXX".to_string(), "123".to_string()); 96 | /// let (video, idx) = VideoType::parse("xxx-123"); 97 | /// assert_eq!(expected, video); 98 | /// assert_eq!(idx, 0); 99 | /// ``` 100 | pub fn parse(name: impl AsRef) -> (VideoType, u32) { 101 | let name = name.as_ref().to_uppercase(); 102 | 103 | let (ty, idx) = match Self::_parse(&name) { 104 | Ok((_, (id, key, idx))) => match id { 105 | "FC2-PPV" => (Self::fc2(key), idx), 106 | _ => (Self::jav(id, key), idx), 107 | }, 108 | Err(_) => (Self::other(name.clone()), 0), 109 | }; 110 | info!("parse {name} to {ty}-{idx}"); 111 | 112 | (ty, idx) 113 | } 114 | 115 | fn _parse(input: &str) -> IResult<&str, (&str, &str, u32)> { 116 | map( 117 | ( 118 | take_while(|c: char| !c.is_ascii_alphabetic()), 119 | Self::parse_name, 120 | take_while(|c: char| !c.is_ascii_digit()), 121 | eof, 122 | ), 123 | |(_, id, _, _)| id, 124 | ) 125 | .parse(input) 126 | } 127 | 128 | fn split(input: &str) -> IResult<&str, Vec<&str>> { 129 | many0(alt((tag("-"), tag(" ")))).parse(input) 130 | } 131 | 132 | fn parse_name(input: &str) -> IResult<&str, (&str, &str, u32)> { 133 | alt((Self::parse_fc2, Self::parse_jav)).parse(input) 134 | } 135 | 136 | fn parse_fc2(input: &str) -> IResult<&str, (&str, &str, u32)> { 137 | map( 138 | ( 139 | tag("FC2"), 140 | Self::split, 141 | opt(tag("PPV")), 142 | Self::split, 143 | take_while1(|c: char| c.is_ascii_digit()), 144 | Self::split, 145 | opt(tag("CD")), 146 | take_while(|c: char| c.is_ascii_digit()), 147 | take_while(|_| true), 148 | ), 149 | |(_, _, _, _, num, _, _, idx, _)| ("FC2-PPV", num, idx.parse::().unwrap_or(0)), 150 | ) 151 | .parse(input) 152 | } 153 | 154 | fn parse_jav(input: &str) -> IResult<&str, (&str, &str, u32)> { 155 | map( 156 | ( 157 | take_while1(|c: char| c.is_ascii_alphabetic()), 158 | Self::split, 159 | take_while1(|c: char| c.is_ascii_digit()), 160 | Self::split, 161 | opt(tag("CD")), 162 | take_while(|c: char| c.is_ascii_digit()), 163 | take_while(|_| true), 164 | ), 165 | |(id, _, num, _, _, idx, _)| (id, num, idx.parse::().unwrap_or(0)), 166 | ) 167 | .parse(input) 168 | } 169 | 170 | fn jav(id: impl Into, key: impl Into) -> VideoType { 171 | VideoType::Jav(id.into(), key.into()) 172 | } 173 | 174 | fn fc2(key: impl Into) -> VideoType { 175 | VideoType::Fc2(key.into()) 176 | } 177 | 178 | fn other(key: impl Into) -> VideoType { 179 | VideoType::Other(key.into()) 180 | } 181 | } 182 | 183 | #[cfg(test)] 184 | mod tests { 185 | use super::*; 186 | use pretty_assertions::assert_eq; 187 | use test_case::test_case; 188 | 189 | #[test_case("stars-804", VideoType::Jav("STARS".to_string(), "804".to_string()), 0; "stars-804")] 190 | #[test_case("stars804", VideoType::Jav("STARS".to_string(), "804".to_string()), 0; "stars804")] 191 | #[test_case("stars804-1", VideoType::Jav("STARS".to_string(), "804".to_string()), 1; "stars804-1")] 192 | #[test_case("stars804-2", VideoType::Jav("STARS".to_string(), "804".to_string()), 2; "stars804-2")] 193 | #[test_case("stars-804-1", VideoType::Jav("STARS".to_string(), "804".to_string()), 1; "stars-804-1")] 194 | #[test_case("ipx-443-1", VideoType::Jav("IPX".to_string(), "443".to_string()), 1; "ipx-443-1")] 195 | #[test_case("ipx-443-2", VideoType::Jav("IPX".to_string(), "443".to_string()), 2; "ipx-443-2")] 196 | #[test_case("ipx443-3", VideoType::Jav("IPX".to_string(), "443".to_string()), 3; "ipx443-3")] 197 | #[test_case("fc2-123456", VideoType::Fc2("123456".to_string()), 0; "fc2-123456")] 198 | #[test_case("fc2ppv-123456", VideoType::Fc2("123456".to_string()), 0; "fc2ppv-123456")] 199 | #[test_case("fc2-ppv-123456", VideoType::Fc2("123456".to_string()), 0; "fc2-ppv-123456")] 200 | #[test_case("fc2-ppv-12345-1", VideoType::Fc2("12345".to_string()), 1; "fc2-ppv-12345-1")] 201 | #[test_case("fc2ppv-12345-2", VideoType::Fc2("12345".to_string()), 2; "fc2ppv-12345-2")] 202 | #[test_case("fc2-12345-3", VideoType::Fc2("12345".to_string()), 3; "fc2-12345-3")] 203 | #[test_case("fc212345-4", VideoType::Fc2("12345".to_string()), 4; "fc212345-4")] 204 | #[test_case("小飞棍来喽", VideoType::Other("小飞棍来喽".to_string()), 0; "小飞棍来喽")] 205 | fn test_parse(name: &str, video: VideoType, idx: u32) { 206 | let (actual_video, actual_idx) = VideoType::parse(name); 207 | assert_eq!(actual_video, video); 208 | assert_eq!(actual_idx, idx); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jane-212/javcap/c91b277cef66fd295501605e80c9fb53ab34dde0/images/logo.png -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | _default: 2 | @just --list 3 | 4 | alias t := try 5 | # mkdir dev and run 6 | try: remove 7 | @mkdir -p dev 8 | @touch dev/FC2-PPV-3061625.wmv 9 | @touch dev/stars-804.wmv 10 | @touch dev/小飞棍来咯.wmv 11 | @cargo r 12 | 13 | log_file := home_directory() / ".cache" / "javcap" / "log" 14 | alias l := log 15 | # print local log 16 | log: 17 | @cat {{log_file}} 18 | 19 | config_file := home_directory() / ".config" / "javcap" / "config.toml" 20 | editor := env("EDITOR", "vim") 21 | alias c := config 22 | # edit local config file 23 | config: 24 | @{{editor}} {{config_file}} 25 | 26 | alias r := remove 27 | # remove dev 28 | remove: 29 | @rm -rf dev 30 | 31 | alias f := format 32 | # format code 33 | format: 34 | @cargo fmt 35 | -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | extend-ignore-re = ["auther_names"] 3 | --------------------------------------------------------------------------------