├── .github └── workflows │ ├── CI.yml │ ├── Release.yml │ └── clean_package.yml ├── .gitignore ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md └── src ├── api.rs ├── bookapi.rs ├── config.rs ├── http.rs └── main.rs /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | pull_request: 8 | branches: 9 | - main 10 | - master 11 | 12 | jobs: 13 | check: 14 | name: Check 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions-rs/toolchain@v1 19 | with: 20 | profile: minimal 21 | toolchain: stable 22 | override: true 23 | - name: cargo check 24 | uses: actions-rs/cargo@v1 25 | with: 26 | command: check 27 | args: --all 28 | - name: fmt 29 | uses: actions-rs/cargo@v1 30 | with: 31 | command: fmt 32 | args: --all -- --check 33 | - name: clippy check 34 | uses: actions-rs/clippy-check@v1 35 | with: 36 | token: ${{ secrets.GITHUB_TOKEN }} 37 | args: --all-features 38 | -------------------------------------------------------------------------------- /.github/workflows/Release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: ["v*"] 6 | 7 | jobs: 8 | linux: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | target: 13 | - x86_64-unknown-linux-musl 14 | - aarch64-unknown-linux-musl 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions-rs/toolchain@v1 18 | with: 19 | toolchain: stable 20 | target: ${{ matrix.target }} 21 | override: true 22 | - uses: actions-rs/cargo@v1 23 | with: 24 | use-cross: true 25 | command: build 26 | args: --release --target=${{ matrix.target }} 27 | - name: Upx compress binary 28 | uses: crazy-max/ghaction-upx@v1 29 | with: 30 | version: v3.95 # v3.96 breaks mipsel, https://github.com/upx/upx/issues/504 31 | files: target/${{ matrix.target }}/release/douban-api-rs 32 | - name: Upload binary artifacts 33 | uses: actions/upload-artifact@v2 34 | with: 35 | name: ${{ matrix.target }}-bin 36 | path: target/${{ matrix.target }}/release/douban-api-rs 37 | if-no-files-found: error 38 | - name: Archive binary 39 | run: | 40 | cd target/${{ matrix.target }}/release 41 | tar czvf douban-api-rs-${{ matrix.target }}.tar.gz douban-api-rs 42 | cd - 43 | - name: Upload binary to GitHub Release 44 | uses: svenstaro/upload-release-action@v2 45 | if: "startsWith(github.ref, 'refs/tags/')" 46 | with: 47 | repo_token: ${{ secrets.GITHUB_TOKEN }} 48 | file: target/${{ matrix.target }}/release/douban-api-rs*.tar.gz* 49 | file_glob: true 50 | overwrite: true 51 | tag: ${{ github.ref }} 52 | 53 | macos: 54 | runs-on: macos-latest 55 | strategy: 56 | matrix: 57 | target: 58 | - x86_64-apple-darwin 59 | steps: 60 | - uses: actions/checkout@v2 61 | - uses: actions-rs/toolchain@v1 62 | with: 63 | toolchain: stable 64 | target: ${{ matrix.target }} 65 | override: true 66 | - uses: actions-rs/cargo@v1 67 | with: 68 | command: build 69 | args: --release --target=${{ matrix.target }} 70 | - name: Archive binary 71 | run: | 72 | cd target/${{ matrix.target }}/release 73 | gtar czvf douban-api-rs-${{ matrix.target }}.tar.gz douban-api-rs 74 | cd - 75 | - name: Upload binary to GitHub Release 76 | uses: svenstaro/upload-release-action@v2 77 | if: "startsWith(github.ref, 'refs/tags/')" 78 | with: 79 | repo_token: ${{ secrets.GITHUB_TOKEN }} 80 | file: target/${{ matrix.target }}/release/douban-api-rs-${{ matrix.target }}.tar.gz 81 | file_glob: true 82 | overwrite: true 83 | tag: ${{ github.ref }} 84 | 85 | windows: 86 | runs-on: windows-latest 87 | strategy: 88 | matrix: 89 | target: 90 | - x86_64-pc-windows-msvc 91 | steps: 92 | - uses: actions/checkout@v2 93 | - uses: actions-rs/toolchain@v1 94 | with: 95 | toolchain: stable 96 | target: ${{ matrix.target }} 97 | override: true 98 | - uses: actions-rs/cargo@v1 99 | with: 100 | command: build 101 | args: --release --target=${{ matrix.target }} 102 | - name: Archive binary 103 | run: | 104 | cd target/${{ matrix.target }}/release 105 | makecab douban-api-rs.exe douban-api-rs-${{ matrix.target }}.zip 106 | cd - 107 | - name: Upload binary to GitHub Release 108 | uses: svenstaro/upload-release-action@v2 109 | if: "startsWith(github.ref, 'refs/tags/')" 110 | with: 111 | repo_token: ${{ secrets.GITHUB_TOKEN }} 112 | file: target/${{ matrix.target }}/release/douban-api-rs-${{ matrix.target }}.zip 113 | file_glob: true 114 | overwrite: true 115 | tag: ${{ github.ref }} 116 | 117 | docker: 118 | name: Build Docker Image 119 | runs-on: ubuntu-latest 120 | needs: [linux] 121 | steps: 122 | - uses: actions/checkout@v2 123 | - uses: actions/download-artifact@v2 124 | with: 125 | name: x86_64-unknown-linux-musl-bin 126 | - run: | 127 | chmod a+x douban-api-rs 128 | mv douban-api-rs douban-api-rs-amd64 129 | - uses: actions/download-artifact@v2 130 | with: 131 | name: aarch64-unknown-linux-musl-bin 132 | - run: | 133 | chmod a+x douban-api-rs 134 | mv douban-api-rs douban-api-rs-arm64 135 | - name: Set up QEMU 136 | uses: docker/setup-qemu-action@v1 137 | - name: Set up Docker Buildx 138 | uses: docker/setup-buildx-action@v1 139 | - name: Login to GitHub Container Registry 140 | uses: docker/login-action@v1 141 | with: 142 | registry: ghcr.io 143 | username: ${{ github.actor }} 144 | password: ${{ secrets.GITHUB_TOKEN }} 145 | - name: Extract metadata (tags, labels) for Docker 146 | id: meta 147 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 148 | with: 149 | images: ghcr.io/${{ github.repository }} 150 | - name: Build and push Docker images 151 | uses: docker/build-push-action@v2 152 | with: 153 | context: . 154 | push: true 155 | platforms: linux/amd64,linux/arm64 156 | tags: ${{ steps.meta.outputs.tags }} 157 | labels: ${{ steps.meta.outputs.labels }} 158 | -------------------------------------------------------------------------------- /.github/workflows/clean_package.yml: -------------------------------------------------------------------------------- 1 | # Attention: 2 | # - Need goto [Settings -> Secrets -> Actions] 3 | # - Add a [PAT] secrets as GitHub Personal access token 4 | name: clean-up-packages 5 | 6 | # Controls when the workflow will run 7 | on: 8 | # schedule: 9 | # - cron: "0 0 1 * *" # the first day of the month 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | # This workflow contains a single job called "build" 17 | clean-up: 18 | # The type of runner that the job will run on 19 | runs-on: ubuntu-latest 20 | 21 | # Steps represent a sequence of tasks that will be executed as part of the job 22 | steps: 23 | - name: Initialize workflow variables 24 | id: vars 25 | run: | 26 | echo ::set-output name=APP_NAME::$(echo '${{ github.repository }}' | awk -F '/' '{print $2}') 27 | - name: Delete old images 28 | uses: snok/container-retention-policy@v1 29 | with: 30 | image-names: ${{steps.vars.outputs.APP_NAME}} 31 | cut-off: One month ago UTC 32 | keep-at-least: 10 33 | skip-tags: latest 34 | account-type: personal 35 | token: ${{ secrets.PAT }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | **/.DS_Store -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "douban-api-rs" 3 | version = "0.2.8" 4 | edition = "2018" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | actix-web = "4.0.0-beta.9" 10 | anyhow = "1.0.43" 11 | hyper = { version = "0.14.11", features = ["server"] } 12 | moka = { version = "0.6.0", default-features = false, features = ["future"] } 13 | openssl-probe = { version = "0.1.4", optional = true } 14 | reqwest = { version = "0.11.4", default-features = false, features = ["json", "gzip", "cookies"] } 15 | serde = { version = "1.0.127", features = ["derive"] } 16 | async-std = { version = "1", features = ["attributes", "tokio1"] } 17 | tokio = { version = "1.10.0", features = ["rt-multi-thread", "io-util", "net", "time", "sync", "macros", "parking_lot", "fs"] } 18 | visdom = "0.4.10" 19 | regex = "1.5.4" 20 | serde_json = "1.0" 21 | env_logger = "0.8" 22 | futures = "0.3" 23 | urlencoding = "2.1.0" 24 | lazy_static = "1.4.0" 25 | clap = { version = "3.0.1", features = ["derive", "env", "wrap_help"] } 26 | 27 | 28 | [features] 29 | default = ["rustls-tls", "atomic64"] 30 | rustls-tls = ["reqwest/rustls-tls"] 31 | native-tls = ["reqwest/native-tls"] 32 | native-tls-vendored = ["reqwest/native-tls-vendored", "openssl-probe"] 33 | atomic64 = ["moka/atomic64"] -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.14 2 | ARG TARGETARCH 3 | ARG TARGETVARIANT 4 | RUN apk --no-cache add ca-certificates tini && \ 5 | apk add tzdata && \ 6 | cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ 7 | echo "Asia/Shanghai" > /etc/timezone && \ 8 | apk del tzdata 9 | 10 | WORKDIR /data/ 11 | ADD douban-api-rs-$TARGETARCH$TARGETVARIANT /usr/bin/douban-api-rs 12 | 13 | # 生成启动脚本 14 | RUN printf '#!/bin/sh \n\n\ 15 | \n\ 16 | /usr/bin/douban-api-rs --port 80 \n\ 17 | \n\ 18 | ' > /entrypoint.sh && \ 19 | chmod +x /entrypoint.sh 20 | 21 | ENTRYPOINT ["/sbin/tini", "--"] 22 | CMD ["/entrypoint.sh"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 cxfksword 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 | # douban-api-rs 2 | 简单的豆瓣api,主要用于配合[jellyfin-plugin-opendouban](https://github.com/caryyu/jellyfin-plugin-opendouban)插件,在jellyfin中刮削电影信息 3 | 4 | 5 | 6 | ## docker运行 7 | 8 | ``` 9 | docker run -d --name=douban-api-rs --restart=unless-stopped -p 5000:80 ghcr.io/cxfksword/douban-api-rs:latest 10 | ``` 11 | 12 | 镜像名称:`ghcr.io/cxfksword/douban-api-rs`,需要使用这个带域名的完整名称才能pull下来。 13 | 14 | 绑定端口:`5000:80` 15 | 16 | 环境变量: 17 | 18 | `DOUBAN_COOKIE`:(可选)豆瓣web登录后的cookie字符串,填写可解决搜索不到部分需登录访问的影片 19 | 20 | 21 | 22 | ## 支持的api 23 | 24 | ``` 25 | /movies?q={movie_name} # 搜索电影 26 | /movies?q={movie_name}&type=full # 搜索电影并获取详细信息 27 | /movies/{sid} # 获取指定电影信息 28 | /movies/{sid}/celebrities # 获取演员列表 29 | /celebrities/{cid} # 获取演员信息 30 | /photo/{sid} # 获取电影壁纸 31 | /v2/book/search?q={book_name}&count=2 # 搜索书籍 count可不传,默认为2, 最大20, 为返回书籍信息数量 32 | /v2/book/isbn/{isbn} # 获取指定isbn的书籍 33 | /v2/book/id/{sid} # 获取指定id的书籍 34 | ``` 35 | 36 | 37 | ## 返回结果示例 38 | 39 | 搜索: 40 | 41 | ``` 42 | [ 43 | { 44 | "cat": "电影", 45 | "sid": "26862259", 46 | "name": "乘风破浪 ", 47 | "rating": "6.8", 48 | "img": "https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2408407697.jpg", 49 | "year": " 2017" 50 | }, 51 | { 52 | "cat": "电影", 53 | "sid": "34894589", 54 | "name": "乘风破浪的姐姐 第一季 ", 55 | "rating": "6.8", 56 | "img": "https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2608297477.jpg", 57 | "year": "2020" 58 | } 59 | ] 60 | ``` 61 | 62 | 63 | 获取电影信息: 64 | 65 | ``` 66 | { 67 | "sid": "26862259", 68 | "name": "乘风破浪", 69 | "rating": "6.8", 70 | "img": "https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2408407697.jpg", 71 | "year": "2017", 72 | "intro": "赛车手阿浪(邓超 饰)一直对父亲(彭于晏 饰)反对自己的赛车事业耿耿于怀,在向父亲证明自己的过程中,阿浪却意外卷入了一场奇妙的冒险。他在这段经历中结识了一群兄弟好友,一同闯过许多奇幻的经历,也对自己的身世有了更多的了解。", 73 | "director": "导演", 74 | "writer": "编剧", 75 | "actor": "主演", 76 | "genre": "类型", 77 | "site": "", 78 | "country": "制片国家/地区", 79 | "language": "语言", 80 | "screen": "上映日期", 81 | "duration": "片长", 82 | "subname": "上映日期", 83 | "imdb": "IMDb", 84 | "celebrities": [ 85 | { 86 | "id": "1275307", 87 | "img": "https://img3.doubanio.com/view/celebrity/raw/public/p42220.jpg", 88 | "name": "韩寒", 89 | "role": "导演" 90 | } 91 | ] 92 | } 93 | ``` 94 | 95 | 获取演员信息: 96 | 97 | ``` 98 | { 99 | "id": "1274235", 100 | "img": "https://img2.doubanio.com/icon/u183170142-13.jpg", 101 | "name": "邓超 Chao Deng", 102 | "role": "演员 / 导演 / 配音 / 主持人", 103 | "intro": "1979年,邓超出生在一个重新组合的小康家庭,爸爸是博物...", 104 | "gender": "男", 105 | "constellation": "水瓶座", 106 | "birthdate": "1979年02月08日", 107 | "birthplace": "中国,江西,南昌", 108 | "nickname": "", 109 | "imdb": "nm2874732", 110 | "family": "孙俪(妻)" 111 | } 112 | ``` 113 | -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | use crate::http::HttpClient; 2 | use anyhow::Result; 3 | use lazy_static::*; 4 | use moka::future::{Cache, CacheBuilder}; 5 | use regex::Regex; 6 | use serde::{Deserialize, Serialize}; 7 | use std::sync::Arc; 8 | use std::time::Duration; 9 | use visdom::Vis; 10 | 11 | lazy_static! { 12 | static ref MOVIE_CACHE: Cache = CacheBuilder::new(CACHE_SIZE) 13 | .time_to_live(Duration::from_secs(10 * 60)) 14 | .build(); 15 | static ref PHOTO_CACHE: Cache> = CacheBuilder::new(CACHE_SIZE) 16 | .time_to_live(Duration::from_secs(10 * 60)) 17 | .build(); 18 | } 19 | 20 | const CACHE_SIZE: usize = 100; 21 | 22 | #[derive(Clone)] 23 | pub struct Douban { 24 | client: Arc, 25 | re_id: Regex, 26 | re_backgroud_image: Regex, 27 | re_sid: Regex, 28 | re_cat: Regex, 29 | re_year: Regex, 30 | re_director: Regex, 31 | re_writer: Regex, 32 | re_actor: Regex, 33 | re_genre: Regex, 34 | re_country: Regex, 35 | re_language: Regex, 36 | re_duration: Regex, 37 | re_screen: Regex, 38 | re_subname: Regex, 39 | re_imdb: Regex, 40 | re_site: Regex, 41 | re_name_math: Regex, 42 | re_role: Regex, 43 | } 44 | 45 | impl Douban { 46 | pub fn new(client: Arc) -> Douban { 47 | let re_id = Regex::new(r"/(\d+?)/").unwrap(); 48 | let re_backgroud_image = Regex::new(r"url\((.+?)\)").unwrap(); 49 | let re_sid = Regex::new(r"sid: (\d+?),").unwrap(); 50 | let re_cat = Regex::new(r"\[(.+?)\]").unwrap(); 51 | let re_year = Regex::new(r"\((\d+?)\)").unwrap(); 52 | let re_director = Regex::new(r"导演: (.+?)\n").unwrap(); 53 | let re_writer = Regex::new(r"编剧: (.+?)\n").unwrap(); 54 | let re_actor = Regex::new(r"主演: (.+?)\n").unwrap(); 55 | let re_genre = Regex::new(r"类型: (.+?)\n").unwrap(); 56 | let re_country = Regex::new(r"制片国家/地区: (.+?)\n").unwrap(); 57 | let re_language = Regex::new(r"语言: (.+?)\n").unwrap(); 58 | let re_duration = Regex::new(r"片长: (.+?)\n").unwrap(); 59 | let re_screen = Regex::new(r"上映日期: (.+?)\n").unwrap(); 60 | let re_subname = Regex::new(r"又名: (.+?)\n").unwrap(); 61 | let re_imdb = Regex::new(r"IMDb: (.+?)\n").unwrap(); 62 | let re_site = Regex::new(r"官方网站: (.+?)\n").unwrap(); 63 | let re_name_math = Regex::new(r"(.+第\w季|[\w\uff1a\uff01\uff0c\u00b7]+)\s*(.*)").unwrap(); 64 | let re_role = Regex::new(r"\([饰|配] (.+?)\)").unwrap(); 65 | Self { 66 | client, 67 | re_id, 68 | re_backgroud_image, 69 | re_sid, 70 | re_cat, 71 | re_year, 72 | re_director, 73 | re_writer, 74 | re_actor, 75 | re_genre, 76 | re_country, 77 | re_language, 78 | re_duration, 79 | re_screen, 80 | re_subname, 81 | re_imdb, 82 | re_site, 83 | re_name_math, 84 | re_role, 85 | } 86 | } 87 | 88 | pub async fn search(&self, q: &str, limit: i32, image_size: &str) -> Result> { 89 | let mut vec = Vec::new(); 90 | if q.is_empty() { 91 | return Ok(vec); 92 | } 93 | 94 | let url = "https://www.douban.com/search"; 95 | let res = self 96 | .client 97 | .get(url) 98 | .query(&[("cat", "1002"), ("q", q)]) 99 | .send() 100 | .await? 101 | .error_for_status(); 102 | 103 | match res { 104 | Ok(res) => { 105 | println!("Response Headers: {:#?}", res.headers()); 106 | let res = res.text().await?; 107 | let document = Vis::load(&res).unwrap(); 108 | let iter = document 109 | .find("div.result-list") 110 | .first() 111 | .find(".result") 112 | .map(|_index, x| { 113 | let x = Vis::dom(x); 114 | let mut rating = x.find("div.rating-info>.rating_nums").text().to_string(); 115 | if rating.is_empty() { 116 | rating = "0".to_string(); 117 | } 118 | let onclick_attr = x.find("div.title a").attr("onclick"); 119 | let onclick = match onclick_attr { 120 | Some(onclick) => onclick.to_string(), 121 | None => String::new(), 122 | }; 123 | let img = self.get_img_by_size( 124 | x.find("a.nbg>img") 125 | .attr("src") 126 | .unwrap() 127 | .to_string() 128 | .as_str(), 129 | image_size, 130 | ); 131 | let sid = self.parse_sid(&onclick); 132 | let name = x.find("div.title a").text().to_string(); 133 | let title_mark = x.find("div.title>h3>span").text().to_string(); 134 | let cat = self.parse_cat(&title_mark); 135 | let subject = x.find("div.rating-info>.subject-cast").text().to_string(); 136 | let year = self.parse_year(subject); 137 | Movie { 138 | cat, 139 | sid, 140 | name, 141 | rating, 142 | img, 143 | year, 144 | } 145 | }) 146 | .into_iter() 147 | .filter(|x| x.cat == "电影" || x.cat == "电视剧"); 148 | if limit > 0 { 149 | vec = iter.take(limit as usize).collect::>(); 150 | } else { 151 | vec = iter.collect::>(); 152 | } 153 | } 154 | Err(err) => { 155 | println!("{:?}", err) 156 | } 157 | } 158 | 159 | Ok(vec) 160 | } 161 | 162 | pub async fn search_full( 163 | &self, 164 | q: &str, 165 | limit: i32, 166 | image_size: &str, 167 | ) -> Result> { 168 | let movies = self.search(q, limit, image_size).await.unwrap(); 169 | let mut list = Vec::with_capacity(movies.len()); 170 | for i in movies.iter() { 171 | list.push(self.get_movie_info(&i.sid, image_size).await.unwrap()) 172 | } 173 | 174 | Ok(list) 175 | } 176 | 177 | pub async fn get_movie_info(&self, sid: &str, image_size: &str) -> Result { 178 | let cache_key = format!("movie_{}_{}", sid, image_size); 179 | if MOVIE_CACHE.get(&cache_key).is_some() { 180 | return Ok(MOVIE_CACHE.get(&cache_key).unwrap()); 181 | } 182 | let url = format!("https://movie.douban.com/subject/{}/", sid); 183 | let res = self 184 | .client 185 | .get(url) 186 | .send() 187 | .await? 188 | .error_for_status() 189 | .unwrap(); 190 | 191 | let res = res.text().await?; 192 | let document = Vis::load(&res).unwrap(); 193 | let x = document.find("#content"); 194 | 195 | let sid = sid.to_string(); 196 | let name_str = x.find("h1>span:first-child").text().to_string(); 197 | let cs = self.re_name_math.captures(&name_str).unwrap(); 198 | let name = (&cs[1]).to_string(); 199 | let original_name = (&cs[2]).to_string(); 200 | 201 | let year_str = x.find("h1>span.year").text().to_string(); 202 | let year = self.parse_year_for_detail(&year_str); 203 | 204 | let mut rating = x 205 | .find("div.rating_self strong.rating_num") 206 | .text() 207 | .to_string(); 208 | if rating.is_empty() { 209 | rating = "0".to_string(); 210 | } 211 | let img = self.get_img_by_size( 212 | x.find("a.nbgnbg>img") 213 | .attr("src") 214 | .unwrap() 215 | .to_string() 216 | .as_str(), 217 | image_size, 218 | ); 219 | 220 | let intro = x.find("div.indent>span").text().trim().replace("©豆瓣", ""); 221 | let info = x.find("#info").text().to_string(); 222 | let ( 223 | director, 224 | writer, 225 | actor, 226 | genre, 227 | site, 228 | country, 229 | language, 230 | screen, 231 | duration, 232 | subname, 233 | imdb, 234 | ) = self.parse_info(&info); 235 | 236 | let celebrities: Vec = 237 | x.find("#celebrities li.celebrity") 238 | .first() 239 | .map(|_index, x| { 240 | let x = Vis::dom(x); 241 | let id_str = x.find("div.info a.name").attr("href").unwrap().to_string(); 242 | let id = self.parse_id(&id_str); 243 | let img_str = x.find("div.avatar").attr("style").unwrap().to_string(); 244 | let img = self 245 | .get_img_by_size(self.parse_backgroud_image(&img_str).as_str(), image_size); 246 | let name = x.find("div.info a.name").text().to_string(); 247 | let role = x.find("div.info span.role").text().to_string(); 248 | let role_type = String::new(); 249 | 250 | Celebrity { 251 | id, 252 | img, 253 | name, 254 | role_type, 255 | role, 256 | } 257 | }); 258 | 259 | let info = MovieInfo { 260 | sid, 261 | name, 262 | original_name, 263 | rating, 264 | img, 265 | year, 266 | intro, 267 | director, 268 | writer, 269 | actor, 270 | genre, 271 | site, 272 | country, 273 | language, 274 | screen, 275 | duration, 276 | subname, 277 | imdb, 278 | celebrities, 279 | }; 280 | MOVIE_CACHE.insert(cache_key, info.clone()).await; 281 | 282 | Ok(info) 283 | } 284 | 285 | pub async fn get_celebrities(&self, sid: &str) -> Result> { 286 | let url = format!("https://movie.douban.com/subject/{}/celebrities", sid); 287 | let res = self 288 | .client 289 | .get(url) 290 | .send() 291 | .await? 292 | .error_for_status() 293 | .unwrap(); 294 | 295 | let res = res.text().await?; 296 | let document = Vis::load(&res).unwrap(); 297 | let x = document.find("#content"); 298 | 299 | let celebrities: Vec = x 300 | .find("ul.celebrities-list li.celebrity") 301 | .map(|_index, x| { 302 | let x = Vis::dom(x); 303 | let id_str = x.find("div.info a.name").attr("href").unwrap().to_string(); 304 | let id = self.parse_id(&id_str); 305 | let img_str = x.find("div.avatar").attr("style").unwrap().to_string(); 306 | let img = self.parse_backgroud_image(&img_str); 307 | let name = x 308 | .find("div.info a.name") 309 | .text() 310 | .split_whitespace() 311 | .next() 312 | .unwrap_or("") 313 | .to_string(); 314 | let mut role = match self.re_role.captures(x.find("div.info span.role").text()) { 315 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 316 | None => String::new(), 317 | }; 318 | let role_type = x 319 | .find("div.info span.role") 320 | .text() 321 | .split_whitespace() 322 | .next() 323 | .unwrap_or("") 324 | .to_string(); 325 | if role.is_empty() { 326 | role = role_type.clone(); 327 | } 328 | 329 | Celebrity { 330 | id, 331 | img, 332 | name, 333 | role_type, 334 | role, 335 | } 336 | }) 337 | .into_iter() 338 | .filter(|x| x.role_type == "导演" || x.role_type == "配音" || x.role_type == "演员") 339 | .take(15) 340 | .collect::>(); 341 | 342 | Ok(celebrities) 343 | } 344 | 345 | pub async fn get_celebrity(&self, id: &str) -> Result { 346 | let url = format!("https://movie.douban.com/celebrity/{}/", id); 347 | let res = self 348 | .client 349 | .get(url) 350 | .send() 351 | .await? 352 | .error_for_status() 353 | .unwrap(); 354 | 355 | let res = res.text().await?; 356 | let document = Vis::load(&res).unwrap(); 357 | let x = document.find("#content"); 358 | let id = id.to_string(); 359 | let img = x 360 | .find("#headline .nbg img") 361 | .attr("src") 362 | .unwrap() 363 | .to_string(); 364 | let name = x.find("h1").text().to_string(); 365 | let mut intro = x.find("#intro span.all").text().trim().to_string(); 366 | if intro.is_empty() { 367 | intro = x.find("#intro div.bd").text().trim().to_string(); 368 | } 369 | 370 | let info = x.find("div.info").text().to_string(); 371 | let (gender, constellation, birthdate, birthplace, role, nickname, family, imdb) = 372 | self.parse_celebrity_info(&info); 373 | 374 | Ok(CelebrityInfo { 375 | id, 376 | img, 377 | name, 378 | role, 379 | intro, 380 | gender, 381 | constellation, 382 | birthdate, 383 | birthplace, 384 | nickname, 385 | imdb, 386 | family, 387 | }) 388 | } 389 | 390 | pub async fn get_wallpaper(&self, sid: &str) -> Result> { 391 | let cache_key = sid.to_string(); 392 | if PHOTO_CACHE.get(&cache_key).is_some() { 393 | return Ok(PHOTO_CACHE.get(&cache_key).unwrap()); 394 | } 395 | let url = format!("https://movie.douban.com/subject/{}/photos?type=W&start=0&sortby=size&size=a&subtype=a", sid); 396 | let res = self 397 | .client 398 | .get(url) 399 | .send() 400 | .await? 401 | .error_for_status() 402 | .unwrap(); 403 | 404 | let res = res.text().await?; 405 | let document = Vis::load(&res).unwrap(); 406 | let wallpapers: Vec = document.find(".poster-col3>li").map(|_index, x| { 407 | let x = Vis::dom(x); 408 | 409 | let id = x.attr("data-id").unwrap().to_string(); 410 | let small = format!("https://img2.doubanio.com/view/photo/s/public/p{}.jpg", id); 411 | let medium = format!("https://img2.doubanio.com/view/photo/m/public/p{}.jpg", id); 412 | let large = format!("https://img2.doubanio.com/view/photo/l/public/p{}.jpg", id); 413 | let size = x.find("div.prop").text().trim().to_string(); 414 | let mut width = String::new(); 415 | let mut height = String::new(); 416 | if !size.is_empty() { 417 | let arr: Vec<&str> = size.split('x').collect(); 418 | width = arr[0].to_string(); 419 | height = arr[1].to_string(); 420 | } 421 | Photo { 422 | id, 423 | small, 424 | medium, 425 | large, 426 | size, 427 | width, 428 | height, 429 | } 430 | }); 431 | 432 | PHOTO_CACHE.insert(cache_key, wallpapers.clone()).await; 433 | Ok(wallpapers) 434 | } 435 | 436 | pub async fn proxy_img(&self, url: &str) -> Result { 437 | Ok(self.client.get(url).send().await.unwrap()) 438 | } 439 | 440 | fn parse_year(&self, text: String) -> String { 441 | text.split('/').last().unwrap().trim().to_string() 442 | } 443 | 444 | fn parse_year_for_detail(&self, text: &str) -> String { 445 | let mut year = String::new(); 446 | for cap in self.re_year.captures_iter(text) { 447 | year = cap[1].to_string(); 448 | } 449 | 450 | year 451 | } 452 | 453 | fn parse_sid(&self, text: &str) -> String { 454 | let mut sid = String::new(); 455 | for cap in self.re_sid.captures_iter(text) { 456 | sid = cap[1].to_string(); 457 | } 458 | 459 | sid 460 | } 461 | 462 | fn parse_cat(&self, text: &str) -> String { 463 | let mut sid = String::new(); 464 | for cap in self.re_cat.captures_iter(text) { 465 | sid = cap[1].to_string(); 466 | } 467 | 468 | sid 469 | } 470 | 471 | fn parse_id(&self, text: &str) -> String { 472 | let mut id = String::new(); 473 | for cap in self.re_id.captures_iter(text) { 474 | id = cap[1].to_string(); 475 | } 476 | 477 | id 478 | } 479 | 480 | fn parse_backgroud_image(&self, text: &str) -> String { 481 | let mut url = String::new(); 482 | for cap in self.re_backgroud_image.captures_iter(text) { 483 | url = cap[1].to_string(); 484 | } 485 | 486 | url 487 | } 488 | 489 | fn parse_info( 490 | &self, 491 | text: &str, 492 | ) -> ( 493 | String, 494 | String, 495 | String, 496 | String, 497 | String, 498 | String, 499 | String, 500 | String, 501 | String, 502 | String, 503 | String, 504 | ) { 505 | let director = match self.re_director.captures(text) { 506 | Some(x) => x.get(1).unwrap().as_str().to_string(), 507 | None => String::new(), 508 | }; 509 | 510 | let writer = match self.re_writer.captures(text) { 511 | Some(x) => x.get(1).unwrap().as_str().to_string(), 512 | None => String::new(), 513 | }; 514 | 515 | let actor = match self.re_actor.captures(text) { 516 | Some(x) => x.get(1).unwrap().as_str().to_string(), 517 | None => String::new(), 518 | }; 519 | 520 | let genre = match self.re_genre.captures(text) { 521 | Some(x) => x.get(1).unwrap().as_str().to_string(), 522 | None => String::new(), 523 | }; 524 | 525 | let country = match self.re_country.captures(text) { 526 | Some(x) => x.get(1).unwrap().as_str().to_string(), 527 | None => String::new(), 528 | }; 529 | 530 | let language = match self.re_language.captures(text) { 531 | Some(x) => x.get(1).unwrap().as_str().to_string(), 532 | None => String::new(), 533 | }; 534 | 535 | let duration = match self.re_duration.captures(text) { 536 | Some(x) => x.get(1).unwrap().as_str().to_string(), 537 | None => String::new(), 538 | }; 539 | 540 | let screen = match self.re_screen.captures(text) { 541 | Some(x) => x.get(1).unwrap().as_str().to_string(), 542 | None => String::new(), 543 | }; 544 | 545 | let subname = match self.re_subname.captures(text) { 546 | Some(x) => x.get(1).unwrap().as_str().to_string(), 547 | None => String::new(), 548 | }; 549 | 550 | let imdb = match self.re_imdb.captures(text) { 551 | Some(x) => x.get(1).unwrap().as_str().to_string(), 552 | None => String::new(), 553 | }; 554 | let site = match self.re_site.captures(text) { 555 | Some(x) => x.get(1).unwrap().as_str().to_string(), 556 | None => String::new(), 557 | }; 558 | 559 | ( 560 | director, writer, actor, genre, site, country, language, screen, duration, subname, 561 | imdb, 562 | ) 563 | } 564 | 565 | fn parse_celebrity_info( 566 | &self, 567 | text: &str, 568 | ) -> ( 569 | String, 570 | String, 571 | String, 572 | String, 573 | String, 574 | String, 575 | String, 576 | String, 577 | ) { 578 | let re_gender = Regex::new(r"性别: \n(.+?)\n").unwrap(); 579 | let gender = match re_gender.captures(text) { 580 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 581 | None => String::new(), 582 | }; 583 | 584 | let re_constellation = Regex::new(r"星座: \n(.+?)\n").unwrap(); 585 | let constellation = match re_constellation.captures(text) { 586 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 587 | None => String::new(), 588 | }; 589 | 590 | let re_birthdate = Regex::new(r"出生日期: \n(.+?)\n").unwrap(); 591 | let mut birthdate = match re_birthdate.captures(text) { 592 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 593 | None => String::new(), 594 | }; 595 | 596 | let re_lifedate = Regex::new(r"生卒日期: \n(.+?) 至").unwrap(); 597 | let lifedate = match re_lifedate.captures(text) { 598 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 599 | None => String::new(), 600 | }; 601 | if birthdate.is_empty() { 602 | birthdate = lifedate.clone(); 603 | } 604 | 605 | let re_birthplace = Regex::new(r"出生地: \n(.+?)\n").unwrap(); 606 | let birthplace = match re_birthplace.captures(text) { 607 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 608 | None => String::new(), 609 | }; 610 | 611 | let re_role = Regex::new(r"职业: \n(.+?)\n").unwrap(); 612 | let role = match re_role.captures(text) { 613 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 614 | None => String::new(), 615 | }; 616 | 617 | let re_nickname = Regex::new(r"更多外文名: \n(.+?)\n").unwrap(); 618 | let nickname = match re_nickname.captures(text) { 619 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 620 | None => String::new(), 621 | }; 622 | 623 | let re_family = Regex::new(r"家庭成员: \n(.+?)\n").unwrap(); 624 | let family = match re_family.captures(text) { 625 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 626 | None => String::new(), 627 | }; 628 | 629 | let re_imdb = Regex::new(r"imdb编号: \n(.+?)\n").unwrap(); 630 | let imdb = match re_imdb.captures(text) { 631 | Some(x) => x.get(1).unwrap().as_str().trim().to_string(), 632 | None => String::new(), 633 | }; 634 | 635 | ( 636 | gender, 637 | constellation, 638 | birthdate, 639 | birthplace, 640 | role, 641 | nickname, 642 | family, 643 | imdb, 644 | ) 645 | } 646 | 647 | fn get_img_by_size(&self, url: &str, image_size: &str) -> String { 648 | let mut img_url = url.to_string(); 649 | 650 | // 改变图片大小 651 | if image_size == "m" || image_size == "l" { 652 | img_url = img_url.replace("s_ratio_poster", image_size); 653 | } 654 | 655 | return img_url; 656 | } 657 | } 658 | 659 | #[derive(Debug, Clone, Serialize, Deserialize)] 660 | pub struct Movie { 661 | cat: String, 662 | sid: String, 663 | name: String, 664 | rating: String, 665 | img: String, 666 | year: String, 667 | } 668 | 669 | #[derive(Debug, Clone, Serialize, Deserialize)] 670 | pub struct MovieInfo { 671 | sid: String, 672 | name: String, 673 | #[serde(rename = "originalName")] 674 | original_name: String, 675 | rating: String, 676 | img: String, 677 | year: String, 678 | intro: String, 679 | director: String, 680 | writer: String, 681 | actor: String, 682 | genre: String, 683 | site: String, 684 | country: String, 685 | language: String, 686 | screen: String, 687 | duration: String, 688 | subname: String, 689 | imdb: String, 690 | pub celebrities: Vec, 691 | } 692 | 693 | #[derive(Debug, Clone, Serialize, Deserialize)] 694 | pub struct Celebrity { 695 | id: String, 696 | img: String, 697 | name: String, 698 | #[serde(skip_serializing)] 699 | role_type: String, 700 | role: String, 701 | } 702 | 703 | #[derive(Debug, Clone, Serialize, Deserialize)] 704 | pub struct CelebrityInfo { 705 | id: String, 706 | img: String, 707 | name: String, 708 | role: String, 709 | intro: String, 710 | gender: String, 711 | constellation: String, 712 | birthdate: String, 713 | birthplace: String, 714 | nickname: String, 715 | imdb: String, 716 | family: String, 717 | } 718 | 719 | #[derive(Debug, Clone, Serialize, Deserialize)] 720 | pub struct Photo { 721 | id: String, 722 | small: String, 723 | medium: String, 724 | large: String, 725 | size: String, 726 | width: String, 727 | height: String, 728 | } 729 | -------------------------------------------------------------------------------- /src/bookapi.rs: -------------------------------------------------------------------------------- 1 | use crate::http::HttpClient; 2 | use anyhow::Result; 3 | use lazy_static::*; 4 | use moka::future::{Cache, CacheBuilder}; 5 | use regex::Regex; 6 | use serde::{Deserialize, Serialize}; 7 | use std::collections::HashMap; 8 | use std::sync::Arc; 9 | use std::time::Duration; 10 | use visdom::Vis; 11 | 12 | lazy_static! { 13 | static ref BOOK_CACHE: Cache = CacheBuilder::new(CACHE_SIZE) 14 | .time_to_live(Duration::from_secs(10 * 60)) 15 | .build(); 16 | } 17 | 18 | const CACHE_SIZE: usize = 100; 19 | 20 | #[derive(Clone)] 21 | pub struct DoubanBookApi { 22 | client: Arc, //请求客户端 23 | re_id: Regex, //id 正则 24 | re_info_pair: Regex, //匹配:字符两边的信息 25 | re_remove_split_space: Regex, //去除/分隔符两边多余空格 26 | } 27 | 28 | impl DoubanBookApi { 29 | pub fn new(client: Arc) -> DoubanBookApi { 30 | let re_id = Regex::new(r"sid: (\d+?),").unwrap(); 31 | let re_remove_split_space = Regex::new(r"\s+?/\s+").unwrap(); 32 | let re_info_pair = Regex::new(r"([^\s]+?):\s*([^\n]+)").unwrap(); 33 | Self { 34 | client, 35 | re_id, 36 | re_info_pair, 37 | re_remove_split_space, 38 | } 39 | } 40 | 41 | pub async fn search(&self, q: &str, count: i32) -> Result> { 42 | let list = self.get_list(q, count).await.unwrap(); 43 | Ok(DoubanBookResult { 44 | code: 0, 45 | books: list, 46 | msg: "".to_string(), 47 | }) 48 | } 49 | 50 | async fn get_list(&self, q: &str, count: i32) -> Result> { 51 | let mut vec = Vec::with_capacity(count as usize); 52 | if q.is_empty() { 53 | return Ok(vec); 54 | } 55 | let url = "https://www.douban.com/search"; 56 | let res = self 57 | .client 58 | .get(url) 59 | .query(&[("cat", "1001"), ("q", q)]) 60 | .send() 61 | .await? 62 | .error_for_status(); 63 | match res { 64 | Ok(res) => { 65 | let res = res.text().await?; 66 | let document = Vis::load(&res).unwrap(); 67 | vec = document 68 | .find("div.result-list") 69 | .first() 70 | .find(".result") 71 | .map(|_index, x| { 72 | let x = Vis::dom(x); 73 | let onclick = x.find("div.title a").attr("onclick").unwrap().to_string(); 74 | let title = x.find("div.title a").text().trim().to_string(); 75 | let summary = x.find("p").text().trim().to_string(); 76 | let large = x.find(".pic img").attr("src").unwrap().to_string(); 77 | let rate = x.find(".rating_nums").text().to_string(); 78 | let sub_str = x.find(".subject-cast").text().to_string(); 79 | let subjects: Vec<&str> = sub_str.split('/').collect(); 80 | let len = subjects.len(); 81 | let mut pubdate = String::from(""); 82 | let mut publisher = String::from(""); 83 | let mut author = Vec::new(); 84 | if len >= 3 { 85 | pubdate = subjects[len - 1].trim().to_string(); 86 | publisher = subjects[len - 2].trim().to_string(); 87 | let mut i = 0; 88 | for elem in subjects { 89 | author.push(elem.trim().to_string()); 90 | i += 1; 91 | if i == len - 2 { 92 | break; 93 | } 94 | } 95 | } else if len == 2 { 96 | author.push(subjects[0].trim().to_string()); 97 | match subjects[1].parse::() { 98 | Ok(_t) => pubdate = subjects[1].trim().to_string(), 99 | Err(_e) => publisher = subjects[1].trim().to_string(), 100 | } 101 | } else if len == 1 { 102 | author.push(subjects[0].trim().to_string()); 103 | } 104 | 105 | let mut m_id = String::from(""); 106 | for c in self.re_id.captures_iter(&onclick) { 107 | m_id = c[1].trim().to_string(); 108 | } 109 | let id = m_id; 110 | 111 | let rating = if rate.is_empty() { 112 | Rating::new(0.0) 113 | } else { 114 | Rating::new(rate.parse::().unwrap()) 115 | }; 116 | let images = Image::new(large); 117 | DoubanBook::simple(SimpleDoubanBook { 118 | id, 119 | author, 120 | images, 121 | rating, 122 | pubdate, 123 | publisher, 124 | summary, 125 | title, 126 | }) 127 | }) 128 | .into_iter() 129 | .take(count as usize) 130 | .collect::>(); 131 | } 132 | Err(err) => { 133 | println!("错误: {:?}", err); 134 | } 135 | } 136 | 137 | Ok(vec) 138 | } 139 | 140 | async fn get_book_internal(&self, url: String) -> Result { 141 | let res = self.client.get(url).send().await?.error_for_status(); 142 | let result_text: String; 143 | let id: String; 144 | match res { 145 | Err(e) => { 146 | println!("{}", e); 147 | return Err(anyhow::Error::from(e)); 148 | } 149 | Ok(t) => { 150 | let t_url = t.url().as_str(); 151 | let t_array = t_url.split('/').collect::>(); 152 | id = t_array[t_array.len() - 2].to_string(); 153 | result_text = t.text().await? 154 | } 155 | } 156 | 157 | let document = Vis::load(&result_text).unwrap(); 158 | let x = document.find("#wrapper"); 159 | let title = x.find("h1>span:first-child").text().to_string(); 160 | let large_img = x.find("a.nbg").attr("href").unwrap().to_string(); 161 | let small_img = x.find("a.nbg>img").attr("src").unwrap().to_string(); 162 | let content = x.find("#content"); 163 | let mut tags = Vec::default(); 164 | x.find("a.tag").map(|_index, t| { 165 | tags.push(Tag { name: t.text() }); 166 | }); 167 | 168 | let rating_str = content 169 | .find("div.rating_self strong.rating_num") 170 | .text() 171 | .trim() 172 | .to_string(); 173 | let rating = if rating_str.is_empty() { 174 | Rating { average: 0.0 } 175 | } else { 176 | Rating { 177 | average: rating_str.parse::().unwrap(), 178 | } 179 | }; 180 | let mut summary = content 181 | .find("#link-report .hidden .intro") 182 | .html() 183 | .trim() 184 | .to_string(); 185 | if summary.is_empty() { 186 | summary = content 187 | .find("#link-report .intro") 188 | .html() 189 | .trim() 190 | .to_string(); 191 | } 192 | let mut author_intro = content 193 | .find(".related_info .indent:not([id]) > .all.hidden .intro") 194 | .html() 195 | .trim() 196 | .to_string(); 197 | if author_intro.is_empty() { 198 | author_intro = content 199 | .find(".related_info .indent:not([id]) .intro") 200 | .html() 201 | .trim() 202 | .to_string(); 203 | } 204 | 205 | let info = content.find("#info"); 206 | let info_text_map = self.parse_info_text(info.text().trim()); 207 | 208 | let author = self.get_texts(&info_text_map, "作者"); 209 | let translators = self.get_texts(&info_text_map, "译者"); 210 | let producer = self.get_text(&info_text_map, "出品方"); 211 | let serials = self.get_text(&info_text_map, "丛书"); 212 | let origin = self.get_text(&info_text_map, "原作名"); 213 | let publisher = self.get_text(&info_text_map, "出版社"); 214 | let pubdate = self.get_text(&info_text_map, "出版年"); 215 | let pages = self.get_text(&info_text_map, "页数"); 216 | let price = self.get_text(&info_text_map, "定价"); 217 | let binding = self.get_text(&info_text_map, "装帧"); 218 | let subtitle = self.get_text(&info_text_map, "副标题"); 219 | let isbn13 = self.get_text(&info_text_map, "ISBN"); 220 | let category = String::from(""); //TODO 页面上是在找不到分类... 221 | let images = Image { 222 | medium: large_img.clone(), 223 | large: large_img, 224 | small: small_img, 225 | }; 226 | let cache_key = id.clone(); 227 | let cache_key1 = isbn13.clone(); 228 | let info = DoubanBook { 229 | id, 230 | author, 231 | author_intro, 232 | translators, 233 | images, 234 | binding, 235 | category, 236 | rating, 237 | isbn13, 238 | pages, 239 | price, 240 | pubdate, 241 | publisher, 242 | producer, 243 | serials, 244 | subtitle, 245 | summary, 246 | title, 247 | tags, 248 | origin, 249 | }; 250 | BOOK_CACHE.insert(cache_key, info.clone()).await; 251 | BOOK_CACHE.insert(cache_key1, info.clone()).await; 252 | Ok(info) 253 | } 254 | 255 | pub async fn get_book_info_by_isbn(&self, isbn: &str) -> Result { 256 | let cache_key = isbn.to_string(); 257 | if BOOK_CACHE.get(&cache_key).is_some() { 258 | return Ok(BOOK_CACHE.get(&cache_key).unwrap()); 259 | } 260 | 261 | let url = format!("https://douban.com/isbn/{}/", isbn); 262 | self.get_book_internal(url).await 263 | } 264 | 265 | pub async fn get_book_info(&self, id: &str) -> Result { 266 | let cache_key = id.to_string(); 267 | if BOOK_CACHE.get(&cache_key).is_some() { 268 | return Ok(BOOK_CACHE.get(&cache_key).unwrap()); 269 | } 270 | let url = format!("https://book.douban.com/subject/{}/", id); 271 | self.get_book_internal(url).await 272 | } 273 | 274 | fn get_text(&self, info_text_map: &HashMap, key: &str) -> String { 275 | info_text_map.get(key).unwrap_or(&String::new()).to_string() 276 | } 277 | 278 | fn get_texts(&self, info_text_map: &HashMap, key: &str) -> Vec { 279 | info_text_map 280 | .get(key) 281 | .unwrap_or(&String::new()) 282 | .split("/") 283 | .filter(|&x| !x.trim().is_empty()) 284 | .map(|x| x.trim().to_string()) 285 | .collect::>() 286 | } 287 | 288 | fn parse_info_text(&self, s: &str) -> HashMap { 289 | let mut map = HashMap::new(); 290 | // 先替换掉多作者/之间的换行符,避免下面的正则匹配少作者 291 | let fix_str = self.re_remove_split_space.replace_all(s, "/").to_string(); 292 | // 再匹配:字符两边信息 293 | for cap in self.re_info_pair.captures_iter(&fix_str) { 294 | map.insert(cap[1].trim().to_string(), cap[2].trim().to_string()); 295 | } 296 | 297 | map 298 | } 299 | } 300 | 301 | #[derive(Debug, Clone, Serialize, Deserialize)] 302 | pub struct DoubanBookResult { 303 | code: u32, 304 | msg: String, 305 | books: Vec, 306 | } 307 | 308 | #[derive(Debug, Clone, Serialize, Deserialize)] 309 | pub struct DoubanBook { 310 | id: String, //id 311 | author: Vec, //作者 312 | author_intro: String, //作者简介 313 | translators: Vec, //译者 314 | images: Image, //封面 315 | binding: String, //装帧方式 316 | category: String, //分类 317 | rating: Rating, //评分 318 | isbn13: String, //isbn 319 | pages: String, //页数 320 | price: String, //价格 321 | pubdate: String, //出版时间 322 | publisher: String, //出版社 323 | producer: String, //出品方 324 | serials: String, //丛书 325 | subtitle: String, //副标题 326 | summary: String, //简介 327 | title: String, //书名 328 | tags: Vec, //标签 329 | origin: String, //原作名 330 | } 331 | 332 | pub struct SimpleDoubanBook { 333 | id: String, 334 | author: Vec, 335 | images: Image, 336 | rating: Rating, 337 | pubdate: String, 338 | publisher: String, 339 | summary: String, 340 | title: String, 341 | } 342 | 343 | impl DoubanBook { 344 | fn simple(info: SimpleDoubanBook) -> DoubanBook { 345 | DoubanBook { 346 | id: info.id, 347 | author: info.author, 348 | author_intro: String::new(), 349 | translators: Vec::new(), 350 | images: info.images, 351 | binding: String::new(), 352 | category: String::new(), 353 | rating: info.rating, 354 | isbn13: String::new(), 355 | pages: String::new(), 356 | price: String::new(), 357 | pubdate: info.pubdate, 358 | publisher: info.publisher, 359 | producer: String::new(), 360 | serials: String::new(), 361 | subtitle: String::new(), 362 | summary: info.summary, 363 | title: info.title, 364 | tags: Vec::new(), 365 | origin: String::new(), 366 | } 367 | } 368 | } 369 | 370 | #[derive(Debug, Clone, Serialize, Deserialize)] 371 | pub struct Image { 372 | small: String, 373 | medium: String, 374 | large: String, 375 | } 376 | 377 | impl Image { 378 | fn new(large: String) -> Image { 379 | Image { 380 | large, 381 | medium: String::new(), 382 | small: String::new(), 383 | } 384 | } 385 | } 386 | 387 | #[derive(Debug, Clone, Serialize, Deserialize)] 388 | pub struct Tag { 389 | name: String, 390 | } 391 | 392 | #[derive(Debug, Clone, Serialize, Deserialize)] 393 | pub struct Rating { 394 | average: f32, 395 | } 396 | 397 | impl Rating { 398 | fn new(rating: f32) -> Rating { 399 | Rating { average: rating } 400 | } 401 | } 402 | 403 | #[derive(Debug, Serialize, Deserialize)] 404 | pub struct HtmlResult { 405 | count: i32, 406 | html: String, 407 | limit: i32, 408 | } 409 | 410 | #[derive(Debug, Serialize, Deserialize)] 411 | pub struct BookListItem { 412 | title: String, //书名 413 | id: String, //id 414 | author: Vec, //作者 415 | pubdate: String, //出版时间 416 | publisher: String, //出版社 417 | images: Image, //封面 418 | rating: Rating, //评分 419 | summary: String, //简介 420 | } 421 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use serde::Deserialize; 3 | 4 | #[derive(Parser, Debug, Clone, Deserialize)] 5 | #[clap(author, version, about, long_about = None)] 6 | pub struct Opt { 7 | /// Listen host 8 | #[clap(long, default_value = "0.0.0.0")] 9 | pub host: String, 10 | /// Listen port 11 | #[clap(short, long, default_value = "8080")] 12 | pub port: u16, 13 | #[clap(short, long, default_value = "3", env = "DOUBAN_API_LIMIT_SIZE")] 14 | pub limit: usize, 15 | #[clap(long, default_value = "", env = "DOUBAN_COOKIE")] 16 | pub cookie: String, 17 | #[clap(short, long)] 18 | pub debug: bool, 19 | } 20 | -------------------------------------------------------------------------------- /src/http.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Opt; 2 | use reqwest::header::{HeaderMap, HeaderValue}; 3 | use reqwest::{cookie::Jar, Error, IntoUrl, Request, RequestBuilder, Response, Url}; 4 | use std::future::Future; 5 | use std::sync::Arc; 6 | use std::time::Duration; 7 | 8 | const ORIGIN: &str = "https://movie.douban.com"; 9 | const REFERER: &str = "https://movie.douban.com/"; 10 | const UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36"; 11 | 12 | #[derive(Clone)] 13 | pub struct HttpClient { 14 | client: reqwest::Client, //请求客户端 15 | } 16 | 17 | impl HttpClient { 18 | pub fn new(config: Opt) -> HttpClient { 19 | let mut headers = HeaderMap::new(); 20 | headers.insert("Origin", HeaderValue::from_static(ORIGIN)); 21 | headers.insert("Referer", HeaderValue::from_static(REFERER)); 22 | 23 | let url = "https://douban.com/".parse::().unwrap(); 24 | let jar = Jar::default(); 25 | if !config.cookie.is_empty() { 26 | for s in config.cookie.split(";") { 27 | let cookie_str = format!("{}; Domain=douban.com", s); 28 | jar.add_cookie_str(cookie_str.as_str(), &url); 29 | } 30 | println!("{:?}", jar); 31 | } 32 | let client = reqwest::Client::builder() 33 | .user_agent(UA) 34 | .default_headers(headers) 35 | .cookie_provider(Arc::new(jar)) 36 | .connect_timeout(Duration::from_secs(10)) 37 | .timeout(Duration::from_secs(30)) 38 | // .connection_verbose(true) 39 | .build() 40 | .unwrap(); 41 | Self { client } 42 | } 43 | 44 | pub fn get(&self, url: U) -> RequestBuilder { 45 | self.client.get(url) 46 | } 47 | 48 | #[allow(dead_code)] 49 | pub fn execute(&self, request: Request) -> impl Future> { 50 | self.client.execute(request) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{ 2 | get, middleware, web, App, HttpRequest, HttpResponse, HttpServer, Responder, Result, 3 | }; 4 | mod api; 5 | mod bookapi; 6 | mod config; 7 | mod http; 8 | use api::Douban; 9 | use bookapi::DoubanBookApi; 10 | use clap::Parser; 11 | use config::Opt; 12 | use http::HttpClient; 13 | use serde::Deserialize; 14 | use std::env; 15 | use std::sync::Arc; 16 | 17 | #[get("/")] 18 | async fn index() -> impl Responder { 19 | HttpResponse::Ok() 20 | .content_type("text/html; charset=utf-8") 21 | .body( 22 | r#" 23 | 接口列表:
24 | /movies?q={movie_name}
25 | /movies?q={movie_name}&type=full
26 | /movies/{sid}
27 | /movies/{sid}/celebrities
28 | /celebrities/{cid}
29 | /photo/{sid}
30 | /v2/book/search?q={book_name}
31 | /v2/book/id/{sid}
32 | /v2/book/isbn/{isbn}
33 | "#, 34 | ) 35 | } 36 | 37 | #[get("/movies")] 38 | async fn movies( 39 | douban_api: web::Data, 40 | req: HttpRequest, 41 | query: web::Query, 42 | opt: web::Data, 43 | ) -> Result { 44 | if query.q.is_empty() { 45 | return Ok("[]".to_string()); 46 | } 47 | 48 | // 没有useragent或为空,是来自jellyfin-plugin-opendouban插件的请求 49 | let from_jellyfin = !req.headers().contains_key("User-Agent") 50 | || req 51 | .headers() 52 | .get("User-Agent") 53 | .unwrap() 54 | .to_str() 55 | .unwrap() 56 | .is_empty(); 57 | 58 | let mut count = query.count.unwrap_or(0); 59 | if count == 0 && from_jellyfin { 60 | count = opt.limit as i32 61 | } 62 | 63 | if query.search_type == "full" { 64 | let result = douban_api 65 | .search_full(&query.q, count, &query.image_size) 66 | .await 67 | .unwrap(); 68 | Ok(serde_json::to_string(&result).unwrap()) 69 | } else { 70 | let result = douban_api 71 | .search(&query.q, count, &query.image_size) 72 | .await 73 | .unwrap(); 74 | Ok(serde_json::to_string(&result).unwrap()) 75 | } 76 | } 77 | 78 | /// {sid} - deserializes to a String 79 | #[get("/movies/{sid}")] 80 | async fn movie( 81 | douban_api: web::Data, 82 | path: web::Path, 83 | query: web::Query, 84 | ) -> Result { 85 | let sid = path.into_inner(); 86 | let result = douban_api 87 | .get_movie_info(&sid, &query.image_size) 88 | .await 89 | .unwrap(); 90 | Ok(serde_json::to_string(&result).unwrap()) 91 | } 92 | 93 | #[get("/movies/{sid}/celebrities")] 94 | async fn celebrities(douban_api: web::Data, path: web::Path) -> Result { 95 | let sid = path.into_inner(); 96 | let result = douban_api.get_celebrities(&sid).await.unwrap(); 97 | Ok(serde_json::to_string(&result).unwrap()) 98 | } 99 | 100 | #[get("/celebrities/{id}")] 101 | async fn celebrity(douban_api: web::Data, path: web::Path) -> Result { 102 | let id = path.into_inner(); 103 | let result = douban_api.get_celebrity(&id).await.unwrap(); 104 | Ok(serde_json::to_string(&result).unwrap()) 105 | } 106 | 107 | #[get("/photo/{sid}")] 108 | async fn photo(douban_api: web::Data, path: web::Path) -> Result { 109 | let sid = path.into_inner(); 110 | let result = douban_api.get_wallpaper(&sid).await.unwrap(); 111 | Ok(serde_json::to_string(&result).unwrap()) 112 | } 113 | 114 | #[get("/v2/book/search")] 115 | async fn books( 116 | query: web::Query, 117 | book_api: web::Data, 118 | ) -> Result { 119 | if query.q.is_empty() { 120 | return Ok("[]".to_string()); 121 | } 122 | let count = query.count.unwrap_or(2); 123 | if count > 20 { 124 | return Err(actix_web::error::ErrorBadRequest( 125 | "{\"message\":\"count不能大于20\"}", 126 | )); 127 | } 128 | let result = book_api.search(&query.q, count).await.unwrap(); 129 | Ok(serde_json::to_string(&result).unwrap()) 130 | } 131 | 132 | #[get("/v2/book/id/{sid}")] 133 | async fn book(path: web::Path, book_api: web::Data) -> Result { 134 | let sid = path.into_inner(); 135 | match book_api.get_book_info(&sid).await { 136 | Ok(info) => Ok(serde_json::to_string(&info).unwrap()), 137 | Err(e) => Err(actix_web::error::ErrorInternalServerError(e)), 138 | } 139 | } 140 | 141 | #[get("/v2/book/isbn/{isbn}")] 142 | async fn book_by_isbn( 143 | path: web::Path, 144 | book_api: web::Data, 145 | ) -> Result { 146 | let isbn = path.into_inner(); 147 | match book_api.get_book_info_by_isbn(&isbn).await { 148 | Ok(info) => Ok(serde_json::to_string(&info).unwrap()), 149 | Err(e) => Err(actix_web::error::ErrorInternalServerError(e)), 150 | } 151 | } 152 | 153 | #[get("/proxy")] 154 | async fn proxy(query: web::Query, douban_api: web::Data) -> impl Responder { 155 | let resp = douban_api.proxy_img(&query.url).await.unwrap(); 156 | let content_type = resp.headers().get("content-type").unwrap(); 157 | HttpResponse::build(resp.status()) 158 | .append_header(("content-type", content_type)) 159 | .body(resp.bytes().await.unwrap()) 160 | } 161 | 162 | #[actix_web::main] 163 | async fn main() -> std::io::Result<()> { 164 | let opt = Opt::parse(); 165 | if env::var("RUST_LOG").is_err() { 166 | if opt.debug { 167 | env::set_var( 168 | "RUST_LOG", 169 | "actix_web=debug,actix_server=debug,reqwest=debug", 170 | ); 171 | } else { 172 | env::set_var("RUST_LOG", "actix_web=info,actix_server=info,reqwest=warn"); 173 | } 174 | } 175 | env_logger::init(); 176 | 177 | let client = Arc::new(HttpClient::new(Opt::parse())); 178 | 179 | HttpServer::new(move || { 180 | App::new() 181 | .wrap(middleware::Logger::default()) 182 | .app_data(web::Data::new(Douban::new(Arc::clone(&client)))) 183 | .app_data(web::Data::new(DoubanBookApi::new(Arc::clone(&client)))) 184 | .app_data(web::Data::new(Opt::parse())) 185 | .service(index) 186 | .service(movies) 187 | .service(movie) 188 | .service(celebrities) 189 | .service(celebrity) 190 | .service(photo) 191 | .service(book) 192 | .service(books) 193 | .service(book_by_isbn) 194 | .service(proxy) 195 | }) 196 | .bind((opt.host, opt.port))? 197 | .run() 198 | .await 199 | } 200 | 201 | #[derive(Deserialize)] 202 | struct SearchQuery { 203 | pub q: String, 204 | #[serde(alias = "type", default)] 205 | pub search_type: String, 206 | #[serde(alias = "s", default)] 207 | pub image_size: String, 208 | pub count: Option, 209 | } 210 | 211 | #[derive(Deserialize)] 212 | struct MovieQuery { 213 | #[serde(alias = "s", default)] 214 | pub image_size: String, 215 | } 216 | 217 | #[derive(Deserialize)] 218 | struct ProxyQuery { 219 | pub url: String, 220 | } 221 | --------------------------------------------------------------------------------