├── .gitignore ├── README.md ├── config.conf ├── cookie ├── download_manager.py ├── main.exe ├── main.py ├── make_exe.sh ├── requirements.txt └── tencent_request.py /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .idea 3 | *.pyc 4 | tmp/* 5 | log/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `TencentClassDownloader` 2 | 腾讯课堂视频下载脚本 3 | 4 | 视频教程:[大型纪录片:腾讯课堂关服了,课程保存方法快来学习,超级简单 5 | ](https://www.bilibili.com/video/BV1uTvCeDEjk) 6 | 7 | # 开始 8 | ## 1、配置环境(两种方式) 9 | 10 | ### ①Python环境运行 11 | 12 | 运行环境 Python3,我测试时的版本是Python3.8 13 | 14 | #### 安装依赖 15 | ``` 16 | pip install -r requirements.txt 17 | ``` 18 | 19 | ### ②直接运行 20 | 21 | 配置好cookie和config.conf,直接运行目录下的main.exe 22 | 23 | 24 | ## 2、获取cookie 25 | 以谷歌浏览器为例,打开腾讯视频并登陆,键盘F12,找到network——点击Doc(如果没有数据,刷新一下界面),找到cookie等信息,拷贝粘贴到目录下的cookie文件中 26 | 27 | 28 | ## 3、配置conf 29 | 修改目录中到config.conf文件, 30 | 31 | ``` 32 | [QQ] # 你当前课程所属的QQ号 33 | number = 123456789 34 | 35 | [url] # 课程链接,一定要按照 https://ke.qq.com/webcourse/xxxxx/xxxxx形式粘贴 36 | url = https://ke.qq.com/webcourse/12345678/123456789 37 | 38 | [process] # 下载视频的线程数,视你电脑能力而定,可以尽量多开一些 39 | process_num = 8 40 | 41 | [output] # 下载视频存放的路径 42 | save_path = D:\\txktDownload\ 43 | 44 | [clarity] # 清晰度,目前只设置了 1 2 3 三种,其中 1 为最清晰, 3为最不清晰, 2为两者之间, 实际情况看你的课程支持哪些清晰度。 45 | clarity = 1 46 | ``` 47 | 48 | 49 | ## 4、运行main.py或者main.exe 50 | ``` 51 | python3 main.py 52 | 或 53 | main.exe 54 | ``` 55 | 等着下载完成就可以了,下载过的视频会自动跳过,如需重新下载请删除原来的视频。 56 | 57 | # 结束 58 | 59 | 交流群[https://t.me/teleportmmm] 60 | -------------------------------------------------------------------------------- /config.conf: -------------------------------------------------------------------------------- 1 | [QQ] 2 | number = 123456789 3 | 4 | [url] 5 | url = https://ke.qq.com/webcourse/12345678/123456789 6 | 7 | [process] 8 | process_num = 8 9 | 10 | [output] 11 | save_path = D:\\txktDownload\ 12 | 13 | [clarity] 14 | clarity = 1 15 | # 1 = high 16 | # 2 = mid 17 | # 3 = low -------------------------------------------------------------------------------- /cookie: -------------------------------------------------------------------------------- 1 | 删掉这句话,此处粘贴上你的cookie -------------------------------------------------------------------------------- /download_manager.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | 4 | import requests 5 | import threading 6 | import os 7 | 8 | from Crypto.Cipher import AES 9 | from Crypto.Util.Padding import pad 10 | from queue import Queue 11 | from urllib.parse import urljoin 12 | 13 | from tqdm import tqdm 14 | 15 | 16 | class M3U8Downloader: 17 | def __init__(self, m3u8_url, sign, output_file, num_threads=4): 18 | self.m3u8_url = m3u8_url 19 | self.output_file = output_file 20 | self.num_threads = num_threads 21 | self.queue = Queue() 22 | self.ts_files = [] 23 | self.IV = None 24 | self.sign = sign 25 | self.cryptor = None 26 | self.progress_bar = None 27 | self.base_url = os.path.dirname(m3u8_url) + '/' 28 | if not os.path.exists('tmp/'): 29 | os.mkdir('tmp/') 30 | self.delete_files_in_folder('tmp/') 31 | 32 | def fetch_m3u8(self): 33 | response = requests.get(self.m3u8_url) 34 | response.raise_for_status() 35 | try: 36 | if self.IV is None: 37 | self.IV = bytes.fromhex(re.findall('IV=0x(.*)', response.text)[0]) 38 | self.creat_aes() 39 | except Exception as e: 40 | print(e) 41 | return response.text 42 | 43 | def creat_aes(self): 44 | self.cryptor = AES.new(self.decrypt_sign(self.sign), AES.MODE_CBC, self.IV) 45 | 46 | def parse_m3u8(self, m3u8_content): 47 | lines = m3u8_content.splitlines() 48 | for line in lines: 49 | if line and not line.startswith("#"): 50 | ts_url = urljoin(self.base_url, line) 51 | self.ts_files.append(ts_url) 52 | self.queue.put(ts_url) 53 | 54 | def download_ts(self): 55 | while not self.queue.empty(): 56 | ts_url = self.queue.get() 57 | ts_filename = ts_url.split("/")[-1].split('&')[0].replace('?', '_') 58 | response = requests.get(ts_url) 59 | response.raise_for_status() 60 | cont = self.cryptor.decrypt(response.content) 61 | 62 | with open('tmp/' + ts_filename, 'wb') as f: 63 | f.write(cont) 64 | self.queue.task_done() 65 | if self.progress_bar: 66 | self.progress_bar.update(1) 67 | 68 | def merge_ts(self): 69 | with open(self.output_file, 'wb') as output_file: 70 | for ts_url in self.ts_files: 71 | ts_filename = ts_url.split("/")[-1].split('&')[0].replace('?', '_') 72 | 73 | with open('tmp/' + ts_filename, 'rb') as ts_file: 74 | output_file.write(ts_file.read()) 75 | os.remove('tmp/' + ts_filename) 76 | 77 | def delete_files_in_folder(self, folder_path): 78 | for filename in os.listdir(folder_path): 79 | file_path = os.path.join(folder_path, filename) 80 | if os.path.isfile(file_path): 81 | os.remove(file_path) 82 | 83 | def decrypt_sign(self, sign): 84 | key = sign[0:32] 85 | iv = sign[-16:] 86 | cipher_text = sign[32:-16] 87 | 88 | key = bytes(key, 'ascii') 89 | iv = bytes(iv, 'ascii') 90 | ciphertext = "" 91 | for pos in range(0, len(cipher_text), 2): 92 | char = int(cipher_text[pos:pos + 2], 16) 93 | ciphertext += chr(char) 94 | ciphertext = ciphertext.encode('iso-8859-1') 95 | 96 | encrypted_data = pad(ciphertext, 16) 97 | # 创建AES CBC解密器 98 | cipher = AES.new(key, AES.MODE_CBC, iv) 99 | 100 | # 使用解密器解密密文 101 | plaintext = cipher.decrypt(encrypted_data) 102 | key_decrypt = base64.b64decode(plaintext) 103 | return key_decrypt 104 | 105 | def run(self): 106 | m3u8_content = self.fetch_m3u8() 107 | self.parse_m3u8(m3u8_content) 108 | self.progress_bar = tqdm(total=len(self.ts_files), desc=f"Downloading ${self.output_file}") 109 | 110 | threads = [] 111 | for _ in range(self.num_threads): 112 | t = threading.Thread(target=self.download_ts) 113 | t.start() 114 | threads.append(t) 115 | 116 | for t in threads: 117 | t.join() 118 | self.progress_bar.close() 119 | 120 | self.merge_ts() 121 | print(f"Download completed: {self.output_file}") 122 | -------------------------------------------------------------------------------- /main.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csgo-adc/TencentClassDownloader/e57094d1835df6266c90723fa44a72b8f71f270b/main.exe -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os.path 3 | 4 | from tqdm import tqdm 5 | 6 | from download_manager import M3U8Downloader 7 | from tencent_request import TencentRequest, clarityResolution 8 | 9 | config = configparser.ConfigParser() 10 | config.read('config.conf') 11 | 12 | 13 | class TencentClassDownloader(object): 14 | def __init__(self): 15 | self.cid = None 16 | self.term_id = None 17 | self.qq = None 18 | self.process_num = 4 19 | self.clarity = clarityResolution.HIGH 20 | self.save_path = None 21 | self.download_list = [] 22 | self.parser_conf() 23 | 24 | def parser_conf(self): 25 | self.qq = config['QQ']['number'] 26 | self.process_num = int(config['process']['process_num']) 27 | self.save_path = config['output']['save_path'] 28 | if not os.path.exists(self.save_path): 29 | os.makedirs(self.save_path) 30 | 31 | clarity = config['clarity']['clarity'] 32 | if clarity == '3': 33 | self.clarity = clarityResolution.LOW 34 | elif clarity == '2': 35 | self.clarity = clarityResolution.MID 36 | else: 37 | self.clarity = clarityResolution.HIGH 38 | 39 | url = config['url']['url'] 40 | url = url.split('/') 41 | self.cid = url[-2] 42 | self.term_id = url[-1].split('#')[0] 43 | 44 | def creat_download_info(self): 45 | cid = self.cid 46 | term_id_list = self.term_id 47 | t = TencentRequest(cid, term_id_list) 48 | chapter_list = t.get_course_list() 49 | 50 | for chapter_info in chapter_list: 51 | for term_info in chapter_info: 52 | task_info = term_info['task_info'] 53 | for course in task_info: 54 | if 'resid_list' in course: 55 | course_name = course['name'] 56 | resid_list = course['resid_list'] 57 | if isinstance(resid_list, str): 58 | video_id = resid_list 59 | m3u8_url, sign = t.get_course_info(cid, term_id_list, video_id, self.qq, self.clarity) 60 | if m3u8_url is None or sign is None: 61 | continue 62 | download_info = { 63 | 'course_name': course_name, 64 | 'cid': cid, 65 | 'term_id': term_id_list, 66 | 'video_id': video_id, 67 | 'm3u8_url': m3u8_url, 68 | 'sign': sign 69 | } 70 | self.download_list.append(download_info) 71 | 72 | elif isinstance(resid_list, list): 73 | for resid in resid_list: 74 | video_id = resid 75 | 76 | m3u8_url, sign = t.get_course_info(cid, term_id_list, video_id, self.qq, self.clarity) 77 | if m3u8_url is None or sign is None: 78 | continue 79 | if len(resid_list) > 1: 80 | c_name = course_name + '_' + video_id 81 | else: 82 | c_name = course_name 83 | download_info = { 84 | 'course_name': c_name, 85 | 'cid': cid, 86 | 'term_id': term_id_list, 87 | 'video_id': video_id, 88 | 'm3u8_url': m3u8_url, 89 | 'sign': sign 90 | } 91 | self.download_list.append(download_info) 92 | 93 | def download(self): 94 | for video_info in tqdm(self.download_list): 95 | video_name = video_info['course_name'] 96 | video_name = video_name.replace('/', '_').replace('?', '_').replace(' ', '_').replace("\\", "_").replace("/", "_") 97 | 98 | file_name = self.save_path + video_name + '_' + video_info['video_id'] + '.mp4' 99 | m3u8_url = video_info['m3u8_url'] 100 | sign = video_info['sign'] 101 | if os.path.exists(file_name): 102 | print(f'The file ${file_name} has been download, jump...') 103 | continue 104 | dm = M3U8Downloader(m3u8_url, sign, file_name, self.process_num) 105 | dm.run() 106 | 107 | 108 | if __name__ == '__main__': 109 | pp = TencentClassDownloader() 110 | pp.creat_download_info() 111 | pp.download() 112 | -------------------------------------------------------------------------------- /make_exe.sh: -------------------------------------------------------------------------------- 1 | rm -rf build main.exe main.spec 2 | 3 | pyinstaller -F main.py 4 | 5 | mv dist/main.exe . 6 | 7 | rm -rf build dist __pycache__ main.spec -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csgo-adc/TencentClassDownloader/e57094d1835df6266c90723fa44a72b8f71f270b/requirements.txt -------------------------------------------------------------------------------- /tencent_request.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | import json 5 | import logging 6 | from enum import Enum 7 | 8 | # 检测log文件夹是否存在,不存在则创建 9 | if not os.path.exists('log'): 10 | os.mkdir('log') 11 | 12 | logging.basicConfig(filename='log/output.log', 13 | level=logging.INFO, 14 | datefmt='%Y/%m/%d %H:%M:%S', 15 | format='%(asctime)s - %(name)s - %(levelname)s - %(lineno)d - %(module)s - %(message)s') 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class clarityResolution(Enum): 20 | HIGH = 1 21 | MID = 2 22 | LOW = 3 23 | 24 | 25 | class TencentRequest(object): 26 | 27 | def __init__(self, cid, term_id_list): 28 | self.cookie = None 29 | self.cid = str(cid) 30 | self.tid = str(term_id_list) 31 | self.load_cookie() 32 | self.header = { 33 | 'Referer': f'https://ke.qq.com/webcourse/${self.cid}/${self.tid}', 34 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", 35 | "Cookie": self.cookie 36 | } 37 | 38 | def load_cookie(self): 39 | try: 40 | with open('cookie', 'r') as f: 41 | self.cookie = f.read().strip() 42 | except Exception as e: 43 | print("load cookie failed") 44 | logger.error(e) 45 | 46 | def get_course_list(self): 47 | terms_url = "https://ke.qq.com/cgi-bin/course/get_terms_detail?cid=" + self.cid + "&term_id_list=%5B" + self.tid + "%5D&bkn=&preload=1" 48 | content = requests.get(terms_url, headers=self.header).text 49 | tlist = json.loads(content) 50 | chapters_list = tlist['result']['terms'][0]['chapter_info'] 51 | sub_info = [] 52 | for chapter in chapters_list: 53 | sub_info.append(chapter['sub_info']) 54 | return sub_info 55 | 56 | def get_course_info(self, cid, tid, vid, qq, r: clarityResolution = clarityResolution.LOW): 57 | m3u8_url = "" 58 | course_url = "https://ke.qq.com/cgi-proxy/rec_video/describe_rec_video?course_id=" + cid + "&file_id=" + vid + "&header=%7B%22srv_appid%22%3A201%2C%22cli_appid%22%3A%22ke%22%2C%22uin%22%3A%22" + qq + "%22%2C%22cli_info%22%3A%7B%22cli_platform%22%3A3%7D%7D&term_id=" + tid + "&vod_type=0" 59 | try: 60 | res = requests.get(course_url, headers=self.header).text 61 | course_info = json.loads(res) 62 | video_list = course_info['result']['rec_video_info']['infos'] 63 | video_list.sort(key=lambda x: x['size']) 64 | if r == clarityResolution.LOW: 65 | m3u8_url = video_list[0].get('url') 66 | elif r == clarityResolution.HIGH: 67 | m3u8_url = video_list[-1].get('url') 68 | elif r == clarityResolution.MID: 69 | length = len(video_list) 70 | if length == 3: 71 | m3u8_url = video_list[1].get('url') 72 | elif length < 3: 73 | m3u8_url = video_list[0].get('url') 74 | else: 75 | m3u8_url = video_list[int(length / 2)].get('url') 76 | 77 | sign = course_info['result']['rec_video_info']['d_sign'] 78 | return m3u8_url, sign 79 | except Exception as e: 80 | print(e) 81 | logger.error(f'get_course_info failed, error = ${str(e)}') 82 | return None, None 83 | --------------------------------------------------------------------------------