[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug提交\nabout: 提交Bug或者其他你看不懂的报错\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**简单描述**\n例：下载单个视频出错，视频连接https://xxxxx/\n\n**环境**\nPython: 3.10\n浏览器: Chrome\n操作系统: Windows 11\n\n**日志**\n```shell\n在这里粘贴日志(log.log)，注意不要把自己的身份信息贴上，避免信息泄露\n```\n\n**Screenshots**\n有截图的话，可以贴上\n\n**报错**\n```shell\n这里写报错信息\n```\n\n**补充**\n"
  },
  {
    "path": ".gitignore",
    "content": "cookies.json\n/__pycache__/\n/.idea/\n/courses/\nlog.log\n"
  },
  {
    "path": "README.md",
    "content": "### 腾讯课堂脚本\r\n要学一些东西，但腾讯课堂不支持自定义变速，播放时有水印，且有些老师的课一遍不够看，于是这个脚本诞生了。\r\n\r\n项目中可能还有bug，欢迎斧正。\r\n\r\n> 2023.2.15测试可用\r\n\r\n### 使用方法\r\n下载代码并解压，确保你安装了python，版本>=3.5\r\n\r\nwindows：\r\n首先用`EDGE`浏览器( **不要开无痕** )打开[腾讯课堂](https://ke.qq.com)，用任意方式登录，然后依次运行下面的命令(保姆级教程)\r\n``` shell\r\ncd qcourse_scripts\r\n\r\npython -m venv qcourse-venv\r\n\r\nqcourse-venv\\scripts\\activate\r\n\r\npip install -Ur requirements.txt\r\n\r\npython qcourse.py\r\n```\r\n\r\nlinux:  \r\n- `python` -> `python3`\r\n- `pip` -> `pip3`\r\n##### Tips\r\n- 若登录失效，删除`cookies.json`再重新运行脚本\r\n- **不可以**下载已经过期的课，不然下载下来的都是未解密的课，密匙文件内容为  \r\n`{\"msg\":\"用户未登录\",\"retcode\":200}`\r\n- **不可以**下载直播课，不管过没过期，在网页上看不了的就下载不了\r\n### 功能\r\n- 模拟登录，支持QQ / 微信，获取cookies\r\n- 下载单个视频\r\n- 按章节下载\r\n- 下载整个课程\r\n- 视频下载后自动转换为`mp4`格式(ffmpeg)\r\n\r\n"
  },
  {
    "path": "downloader.py",
    "content": "from pathlib import Path\n\nimport requests\nimport datetime\nfrom Crypto.Cipher import AES\nimport httpx\n\nfrom logger import logger\nfrom utils import ts2mp4\n\n\ndef add_to_16(value):\n    while len(value) % 16 != 0:\n        value += '\\0'\n    return str.encode(value)\n\n\ndef decrypt(ciphertext, key):\n    iv = ciphertext[:AES.block_size]\n    cipher = AES.new(key, AES.MODE_CBC, iv)\n    plaintext = cipher.decrypt(ciphertext[AES.block_size :])\n    return plaintext.rstrip(b'\\0')\n\n\ndef decrypt_file(filename, key):\n    with open(filename, 'rb') as f:\n        ciphertext = f.read()\n    dec = decrypt(ciphertext, key)\n    with open(filename, 'wb') as f:\n        f.write(dec)\n\n\ndef get_key(filename):\n    with open(filename, 'rb') as f:\n        key = f.read()\n    return key\n\n\ndef download(file_url, file):\n    res = requests.get(file_url)\n    with open(file, 'wb') as f:\n        f.write(res.content)\n    return 0\n\n\ndef progress(percent, width=30, filename=None):\n    left = int(width * percent // 100)\n    right = width - left\n    print(\n        filename,\n        '\\r[',\n        '■' * left,\n        ' ' * right,\n        ']',\n        f' {percent:.0f}%',\n        sep='',\n        end='',\n        flush=True,\n    )\n\n\ndef lg_download(file_url, filename, path, headers=None):\n    # 用来下载大文件，有进度条\n    file = str(Path(path, filename))\n    response = requests.get(file_url, stream=True, headers=headers)\n    size = 0\n    chunk_size = 1024\n    content_size = int(response.headers['content-length'])\n    if response.status_code == 200:\n        print(\n            '正在下载 {filename},大小: {size:.2f} MB'.format(\n                filename=filename, size=content_size / chunk_size / 1024\n            )\n        )\n\n        with open(file, 'wb') as f:\n            last_show_time = datetime.datetime.now()\n            delta_time = datetime.timedelta(seconds=1)\n            for data in response.iter_content(chunk_size=chunk_size):\n                f.write(data)\n                size += len(data)\n                if datetime.datetime.now() - last_show_time > delta_time:\n                    progress(size / content_size * 100, filename=filename)\n                    last_show_time = datetime.datetime.now()\n\n\nasync def async_download(url, path: Path, filename):\n    client = httpx.AsyncClient(\n        timeout=httpx.Timeout(10, connect=5, read=5, write=5, pool=5)\n    )\n    filename_ext = filename + '.ts'\n    base_file = path.joinpath(filename_ext)\n    size = 0\n    async with client.stream('GET', url) as response:\n        content_size = int(response.headers['content-length'])\n        if base_file.exists():\n            if base_file.stat().st_size == content_size:\n                return\n\n        with open(base_file, 'wb') as f:\n            current_size = 0\n            last_show_time = datetime.datetime.now()\n            delta_time = datetime.timedelta(seconds=1)\n            async for chunk in response.aiter_bytes(chunk_size=1024):\n                f.write(chunk)\n                size += len(chunk)\n                if size > current_size:\n                    current_size = max(size, current_size)\n                if datetime.datetime.now() - last_show_time > delta_time:\n                    progress(size / content_size * 100, filename=filename)\n                    last_show_time = datetime.datetime.now()\n    logger.info('Download '+filename)\n    await client.aclose()\n\n\ndef _download(url, path: Path, filename):\n    client = httpx.Client(timeout=httpx.Timeout(10, connect=5, read=5, write=5, pool=5))\n    filename_ext = filename + '.ts'\n    base_file = path.joinpath(filename_ext)\n    size = 0\n    with client.stream('GET', url) as response:\n        content_size = int(response.headers['content-length'])\n        if base_file.exists() and base_file.stat().st_size == content_size:\n            return\n        with open(base_file, 'wb') as f:\n            current_size = 0\n            last_show_time = datetime.datetime.now()\n            delta_time = datetime.timedelta(seconds=1)\n            for chunk in response.iter_bytes():\n                f.write(chunk)\n                size += len(chunk)\n                current_size = size\n                if datetime.datetime.now() - last_show_time > delta_time:\n                    progress(current_size / content_size * 100, filename=filename)\n                    last_show_time = datetime.datetime.now()\n\n\nasync def download_single(ts_url, key_url, filename, path):\n    print(filename, '开始下载')\n    filename = filename.replace('/', '／').replace('\\\\', '＼')\n    file: Path = path.joinpath(filename)\n    final_video_name = file.name.split('.')[0] + '.mp4'\n    if file.parent.joinpath(final_video_name).exists():\n        print(final_video_name + '已存在！')\n        return\n    await async_download(ts_url, path, filename)\n    # _download(ts_url, path, filename)\n    download(file_url=key_url, file=file)\n    key = get_key(file)\n    decrypt_file(str(file) + '.ts', key)\n    file.unlink()\n    ts2mp4(str(file) + '.ts')\n    print('\\n' + filename + ' 下载完成！')\n"
  },
  {
    "path": "downloader_m3u8.py",
    "content": "import os\nimport requests\nfrom pathlib import Path\nfrom downloader import ts2mp4, progress\n\n\ndef get_m3u8_body(url):\n    print('read m3u8 file:', url)\n    with requests.Session() as session:\n        adapter = requests.adapters.HTTPAdapter(\n            pool_connections=10, pool_maxsize=10, max_retries=10\n        )\n        session.mount('http://', adapter)\n        session.mount('https://', adapter)\n        r = session.get(url, timeout=10)\n    return r.text\n\n\ndef get_url_list(host, body):\n    lines = body.split('\\n')\n    ts_url_list = []\n    for line in lines:\n        if not line.startswith('#') and line != '':\n            if line.lower().startswith('http'):\n                ts_url_list.append(line)\n            else:\n                ts_url_list.append('%s/%s' % (host, line))\n    return ts_url_list\n\n\ndef _download_ts_file(ts_url_list, file):\n    i = 0\n    total = len(ts_url_list)\n    for ts_url in ts_url_list:\n        i += 1\n        r = requests.get(ts_url)\n        with open(file, 'ab') as f:\n            f.write(r.content)\n        progress(i / total * 100)\n    print(f'{file}下载完成')\n    return file\n\n\ndef _check_dir(path):\n    if os.path.exists(path):\n        return\n    os.makedirs(path)\n\n\ndef get_download_url_list(host, m3u8_url, url_list=None):\n    if url_list is None:\n        url_list = []\n    body = get_m3u8_body(m3u8_url)\n    ts_url_list = get_url_list(host, body)\n    for url in ts_url_list:\n        if url.lower().endswith('.m3u8'):\n            url_list = get_download_url_list(host, url, url_list)\n        else:\n            url_list.append(url)\n    return url_list\n\n\ndef download_ts(m3u8_url, path: Path, filename, begin=0):\n    _check_dir(path)\n    host = m3u8_url[: m3u8_url.rindex('/')]\n    ts_url_list = get_download_url_list(host, m3u8_url)[begin:]\n    print('Total file count:', len(ts_url_list))\n    return _download_ts_file(ts_url_list, path.joinpath(filename))\n\n\ndef download_m3u8_raw(m3u8_url, path: Path, filename, trash_first):\n    if trash_first:\n        begin = 2\n    else:\n        begin = 0\n    ts2mp4(download_ts(m3u8_url, path, filename, begin))\n\n\n__all__ = ['download_ts']\n"
  },
  {
    "path": "logger.py",
    "content": "import logging\n\n\nclass Logger(object):\n    def __init__(self):\n        self.logger = logging.getLogger()\n        self.logger.setLevel(logging.INFO)\n\n        formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')\n        log_file = './log.log'\n        file_handler = logging.FileHandler(log_file)\n        file_handler.setFormatter(formatter)\n        file_handler.setLevel(logging.DEBUG)\n\n        self.logger.addHandler(file_handler)\n\n    def debug(self, msg):\n        self.logger.debug(msg)\n\n    def info(self, msg):\n        self.logger.info(msg)\n\n    def error(self, msg):\n        self.logger.error(msg)\n\n    def warn(self, msg):\n        self.logger.warning(msg)\n\n    def critical(self, msg):\n        self.logger.critical(msg)\n\n\nlogger = Logger()\n\n"
  },
  {
    "path": "qcourse.py",
    "content": "import asyncio\nimport json\nimport re\nfrom pathlib import Path\nfrom uuid import uuid1\n\nimport browser_cookie3\nfrom requests.utils import dict_from_cookiejar, cookiejar_from_dict\n\nimport utils\nfrom logger import logger\nfrom utils import (\n    print_menu,\n    get_course_from_api,\n    get_download_url_from_course_url,\n    choose_term,\n    get_download_urls,\n    choose_chapter,\n    get_courses_from_chapter,\n    get_chapters_from_file,\n)\nfrom downloader import download_single\nfrom downloader_m3u8 import download_m3u8_raw as m3u8_down\n\nBASE_DIR = Path()\nCOURSE_DIR = BASE_DIR.joinpath('courses')\nif not COURSE_DIR.exists():\n    COURSE_DIR.mkdir()\n\n\nclass QCourse:\n\n    def login(self):\n        if not self.is_login():\n            # Todo: 默认用edge，下版本考虑添加浏览器选择\n            cj = self.get_cookies()\n            self.save_cookies(dict_from_cookiejar(cj))\n            print('登陆成功！')\n            logger.info(dict_from_cookiejar(cj))\n\n    def get_cookies(self):\n        if not self.is_login():\n            browsers = {\n                'Chrome': browser_cookie3.chrome,\n                'FireFox': browser_cookie3.firefox,\n                'Opera': browser_cookie3.opera,\n                'Edge': browser_cookie3.edge,\n                'Chromium': browser_cookie3.Chromium,\n                'Brave': browser_cookie3.brave,\n                'Vivaldi': browser_cookie3.vivaldi,\n                'Safari': browser_cookie3.safari,\n            }\n            print('登录腾讯课堂(https://ke.qq.com)，然后选择你安装的浏览器')\n            browser_menu = list(browsers.keys())\n            print_menu(browser_menu)\n            chosen = int(input('请选择你的浏览器序号：'))\n            return browsers[browser_menu[chosen]](domain_name='ke.qq.com')\n\n    @staticmethod\n    def is_login():\n        return Path('cookies.json').exists()\n\n    @staticmethod\n    def save_cookies(cookies):\n        with open('cookies.json', 'w') as f:\n            json.dump(cookies, f, indent=4)\n\n    @staticmethod\n    def load_cookie():\n        cookies = Path('cookies.json')\n        if cookies.exists():\n            return cookiejar_from_dict(json.loads(cookies.read_bytes()))\n\n    @staticmethod\n    def clear_cookies():\n        cookies = Path('cookies.json')\n        if cookies.exists():\n            cookies.unlink()\n\n\nasync def parse_course_url_and_download(video_url, filename=None, path=None):\n    if not path:\n        path = Path('courses')\n    if not filename:\n        filename = str(uuid1())\n    urls = get_download_url_from_course_url(video_url, -1)\n    if urls[1]:\n        await download_single(urls[0], urls[1], filename, path)\n    else:\n        m3u8_down(urls[0], path, filename, True)\n\n\nasync def download_selected_chapter(term_id, filename, chapter_name, courses, cid):\n    tasks = []\n    for course in courses:\n        path = Path('courses', filename, chapter_name)\n        course_name = course.get('name')\n        file_id = course.get('resid_list')\n        file_id = re.search(r'(\\d+)', file_id).group(1)\n        urls = get_download_urls(term_id, file_id, cid=cid)\n        tasks.append(\n            asyncio.create_task(\n                download_single(\n                    ts_url=urls[0], key_url=urls[1], filename=course_name, path=path\n                )\n            )\n        )\n    sem = asyncio.Semaphore(3)\n    async with sem:\n        await asyncio.wait(tasks)\n\n\ndef main():\n    menu = ['下载单个视频', '下载课程指定章节', '下载课程全部视频', '退出登录']\n    print_menu(menu)\n    chosen = int(input('\\n输入需要的功能：'))\n    qq_course = QCourse()\n    qq_course.login()\n    # =========================================\n    if chosen == 0:\n        course_url = input('输入课程链接：')\n        logger.info('URL: '+course_url)\n        asyncio.run(parse_course_url_and_download(course_url))\n    elif chosen == 1:\n        cid = str(utils.choose_course())\n        course_name = get_course_from_api(cid)\n        print('获取课程信息成功')\n        info_file = Path(course_name + '.json')\n        term_index, term_id, term = choose_term(info_file)\n        chapter = choose_chapter(term)\n        chapter_name = chapter.get('name').replace('/', '／').replace('\\\\', '＼')\n        courses = get_courses_from_chapter(chapter)\n        logger.info('cid: {}，name: {}, term: {}, chapter: {}'.format(cid, course_name, term_id, chapter_name))\n        print('即将开始下载章节：' + chapter_name)\n        print('=' * 20)\n\n        chapter_path = COURSE_DIR.joinpath(course_name, chapter_name)\n        if not chapter_path.exists():\n            chapter_path.mkdir(parents=True)\n        asyncio.run(\n            download_selected_chapter(term_id, course_name, chapter_name, courses, cid)\n        )\n    elif chosen == 2:\n        cid = str(utils.choose_course())\n        course_name = get_course_from_api(cid)\n        term_index, term_id, term = choose_term(course_name + '.json')\n        print('获取课程信息成功,准备下载！')\n        logger.info('cid: '+cid)\n        chapters = get_chapters_from_file(course_name + '.json', term_index)\n        for chapter in chapters:\n            chapter_name = chapter.get('name').replace('/', '／').replace('\\\\', '＼')\n            courses = get_courses_from_chapter(chapter)\n            print('即将开始下载章节：' + chapter_name)\n            print('=' * 20)\n\n            chapter_path = COURSE_DIR.joinpath(course_name, chapter_name)\n            if not chapter_path.exists():\n                chapter_path.mkdir(parents=True)\n            asyncio.run(\n                download_selected_chapter(\n                    term_id, course_name, chapter_name, courses, cid\n                )\n            )\n    elif chosen == 3:\n        QCourse.clear_cookies()\n    else:\n        print('请按要求输入！')\n\n\nif __name__ == '__main__':\n    main()\n"
  },
  {
    "path": "utils.py",
    "content": "import base64\r\nimport json\r\nimport os\r\nimport re\r\nimport subprocess\r\nimport sys\r\nimport time\r\nfrom pathlib import Path\r\n\r\nimport requests\r\nfrom requests.utils import dict_from_cookiejar, cookiejar_from_dict\r\nimport json\r\nimport subprocess\r\nfrom urllib.parse import urlparse, parse_qs\r\nfrom urllib.request import getproxies\r\n\r\n\r\nclass API:\r\n    ItemsUri = 'https://ke.qq.com/cgi-bin/course/get_terms_detail'\r\n    TokenUri = 'https://ke.qq.com/cgi-bin/qcloud/get_token'\r\n    MediaUri = 'https://playvideo.qcloud.com/getplayinfo/v2/1258712167/'\r\n    InfoUri = 'https://ke.qq.com/cgi-bin/identity/info'\r\n    BasicInfoUri = 'https://ke.qq.com/cgi-bin/course/basic_info?cid={cid}'\r\n    MiniAppQrcode = 'https://ke.qq.com/cgi-proxy/get_miniapp_qrcode?'\r\n    LoginState = 'https://ke.qq.com/cgi-proxy/get_login_state?'\r\n    A2Login = 'https://ke.qq.com/cgi-proxy/account_login/a2_login?'\r\n    XLogin = 'https://xui.ptlogin2.qq.com/cgi-bin/xlogin?'\r\n    PtQrShow = 'https://ssl.ptlogin2.qq.com/ptqrshow?'\r\n    PtQrLogin = 'https://ssl.ptlogin2.qq.com/ptqrlogin?'\r\n    Check = 'https://ssl.ptlogin2.qq.com/check?'\r\n    CourseList = 'https://ke.qq.com/cgi-proxy/user/user_center/get_plan_list'\r\n    VideoRec = 'https://ke.qq.com/cgi-proxy/rec_video/describe_rec_video'\r\n    DefaultAccount = 'https://ke.qq.com/cgi-proxy/accbind/get_default_account'\r\n\r\n\r\nDEFAULT_HEADERS = {'referer': 'https://ke.qq.com/webcourse/'}\r\nCURRENT_USER = {}\r\nPROXIES = getproxies()  # 当你使用魔法，避免出现check_hostname requires server_hostname\r\n\r\n\r\ndef get_course_from_api(cid):\r\n    # 获取课程信息\r\n    # url = 'https://ke.qq.com/cgi-bin/course/basic_info?cid=' + str(cid)\r\n    url = API.BasicInfoUri.format(cid=cid)\r\n    response = requests.get(url, headers=DEFAULT_HEADERS, proxies=PROXIES).json()\r\n    name = (\r\n        response.get('result')\r\n            .get('course_detail')\r\n            .get('name')\r\n            .replace('/', '／')\r\n            .replace('\\\\', '＼')\r\n    )\r\n    with open(name + '.json', 'w') as f:\r\n        json.dump(response, f, ensure_ascii=False, indent=4)\r\n    return name\r\n\r\n\r\ndef get_terms_from_api(cid, term_id_list):\r\n    # term_id_list是一个数组，里面是整数格式的term_id\r\n    params = {'cid': cid, 'term_id_list': term_id_list}\r\n    response = requests.get(API.ItemsUri, params=params, headers=DEFAULT_HEADERS, proxies=PROXIES).json()\r\n    return response\r\n\r\n\r\ndef get_terms(filename):\r\n    # 从json文件内获取学期信息\r\n    with open(filename, 'r') as f:\r\n        course_info = json.loads(f.read()).get('result')\r\n    if course_info.get('course_detail'):\r\n        terms = course_info.get('course_detail').get('terms')\r\n    else:\r\n        terms = course_info.get('terms')\r\n    return terms\r\n\r\n\r\ndef get_chapters_from_file(filename, term_index):\r\n    # 从json文件内获取章节信息\r\n    with open(filename, 'r') as f:\r\n        course_info = json.loads(f.read())\r\n    chapters = (\r\n        course_info.get('result')\r\n            .get('course_detail')\r\n            .get('terms')[term_index]\r\n            .get('chapter_info')[0]\r\n            .get('sub_info')\r\n    )\r\n    return chapters\r\n\r\n\r\ndef get_chapters(term):\r\n    return term.get('chapter_info')[0].get('sub_info')\r\n\r\n\r\ndef get_courses_from_chapter(chapter):\r\n    return chapter.get('task_info')\r\n\r\n\r\ndef get_all_courses():\r\n    # 下个版本加入该函数，用于获取用户计划内的课程，在脚本运行时不用输入cid了\r\n    # count参数最多为10，返回response中end代表有没有下一页\r\n    # 考虑到课程一般不会有几百个，所以不做分页处理\r\n    def _load_res(r):\r\n        if r:\r\n            for i in r.get('map_list'):\r\n                for j in i.get('map_courses'):\r\n                    res.append({\r\n                        'name': j.get('cname'),\r\n                        'cid': j.get('cid')\r\n                    })\r\n    res = []\r\n    page = 1\r\n    response = requests.get(API.CourseList,\r\n                            params={'page': page, 'count': '10'},\r\n                            headers=DEFAULT_HEADERS,\r\n                            cookies=load_json_cookies(),\r\n                            proxies=PROXIES).json().get('result')\r\n    _load_res(response)\r\n    while response.get('end') == 0:\r\n        page += 1\r\n        response = requests.get(API.CourseList,\r\n                                params={'page': page, 'count': '10'},\r\n                                headers=DEFAULT_HEADERS,\r\n                                cookies=load_json_cookies(),\r\n                                proxies=PROXIES).json().get('result')\r\n        _load_res(response)\r\n    return res\r\n\r\n\r\ndef choose_course():\r\n    courses = get_all_courses()\r\n    print('你的账号里有如下课程：')\r\n    for course in courses:\r\n        print(str(courses.index(course)) + '. ' + course.get('name'))\r\n    cid = courses[int(input('请输入要下载的课程序号(回车结束)：'))].get('cid')\r\n    return cid\r\n\r\n\r\ndef get_course_url(course):\r\n    # 传入课程字典，拼接成课程链接\r\n    cid = course.get('cid')\r\n    term_id = course.get('term_id')\r\n    course_id = course.get('taid')\r\n    url = 'https://ke.qq.com/webcourse/{}/{}#taid={}&vid={}'.format(\r\n        cid, term_id, course_id, course.get('resid_list')\r\n    )\r\n    return url\r\n\r\n\r\ndef get_all_urls(filename, term_index):\r\n    chapters = get_chapters_from_file(filename, term_index)\r\n    result = {}\r\n    for chapter in chapters:\r\n        chapter_name = chapter.get('name')\r\n        courses = get_courses_from_chapter(chapter)\r\n        chapter_info = {}\r\n        for course in courses:\r\n            # 这里跳过了文件类附件下载\r\n            # TODO：添加附件下载支持\r\n            chapter_info.update({course.get('name'): get_course_url(course)})\r\n        result.update({chapter_name: chapter_info})\r\n    return result\r\n\r\n\r\ndef print_menu(menu):\r\n    for item in menu:\r\n        print(str(menu.index(item)) + '. ' + item)\r\n\r\n\r\ndef run_shell(shell, retry=True, retry_times=3):\r\n    cmd = subprocess.Popen(\r\n        shell,\r\n        close_fds=True,\r\n        shell=True,\r\n        bufsize=1,\r\n        stderr=subprocess.DEVNULL,\r\n        stdout=subprocess.DEVNULL,\r\n    )\r\n\r\n    if retry and cmd.returncode != 0:\r\n        time.sleep(1)\r\n        if retry_times > 0:\r\n            return run_shell(shell, retry=True, retry_times=retry_times - 1)\r\n        print('\\nShell出现异常，请自行查看课程文件是否转码成功')\r\n    return cmd.returncode\r\n\r\n\r\ndef ts2mp4(file):\r\n    file = Path(file)\r\n    ffmpeg = Path('ffmpeg.exe')\r\n    basename = file.name.split('.ts')[0]\r\n    file_dir = file.parent\r\n    output = file_dir.joinpath(basename)\r\n    cmd = str(ffmpeg) + ' -i \"' + str(file) + '\" -c copy \"' + str(output) + '\".mp4'\r\n    # 这个命令会报错，但是我不熟悉ffmpeg，而且似乎输出视频没有毛病，所以屏蔽了错误输出\r\n    run_shell(cmd, retry_times=False)\r\n    file.unlink()\r\n\r\n\r\ndef choose_term(filename):\r\n    terms = get_terms(filename)\r\n    term_index = 0\r\n    if len(terms) > 1:\r\n        print_menu([i.get('name') for i in terms])\r\n        term_index = int(input('请选择学期：'))\r\n    term = terms[term_index]\r\n    term_id = term.get('term_id')\r\n    return term_index, term_id, term\r\n\r\n\r\ndef choose_chapter(term):\r\n    chapters = get_chapters(term)\r\n    chapter_names = [chapter.get('name') for chapter in chapters]\r\n    print_menu(chapter_names)\r\n    chapter_index = int(input('请选择章节：'))\r\n    chapter = chapters[chapter_index]\r\n    return chapter\r\n\r\n\r\ndef load_json_cookies():\r\n    cookies = Path('cookies.json')\r\n    if cookies.exists():\r\n        return cookiejar_from_dict(json.loads(cookies.read_bytes()))\r\n\r\n\r\ndef parse_video_url(video_url):\r\n    # 从播放url中提取出获取token要用的file_id和term_id\r\n    file_id = parse_qs(video_url).get('vid')[0]\r\n    term_id = urlparse(video_url).path.split('/')[-1]\r\n    return term_id, file_id\r\n\r\n\r\ndef parse_cid_url(video_url):\r\n    pattern = re.compile('https://ke.qq.com/webcourse/(.*)/')\r\n    return pattern.findall(video_url)[0]\r\n\r\n\r\ndef get_video_token(term_id, file_id):\r\n    # 获得sign, t, us这三个参数 这三个参数用来获取视频m3u8\r\n    params = {'term_id': term_id, 'fileId': file_id}\r\n    response = requests.get(\r\n        API.TokenUri, params=params, cookies=load_json_cookies(), proxies=PROXIES\r\n    ).json()\r\n    return response.get('result')\r\n\r\n\r\ndef get_video_info(file_id, t, sign, us):\r\n    \"\"\"\r\n    1258712167这个跟请求的cdn有关\r\n    但我发现这东西写死在js里，且不同账户下不同课程都是用这一个cdn\r\n    而且这东西没有通过api数据返回，初步判断它是固定的\r\n    因此将其作为固定参数\r\n    \"\"\"\r\n    url = API.MediaUri + str(file_id)\r\n    params = {'t': t, 'sign': sign, 'us': us, 'exper': 0}\r\n    response = requests.get(url, params=params, cookies=load_json_cookies(), proxies=PROXIES).json()\r\n    return response\r\n\r\n\r\ndef get_token_for_key_url(term_id, cid):\r\n    \"\"\"\r\n    这个key_url后面要接一个token，研究发现，token是如下结构base64加密后得到的\r\n    其中的plskey是要填的，这个东西来自登陆时的token去掉结尾的两个'='，也可以在cookies.json里获取\r\n    \"\"\"\r\n    if not CURRENT_USER:\r\n        cookies = Path('cookies.json')\r\n        if cookies.exists():\r\n            cookies = json.loads(cookies.read_bytes())\r\n            uin = get_uin()\r\n            CURRENT_USER['uin'] = uin\r\n            if len(CURRENT_USER.get('uin')) > 10:\r\n                # 微信\r\n                CURRENT_USER['ext'] = cookies.get('uid_a2')\r\n                CURRENT_USER['appid'] = cookies.get('uid_appid')\r\n                CURRENT_USER['uid_type'] = cookies.get('uid_type')\r\n                str_token = 'uin={uin};skey=;pskey=;plskey=;ext={uid_a2};uid_appid={appid};' \\\r\n                            'uid_type={uid_type};uid_origin_uid_type=2;uid_origin_auth_type=2;' \\\r\n                            'cid={cid};term_id={term_id};vod_type=0;platform=3'\\\r\n                    .format(uin=uin,\r\n                            uid_a2=CURRENT_USER.get('ext'),\r\n                            appid=CURRENT_USER.get('appid'),\r\n                            uid_type=CURRENT_USER.get('uid_type'),\r\n                            cid=cid,\r\n                            term_id=term_id)\r\n            else:\r\n                skey = pskey = plskey = None\r\n                CURRENT_USER['p_lskey'] = cookies.get('p_lskey')\r\n                CURRENT_USER['skey'] = cookies.get('skey')\r\n                CURRENT_USER['pskey'] = cookies.get('p_skey')\r\n                str_token = 'uin={uin};skey={skey};pskey={pskey};plskey={plskey};ext=;uid_type=0;' \\\r\n                            'uid_origin_uid_type=0;uid_origin_auth_type=0;cid={cid};term_id={term_id};' \\\r\n                            'vod_type=0'\\\r\n                    .format(uin=uin,\r\n                            skey=CURRENT_USER.get('skey'),\r\n                            pskey=CURRENT_USER.get('pskey'),\r\n                            plskey=CURRENT_USER.get('plskey'),\r\n                            cid=cid,\r\n                            term_id=term_id)\r\n            CURRENT_USER['token'] = str_token\r\n\r\n    # 直接从CURRENT_USER里读取参数\r\n    return base64.b64encode(CURRENT_USER.get('token').encode()).decode()[:-2]\r\n\r\n\r\ndef get_video_url(video_info, video_index=-1, cid=None, term_id=None):\r\n    \"\"\"\r\n    接收来自get_video_info函数返回的视频信息\r\n    根据video_index返回不同清晰度的视频ts下载链接\r\n    \"\"\"\r\n    video = video_info.get('videoInfo').get('transcodeList', None)\r\n    if video:\r\n        video = video[video_index]\r\n        video_url = video.get('url').replace('.m3u8', '.ts')\r\n        key_url = (\r\n                get_key_url_from_m3u8(video.get('url'))\r\n                + '&token='\r\n                + get_token_for_key_url(term_id=term_id, cid=cid)\r\n        )\r\n        return video_url, key_url\r\n    return video_info.get('videoInfo').get('sourceVideo').get('url'), None\r\n\r\n\r\ndef get_key_url_from_m3u8(m3u8_url):\r\n    # 传入带sign, t, us参数的m3u8下载链接\r\n    m3u8_text = requests.get(m3u8_url, proxies=PROXIES).text\r\n    pattern = re.compile(r'(https://ke.qq.com/cgi-bin/qcloud/get_dk.+)\"')\r\n    return pattern.findall(m3u8_text)[0]\r\n\r\n\r\ndef get_download_url_from_course_url(video_url, video_index=-1):\r\n    term_id, file_id = parse_video_url(video_url)\r\n    cid = parse_cid_url(video_url)\r\n    tokens = get_video_token(term_id, file_id)\r\n    video_info = get_video_info(\r\n        file_id, tokens.get('t'), tokens.get('sign'), tokens.get('us')\r\n    )\r\n    return get_video_url(video_info, video_index, cid=cid, term_id=term_id)\r\n\r\n\r\ndef get_download_urls(term_id, file_id, video_index=-1, cid=None):\r\n    tokens = get_video_token(term_id, file_id)\r\n    video_info = get_video_info(\r\n        file_id, tokens.get('t'), tokens.get('sign'), tokens.get('us')\r\n    )\r\n    return get_video_url(video_info, video_index, cid=cid, term_id=term_id)\r\n\r\n\r\ndef clear_screen():\r\n    if sys.platform.startswith('win'):\r\n        os.system('cls')\r\n    else:\r\n        os.system('clear')\r\n\r\n\r\ndef get_video_rec(cid, file_id, term_id, video_index=0):\r\n    # 腾讯课堂现在强制绑定手机号，看样子是更新了\r\n    # 新发现的接口，疑似wechat / qq通用，无需cookie，只要uin\r\n    # 返回dk( ts文件密匙 )，视频文件链接，时长，生存时间，字幕等信息\r\n    # 这里返回的rec_video_info.info里面含有不同清晰度的视频文件，越清晰的排序越靠前\r\n    params = {\r\n        'course_id': cid,\r\n        'file_id': file_id,\r\n        'term_id': term_id,\r\n        'header': '{{\"srv_appid\":201,\"cli_appid\":\"ke\",\"uin\":\"{}\",\"cli_info\":{{\"cli_platform\":3}}}}'.format(get_uin())\r\n    }\r\n    response = requests.get(API.VideoRec, headers=DEFAULT_HEADERS, params=params, proxies=PROXIES).json()\r\n    if response:\r\n        info = response.get('result').get('rec_video_info')\r\n        ts_url = info.get('infos')[video_index].get('url').replace('.m3u8', '.ts')\r\n        key = info.get('dk')\r\n        return ts_url, key\r\n\r\n\r\ndef get_uin():\r\n    response = requests.get(API.DefaultAccount,\r\n                            cookies=load_json_cookies(),\r\n                            headers=DEFAULT_HEADERS,\r\n                            proxies=PROXIES).json()\r\n    if response.get('retcode') == 0:\r\n        return response.get('result').get('tiny_id')\r\n    return input('请输入你的QQ号 / 微信uin(回车结束)：')\r\n\r\n"
  }
]