├── .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 |
4 |
5 |
6 |
Javcap
7 |
8 |
9 |
10 | 电影刮削器
11 |
12 |
13 |
14 |
19 |
20 |
21 |
22 | 下载及使用
23 |
24 |
25 | [WIKI](https://github.com/jane-212/javcap/wiki)
26 |
27 |
28 |
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