├── .github └── workflows │ └── build-release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── assets ├── README.md ├── demo-pku3b-a.gif ├── demo-pku3b-a.yml ├── demo-pku3b-v.gif ├── demo-pku3b-v.yml └── windows_install.bat ├── build.rs └── src ├── api ├── low_level.rs └── mod.rs ├── cli ├── cmd_assignment.rs ├── cmd_video.rs ├── mod.rs └── pbar.rs ├── config.rs ├── main.rs ├── multipart.rs ├── qs.rs ├── utils.rs └── walkdir.rs /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | # ref: https://dzfrias.dev/blog/deploy-rust-cross-platform-github-actions/ 2 | name: Deploy 3 | 4 | on: 5 | workflow_dispatch: {} 6 | push: 7 | tags: 8 | - "[0-9]+.[0-9]+.[0-9]+" 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | build-and-upload: 15 | name: Build and upload 16 | runs-on: ${{ matrix.os }} 17 | 18 | strategy: 19 | matrix: 20 | # You can add more, for any target you'd like! 21 | include: 22 | - build: linux 23 | os: ubuntu-latest 24 | target: x86_64-unknown-linux-gnu 25 | 26 | - build: macos-aarch64 27 | os: macos-latest 28 | target: aarch64-apple-darwin 29 | 30 | - build: windows-msvc 31 | os: windows-latest 32 | target: x86_64-pc-windows-msvc 33 | 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v4 37 | 38 | - name: Get the release version from the tag 39 | shell: bash 40 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 41 | 42 | - name: Install Rust 43 | # Or @nightly if you want 44 | uses: dtolnay/rust-toolchain@stable 45 | # Arguments to pass in 46 | with: 47 | # Make Rust compile to our target (defined in the matrix) 48 | targets: ${{ matrix.target }} 49 | 50 | - name: Build 51 | run: cargo build --verbose --release --target ${{ matrix.target }} 52 | 53 | - name: Build archive 54 | shell: bash 55 | run: | 56 | # Replace with the name of your binary 57 | binary_name="pku3b" 58 | 59 | dirname="$binary_name-${{ env.VERSION }}-${{ matrix.target }}" 60 | mkdir "$dirname" 61 | if [ "${{ matrix.os }}" = "windows-latest" ]; then 62 | mv "target/${{ matrix.target }}/release/$binary_name.exe" "$dirname" 63 | else 64 | mv "target/${{ matrix.target }}/release/$binary_name" "$dirname" 65 | fi 66 | 67 | if [ "${{ matrix.os }}" = "windows-latest" ]; then 68 | 7z a "$dirname.zip" "$dirname" 69 | echo "ASSET=$dirname.zip" >> $GITHUB_ENV 70 | else 71 | tar -czf "$dirname.tar.gz" "$dirname" 72 | echo "ASSET=$dirname.tar.gz" >> $GITHUB_ENV 73 | fi 74 | 75 | - name: Release 76 | uses: softprops/action-gh-release@v2 77 | with: 78 | files: | 79 | ${{ env.ASSET }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .vscode 3 | /cache -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pku3b" 3 | version = "0.8.1" 4 | edition = "2024" 5 | authors = ["Weiyao Huang "] 6 | license = "MIT" 7 | description = "A tool for PKU students to check their courses." 8 | readme = "README.md" 9 | homepage = "https://github.com/sshwy/pku3b" 10 | repository = "https://github.com/sshwy/pku3b" 11 | keywords = ["cli"] 12 | categories = ["command-line-utilities"] 13 | exclude = ["/assets"] 14 | build = "build.rs" 15 | 16 | [dependencies] 17 | aes = { version = "0.8.4", optional = true } 18 | anyhow = { version = "1.0", default-features = false } 19 | bytes = { version = "1.10", default-features = false } 20 | cbc = { version = "0.1.2", optional = true, features = ["std"] } 21 | chrono = { version = "0.4.40", default-features = false, features = ["clock"] } 22 | clap = { version = "4.5.31", features = ["derive"] } 23 | compio = { version = "0.14", features = [ 24 | "macros", 25 | "process", 26 | ], default-features = false } 27 | cyper = { version = "0.3.0", default-features = false, features = [ 28 | "cookies", 29 | "rustls", 30 | ] } 31 | directories = "6.0.0" 32 | env_logger = { version = "0.11.6", features = [ 33 | "auto-color", 34 | ], default-features = false } 35 | futures-channel = "0.3.31" 36 | futures-util = { version = "0.3.31", features = [ 37 | "alloc", 38 | "async-await-macro", 39 | ], default-features = false } 40 | http = { version = "1.2.0", default-features = false } 41 | indicatif = "0.17.11" 42 | inquire = { version = "0.7.5", features = ["crossterm", "macros"], default-features = false } 43 | itertools = "0.14.0" 44 | log = "0.4.26" 45 | m3u8-rs = { version = "6.0.0", optional = true } 46 | memchr = "2.7.4" 47 | rand = { version = "0.9.0", features = [ 48 | "thread_rng", 49 | ], default-features = false } 50 | regex = "1.11.1" 51 | rustls = "0.23.23" 52 | scraper = { version = "0.23.1", default-features = false } 53 | serde = { version = "1.0", features = [ 54 | "serde_derive", 55 | ], default-features = false } 56 | serde_json = "1.0" 57 | shadow-rs = { version = "1.0.1", features = [ 58 | "build", 59 | ], default-features = false } 60 | toml = "0.8" 61 | url = "2.5.4" 62 | 63 | [build-dependencies] 64 | shadow-rs = { version = "1.0.1", features = [ 65 | "build", 66 | ], default-features = false } 67 | 68 | [features] 69 | dev = [] 70 | default = ["video-download"] 71 | 72 | # support for downloading videos 73 | aes = ["dep:aes"] 74 | cbc = ["dep:cbc"] 75 | m3u8-rs = ["dep:m3u8-rs"] 76 | video-download = ["m3u8-rs", "aes", "cbc"] 77 | 78 | [profile.release] 79 | lto = true 80 | codegen-units = 1 81 | 82 | [target.'cfg(hyper_unstable_tracing)'.dependencies] 83 | tracing = { version = "0.1.41" } 84 | tracing-subscriber = { version = "^0.3.16", features = ["env-filter"] } 85 | 86 | [lints.rust.unexpected_cfgs] 87 | level = "allow" 88 | check-cfg = ['cfg(hyper_unstable_tracing)'] 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PKU3b: A Better Black Board for PKUers 🎓 2 | 3 | > This project is currently under active development. 🚧 4 | 5 | [![Crates.io](https://img.shields.io/crates/v/pku3b)](https://crates.io/crates/pku3b) 6 | ![Issues](https://img.shields.io/github/issues-search?query=repo%3Asshwy%2Fpku3b%20is%3Aopen&label=issues&color=orange) 7 | ![Closed Issues](https://img.shields.io/github/issues-search?query=repo%3Asshwy%2Fpku3b%20is%3Aclosed&label=closed%20issues&color=green) 8 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/sshwy/pku3b/build-release.yml) 9 | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/sshwy/pku3b/total) 10 | 11 | 如果这个项目为你带来了便利,不妨给个 star ⭐ 支持一下~ 12 | 13 | pku3b 是一个由 Rust 实现的小巧 (~10MB) 命令行工具,用于爬取北京大学教学网 () 的信息。目前它可以 14 | 15 | - 📋 查看课程作业信息(未完成/全部) 16 | - 📂 下载课程作业附件 17 | - 📤 提交课程作业 18 | - 🎥 查看课程回放列表 19 | - ⏯️ 下载课程回放(需要 ffmpeg) 20 | 21 | 基本用法如下: 22 | 23 | ```text 24 | A tool for PKU students to check their courses. 25 | 26 | Usage: pku3b [COMMAND] 27 | 28 | Commands: 29 | assignment 获取课程作业信息/下载附件/提交作业 [aliases: a] 30 | video 获取课程回放/下载课程回放 [aliases: v] 31 | init (重新) 初始化配置选项 32 | config 显示或修改配置项 33 | cache 查看缓存大小/清除缓存 34 | help Print this message or the help of the given subcommand(s) 35 | 36 | Options: 37 | -h, --help Print help (see more with '--help') 38 | -V, --version Print version 39 | ``` 40 | 41 | ## Demo 🎬 42 | 43 | 查看作业/下载附件: 44 | 45 | ![demo-a](assets/demo-pku3b-a.gif) 46 | 47 | 查看/下载课程回放,支持断点续传 (10 倍速): 48 | 49 | ![demo-v](assets/demo-pku3b-v.gif) 50 | 51 | ## Getting Started 🚀 52 | 53 | ### [1/3] Install `pku3b` 54 | 55 | 首先你需要安装 `pku3b` 本身。**在安装完成后请重新开一个终端窗口,否则会找不到该命令**。 56 | 57 | #### Build from Source 58 | 59 | 这个安装方式在 Win/Linux/Mac 上均适用。 60 | 61 | 如果你的电脑上恰好有 rust 工具链,那么建议你使用 cargo 安装最新版本。如果需要更新,只需再次执行这个命令: 62 | 63 | ```bash 64 | cargo install pku3b 65 | ``` 66 | 67 | #### Windows 🖥️ 68 | 69 | 对于 Windows 系统,你可以在终端(Powershell/Terminal)执行命令来安装 pku3b。首先你可以执行以下命令来确保终端可以访问 Github。如果该命令输出 `200`,说明成功: 70 | 71 | ```powershell 72 | (Invoke-WebRequest -Uri "https://github.com/sshwy/pku3b" -Method Head).StatusCode 73 | ``` 74 | 75 | 为了保证你能够执行远程下载的批处理脚本,你需要暂时关闭【Windows 安全中心 > 病毒和威胁防护 > 管理设置 > 实时保护】,然后执行以下命令(直接复制全部文本粘贴至命令行)来安装指定版本的 pku3b (当前最新版 `0.8.1`): 76 | 77 | ```powershell 78 | Invoke-WebRequest ` 79 | -Uri "https://raw.githubusercontent.com/sshwy/pku3b/refs/heads/master/assets/windows_install.bat" ` 80 | -OutFile "$env:TEMP\script.bat"; ` 81 | Start-Process ` 82 | -FilePath "$env:TEMP\script.bat" ` 83 | -ArgumentList "0.8.1" ` 84 | -NoNewWindow -Wait 85 | ``` 86 | 87 | 安装过程大致如下: 88 | 89 | ```powershell 90 | Step 1: Downloading pku3b version 0.8.1... 91 | Download complete. 92 | Step 2: Extracting pku3b version 0.8.1... 93 | Extraction complete. 94 | Step 3: Moving pku3b.exe to C:\Users\Sshwy\AppData\Local\pku3b\bin... 95 | 移动了 1 个文件。 96 | File moved to C:\Users\Sshwy\AppData\Local\pku3b\bin. 97 | Step 4: Checking if C:\Users\Sshwy\AppData\Local\pku3b\bin is in the PATH variable... 98 | C:\Users\Sshwy\AppData\Local\pku3b\bin is already in the PATH variable. 99 | Installation complete! 100 | 请按任意键继续. . . 101 | ``` 102 | 103 | #### MacOS 🍏 104 | 105 | 你可以使用 Homebrew 安装 (你需要保证你的终端可以访问 Github): 106 | 107 | ```bash 108 | brew install sshwy/tools/pku3b 109 | ``` 110 | 111 | #### Linux 🐧 112 | 113 | 你可以从 [Release](https://github.com/sshwy/pku3b/releases) 页面中找到你所使用的操作系统对应的版本,然后下载二进制文件,放到应该放的位置,然后设置系统的环境变量。你也可以不设置环境变量,而是直接通过文件路径来执行这个程序。 114 | 115 | ### [2/3] Install FFmpeg (optional) 116 | 117 | 如果需要使用下载课程回放的功能,你需要额外安装 `ffmpeg`: 118 | 119 | - 在 Windows 🖥️ 上推荐使用 winget 安装: `winget install ffmpeg`。如果您艺高人胆大,也可以手动从官网上下载二进制文件安装,然后将 `ffmpeg` 命令加入系统环境变量。 120 | - 在 MacOS 🍏 上可以使用 Homebrew 安装: `brew install ffmpeg`; 121 | - 在 Linux 🐧 上使用发行版的包管理器安装(以 Ubuntu 为例): `apt install ffmpeg`; 122 | 123 | 安装完成后请新开一个终端窗口,并执行 `ffmpeg` 命令检查是否安装成功(没有显示“找不到该命令”就说明安装成功)。 124 | 125 | ### [3/3] Initialization 126 | 127 | 在首次执行命令前你需要登陆教学网。执行以下命令,根据提示输入教学网账号密码来完成初始化设置(只需要执行一次): 128 | 129 | ```bash 130 | pku3b init 131 | ``` 132 | 133 | 完成初始化设置后即可使用该工具啦。如果之后想修改配置,可以使用 `pku3b config -h` 查看帮助。 134 | 135 | 更多示例: 136 | 137 | - 📋 查看未完成的作业列表: `pku3b a ls` 138 | - 📋 查看全部作业列表: `pku3b a ls -a` 139 | - 📂 下载作业附件: `pku3b a down `: ID 请在作业列表中查看 140 | - 📂 交互式下载作业附件: `pku3b a down`: ID 请在作业列表中查看 141 | - 📤 提交作业: `pku3b a sb `: PATH 为文件路径,可以是各种文件,例如 pdf、zip、txt 等等 142 | - 📤 交互式提交作业: `pku3b a sb`: 会在当前工作目录中寻找要提交的作业 143 | - 🎥 查看课程回放列表: `pku3b v ls` 144 | - 🎥 查看所有学期课程回放列表: `pku3b v ls --all-term` 145 | - ⏯️ 下载课程回放: `pku3b v down `: ID 请在课程回放列表中复制,该命令会将视频转换为 mp4 格式保存在执行命令时所在的目录下(如果要下载历史学期的课程回放,需要使用 `--all-term` 选项)。 146 | - 🗑️ 查看缓存占用: `pku3b cache` 147 | - 🗑️ 清空缓存: `pku3b cache clean` 148 | - ❓ 查看某个命令的使用方法 (以下载课程回放的命令为例): `pku3b help v down` 149 | - ⚙️ 输出调试日志: 150 | - 在 Windows 上:设置终端环境变量(临时)`$env:RUST_LOG = 'info'`,那么在这个终端之后执行的 pku3b 命令都会输出调试日志。 151 | - 在 Linux/Mac 上:同样可以设置终端环境变量 `export RUST_LOG=info`;另外一个方法是在执行 pku3b 的命令前面加上 `RUST_LOG=info`,整个命令形如 `RUST_LOG=info pku3b [arguments...]` 152 | 153 | ## Motivation 💡 154 | 155 | 众所周知 PKU 的教学网 UI 长得非常次时代,信息获取效率奇低。对此已有的解决方案是借助 [PKU-Art](https://github.com/zhuozhiyongde/PKU-Art) 把 UI 变得赏心悦目一点。 156 | 157 | 但是如果你和我一样已经进入到早十起不来、签到不想管、不知道每天要上什么课也不想关心、对教学网眼不见为净的状态,那我猜你至少会关注作业的 DDL,或者期末的时候看看回放。于是 `pku3b` 应运而生。在开发项目的过程中又有了更多想法,于是功能就逐渐增加了。 158 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # Assets 2 | 3 | We use [terminalizer](https://github.com/faressoft/terminalizer) to draw demo gifs. 4 | Other recorders can be found in [awesome-terminal-recorder](https://github.com/orangekame3/awesome-terminal-recorder?tab=readme-ov-file) 5 | 6 | e.g. 7 | 8 | ```bash 9 | terminalizer render demo-pku3b-a -o demo-pku3b-a.gif 10 | terminalizer render demo-pku3b-a-down-i -o demo-pku3b-a-down-i.gif 11 | terminalizer render demo-pku3b-v -o demo-pku3b-v.gif -s 10 # 10x faster 12 | ``` 13 | -------------------------------------------------------------------------------- /assets/demo-pku3b-a.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sshwy/pku3b/8f9f88d40d455a68e6f5c9a62b756a81697857c3/assets/demo-pku3b-a.gif -------------------------------------------------------------------------------- /assets/demo-pku3b-a.yml: -------------------------------------------------------------------------------- 1 | # The configurations that used for the recording, feel free to edit them 2 | config: 3 | 4 | # Specify a command to be executed 5 | # like `/bin/bash -l`, `ls`, or any other commands 6 | # the default is bash for Linux 7 | # or powershell.exe for Windows 8 | command: bash -l 9 | 10 | # Specify the current working directory path 11 | # the default is the current working directory path 12 | cwd: /Users/sshwy/pku3b 13 | 14 | # Export additional ENV variables 15 | env: 16 | recording: true 17 | 18 | # Explicitly set the number of columns 19 | # or use `auto` to take the current 20 | # number of columns of your shell 21 | cols: 80 22 | 23 | # Explicitly set the number of rows 24 | # or use `auto` to take the current 25 | # number of rows of your shell 26 | rows: 24 27 | 28 | # Amount of times to repeat GIF 29 | # If value is -1, play once 30 | # If value is 0, loop indefinitely 31 | # If value is a positive number, loop n times 32 | repeat: 0 33 | 34 | # Quality 35 | # 1 - 100 36 | quality: 5 37 | 38 | # Delay between frames in ms 39 | # If the value is `auto` use the actual recording delays 40 | frameDelay: auto 41 | 42 | # Maximum delay between frames in ms 43 | # Ignored if the `frameDelay` isn't set to `auto` 44 | # Set to `auto` to prevent limiting the max idle time 45 | maxIdleTime: 2000 46 | 47 | # The surrounding frame box 48 | # The `type` can be null, window, floating, or solid` 49 | # To hide the title use the value null 50 | # Don't forget to add a backgroundColor style with a null as type 51 | frameBox: 52 | type: floating 53 | title: pku3b assignment 54 | style: 55 | border: 0px black solid 56 | # boxShadow: none 57 | margin: 8px 58 | 59 | # Add a watermark image to the rendered gif 60 | # You need to specify an absolute path for 61 | # the image on your machine or a URL, and you can also 62 | # add your own CSS styles 63 | watermark: 64 | imagePath: null 65 | style: 66 | position: absolute 67 | right: 15px 68 | bottom: 15px 69 | width: 100px 70 | opacity: 0.9 71 | 72 | # Cursor style can be one of 73 | # `block`, `underline`, or `bar` 74 | cursorStyle: block 75 | 76 | # Font family 77 | # You can use any font that is installed on your machine 78 | # in CSS-like syntax 79 | fontFamily: "Monaco, Lucida Console, Ubuntu Mono, Monospace" 80 | 81 | # The size of the font 82 | fontSize: 11 83 | 84 | # The height of lines 85 | lineHeight: 1 86 | 87 | # The spacing between letters 88 | letterSpacing: 0 89 | 90 | # Theme 91 | theme: 92 | background: "transparent" 93 | foreground: "#afafaf" 94 | cursor: "#c7c7c7" 95 | black: "#232628" 96 | red: "#fc4384" 97 | green: "#b3e33b" 98 | yellow: "#ffa727" 99 | blue: "#75dff2" 100 | magenta: "#ae89fe" 101 | cyan: "#708387" 102 | white: "#d5d5d0" 103 | brightBlack: "#626566" 104 | brightRed: "#ff7fac" 105 | brightGreen: "#c8ed71" 106 | brightYellow: "#ebdf86" 107 | brightBlue: "#75dff2" 108 | brightMagenta: "#ae89fe" 109 | brightCyan: "#b1c6ca" 110 | brightWhite: "#f9f9f4" 111 | 112 | # Records, feel free to edit them 113 | records: 114 | - delay: 133 115 | content: "Restored session: Sun Mar 9 23:05:00 CST 2025\r\n\e]7;file://bogon/Users/sshwy/pku3b\a\e[?2004hbogon:pku3b sshwy$ " 116 | - delay: 1356 117 | content: p 118 | - delay: 62 119 | content: k 120 | - delay: 256 121 | content: u 122 | - delay: 145 123 | content: '3' 124 | - delay: 226 125 | content: b 126 | - delay: 172 127 | content: ' ' 128 | - delay: 119 129 | content: a 130 | - delay: 71 131 | content: ' ' 132 | - delay: 305 133 | content: l 134 | - delay: 123 135 | content: s 136 | - delay: 645 137 | content: "\r\n\e[?2004l\r" 138 | - delay: 9 139 | content: "⠁ reading config... \r\e[2K⠁ reading config... \r\e[2K⠁ logging in to blackboard... " 140 | - delay: 100 141 | content: "\r\e[2K⠉ logging in to blackboard... " 142 | - delay: 101 143 | content: "\r\e[2K⠙ logging in to blackboard... " 144 | - delay: 48 145 | content: "\r\e[2K⠙ fetching courses... " 146 | - delay: 53 147 | content: "\r\e[2K⠚ fetching courses... " 148 | - delay: 102 149 | content: "\r\e[2K⠒ fetching courses... " 150 | - delay: 100 151 | content: "\r\e[2K⠂ fetching courses... " 152 | - delay: 101 153 | content: "\r\e[2K⠂ fetching courses... " 154 | - delay: 101 155 | content: "\r\e[2K⠒ fetching courses... " 156 | - delay: 75 157 | content: "\r\e[2K" 158 | - delay: 222 159 | content: " [00:00:00] [\e[36m\e[34m \e[0m\e[0m] 0/8 \r\e[2K [00:00:00] [\e[36m=======>\e[34m \e[0m\e[0m] 1/8 " 160 | - delay: 102 161 | content: "\r\e[2K [00:00:00] [\e[36m=======>\e[34m \e[0m\e[0m] 1/8 \r\e[2K [00:00:00] [\e[36m===============>\e[34m \e[0m\e[0m] 2/8 " 162 | - delay: 18 163 | content: "\r\e[2K [00:00:00] [\e[36m===============>\e[34m \e[0m\e[0m] 2/8 \r\e[2K [00:00:00] [\e[36m======================>\e[34m \e[0m\e[0m] 3/8 " 164 | - delay: 117 165 | content: "\r\e[2K [00:00:00] [\e[36m======================>\e[34m \e[0m\e[0m] 3/8 \r\e[2K [00:00:00] [\e[36m==============================>\e[34m \e[0m\e[0m] 4/8 " 166 | - delay: 158 167 | content: "\r\e[2K [00:00:00] [\e[36m==============================>\e[34m \e[0m\e[0m] 4/8 \r\e[2K [00:00:00] [\e[36m======================================>\e[34m \e[0m\e[0m] 5/8 " 168 | - delay: 17 169 | content: "\r\e[2K [00:00:00] [\e[36m======================================>\e[34m \e[0m\e[0m] 5/8 \r\e[2K [00:00:00] [\e[36m=============================================>\e[34m \e[0m\e[0m] 6/8 " 170 | - delay: 179 171 | content: "\r\e[2K [00:00:00] [\e[36m================================>\e[34m \e[0m\e[0m] 6/11 " 172 | - delay: 293 173 | content: "\r\e[2K [00:00:01] [\e[36m===========================>\e[34m \e[0m\e[0m] 6/13 " 174 | - delay: 160 175 | content: "\r\e[2K [00:00:01] [\e[36m================================>\e[34m \e[0m\e[0m] 7/13 " 176 | - delay: 180 177 | content: "\r\e[2K [00:00:01] [\e[36m====================================>\e[34m \e[0m\e[0m] 8/13 " 178 | - delay: 84 179 | content: "\r\e[2K [00:00:01] [\e[36m=========================================>\e[34m \e[0m\e[0m] 9/13 \r\e[2K [00:00:01] [\e[36m=============================================>\e[34m \e[0m\e[0m] 10/13 " 180 | - delay: 546 181 | content: "\r\e[2K [00:00:02] [\e[36m=================================================>\e[34m \e[0m\e[0m] 11/13 " 182 | - delay: 171 183 | content: "\r\e[2K [00:00:02] [\e[36m======================================================>\e[34m \e[0m\e[0m] 12/13 \r\e[2K [00:00:02] [\e[36m===========================================================\e[34m\e[0m\e[0m] 13/13 \r\e[2K\e[2m>\e[0m \e[1m未完成作业\e[0m \e[2m<\e[0m\r\n\r\n\e[36m\e[1m\e[4m[数据库概论(24-25学年第2学期)]\e[0m\e[0m\r\n\r\n\e[95m\e[4m第 0,1,2 章大模型问题\e[0m\e[0m (\e[33min 7d 24m 41s\e[0m) \e[2mff9068d9cf4968bc\e[0m\r\n\r\n\e[3m附件\e[0m\r\n\e[2m•\e[0m 数据库概论问题清单 ——第 0, 1, 2 章.docx\r\n\r\n\e[3m描述\e[0m\r\n请同学们根据问题清单上的设问,向大模型提问,然后把相关回答汇总成一个学习文档后提交,同时也可以把它导入到自己的个人知识库中。\r\n欢迎同学们提出更多的和章节内容相关的问题,不仅仅限于老师构思的这些问题。\r\n\r\n\e[95m\e[4m第 2 章 作业 ER模型\e[0m\e[0m (\e[33min 14d 24m 41s\e[0m) \e[2m608339e2c36b2c27\e[0m\r\n\r\n\e[3m附件\e[0m\r\n\e[2m•\e[0m 第 2 章 ER模型.pptx\r\n\r\n\e[36m\e[1m\e[4m[算法设计与分析(实验班)(24-25学年第2学期)]\e[0m\e[0m\r\n\r\n\e[95m\e[4mAssignment 3\e[0m\e[0m (\e[33min 2d 18h 54m 41s\e[0m) \e[2mf4f30444c7485d49\e[0m\r\n\r\n\e[3m附件\e[0m\r\n\e[2m•\e[0m Assignment3.pdf\r\n\r\n\e[3m描述\e[0m\r\nDue: 6 pm on Wednesday, March 12\r\n\r\n\e]7;file://bogon/Users/sshwy/pku3b\a\e[?2004hbogon:pku3b sshwy$ " 184 | - delay: 3820 185 | content: "\e[H\e[2Jbogon:pku3b sshwy$ " 186 | - delay: 1326 187 | content: p 188 | - delay: 51 189 | content: k 190 | - delay: 285 191 | content: u 192 | - delay: 188 193 | content: '3' 194 | - delay: 270 195 | content: b 196 | - delay: 232 197 | content: ' ' 198 | - delay: 336 199 | content: a 200 | - delay: 209 201 | content: ' ' 202 | - delay: 241 203 | content: l 204 | - delay: 160 205 | content: s 206 | - delay: 159 207 | content: ' ' 208 | - delay: 202 209 | content: '-' 210 | - delay: 272 211 | content: a 212 | - delay: 1005 213 | content: "\r\n\e[?2004l\r" 214 | - delay: 11 215 | content: "⠁ reading config... \r\e[2K⠁ reading config... \r\e[2K⠁ logging in to blackboard... " 216 | - delay: 101 217 | content: "\r\e[2K⠉ logging in to blackboard... " 218 | - delay: 101 219 | content: "\r\e[2K⠙ logging in to blackboard... " 220 | - delay: 20 221 | content: "\r\e[2K⠙ fetching courses... \r\e[2K [00:00:00] [\e[36m\e[34m \e[0m\e[0m] 0/8 \r\e[2K [00:00:00] [\e[36m=======>\e[34m \e[0m\e[0m] 1/8 \r\e[2K [00:00:00] [\e[36m=======>\e[34m \e[0m\e[0m] 1/8 \r\e[2K [00:00:00] [\e[36m===============>\e[34m \e[0m\e[0m] 2/8 \r\e[2K [00:00:00] [\e[36m===============>\e[34m \e[0m\e[0m] 2/8 \r\e[2K [00:00:00] [\e[36m======================>\e[34m \e[0m\e[0m] 3/8 \r\e[2K [00:00:00] [\e[36m======================>\e[34m \e[0m\e[0m] 3/8 \r\e[2K [00:00:00] [\e[36m==============================>\e[34m \e[0m\e[0m] 4/8 \r\e[2K [00:00:00] [\e[36m==============================>\e[34m \e[0m\e[0m] 4/8 \r\e[2K [00:00:00] [\e[36m======================================>\e[34m \e[0m\e[0m] 5/8 \r\e[2K [00:00:00] [\e[36m======================================>\e[34m \e[0m\e[0m] 5/8 \r\e[2K [00:00:00] [\e[36m=============================================>\e[34m \e[0m\e[0m] 6/8 \r\e[2K [00:00:00] [\e[36m====================================>\e[34m \e[0m\e[0m] 6/10 \r\e[2K [00:00:00] [\e[36m===========================>\e[34m \e[0m\e[0m] 6/13 \r\e[2K [00:00:00] [\e[36m================================>\e[34m \e[0m\e[0m] 7/13 \r\e[2K [00:00:00] [\e[36m====================================>\e[34m \e[0m\e[0m] 8/13 \r\e[2K [00:00:00] [\e[36m=========================================>\e[34m \e[0m\e[0m] 9/13 \r\e[2K [00:00:00] [\e[36m=============================================>\e[34m \e[0m\e[0m] 10/13 \r\e[2K [00:00:00] [\e[36m=================================================>\e[34m \e[0m\e[0m] 11/13 \r\e[2K [00:00:00] [\e[36m======================================================>\e[34m \e[0m\e[0m] 12/13 \r\e[2K\e[2m>\e[0m \e[1m所有作业 (包括已完成)\e[0m \e[2m<\e[0m\r\n\r\n\e[36m\e[1m\e[4m[数据库概论(24-25学年第2学期)]\e[0m\e[0m\r\n\r\n\e[95m\e[4m第 0,1,2 章大模型问题\e[0m\e[0m (\e[33min 7d 24m 34s\e[0m) \e[2mff9068d9cf4968bc\e[0m\r\n\r\n\e[3m附件\e[0m\r\n\e[2m•\e[0m 数据库概论问题清单 ——第 0, 1, 2 章.docx\r\n\r\n\e[3m描述\e[0m\r\n请同学们根据问题清单上的设问,向大模型提问,然后把相关回答汇总成一个学习文档后提交,同时也可以把它导入到自己的个人知识库中。\r\n欢迎同学们提出更多的和章节内容相关的问题,不仅仅限于老师构思的这些问题。\r\n\r\n\e[95m\e[4m第 2 章 作业 ER模型\e[0m\e[0m (\e[33min 14d 24m 34s\e[0m) \e[2m608339e2c36b2c27\e[0m\r\n\r\n\e[3m附件\e[0m\r\n\e[2m•\e[0m 第 2 章 ER模型.pptx\r\n\r\n\e[36m\e[1m\e[4m[算法设计与分析(实验班)(24-25学年第2学期)]\e[0m\e[0m\r\n\r\n\e[95m\e[4mAssignment 1\e[0m\e[0m (\e[32m已完成\e[0m) \e[2m尝试 25-2-27 下午2:37\e[0m \e[2ma04df001a4140ae6\e[0m\r\n\r\n\e[3m附件\e[0m\r\n\e[2m•\e[0m Assignment1.pdf\r\n\e[2m•\e[0m 1.19.png\r\n\e[2m•\e[0m 1.21.png\r\n\r\n\e[3m描述\e[0m\r\nDue: 1 pm on Friday, February 28\r\n\r\n\e[95m\e[4mAssignment 2\e[0m\e[0m (\e[32m已完成\e[0m) \e[2m尝试2 25-2-28 下午1:38\e[0m \e[2mcba5860f4095b2f0\e[0m\r\n\r\n\e[3m附件\e[0m\r\n\e[2m•\e[0m Assignment2.pdf\r\n\e[2m•\e[0m 2.7.png\r\n\e[2m•\e[0m 2.27.png\r\n\e[2m•\e[0m 2.27图.png\r\n\r\n\e[3m描述\e[0m\r\nDue: 6 pm on Wednesday, March 5\r\n\r\n\e[95m\e[4mAssignment 3\e[0m\e[0m (\e[33min 2d 18h 54m 34s\e[0m) \e[2mf4f30444c7485d49\e[0m\r\n\r\n\e[3m附件\e[0m\r\n\e[2m•\e[0m Assignment3.pdf\r\n\r\n\e[3m描述\e[0m\r\nDue: 6 pm on Wednesday, March 12\r\n\r\n\e]7;file://bogon/Users/sshwy/pku3b\a\e[?2004hbogon:pku3b sshwy$ " 222 | - delay: 4457 223 | content: "\e[H\e[2Jbogon:pku3b sshwy$ " 224 | - delay: 921 225 | content: p 226 | - delay: 67 227 | content: k 228 | - delay: 267 229 | content: u 230 | - delay: 224 231 | content: '3' 232 | - delay: 269 233 | content: b 234 | - delay: 190 235 | content: ' ' 236 | - delay: 206 237 | content: a 238 | - delay: 148 239 | content: ' ' 240 | - delay: 863 241 | content: d 242 | - delay: 106 243 | content: o 244 | - delay: 100 245 | content: w 246 | - delay: 121 247 | content: 'n' 248 | - delay: 114 249 | content: ' ' 250 | - delay: 986 251 | content: "\e[7mf4f30444c7485d49\e[27m" 252 | - delay: 422 253 | content: "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\bf4f30444c7485d49 " 254 | - delay: 795 255 | content: "\r\n\e[?2004l\r" 256 | - delay: 10 257 | content: "⠁ reading config... \r\e[2K⠁ reading config... \r\e[2K⠁ logging in to blackboard... " 258 | - delay: 106 259 | content: "\r\e[2K⠉ logging in to blackboard... " 260 | - delay: 101 261 | content: "\r\e[2K⠙ logging in to blackboard... " 262 | - delay: 44 263 | content: "\r\e[2K⠙ fetching courses... \r\e[2K⠙ finding assignment... \r\e[2K⠙ fetch assignment metadata... \r\e[2K⠙ [1/1] downloading attachment 'Assignment3.pdf'... " 264 | - delay: 52 265 | content: "\r\e[2K⠚ [1/1] downloading attachment 'Assignment3.pdf'... " 266 | - delay: 301 267 | content: "\r\e[2K⠒ [1/1] downloading attachment 'Assignment3.pdf'... " 268 | - delay: 301 269 | content: "\r\e[2K⠂ [1/1] downloading attachment 'Assignment3.pdf'... " 270 | - delay: 301 271 | content: "\r\e[2K⠂ [1/1] downloading attachment 'Assignment3.pdf'... " 272 | - delay: 301 273 | content: "\r\e[2K⠒ [1/1] downloading attachment 'Assignment3.pdf'... " 274 | - delay: 301 275 | content: "\r\e[2K⠲ [1/1] downloading attachment 'Assignment3.pdf'... " 276 | - delay: 70 277 | content: "\r\e[2KDone.\r\n\e]7;file://bogon/Users/sshwy/pku3b\a\e[?2004hbogon:pku3b sshwy$ " 278 | - delay: 4002 279 | content: "\e[?2004l\r\r\nlogout\r\n\r\nSaving session..." 280 | - delay: 10 281 | content: "\r\n...saving history..." 282 | - delay: 7 283 | content: "truncating history files...\r\n...completed.\r\n" 284 | -------------------------------------------------------------------------------- /assets/demo-pku3b-v.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sshwy/pku3b/8f9f88d40d455a68e6f5c9a62b756a81697857c3/assets/demo-pku3b-v.gif -------------------------------------------------------------------------------- /assets/windows_install.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal 3 | 4 | :: Check if version argument is provided 5 | if "%1"=="" ( 6 | echo Error: Please provide the version string as a command-line argument. 7 | echo Usage: windows_install.bat version 8 | exit /b 1 9 | ) 10 | 11 | :: Define variables 12 | set "VERSION=%1" :: Version passed as a command-line argument 13 | set "URL=https://github.com/sshwy/pku3b/releases/download/%VERSION%/pku3b-%VERSION%-x86_64-pc-windows-msvc.zip" 14 | set "ZIP_FILE=%TEMP%\pku3b.zip" 15 | set "EXTRACT_DIR=%TEMP%\pku3b" 16 | set "SLUG=pku3b" :: You can change this to a different slug for other apps 17 | set "HOME_DIR=%USERPROFILE%\AppData\Local" :: Default to AppData\Local 18 | set "DEST_DIR=%HOME_DIR%\%SLUG%\bin" 19 | set "EXE_FILE=%EXTRACT_DIR%\%SLUG%-%VERSION%-x86_64-pc-windows-msvc\%SLUG%.exe" 20 | set "PATH_VAR=%HOME_DIR%\%SLUG%\bin" 21 | 22 | :: Step 1: Download the file 23 | echo Step 1: Downloading %SLUG% version %VERSION%... 24 | powershell -Command "(New-Object System.Net.WebClient).DownloadFile('%URL%', '%ZIP_FILE%')" 25 | if %errorlevel% neq 0 ( 26 | echo Failed to download the file. Exiting... 27 | exit /b 1 28 | ) 29 | echo Download complete. 30 | 31 | :: Step 2: Extract the zip file 32 | echo Step 2: Extracting %SLUG% version %VERSION%... 33 | powershell -Command "Expand-Archive -Path '%ZIP_FILE%' -DestinationPath '%EXTRACT_DIR%' -Force" 34 | if %errorlevel% neq 0 ( 35 | echo Failed to extract the zip file. Exiting... 36 | exit /b 1 37 | ) 38 | echo Extraction complete. 39 | 40 | :: Step 3: Move %SLUG%.exe to the target directory 41 | echo Step 3: Moving %SLUG%.exe to %DEST_DIR%... 42 | if not exist "%DEST_DIR%" mkdir "%DEST_DIR%" 43 | move /Y "%EXE_FILE%" "%DEST_DIR%\%SLUG%.exe" 44 | if %errorlevel% neq 0 ( 45 | echo Failed to move the executable. Exiting... 46 | exit /b 1 47 | ) 48 | echo File moved to %DEST_DIR%. 49 | 50 | :: Step 4: Add the directory to the PATH variable if it's not already there 51 | echo Step 4: Checking if %PATH_VAR% is in the PATH variable... 52 | set "CURRENT_PATH=%PATH%" 53 | echo %CURRENT_PATH% | findstr /C:"%PATH_VAR%" >nul 54 | if %errorlevel% neq 0 ( 55 | echo %PATH_VAR% is not in the PATH variable. Adding it... 56 | setx PATH "%CURRENT_PATH%;%PATH_VAR%" 57 | if %errorlevel% neq 0 ( 58 | echo Failed to update the PATH variable. Exiting... 59 | exit /b 1 60 | ) 61 | echo PATH updated successfully. 62 | ) else ( 63 | echo %PATH_VAR% is already in the PATH variable. 64 | ) 65 | 66 | echo Installation complete! 67 | 68 | endlocal 69 | pause 70 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use shadow_rs::ShadowBuilder; 2 | 3 | fn main() { 4 | ShadowBuilder::builder().build().unwrap(); 5 | } 6 | -------------------------------------------------------------------------------- /src/api/low_level.rs: -------------------------------------------------------------------------------- 1 | //! Low-level client that send http requests to the target server. 2 | 3 | use anyhow::Context as _; 4 | use rand::Rng as _; 5 | use scraper::Html; 6 | use std::str::FromStr as _; 7 | 8 | use crate::multipart; 9 | 10 | pub const OAUTH_LOGIN: &str = "https://iaaa.pku.edu.cn/iaaa/oauthlogin.do"; 11 | pub const OAUTH_REDIR: &str = 12 | "http://course.pku.edu.cn/webapps/bb-sso-BBLEARN/execute/authValidate/campusLogin"; 13 | pub const SSO_LOGIN: &str = 14 | "https://course.pku.edu.cn/webapps/bb-sso-BBLEARN/execute/authValidate/campusLogin"; 15 | pub const BLACKBOARD_HOME: &str = "https://course.pku.edu.cn/webapps/portal/execute/tabs/tabAction"; 16 | pub const COURSE_INFO: &str = "https://course.pku.edu.cn/webapps/blackboard/execute/announcement"; 17 | pub const UPLOAD_ASSIGNMENT: &str = "https://course.pku.edu.cn/webapps/assignment/uploadAssignment"; 18 | pub const LIST_CONTENT: &str = 19 | "https://course.pku.edu.cn/webapps/blackboard/content/listContent.jsp"; 20 | pub const VIDEO_LIST: &str = 21 | "https://course.pku.edu.cn/webapps/bb-streammedia-hqy-BBLEARN/videoList.action"; 22 | pub const VIDEO_SUB_INFO: &str = 23 | "https://yjapise.pku.edu.cn/courseapi/v2/schedule/get-sub-info-by-auth-data"; 24 | 25 | /// 一个基础的爬虫 client,函数的返回内容均为原始的,未处理的信息. 26 | #[derive(Clone)] 27 | pub struct LowLevelClient { 28 | http_client: cyper::Client, 29 | } 30 | 31 | impl LowLevelClient { 32 | pub fn from_cyper_client(client: cyper::Client) -> Self { 33 | Self { 34 | http_client: client, 35 | } 36 | } 37 | 38 | /// 向 [`OAUTH_LOGIN`] 发送登录请求,并返回 JSON (形如 { token: "..." }) 39 | pub async fn oauth_login( 40 | &self, 41 | username: &str, 42 | password: &str, 43 | ) -> anyhow::Result { 44 | let res = self 45 | .http_client 46 | .post(OAUTH_LOGIN)? 47 | .form(&[ 48 | ("appid", "blackboard"), 49 | ("userName", username), 50 | ("password", password), 51 | ("randCode", ""), 52 | ("smsCode", ""), 53 | ("otpCode", ""), 54 | ("redirUrl", OAUTH_REDIR), 55 | ])? 56 | .send() 57 | .await?; 58 | 59 | anyhow::ensure!( 60 | res.status().is_success(), 61 | "oauth login not success: {}", 62 | res.status() 63 | ); 64 | 65 | let rbody = res.text().await?; 66 | let value = serde_json::Value::from_str(&rbody).context("fail to parse response json")?; 67 | Ok(value) 68 | } 69 | 70 | /// 使用 OAuth login 返回的 token 登录教学网。登录状态会记录在 client cookie 中,无需返回值. 71 | pub async fn bb_sso_login(&self, token: &str) -> anyhow::Result<()> { 72 | let mut rng = rand::rng(); 73 | 74 | let _rand: f64 = rng.sample(rand::distr::Open01); 75 | let _rand = format!("{_rand:.20}"); 76 | 77 | let res = self 78 | .http_client 79 | .get(SSO_LOGIN)? 80 | .query(&[("_rand", _rand.as_str()), ("token", token)])? 81 | .send() 82 | .await?; 83 | 84 | anyhow::ensure!(res.status().is_success(), "status not success"); 85 | 86 | Ok(()) 87 | } 88 | 89 | /// 获取教学网主页内容 ([`BLACKBOARD_HOME`]), 返回 HTML 文档 90 | pub async fn bb_homepage(&self) -> anyhow::Result { 91 | let res = self 92 | .http_client 93 | .get(BLACKBOARD_HOME)? 94 | .query(&[("tab_tab_group_id", "_1_1")])? 95 | .send() 96 | .await?; 97 | 98 | anyhow::ensure!(res.status().is_success(), "status not success"); 99 | 100 | let rbody = res.text().await?; 101 | let dom = scraper::Html::parse_document(&rbody); 102 | Ok(dom) 103 | } 104 | 105 | /// 根据课程的 key 获取课程主页内容 ([`COURSE_INFO`]) 106 | pub async fn bb_coursepage(&self, key: &str) -> anyhow::Result { 107 | let res = self 108 | .http_client 109 | .get(COURSE_INFO)? 110 | .query(&[ 111 | ("method", "search"), 112 | ("context", "course_entry"), 113 | ("course_id", key), 114 | ("handle", "announcements_entry"), 115 | ("mode", "view"), 116 | ])? 117 | .send() 118 | .await?; 119 | 120 | anyhow::ensure!(res.status().is_success(), "status not success"); 121 | 122 | let rbody = res.text().await?; 123 | let dom = scraper::Html::parse_document(&rbody); 124 | Ok(dom) 125 | } 126 | 127 | /// 根据 content_id 和 course_id 获取课程内容列表页面(包含作业、公告和一些其他东西) 128 | pub async fn bb_course_content_page( 129 | &self, 130 | course_id: &str, 131 | content_id: &str, 132 | ) -> anyhow::Result { 133 | let res = self 134 | .http_client 135 | .get(LIST_CONTENT)? 136 | .query(&[("content_id", content_id), ("course_id", course_id)])? 137 | .send() 138 | .await?; 139 | 140 | anyhow::ensure!(res.status().is_success(), "status not success"); 141 | 142 | let rbody = res.text().await?; 143 | let dom = scraper::Html::parse_document(&rbody); 144 | Ok(dom) 145 | } 146 | 147 | /// 根据 content_id 和 course_id 获取作业上传页面的信息. 148 | pub async fn bb_course_assignment_uploadpage( 149 | &self, 150 | course_id: &str, 151 | content_id: &str, 152 | ) -> anyhow::Result { 153 | let res = self 154 | .http_client 155 | .get(UPLOAD_ASSIGNMENT)? 156 | .query(&[ 157 | ("action", "newAttempt"), 158 | ("content_id", content_id), 159 | ("course_id", course_id), 160 | ])? 161 | .send() 162 | .await?; 163 | 164 | anyhow::ensure!(res.status().is_success(), "status not success"); 165 | 166 | let rbody = res.text().await?; 167 | let dom = scraper::Html::parse_document(&rbody); 168 | Ok(dom) 169 | } 170 | 171 | /// 根据 content_id 和 course_id 获取作业的历史提交页面. 172 | pub async fn bb_course_assignment_viewpage( 173 | &self, 174 | course_id: &str, 175 | content_id: &str, 176 | ) -> anyhow::Result { 177 | let res = self 178 | .http_client 179 | .get(UPLOAD_ASSIGNMENT)? 180 | .query(&[ 181 | ("mode", "view"), 182 | ("content_id", content_id), 183 | ("course_id", course_id), 184 | ])? 185 | .send() 186 | .await?; 187 | 188 | anyhow::ensure!(res.status().is_success(), "status not success"); 189 | 190 | let rbody = res.text().await?; 191 | let dom = scraper::Html::parse_document(&rbody); 192 | Ok(dom) 193 | } 194 | 195 | /// 向 [`UPLOAD_ASSIGNMENT`] 发送提交作业的请求 196 | pub async fn bb_course_assignment_uploaddata( 197 | &self, 198 | body: multipart::MultipartBuilder<'_>, 199 | ) -> anyhow::Result { 200 | let boundary = body.boundary().to_owned(); 201 | let body = body.build().context("build multipart form body")?; 202 | 203 | log::debug!("body built: {}", body.len()); 204 | 205 | let res = self 206 | .http_client 207 | .post(UPLOAD_ASSIGNMENT)? 208 | .header("origin", "https://course.pku.edu.cn")? 209 | .header("accept", "*/*")? 210 | .header( 211 | "content-type", 212 | format!("multipart/form-data; boundary={}", boundary), 213 | )? 214 | .query(&[("action", "submit")])? 215 | .body(body) 216 | .send() 217 | .await?; 218 | 219 | Ok(res) 220 | } 221 | 222 | /// 根据 course_id 获取回放列表页面内容. 223 | pub async fn bb_course_video_list(&self, course_id: &str) -> anyhow::Result { 224 | let res = self 225 | .http_client 226 | .get(VIDEO_LIST)? 227 | .query(&[ 228 | ("sortDir", "ASCENDING"), 229 | ("numResults", "100"), // 一门课一般不会有超过 100 条回放 230 | ("editPaging", "false"), 231 | ("course_id", course_id), 232 | ("mode", "view"), 233 | ("startIndex", "0"), 234 | ])? 235 | .send() 236 | .await?; 237 | 238 | anyhow::ensure!(res.status().is_success(), "status not success"); 239 | 240 | let rbody = res.text().await?; 241 | let dom = scraper::Html::parse_document(&rbody); 242 | Ok(dom) 243 | } 244 | 245 | /// 获取视频回放的 sub_info(用于下载 m3u8 playlist), 返回 JSON 信息 246 | pub async fn bb_course_video_sub_info( 247 | &self, 248 | course_id: &str, 249 | sub_id: &str, 250 | app_id: &str, 251 | auth_data: &str, 252 | ) -> anyhow::Result { 253 | let res = self 254 | .http_client 255 | .get(VIDEO_SUB_INFO)? 256 | .query(&[ 257 | ("all", "1"), 258 | ("course_id", course_id), 259 | ("sub_id", sub_id), 260 | ("with_sub_data", "1"), 261 | ("app_id", app_id), 262 | ("auth_data", auth_data), 263 | ])? 264 | .send() 265 | .await?; 266 | 267 | anyhow::ensure!(res.status().is_success(), "status not success"); 268 | 269 | let rbody = res.text().await?; 270 | let value = serde_json::Value::from_str(&rbody)?; 271 | Ok(value) 272 | } 273 | 274 | /// 利用 [`convert_uri`] 将 uri 自动补全,然后发送请求. 275 | pub async fn get_by_uri(&self, uri: &str) -> anyhow::Result { 276 | let url = convert_uri(uri)?; 277 | log::trace!("GET {}", url); 278 | let res = self 279 | .http_client 280 | .get(url) 281 | .context("create request failed")? 282 | .send() 283 | .await?; 284 | Ok(res) 285 | } 286 | 287 | /// 利用 [`convert_uri`] 将 uri 自动补全,然后发送请求, 返回页面 HTML 288 | #[allow(unused)] 289 | pub async fn page_by_uri(&self, uri: &str) -> anyhow::Result { 290 | let res = self.get_by_uri(uri).await?; 291 | 292 | anyhow::ensure!(res.status().is_success(), "status not success"); 293 | 294 | let rbody = res.text().await?; 295 | let dom = scraper::Html::parse_document(&rbody); 296 | Ok(dom) 297 | } 298 | } 299 | 300 | /// 将 uri 转换为完整的 url。协议默认为 `https`,域名默认为 `course.pku.edu.cn`。 301 | pub fn convert_uri(uri: &str) -> anyhow::Result { 302 | let uri = http::Uri::from_str(uri).context("parse uri string")?; 303 | let http::uri::Parts { 304 | scheme, 305 | authority, 306 | path_and_query, 307 | .. 308 | } = uri.into_parts(); 309 | 310 | let url = format!( 311 | "{}://{}{}", 312 | scheme.as_ref().map(|s| s.as_str()).unwrap_or("https"), 313 | authority 314 | .as_ref() 315 | .map(|a| a.as_str()) 316 | .unwrap_or("course.pku.edu.cn"), 317 | path_and_query.as_ref().map(|p| p.as_str()).unwrap_or(""), 318 | ); 319 | 320 | Ok(url) 321 | } 322 | 323 | #[cfg(test)] 324 | mod tests { 325 | use super::*; 326 | 327 | #[test] 328 | fn test_convert_uri() { 329 | let uri = "/path/to/resource"; 330 | let expected = "https://course.pku.edu.cn/path/to/resource"; 331 | let result = convert_uri(uri).unwrap(); 332 | assert_eq!(result, expected); 333 | 334 | let uri = "http://example.com/path/to/resource"; 335 | let expected = "http://example.com/path/to/resource"; 336 | let result = convert_uri(uri).unwrap(); 337 | assert_eq!(result, expected); 338 | 339 | let uri = "https://example.com/path/to/resource"; 340 | let expected = "https://example.com/path/to/resource"; 341 | let result = convert_uri(uri).unwrap(); 342 | assert_eq!(result, expected); 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | mod low_level; 2 | 3 | use anyhow::Context; 4 | use chrono::TimeZone; 5 | use cyper::IntoUrl; 6 | use itertools::Itertools; 7 | use scraper::Selector; 8 | use std::{ 9 | collections::{HashMap, HashSet}, 10 | hash::{Hash, Hasher}, 11 | str::FromStr, 12 | sync::Arc, 13 | }; 14 | 15 | use crate::{ 16 | multipart, qs, 17 | utils::{with_cache, with_cache_bytes}, 18 | }; 19 | 20 | const ONE_HOUR: std::time::Duration = std::time::Duration::from_secs(3600); 21 | const ONE_DAY: std::time::Duration = std::time::Duration::from_secs(3600 * 24); 22 | const AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; 23 | 24 | struct ClientInner { 25 | http_client: low_level::LowLevelClient, 26 | cache_ttl: Option, 27 | download_artifact_ttl: Option, 28 | } 29 | 30 | impl std::fmt::Debug for ClientInner { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | f.debug_struct("ClientInner") 33 | .field("cache_ttl", &self.cache_ttl) 34 | .field("download_artifact_ttl", &self.download_artifact_ttl) 35 | .finish() 36 | } 37 | } 38 | 39 | #[derive(Debug, Clone)] 40 | pub struct Client(Arc); 41 | 42 | impl std::ops::Deref for Client { 43 | type Target = low_level::LowLevelClient; 44 | 45 | fn deref(&self) -> &Self::Target { 46 | &self.0.http_client 47 | } 48 | } 49 | 50 | impl Client { 51 | pub fn new( 52 | cache_ttl: Option, 53 | download_artifact_ttl: Option, 54 | ) -> Self { 55 | let mut default_headers = http::HeaderMap::new(); 56 | default_headers.insert(http::header::USER_AGENT, AGENT.parse().unwrap()); 57 | let http_client = cyper::Client::builder() 58 | .cookie_store(true) 59 | .default_headers(default_headers) 60 | .build(); 61 | 62 | log::info!("Cache TTL: {:?}", cache_ttl); 63 | log::info!("Download Artifact TTL: {:?}", download_artifact_ttl); 64 | 65 | Self( 66 | ClientInner { 67 | http_client: low_level::LowLevelClient::from_cyper_client(http_client), 68 | cache_ttl, 69 | download_artifact_ttl, 70 | } 71 | .into(), 72 | ) 73 | } 74 | 75 | pub fn new_nocache() -> Self { 76 | Self::new(None, None) 77 | } 78 | 79 | pub async fn blackboard(&self, username: &str, password: &str) -> anyhow::Result { 80 | let c = &self.0.http_client; 81 | let value = c.oauth_login(username, password).await?; 82 | let token = value 83 | .as_object() 84 | .context("value not an object")? 85 | .get("token") 86 | .context("password not correct")? 87 | .as_str() 88 | .context("property 'token' not string")? 89 | .to_owned(); 90 | c.bb_sso_login(&token).await?; 91 | 92 | log::debug!("iaaa oauth token for {username}: {token}"); 93 | 94 | Ok(Blackboard { 95 | client: self.clone(), 96 | }) 97 | } 98 | 99 | pub fn cache_ttl(&self) -> Option<&std::time::Duration> { 100 | self.0.cache_ttl.as_ref() 101 | } 102 | 103 | pub fn download_artifact_ttl(&self) -> Option<&std::time::Duration> { 104 | self.0.download_artifact_ttl.as_ref() 105 | } 106 | } 107 | 108 | impl Default for Client { 109 | fn default() -> Self { 110 | Self::new(Some(ONE_HOUR), Some(ONE_DAY)) 111 | } 112 | } 113 | 114 | #[derive(Debug)] 115 | pub struct Blackboard { 116 | client: Client, 117 | // token: String, 118 | } 119 | 120 | impl Blackboard { 121 | async fn _get_courses(&self) -> anyhow::Result> { 122 | let dom = self.client.bb_homepage().await?; 123 | let re = regex::Regex::new(r"key=([\d_]+),").unwrap(); 124 | let ul_sel = Selector::parse("ul.courseListing").unwrap(); 125 | let sel = Selector::parse("li a").unwrap(); 126 | 127 | let f = |a: scraper::ElementRef<'_>| { 128 | let href = a.value().attr("href").unwrap(); 129 | let text = a.text().collect::(); 130 | // use regex to extract course key (of form key=_80052_1) 131 | 132 | let key = re 133 | .captures(href) 134 | .and_then(|s| s.get(1)) 135 | .context("course key not found")? 136 | .as_str() 137 | .to_owned(); 138 | 139 | Ok((key, text)) 140 | }; 141 | 142 | // the first one contains the courses in the current semester 143 | let ul = dom.select(&ul_sel).nth(0).context("courses not found")?; 144 | let courses = ul.select(&sel).map(f).collect::>>()?; 145 | 146 | // the second one contains the courses in the previous semester 147 | let ul_history = dom.select(&ul_sel).nth(1).context("courses not found")?; 148 | let courses_history = ul_history 149 | .select(&sel) 150 | .map(f) 151 | .collect::>>()?; 152 | 153 | Ok(courses 154 | .into_iter() 155 | .map(|(k, t)| (k, t, true)) 156 | .chain(courses_history.into_iter().map(|(k, t)| (k, t, false))) 157 | .collect()) 158 | } 159 | pub async fn get_courses(&self, only_current: bool) -> anyhow::Result> { 160 | log::info!("fetching courses..."); 161 | 162 | let courses = with_cache( 163 | "Blackboard::_get_courses", 164 | self.client.cache_ttl(), 165 | self._get_courses(), 166 | ) 167 | .await?; 168 | 169 | let mut courses = courses 170 | .into_iter() 171 | .map(|(id, long_title, is_current)| { 172 | Ok(CourseHandle { 173 | client: self.client.clone(), 174 | meta: CourseMeta { 175 | id, 176 | long_title, 177 | is_current, 178 | } 179 | .into(), 180 | }) 181 | }) 182 | .collect::>>()?; 183 | 184 | if only_current { 185 | courses.retain(|c| c.meta.is_current); 186 | } 187 | 188 | Ok(courses) 189 | } 190 | } 191 | 192 | #[derive(Debug)] 193 | pub struct CourseMeta { 194 | id: String, 195 | long_title: String, 196 | /// 是否是当前学期的课程 197 | is_current: bool, 198 | } 199 | 200 | impl CourseMeta { 201 | pub fn id(&self) -> &str { 202 | &self.id 203 | } 204 | 205 | /// Course Name (semester) 206 | pub fn title(&self) -> &str { 207 | self.long_title.split_once(":").unwrap().1.trim() 208 | } 209 | 210 | /// Cousre Name 211 | pub fn name(&self) -> &str { 212 | let s = self.title(); 213 | let i = s 214 | .char_indices() 215 | .filter(|(_, c)| *c == '(') 216 | .last() 217 | .unwrap() 218 | .0; 219 | s.split_at(i).0.trim() 220 | } 221 | } 222 | 223 | #[derive(Debug, Clone)] 224 | pub struct CourseHandle { 225 | client: Client, 226 | meta: Arc, 227 | } 228 | 229 | impl CourseHandle { 230 | pub async fn _get(&self) -> anyhow::Result> { 231 | let dom = self.client.bb_coursepage(&self.meta.id).await?; 232 | 233 | let entries = dom 234 | .select(&Selector::parse("#courseMenuPalette_contents > li > a").unwrap()) 235 | .map(|a| { 236 | let text = a.text().collect::(); 237 | let href = a.value().attr("href").unwrap(); 238 | Ok((text, href.to_owned())) 239 | }) 240 | .collect::>>()?; 241 | 242 | Ok(entries) 243 | } 244 | 245 | pub async fn get(&self) -> anyhow::Result { 246 | log::info!("fetching course {}", self.meta.title()); 247 | 248 | let entries = with_cache( 249 | &format!("CourseHandle::_get_{}", self.meta.id), 250 | self.client.cache_ttl(), 251 | self._get(), 252 | ) 253 | .await?; 254 | 255 | Ok(Course { 256 | client: self.client.clone(), 257 | meta: self.meta.clone(), 258 | entries, 259 | }) 260 | } 261 | } 262 | 263 | #[derive(Debug, Clone)] 264 | pub struct Course { 265 | client: Client, 266 | meta: Arc, 267 | entries: HashMap, 268 | } 269 | 270 | impl Course { 271 | pub fn client(&self) -> &Client { 272 | &self.client 273 | } 274 | 275 | pub fn meta(&self) -> &CourseMeta { 276 | &self.meta 277 | } 278 | 279 | pub fn content_stream(&self) -> CourseContentStream { 280 | CourseContentStream::new( 281 | self.client.clone(), 282 | self.meta.clone(), 283 | self.entries() 284 | .iter() 285 | .filter_map(|(_, uri)| { 286 | let url = low_level::convert_uri(uri).ok()?.into_url().ok()?; 287 | if !low_level::LIST_CONTENT.ends_with(url.path()) { 288 | return None; 289 | } 290 | 291 | let (_, content_id) = url.query_pairs().find(|(k, _)| k == "content_id")?; 292 | 293 | Some(content_id.to_string()) 294 | }) 295 | .collect(), 296 | ) 297 | } 298 | 299 | pub fn build_content(&self, data: CourseContentData) -> CourseContent { 300 | CourseContent { 301 | client: self.client.clone(), 302 | course: self.meta.clone(), 303 | data: data.into(), 304 | } 305 | } 306 | 307 | pub fn entries(&self) -> &HashMap { 308 | &self.entries 309 | } 310 | #[allow(dead_code)] 311 | pub async fn query_launch_link(&self, uri: &str) -> anyhow::Result { 312 | let res = self.client.get_by_uri(uri).await?; 313 | let st = res.status(); 314 | anyhow::ensure!(st.as_u16() == 302, "invalid status: {}", st); 315 | let loc = res 316 | .headers() 317 | .get("location") 318 | .context("location header not found")? 319 | .to_str() 320 | .context("location header not str")? 321 | .to_owned(); 322 | 323 | Ok(loc) 324 | } 325 | pub async fn get_video_list(&self) -> anyhow::Result> { 326 | log::info!("fetching video list for course {}", self.meta.title()); 327 | 328 | let videos = with_cache( 329 | &format!("Course::get_video_list_{}", self.meta.id), 330 | self.client.cache_ttl(), 331 | self._get_video_list(), 332 | ) 333 | .await?; 334 | 335 | let videos = videos 336 | .into_iter() 337 | .map(|meta| { 338 | Ok(CourseVideoHandle { 339 | client: self.client.clone(), 340 | meta: meta.into(), 341 | course: self.meta.clone(), 342 | }) 343 | }) 344 | .collect::>>()?; 345 | 346 | Ok(videos) 347 | } 348 | async fn _get_video_list(&self) -> anyhow::Result> { 349 | let u = low_level::VIDEO_LIST.into_url()?; 350 | let dom = self.client.bb_course_video_list(&self.meta.id).await?; 351 | 352 | let videos = dom 353 | .select(&Selector::parse("tbody#listContainer_databody > tr").unwrap()) 354 | .map(|tr| { 355 | let title = tr 356 | .child_elements() 357 | .nth(0) 358 | .unwrap() 359 | .text() 360 | .collect::(); 361 | let s = Selector::parse("span.table-data-cell-value").unwrap(); 362 | let mut values = tr.select(&s); 363 | let time = values 364 | .next() 365 | .context("time not found")? 366 | .text() 367 | .collect::(); 368 | let _ = values.next().context("teacher not found")?; 369 | let link = values.next().context("video link not found")?; 370 | let a = link 371 | .child_elements() 372 | .next() 373 | .context("video link anchor not found")?; 374 | let link = a 375 | .value() 376 | .attr("href") 377 | .context("video link not found")? 378 | .to_owned(); 379 | 380 | Ok(CourseVideoMeta { 381 | title, 382 | time, 383 | url: u.join(&link)?.to_string(), 384 | }) 385 | }) 386 | .collect::>>()?; 387 | 388 | Ok(videos) 389 | } 390 | } 391 | 392 | pub struct CourseContentStream { 393 | /// 一次性发射的请求数量 394 | batch_size: usize, 395 | client: Client, 396 | course: Arc, 397 | visited_ids: HashSet, 398 | probe_ids: Vec, 399 | } 400 | 401 | impl CourseContentStream { 402 | fn new(client: Client, course: Arc, probe_ids: Vec) -> Self { 403 | // implicitly deduplicate probe_ids 404 | let visited_ids = HashSet::from_iter(probe_ids); 405 | let probe_ids = visited_ids.iter().cloned().collect(); 406 | Self { 407 | batch_size: 8, 408 | client, 409 | course, 410 | visited_ids, 411 | probe_ids, 412 | } 413 | } 414 | async fn try_next_batch(&mut self, ids: &[String]) -> anyhow::Result> { 415 | let futs = ids 416 | .iter() 417 | .map(|id| self.client.bb_course_content_page(&self.course.id, id)); 418 | 419 | let doms = futures_util::future::join_all(futs).await; 420 | 421 | let mut all_contents = Vec::new(); 422 | for dom in doms { 423 | let dom = dom?; 424 | let selector = Selector::parse("#content_listContainer > li").unwrap(); 425 | let contents = dom 426 | .select(&selector) 427 | .filter_map(|li| { 428 | CourseContentData::from_element(li) 429 | .inspect_err(|e| log::warn!("CourseContentData::from_element error: {e}")) 430 | .ok() 431 | }) 432 | // filter out visited ids 433 | .filter(|data| self.visited_ids.insert(data.id.to_owned())) 434 | // add the rest new ids to probe_ids 435 | .inspect(|data| { 436 | if data.has_link { 437 | self.probe_ids.push(data.id.to_owned()) 438 | } 439 | }); 440 | 441 | all_contents.extend(contents); 442 | } 443 | 444 | Ok(all_contents) 445 | } 446 | pub async fn next_batch(&mut self) -> Option> { 447 | let ids = self 448 | .probe_ids 449 | .split_off(self.probe_ids.len().saturating_sub(self.batch_size)); 450 | if ids.is_empty() { 451 | return None; 452 | } 453 | match self.try_next_batch(&ids).await { 454 | Ok(r) => Some(r), 455 | Err(e) => { 456 | log::warn!("try_next_batch error {ids:?}: {e}"); 457 | return Box::pin(self.next_batch()).await; 458 | } 459 | } 460 | } 461 | pub fn num_finished(&self) -> usize { 462 | self.visited_ids.len() - self.probe_ids.len() 463 | } 464 | pub fn len(&self) -> usize { 465 | self.visited_ids.len() 466 | } 467 | } 468 | 469 | #[derive(Debug, Clone)] 470 | pub struct CourseContent { 471 | client: Client, 472 | course: Arc, 473 | data: Arc, 474 | } 475 | 476 | impl CourseContent { 477 | pub fn into_assignment_opt(self) -> Option { 478 | if let CourseContentKind::Assignment = self.data.kind { 479 | Some(CourseAssignmentHandle { 480 | client: self.client, 481 | course: self.course, 482 | content: self.data, 483 | }) 484 | } else { 485 | None 486 | } 487 | } 488 | } 489 | 490 | #[derive(Debug, serde::Deserialize, serde::Serialize)] 491 | enum CourseContentKind { 492 | Document, 493 | Assignment, 494 | Unknown, 495 | } 496 | 497 | #[derive(Debug, serde::Deserialize, serde::Serialize)] 498 | pub struct CourseContentData { 499 | id: String, 500 | title: String, 501 | kind: CourseContentKind, 502 | has_link: bool, 503 | descriptions: Vec, 504 | attachments: Vec<(String, String)>, 505 | } 506 | 507 | fn collect_text(element: scraper::ElementRef) -> String { 508 | let mut text_content = String::new(); 509 | for node_ref in element.children() { 510 | match node_ref.value() { 511 | scraper::node::Node::Text(text) => { 512 | if !text.trim().is_empty() { 513 | text_content.push_str(text); 514 | } 515 | } 516 | scraper::node::Node::Element(el) => { 517 | if el.name() != "script" { 518 | if let Some(child_element) = scraper::ElementRef::wrap(node_ref) { 519 | text_content.push_str(&collect_text(child_element)); 520 | } 521 | } 522 | } 523 | _ => {} 524 | } 525 | } 526 | text_content 527 | } 528 | 529 | impl CourseContentData { 530 | fn from_element(el: scraper::ElementRef<'_>) -> anyhow::Result { 531 | anyhow::ensure!(el.value().name() == "li", "not a li element"); 532 | let (img, title_div, detail_div) = el.child_elements().take(3).collect_tuple().unwrap(); 533 | 534 | let kind = match img.attr("alt") { 535 | Some("作业") => CourseContentKind::Assignment, 536 | Some("项目") | Some("文件") => CourseContentKind::Document, 537 | alt => { 538 | log::warn!("unknown content kind: {alt:?}"); 539 | CourseContentKind::Unknown 540 | } 541 | }; 542 | 543 | let id = title_div 544 | .attr("id") 545 | .context("content_id not found")? 546 | .to_owned(); 547 | 548 | let title = title_div.text().collect::().trim().to_owned(); 549 | let has_link = title_div 550 | .select(&Selector::parse("a").unwrap()) 551 | .next() 552 | .is_some(); 553 | 554 | let descriptions = detail_div 555 | .select(&Selector::parse("div.vtbegenerated > *").unwrap()) 556 | .map(|p| collect_text(p).trim().to_owned()) 557 | .collect::>(); 558 | 559 | let attachments = detail_div 560 | .select(&Selector::parse("ul.attachments > li > a").unwrap()) 561 | .map(|a| { 562 | let text = a.text().collect::(); 563 | let href = a.value().attr("href").unwrap(); 564 | let text = if let Some(text) = text.strip_prefix("\u{a0}") { 565 | text.to_owned() 566 | } else { 567 | text 568 | }; 569 | Ok((text, href.to_owned())) 570 | }) 571 | .collect::>>()?; 572 | 573 | Ok(CourseContentData { 574 | id, 575 | title, 576 | kind, 577 | has_link, 578 | descriptions, 579 | attachments, 580 | }) 581 | } 582 | } 583 | 584 | #[derive(Debug, Clone)] 585 | pub struct CourseAssignmentHandle { 586 | client: Client, 587 | course: Arc, 588 | content: Arc, 589 | } 590 | 591 | impl CourseAssignmentHandle { 592 | pub fn id(&self) -> String { 593 | let mut hasher = std::hash::DefaultHasher::new(); 594 | self.course.id.hash(&mut hasher); 595 | self.content.id.hash(&mut hasher); 596 | let x = hasher.finish(); 597 | format!("{x:x}") 598 | } 599 | 600 | async fn _get(&self) -> anyhow::Result { 601 | let dom = self 602 | .client 603 | .bb_course_assignment_uploadpage(&self.course.id, &self.content.id) 604 | .await?; 605 | 606 | let deadline = dom 607 | .select(&Selector::parse("#assignMeta2 + div").unwrap()) 608 | .next() 609 | .map(|e| { 610 | // replace consecutive whitespaces with a single space 611 | e.text() 612 | .collect::() 613 | .split_whitespace() 614 | .collect::>() 615 | .join(" ") 616 | }); 617 | 618 | let attempt = self._get_current_attempt().await?; 619 | 620 | Ok(CourseAssignmentData { deadline, attempt }) 621 | } 622 | pub async fn get(&self) -> anyhow::Result { 623 | let data = with_cache( 624 | &format!( 625 | "CourseAssignmentHandle::_get_{}_{}", 626 | self.content.id, self.course.id 627 | ), 628 | self.client.cache_ttl(), 629 | self._get(), 630 | ) 631 | .await?; 632 | 633 | Ok(CourseAssignment { 634 | client: self.client.clone(), 635 | course: self.course.clone(), 636 | content: self.content.clone(), 637 | data, 638 | }) 639 | } 640 | 641 | async fn _get_current_attempt(&self) -> anyhow::Result> { 642 | let dom = self 643 | .client 644 | .bb_course_assignment_viewpage(&self.course.id, &self.content.id) 645 | .await?; 646 | 647 | let attempt_label = if let Some(e) = dom 648 | .select(&Selector::parse("h3#currentAttempt_label").unwrap()) 649 | .next() 650 | { 651 | e.text().collect::() 652 | } else { 653 | return Ok(None); 654 | }; 655 | 656 | let attempt_label = attempt_label 657 | .split_whitespace() 658 | .collect::>() 659 | .join(" "); 660 | 661 | Ok(Some(attempt_label)) 662 | } 663 | } 664 | 665 | #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] 666 | struct CourseAssignmentData { 667 | // descriptions: Vec, 668 | // attachments: Vec<(String, String)>, 669 | deadline: Option, 670 | attempt: Option, 671 | } 672 | 673 | #[derive(Debug, Clone)] 674 | pub struct CourseAssignment { 675 | client: Client, 676 | course: Arc, 677 | content: Arc, 678 | data: CourseAssignmentData, 679 | } 680 | 681 | impl CourseAssignment { 682 | pub fn title(&self) -> &str { 683 | &self.content.title 684 | } 685 | 686 | pub fn descriptions(&self) -> &[String] { 687 | &self.content.descriptions 688 | } 689 | 690 | pub fn attachments(&self) -> &[(String, String)] { 691 | &self.content.attachments 692 | } 693 | 694 | pub fn last_attempt(&self) -> Option<&str> { 695 | self.data.attempt.as_deref() 696 | } 697 | 698 | pub async fn get_submit_formfields(&self) -> anyhow::Result> { 699 | let dom = self 700 | .client 701 | .bb_course_assignment_uploadpage(&self.course.id, &self.content.id) 702 | .await?; 703 | 704 | let extract_field = |input: scraper::ElementRef<'_>| { 705 | let name = input.value().attr("name")?.to_owned(); 706 | let value = input.value().attr("value")?.to_owned(); 707 | Some((name, value)) 708 | }; 709 | 710 | let submitformfields = dom 711 | .select(&Selector::parse("form#uploadAssignmentFormId input").unwrap()) 712 | .map(extract_field) 713 | .chain( 714 | dom.select(&Selector::parse("div.field input").unwrap()) 715 | .map(extract_field), 716 | ) 717 | .flatten() 718 | .collect::>(); 719 | 720 | Ok(submitformfields) 721 | } 722 | 723 | pub async fn submit_file(&self, path: &std::path::Path) -> anyhow::Result<()> { 724 | log::info!("submitting file: {}", path.display()); 725 | 726 | let ext = path 727 | .extension() 728 | .unwrap_or_default() 729 | .to_string_lossy() 730 | .to_string(); 731 | let content_type = get_mime_type(&ext); 732 | log::info!("content type: {}", content_type); 733 | 734 | let filename = path 735 | .file_name() 736 | .context("file name not found")? 737 | .to_string_lossy() 738 | .to_string(); 739 | 740 | let map = self.get_submit_formfields().await?; 741 | log::trace!("map: {:#?}", map); 742 | 743 | macro_rules! add_field_from_map { 744 | ($body:ident, $name:expr) => { 745 | let $body = $body.add_field( 746 | $name, 747 | map.get($name) 748 | .with_context(|| format!("field '{}' not found", $name))? 749 | .as_bytes(), 750 | ); 751 | }; 752 | } 753 | 754 | let body = multipart::MultipartBuilder::new(); 755 | add_field_from_map!(body, "attempt_id"); 756 | add_field_from_map!(body, "blackboard.platform.security.NonceUtil.nonce"); 757 | add_field_from_map!(body, "blackboard.platform.security.NonceUtil.nonce.ajax"); 758 | add_field_from_map!(body, "content_id"); 759 | add_field_from_map!(body, "course_id"); 760 | add_field_from_map!(body, "isAjaxSubmit"); 761 | add_field_from_map!(body, "lu_link_id"); 762 | add_field_from_map!(body, "mode"); 763 | add_field_from_map!(body, "recallUrl"); 764 | add_field_from_map!(body, "remove_file_id"); 765 | add_field_from_map!(body, "studentSubmission.text_f"); 766 | add_field_from_map!(body, "studentSubmission.text_w"); 767 | add_field_from_map!(body, "studentSubmission.type"); 768 | add_field_from_map!(body, "student_commentstext_f"); 769 | add_field_from_map!(body, "student_commentstext_w"); 770 | add_field_from_map!(body, "student_commentstype"); 771 | add_field_from_map!(body, "textbox_prefix"); 772 | let body = body 773 | .add_field("studentSubmission.text", b"") 774 | .add_field("student_commentstext", b"") 775 | .add_field("dispatch", b"submit") 776 | .add_field("newFile_artifactFileId", b"undefined") 777 | .add_field("newFile_artifactType", b"undefined") 778 | .add_field("newFile_artifactTypeResourceKey", b"undefined") 779 | .add_field("newFile_attachmentType", b"L") // not sure 780 | .add_field("newFile_fileId", b"new") 781 | .add_field("newFile_linkTitle", filename.as_bytes()) 782 | .add_field("newFilefilePickerLastInput", b"dummyValue") 783 | .add_file( 784 | "newFile_LocalFile0", 785 | &filename, 786 | content_type, 787 | std::fs::File::open(path)?, 788 | ) 789 | .add_field("useless", b""); 790 | 791 | let res = self.client.bb_course_assignment_uploaddata(body).await?; 792 | 793 | if !res.status().is_success() { 794 | let st = res.status(); 795 | let rbody = res.text().await?; 796 | if rbody.contains("尝试呈现错误页面时发生严重的内部错误") { 797 | anyhow::bail!("invalid status {} (caused by unknown server error)", st); 798 | } 799 | 800 | log::debug!("response: {}", rbody); 801 | anyhow::bail!("invalid status {}", st); 802 | } 803 | 804 | Ok(()) 805 | } 806 | 807 | /// Try to parse the deadline string into a NaiveDateTime. 808 | pub fn deadline(&self) -> Option> { 809 | let d = self.data.deadline.as_deref()?; 810 | let re = regex::Regex::new( 811 | r"(\d{4})年(\d{1,2})月(\d{1,2})日 星期. (上午|下午)(\d{1,2}):(\d{1,2})", 812 | ) 813 | .unwrap(); 814 | 815 | if let Some(caps) = re.captures(d) { 816 | let year: i32 = caps[1].parse().ok()?; 817 | let month: u32 = caps[2].parse().ok()?; 818 | let day: u32 = caps[3].parse().ok()?; 819 | let mut hour: u32 = caps[5].parse().ok()?; 820 | let minute: u32 = caps[6].parse().ok()?; 821 | 822 | // Adjust for PM times 823 | if &caps[4] == "下午" && hour < 12 { 824 | hour += 12; 825 | } 826 | 827 | // Create NaiveDateTime 828 | let naive_dt = chrono::NaiveDateTime::new( 829 | chrono::NaiveDate::from_ymd_opt(year, month, day)?, 830 | chrono::NaiveTime::from_hms_opt(hour, minute, 0)?, 831 | ); 832 | 833 | let r = chrono::Local.from_local_datetime(&naive_dt).unwrap(); 834 | 835 | Some(r) 836 | } else { 837 | None 838 | } 839 | } 840 | 841 | pub fn deadline_raw(&self) -> Option<&str> { 842 | self.data.deadline.as_deref() 843 | } 844 | 845 | pub async fn download_attachment( 846 | &self, 847 | uri: &str, 848 | dest: &std::path::Path, 849 | ) -> anyhow::Result<()> { 850 | log::debug!( 851 | "downloading attachment from https://course.pku.edu.cn{}", 852 | uri 853 | ); 854 | let res = self.client.get_by_uri(uri).await?; 855 | anyhow::ensure!( 856 | res.status().as_u16() == 302, 857 | "status not 302: {}", 858 | res.status() 859 | ); 860 | 861 | let loc = res 862 | .headers() 863 | .get("location") 864 | .context("location header not found")? 865 | .to_str() 866 | .context("location header not str")? 867 | .to_owned(); 868 | 869 | log::debug!("redicted to https://course.pku.edu.cn{}", loc); 870 | let res = self.client.get_by_uri(&loc).await?; 871 | anyhow::ensure!(res.status().is_success(), "status not success"); 872 | 873 | let rbody = res.bytes().await?; 874 | let r = compio::fs::write(dest, rbody).await; 875 | compio::buf::buf_try!(@try r); 876 | Ok(()) 877 | } 878 | } 879 | 880 | #[derive(Debug, serde::Serialize, serde::Deserialize)] 881 | pub struct CourseVideoMeta { 882 | title: String, 883 | time: String, 884 | url: String, 885 | } 886 | 887 | impl CourseVideoMeta { 888 | pub fn title(&self) -> &str { 889 | &self.title 890 | } 891 | pub fn time(&self) -> &str { 892 | &self.time 893 | } 894 | } 895 | 896 | #[derive(Debug)] 897 | pub struct CourseVideoHandle { 898 | client: Client, 899 | meta: Arc, 900 | course: Arc, 901 | } 902 | 903 | impl CourseVideoHandle { 904 | /// Course video identifier computed from hash. 905 | pub fn id(&self) -> String { 906 | let mut hasher = std::hash::DefaultHasher::new(); 907 | self.course.id.hash(&mut hasher); 908 | self.meta.title.hash(&mut hasher); 909 | self.meta.time.hash(&mut hasher); 910 | let x = hasher.finish(); 911 | format!("{x:x}") 912 | } 913 | pub fn meta(&self) -> &CourseVideoMeta { 914 | &self.meta 915 | } 916 | async fn get_iframe_url(&self) -> anyhow::Result { 917 | let res = self.client.get_by_uri(&self.meta.url).await?; 918 | anyhow::ensure!(res.status().is_success(), "status not success"); 919 | let rbody = res.text().await?; 920 | let dom = scraper::Html::parse_document(&rbody); 921 | let iframe = dom 922 | .select(&Selector::parse("#content iframe").unwrap()) 923 | .next() 924 | .context("iframe not found")?; 925 | let src = iframe 926 | .value() 927 | .attr("src") 928 | .context("src not found")? 929 | .to_owned(); 930 | 931 | let res = self.client.get_by_uri(&src).await?; 932 | anyhow::ensure!(res.status().as_u16() == 302, "status not 302"); 933 | let loc = res 934 | .headers() 935 | .get("location") 936 | .context("location header not found")? 937 | .to_str() 938 | .context("location header not str")? 939 | .to_owned(); 940 | 941 | Ok(loc) 942 | } 943 | 944 | async fn get_sub_info(&self, loc: &str) -> anyhow::Result { 945 | let qs = qs::Query::from_str(loc).context("parse loc qs failed")?; 946 | let course_id = qs 947 | .get("course_id") 948 | .context("course_id not found")? 949 | .to_owned(); 950 | let sub_id = qs.get("sub_id").context("sub_id not found")?.to_owned(); 951 | let app_id = qs.get("app_id").context("app_id not found")?.to_owned(); 952 | let auth_data = qs 953 | .get("auth_data") 954 | .context("auth_data not found")? 955 | .to_owned(); 956 | 957 | let value = self 958 | .client 959 | .bb_course_video_sub_info(&course_id, &sub_id, &app_id, &auth_data) 960 | .await?; 961 | 962 | Ok(value) 963 | } 964 | 965 | fn get_m3u8_path(&self, sub_info: serde_json::Value) -> anyhow::Result { 966 | let sub_content = sub_info 967 | .as_object() 968 | .context("sub_info not object")? 969 | .get("list") 970 | .context("sub_info.list not found")? 971 | .as_array() 972 | .context("sub_info.list not array")? 973 | .first() 974 | .context("sub_info.list empty")? 975 | .as_object() 976 | .context("sub_info.list[0] not object")? 977 | .get("sub_content") 978 | .context("sub_info.list[0].sub_content not found")? 979 | .as_str() 980 | .context("sub_info.list[0].sub_content not string")?; 981 | 982 | let sub_content = serde_json::Value::from_str(sub_content)?; 983 | 984 | let save_playback = sub_content 985 | .as_object() 986 | .context("sub_content not object")? 987 | .get("save_playback") 988 | .context("sub_content.save_playback not found")? 989 | .as_object() 990 | .context("sub_content.save_playback not object")?; 991 | 992 | let is_m3u8 = save_playback 993 | .get("is_m3u8") 994 | .context("sub_content.save_playback.is_m3u8 not found")? 995 | .as_str() 996 | .context("sub_content.save_playback.is_m3u8 not string")?; 997 | 998 | anyhow::ensure!(is_m3u8 == "yes", "not m3u8"); 999 | 1000 | let url = save_playback 1001 | .get("contents") 1002 | .context("save_playback.contents not found")? 1003 | .as_str() 1004 | .context("save_playback.contents not string")?; 1005 | 1006 | Ok(url.to_owned()) 1007 | } 1008 | 1009 | async fn get_m3u8_playlist(&self, url: &str) -> anyhow::Result { 1010 | let res = self.client.get_by_uri(url).await?; 1011 | anyhow::ensure!(res.status().is_success(), "status not success"); 1012 | let rbody = res.bytes().await?; 1013 | Ok(rbody) 1014 | } 1015 | 1016 | async fn _get(&self) -> anyhow::Result<(String, bytes::Bytes)> { 1017 | let loc = self.get_iframe_url().await?; 1018 | let info = self.get_sub_info(&loc).await?; 1019 | let pl_url = self.get_m3u8_path(info)?; 1020 | let pl_raw = self.get_m3u8_playlist(&pl_url).await?; 1021 | Ok((pl_url, pl_raw)) 1022 | } 1023 | 1024 | pub async fn get(&self) -> anyhow::Result { 1025 | let (pl_url, pl_raw) = self._get().await.with_context(|| { 1026 | format!( 1027 | "get course video for {} {}", 1028 | self.course.title(), 1029 | self.meta().title() 1030 | ) 1031 | })?; 1032 | 1033 | let pl_raw = pl_raw.to_vec(); 1034 | let (_, pl) = m3u8_rs::parse_playlist(&pl_raw) 1035 | .map_err(|e| anyhow::anyhow!("{:#}", e)) 1036 | .context("parse m3u8 failed")?; 1037 | 1038 | match pl { 1039 | m3u8_rs::Playlist::MasterPlaylist(_) => anyhow::bail!("master playlist not supported"), 1040 | m3u8_rs::Playlist::MediaPlaylist(pl) => Ok(CourseVideo { 1041 | client: self.client.clone(), 1042 | course: self.course.clone(), 1043 | meta: self.meta.clone(), 1044 | pl_url: pl_url.into_url().context("parse pl_url failed")?, 1045 | pl_raw: pl_raw.into(), 1046 | pl, 1047 | }), 1048 | } 1049 | } 1050 | } 1051 | 1052 | #[derive(Debug)] 1053 | pub struct CourseVideo { 1054 | client: Client, 1055 | course: Arc, 1056 | meta: Arc, 1057 | pl_raw: bytes::Bytes, 1058 | pl_url: url::Url, 1059 | pl: m3u8_rs::MediaPlaylist, 1060 | } 1061 | 1062 | impl CourseVideo { 1063 | pub fn course_name(&self) -> &str { 1064 | self.course.name() 1065 | } 1066 | 1067 | pub fn meta(&self) -> &CourseVideoMeta { 1068 | &self.meta 1069 | } 1070 | 1071 | pub fn m3u8_raw(&self) -> bytes::Bytes { 1072 | self.pl_raw.clone() 1073 | } 1074 | 1075 | pub fn len_segments(&self) -> usize { 1076 | self.pl.segments.len() 1077 | } 1078 | 1079 | /// Refresh the key for the given segment index. You should call this method before getting the segment data referenced by the index. 1080 | /// 1081 | /// The EXT-X-KEY tag specifies how to decrypt them. It applies to every Media Segment and to every Media 1082 | /// Initialization Section declared by an EXT-X-MAP tag that appears 1083 | /// between it and the next EXT-X-KEY tag in the Playlist file with the 1084 | /// same KEYFORMAT attribute (or the end of the Playlist file). 1085 | pub fn refresh_key<'a>( 1086 | &'a self, 1087 | index: usize, 1088 | key: Option<&'a m3u8_rs::Key>, 1089 | ) -> Option<&'a m3u8_rs::Key> { 1090 | let seg = &self.pl.segments[index]; 1091 | fn fallback_keyformat(key: &m3u8_rs::Key) -> &str { 1092 | key.keyformat.as_deref().unwrap_or("identity") 1093 | } 1094 | 1095 | if let Some(newkey) = &seg.key { 1096 | if key.is_none_or(|k| fallback_keyformat(k) == fallback_keyformat(newkey)) { 1097 | return Some(newkey); 1098 | } 1099 | } 1100 | key 1101 | } 1102 | 1103 | pub fn segment(&self, index: usize) -> &m3u8_rs::MediaSegment { 1104 | &self.pl.segments[index] 1105 | } 1106 | 1107 | /// Fetch the segment data for the given index. If `key` is provided, the segment will be decrypted. 1108 | pub async fn get_segment_data<'a>( 1109 | &'a self, 1110 | index: usize, 1111 | key: Option<&'a m3u8_rs::Key>, 1112 | ) -> anyhow::Result { 1113 | log::info!( 1114 | "downloading segment {}/{} for video {}", 1115 | index, 1116 | self.len_segments(), 1117 | self.meta.title() 1118 | ); 1119 | 1120 | let seg = &self.pl.segments[index]; 1121 | 1122 | // fetch maybe encrypted segment data 1123 | let seg_url: String = self.pl_url.join(&seg.uri).context("join seg url")?.into(); 1124 | let mut bytes = with_cache_bytes( 1125 | &format!("CourseVideo::download_segment_{}", seg_url), 1126 | self.client.download_artifact_ttl(), 1127 | self._download_segment(&seg_url), 1128 | ) 1129 | .await 1130 | .context("download segment data")?; 1131 | 1132 | // decrypt it if needed 1133 | if let Some(key) = key { 1134 | // sequence number may be used to construct IV 1135 | let seq = (self.pl.media_sequence as usize + index) as u128; 1136 | bytes = self 1137 | .decrypt_segment(key, bytes, seq) 1138 | .await 1139 | .context("decrypt segment data")?; 1140 | } 1141 | 1142 | Ok(bytes) 1143 | } 1144 | 1145 | async fn _download_segment(&self, seg_url: &str) -> anyhow::Result { 1146 | let res = self.client.get_by_uri(seg_url).await?; 1147 | anyhow::ensure!(res.status().is_success(), "status not success"); 1148 | 1149 | let bytes = res.bytes().await?; 1150 | Ok(bytes) 1151 | } 1152 | 1153 | async fn get_aes128_key(&self, url: &str) -> anyhow::Result<[u8; 16]> { 1154 | // fetch aes128 key from uri 1155 | let r = with_cache_bytes( 1156 | &format!("CourseVideo::get_aes128_uri_{}", url), 1157 | self.client.download_artifact_ttl(), 1158 | async { 1159 | let r = self.client.get_by_uri(url).await?.bytes().await?; 1160 | Ok(r) 1161 | }, 1162 | ) 1163 | .await? 1164 | .to_vec(); 1165 | 1166 | if r.len() != 16 { 1167 | anyhow::bail!("key length not 16: {:?}", String::from_utf8(r)); 1168 | } 1169 | 1170 | // convert to array 1171 | let mut key = [0; 16]; 1172 | key.copy_from_slice(&r); 1173 | Ok(key) 1174 | } 1175 | 1176 | async fn decrypt_segment( 1177 | &self, 1178 | key: &m3u8_rs::Key, 1179 | bytes: bytes::Bytes, 1180 | seq: u128, 1181 | ) -> anyhow::Result { 1182 | use aes::cipher::{ 1183 | BlockDecryptMut, KeyIvInit, block_padding::Pkcs7, generic_array::GenericArray, 1184 | }; 1185 | // ref: https://datatracker.ietf.org/doc/html/rfc8216#section-4.3.2.4 1186 | match &key.method { 1187 | // An encryption method of AES-128 signals that Media Segments are 1188 | // completely encrypted using [AES_128] with a 128-bit key, Cipher 1189 | // Block Chaining, and PKCS7 padding [RFC5652]. CBC is restarted 1190 | // on each segment boundary, using either the IV attribute value 1191 | // or the Media Sequence Number as the IV; see Section 5.2. The 1192 | // URI attribute is REQUIRED for this METHOD. 1193 | m3u8_rs::KeyMethod::AES128 => { 1194 | let uri = key.uri.as_ref().context("key uri not found")?; 1195 | let iv = if let Some(iv) = &key.iv { 1196 | let iv = iv.to_ascii_uppercase(); 1197 | let hx = iv.strip_prefix("0x").context("iv not start with 0x")?; 1198 | u128::from_str_radix(hx, 16).context("parse iv failed")? 1199 | } else { 1200 | seq 1201 | } 1202 | .to_be_bytes(); 1203 | 1204 | let aes_key = self.get_aes128_key(uri).await?; 1205 | 1206 | let aes_key = GenericArray::from(aes_key); 1207 | let iv = GenericArray::from(iv); 1208 | 1209 | let de = cbc::Decryptor::::new(&aes_key, &iv) 1210 | .decrypt_padded_vec_mut::(&bytes) 1211 | .context("decrypt failed")?; 1212 | 1213 | Ok(de.into()) 1214 | } 1215 | r => unimplemented!("m3u8 key: {:?}", r), 1216 | } 1217 | } 1218 | } 1219 | 1220 | /// 根据文件扩展名返回对应的 MIME 类型 1221 | pub fn get_mime_type(extension: &str) -> &str { 1222 | let mime_types: HashMap<&str, &str> = [ 1223 | ("html", "text/html"), 1224 | ("htm", "text/html"), 1225 | ("txt", "text/plain"), 1226 | ("csv", "text/csv"), 1227 | ("json", "application/json"), 1228 | ("xml", "application/xml"), 1229 | ("png", "image/png"), 1230 | ("jpg", "image/jpeg"), 1231 | ("jpeg", "image/jpeg"), 1232 | ("gif", "image/gif"), 1233 | ("bmp", "image/bmp"), 1234 | ("webp", "image/webp"), 1235 | ("mp3", "audio/mpeg"), 1236 | ("wav", "audio/wav"), 1237 | ("mp4", "video/mp4"), 1238 | ("avi", "video/x-msvideo"), 1239 | ("pdf", "application/pdf"), 1240 | ("zip", "application/zip"), 1241 | ("tar", "application/x-tar"), 1242 | ("7z", "application/x-7z-compressed"), 1243 | ("rar", "application/vnd.rar"), 1244 | ("exe", "application/octet-stream"), 1245 | ("bin", "application/octet-stream"), 1246 | ] 1247 | .iter() 1248 | .cloned() 1249 | .collect(); 1250 | 1251 | mime_types 1252 | .get(extension) 1253 | .copied() 1254 | .unwrap_or("application/octet-stream") 1255 | } 1256 | 1257 | #[cfg(test)] 1258 | mod tests { 1259 | use super::*; 1260 | 1261 | #[test] 1262 | fn test_get_mime_type() { 1263 | assert_eq!(get_mime_type("html"), "text/html"); 1264 | assert_eq!(get_mime_type("png"), "image/png"); 1265 | assert_eq!(get_mime_type("mp3"), "audio/mpeg"); 1266 | assert_eq!(get_mime_type("unknown"), "application/octet-stream"); 1267 | } 1268 | } 1269 | -------------------------------------------------------------------------------- /src/cli/cmd_assignment.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Context; 4 | 5 | use super::*; 6 | 7 | async fn get_contents( 8 | c: &api::Course, 9 | pb: indicatif::ProgressBar, 10 | ) -> anyhow::Result> { 11 | let fut = async { 12 | let mut s = c.content_stream(); 13 | 14 | // let pb = pbar::new(s.len() as u64).with_message("search contents"); 15 | pb.set_length(s.len() as u64); 16 | pb.tick(); 17 | 18 | let mut contents = Vec::new(); 19 | while let Some(batch) = s.next_batch().await { 20 | contents.extend(batch); 21 | 22 | pb.set_length(s.len() as u64); 23 | pb.set_position(s.num_finished() as u64); 24 | pb.tick(); 25 | } 26 | 27 | pb.finish_with_message("done."); 28 | Ok(contents) 29 | }; 30 | 31 | let data = utils::with_cache( 32 | &format!("get_course_contents_{}", c.meta().id()), 33 | c.client().cache_ttl(), 34 | fut, 35 | ) 36 | .await?; 37 | 38 | Ok(data.into_iter().map(|data| c.build_content(data)).collect()) 39 | } 40 | 41 | async fn get_assignments( 42 | c: &api::Course, 43 | pb: indicatif::ProgressBar, 44 | ) -> anyhow::Result> { 45 | let r = get_contents(c, pb) 46 | .await? 47 | .into_iter() 48 | .filter_map(|c| c.into_assignment_opt()) 49 | .collect(); 50 | Ok(r) 51 | } 52 | 53 | async fn get_courses_and_assignments( 54 | force: bool, 55 | cur_term: bool, 56 | ) -> anyhow::Result)>> { 57 | let courses = load_courses(force, cur_term).await?; 58 | 59 | // fetch each course concurrently 60 | let m = indicatif::MultiProgress::new(); 61 | let pb = m.add(pbar::new(courses.len() as u64)).with_prefix("All"); 62 | let futs = courses.into_iter().map(async |c| -> anyhow::Result<_> { 63 | let c = c.get().await.context("fetch course")?; 64 | let assignments = get_assignments( 65 | &c, 66 | m.add(pbar::new(0).with_prefix(c.meta().name().to_owned())), 67 | ) 68 | .await 69 | .with_context(|| format!("fetch assignment handles of {}", c.meta().title()))?; 70 | 71 | pb.inc_length(assignments.len() as u64); 72 | let futs = assignments.into_iter().map(async |a| -> anyhow::Result<_> { 73 | let id = a.id(); 74 | let r = a.get().await.context("fetch assignment")?; 75 | pb.inc(1); 76 | Ok((id, r)) 77 | }); 78 | let assignments = try_join_all(futs).await?; 79 | 80 | pb.inc(1); 81 | Ok((c, assignments)) 82 | }); 83 | let courses = try_join_all(futs).await?; 84 | pb.finish_and_clear(); 85 | m.clear().unwrap(); 86 | drop(pb); 87 | drop(m); 88 | 89 | Ok(courses) 90 | } 91 | 92 | pub async fn list(force: bool, all: bool, cur_term: bool) -> anyhow::Result<()> { 93 | let courses = get_courses_and_assignments(force, cur_term).await?; 94 | 95 | let mut all_assignments = courses 96 | .iter() 97 | .flat_map(|(c, assignments)| { 98 | assignments 99 | .iter() 100 | .map(move |(id, a)| (c.to_owned(), id.to_owned(), a)) 101 | }) 102 | // retain only unfinished assignments if not in full mode 103 | .filter(|(_, _, a)| all || a.last_attempt().is_none()) 104 | .collect::>(); 105 | 106 | // sort by deadline 107 | log::debug!("sorting assignments..."); 108 | all_assignments.sort_by_cached_key(|(_, _, a)| a.deadline()); 109 | 110 | // prepare output statements 111 | let mut outbuf = Vec::new(); 112 | let title = if all { 113 | "所有作业 (包括已完成)" 114 | } else { 115 | "未完成作业" 116 | }; 117 | let total = all_assignments.len(); 118 | writeln!(outbuf, "{D}>{D:#} {B}{title} ({total}){B:#} {D}<{D:#}\n")?; 119 | 120 | for (c, id, a) in all_assignments { 121 | write_course_assignment(&mut outbuf, &id, &c, a).context("io error")?; 122 | } 123 | 124 | // write to stdout 125 | buf_try!(@try fs::stdout().write_all(outbuf).await); 126 | 127 | Ok(()) 128 | } 129 | 130 | type AssignmentListItem = (Arc, String, api::CourseAssignment); 131 | 132 | async fn fetch_assignments( 133 | force: bool, 134 | all: bool, 135 | cur_term: bool, 136 | ) -> anyhow::Result> { 137 | let courses = get_courses_and_assignments(force, cur_term).await?; 138 | 139 | let mut all_assignments = courses 140 | .into_iter() 141 | .flat_map(|(c, assignments)| { 142 | let c = Arc::new(c); 143 | assignments 144 | .into_iter() 145 | .map(move |(id, a)| (c.clone(), id, a)) 146 | }) 147 | // retain only unfinished assignments if not in full mode 148 | .filter(|(_, _, a)| all || a.last_attempt().is_none()) 149 | .collect::>(); 150 | 151 | // sort by deadline 152 | log::debug!("sorting assignments..."); 153 | all_assignments.sort_by_cached_key(|(_, _, a)| a.deadline()); 154 | 155 | Ok(all_assignments) 156 | } 157 | 158 | async fn select_assignment( 159 | mut items: Vec, 160 | ) -> anyhow::Result { 161 | if items.is_empty() { 162 | anyhow::bail!("assignments not found"); 163 | } 164 | 165 | let mut options = Vec::new(); 166 | 167 | for (idx, (c, id, a)) in items.iter().enumerate() { 168 | let mut outbuf = Vec::new(); 169 | write!(outbuf, "[{}] ", idx + 1)?; 170 | write_assignment_title_ln(&mut outbuf, id, c, a).context("io error")?; 171 | options.push(String::from_utf8(outbuf).unwrap()); 172 | } 173 | 174 | let s = inquire::Select::new("请选择要下载的作业", options).raw_prompt()?; 175 | let idx = s.index; 176 | let r = items.swap_remove(idx); 177 | 178 | Ok(r) 179 | } 180 | 181 | pub async fn download( 182 | id: Option<&str>, 183 | dir: &std::path::Path, 184 | force: bool, 185 | all: bool, 186 | cur_term: bool, 187 | ) -> anyhow::Result<()> { 188 | let items = fetch_assignments(force, all, cur_term).await?; 189 | let a = match id { 190 | Some(id) => match items.into_iter().find(|x| x.1 == id) { 191 | Some(r) => r, 192 | None => anyhow::bail!("assignment with id {} not found", id), 193 | }, 194 | None => select_assignment(items).await?, 195 | }; 196 | 197 | let sp = pbar::new_spinner(); 198 | download_data(sp, dir, &a.2).await?; 199 | 200 | Ok(()) 201 | } 202 | 203 | async fn download_data( 204 | sp: pbar::AsyncSpinner, 205 | dir: &std::path::Path, 206 | a: &api::CourseAssignment, 207 | ) -> anyhow::Result<()> { 208 | if !dir.exists() { 209 | compio::fs::create_dir_all(dir).await?; 210 | } 211 | 212 | let atts = a.attachments(); 213 | let tot = atts.len(); 214 | for (id, (name, uri)) in atts.iter().enumerate() { 215 | sp.set_message(format!( 216 | "[{}/{tot}] downloading attachment '{name}'...", 217 | id + 1 218 | )); 219 | a.download_attachment(uri, &dir.join(name)) 220 | .await 221 | .with_context(|| format!("download attachment '{}'", name))?; 222 | } 223 | 224 | drop(sp); 225 | println!("Done."); 226 | Ok(()) 227 | } 228 | 229 | pub async fn submit(id: Option<&str>, path: Option<&std::path::Path>) -> anyhow::Result<()> { 230 | let items = fetch_assignments(false, false, true).await?; 231 | 232 | let (c, _, a) = match id { 233 | Some(id) => match items.into_iter().find(|x| x.1 == id) { 234 | Some(r) => r, 235 | None => anyhow::bail!("assignment with id {} not found", id), 236 | }, 237 | None => select_assignment(items).await?, 238 | }; 239 | 240 | let path = match path { 241 | Some(path) => path.to_owned(), 242 | None => { 243 | // list the current dir and use inquire::Select to choose a file 244 | 245 | let mut options = Vec::new(); 246 | // fill options with files in the current dir 247 | let entries = std::fs::read_dir(".")?; 248 | for entry in entries { 249 | let Ok(entry) = entry else { 250 | continue; 251 | }; 252 | let path = entry.path(); 253 | if path.is_file() { 254 | options.push(path.to_str().unwrap().to_owned()); 255 | } 256 | } 257 | 258 | if options.is_empty() { 259 | anyhow::bail!("no files found in current directory"); 260 | } 261 | let s = inquire::Select::new("请选择要提交的文件", options).prompt()?; 262 | 263 | s.into() 264 | } 265 | }; 266 | 267 | if !path.exists() { 268 | anyhow::bail!("file not found: {:?}", path); 269 | } 270 | 271 | let sp = pbar::new_spinner(); 272 | sp.set_message("submit file..."); 273 | a.submit_file(path.as_path()) 274 | .await 275 | .with_context(|| format!("submit {:?} to {:?}", path.display(), a.title()))?; 276 | 277 | drop(sp); 278 | 279 | println!( 280 | "成功将 {GR}{H2}{}{H2:#}{GR:#} 提交至 {MG}{H1}{} {}{H1:#}{MG:#} 课程作业", 281 | path.display(), 282 | c.meta().name(), 283 | a.title() 284 | ); 285 | 286 | println!("{EM:}tips: 执行 {H2}pku3b a -f ls -a{H2:#} 可强制刷新缓存并查看作业完成状态{EM:#}"); 287 | Ok(()) 288 | } 289 | 290 | fn write_assignment_title_ln( 291 | buf: &mut Vec, 292 | id: &str, 293 | c: &api::Course, 294 | a: &api::CourseAssignment, 295 | ) -> std::io::Result<()> { 296 | write!(buf, "{BL}{B}{}{B:#}{BL:#} {D}>{D:#} ", c.meta().name())?; 297 | write!(buf, "{BL}{B}{}{B:#}{BL:#}", a.title())?; 298 | if let Some(att) = a.last_attempt() { 299 | write!(buf, " ({GR}已完成: {att}{GR:#})")?; 300 | } else if let Some(t) = a.deadline() { 301 | let delta = t - chrono::Local::now(); 302 | write!(buf, " ({})", fmt_time_delta(delta))?; 303 | } else if let Some(raw) = a.deadline_raw() { 304 | write!(buf, " ({})", raw)?; 305 | } else { 306 | write!(buf, " (无截止时间)")?; 307 | } 308 | writeln!(buf, " {D}{}{D:#}", id)?; 309 | Ok(()) 310 | } 311 | 312 | fn write_course_assignment( 313 | buf: &mut Vec, 314 | id: &str, 315 | c: &api::Course, 316 | a: &api::CourseAssignment, 317 | ) -> std::io::Result<()> { 318 | write_assignment_title_ln(buf, id, c, a)?; 319 | 320 | if !a.descriptions().is_empty() { 321 | writeln!(buf)?; 322 | for p in a.descriptions() { 323 | writeln!(buf, "{p}")?; 324 | } 325 | } 326 | if !a.attachments().is_empty() { 327 | writeln!(buf)?; 328 | for (name, _) in a.attachments() { 329 | writeln!(buf, "{D}[附件]{D:#} {UL}{name}{UL:#}")?; 330 | } 331 | } 332 | writeln!(buf)?; 333 | 334 | Ok(()) 335 | } 336 | 337 | pub fn fmt_time_delta(delta: chrono::TimeDelta) -> String { 338 | use utils::style::*; 339 | if delta < chrono::TimeDelta::zero() { 340 | return format!("{RD}due{RD:#}"); 341 | } 342 | 343 | let s = if delta > chrono::TimeDelta::days(1) { 344 | Style::new().fg_color(Some(AnsiColor::Yellow.into())) 345 | } else { 346 | Style::new().fg_color(Some(AnsiColor::Red.into())) 347 | }; 348 | 349 | let mut delta = delta.to_std().unwrap(); 350 | let mut res = String::new(); 351 | res.push_str("in "); 352 | if delta.as_secs() >= 86400 { 353 | res.push_str(&format!("{}d ", delta.as_secs() / 86400)); 354 | delta = std::time::Duration::from_secs(delta.as_secs() % 86400); 355 | } 356 | if delta.as_secs() >= 3600 { 357 | res.push_str(&format!("{}h ", delta.as_secs() / 3600)); 358 | delta = std::time::Duration::from_secs(delta.as_secs() % 3600); 359 | } 360 | if delta.as_secs() >= 60 { 361 | res.push_str(&format!("{}m ", delta.as_secs() / 60)); 362 | delta = std::time::Duration::from_secs(delta.as_secs() % 60); 363 | } 364 | res.push_str(&format!("{}s", delta.as_secs())); 365 | format!("{s}{}{s:#}", res) 366 | } 367 | -------------------------------------------------------------------------------- /src/cli/cmd_video.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | 3 | use super::*; 4 | pub async fn list(force: bool, cur_term: bool) -> anyhow::Result<()> { 5 | let courses = load_courses(force, cur_term).await?; 6 | 7 | let pb = pbar::new(courses.len() as u64); 8 | let futs = courses.into_iter().map(async |c| -> anyhow::Result<_> { 9 | let c = c.get().await.context("fetch course")?; 10 | let vs = c.get_video_list().await.context("fetch video list")?; 11 | pb.inc(1); 12 | Ok((c, vs)) 13 | }); 14 | let courses = try_join_all(futs).await?; 15 | pb.finish_and_clear(); 16 | 17 | let mut outbuf = Vec::new(); 18 | let title = "课程回放"; 19 | 20 | writeln!(outbuf, "{D}>{D:#} {B}{}{B:#} {D}<{D:#}\n", title)?; 21 | 22 | for (c, vs) in courses { 23 | if vs.is_empty() { 24 | continue; 25 | } 26 | 27 | writeln!(outbuf, "{BL}{H1}[{}]{H1:#}{BL:#}\n", c.meta().title())?; 28 | 29 | for v in vs { 30 | writeln!( 31 | outbuf, 32 | "{D}•{D:#} {} ({}) {D}{}{D:#}", 33 | v.meta().title(), 34 | v.meta().time(), 35 | v.id() 36 | )?; 37 | } 38 | 39 | writeln!(outbuf)?; 40 | } 41 | 42 | buf_try!(@try fs::stdout().write_all(outbuf).await); 43 | Ok(()) 44 | } 45 | 46 | pub async fn download(force: bool, id: String, cur_term: bool) -> anyhow::Result<()> { 47 | let (_, courses, sp) = load_client_courses(force, cur_term).await?; 48 | 49 | sp.set_message("finding video..."); 50 | let mut target_video = None; 51 | for c in courses { 52 | let c = c.get().await.context("fetch course")?; 53 | 54 | let vs = c.get_video_list().await?; 55 | for v in vs { 56 | if v.id() == id { 57 | target_video = Some(v); 58 | break; 59 | } 60 | } 61 | 62 | if target_video.is_some() { 63 | break; 64 | } 65 | } 66 | let Some(v) = target_video else { 67 | anyhow::bail!("video with id {} not found", id); 68 | }; 69 | 70 | sp.set_message("fetch video metadata..."); 71 | let v = v.get().await?; 72 | 73 | drop(sp); 74 | 75 | println!("下载课程回放:{} ({})", v.course_name(), v.meta().title()); 76 | 77 | // prepare download dir 78 | let dir = utils::projectdir() 79 | .cache_dir() 80 | .join("video_download") 81 | .join(&id); 82 | fs::create_dir_all(&dir) 83 | .await 84 | .context("create dir failed")?; 85 | 86 | let paths = download_segments(&v, &dir) 87 | .await 88 | .context("download ts segments")?; 89 | 90 | let m3u8 = dir.join("playlist").with_extension("m3u8"); 91 | buf_try!(@try fs::write(&m3u8, v.m3u8_raw()).await); 92 | 93 | // merge all segments into one file 94 | let merged = dir.join("merged").with_extension("ts"); 95 | merge_segments(&merged, &paths).await?; 96 | let dest = format!("{}_{}.mp4", v.course_name(), v.meta().title()); 97 | log::info!("Merged segments to {}", merged.display()); 98 | log::info!( 99 | r#"You may execute `ffmpeg -i "{}" -c copy "{}"` to convert it to mp4"#, 100 | merged.display(), 101 | dest, 102 | ); 103 | 104 | // convert the merged ts file to mp4. overwrite existing file 105 | let sp = pbar::new_spinner(); 106 | sp.set_message("Converting to mp4 file..."); 107 | let c = compio::process::Command::new("ffmpeg") 108 | .args(["-y", "-hide_banner", "-loglevel", "quiet"]) 109 | .args(["-i", merged.to_string_lossy().as_ref()]) 110 | .args(["-c", "copy"]) 111 | .arg(&dest) 112 | .output() 113 | .await 114 | .context("execute ffmpeg")?; 115 | drop(sp); 116 | 117 | if c.status.success() { 118 | println!("下载完成, 文件保存为: {GR}{H2}{}{H2:#}{GR:#}", dest); 119 | } else { 120 | anyhow::bail!("ffmpeg failed with exit code {:?}", c.status.code()); 121 | } 122 | 123 | Ok(()) 124 | } 125 | 126 | async fn download_segments( 127 | v: &api::CourseVideo, 128 | dir: impl AsRef, 129 | ) -> anyhow::Result> { 130 | let dir = dir.as_ref(); 131 | if !dir.exists() { 132 | anyhow::bail!("dir {} not exists", dir.display()); 133 | } 134 | 135 | let tot = v.len_segments(); 136 | let pb = pbar::new(tot as u64).with_prefix("download"); 137 | pb.tick(); 138 | 139 | let mut key = None; 140 | let mut paths = Vec::new(); 141 | // faster than try_join_all 142 | for i in 0..tot { 143 | key = v.refresh_key(i, key); 144 | let path = dir.join(&v.segment(i).uri).with_extension("ts"); 145 | 146 | if !path.exists() { 147 | log::debug!("key: {:?}", key); 148 | let seg = v 149 | .get_segment_data(i, key) 150 | .await 151 | .with_context(|| format!("get segment #{i} with key {key:?}"))?; 152 | 153 | // fs::write is not atomic, so we write to a tmp file first 154 | let tmpath = path.with_extension("tmp"); 155 | buf_try!(@try fs::write(&tmpath, seg).await); 156 | fs::rename(tmpath, &path).await.context("rename tmp file")?; 157 | } 158 | 159 | pb.inc(1); 160 | paths.push(path); 161 | } 162 | pb.finish_and_clear(); 163 | 164 | Ok(paths) 165 | } 166 | 167 | async fn merge_segments( 168 | dest: impl AsRef, 169 | paths: &[std::path::PathBuf], 170 | ) -> anyhow::Result<()> { 171 | let f = fs::File::create(&dest) 172 | .await 173 | .context("create merged file failed")?; 174 | let mut f = std::io::Cursor::new(f); 175 | 176 | let pb = pbar::new(paths.len() as u64).with_prefix("merge segments"); 177 | pb.tick(); 178 | for p in paths { 179 | let data = fs::read(p).await.context("read segments failed")?; 180 | buf_try!(@try f.write(data).await); 181 | pb.inc(1); 182 | } 183 | pb.finish_and_clear(); 184 | 185 | Ok(()) 186 | } 187 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | mod cmd_assignment; 2 | mod cmd_video; 3 | mod pbar; 4 | 5 | use crate::{api, build, config, utils, walkdir}; 6 | use anyhow::Context as _; 7 | use clap::{ 8 | CommandFactory, Parser, Subcommand, 9 | builder::styling::{AnsiColor, Style}, 10 | }; 11 | use compio::{ 12 | buf::buf_try, 13 | fs, 14 | io::{AsyncWrite, AsyncWriteExt}, 15 | }; 16 | use futures_util::{StreamExt, future::try_join_all}; 17 | use std::io::Write as _; 18 | use utils::style::*; 19 | 20 | #[derive(Parser)] 21 | #[command( 22 | version, 23 | long_version(shadow_rs::formatcp!( 24 | "{}\nbuild_time: {}\nbuild_env: {}, {}\nbuild_target: {} (on {})", 25 | build::PKG_VERSION, build::BUILD_TIME, build::RUST_VERSION, build::RUST_CHANNEL, 26 | build::BUILD_TARGET, build::BUILD_OS 27 | )), 28 | author, 29 | about, 30 | long_about = "a Better BlackBoard for PKUers. 北京大学教学网命令行工具 (️Win/Linux/Mac), 支持查看/提交作业、下载课程回放." 31 | )] 32 | pub struct Cli { 33 | #[command(subcommand)] 34 | command: Option, 35 | } 36 | 37 | #[derive(Subcommand)] 38 | enum Commands { 39 | /// 获取课程作业信息/下载附件/提交作业 40 | #[command(visible_alias("a"), arg_required_else_help(true))] 41 | Assignment { 42 | /// 强制刷新 43 | #[arg(short, long, default_value = "false")] 44 | force: bool, 45 | 46 | #[command(subcommand)] 47 | command: AssignmentCommands, 48 | }, 49 | 50 | /// 获取课程回放/下载课程回放 51 | #[command(visible_alias("v"), arg_required_else_help(true))] 52 | Video { 53 | /// 强制刷新 54 | #[arg(short, long, default_value = "false")] 55 | force: bool, 56 | 57 | #[command(subcommand)] 58 | command: VideoCommands, 59 | }, 60 | 61 | /// (重新) 初始化配置选项 62 | Init, 63 | 64 | /// 显示或修改配置项 65 | Config { 66 | // 属性名称 67 | attr: Option, 68 | /// 属性值 69 | value: Option, 70 | }, 71 | 72 | /// 查看缓存大小/清除缓存 73 | Cache { 74 | #[command(subcommand)] 75 | command: Option, 76 | }, 77 | 78 | #[cfg(feature = "dev")] 79 | #[command(hide(true))] 80 | Debug, 81 | } 82 | 83 | #[derive(Subcommand)] 84 | enum VideoCommands { 85 | /// 获取课程回放列表 86 | #[command(visible_alias("ls"))] 87 | List { 88 | /// 显示所有学期的课程回放 89 | #[arg(long, default_value = "false")] 90 | all_term: bool, 91 | }, 92 | 93 | /// 下载课程回放视频 (MP4 格式),支持断点续传 94 | #[command(visible_alias("down"))] 95 | Download { 96 | /// 课程回放 ID (形如 `e780808c9eb81f61`, 可通过 `pku3b video list` 查看) 97 | id: String, 98 | /// 在所有学期的课程回放范围中查找 99 | #[arg(long, default_value = "false")] 100 | all_term: bool, 101 | }, 102 | } 103 | 104 | #[derive(Subcommand)] 105 | enum CacheCommands { 106 | /// 查看缓存大小 107 | Show, 108 | /// 清除缓存 109 | Clean, 110 | } 111 | 112 | #[derive(Subcommand)] 113 | enum AssignmentCommands { 114 | /// 查看作业列表,按照截止日期排序 115 | #[command(visible_alias("ls"))] 116 | List { 117 | /// 显示所有作业,包括已完成的 118 | #[arg(short, long, default_value = "false")] 119 | all: bool, 120 | /// 显示所有学期的作业(包括已完成的) 121 | #[arg(long, default_value = "false")] 122 | all_term: bool, 123 | }, 124 | /// 下载作业要求和附件到指定文件夹下 125 | /// 126 | /// 如果没有指定作业 ID,则会启用交互式模式,列出所有作业供用户选择 127 | #[command(visible_alias("down"))] 128 | Download { 129 | /// (Optionl) 作业 ID (ID 形如 `f4f30444c7485d49`, 可通过 `pku3b assignment list` 查看) 130 | #[arg(group = "download-type")] 131 | id: Option, 132 | /// 文件下载目录 (支持相对路径) 133 | #[arg(short, long, default_value = ".")] 134 | dir: std::path::PathBuf, 135 | /// 在所有学期的作业范围中查找 136 | #[arg(long, default_value = "false")] 137 | all_term: bool, 138 | }, 139 | /// 提交课程作业 140 | /// 141 | /// 如果没有指定作业 ID,则会启用交互式模式,列出所有作业供用户选择 142 | /// 143 | /// 如果没有指定文件路径,则会启用交互式模式,列出当前工作目录下所有文件供用户选择 144 | #[command(visible_alias("sb"))] 145 | Submit { 146 | /// 作业 ID (形如 `f4f30444c7485d49`, 可通过 `pku3b assignment list` 查看) 147 | id: Option, 148 | /// 提交文件路径 149 | path: Option, 150 | }, 151 | } 152 | 153 | /// Client, courses and spinner are returned. Spinner hasn't stopped. 154 | async fn load_client_courses( 155 | force: bool, 156 | only_current: bool, 157 | ) -> anyhow::Result<(api::Client, Vec, pbar::AsyncSpinner)> { 158 | let client = if force { 159 | api::Client::new_nocache() 160 | } else { 161 | api::Client::default() 162 | }; 163 | 164 | let sp = pbar::new_spinner(); 165 | 166 | sp.set_message("reading config..."); 167 | let cfg_path = utils::default_config_path(); 168 | let cfg = config::read_cfg(cfg_path) 169 | .await 170 | .context("read config file")?; 171 | 172 | sp.set_message("logging in to blackboard..."); 173 | let blackboard = client 174 | .blackboard(&cfg.username, &cfg.password) 175 | .await 176 | .context("login to blackboard")?; 177 | 178 | sp.set_message("fetching courses..."); 179 | let courses = blackboard 180 | .get_courses(only_current) 181 | .await 182 | .context("fetch course handles")?; 183 | 184 | Ok((client, courses, sp)) 185 | } 186 | 187 | async fn load_courses(force: bool, only_current: bool) -> anyhow::Result> { 188 | let (_, r, _) = load_client_courses(force, only_current).await?; 189 | Ok(r) 190 | } 191 | 192 | async fn command_config( 193 | attr: Option, 194 | value: Option, 195 | ) -> anyhow::Result<()> { 196 | let cfg_path = utils::default_config_path(); 197 | log::info!("Config path: '{}'", cfg_path.display()); 198 | let mut cfg = match config::read_cfg(&cfg_path).await { 199 | Ok(r) => r, 200 | Err(e) => { 201 | anyhow::bail!("fail to read config: {e} (hint: run `pku3b init` to initialize it)") 202 | } 203 | }; 204 | 205 | let Some(attr) = attr else { 206 | let s = toml::to_string_pretty(&cfg)?; 207 | println!("{}", s); 208 | return Ok(()); 209 | }; 210 | 211 | if let Some(value) = value { 212 | cfg.update(attr, value)?; 213 | config::write_cfg(&cfg_path, &cfg).await?; 214 | } else { 215 | let mut buf = Vec::new(); 216 | cfg.display(attr, &mut buf)?; 217 | buf_try!(@try fs::stdout().write_all(buf).await); 218 | } 219 | Ok(()) 220 | } 221 | 222 | async fn command_init() -> anyhow::Result<()> { 223 | let cfg_path = utils::default_config_path(); 224 | 225 | let username = inquire::Text::new("Enter PKU IAAA Username (ID):").prompt()?; 226 | let password = inquire::Password::new("Enter PKU IAAA Password:").prompt()?; 227 | 228 | let cfg = config::Config { username, password }; 229 | config::write_cfg(&cfg_path, &cfg).await?; 230 | 231 | println!("Configuration initialized."); 232 | Ok(()) 233 | } 234 | 235 | async fn command_cache_clean(dry_run: bool) -> anyhow::Result<()> { 236 | let dir = utils::projectdir(); 237 | log::info!("Cache dir: '{}'", dir.cache_dir().display()); 238 | let sp = pbar::new_spinner(); 239 | sp.set_message("scanning cache dir..."); 240 | 241 | let mut total_bytes = 0; 242 | if dir.cache_dir().exists() { 243 | let d = std::fs::read_dir(dir.cache_dir())?; 244 | 245 | let mut s = walkdir::walkdir(d, false); 246 | while let Some(e) = s.next().await { 247 | let e = e?; 248 | #[cfg(unix)] 249 | let s = { 250 | use std::os::unix::fs::MetadataExt; 251 | e.metadata()?.size() 252 | }; 253 | #[cfg(windows)] 254 | let s = { 255 | use std::os::windows::fs::MetadataExt; 256 | e.metadata()?.file_size() 257 | }; 258 | total_bytes += s; 259 | } 260 | 261 | if !dry_run { 262 | std::fs::remove_dir_all(dir.cache_dir())?; 263 | } 264 | } 265 | drop(sp); 266 | 267 | let sizenum = total_bytes as f64 / 1024.0f64.powi(3); 268 | if dry_run { 269 | println!("缓存大小: {B}{:.2}GB{B:#}", sizenum); 270 | } else { 271 | println!("缓存已清空 (释放 {B}{:.2}GB{B:#})", sizenum); 272 | } 273 | Ok(()) 274 | } 275 | 276 | pub async fn start(cli: Cli) -> anyhow::Result<()> { 277 | if let Some(command) = cli.command { 278 | match command { 279 | Commands::Config { attr, value } => command_config(attr, value).await?, 280 | Commands::Init => command_init().await?, 281 | Commands::Cache { command } => { 282 | if let Some(command) = command { 283 | match command { 284 | CacheCommands::Clean => command_cache_clean(false).await?, 285 | CacheCommands::Show => command_cache_clean(true).await?, 286 | } 287 | } else { 288 | command_cache_clean(true).await? 289 | } 290 | } 291 | Commands::Assignment { force, command } => match command { 292 | AssignmentCommands::List { all, all_term } => { 293 | cmd_assignment::list(force, all || all_term, !all_term).await? 294 | } 295 | AssignmentCommands::Download { id, dir, all_term } => { 296 | cmd_assignment::download(id.as_deref(), &dir, force, all_term, !all_term) 297 | .await? 298 | } 299 | AssignmentCommands::Submit { id, path } => { 300 | cmd_assignment::submit(id.as_deref(), path.as_deref()).await? 301 | } 302 | }, 303 | Commands::Video { force, command } => match command { 304 | VideoCommands::List { all_term } => cmd_video::list(force, !all_term).await?, 305 | VideoCommands::Download { id, all_term } => { 306 | cmd_video::download(force, id, !all_term).await? 307 | } 308 | }, 309 | 310 | #[cfg(feature = "dev")] 311 | Commands::Debug => command_debug().await?, 312 | } 313 | } else { 314 | Cli::command().print_help()?; 315 | } 316 | 317 | Ok(()) 318 | } 319 | 320 | #[cfg(feature = "dev")] 321 | async fn command_debug() -> anyhow::Result<()> { 322 | Ok(()) 323 | } 324 | -------------------------------------------------------------------------------- /src/cli/pbar.rs: -------------------------------------------------------------------------------- 1 | use indicatif::{ProgressBar, ProgressStyle, WeakProgressBar}; 2 | struct TickerHandle { 3 | #[allow(dead_code)] 4 | handle: compio::runtime::JoinHandle<()>, 5 | } 6 | 7 | fn spawn_pb_ticker(pb: WeakProgressBar, interval: std::time::Duration) -> TickerHandle { 8 | let h = compio::runtime::spawn(async move { 9 | while let Some(pb) = pb.upgrade() { 10 | pb.tick(); 11 | compio::time::sleep(interval).await; 12 | } 13 | }); 14 | 15 | TickerHandle { handle: h } 16 | } 17 | 18 | fn pb_style() -> ProgressStyle { 19 | // a trailing space is left for the cursor 20 | ProgressStyle::with_template( 21 | "{prefix} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} ", 22 | ) 23 | .unwrap() 24 | .progress_chars("=> ") 25 | } 26 | 27 | /// Progress bar that ticks asynchronously 28 | pub struct AsyncSpinner { 29 | pb: ProgressBar, 30 | #[allow(dead_code)] 31 | ticker: TickerHandle, 32 | } 33 | 34 | impl std::ops::Deref for AsyncSpinner { 35 | type Target = ProgressBar; 36 | fn deref(&self) -> &Self::Target { 37 | &self.pb 38 | } 39 | } 40 | 41 | /// Create a new spinner with a default style 42 | pub fn new_spinner() -> AsyncSpinner { 43 | let pb = ProgressBar::new_spinner(); 44 | let w = pb.downgrade(); 45 | let ticker = spawn_pb_ticker(w, std::time::Duration::from_millis(100)); 46 | AsyncSpinner { pb, ticker } 47 | } 48 | 49 | /// Create a new progress bar with a given length and a default style 50 | pub fn new(pb_len: u64) -> ProgressBar { 51 | let pb = ProgressBar::new(pb_len); 52 | pb.set_style(pb_style()); 53 | pb 54 | } 55 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use compio::fs; 2 | 3 | #[derive(serde::Deserialize, serde::Serialize)] 4 | pub struct Config { 5 | pub username: String, 6 | pub password: String, 7 | } 8 | 9 | impl Config { 10 | pub fn display(&self, attr: ConfigAttrs, buf: &mut Vec) -> anyhow::Result<()> { 11 | use std::io::Write as _; 12 | match attr { 13 | ConfigAttrs::Username => writeln!(buf, "{}", self.username)?, 14 | ConfigAttrs::Password => writeln!(buf, "{}", self.password)?, 15 | }; 16 | Ok(()) 17 | } 18 | 19 | pub fn update(&mut self, attr: ConfigAttrs, value: String) -> anyhow::Result<()> { 20 | match attr { 21 | ConfigAttrs::Username => self.username = value, 22 | ConfigAttrs::Password => self.password = value, 23 | } 24 | 25 | Ok(()) 26 | } 27 | } 28 | 29 | #[derive(Debug, Clone)] 30 | pub enum ConfigAttrs { 31 | Username, 32 | Password, 33 | } 34 | 35 | impl clap::ValueEnum for ConfigAttrs { 36 | fn value_variants<'a>() -> &'a [Self] { 37 | &[Self::Username, Self::Password] 38 | } 39 | 40 | fn to_possible_value(&self) -> Option { 41 | match self { 42 | Self::Username => Some(clap::builder::PossibleValue::new("username")), 43 | Self::Password => Some(clap::builder::PossibleValue::new("password")), 44 | } 45 | } 46 | } 47 | 48 | /// Reads the configuration from the specified file path asynchronously. 49 | /// 50 | /// # Errors 51 | /// 52 | /// This function will return an error if: 53 | /// - The file does not exist. 54 | /// - The file cannot be opened. 55 | /// - The file contents cannot be read. 56 | /// - The file contents cannot be parsed as TOML. 57 | /// 58 | pub async fn read_cfg(path: impl AsRef) -> anyhow::Result { 59 | let path = path.as_ref(); 60 | 61 | if !path.exists() { 62 | anyhow::bail!("file not found"); 63 | } 64 | 65 | let buffer = fs::read(path).await?; 66 | let content = String::from_utf8(buffer)?; //.context("invalid UTF-8")?; 67 | let cfg: Config = toml::from_str(&content)?; 68 | 69 | Ok(cfg) 70 | } 71 | 72 | pub async fn write_cfg(path: impl AsRef, cfg: &Config) -> anyhow::Result<()> { 73 | let path = path.as_ref(); 74 | // Create the parent directory if it does not exist 75 | if let Some(par) = path.parent() { 76 | if !par.exists() { 77 | fs::create_dir_all(par).await?; 78 | } 79 | } 80 | 81 | let content = toml::to_string(cfg)?; 82 | fs::write(path, content).await.0?; 83 | Ok(()) 84 | } 85 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | extern crate directories as dirs; 2 | 3 | mod api; 4 | mod cli; 5 | mod config; 6 | mod multipart; 7 | mod qs; 8 | mod utils; 9 | mod walkdir; 10 | 11 | use shadow_rs::shadow; 12 | shadow!(build); 13 | 14 | use clap::Parser as _; 15 | 16 | #[compio::main] 17 | async fn main() { 18 | rustls::crypto::aws_lc_rs::default_provider() 19 | .install_default() 20 | .unwrap(); 21 | 22 | #[cfg(not(hyper_unstable_tracing))] 23 | { 24 | env_logger::builder() 25 | .filter_module("selectors::matching", log::LevelFilter::Info) 26 | .filter_module("html5ever::tokenizer", log::LevelFilter::Info) 27 | .filter_module("html5ever::tree_builder", log::LevelFilter::Info) 28 | .init(); 29 | } 30 | 31 | #[cfg(hyper_unstable_tracing)] 32 | { 33 | tracing_subscriber::fmt::init(); 34 | } 35 | 36 | log::debug!("logger initialized..."); 37 | 38 | let cli = cli::Cli::parse(); 39 | 40 | match cli::start(cli).await { 41 | Ok(r) => r, 42 | Err(e) => { 43 | use utils::style::*; 44 | eprintln!("{RD}{B}Error{B:#}{RD:#}: {e:#}"); 45 | std::process::exit(1); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/multipart.rs: -------------------------------------------------------------------------------- 1 | use rand::{Rng, distr::Alphanumeric}; 2 | use std::io::Read; 3 | 4 | /// 结构化的表单字段 5 | pub struct FormField<'a> { 6 | name: &'a str, 7 | filename: Option<&'a str>, 8 | content_type: Option<&'a str>, 9 | reader: Option>, 10 | data: Option<&'a [u8]>, 11 | } 12 | 13 | /// Multipart 表单构造器 14 | pub struct MultipartBuilder<'a> { 15 | boundary: String, 16 | fields: Vec>, 17 | } 18 | 19 | impl<'a> MultipartBuilder<'a> { 20 | /// 创建一个新的 MultipartBuilder 21 | pub fn new() -> Self { 22 | let boundary: String = rand::rng() 23 | .sample_iter(&Alphanumeric) 24 | .take(16) 25 | .map(char::from) 26 | .collect(); 27 | 28 | Self { 29 | boundary: format!("----WebKitFormBoundary{}", boundary), 30 | fields: Vec::new(), 31 | } 32 | } 33 | 34 | /// 添加一个普通字段 35 | pub fn add_field(mut self, name: &'a str, data: &'a [u8]) -> Self { 36 | self.fields.push(FormField { 37 | name, 38 | filename: None, 39 | content_type: None, 40 | reader: None, 41 | data: Some(data), 42 | }); 43 | self 44 | } 45 | 46 | /// 添加一个带文件名的字段 47 | pub fn add_file( 48 | mut self, 49 | name: &'a str, 50 | filename: &'a str, 51 | content_type: &'a str, 52 | reader: R, 53 | ) -> Self { 54 | self.fields.push(FormField { 55 | name, 56 | filename: Some(filename), 57 | content_type: Some(content_type), 58 | reader: Some(Box::new(reader)), 59 | data: None, 60 | }); 61 | self 62 | } 63 | 64 | /// 构建 multipart/form-data body 65 | pub fn build(mut self) -> anyhow::Result> { 66 | let mut output = Vec::new(); 67 | let dash_boundary = format!("--{}", self.boundary); 68 | 69 | for field in &mut self.fields { 70 | output.extend_from_slice(dash_boundary.as_bytes()); 71 | output.extend_from_slice(b"\r\n"); 72 | 73 | // Content-Disposition 74 | output.extend_from_slice( 75 | format!("Content-Disposition: form-data; name=\"{}\"", field.name).as_bytes(), 76 | ); 77 | if let Some(filename) = field.filename { 78 | output.extend_from_slice(format!("; filename=\"{}\"", filename).as_bytes()); 79 | } 80 | output.extend_from_slice(b"\r\n"); 81 | 82 | // Content-Type (optional) 83 | if let Some(content_type) = field.content_type { 84 | output.extend_from_slice(format!("Content-Type: {}\r\n", content_type).as_bytes()); 85 | } 86 | output.extend_from_slice(b"\r\n"); 87 | 88 | // 读取数据 89 | if let Some(data) = field.data { 90 | output.extend_from_slice(data); 91 | } else if let Some(reader) = field.reader.as_mut() { 92 | std::io::copy(reader, &mut output)?; 93 | } 94 | output.extend_from_slice(b"\r\n"); 95 | } 96 | 97 | // 结束 boundary 98 | output.extend_from_slice(dash_boundary.as_bytes()); 99 | output.extend_from_slice(b"--\r\n"); 100 | 101 | Ok(output) 102 | } 103 | 104 | /// 获取 boundary 字符串 105 | pub fn boundary(&self) -> &str { 106 | &self.boundary 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use super::*; 113 | use std::io::Cursor; 114 | 115 | #[test] 116 | fn test_multipart_builder() { 117 | let file_content = b"File content"; 118 | let file_reader = Cursor::new(file_content); 119 | 120 | let builder = MultipartBuilder::new() 121 | .add_field("field1", b"Hello, world!") 122 | .add_file("field2", "file.txt", "text/plain", file_reader); 123 | 124 | let body = builder.build().unwrap(); 125 | let body_str = String::from_utf8(body.to_vec()).unwrap(); 126 | 127 | assert!(body_str.contains("Content-Disposition: form-data; name=\"field1\"")); 128 | assert!(body_str.contains("Hello, world!")); 129 | assert!( 130 | body_str 131 | .contains("Content-Disposition: form-data; name=\"field2\"; filename=\"file.txt\"") 132 | ); 133 | assert!(body_str.contains("Content-Type: text/plain")); 134 | assert!(body_str.contains("File content")); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/qs.rs: -------------------------------------------------------------------------------- 1 | //! Simple query string parser. 2 | 3 | use std::str::FromStr; 4 | 5 | pub struct Query { 6 | qs: Vec, 7 | } 8 | 9 | impl Query { 10 | pub fn get(&self, key: &str) -> Option<&str> { 11 | self.qs 12 | .iter() 13 | .find(|&s| s.starts_with(key)) 14 | .map(|s| s.split_at(key.len() + 1).1) 15 | } 16 | } 17 | 18 | impl FromStr for Query { 19 | type Err = ::Err; 20 | 21 | fn from_str(s: &str) -> Result { 22 | let uri = http::Uri::from_str(s)?; 23 | let qs = uri 24 | .query() 25 | .map(|q| q.split('&').map(ToOwned::to_owned).collect::>()) 26 | .unwrap_or_default(); 27 | Ok(Self { qs }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use compio::{buf::buf_try, fs, io::AsyncReadAtExt}; 2 | 3 | pub mod style { 4 | use clap::builder::styling::{AnsiColor, Color, Style}; 5 | 6 | pub const D: Style = Style::new().dimmed(); 7 | pub const B: Style = Style::new().bold(); 8 | pub const H1: Style = Style::new().bold().underline(); 9 | pub const H2: Style = UL; 10 | pub const UL: Style = Style::new().underline(); 11 | pub const EM: Style = Style::new().italic(); 12 | pub const GR: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green))); 13 | pub const MG: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightMagenta))); 14 | pub const BL: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan))); 15 | pub const RD: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red))); 16 | } 17 | 18 | pub fn projectdir() -> dirs::ProjectDirs { 19 | dirs::ProjectDirs::from("org", "sshwy", "pku3b").expect("could not find project directories") 20 | } 21 | 22 | pub fn default_config_path() -> std::path::PathBuf { 23 | crate::utils::projectdir().config_dir().join("cfg.toml") 24 | } 25 | 26 | /// If the cache file exists and is not expired, return the deserialized content. 27 | /// Otherwise, execute the future, serialize the result to the cache file, and return the result. 28 | pub async fn with_cache( 29 | name: &str, 30 | ttl: Option<&std::time::Duration>, 31 | fut: F, 32 | ) -> anyhow::Result 33 | where 34 | F: std::future::Future>, 35 | T: serde::de::DeserializeOwned + serde::Serialize + 'static, 36 | { 37 | let name_hash = { 38 | use std::hash::{Hash, Hasher}; 39 | let mut hasher = std::collections::hash_map::DefaultHasher::new(); 40 | name.hash(&mut hasher); 41 | let type_id = std::any::TypeId::of::(); 42 | type_id.hash(&mut hasher); 43 | hasher.finish() 44 | }; 45 | let name = format!("with_cache-{:x}", name_hash); 46 | 47 | let path = &projectdir().cache_dir().join(&name); 48 | 49 | if let Ok(f) = fs::File::open(path).await { 50 | if let Some(ttl) = ttl { 51 | if f.metadata().await?.modified()?.elapsed()? < *ttl { 52 | let r = f.read_to_end_at(Vec::new(), 0).await; 53 | let (_, buf) = buf_try!(@try r); 54 | // ignore deserialization error 55 | if let Ok(r) = serde_json::from_slice(&buf) { 56 | log::trace!("cache hit: {}", name); 57 | return Ok(r); 58 | } 59 | } 60 | } 61 | } 62 | 63 | let r = fut.await?; 64 | fs::create_dir_all(path.parent().unwrap()).await?; 65 | let buf = serde_json::to_vec(&r)?; 66 | buf_try!(@try fs::write(path, buf).await); 67 | 68 | Ok(r) 69 | } 70 | 71 | pub async fn with_cache_bytes( 72 | name: &str, 73 | ttl: Option<&std::time::Duration>, 74 | fut: F, 75 | ) -> anyhow::Result 76 | where 77 | F: std::future::Future>, 78 | { 79 | let name_hash = { 80 | use std::hash::{Hash, Hasher}; 81 | let mut hasher = std::collections::hash_map::DefaultHasher::new(); 82 | name.hash(&mut hasher); 83 | hasher.finish() 84 | }; 85 | let name = format!("with_cache_bytes-{:x}", name_hash); 86 | 87 | let path = &projectdir().cache_dir().join(&name); 88 | 89 | if let Ok(f) = fs::File::open(path).await { 90 | if let Some(ttl) = ttl { 91 | if f.metadata().await?.modified()?.elapsed()? < *ttl { 92 | let r = f.read_to_end_at(Vec::new(), 0).await; 93 | let (_, buf) = buf_try!(@try r); 94 | log::trace!("cache hit: {}", name); 95 | return Ok(bytes::Bytes::from(buf)); 96 | } 97 | } 98 | } 99 | 100 | let r = fut.await?; 101 | fs::create_dir_all(path.parent().unwrap()).await?; 102 | let (_, r) = buf_try!(@try fs::write(path, r).await); 103 | 104 | Ok(r) 105 | } 106 | -------------------------------------------------------------------------------- /src/walkdir.rs: -------------------------------------------------------------------------------- 1 | use std::collections::LinkedList; 2 | use std::fs::{DirEntry, ReadDir}; 3 | 4 | type Item = Result; 5 | pub struct Iter { 6 | /// in post-order traversal, we also store the dir entry in the stack. 7 | stack: LinkedList<(ReadDir, Option)>, 8 | /// preorder or postorder 9 | preorder: bool, 10 | } 11 | 12 | impl Iterator for Iter { 13 | type Item = Item; 14 | 15 | fn next(&mut self) -> Option { 16 | fn try_read_dir(e: &Item) -> Option { 17 | if let Ok(e) = &e { 18 | let path = e.path(); 19 | if path.is_dir() { 20 | // if the directory is readable, push it to the stack. 21 | // otherwise, ignore it 22 | if let Ok(dir) = path.read_dir() { 23 | return Some(dir); 24 | } 25 | } 26 | } 27 | None 28 | } 29 | while let Some((d, _)) = self.stack.back_mut() { 30 | if let Some(e) = d.next() { 31 | // if the entry is a readable directory, push it to the stack. 32 | // otherwise it is considered as a leafy entry. 33 | if let Some(dir) = try_read_dir(&e) { 34 | if self.preorder { 35 | self.stack.push_back((dir, None)); 36 | return Some(e); 37 | } else { 38 | // e is a directory, which is not the item we want to return. 39 | // so we store it and get the next item recursively. 40 | self.stack.push_back((dir, Some(e))); 41 | return self.next(); 42 | } 43 | } 44 | 45 | return Some(e); 46 | } 47 | 48 | // no more entries in this directory, so we drop d 49 | if let Some((_, Some(e))) = self.stack.pop_back() { 50 | // we only store the directory entry in post-order traversal 51 | assert!(!self.preorder); 52 | return Some(e); 53 | } 54 | } 55 | // no more directories in the stack 56 | None 57 | } 58 | } 59 | 60 | impl futures_util::Stream for Iter { 61 | type Item = Item; 62 | 63 | fn poll_next( 64 | mut self: std::pin::Pin<&mut Self>, 65 | _: &mut std::task::Context<'_>, 66 | ) -> std::task::Poll> { 67 | std::task::Poll::Ready(self.next()) 68 | } 69 | } 70 | 71 | pub fn walkdir(dir: ReadDir, preorder: bool) -> futures_util::stream::Iter { 72 | futures_util::stream::iter(Iter { 73 | stack: LinkedList::from([(dir, None)]), 74 | preorder, 75 | }) 76 | } 77 | --------------------------------------------------------------------------------