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