全网开源首发!!请帮忙点点赞+关注吧,谢谢!
未经许可禁止转载,禁止任何修改后二次传播。
卖二手课的商家请自行绕道,严禁使用文本提供的开源程序进行任何的盈利行为!
想把视频下载下来存到云盘,但官网并没有提供外置存储的功能。网上都是些引流要掏馒头帮下载的文章,那就自己来吧,生为大学谋福利!
零、更新日志
12月22日更新:源代码在第五章,软件在第一章,点赞+关注自取。
12月23日更新:增加了使用账号密码登录,不用去找Authorization参数了
12月25日更新:报错:“正由另一进程使用,因此该进程无法访问此文件。”把maxtreads改到32就好了,可能因为网速上限太大了,多线程下载造成资源抢占了。错误解决方案提供者:里欧布鲁斯
12月26日更新:报错:“SyntaxError:缺少标识符、字符串或数字”;这是因为课程包结构不一样,导致报错。已经将字典提取改成了正则匹配,最大化适配课程包结构。(下载链接已替换为新版本链接)
12月29日更新:v1.0系列中调用JS文件的解密也会引起“SyntaxError:缺少标识符、字符串或数字”的错误。目前纯算解密已完成,还加了断点续传功能。v2.0系列正式发布!!在此感谢Endlin Boeingstein的协助测试!
12月30日更新:昨天忘记把对m3u8链接加文件存在检测和断点续传了,今天给完善了。
顺带说一句:程序内下载速度没做限制,你带宽多少就能跑到多少。但是下视频的时候如果可以自行做一下限速处理吧,尽量在10MB/s以内,别把启航上传带宽拉太高了。
一、可执行文件
大学生不懂代码但是只想要下视频的点这个:启航课程视频下载.exe
只下这个就行,依赖项全都打包好了,解压出来双击运行,按照运行框的提示操作就行。
二、实现流程
1、通过用户名密码登录
2、获取账号下已有课程
3、选择需要下载的课程
4、下载视频,或者只保留视频解析链接
三、视频链接的解密处理
启航以前的视频播放采用的是M3U8传输,启航没有对M3U8的链接做加密,所以不需要解密。
m3u8全称为MP3 Playlist file version 8,被用于HLS(HTTP Live Streaming)流媒体协议中,作为播放列表文件来指引播放器按顺序加载视频片段。
现在视频播放是传输的MP4文件,HTTP流式传输(没具体分析,应该是)。启航对这种视频链接进行了加密处理,所以在获取视频链接(M3U8文件和MP4文件)时我分别对这两种链接的做了不同的处理。
在get_mp4_url()方法中有while循环并且校验了解密后链接的合法性是因为我不明确是我解密代码扒得不全还是因为内存污染的原因,导致有时候解密出来的链接有几个字符是乱码,所以做了while循环和合法性校验,如果有乱码重新请求新的加密链接再次解密。
对于视频的下载,M3U8文件使用的是GitHub上的下载器,没有再编写代码来处理他。MP4链接编写了一个简易的下载代码来完成。
M3U8下载器GitHub主页链接:N_m3u8DL-CLI_v3.0.2
四、免责声明
本文所提供的信息仅供个人学习和研究之用,不得用于任何商业目的或非法活动。在尝试下载或处理任何在线课程视频时,请确保您有权访问并下载这些内容,并遵守相关的版权法律法规。
本文不保证所提供的下载方法或工具完全有效或安全,也不对任何因使用本文内容而导致的损失或损害承担责任。在使用本文提供的方法或工具时,请自行承担风险,并确保您的行为符合相关法律法规的规定。
五、源代码
import base64
import json
import re
import requests
import os
os.system('chcp 65001') # 将cmd的显示字符编码从默认的GBK改为UTF-8
class Download_QiHang_Video:
def __init__(self, account, password, workDir='./视频存放'):
self.video_name_vid_list = []
self.authorization = self.login(account, password)
self.workDir = workDir
self.productName = None
self.productCurriculumId = None
self.userProductId = None
self.get_ProductInfoDic() # 初始化程序
def login(self, account, password):
headers = {
'accept': 'application/json, text/plain, */*',
'accept-language': 'zh-CN,zh;q=0.9',
'cache-control': 'no-cache',
'content-type': 'application/json;charset=UTF-8',
'origin': 'https://www.iqihang.com',
'referer': 'https://www.iqihang.com/ark/exam26?code=11',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
}
json_data = {
'clientType': 2,
'address': 'https://www.iqihang.com/ark/exam26',
'browser': 'chrome',
'browserVersion': '131.0.0.0',
'loginAddress': '',
'loginProduct': '启航教育',
'loginTerminal': 4,
'loginType': 2,
'loginVersion': '1.0.0',
'network': '',
'networkType': '',
'os': 'Win32',
'osVersion': 'window 10',
'plant': '',
'plantType': '',
'source': '启航教育',
'sourceType': 17,
'terminal': 4,
'version': '1.0.0',
'account': f'{account}',
'password': f'{password}',
}
response = requests.post('https://www.iqihang.com/api/ark/sso/login', headers=headers, json=json_data)
dataJson = response.json()
if dataJson['code'] == 0:
token = dataJson["data"]["token"]
print("\033[1;32m登录成功!!\033[0m")
return f"Bearer {token}"
else:
msg = dataJson['msg']
raise ValueError(f'{msg}')
# =====获取账户下拥有的课程信息并选择需要下载哪个课程包 程序初始化入口=====
def get_ProductInfoDic(self):
ProductInfoDic_list = []
index = 0
headers = {
'authorization': f'{self.authorization}',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
}
params = {
'status': '',
'type': '1',
}
response = requests.get('https://www.iqihang.com/api/ark/web/v1/user/course/course-list', params=params, headers=headers)
dataJson = response.json()
for data in dataJson["data"]:
userProductId = data["id"]
productName = data["productName"]
productCurriculumId = data["productCurriculumId"]
ProductInfoDic = {
"userProductId": userProductId,
"productName": productName,
"productCurriculumId": productCurriculumId
}
ProductInfoDic_list.append(ProductInfoDic)
print(f"序号{index}:{productName}")
index += 1
index = int(input("\033[1;32m请选择序号(回车确认):\033[0m"))
print(f"\033[1;32m开始处理“{ProductInfoDic_list[index]['productName']}”\033[0m")
ProductInfoDic = ProductInfoDic_list[index]
self.productName = ProductInfoDic['productName']
self.productCurriculumId = ProductInfoDic['productCurriculumId']
self.userProductId = ProductInfoDic['userProductId']
self.get_vid()
# =====获取产品包含的的视频详细信息,主要需要视频名称和vid=====
def get_ProductJson(self, productCurriculumId):
headers = {
'authorization': f'{self.authorization}',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
}
response = requests.get(f'https://www.iqihang.com/api/ark/web/v1/course/catalog/{productCurriculumId}', headers=headers)
return response.json()
# =====从文件中提取vid=====
def get_vid(self):
ProductJson = self.get_ProductJson(self.productCurriculumId)
try:
dataJson = ProductJson["data"]["courseNodes"]
resourceName_list = re.findall(r'\'resourceName\': (.*?),', str(dataJson), re.S)
vid_list = re.findall(r'\'vid\': (.*?),', str(dataJson), re.S)
if len(resourceName_list) == len(vid_list):
for i in range(len(resourceName_list)):
name = resourceName_list[i].replace("\'", "")
vid = vid_list[i].replace("\'", "")
if vid_list[i] != 'None':
self.video_name_vid_list.append((name, vid))
else:
raise "匹配到的名称和vid数量不一致, 请联系作者"
except Exception as e:
raise f"get_vid()报错:{e}"
# =====获取视频链接======
def get_video_url(self, name, vid):
# 非9位vid视频返回m3u8链接
if len(vid) != 9:
url = self.get_m3u8_url(vid)
else:
# 9位vid的视频直接返回.MP4链接
url = self.get_mp4_url(vid)
return url
# 非9位vid视频返回m3u8链接
def get_m3u8_url(self, vid):
headers = {
'Host': 'p.bokecc.com',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
'referer': 'https://www.iqihang.com/',
'accept-language': 'zh-CN,zh;q=0.9',
}
params = {
'vid': f'{vid}',
'siteid': 'A183AC83A2983CCC', # 固定写死
}
response = requests.get('https://p.bokecc.com/servlet/getvideofile', params=params, headers=headers)
dataJson = json.loads(response.text.replace('null(', '')[:-1])
try:
url = dataJson["copies"][1]["playurl"]
return url
except:
return None
# 9位vid的视频返回MP4链接
def get_mp4_url(self, vid):
headers = {
'accept': 'application/json, text/plain, */*',
'accept-language': 'zh-CN,zh;q=0.9',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
'authorization': self.authorization,
}
session = requests.session()
session.headers = headers
while True:
params = {
'id': '1', # 应为resourceId,但实际不影响,但不可为空不可删除
'userProductId': self.userProductId, # 用户产品id
'vid': vid,
'isAllowAudition': '0', # 可选,可删除
}
response = session.get('https://www.iqihang.com/api/ark/web/v1/baijiayun/player/token', params=params, allow_redirects=False)
token = response.json()["data"]["token"]
params = {
"vid": vid,
"token": token
}
response = session.get('https://www.baijiayun.com/vod/video/getPlayUrl', params=params)
dataJson = json.loads(response.text)
enc_url_list = []
for i in ['1080p', 'superHD']:
for j in range(0, 3):
enc_url_list.append(dataJson["data"]["play_info"][i]["cdn_list"][j]["enc_url"])
for enc_url in enc_url_list:
url = self.decodeUrl(enc_url)
if url != -1:
return url
# =====下载视频=====
def Download(self, name, url):
if url is not None:
if ".m3u8?" in url:
self.download_m3u8(name, url)
else:
self.download_mp4(name, url)
# M3U8 使用第三方下载器下载,不再编写处理m3u8文件的代码
def download_m3u8(self, name, url):
if len(name.split(" ")) > 1:
workDir = self.workDir + "/" + name.split(" ")[0]
else:
workDir = self.workDir
cmd = f'N_m3u8DL-CLI_v3.0.2.exe "{url}" --workDir "{workDir}" --saveName "{name}" --maxThreads "32" --retryCount "3" --enableDelAfterDone'
os.system(cmd)
# mp4 使用requests下载
def download_mp4(self, file_name, url):
# 确保文件名以 .mp4 结尾
if not file_name.endswith(".mp4"):
file_name += ".mp4"
# 分章节目录
if len(file_name.split(" ")) > 1:
workDir = self.workDir + "/" + file_name.split(" ")[0]
else:
workDir = self.workDir
# 构建完整文件路径
full_path = os.path.join(workDir, file_name)
# 创建下载路径目录(如果不存在)
os.makedirs(workDir, exist_ok=True)
# 检查文件是否已经存在并完整
existing_size = os.path.getsize(full_path) if os.path.exists(full_path) else 0
# 获取远程文件总大小
try:
head_response = requests.head(url)
head_response.raise_for_status()
total_size = int(head_response.headers.get('content-length', 0))
except requests.exceptions.RequestException as e:
print(f"无法获取文件大小:{e}")
return
if existing_size >= total_size:
print(f"文件已存在且完整,跳过下载:{full_path}")
return
# 设置 HTTP 头部,指定下载的起始字节位置
headers = {"Range": f"bytes={existing_size}-"} if existing_size > 0 else {}
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
try:
# 发送 HTTP GET 请求下载文件
with requests.get(url, stream=True, headers=headers) as response:
# 检查请求状态码
if response.status_code not in (200, 206):
raise requests.exceptions.RequestException(f"Unexpected status code: {response.status_code}")
# 将文件追加写入本地
with open(full_path, "ab") as file:
for chunk in response.iter_content(chunk_size=8192): # 每次读取 8KB
if chunk: # 确保 chunk 非空
file.write(chunk)
existing_size += len(chunk)
# 显示进度条
print(f"\r正在下载 《{file_name}》: {existing_size}/{total_size} bytes ({(existing_size / total_size) * 100:.2f}%)", end="")
print(f"\n\033[1;32m下载完成:\033[0m{file_name}")
except requests.exceptions.RequestException as e:
print(f"下载失败:{e} (重新运行如果还有这个报错请联系作者)")
def decodeUrl(self, enc_url):
try:
encoded_str = enc_url.replace("bjcloudvod://", '')
# 修复填充
if len(encoded_str) % 4 != 0:
encoded_str += "=" * (4 - len(encoded_str) % 4)
# 解码为字节
decoded_bytes = base64.b64decode(encoded_str)
# 如果需要将字节转换为字符串
decoded_str = decoded_bytes.decode("utf-8")
n = ord(decoded_str[0]) % 8
decoded_str = decoded_str[1:]
s = [] # 初始化空列表
l = 0 # 索引从 0 开始
# 循环遍历字符串 t
for l, a in enumerate(decoded_str):
p = l % 4 * n + l % 3 + 1
s.append(chr(ord(a) - p)) # 计算新的字符并添加到列表
# 将处理后的字符列表合并成字符串
dec_url = ''.join(s)
return dec_url
except:
return -1
if __name__ == '__main__':
print('\033[1;31m视频将存放在:“.\视频存放”目录下\033[0m')
account = str(input("\033[1;32m请输入账号(回车确认):\033[0m")).replace("\'", "").replace("\"", "") or False
password = str(input("\033[1;32m请输入密码(回车确认):\033[0m")).replace("\'", "").replace("\"", "") or False
if account is not False and password is not False:
try:
QiHang = Download_QiHang_Video(account=account, password=password) # 实例化对象
for name, vid in QiHang.video_name_vid_list:
url = QiHang.get_video_url(name, vid) # 获取url
QiHang.Download(name, url) # 下载视频
print("\033[1;32m下载完成!\033[0m")
except Exception as e:
print(f"出现错误:\033[1;31m{e}\033[0m")
else:
print("\033[1;31m请输入账号和密码\033[0m")
input("按任意键退出...")