本文记录获取优酷直播旗下『轮播台频道』真实流媒体地址的调试过程,并用 Python 代码实现。获取的直播源经测试可在 PotPlayer 中播放。
此次调试过程中走了弯路,浪费不少时间,多亏 Jell 的提醒,让最后实现变的简单很多。博主在这里先按成功的调试步骤记录,文末补上了遇坑掉头的点。
轮播台是优酷直播下的一个频道,播放一些经典的电影电视剧。博主感觉其画质要比其他直播平台影视区的要好一点点,而且没有可见的平台水印和主播推流时放置的各种遮挡物。
浏览器调试
1、用头图的直播间 “成龙经典电影高分动作大片 [24h]” 测试,其链接形式如下,里面的 8021903 就是直播间 id:
https://vku.youku.com/live/ilproom?spm=a2hcb.20025885.m_16249_c_59932.d_1&id=8021903&scm=20140670.rcmd.16249.live_8021903&scm=20140719.rcmd.16249.native_http%3A%2F%2Fvku.youku.com%2Flive%2Filproom%3Fid%3D8021903
2、Chrome 打开一个无痕窗口,F12 启动开发者工具,请求上述地址直到开始播放。在 “NetWork” 中观察请求过程。发现 ts 切片前请求的 m3u8 格式播放地址:
https://lvo-live.youku.com/vod2live/03000700005CACE3B173FEA330EC2DBB6D42B3-B932-464D-8DCC-E820CFF5DA5D-1-110-LIVE-494-0421-0240-33484_mp4hd2v3.m3u8?title=成龙经典电影高分动作大片[24h]&ver=1.0.0&uid=0&log_type=log_type&aliyun_uuid=9208F+xe6g0CAXWYWXEb8JRs&cdnQuality=720p&quality=4&multi_raw_stream=03000700005CACE3B173FEA330EC2DBB6D42B3-B932-464D-8DCC-E820CFF5DA5D-1-110-LIVE-494-0421-0240-33484&ccode=live05010101&expire=21600&psid=B752DB840EE3EA742FD166E79A6518DD&ups_client_netip=222.222.222.222&ups_ts=1588954323&ups_userid=0&utid=9208F+xe6g0CAXWYWXEb8JRs&vid=8021903_8026947&fn=03000700005CACE3B173FEA330EC2DBB6D42B3-B932-464D-8DCC-E820CFF5DA5D-1-110-LIVE-494-0421-0240-33484_mp4hd2v3&vkey=Be1441765a4dbbbeb65edb66069df9f2b
在 PotPlayer 里测试可以播放,精简下参数,链接这样就行了:
http://lvo-live.youku.com/vod2live/03000700005CACE3B173FEA330EC2DBB6D42B3-B932-464D-8DCC-E820CFF5DA5D-1-110-LIVE-498-0421-0240-3566_mp4hd2v3.m3u8?&expire=21600&psid=1&ups_ts=1588838872&vkey=
保留下来的 expire 参数固定为 21600,ups_ts 为 10 位时间戳,vkey 需要保留但值可为空。每个直播间的这段 streamName 不同:03000700005CACE3B173FEA330EC2DBB6D42B3-B932-464D-8DCC-E820CFF5DA5D-1-110-LIVE-498-0421-0240-3566
3、寻找 streamName。按载入时间向上查找,找到一条链接的返回数据中有我们要的 streamName:
https://acs.youku.com/h5/mtop.youku.live.com.livefullinfo/1.0/?jsv=2.4.2&appKey=24679788&t=1588924371934&sign=c05c831fd52e0e19e7a88142e15fdb9c&api=mtop.youku.live.com.livefullinfo&v=1.0&AntiCreep=true&type=jsonp&dataType=jsonp&callback=mtopjsonp1&data={"liveId":"8021903","app":"Pc"}
删除其中不要的参数,有这几个必需的:
jsv=2.4.2
api=mtop.youku.live.com.livefullinfo
v=1.0
AntiCreep=true
type=jsonp
dataType=jsonp
callback=mtopjsonp1
appKey=24679788,固定值。
t=1588924371934,13 位时间戳。
sign=c05c831fd52e0e19e7a88142e15fdb9c,一个 md5 值。
data={"liveId":"8021903","app":"Pc"},liveId 为直播间 id,app 值固定为 Pc。
4、现在只要找到 sign 即可。继续查上一步中 js 的发起点,在 https://g.alicdn.com/mtb/lib-mtop/2.5.0/mtop.js 中找到如下代码:
if (d.H5Request === !0) {
var f = "//" + (d.prefix ? d.prefix + "." : "") + (d.subDomain ? d.subDomain +".":"") + d.mainDomain + "/h5/" + c.api.toLowerCase () + "/" + c.v.toLowerCase () + "/"
, g = c.appKey || ("waptest" === d.subDomain ? "4272" : "12574478")
, i = (new Date).getTime ()
, j = h (d.token + "&" + i + "&" + g + "&" + c.data)
, k = {
jsv: w,
appKey: g,
t: i,
sign: j
}
很明显 j = h (d.token + "&" + i + "&" + g + "&" + c.data) 就是 sign 的计算过程,在下方断点,确定其中 i 是 13 位时间戳,g 是 appKey,c.data 是 {"liveId":liveId,"app":"Pc"}。同时可以验证 h 函数是个正常的 md5,函数定义在 mtop.js 第 34 行。
5、只剩找 d.token 了。继续向上查 d.token 在哪里生成的,找到如下代码:
var x = "_m_h5_c", y = "_m_h5_tk", z = "_m_h5_tk_enc";
……
o.prototype.__getTokenFromCookie = function () {
var a = this.options;
return a.CDR && k (x) ? a.token = k (x).split (";")[0] : a.token = a.token || k (y),
a.token && (a.token = a.token.split ("_")[0]),
p.resolve ()
}
……
function k (a) {
var b = new RegExp ("(?:^|;\\s*)" + a + "\\=([^;]+)(?:;\\s*|$)").exec (document.cookie);
return b ? b [1] : void 0
}
观察到 k 函数读取返回 cookie 中的_m_h5_tk 字段,getTokenFromCookie 中用 “_” 分割后返回的就是 token 了。如 _m_h5_tk:ed205a6802d3068fbaf8b3cb41dce19e_1589013137498,取 ed205a6802d3068fbaf8b3cb41dce19e。
6、至此 token、sign 等参数来源都搞清楚了,浏览器中调试完毕。
Python 实现
用 Python 实现时先在 session 中获取 cookie 中的_m_h5_tk,计算出 sign ,然后带上 cookie 请求获取 streamName,拼接其他参数后即为播放地址 。其他直播平台可参看我的 GitHub。
附上 Python 代码:
import requests
import time
import hashlib
import json
def get_real_url(liveId):
try:
tt = str(int(time.time()*1000))
data = json.dumps({"liveId":liveId,"app":"Pc"}, separators=(',', ':'))
url = 'https://acs.youku.com/h5/mtop.youku.live.com.livefullinfo/1.0/?appKey=24679788'
s = requests.Session()
cookies = s.get(url).cookies
token = requests.utils.dict_from_cookiejar(cookies).get('_m_h5_tk')[0:32]
sign = hashlib.md5((token + '&' + tt + '&' + '24679788' + '&' + data).encode('utf-8')).hexdigest()
params = {
't': tt,
'sign': sign,
'data': data
}
response = s.get(url, params=params).json()
name = response.get('data').get('data').get('name')
streamName = response.get('data').get('data').get('stream')[0].get('streamName')
real_url = 'http://lvo-live.youku.com/vod2live/{}_mp4hd2v3.m3u8?&expire=21600&psid=1&ups_ts={}&vkey='.format(streamName, int(time.time()))
except:
name = real_url = '请求错误'
return name, real_url
liveId = input('请输入优酷轮播台liveId:\n')
real_url = get_real_url(liveId)
print('该直播间地址为:')
print(real_url[0])
print(real_url[1])
掉坑过程
当时在浏览器调试的第 3 步中,并没有想到直接寻找 streamName,而是向上追踪 m3u8 格式请求的发起点,找到下面这个请求返回的数据里 h264PlayUrl 就是我们要的播放地址:
https://acs.youku.com/h5/mtop.youku.live.com.liveplaycontrol/4.0/?jsv=2.5.0&appKey=23536927&t=1588954324805&sign=15868e0a40629bc0850387528f1d0982&api=mtop.youku.live.com.liveplaycontrol&v=4.0&H5Request=true&AntiFlood=true&type=jsonp&dataType=jsonp&callback=mtopjsonp3&data={"ckey":"123#M35DblB8skRuuQbxlDng8ldEzQXHO1A9926XuojAbl/HzaHKDd88IT4aL2xadyAf5RcptR926B3MD/0uI29SdtTYTwR1OR5WoMdCMezIUzGrUm8pOz29k1ObyXunExj6RyykKSqN+8hHuDMFH9I3eB3OFibhMDILTyNHCPA3xq+CB77aO0YcR51k+eUHPeBfQPZKjCsdKtq6NNW4nDPtU/noMuEXQ3mg7H7J3jDTyxiNtHFyhLbzqmvoTZ2PPpOeBLu9sxqiPXcwWFuaYREtVTGezPZim5Sqi7X+Du37jzvQ1Ac37zkLXyrkPpRxFJn9wAwiSrqd57vrqkx2e7WYiKAdC9Oo2+M6enbpdtdGGuxVW266uNdn/962JsgTjuZhLyqRKaR8GB+oivrdwcwUaPJYOwNr9fcfMVCVBUTXpODBnJTB/YzxivqJUFWOGvyLEZTyDcsjvjCVG0SszxgdqGHCwreqgvEWnlUXq7jbk9paw/1SxGQD9/38IiAt7CgxZNQOQYSNx4NuaEQ5zUX5Dl3UNp/0xFc/toYHdftepXZnAVt0Y8XHVxX5lzF9J6hwuCdB19MiISAyljzisBVYblhlIwGn4A/OK/2rxMQ8H9qZr2NRDh3QH8BV/KnFtjqtWgdMc+IvAJXrSH8SsqYUAJ1BeTLKb7OzwBWmeBHDA7EQENuWDMW+APOGszkbl+bpTDV0sVF=","encryptRClient":"","liveId":8021903,"sceneId":0,"reqQuality":0,"ad":"{\"site\":\"youku\",\"aw\":\"w\",\"p\":1,\"vs\":\"1.0\",\"vc\":0,\"bt\":\"pc\",\"rst\":\"mp4\",\"dq\":0,\"isvert\":0,\"wintype\":\"h5\",\"bf\":0,\"utdid\":\"\",\"fu\":0,\"os\":\"win\",\"dvh\":1058,\"dvw\":426,\"ccode\":\"live05010101\",\"lid\":8021903}","cna":"9208F+xe6g0CAXWYWXEb8JRs","playAbilities":"{\"decode_resolution_FPS\":\"1080p_50\",\"abrPlay\":1,\"vrPlay\":1}","keyIndex":"web01","ccode":"live05010101","app":"Pc","refer":""}
格式化参数后发现大部分都是固定的,变化的有:liveId 是直播间号;t 是 13 位时间戳;sign 是个 md5;而 ckey 很复杂。
jsv: 2.5.0
appKey: 23536927
t: 1588954324805
sign: 15868e0a40629bc0850387528f1d0982
api: mtop.youku.live.com.liveplaycontrol
v: 4.0
H5Request: true
AntiFlood: true
type: jsonp
dataType: jsonp
callback: mtopjsonp3
data: {"ckey":"123#M35DblB8skRuuQbxlDng8ldEzQXHO1A9926XuojAbl/HzaHKDd88IT4aL2xadyAf5RcptR926B3MD/0uI29SdtTYTwR1OR5WoMdCMezIUzGrUm8pOz29k1ObyXunExj6RyykKSqN+8hHuDMFH9I3eB3OFibhMDILTyNHCPA3xq+CB77aO0YcR51k+eUHPeBfQPZKjCsdKtq6NNW4nDPtU/noMuEXQ3mg7H7J3jDTyxiNtHFyhLbzqmvoTZ2PPpOeBLu9sxqiPXcwWFuaYREtVTGezPZim5Sqi7X+Du37jzvQ1Ac37zkLXyrkPpRxFJn9wAwiSrqd57vrqkx2e7WYiKAdC9Oo2+M6enbpdtdGGuxVW266uNdn/962JsgTjuZhLyqRKaR8GB+oivrdwcwUaPJYOwNr9fcfMVCVBUTXpODBnJTB/YzxivqJUFWOGvyLEZTyDcsjvjCVG0SszxgdqGHCwreqgvEWnlUXq7jbk9paw/1SxGQD9/38IiAt7CgxZNQOQYSNx4NuaEQ5zUX5Dl3UNp/0xFc/toYHdftepXZnAVt0Y8XHVxX5lzF9J6hwuCdB19MiISAyljzisBVYblhlIwGn4A/OK/2rxMQ8H9qZr2NRDh3QH8BV/KnFtjqtWgdMc+IvAJXrSH8SsqYUAJ1BeTLKb7OzwBWmeBHDA7EQENuWDMW+APOGszkbl+bpTDV0sVF=","encryptRClient":"","liveId":8021903,"sceneId":0,"reqQuality":0,"ad":"{\"site\":\"youku\",\"aw\":\"w\",\"p\":1,\"vs\":\"1.0\",\"vc\":0,\"bt\":\"pc\",\"rst\":\"mp4\",\"dq\":0,\"isvert\":0,\"wintype\":\"h5\",\"bf\":0,\"utdid\":\"\",\"fu\":0,\"os\":\"win\",\"dvh\":1058,\"dvw\":426,\"ccode\":\"live05010101\",\"lid\":8021903}","cna":"9208F+xe6g0CAXWYWXEb8JRs","playAbilities":"{\"decode_resolution_FPS\":\"1080p_50\",\"abrPlay\":1,\"vrPlay\":1}","keyIndex":"web01","ccode":"live05010101","app":"Pc","refer":""}
先从最复杂的 ckey 入手,Ctrl + Shift + F 全局搜索 ckey,在 https://g.alicdn.com/live-platform/dawnPlayer/1.1.6/js/ply.js 中赋值生成 ckey 的代码块:
this.ckey = "",
this.maxCount = t.maxCount || 5,
this.defaultCkey = t.defaultCkey || u,
this.onlyHost = t.onlyHost ? 1 : 0,
this.startTime = (new Date).getTime (),
this.init ()
}
return (0,
a.default)(e, [{
key: "init",
value: function () {
if (!window.UAOpt) {
var e = {};
this.uaKey = "collina_" + Date.now (),
e.OnlyHost = this.onlyHost,
e.SendMethod = 9,
e.FormId = "login_submit_form",
e.ExTarget = ["password"],
e.LogVal = this.uaKey,
window [e.LogVal] = "",
e.Token = (new Date).getTime () + ":" + Math.random (),
e.MaxMCLog = this.maxCount,
e.MaxKSLog = this.maxCount,
e.MaxMPLog = this.maxCount,
e.MaxTCLog = this.maxCount,
e.MaxFocusLog = this.maxCount,
e.ResHost = "aeu.alicdn.com",
e.Flag = 1670350,
window.UA_Opt = e
}
if (window [window.UA_Opt.LogVal] ? (this.endTime = (new Date).getTime (),
this.ckey = window [window.UA_Opt.LogVal]) : this.addCallback (),
void 0 === window.acjs) {
this.startTime = (new Date).getTime ();
var t = document.createElement ("script");
t.src = "//af.alicdn.com/js/uac.js",
document.head.appendChild (t)
}
}
}, {
key: "addCallback",
value: function () {
var e = this;
window.UA_Opt.callback = function () {
e.endTime = (new Date).getTime (),
e.ckey = window [window.UA_Opt.LogVal],
e.callback && (e.callback ({
ckey: e.ckey
}),
e.callback = null)
}
}
}
最原始的 ckey 是个空值,经过一番运算后被赋值 window [window.UA_Opt.LogVal]。document.head.appendChild 在 head 里插入一个 uac.js,下断点调试或跟踪播放链接的调用栈,同样也可找到 https://af.alicdn.com/js/uac.js 和 https://af.alicdn.com/AWSC/uab/1.123.6/collina.js 两个 JS 文件。
小插曲:线上请求的 collina.js 里有个注释导致在 Chrome 中调试时无法格式化代码,只得下载到本地格式化后,使用 Charles 或 Fiddler 调用本地 js。
collina.js 中相关代码块:
!function () {
function e (t, s, d, p, u) {
……
}
……
for (var p = 1; void 0 !== p; ) {
var u = 7 & p,
v = p >> 3,
g = 7 & v;
switch (u) {
case 0:
!function () {
b = {},
p = 2
}
();
break;
case 1:
var f = [];
f.unshift ([]);
var l = "__acjs_awsc_123",
C = [],
b = window.UA_Opt,
k = !b;
p = k ? 0 : 2;
break;
case 2:
window.UA_Opt = b;
var m = window.UA_Opt,
A = m.loadTime;
p = A ? 3 : 4;
break;
case 3:
var S = new Date;
m.loadTime = +S,
p = 4;
break;
case 4:
var x = 0,
w,
O,
j,
y = 0,
E = "",
R = {};
y = 6,
e (25),
p = void 0
}
}
}();
其中 e (25) 运算完成后即可返回我们要的 window [window.UA_Opt.LogVal] 值。博主到这里就没有继续了,技术太菜,没办法在 Python 中还原整个过程,也无法验证 ckey 参数是否正确。有大神说可以用 PyExecjs 模块执行 js,但因为没有 window 对象,要在 Node.js 中安装 jsdom 等模块;或者 Selenium 执行 collina.js。
关于优酷的 ckey 参数,博主曾在前两篇博文 《Node.js 安装 jsdom 和 canvas 模块》 和 《一次 requests.get 的踩坑记录》中也有过提及。


