├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── gen_caption.py ├── gui.py ├── hooks ├── hook-whisper.py └── hook-zhconv.py ├── m3u8dl.py ├── main.py ├── md └── README │ ├── image-20230926124922726.png │ ├── image-20231018204208066.png │ ├── image-20240409095211597.png │ ├── image-20240409095831766.png │ ├── image-20240409105228362.png │ ├── image-20240409131033038.png │ ├── image-20240413001454717.png │ ├── image-20240413001734218.png │ ├── image-20240413002004628.png │ ├── image-20240413002242979.png │ ├── image-20240529171253980.png │ ├── image-20240529171540279.png │ ├── image-20240529171709402.png │ ├── image-20240809182344017.png │ ├── image-20240809182406184.png │ ├── image-20240809182413373.png │ ├── image-20240809182420653.png │ └── image-20240809183350633.png ├── requirements-whisper.txt ├── requirements.txt ├── templates └── index.html ├── utils.py ├── webui ├── script.js └── styles.css ├── webui_interface.py ├── yhkt.ico └── 项目详解.md /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | output/ 3 | old/ 4 | *.zip 5 | *.exe 6 | build/ 7 | dist/ 8 | *.spec 9 | whisper_models/ 10 | release_*/ 11 | *.json 12 | .idea 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | output/ 3 | old/ 4 | *.zip 5 | *.exe 6 | build/ 7 | dist/ 8 | *.spec 9 | whisper_models/ 10 | release_*/ 11 | *.json 12 | .idea 13 | .DS_Store 14 | auth.txt 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | WORKDIR /app 4 | 5 | # 创建新的sources.list 6 | RUN echo "deb http://mirrors.ustc.edu.cn/debian/ buster main contrib non-free" > /etc/apt/sources.list && \ 7 | echo "deb http://mirrors.ustc.edu.cn/debian/ buster-updates main contrib non-free" >> /etc/apt/sources.list && \ 8 | echo "deb http://mirrors.ustc.edu.cn/debian-security buster/updates main contrib non-free" >> /etc/apt/sources.list 9 | 10 | 11 | RUN apt-get update && \ 12 | apt-get install -y ffmpeg && \ 13 | rm -rf /var/lib/apt/lists/* 14 | 15 | COPY . /app 16 | 17 | 18 | RUN pip install Flask requests 19 | 20 | EXPOSE 5001 21 | 22 | VOLUME /app/output 23 | 24 | CMD ["python", "webui_interface.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 AuYang261 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BIT_yanhe_download 2 | 3 | ## 介绍 4 | 5 | 本项目可下载[延河课堂 (yanhekt.cn)](https://www.yanhekt.cn/recordCourse)中的课程视频。延河课堂是北京理工大学的在线课堂,提供了大量的课程视频,但是没有提供下载功能。本项目可以下载指定课程的摄像头和屏幕信号,包括无权限的课程。 6 | 7 | 项目详细报告见[项目详解](./项目详解.md),仅供参考。 8 | 9 | 欢迎提出建议和 star! 10 | 11 | ## 使用:下载指定课程 12 | 13 | [点击此处下载](https://github.com/AuYang261/BIT_yanhe_download/releases/latest/download/release_downloader.zip)并解压。 14 | 15 | 在[延河课堂 (yanhekt.cn)](https://www.yanhekt.cn/recordCourse)中找到想下载的课程,以链接为 `https://www.yanhekt.cn/course/40524 `的课程为例,复制地址栏最后的五位编号 40524。注意是课程列表的链接(以 `yanhekt.cn/course/五位编号 `开头),不是视频界面的链接(以 `yanhekt.cn/session/六位编号`开头)。 16 | 17 | ![image-20231018204208066](md/README/image-20231018204208066.png) 18 | 19 | ### 登录延河课堂 20 | 21 | 新版的延河课堂要求登录才能查看课程列表,故需要先自行登录延河课堂。登录后,在延河课堂的页面的地址栏输入如下代码(注意,浏览器会自动去掉前缀"javascript:",故直接复制粘贴后需手动补上): 22 | 23 | ``` 24 | javascript:alert(JSON.parse(localStorage.auth).token) 25 | ``` 26 | 27 | ![image-20240809182406184](md/README/image-20240809182406184.png) 28 | 29 | 回车后会弹出提示框,复制该身份认证码。 30 | 31 | ![image-20240809182413373](md/README/image-20240809182413373.png) 32 | 33 | 或者可以按 `F12`键打开”控制台“,在其中输入上述代码,也能得到身份认证码。 34 | 35 | ### 网页 GUI 交互 36 | 37 | 双击运行 `webui_interface.exe` 文件打开网页服务器,会自动弹出浏览器网页。 38 | 39 | 而后在打开的网页中新建任务即可。 40 | 41 | 下载类型可选摄像头(即教室后的摄像头录像)或电脑屏幕(即教室电脑的屏幕信号)。 42 | 43 | 可以选择是否下载教室蓝牙话筒信号(该课程有蓝牙话筒信号时有效),若老师未使用蓝牙话筒则该信号没有声音。 44 | 45 | ![image-20240529171709402](md/README/image-20240529171709402.png) 46 | 47 | 首次使用或之前的登录失效时,需要输入上述获取的身份认证码。 48 | 49 | 若之前使用过本工具(包括其他交互方式),登录未失效,身份认证码会自动保存,无需每次都填写。 50 | 51 | ![image-20240809182420653](md/README/image-20240809182420653.png) 52 | 53 | 下载完成的文件在 `output/`目录下以 `课程名-video/screen`格式命名的文件夹中。若下载了蓝牙音频则保存在和视频同目录同名的 `.aac`文件中。 54 | 55 | ![image-20230926124922726](md/README/image-20230926124922726.png) 56 | 57 | ### 命令行 GUI 交互 58 | 59 | 打开命令行(在 `release_downloader.zip `解压的文件夹地址栏中搜索 cmd),在命令行中输入 `gui.exe` 文件运行。直接双击运行可能会有字符对不齐的问题,导致难以识别文字。最好将命令行窗口最大化以免字符显示不全。 60 | 61 | ![image-20240413001454717](md/README/image-20240413001454717.png) 62 | 63 | 首先输入你想下载的课程编号(40524),回车(小键盘的回车似乎不能用),获取课程视频列表: 64 | 65 | ![image-20240413001734218](md/README/image-20240413001734218.png) 66 | 67 | 同样,首次使用或之前的登录失效时,需要输入上述获取的身份认证码;登录未失效则不用。 68 | 69 | ![image-20240809183350633](md/README/image-20240809183350633.png) 70 | 71 | image-20240413002004628 72 | 73 | 按键盘上下键移动光标,按空格选择/取消选择,至少需要选择一个视频。选择完成后按回车确认。若想退出按 q 键即可。 74 | 75 | 确认后,选择要下载的信号,同样至少需要选择一个信号,选择完成后按回车确认。 76 | 77 | ![image-20240413002242979](md/README/image-20240413002242979.png) 78 | 79 | 而后选择是否下载教室蓝牙话筒信号,选择完成后按回车确认。开始下载。按 `ctrl+c`停止。 80 | 81 | ![image-20240529171253980](md/README/image-20240529171253980.png) 82 | 83 | ### 原始交互方式 84 | 85 | 若使用上述 GUI 显示有问题,可直接使用原始交互方式。双击运行 `main.exe` 文件,并输入你想下载的课程编号(40524)和身份认证码(如果需要)。输出课程视频列表: 86 | 87 | ![image-20240529171540279](md/README/image-20240529171540279.png) 88 | 89 | 输入想下载的视频编号,用英文逗号(,)分隔,回车。接着输入数字选择下载摄像头信号还是下载屏幕信号,默认为摄像头信号。而后选择是否下载蓝牙话筒信号。回车即开始下载。 90 | 91 | ## 自动生成字幕 92 | 93 | 本项目提供自动生成字幕功能,使用 openai 的[whisper](https://github.com/openai/whisper)项目及其模型在本地进行语音转文字生成字幕。 94 | 95 | 最好使用 GPU 运行,否则速度较慢,依赖见[下文](#依赖)。 96 | 97 | 下载[字幕生成程序 gen_caption](https://github.com/AuYang261/BIT_yanhe_download/releases/tag/v2.0),由于程序比较大,采用了分卷压缩发布。全部下载并解压,得到一个 `gen_caption.exe `可执行文件,保存在上述 `release_downloader.zip `解压的目录中,和保存视频的目录 `output/`同级,如下所示: 98 | 99 | ![image-20240409105228362](md/README/image-20240409105228362.png) 100 | 101 | 下载完视频后,双击运行 `gen_caption.exe`(文件较大,需要等一会),输入数字选择视频,回车。再输入数字选择使用多大的模型,越往下效果越好,但所需时间也越长,默认使用 base 模型。第一次使用会自动下载模型(几百 M),请耐心等待。如下所示: 102 | 103 | ![image-20240409131033038](md/README/image-20240409131033038.png) 104 | 105 | 等待程序运行完成,生成的字幕文件为 `.srt`格式,与视频文件在同级目录下,用支持字幕的播放器(如 potplayer)打开视频即可看到带字幕的视频。 106 | 107 | _tips: 语音转文字所需的时间较长,可以先观看视频,字幕生成好了再重新打开视频享受字幕。使用 GPU 大约需要几分钟,不使用 GPU 则需要更长时间。_ 108 | 109 | ## 依赖 110 | 111 | - ffmpeg,已在 Release 中提供。若在 Linux 环境下运行,需手动安装 ffmpeg: 112 | 113 | ```bash 114 | sudo apt update 115 | sudo apt install ffmpeg 116 | ``` 117 | 118 | - **若使用 GPU 运行自动生成字幕功能,需要先安装 cuda,安装方法见[cuda 安装](https://blog.csdn.net/chen565884393/article/details/127905428)。** 119 | 120 | _若想用 python 环境运行,需安装以下依赖_ 121 | 122 | - python,[下载](https://www.python.org/ftp/python/3.9.4/python-3.9.4-amd64.exe)并安装 123 | - python 第三方库 requests。打开命令行,运行如下命令安装: 124 | 125 | ```bash 126 | pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple 127 | ``` 128 | 129 | - 安装语音转文字的依赖:(依赖于 pytorch,若未安装 pytorch,会自动安装,但是 cpu 版本。安装 cuda 版本的 pytorch 方法见[pytorch 官网](https://pytorch.org/get-started/locally/)。) 130 | 131 | ```bash 132 | pip install -r requirements_whisper.txt -i https://pypi.tuna.tsinghua.edu.cn/simple 133 | ``` 134 | 135 | ## 注意 136 | 137 | - 需要关闭本机上的代理,否则会提示类似 `check_hostname requires server_hostname`的报错信息。 138 | - 可以下载无权限的课程,只要知道课程链接(中的课程编号)就行。 139 | 140 | ## 打包(仅开发者需要) 141 | 142 | 如果想要运行时不依赖 python 环境,可将 python 程序打包成可执行文件。Release 中已打包。 143 | 144 | 使用如下命令打包: 145 | 146 | ```bash 147 | # 若未安装pyinstaller,运行以下命令安装 148 | pip install pyinstaller 149 | # 打包 150 | pyinstaller -F main.py -i yhkt.ico 151 | pyinstaller -F gui.py -i yhkt.ico 152 | pyinstaller -F webui_interface.py --add-data webui:webui --add-data templates:templates -i yhkt.ico 153 | pyinstaller -F gen_caption.py -i yhkt.ico 154 | ``` 155 | 156 | 打包 `gen_caption.py`时可能会失败,提示递归过深: 157 | 158 | image-20240409095211597 159 | 160 | 解决方法参考[这里](https://zhuanlan.zhihu.com/p/661325305),需要修改项目根目录下的 `gen_caption.spec`配置文件,在文件开始处加上以下代码: 161 | 162 | ```python 163 | import sys ; sys.setrecursionlimit(sys.getrecursionlimit() * 5) 164 | ``` 165 | 166 | 再使用如下命令打包: 167 | 168 | ```bash 169 | pyinstaller --clean .\gen_caption.spec 170 | ``` 171 | 172 | 打包完成后运行若出现 Temp 目录下的文件未找到: 173 | 174 | ![image-20240409095831766](md/README/image-20240409095831766.png) 175 | 176 | 解决方法参考[这个](https://blog.csdn.net/qq_42324086/article/details/118280341),将项目 `hooks`目录下的 `hook-whisper.py`和 `hook-zhconv.py`文件复制到 pyinstaller 的 hook 目录下(通常在 `python根目录\Lib\site-packages\PyInstaller\hooks`)。 177 | -------------------------------------------------------------------------------- /gen_caption.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | 5 | import whisper 6 | from zhconv import convert # 简繁体转换 7 | 8 | 9 | def seconds_to_hmsm(seconds): 10 | """ 11 | 输入一个秒数,输出为H:M:S:M时间格式 12 | @params: 13 | seconds - Required : 秒 (float) 14 | """ 15 | hours = str(int(seconds // 3600)) 16 | minutes = str(int((seconds % 3600) // 60)) 17 | seconds = seconds % 60 18 | milliseconds = str(int(int((seconds - int(seconds)) * 1000))) # 毫秒留三位 19 | seconds = str(int(seconds)) 20 | # 补0 21 | if len(hours) < 2: 22 | hours = "0" + hours 23 | if len(minutes) < 2: 24 | minutes = "0" + minutes 25 | if len(seconds) < 2: 26 | seconds = "0" + seconds 27 | if len(milliseconds) < 3: 28 | milliseconds = "0" * (3 - len(milliseconds)) + milliseconds 29 | return f"{hours}:{minutes}:{seconds},{milliseconds}" 30 | 31 | 32 | def main(): 33 | # 视频文件路径 34 | video_paths = [] 35 | if len(sys.argv) >= 2: 36 | video_paths.append(sys.argv[1]) 37 | else: 38 | files = [] 39 | for dirpath, dirnames, filenames in os.walk("."): 40 | for filename in filenames: 41 | if filename.endswith(".mp4"): 42 | files.append(os.path.join(dirpath, filename).replace("\\", "/")) 43 | for i, f in enumerate(files): 44 | print(f"[{i}]: ", f) 45 | input_list = eval( 46 | "[" + input("select a video file by input a num(split with ','): ") + "]" 47 | ) 48 | for i in input_list: 49 | video_paths.append(files[i]) 50 | print("selected video files:", video_paths) 51 | models = [] 52 | for model in whisper.available_models(): 53 | if ".en" in model: 54 | continue 55 | print(f"[{len(models)}]: ", model) 56 | models.append(model) 57 | model_index = input("select a model by input a num(default 'base'): ") 58 | try: 59 | model_name = models[eval(model_index)] 60 | except Exception: 61 | model_name = "base" 62 | print("selected model:", model_name) 63 | 64 | for video_path in video_paths: 65 | audio_path = video_path.replace("mp4", "m4a") 66 | cmd = f'ffmpeg -i "{video_path}" -vn -ar {whisper.audio.SAMPLE_RATE} "{audio_path}"' 67 | os.system(cmd) 68 | 69 | model = whisper.load_model(model_name, download_root="whisper_models/") 70 | 71 | start = time.time() 72 | result = model.transcribe(audio_path, verbose=False, language="zh") 73 | print("Time cost: ", time.time() - start) 74 | 75 | # 写入字幕文件 76 | with open(video_path.replace("mp4", "srt"), "w", encoding="utf-8") as f: 77 | i = 1 78 | for r in result["segments"]: 79 | f.write(str(i) + "\n") 80 | f.write( 81 | seconds_to_hmsm(float(r["start"])) 82 | + " --> " 83 | + seconds_to_hmsm(float(r["end"])) 84 | + "\n" 85 | ) 86 | i += 1 87 | f.write( 88 | convert(r["text"], "zh-cn") + "\n" 89 | ) # 结果可能是繁体,转为简体zh-cn 90 | f.write("\n") 91 | 92 | # 删除音频文件 93 | os.remove(audio_path) 94 | 95 | 96 | if __name__ == "__main__": 97 | main() 98 | -------------------------------------------------------------------------------- /gui.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import sys 3 | 4 | import m3u8dl 5 | import utils 6 | 7 | videoList = [] 8 | courseName = "" 9 | professor = "" 10 | selected_videos = [] 11 | selected_signal = [] 12 | download_audio = [] 13 | 14 | align = 0 15 | 16 | 17 | class Row: 18 | def __init__(self, text, highlighted=False): 19 | self.text = text 20 | self.highlighted = highlighted 21 | 22 | 23 | def draw_line(stdscr, text, row): 24 | # 在每个中文字符后插入一个空格,以解决中文字符宽度问题 25 | new_text = "" 26 | for c in text: 27 | new_text += c 28 | if ord(c) > 127: 29 | new_text += " " 30 | stdscr.addnstr(row, align, new_text, get_cmd_window_size(stdscr)[1]) 31 | 32 | 33 | def draw_menu(stdscr, options, checked, title, subtitle, current_row): 34 | stdscr.clear() 35 | height, width = get_cmd_window_size(stdscr) 36 | draw_line(stdscr, title, 0) 37 | draw_line(stdscr, subtitle, 1) 38 | msg = [] 39 | for idx, option in enumerate(options): 40 | checkmark = "[X]" if checked[idx] else "[ ]" 41 | msg.append(Row(f"{checkmark} {option}", idx == current_row)) 42 | draw_multi_select(stdscr, msg, current_row) 43 | draw_line(stdscr, "按上下键移动,按空格键选择/取消选择", height - 2) 44 | draw_line(stdscr, "按回车键确认,按q键退出", height - 1) 45 | stdscr.refresh() 46 | 47 | 48 | def draw_multi_select(stdscr, messages: list, center_row): 49 | # 获取屏幕的行数和列数 50 | height, width = get_cmd_window_size(stdscr) 51 | 52 | # 计算消息的开始位置以使其居中 53 | total_messages = len(messages) 54 | visible_messages = min(height - 5, total_messages) # 屏幕可以显示的最大消息数 55 | start_row = max(2, (height // 2) - (visible_messages // 2)) 56 | 57 | # 确定要显示的消息的范围 58 | start_index = min( 59 | max(0, center_row - (visible_messages // 2)), total_messages - visible_messages 60 | ) 61 | end_index = min(total_messages, start_index + visible_messages) 62 | 63 | for i in range(start_index, end_index): 64 | message = messages[i] 65 | draw_line( 66 | stdscr, 67 | message.text + (" <=" if message.highlighted else ""), 68 | start_row + (i - start_index), 69 | ) 70 | 71 | 72 | def multi_select(stdscr, options, title, subtitle="", checked=None): 73 | # curses.curs_set(0) # 隐藏光标 74 | if checked is None: 75 | checked = [False] * len(options) 76 | else: 77 | checked = [bool(c) for c in checked] 78 | current_row = 0 79 | while True: 80 | draw_menu(stdscr, options, checked, title, subtitle, current_row) 81 | key = stdscr.getch() 82 | 83 | if key == curses.KEY_DOWN: 84 | current_row = (current_row + 1) % len(options) # 向下循环移动 85 | elif key == curses.KEY_UP: 86 | current_row = (current_row - 1) % len(options) # 向上循环移动 87 | elif key == ord("q"): 88 | sys.exit() # 退出程序 89 | elif key == ord(" "): 90 | checked[current_row] = not checked[current_row] # 切换当前行的勾选状态 91 | elif key == curses.KEY_ENTER or key in [10, 13]: 92 | break 93 | 94 | # 获取选择 95 | return [i for i, c in enumerate(checked) if c] 96 | 97 | 98 | def config(stdscr): 99 | global \ 100 | videoList, \ 101 | courseName, \ 102 | professor, \ 103 | selected_videos, \ 104 | selected_signal, \ 105 | download_audio 106 | 107 | height, width = get_cmd_window_size(stdscr) 108 | 109 | # 开启回显 110 | curses.echo() 111 | # 设置背景色 112 | curses.start_color() 113 | # 设置颜色对 114 | curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE) 115 | # 设置窗口 116 | stdscr.clear() 117 | stdscr.refresh() 118 | 119 | # stdscr.border(0) 120 | # 提示用户输入 121 | url_base = "https://www.yanhekt.cn/course/" 122 | 123 | draw_line(stdscr, "请输入课程编号(回车退出):", 0) 124 | 125 | draw_line(stdscr, f"{url_base}", 1) 126 | 127 | # 等待用户输入字符串并显示它 128 | courseID = stdscr.getstr().decode("utf-8") 129 | if not courseID: 130 | sys.exit() 131 | 132 | if not utils.read_auth() or not utils.test_auth(courseID=courseID): 133 | stdscr.clear() 134 | for i, line in enumerate(utils.auth_prompt()): 135 | draw_line(stdscr, line, i) 136 | auth = stdscr.getstr().decode("utf-8") 137 | utils.write_auth(auth) 138 | if not utils.test_auth(courseID=courseID): 139 | stdscr.clear() 140 | draw_line(stdscr, "身份验证失败", 0) 141 | stdscr.getch() 142 | sys.exit() 143 | videoList, courseName, professor = utils.get_course_info(courseID=courseID) 144 | 145 | selected_videos = [] 146 | 147 | while True: 148 | selected_videos = multi_select( 149 | stdscr, 150 | [v["title"] for v in videoList], 151 | f"课程名:{courseName},请选择要下载的视频:", 152 | ) 153 | if not selected_videos: 154 | stdscr.clear() 155 | draw_line(stdscr, "请至少选择一个视频,按回车继续", 0) 156 | stdscr.getch() 157 | else: 158 | break 159 | 160 | selected_signal = [] 161 | 162 | while True: 163 | selected_signal = multi_select( 164 | stdscr, ["摄像头", "电脑屏幕"], "选择要下载的信号:" 165 | ) 166 | if not selected_signal: 167 | stdscr.clear() 168 | draw_line(stdscr, "请至少选择一个信号,按回车继续", 0) 169 | stdscr.getch() 170 | else: 171 | break 172 | 173 | download_audio = multi_select( 174 | stdscr, 175 | ["下载蓝牙音频"], 176 | "选择是否下载教室蓝牙话筒的音频(如果有的话):", 177 | "若教师未使用教室蓝牙话筒则该音频无声音", 178 | checked=[True], 179 | ) 180 | 181 | stdscr.clear() 182 | 183 | 184 | def get_cmd_window_size(stdscr): 185 | return stdscr.getmaxyx() 186 | 187 | 188 | @utils.print_help 189 | def main(): 190 | global align 191 | align = 25 192 | curses.wrapper(config) 193 | 194 | fail = [] 195 | for i in selected_videos: 196 | c = videoList[i] 197 | name = courseName + "-" + professor + "-" + c["title"] 198 | print(name) 199 | try: 200 | if 1 in selected_signal: 201 | path = f"output/{courseName}-screen" 202 | m3u8dl.M3u8Download(c["videos"][0]["vga"], path, name) 203 | if 0 in selected_signal: 204 | path = f"output/{courseName}-video" 205 | m3u8dl.M3u8Download(c["videos"][0]["main"], path, name) 206 | if download_audio: 207 | audio_url = utils.get_audio_url(c["video_ids"][0]) 208 | if audio_url: 209 | print("Downloading audio...") 210 | utils.download_audio(audio_url, path, name) 211 | print("Download audio successfully.") 212 | except Exception as e: 213 | print(e) 214 | fail.append(name) 215 | input(f"下载{name}失败,按回车键开始下一个") 216 | if fail: 217 | print("以下视频下载失败:") 218 | for f in fail: 219 | print(f) 220 | input("按回车键退出") 221 | else: 222 | input("下载结束,按回车键退出") 223 | 224 | 225 | if __name__ == "__main__": 226 | main() 227 | -------------------------------------------------------------------------------- /hooks/hook-whisper.py: -------------------------------------------------------------------------------- 1 | from PyInstaller.utils.hooks import collect_data_files 2 | 3 | datas = collect_data_files("whisper") 4 | -------------------------------------------------------------------------------- /hooks/hook-zhconv.py: -------------------------------------------------------------------------------- 1 | from PyInstaller.utils.hooks import collect_data_files 2 | 3 | datas = collect_data_files("zhconv") 4 | -------------------------------------------------------------------------------- /m3u8dl.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import queue 4 | import re 5 | import signal 6 | import sys 7 | import time 8 | from concurrent.futures import ThreadPoolExecutor 9 | from subprocess import run 10 | 11 | import requests 12 | import urllib3 13 | 14 | import utils 15 | 16 | 17 | class ThreadPoolExecutorWithQueueSizeLimit(ThreadPoolExecutor): 18 | """ 19 | 实现多线程有界队列 20 | 队列数为线程数的2倍 21 | """ 22 | 23 | def __init__(self, max_workers=None, *args, **kwargs): 24 | super().__init__(max_workers, *args, **kwargs) 25 | self._work_queue = queue.Queue(max_workers * 2) 26 | 27 | 28 | def make_sum(): 29 | ts_num = 0 30 | while True: 31 | yield ts_num 32 | ts_num += 1 33 | 34 | 35 | def dummy_func(downloaded, total, merge_status): 36 | return 37 | 38 | 39 | class M3u8Download: 40 | """ 41 | :param url: 完整的m3u8文件链接 如"https://www.bilibili.com/example/index.m3u8" 42 | :param name: 保存m3u8的文件名 如"index" 43 | :param max_workers: 多线程最大线程数 44 | :param num_retries: 重试次数 45 | :param base64_key: base64编码的字符串 46 | """ 47 | 48 | def __init__( 49 | self, 50 | url, 51 | workDir, 52 | name, 53 | max_workers=32, 54 | num_retries=99, 55 | base64_key=None, 56 | progress_callback=dummy_func, 57 | ): 58 | self._url = url 59 | self._token = None 60 | self._workDir = workDir 61 | self._name = name 62 | self._max_workers = max_workers 63 | self._num_retries = num_retries 64 | self._progress_callback = progress_callback 65 | if not os.path.exists(os.path.join(os.getcwd(), self._workDir)): 66 | os.makedirs(os.path.join(os.getcwd(), self._workDir)) 67 | self._file_path = os.path.join(os.getcwd(), self._workDir, self._name) 68 | if os.path.exists(self._file_path + ".mp4"): 69 | print(f"File '{self._file_path}.mp4' already exists, skip download") 70 | self._progress_callback(100, 100, 2) 71 | return 72 | self._front_url = None 73 | self._ts_url_list = [] 74 | self._success_sum = 0 75 | self._ts_sum = 0 76 | self._key = base64.b64decode(base64_key.encode()) if base64_key else None 77 | self._headers = { 78 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36 Edg/93.0.961.52", 79 | "Origin": "https://www.yanhekt.cn", 80 | "referer": "https://www.yanhekt.cn/", 81 | } 82 | self.timestamp, self.signature = utils.getSignature() 83 | urllib3.disable_warnings() 84 | 85 | self._url = utils.encryptURL(self._url) 86 | 87 | self.get_m3u8_info(self._url, self._num_retries) 88 | 89 | def signal_handler(sig, frame): 90 | print("Caught KeyboardInterrupt. Shutting down...") 91 | os._exit(1) 92 | 93 | signal.signal(signal.SIGINT, signal_handler) 94 | print(f"Downloading: {self._name}", f"Save path: {self._file_path}", sep="\n") 95 | with ThreadPoolExecutorWithQueueSizeLimit(self._max_workers) as pool: 96 | pool.submit(self.updateSignatureLoop) 97 | for k, ts_url in enumerate(self._ts_url_list): 98 | pool.submit( 99 | self.download_ts, 100 | ts_url, 101 | # The `.ts` extension is mandatory for FFmpeg 7.1.1+. 102 | # https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/b753bac08f6881b2d3dea8f1ab84c81550f35897 103 | # https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/6c4e56f07d1a703435854f2156c881885f7798da 104 | os.path.join(self._file_path, f"{k}.ts"), 105 | self._num_retries, 106 | ) 107 | if self._success_sum == self._ts_sum: 108 | self._progress_callback(self._success_sum, self._ts_sum, 1) 109 | self.output_mp4() 110 | self.delete_file() 111 | print(f"Download successfully --> {self._name}") 112 | self._progress_callback(self._success_sum, self._ts_sum, 2) 113 | 114 | def updateSignatureLoop(self): 115 | while self._success_sum != self._ts_sum: 116 | self.timestamp, self.signature = utils.getSignature() 117 | time.sleep(10) 118 | 119 | def get_m3u8_info(self, m3u8_url: str, num_retries: int) -> None: 120 | """ 121 | 获取m3u8信息 122 | """ 123 | 124 | if not self._token: 125 | self._token = utils.getToken() 126 | token = self._token 127 | url = utils.add_signature_for_url( 128 | m3u8_url, token, self.timestamp, self.signature 129 | ) 130 | try: 131 | with requests.get( 132 | url, timeout=(3, 30), verify=False, headers=self._headers 133 | ) as res: 134 | if res.status_code != 200: 135 | raise Exception(f"Failed to get m3u8 info: {res.status_code}") 136 | self._front_url = res.url.split(res.request.path_url)[0] 137 | if "EXT-X-STREAM-INF" in res.text: # 判定为顶级M3U8文件 138 | for line in res.text.split("\n"): 139 | if "#" in line: 140 | continue 141 | elif line.startswith("http"): 142 | self._url = line 143 | elif line.startswith("/"): 144 | self._url = self._front_url + line 145 | else: 146 | self._url = self._url.rsplit("/", 1)[0] + "/" + line 147 | self.get_m3u8_info(self._url, self._num_retries) 148 | else: 149 | m3u8_text_str = res.text 150 | self.get_ts_url(m3u8_text_str) 151 | except Exception as e: 152 | print(e) 153 | if num_retries > 0: 154 | self.get_m3u8_info(m3u8_url, num_retries - 1) 155 | 156 | def get_ts_url(self, m3u8_text_str: str) -> None: 157 | """ 158 | 获取每一个ts文件的链接 159 | """ 160 | if not os.path.exists(self._file_path): 161 | os.mkdir(self._file_path) 162 | new_m3u8_str = "" 163 | ts = make_sum() 164 | for line in m3u8_text_str.split("\n"): 165 | if "#" in line: 166 | if "EXT-X-KEY" in line and "URI=" in line: 167 | if os.path.exists(os.path.join(self._file_path, "key")): 168 | continue 169 | key = self.download_key(line, 5) 170 | if key: 171 | new_m3u8_str += f"{key}\n" 172 | continue 173 | new_m3u8_str += f"{line}\n" 174 | if "EXT-X-ENDLIST" in line: 175 | break 176 | else: 177 | if line.startswith("http"): 178 | self._ts_url_list.append(line) 179 | elif line.startswith("/"): 180 | self._ts_url_list.append(self._front_url + line) 181 | else: 182 | self._ts_url_list.append(self._url.rsplit("/", 1)[0] + "/" + line) 183 | new_m3u8_str += os.path.join(self._file_path, f"{next(ts)}.ts") + "\n" 184 | self._ts_sum = next(ts) 185 | with open(self._file_path + ".m3u8", "wb") as f: 186 | f.write(new_m3u8_str.encode("utf-8")) 187 | 188 | def download_ts(self, ts_url_original: str, name: str, num_retries: int) -> None: 189 | """ 190 | 下载 .ts 文件 191 | """ 192 | if not self._token: 193 | self._token = utils.getToken() 194 | token = self._token 195 | ts_url = utils.add_signature_for_url( 196 | ts_url_original.split("\n")[0], token, self.timestamp, self.signature 197 | ) 198 | try: 199 | if not os.path.exists(name): 200 | with requests.get( 201 | ts_url, 202 | stream=True, 203 | timeout=(5, 60), 204 | verify=False, 205 | headers=self._headers, 206 | ) as res: 207 | if res.status_code == 200: 208 | with open(name, "wb") as ts: 209 | for chunk in res.iter_content(chunk_size=1024): 210 | if chunk: 211 | ts.write(chunk) 212 | self._success_sum += 1 213 | sys.stdout.write( 214 | "\r[%-25s](%d/%d)" 215 | % ( 216 | "*" * (100 * self._success_sum // self._ts_sum // 4), 217 | self._success_sum, 218 | self._ts_sum, 219 | ) 220 | ) 221 | sys.stdout.flush() 222 | else: 223 | self.download_ts(ts_url_original, name, num_retries - 1) 224 | else: 225 | self._success_sum += 1 226 | 227 | self._progress_callback(self._success_sum, self._ts_sum, 0) 228 | except Exception: 229 | if os.path.exists(name): 230 | os.remove(name) 231 | if num_retries > 0: 232 | self.download_ts(ts_url_original, name, num_retries - 1) 233 | 234 | def download_key(self, key_line, num_retries): 235 | """ 236 | 下载key文件 237 | """ 238 | mid_part = re.search(r"URI=[\'|\"].*?[\'|\"]", key_line).group() 239 | may_key_url = mid_part[5:-1] 240 | if self._key: 241 | with open(os.path.join(self._file_path, "key"), "wb") as f: 242 | f.write(self._key) 243 | return f'{key_line.split(mid_part)[0]}URI="./{self._name}/key"' 244 | if may_key_url.startswith("http"): 245 | true_key_url = may_key_url 246 | elif may_key_url.startswith("/"): 247 | true_key_url = self._front_url + may_key_url 248 | else: 249 | true_key_url = self._url.rsplit("/", 1)[0] + "/" + may_key_url 250 | try: 251 | with requests.get( 252 | true_key_url, timeout=(5, 30), verify=False, headers=self._headers 253 | ) as res: 254 | with open(os.path.join(self._file_path, "key"), "wb") as f: 255 | f.write(res.content) 256 | return f'{key_line.split(mid_part)[0]}URI="./{self._name}/key"{key_line.split(mid_part)[-1]}' 257 | except Exception as e: 258 | print(e) 259 | if os.path.exists(os.path.join(self._file_path, "key")): 260 | os.remove(os.path.join(self._file_path, "key")) 261 | print("加密视频,无法加载key,解密失败") 262 | if num_retries > 0: 263 | self.download_key(key_line, num_retries - 1) 264 | 265 | def output_mp4(self) -> None: 266 | """ 267 | 合并.ts文件,输出mp4格式视频,需要ffmpeg 268 | """ 269 | run( 270 | [ 271 | "ffmpeg", 272 | "-i", f"{self._file_path}.m3u8", 273 | "-acodec", "copy", 274 | "-vcodec", "copy", 275 | "-f", "mp4", 276 | f"{self._file_path}.mp4", 277 | ], 278 | check=True, 279 | ) 280 | 281 | def delete_file(self): 282 | file = os.listdir(self._file_path) 283 | for item in file: 284 | os.remove(os.path.join(self._file_path, item)) 285 | os.removedirs(self._file_path) 286 | os.remove(self._file_path + ".m3u8") 287 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import m3u8dl 5 | import utils 6 | 7 | headers = { 8 | "Origin": "https://www.yanhekt.cn", 9 | "xdomain-client": "web_user", 10 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26", 11 | } 12 | 13 | 14 | @utils.print_help 15 | def main(): 16 | if len(sys.argv) == 1: 17 | courseID = input("输 入 课 程 ID: ") 18 | else: 19 | courseID = sys.argv[1] 20 | 21 | if not utils.read_auth() or not utils.test_auth(courseID=courseID): 22 | auth = input("。".join(utils.auth_prompt())) 23 | utils.write_auth(auth) 24 | if not utils.test_auth(courseID=courseID): 25 | print("身份验证失败") 26 | sys.exit() 27 | videoList, courseName, professor = utils.get_course_info(courseID=courseID) 28 | 29 | print(f"课 程 名: {courseName}") 30 | 31 | for i, c in enumerate(videoList): 32 | print(f"[{i}]: ", c["title"]) 33 | 34 | index = eval( 35 | "[" + input("选 择 课 程 编 号 (用 英 文 逗 号 ','分 隔, 例 如: 0,2,4): ") + "]" 36 | ) 37 | vga = input( 38 | "选 择 下 载 摄 像 头 (1) 还 是 电 脑 屏 幕 (2)?(输 入 1 或 2, 默 认 摄 像 头):" 39 | ) 40 | audio = input( 41 | "是 否 下 载 教 室 蓝 牙 话 筒 的 音 频 ?若 教 师 未 使 用 蓝 牙 话 筒 则 该 音 频 无 声 音 (输 入 1不 下 载, 默 认 下 载):" 42 | ) 43 | if not os.path.exists("output/"): 44 | os.mkdir("output/") 45 | for i in index: 46 | c = videoList[i] 47 | name = courseName + "-" + professor + "-" + c["title"] 48 | print(name) 49 | if vga == "2": 50 | path = f"output/{courseName}-screen" 51 | print("Downloading screen...") 52 | m3u8dl.M3u8Download(c["videos"][0]["vga"], path, name) 53 | else: 54 | path = f"output/{courseName}-video" 55 | print("Downloading video...") 56 | m3u8dl.M3u8Download(c["videos"][0]["main"], path, name) 57 | if audio == "" and c["video_ids"]: 58 | audio_url = utils.get_audio_url(c["video_ids"][0]) 59 | if audio_url: 60 | print("Downloading audio...") 61 | utils.download_audio(audio_url, path, name) 62 | print("Download audio successfully.") 63 | 64 | 65 | if __name__ == "__main__": 66 | main() 67 | -------------------------------------------------------------------------------- /md/README/image-20230926124922726.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20230926124922726.png -------------------------------------------------------------------------------- /md/README/image-20231018204208066.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20231018204208066.png -------------------------------------------------------------------------------- /md/README/image-20240409095211597.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240409095211597.png -------------------------------------------------------------------------------- /md/README/image-20240409095831766.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240409095831766.png -------------------------------------------------------------------------------- /md/README/image-20240409105228362.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240409105228362.png -------------------------------------------------------------------------------- /md/README/image-20240409131033038.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240409131033038.png -------------------------------------------------------------------------------- /md/README/image-20240413001454717.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240413001454717.png -------------------------------------------------------------------------------- /md/README/image-20240413001734218.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240413001734218.png -------------------------------------------------------------------------------- /md/README/image-20240413002004628.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240413002004628.png -------------------------------------------------------------------------------- /md/README/image-20240413002242979.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240413002242979.png -------------------------------------------------------------------------------- /md/README/image-20240529171253980.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240529171253980.png -------------------------------------------------------------------------------- /md/README/image-20240529171540279.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240529171540279.png -------------------------------------------------------------------------------- /md/README/image-20240529171709402.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240529171709402.png -------------------------------------------------------------------------------- /md/README/image-20240809182344017.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240809182344017.png -------------------------------------------------------------------------------- /md/README/image-20240809182406184.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240809182406184.png -------------------------------------------------------------------------------- /md/README/image-20240809182413373.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240809182413373.png -------------------------------------------------------------------------------- /md/README/image-20240809182420653.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240809182420653.png -------------------------------------------------------------------------------- /md/README/image-20240809183350633.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/md/README/image-20240809183350633.png -------------------------------------------------------------------------------- /requirements-whisper.txt: -------------------------------------------------------------------------------- 1 | openai-whisper 2 | zhconv 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | windows-curses 3 | Flask -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 延河下载器 9 | 10 | 11 | 12 |
13 | 14 |
15 | 16 |
17 |
18 | 19 | 20 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from hashlib import md5 4 | 5 | import requests 6 | 7 | # 在延河课堂网站的main.js中4937号的O[N(149, 270, 240, 274)]["k"]()函数的返回值 8 | magic = "1138b69dfef641d9d7ba49137d2d4875" 9 | headers = { 10 | "Origin": "https://www.yanhekt.cn", 11 | "Referer": "https://www.yanhekt.cn/", 12 | "xdomain-client": "web_user", 13 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26", 14 | "Xdomain-Client": "web_user", 15 | "Xclient-Signature": md5((magic + "_v1_undefined").encode()).hexdigest(), 16 | "Xclient-Version": "v1", 17 | "Xclient-Timestamp": str(int(time.time())), 18 | "Authorization": "", 19 | } 20 | 21 | 22 | def auth_prompt(code=True): 23 | return [ 24 | "请先在浏览器登录延河课堂", 25 | "并在延河课堂的地址栏输入 javascript:alert(JSON.parse(localStorage.auth).token)", 26 | '注意粘贴时浏览器会自动去掉"javascript:",需要手动补上', 27 | "或者按F12打开控制台粘贴这段代码", 28 | "然后将弹出的内容粘贴到" + ("这里:" if code else '"身份认证码"栏'), 29 | ] 30 | 31 | 32 | def encryptURL(url: str) -> str: 33 | url_list = url.split("/") 34 | # "c3d47d7b3aa8caf2983b313cb6cd142f" 35 | url_list.insert(-1, md5((magic + "_100").encode()).hexdigest()) 36 | return "/".join(url_list) 37 | 38 | 39 | def getSignature(): 40 | timestamp = str(int(time.time())) 41 | signature = md5((magic + "_v1_" + timestamp).encode()).hexdigest() 42 | return timestamp, signature 43 | 44 | 45 | def getToken() -> str: 46 | req = requests.get( 47 | "https://cbiz.yanhekt.cn/v1/auth/video/token?id=0", headers=headers 48 | ) 49 | # Example response: `{"code":0,"message":"","data":{"token":"12345678901234ab","expired_at":1742300867,"now":1742300267}}` 50 | data = req.json()["data"] 51 | if not data: 52 | read_auth() 53 | req = requests.get( 54 | "https://cbiz.yanhekt.cn/v1/auth/video/token?id=0", headers=headers 55 | ) 56 | data = req.json()["data"] 57 | if not data: 58 | raise Exception("获取Token失败") 59 | return data["token"] 60 | 61 | 62 | def add_signature_for_url(url: str, token: str, timestamp: str, signature: str) -> str: 63 | url = ( 64 | url 65 | + "?Xvideo_Token=" 66 | + token 67 | + "&Xclient_Timestamp=" 68 | + timestamp 69 | + "&Xclient_Signature=" 70 | + signature 71 | + "&Xclient_Version=v1&Platform=yhkt_user" 72 | ) 73 | return url 74 | 75 | 76 | def read_auth(): 77 | if not os.path.exists("auth.txt"): 78 | return "" 79 | with open("auth.txt") as f: 80 | auth = f.read().strip() 81 | headers["Authorization"] = "Bearer " + auth 82 | return auth 83 | 84 | 85 | def write_auth(auth): 86 | headers["Authorization"] = "Bearer " + auth 87 | with open("auth.txt", "w") as f: 88 | f.write(auth) 89 | 90 | 91 | def remove_auth(): 92 | headers["Authorization"] = "" 93 | if os.path.exists("auth.txt"): 94 | os.remove("auth.txt") 95 | 96 | 97 | def test_auth(courseID): 98 | """ 99 | Test if the auth in headers is valid. 100 | Return True if the auth is valid, otherwise False. 101 | """ 102 | res = requests.get( 103 | f"https://cbiz.yanhekt.cn/v2/course/session/list?course_id={courseID}", 104 | headers=headers, 105 | ) 106 | return bool(res.json()["data"]) 107 | 108 | 109 | def get_course_info(courseID): 110 | courseID = courseID.strip() 111 | 112 | course = requests.get( 113 | f"https://cbiz.yanhekt.cn/v1/course?id={courseID}&with_professor_badges=true", 114 | headers=headers, 115 | ) 116 | res = requests.get( 117 | f"https://cbiz.yanhekt.cn/v2/course/session/list?course_id={courseID}", 118 | headers=headers, 119 | ) 120 | 121 | if course.json()["code"] != "0" and course.json()["code"] != 0: 122 | # print(course.json()["code"]) 123 | # print(course.json()["message"]) 124 | raise Exception( 125 | f"courseID: {courseID}, {course.json()['message']}。请检查您的课程ID,注意它应该是5位数字,从课程信息界面的链接yanhekt.cn/course/***获取,而不是课程播放界面的链接yanhekt.cn/session/***" 126 | ) 127 | # print(course.json()["data"]["name_zh"]) 128 | videoList = res.json()["data"] 129 | name = course.json()["data"]["name_zh"].strip() 130 | if not videoList: 131 | raise Exception(f"该课程({name})没有视频信息,请检查课程ID是否正确") 132 | 133 | return ( 134 | videoList, 135 | name, 136 | ( 137 | course.json()["data"]["professors"][0]["name"].strip() 138 | if course.json()["data"]["professors"] 139 | else "未知教师" 140 | ), 141 | ) 142 | 143 | 144 | def get_audio_url(video_id): 145 | res = requests.get( 146 | f"https://cbiz.yanhekt.cn/v1/video?id={video_id}", 147 | headers=headers, 148 | ) 149 | return res.json()["data"].get("audio", "") 150 | 151 | 152 | def download_audio(url, path, name): 153 | token = getToken() 154 | url = add_signature_for_url(url, token, *getSignature()) 155 | _headers = headers.copy() 156 | _headers["Host"] = "cvideo.yanhekt.cn" 157 | res = requests.get(url, headers=_headers) 158 | while res.status_code != 200: 159 | time.sleep(0.1) 160 | res = requests.get(url, headers=_headers) 161 | with open(f"{path}/{name}.aac", "wb") as f: 162 | f.write(res.content) 163 | 164 | 165 | def print_help(f: callable): 166 | def wrap(): 167 | try: 168 | f() 169 | except Exception as e: 170 | print(e) 171 | print( 172 | "If the problem is still not solved, you can report an issue in https://github.com/AuYang261/BIT_yanhe_download/issues." 173 | ) 174 | print( 175 | "Or contact with the author xu_jyang@163.com. Thanks for your report!" 176 | ) 177 | print( 178 | "如果问题仍未解决,您可以在https://github.com/AuYang261/BIT_yanhe_download/issues 中报告问题。" 179 | ) 180 | print("或者联系作者xu_jyang@163.com。感谢您的报告!") 181 | 182 | return wrap 183 | -------------------------------------------------------------------------------- /webui/script.js: -------------------------------------------------------------------------------- 1 | document.getElementById("newTaskButton").onclick = function () { 2 | document.getElementById("taskPopup").style.display = "block"; 3 | }; 4 | 5 | document.getElementsByClassName("close")[0].onclick = function () { 6 | document.getElementById("taskPopup").style.display = "none"; 7 | }; 8 | 9 | // Implement the logic to fetch course number and handle form submission 10 | function fetchCourseNumber() { 11 | fetch(`/get_course?course_id=${document.getElementById("courseId").value}&auth=${document.getElementById("auth").value}`) 12 | .then((response) => response.json()) 13 | .then((data) => { 14 | console.log(data); 15 | if (data.code && data.code == 403) { 16 | document.getElementById("auth_prompt").innerHTML = data.msg; 17 | alert(data.msg); 18 | } 19 | document.getElementById("courseList").innerHTML = ``; 20 | document.getElementById("courseName11").innerHTML = `课程名: ${data.courseName == "" ? "未知" : data.courseName 21 | }`; 22 | document.getElementById("professor11").innerHTML = `老师: ${data.professor == "" ? "未知" : data.professor 23 | }`; 24 | let courseListHTML = ""; 25 | for (let i = 0; i < data.videoList.length; i++) { 26 | courseListHTML += `
  • ${data.videoList[i].title}
  • `; 27 | } 28 | document.getElementById("courseList").innerHTML = courseListHTML; 29 | document.querySelectorAll("#courseList li").forEach((item) => { 30 | item.addEventListener("click", () => { 31 | item.classList.toggle("selected"); 32 | }); 33 | }); 34 | }) 35 | .catch((error) => console.error("Error:", error)); 36 | } 37 | 38 | document.getElementById("taskForm").onsubmit = function (event) { 39 | event.preventDefault(); 40 | let courseId = document.getElementById("courseId").value; 41 | if (courseId.trim() == "") { 42 | alert("课程名不能为空"); 43 | return; 44 | } 45 | let downloadType = document.getElementById("downloadType").value; 46 | let downloadAudio = document.getElementById("downloadAudio").value; 47 | let selected_index = []; 48 | let courseList = document.getElementById("courseList"); 49 | for (let i = 0; i < courseList.childNodes.length; i++) { 50 | let child = courseList.childNodes[i]; 51 | if (child.className == "selected") { 52 | selected_index.push(child.getAttribute("data-value")); 53 | } 54 | } 55 | let course_number = selected_index.join(","); 56 | fetch("/new_task", { 57 | method: "POST", 58 | headers: { 59 | "Content-Type": "application/json", 60 | }, 61 | body: JSON.stringify({ 62 | course_id: courseId.trim(), 63 | course_number: course_number, 64 | download_version: downloadType, 65 | download_audio: downloadAudio 66 | }), 67 | }) 68 | .then((response) => response.json()) 69 | .then((data) => { 70 | console.log(data); 71 | document.getElementById("taskPopup").style.display = "none"; 72 | }) 73 | .catch((error) => console.error("Error:", error)); 74 | }; 75 | 76 | function getDownloadStatusText(task_obj) { 77 | const merge_status = task_obj["merge_status"]; 78 | const cur = task_obj["cur"]; 79 | const tot = task_obj["tot"]; 80 | const cancel = task_obj["canceled"]; 81 | if (cancel) { 82 | return "已取消"; 83 | } 84 | if (merge_status == 0) { 85 | if (cur == 0) { 86 | return "等待中..."; 87 | } else { 88 | return `下载中...(${((cur / tot) * 100).toFixed(2)} %)`; 89 | } 90 | } else if (merge_status == 1) { 91 | return "合并视频中..."; 92 | } else if (merge_status == 2) { 93 | return "已完成"; 94 | } else { 95 | return "未知状态"; 96 | } 97 | } 98 | 99 | function cancelTask(btn) { 100 | let uuid = btn.getAttribute("data-task-uuid"); 101 | console.log(uuid); 102 | fetch(`/kill_task?uuid=${uuid}`) 103 | .then((response) => response.json()) 104 | .then((data) => { 105 | console.log(data); 106 | let remove_node = document.getElementById(`${uuid}-task`); 107 | if (remove_node != null) { 108 | remove_node.parentNode.removeChild(remove_node); 109 | } 110 | }) 111 | .catch((error) => console.error("Error:", error)); 112 | } 113 | 114 | setInterval(() => { 115 | const addElement = (task_obj) => { 116 | if (task_obj["canceled"]) { 117 | return; 118 | } 119 | const download_version = 120 | task_obj["download_type"] == 2 ? "电脑屏幕" : "摄像头"; 121 | const html = ` 122 |
    123 |
    124 | ${task_obj["name"]}(${download_version}) 125 |
    126 | ${getDownloadStatusText(task_obj)} 128 | 130 |
    131 |
    132 |
    133 |
    134 |
    135 |
    136 | `; 137 | let taskList = document.getElementById("taskList"); 138 | taskList.innerHTML = html + taskList.innerHTML; 139 | }; 140 | const updateElement = (task_obj) => { 141 | const uuid = task_obj["uuid"]; 142 | const status_ele = document.getElementById(`${task_obj["uuid"]}-status`); 143 | const progress_ele = document.getElementById( 144 | `${task_obj["uuid"]}-progress` 145 | ); 146 | status_ele.innerText = getDownloadStatusText(task_obj); 147 | const progress = (100 * task_obj["cur"]) / task_obj["tot"]; 148 | progress_ele.style.width = `${progress.toFixed(2)}%`; 149 | }; 150 | const removeCanceledElement = (uuid_arr) => { 151 | let all_task_elem = document.getElementsByClassName("task"); 152 | for (let i = 0; i < all_task_elem.length; i++) { 153 | const uuid = all_task_elem[i].getAttribute("id").replace("-task", ""); 154 | if (!uuid_arr.includes(uuid)) { 155 | all_task_elem[i].parentNode.removeChild(all_task_elem[i]); 156 | } 157 | } 158 | } 159 | fetch("/get_status") 160 | .then((response) => response.json()) 161 | .then((data) => { 162 | // console.log(data); 163 | let uuid_arr = []; 164 | for (let i = 0; i < data.length; i++) { 165 | const uuid = data[i]["uuid"]; 166 | if (!data[i]["canceled"]) { 167 | uuid_arr.push(uuid); 168 | } 169 | let exist_ele = document.getElementById(`${uuid}-task`); 170 | if (exist_ele == null) { 171 | addElement(data[i]); 172 | } else { 173 | updateElement(data[i]); 174 | } 175 | } 176 | removeCanceledElement(uuid_arr); 177 | }) 178 | .catch((error) => console.error("Error:", error)); 179 | }, 1000); 180 | 181 | const listItems = document.querySelectorAll("#courseList li"); 182 | listItems.forEach((item) => { 183 | item.addEventListener("click", () => { 184 | item.classList.toggle("selected"); 185 | }); 186 | }); 187 | 188 | function selectAll(select) { 189 | let list = document.getElementById("courseList"); 190 | for (let i = 0; i < list.childNodes.length; i++) { 191 | list.childNodes[i].className = select ? "selected" : ""; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /webui/styles.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | height: 100%; 3 | margin: 0; 4 | font-family: Arial, sans-serif; 5 | background-color: #f0f8ff; 6 | } 7 | 8 | .container { 9 | padding: 20px; 10 | } 11 | 12 | .btn { 13 | background: linear-gradient(145deg, #4682B4, #005A9C); 14 | color: white; 15 | border: none; 16 | padding: 10px 20px; 17 | text-align: center; 18 | text-decoration: none; 19 | display: inline-block; 20 | font-size: 16px; 21 | border-radius: 5px; 22 | margin: 4px 2px; 23 | cursor: pointer; 24 | transition: background-color 0.3s, transform 0.2s; 25 | } 26 | 27 | .btn:hover { 28 | background: linear-gradient(145deg, #357ABD, #004B8D); 29 | transform: scale(1.05); 30 | } 31 | 32 | .popup { 33 | display: none; 34 | position: fixed; 35 | z-index: 1; 36 | left: 0; 37 | top: 0; 38 | width: 100%; 39 | height: 100%; 40 | overflow: auto; 41 | background-color: rgba(0,0,0,0.4); 42 | } 43 | 44 | .popup-content { 45 | background-color: #fff; 46 | margin: 10% auto; /* Centrally aligns the popup */ 47 | padding: 20px; 48 | border: 1px solid #888; 49 | width: 60%; /* Adjust width as necessary */ 50 | border-radius: 8px; 51 | display: flex; 52 | flex-direction: column; /* Makes the form vertical */ 53 | } 54 | 55 | 56 | .close { 57 | color: #aaaaaa; 58 | float: right; 59 | font-size: 28px; 60 | font-weight: bold; 61 | } 62 | 63 | .close:hover, 64 | .close:focus { 65 | color: #000; 66 | text-decoration: none; 67 | cursor: pointer; 68 | } 69 | 70 | input[type="text"], select, input[type="checkbox"] { 71 | padding: 10px; 72 | margin-top: 5px; 73 | margin-bottom: 10px; 74 | display: inline-block; 75 | border: 1px solid #ccc; 76 | border-radius: 4px; 77 | box-sizing: border-box; 78 | transition: border-color 0.3s; 79 | } 80 | 81 | input[type="text"]:focus, select:focus { 82 | border-color: #4682B4; 83 | } 84 | 85 | label { 86 | display: block; 87 | margin-bottom: 5px; 88 | color: #666; 89 | } 90 | 91 | fieldset { 92 | border: 1px solid #ccc; 93 | padding: 10px; 94 | margin-top: 5px; 95 | } 96 | 97 | legend { 98 | padding: 0 10px; 99 | color: #4682B4; 100 | } 101 | 102 | 103 | .task { 104 | background-color: #fff; 105 | border: 1px solid #ccc; 106 | padding: 10px; 107 | margin-top: 10px; 108 | border-radius: 5px; 109 | display: flex; 110 | flex-direction: column; 111 | } 112 | 113 | .task-info { 114 | width: 100%; 115 | display: flex; 116 | justify-content: space-between; 117 | align-items: center; 118 | } 119 | 120 | .status-container { 121 | display: flex; 122 | align-items: center; 123 | } 124 | 125 | .cancel-btn { 126 | font-size: 16px; 127 | color: #888; 128 | border: none; 129 | background: none; 130 | cursor: pointer; 131 | transition: color 0.3s; 132 | margin-left: 10px; /* Ensure some spacing between status and button */ 133 | } 134 | 135 | .cancel-btn:hover { 136 | color: #555; 137 | } 138 | 139 | .progress-bar { 140 | width: 100%; 141 | background-color: #e0e0e0; 142 | border-radius: 5px; 143 | overflow: hidden; 144 | margin-top: 5px; 145 | } 146 | 147 | .progress { 148 | height: 10px; 149 | background: linear-gradient(to right, #4682B4, #005A9C); 150 | width: 0%; 151 | transition: width 1s ease; 152 | } 153 | 154 | .checkbox-group { 155 | display: flex; 156 | flex-direction: column; /* Makes checkboxes stack vertically */ 157 | align-items: flex-start; /* Aligns checkboxes to the left */ 158 | } 159 | 160 | .checkbox-group label { 161 | display: flex; 162 | align-items: center; 163 | margin-bottom: 10px; /* Adds space between each checkbox label group */ 164 | } 165 | 166 | .checkbox-group input[type="checkbox"] { 167 | margin-right: 5px; /* Keeps a small space between checkbox and label */ 168 | } 169 | 170 | 171 | .input-field { 172 | width: 100%; /* Full width input fields */ 173 | padding: 8px; 174 | margin-top: 5px; 175 | margin-bottom: 10px; 176 | display: inline-block; 177 | border: 1px solid #ccc; 178 | border-radius: 4px; 179 | box-sizing: border-box; 180 | } 181 | 182 | #courseList { 183 | padding-inline-start: 0px; 184 | } 185 | 186 | #courseList li { 187 | padding: 5px; 188 | border: 1px solid #ccc; 189 | list-style-type: none; 190 | cursor: pointer; 191 | } 192 | 193 | #courseList li.selected { 194 | background-color: #4682B4; 195 | color: white; 196 | } -------------------------------------------------------------------------------- /webui_interface.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import threading 3 | import time 4 | import uuid 5 | import webbrowser 6 | from queue import Empty, Queue 7 | 8 | from flask import ( 9 | Flask, 10 | jsonify, 11 | render_template, 12 | request, 13 | send_from_directory, 14 | ) 15 | 16 | import m3u8dl 17 | import utils 18 | 19 | app = Flask(__name__, static_folder="webui") 20 | 21 | """ 22 | { 23 | "url": 24 | "output": 25 | "name": 26 | "cur": 27 | "tot": 28 | "uuid": 29 | "canceled": 30 | "merge_status": 31 | "download_type": 32 | "download_audio": bool 33 | "audio_url": 34 | } 35 | """ 36 | all_task_status = [] 37 | 38 | 39 | """ 40 | { 41 | "uuid" 42 | } 43 | """ 44 | task_queue = Queue() 45 | 46 | 47 | def find_all_task_by_uuid(uuid): 48 | for id, task in enumerate(all_task_status): 49 | if task["uuid"] == uuid: 50 | return task, id 51 | return None 52 | 53 | 54 | g_father_queue = None 55 | current_task_uuid = "" 56 | 57 | 58 | def executor_progress_callback(cur, tot, merge_status): 59 | global g_father_queue, current_task_uuid 60 | g_father_queue.put( 61 | { 62 | "uuid": current_task_uuid, 63 | "cur": cur, 64 | "tot": tot, 65 | "merge_status": merge_status, 66 | } 67 | ) 68 | # print({ 69 | # "uuid": current_task_uuid, 70 | # "cur": cur, 71 | # "tot": tot, 72 | # "merge_status": merge_status 73 | # }) 74 | return False 75 | 76 | 77 | def execute_one_download_task_worker(task_dict, father_queue): 78 | global current_task_uuid, g_father_queue 79 | print(f"downloading task {task_dict}") 80 | current_task_uuid = task_dict["uuid"] 81 | url = task_dict["url"] 82 | output = task_dict["output"] 83 | name = task_dict["name"] 84 | g_father_queue = father_queue 85 | m3u8dl.M3u8Download(url, output, name, progress_callback=executor_progress_callback) 86 | if task_dict["download_audio"]: 87 | audio_url = task_dict["audio_url"] 88 | if audio_url: 89 | print("Downloading audio...") 90 | utils.download_audio(audio_url, output, name) 91 | print("Download audio successfully.") 92 | return 93 | 94 | 95 | def execute_tasks(): 96 | global all_task_status 97 | queue = multiprocessing.Queue() 98 | while True: 99 | try: 100 | task = task_queue.get(timeout=1) 101 | task_uuid = task["uuid"] 102 | task_obj, task_id = find_all_task_by_uuid(task_uuid) 103 | if task_obj["canceled"] is True: 104 | all_task_status.pop(task_id) 105 | continue 106 | process = multiprocessing.Process( 107 | target=execute_one_download_task_worker, args=(task_obj, queue) 108 | ) 109 | process.start() 110 | while True: 111 | if all_task_status[task_id]["canceled"]: 112 | print("task canceled, terminate subprocess...") 113 | process.terminate() 114 | all_task_status.pop(task_id) 115 | break 116 | try: 117 | msg = queue.get_nowait() 118 | update_obj, update_id = find_all_task_by_uuid(msg["uuid"]) 119 | all_task_status[update_id]["cur"] = msg["cur"] 120 | all_task_status[update_id]["tot"] = msg["tot"] 121 | all_task_status[update_id]["merge_status"] = msg["merge_status"] 122 | except Empty: 123 | if process.is_alive() is False: 124 | break 125 | time.sleep(0.1) 126 | continue 127 | except TypeError: 128 | continue 129 | except Empty: 130 | continue 131 | except TypeError: 132 | continue 133 | 134 | 135 | @app.route("/") 136 | def index(): 137 | auth = utils.read_auth() 138 | return render_template( 139 | "index.html", 140 | auth=auth, 141 | auth_prompt="" if auth else "。".join(utils.auth_prompt()), 142 | ) 143 | 144 | 145 | @app.route("/get_course") 146 | def get_course(): 147 | course_id = request.args.get("course_id") 148 | auth = request.args.get("auth") 149 | if auth: 150 | utils.write_auth(auth) 151 | if not utils.test_auth(courseID=course_id): 152 | utils.remove_auth() 153 | return jsonify({"code": 403, "msg": "。".join(utils.auth_prompt(False))}) 154 | try: 155 | videoList, courseName, professor = utils.get_course_info(courseID=course_id) 156 | except Exception: 157 | return jsonify({"videoList": [], "courseName": "", "professor": ""}) 158 | return jsonify( 159 | {"videoList": videoList, "courseName": courseName, "professor": professor} 160 | ) 161 | 162 | 163 | @app.route("/new_task", methods=["POST"]) 164 | def new_task(): 165 | global task_queue, all_task_status 166 | data = request.json 167 | course_id = data["course_id"] 168 | course_number = data["course_number"] 169 | download_version = data["download_version"] 170 | download_audio = data["download_audio"] 171 | videoList, courseName, professor = utils.get_course_info(courseID=course_id) 172 | course_number_arr = course_number.split(",") 173 | ret_id = [] 174 | for courseNum in course_number_arr: 175 | courseNumT = int(courseNum) 176 | c = videoList[courseNumT] 177 | name = courseName + "-" + professor + "-" + c["title"] 178 | print(name) 179 | 180 | cur_uuid = str(uuid.uuid4()) 181 | ret_id.append(cur_uuid) 182 | task_status = { 183 | "url": "", 184 | "output": "", 185 | "name": name, 186 | "cur": 0, 187 | "tot": 0, 188 | "uuid": cur_uuid, 189 | "canceled": False, 190 | "merge_status": 0, 191 | "download_type": download_version, 192 | "download_audio": download_audio == "1", 193 | "audio_url": "", 194 | } 195 | 196 | task_status["audio_url"] = utils.get_audio_url(c["video_ids"][0]) 197 | if download_version == "2": 198 | print("Downloading screen...") 199 | task_status["url"] = c["videos"][0]["vga"] 200 | task_status["output"] = "output/" + courseName + "-screen" 201 | else: 202 | print("Downloading video...") 203 | task_status["url"] = c["videos"][0]["main"] 204 | task_status["output"] = "output/" + courseName + "-video" 205 | all_task_status.append(task_status) 206 | task_queue.put({"uuid": cur_uuid}) 207 | 208 | return jsonify({"status": "success", "task_id": ret_id}) 209 | 210 | 211 | @app.route("/get_status") 212 | def get_status(): 213 | global all_task_status 214 | return jsonify(all_task_status) 215 | 216 | 217 | @app.route("/kill_task") 218 | def kill_task(): 219 | global all_task_status 220 | uuid = request.args.get("uuid") 221 | task, id = find_all_task_by_uuid(uuid) 222 | if task["merge_status"] == 2: 223 | # if already finished 224 | all_task_status.pop(id) 225 | return jsonify({"status": "ok"}) 226 | all_task_status[id]["canceled"] = True 227 | return jsonify({"status": "ok"}) 228 | 229 | 230 | @app.route("/") 231 | def static_files(path): 232 | return send_from_directory(app.static_folder, path) 233 | 234 | 235 | if __name__ == "__main__": 236 | multiprocessing.freeze_support() 237 | t = threading.Thread(target=execute_tasks) 238 | t.start() 239 | webbrowser.open("http://127.0.0.1:5001/") 240 | app.run(debug=False, host="0.0.0.0", use_reloader=False, port=5001) 241 | -------------------------------------------------------------------------------- /yhkt.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AuYang261/BIT_yanhe_download/e173cf593f4802fcc4401e71d6edf43bb0e65541/yhkt.ico -------------------------------------------------------------------------------- /项目详解.md: -------------------------------------------------------------------------------- 1 | # BIT_yanhe_download 详解 2 | 3 | _以下内容由 ChatGPT 4o, Deep-research 模式生成,人工修改,仅供参考。_ 4 | 5 | ## 项目背景说明 6 | 7 | BIT_yanhe_download 项目是一个用于下载北京理工大学“延河课堂”在线课程视频的工具。延河课堂平台提供了课程视频的在线观看,但官方未提供下载功能,因此本项目旨在解决这一需求。通过该工具,用户可以下载指定课程下的所有录播视频,包括教室后方摄像头画面和讲台电脑屏幕画面两种信号,并可选下载教室蓝牙麦克风的音频。项目使用 Python 编写,利用了第三方库如 requests(进行网络请求)、flask(提供 Web 界面支持)、curses(实现命令行图形界面)、ffmpeg(合并视频片段)等。项目发布形式为源代码及可执行文件(如 gui.exe 和 webui_interface.exe),方便用户在命令行或浏览器界面下使用。总体而言,BIT_yanhe_download 的目标是为延河课堂提供一个易用的离线下载方案,帮助师生将课程录像保存到本地。 8 | 9 | ## 代码结构和特点 10 | 11 | 项目代码包含多个模块,按照功能大致分为以下几部分: 12 | 13 | - 主程序入口:main.py 是命令行模式下的入口,提供交互式文本提示;gui.py 则实现了一个基于 curses 的终端图形界面(命令行 GUI),提供菜单式交互体验。这两个入口模块负责解析用户输入(课程 ID、选择视频/屏幕、是否下载音频等),然后调用核心下载功能完成任务。 14 | - Web 界面模块:webui_interface.py 实现了基于 Flask 的网页服务器。它定义了路由和 API 端点,实现包括主页渲染、课程信息获取、创建下载任务、查询任务状态、取消任务等功能。在这个模块中,使用了 Python 的 multiprocessing 来启动独立进程执行下载任务,以避免阻塞 Flask 主线程,并通过进程间队列与网页前端实时通信进度。Web 界面相关的静态资源存放在 webui/目录,模板文件位于 templates/目录(如主页模板 index.html)。 15 | - 下载核心模块:m3u8dl.py 是核心下载实现模块。它包含一个 M3u8Download 类,封装了处理 M3U8 文件和下载 .ts 视频片段的全部逻辑,包括解析播放列表、多线程下载片段、重试机制、密钥处理和最终合并输出等。本模块也定义了一些辅助类/函数,如 `ThreadPoolExecutorWithQueueSizeLimit`(自定义线程池,限制队列长度为线程数的 2 倍),make_sum(生成片段编号的生成器)等,提高下载性能和稳定性。 16 | - 工具模块:utils.py 提供了一系列工具函数,用于与延河课堂后台 API 交互和生成签名。例如读取/保存用户认证令牌、调用课程信息接口、获取视频列表和音频下载链接、计算请求签名等。这些函数将平台的内部规则(如需要的 HTTP 头、签名算法等)封装起来,供主程序和下载模块调用。 17 | - 字幕生成模块:gen_caption.py 是一个独立的脚本模块,利用 OpenAI Whisper 模型将下载的视频音频生成字幕文件(依赖 whisper 模型和 tqdm 等库)。这是附加功能,在下载完成后可选运行,以得到课程的文本字幕,便于检索或辅助理解(需要额外安装模型,相关依赖在 requirements-whisper.txt 中列出)。 18 | - 配置及其他:项目根目录还有一些配置文件,例如 requirements.txt 列出了所需 Python 库,Dockerfile 提供了容器化运行环境,yhkt.ico 是程序图标。hooks/目录下包含 PyInstaller 的 hook 脚本(如 hook-whisper.py 等),方便将程序打包成单文件可执行时正确包含所有依赖资源。 19 | 20 | 项目代码特点在于界面与核心逻辑分离:用户既可选择命令行模式快速操作,也能使用友好的 Web 界面批量下载。核心下载功能通过统一的接口(调用 M3u8Download 类等)供不同界面复用,减少重复代码。代码中还包含中英文提示信息和异常处理,方便不同语言的用户并提高出错时的可诊断性。此外,项目针对延河课堂的特殊限制(如需要登录令牌、请求签名)进行了逆向分析并在代码中实现,使得无论有无课程访问权限,都可以通过提供有效的身份认证码完成下载。总体而言,代码结构清晰:UI 层负责与用户交互,逻辑层(utils 和 m3u8dl)负责处理数据和执行下载,模块之间通过函数调用与数据结构协作完成工作。 21 | 22 | ## 原理分析 23 | 24 | 核心下载功能的原理是模拟浏览器对延河课堂视频流的请求过程,获取视频播放列表并下载其中的媒体分片。延河课堂的视频采用了 M3U8 (HLS) 协议,即将整段视频拆分为多个.ts 格式的片段,并通过一个.m3u8 索引文件来描述所有片段的 URL。项目通过分析延河课堂前端代码和网络请求,掌握了获取这些视频流的步骤和必要的校验参数。整体原理可概括如下: 25 | 26 | ### 身份认证与课程信息获取 27 | 28 | 由于新版延河课堂需要登录才能访问课程列表,首先必须提供用户的身份认证令牌(JWT Token)。用户从浏览器中获取此 token(存储在网页 localStorage.auth 中)并粘贴给程序。程序将该 token 添加到请求头的 Authorization 字段,以授权后续 API 请求。接着通过延河课堂提供的开放 API 获取课程信息和视频列表,根据课程 ID 获取该课程下所有课节的视频信息列表。一旦成功,则拿到课程名、教师名和视频列表数据,供用户进一步选择。 29 | 30 | ### 用户选择和参数设置 31 | 32 | 用户可以选择要下载的视频编号(支持选择多个),以及下载哪种信号源:摄像头画面或屏幕录制。程序根据选择决定使用视频列表中对应项的"main"(主摄像头)或"vga"(屏幕)链接。此外,用户可选是否下载蓝牙麦克风音频。如果选择是(默认是下载音频),程序会利用视频的 video_id 调用 `/v1/video?id=...` 接口获取对应音频流的地址。这些参数决定了后续下载的 URL 和保存路径。例如,若课程名为“计算机网络”,教师“张三”,选中了第 1 个视频,摄像头信号,则程序将构造输出目录 output/计算机网络-video,文件名前缀计算机网络-张三-课节标题。 33 | 34 | ### 签名生成与 M3U8 获取 35 | 36 | 延河课堂对视频流地址进行了权限校验和防盗链处理。具体来说,每个视频流 URL 需要附加一系列查询参数(如 Xvideo_Token、Xclient_Signature、Xclient_Timestamp 等)才能访问。其中,Xvideo_Token 通过调用 `/v1/auth/video/token` 接口获得一个临时令牌;Xclient_Timestamp 是当前时间戳;Xclient_Signature 则通过服务器端的算法生成(项目作者从延河课堂前端 JS 中逆向得到了算法与一个 magic 字符串常量)。在代码中,这一流程由 `utils.getToken()`、`utils.getSignature()`和 `utils.add_signature_for_url()`实现。首先 `getToken()` 向 `/v1/auth/video/token?id=0 `发送 GET 请求,如果未携带有效身份则返回空,此时会自动调用 `read_auth()` 加入用户提供的认证后重试获取。拿到视频令牌 token 后,`getSignature()` 使用预置的 `magic="1138b69dfef641d9d7ba49137d2d4875"` 字符串,拼接当前时间戳构造字符串并求 MD5,得到签名值。最后 `add_signature_for_url()` 把原始 m3u8 链接和取得的 token、timestamp、signature 拼接到 URL 查询参数上,形成完整的带签名请求链接。有了这个签名 URL,程序便使用 `requests.get` 去获取 .m3u8 文件内容。 37 | 38 | ### 解析 M3U8 和准备下载 39 | 40 | 拿到 .m3u8 文件文本后,程序需要解析其中的信息。首先检查是否为顶级索引(即含有多路流信息的 master playlist)。判断方法是看文件内容是否包含 `#EXT-X-STREAM-INF` 标签。如果存在,说明此 M3U8 文件指向实际的视频播放列表(例如不同清晰度或角度)。代码会遍历文件中的非注释行:如果是以 http 开头的完整 URL,则直接取该 URL;如果以 `/` 开头,则需要在前面补上域名前缀;如果是相对路径,则补上当前链接的路径前缀。这样获取到真正的视频流 .m3u8 链接后,递归调用 `get_m3u8_info()`再次获取,直到拿到最终的片段列表文件文本为止。对于每一个最终的 M3U8 内容,程序调用 `get_ts_url()` 解析出所有 .ts 片段的 URL 列表以及处理加密信息。 41 | 42 | ### 处理加密密钥(如果有) 43 | 44 | 某些课程视频可能对 HLS 流做了加密,对应 .m3u8 文件中会出现 `#EXT-X-KEY` 标签,指明解密该片段所需的密钥 URI。`get_ts_url()`函数会检测到带 EXT-X-KEY 且包含 URI=的行。如果此前未下载过密钥文件,则调用 `download_key()` 尝试下载。`download_key()` 通过正则提取 URI 地址,判断其类型(绝对 http 链接、相对路径等)拼接成可访问的 URL,然后发送请求获取密钥内容。成功获取后,将密钥二进制内容保存为当前视频文件夹下的 key 文件,并返回一个修改后的密钥行,把原来的 URI 替换为本地路径(例如 `URI="./课程名-.../key"`)。这样后续合并时 ffmpeg 能够从本地读取密钥完成解密。如果密钥下载失败,多次重试后仍不能获取,则函数打印提示“加密视频,无法加载 key,解密失败” 并跳过,这种情况下保留原始 URI 在 m3u8 文件中(ffmpeg 在合并时可能会尝试自行联网获取密钥)。 45 | 46 | ### 生成本地索引和 URL 列表 47 | 48 | 在 `get_ts_url()` 中,程序为每个片段 URL 生成对应的本地文件名,并构造一个新的本地 .m3u8 文件内容。具体来说,`get_ts_url()` 读取原始 M3U8 文本的每一行:对于注释行(以#开头,且不是密钥行)直接附加到新的 m3u8 字符串;对于片段 URL 行,分三种情况处理得到完整片段 URL 并加入 `_ts_url_list` 列表。随后使用一个计数生成器 `ts = make_sum()` 为片段编号(从 0 开始递增)生成文件名。比如第一个片段将被保存为 0.ts,第二个为 1.ts 等。新的 m3u8 字符串则把每个片段对应的行替换为本地路径(即“输出目录/视频名/编号.ts”)。如此逐行处理完毕后,得到片段总数\_ts_sum 并将新 m3u8 内容写出到 `文件输出目录/视频名.m3u8`。这个本地 m3u8 文件之后会交给 ffmpeg 用于合并视频。 49 | 50 | ### 多线程下载视频片段 51 | 52 | 准备好片段 URL 列表后,程序正式开始下载。M3u8Download 类的构造函数中创建了自定义线程池 `ThreadPoolExecutorWithQueueSizeLimit`,最大线程数默认 32,并限制任务队列大小为 64(线程数的两倍)。然后启动一个后台线程执行 updateSignatureLoop(),再为每个片段提交一个下载任务。`updateSignatureLoop` 会每隔 10 秒刷新一次全局签名 timestamp 和 signature(调用 `utils.getSignature()` 更新实例属性)。这样做是因为延河课堂可能要求签名在一定时间内有效,长时间下载需要不断更新,以防后续请求失效。实际下载由 `download_ts()` 函数完成:它根据全局视频 token 和当前 signature 构造片段请求 URL,然后使用 requests.get 发出 HTTP 请求获取.ts 文件数据。为提高效率,`requests.get` 使用了 `stream=True` 分块下载,收到响应后立即循环写入文件(以 1024 字节块写入)。每成功下载一个片段,计数器 `_success_sum` 加 1,并通过控制台输出或回调函数更新进度。如果某次请求返回非 200 状态,或者出现网络异常,`download_ts` 会删除可能残留的文件片段并重试(递归调用自身,最大重试次数默认为 99 次)。线程池使多个片段同时下载,从而大大加快整个视频获取过程。当所有片段任务都完成后,程序检查成功数与总数是否相等,若相等则进入合并阶段。 53 | 54 | ### 合并视频与保存音频 55 | 56 | 当 `_success_sum == _ts_sum` 时,说明所有片段已成功下载。此时程序调用 `output_mp4()`,利用 ffmpeg 将刚才保存的本地.m3u8 文件合成为 MP4 视频。具体命令为:`ffmpeg -i "xxx.m3u8" -acodec copy -vcodec copy -f mp4 "xxx.mp4"`,即直接复制音频视频编码而不重新编码,将格式转为 MP4 封装。ffmpeg 会读取.m3u8 文件中列出的 .ts 分片(如果有加密且本地存在 key 文件,也会自动使用)并输出一个完整的视频文件。一旦 ffmpeg 执行成功,程序调用 `delete_file()` 清理临时文件:删除先前创建的片段文件以及密钥文件,并移除存放片段的临时文件夹。最终,下载的视频保存在 `output/` 目录下,以“课程名-视频/屏幕”区分不同信号源的子目录分类。若用户选择下载蓝牙音频且该课程有对应音频轨道,主视频下载完成后,程序通过之前获取的 audio_url 再次发起请求下载音频文件(这是一个单独的.aac 音频流)。`utils.download_audio()` 会对音频 URL 也附加签名参数并请求数据,获取后直接保存为与视频同名但扩展名为 .aac 的文件。这样,完整的课程视频和独立音频就下载到本地了。 57 | 58 | 上述流程在命令行模式和 Web 模式下稍有差异:命令行下由 `main.py` 顺序执行,用户需同步等待下载完成;Web 模式下,每个下载任务由独立进程异步执行,前端通过轮询 `/get_status` 接口获取任务进度,并可同时进行多个任务。无论界面如何,核心原理相同,都是通过课程 ID -> API 获取信息 -> 拼接签名 URL -> 下载 M3U8 -> 下载.ts 片段 -> 合并这一系列步骤,实现对延河课堂流媒体资源的抓取。 59 | 60 | ## 核心功能的实现概述 61 | 62 | 核心功能围绕 下载指定课程的录播视频 展开,实现步骤与原理分析中描述的一一对应。这里以 m3u8dl.py 关键模块函数来说明其具体实现。 63 | 64 | 核心下载类 M3u8Download 在其 **init** 方法中串联起了获取 M3U8、解析和下载的过程。其实现步骤可简述如下(对应代码见 m3u8dl.py): 65 | 66 | - 初始化属性。 67 | 68 | 包括保存输入参数 url、workDir、name,默认线程数 32 和重试次数 99 等。若发现目标 MP4 文件已存在,则直接提示文件存在,避免重复下载。之后调用 `utils.encryptURL()` 对传入的 M3U8 地址进行处理。`encryptURL` 会在原始 URL 路径倒数第二段插入一个 MD5 字符串(基于 magic 和固定参数计算),这是延河课堂对视频地址的特殊要求之一。这样得到真正可用的 .m3u8 文件 URL。 69 | 70 | - 调用 self.get_m3u8_info(self.\_url, self.\_num_retries) 获取并解析 m3u8 信息。 71 | 72 | 此函数负责下载 .m3u8 内容并识别其中是顶级索引还是直接的片段列表,如前文原理部分第 4 步所述。实现上,它先确保持有视频 token(若 self.\_token 为空则调用 `utils.getToken()` 获取一次并缓存)。然后用当前 token 和签名构造带参数的 m3u8 请求链接并执行 `requests.get` 抓取内容。若成功,解析文本:如果检测到 `#EXT-X-STREAM-INF` 则按逻辑选取实际流 URL 并递归调用自身;否则将文本传递给 `self.get_ts_url()` 提取片段 URL 列表。整个过程中若发生异常且 `num_retries>0` 则递减重试(最多 99 次),超过重试次数则会冒出异常到上层(最终被 `print_help` 捕获打印)。 73 | 74 | - self.get_ts_url(m3u8_text_str) 将 m3u8 文本逐行处理,收集片段 URL 和处理密钥(对应原理第 5、6 步)。 75 | 76 | 代码实现上,使用 os.mkdir 创建以视频名为名的子目录用于存放片段。然后初始化 `new_m3u8_str` 空字符串,和计数生成器 `ts = make_sum()`。循环每一行:如果是密钥行,且本地未下载过密钥,则调用 `download_key()`获取密钥并将 `EXT-X-KEY` 行替换成本地引用;其他#开头行原样加入 `new_m3u8_str`;遇到 `#EXT-X-ENDLIST` 直接结束循环(不再读后续内容,即使有多余行也忽略)。如果是片段 URI 行,则分三种情况构造完整 URL 并追加到 `self._ts_url_list` 列表;同时生成下一个序号,构造对应的本地文件路径加入 `new_m3u8_str`。循环结束后,调用 next(ts)获取片段总数赋给 `self._ts_sum`。最后将拼接好的本地 m3u8 内容写入磁盘文件,以备合并使用。至此,下载前的准备工作全部完成:`_ts_url_list` 里存放了所有待下载片段的远程链接,片段总数 `_ts_sum` 已知,文件目录也建好了。 77 | 78 | - 进入下载阶段。 79 | 80 | 在**init**的主体中,随后打印开始下载提示和保存路径。然后使用 `ThreadPoolExecutorWithQueueSizeLimit(self._max_workers)` 创建受控线程池,在线程池上下文中做两件事: 首先 `pool.submit(self.updateSignatureLoop)` 启动一个签名更新线程,其次遍历片段 URL 列表,为每个 URL 提交 `pool.submit(self.download_ts, ts_url, 本地文件名, 重试次数)`下载任务。`updateSignatureLoop` 在独立线程中每隔 10 秒更新一次签名。 81 | 82 | 而 `download_ts` 则在多个线程中并发执行实际下载:它内部会检查并确保 `self._token` 存在,然后构造带签名参数的片段 URL(这里利用了之前 `updateSignatureLoop` 更新的 `self.signature` 值,每个线程在发请求时都能取到最近刷新的签名)。之后用 `requests` 下载片段内容并保存,如遇失败按前述逻辑重试。值得注意的是,`download_ts` 在写完一个片段文件后,将 `self._success_sum` 加 1,并通过两种方式通知进度:如果是在 CLI 模式,没有提供自定义 `progress_callback`,则执行 sys.stdout.write 绘制简单进度条;如果在 Web 模式,传入的 `progress_callback` 会将进度信息通过队列发送到父进程,由父进程更新 `all_task_status` 供前端查询。不管哪种模式,每完成一个片段下载都会及时更新计数,确保进度反馈及时。 83 | 84 | - 当线程池中的下载任务全部结束后,线程池的上下文退出。 85 | 86 | 接下来代码检查下载结果:如果成功数 \_success_sum 与总数 \_ts_sum 相等,则表示全部片段下载成功。于是调用 `self._progress_callback(self._success_sum, self._ts_sum, 1)` 将任务状态置为“合并中”(Web 模式下会将 merge_status 更新为 1),然后调用 `self.output_mp4()` 执行 ffmpeg 合并。`output_mp4` 通过 `subprocess.run` 运行预设的 ffmpeg 命令行合并文件。若 ffmpeg 合并出错会抛异常(在 CLI 模式会被 print_help 捕获,在 Web 模式子进程会崩溃退出),正常情况下合并完成即得到最终 MP4 文件。随后调用 `self.delete_file()`,删除此前创建的临时 .ts 片段和密钥文件并移除空文件夹。最后打印下载成功消息并再次调用进度回调将状态置为 2(完成)。这样,一个视频流的下载流程彻底结束。 87 | 88 | 以上描述了主程序驱动和核心下载类的大体实现方式。可以看到,源码严格按照预定的逻辑顺序构造,使用较多的函数划分使得每个步骤职责明确:utils 封装了与平台交互的细节、M3u8Download 内部又细分出获取索引、下载片段、合并输出等函数模块。通过合理的异常处理和重试机制,程序在网络不稳定或参数错误时也能给出提示或自动重试,从而提高成功率和用户体验。 89 | 90 | ## 问题分析及思考 91 | 92 | 虽然 BIT_yanhe_download 项目实现了主要功能,但在阅读代码过程中,也发现了一些可以改进之处和可能存在的问题: 93 | 94 | - 安全性与健壮性:命令行界面使用了 Python 的 eval() 来解析用户输入的序号列表。这在功能上简便,但如果用户输入恶意代码(例如输入一个类似 `import('os').system('rm -rf \*')`的字符串),eval 将直接执行它,带来严重安全风险。为避免隐患,建议改用安全的解析方法,如将输入字符串用逗号分割再用 int()转换,或使用正则验证格式后再 exec,不要直接 eval 未经校验的用户输入。 95 | - ffmpeg 依赖提醒:`output_mp4` 直接调用系统 ffmpeg 命令,但并未检查其是否安装。当用户环境缺少 ffmpeg 时,会抛出异常(被 `print_help` 捕获打印出来)。对于不熟悉技术的用户,这可能不清楚如何解决。项目可以在 README 或运行时检测 ffmpeg 可执行文件是否存在,如不存在则提示用户安装,增强易用性。 96 | 97 | 除了代码本身的问题,通过实验和调试可以进一步理解程序运行情况并验证其有效性: 98 | 99 | - 身份验证流程测试:为提升用户体验,可以逆向分析延河课堂的身份认证过程(北理工统一身份认证),自动获取 token 鉴权码,而不需要在浏览器自行登录后执行 js 命令获取 token 再粘贴到程序中。 100 | - 字幕生成:虽然不属于核心下载,但 gen_caption.py 的作用值得一提。它通过调用 ffmpeg 提取音频并用 whisper 模型转写字幕。这需要额外下载模型,运行相对耗时。实验表明在配置好环境后,对一个 1 小时课程生成字幕可能需要几分钟到十几分钟,得到的字幕质量取决于音频清晰度。这个模块独立运行,不影响下载逻辑,但为有需要的用户提供了扩展功能。使用时可能的问题是其依赖库很多,导致打包的二进制文件过大,同时需要 GPU 环境以高效运行,对非专业用户不是很友好。 101 | 102 | 总的来说,BIT_yanhe_download 的代码是相对成熟和完整的,但仍有优化空间。通过代码阅读和上述思考,我们了解到它在面对真实使用场景时的表现。从开发者角度,可以考虑完善其中的细节,使之更加安全健壮,例如替换不安全的解析方式、加入对失败情况的处理和用户提示。尽管如此,本项目已经很好地实现了预期功能,为需要下载延河课堂视频的用户提供了极大便利,体现了作者对目标站点机制的深入理解和将其转化为代码能力。 103 | 104 | _(作者:太会夸了 QwQ)_ 105 | 106 | # 作者自述 107 | 108 | 本来只是一门课程作业要写一篇开源项目源码阅读报告,想着这个项目规模不大,比较合适用来作案例。但又不想自己写报告,于是丢给 ChatGPT ,其新出的深度研究(Deep-research)模式可以指定一个仓库链接,直接生成一份报告,格式都能指定好,用过之后感觉它对项目的理解比我还深入。虽然已经大半年没看了,但是通过阅读修改这篇报告,感觉又重新认识了这个项目,同时也重新熟悉了由各位 contributors 贡献的代码。 109 | 110 | 作为已经毕业的 BITer,能在 BIT 留下自己的痕迹也算是我的荣幸。但由于已经无法登录北理工统一身份认证平台,项目的维护还需要靠社区。**在此感谢一直以来各位 Contributors 和 Issue-Participants 对本项目的支持!** 111 | --------------------------------------------------------------------------------