PP体育的直播源加密其实也比较简单:1、并没有什么复杂算法,大部分可在加载的 JavaScript 中搜到;2、最重要是找到突破口。
按照以往经验,先打开一个直播间http://sports.pptv.com/sportslive/pg_h5live?sectionid=184837&matchid=,右键查看网页源代码,并没有找到 flv 或 m3u8 类似的媒体文件地址。点击播放后,发现 Network 中每 5 秒加载一个 block,类似 ts 切片,观察 block 的地址:http://migu.live.pptv.com/live/9a3170d0992c89e6549bc7f9728445f9/1620558265.block?k=6219a6d594dd5c4f7e13ccae9d5b8f6d-13a6-1620601507&vvid=a321cbd9-336d-4d68-cc04-50cd62a36e17&type=mhpptv&o=0&sv=4.1.18。url 中有很多特征参数,比如主机地址、32 位字符串、时间戳.block、参数 k、vvid、type 等。既然没有找到直接返回 m3u8 或类似地址的请求,猜测可能是经过拼接了。
一、按加载顺序往前找,看有没有返回类似特征参数的请求,找到这个:http://oneplay.api.pptv.com/ups-service/play?cipher=xxxxx&encryptParams=xxxxxx&vvId=a321cbd9-336d-4d68-cc04-50cd62a36e17&format=jsonp&cb=getPlayEncode_1620558308095,响应内容如下(省略部分),其中有"流畅360P、蓝光1080P"等视频清晰度信息;sh、bh、rid、key 的值也和上面参数类似,可以从这里入手,看获取到数据后去干嘛了。
"media": {
"id": 512484,
"mediaType": 4,
"resource": {
"stream": {
"live2": {
"item": [{
"dt": {
"st": "Sat May 15 14:49:25 2021 UTC",
"flag": 15,
"sh": {
"content": "migu.live.pptv.com"
},
"bh": {
"content": "aliyun.live.pptv.com"
},
"iv": "5788a9b4a9f382b31d981e6d03b83a99",
"key": {
"content": "d91c85771cb3a28d5b3299ff28905b7b-f2c9-1621133365"
},
"bwt": 0
},
"mt": "6",
"format": "h264",
"bitrate": 664,
"rid": "8c1a5ccf1269ee039997114060d8c6c4"
}
],
"delay": 45,
"interval": 5
}
},
"videoId": "218934242",
"reqId": "b4c241bf-d33c-42e8-9af4-85bbc0fa13b2",
"resourceType": 4
},
"resourceInfo": {
"item": [{
"bitrate": 664,
"encrypt": "VBR",
"filesize": 0,
"format": "h264",
"fps": 25,
"ft": 0,
"ftn": "流畅360P",
"height": 360,
"mt": "6",
"protocol": "live2",
"rid": "8c1a5ccf1269ee039997114060d8c6c4",
"vip": 0,
"watch": 1,
"width": 640
},
]
}
}
二、先在发起上面请求的地方下断点再单步执行,找到相关代码如下,只贴关键代码和注释说明:
// 下面是发起http://oneplay.api.pptv.com/ups-service/play请求的地方,返回各种直播流参数后再下一步处理,load事件绑定的onLocalScrEleLoadedSuccess函数会继续执行。
return s()(e, [{
key: "load",
value: function (e, t) {
this.clear();
var i = this;
this.programInfoAddr = e,
this.getPlayEncodeName = "getPlayEncode_" + (new Date).getTime(),
window[this.getPlayEncodeName] = function (e) {
i.programInfo = e
},
t === o.i.PPVIDEO ? this.programInfoAddr += "&cb=" + this.getPlayEncodeName : t === o.i.SULSP && (this.programInfoAddr += "&callback=" + this.getPlayEncodeName),
this.scriptEle = document.createElement("script"),
this.scriptEle.setAttribute("src", this.programInfoAddr),
this.scriptEle.setAttribute("type", "text/javascript"),
this.scriptEle.setAttribute("async", "true"),
this.scriptEle.addEventListener("load", i.onLocalScrEleLoadedSuccess),
this.scriptEle.addEventListener("error", i.onLocalScrEleLoadedError),
document.head.appendChild(this.scriptEle)
}
}
// 实际上是执行onScrEleLoadedSuccess这个函数,其中this.programInfo即上步请求返回的各种信息
{
key: "onScrEleLoadedSuccess",
value: function () {
null !== this.programInfo && (this.failedLoadCount > 0 && (this.failedLoadCount = 0),
this.clear(),
this.loadSuccessHandler.handler.call(this.loadSuccessHandler.scope, this.programInfo))
}
}
// 下面一直是单步进入函数,各种函数名也直接说明了它们的功能
{
key: "onWebPlayInfoLoadSuccess",
value: function (e) {
var t = e;
this.webplayInfoAdaptor.parseWebPlayInfo(t)
}
}
{
key: "parseWebPlayInfo",
value: function (e) {
this.webplayInfoParser.parse(e, this.config)
}
}
{
key: "parse",
value: function (e, t) {
this.initialize(),
this.playerConfig = t;
/* 省略部分 */
i ? this.parseErrorHandler.handler.call(this.parseErrorHandler.scope, n) : this.finalizeParse()
}
}
{
key: "finalizeParse",
value: function () {
var e = {};
this.webplayInfo.streams.length <= 0 ? (e.errormsg = "no resource node or no children in resouce node",
e.errorcode = o.e.CODE_PROGRAMINFO_PARSE_ERROR,
this.parseErrorHandler.handler.call(this.parseErrorHandler.scope, e)) : this.webplayInfo.respType == o.k.PROGRAM_TYPE && this.webplayInfo.streamDetails.length <= 0 ? (e.errormsg = "no resourceInfo node or no children in resourceInfo node",
e.errorcode = o.e.CODE_PROGRAMINFO_PARSE_ERROR,
this.parseErrorHandler.handler.call(this.parseErrorHandler.scope, e)) : this.parseSuccessHandler.handler.call(this.parseSuccessHandler.scope, this.webplayInfo)
}
}
{
key: "onWebPlayInfoParseSuccess",
value: function (e) {
var t = this.webplayInfoAdaptor.buildStream(e);
this.observer.publish(l.a.STREAM_INITIALIZE, {
data: t
})
}
}
// 下面开始创建串流了,进入buildStreamFromOnePlay函数
{
key: "buildStream",
value: function (e) {
var t = null;
switch (this.type) {
case o.l.OLD_PLAY:
t = c.a.buildStreamFromWebPlay(e, this.config);
break;
case o.l.ONE_PLAY:
t = this.protocolAdaptor.buildStreamFromOnePlay(e)
}
return t
}
}
// 进入buildStreamFromOnePlay函数中的switch语句会根据this.webplayInfo.channel.vt的值来执行对应函数,这里是buildOnePlayLIVESubStreams
return s()(e, [{
key: "buildStreamFromOnePlay",
value: function (e) {
this.webplayInfo = e;
var t = {},
i = null,
n = null,
a = null;
switch (this.webplayInfo.channel.vt) {
/* 省略部分 */
default:
t.producer = l.i.PPVIDEO,
i = this.buildOnePlayLIVESubStreams(f),
n = this.buildOnePlayLIVESubStreams(h)
}
break;
case l.j.VT_TYPE_5:
t.streamType = l.c.FAKEVOD,
t.duration = this.webplayInfo.duration,
t.producer = l.i.PPVIDEO,
i = this.buildOnePlayLIVESubStreams(f),
n = this.buildOnePlayLIVESubStreams(h)
}
三、下面到了最关键的 buildOnePlayLIVESubStreams 函数,其中第 49 行有个 for 循环,对应四种不同的清晰度依次拼接串流地址,调试时只需看一个循环就好,进入到 16 行的 d() 函数中,会根据串流类型来判断执行不同函数,这里 HLS 对应 buildOnePlayHLSLIVESubStreamsDetails 函数,根据前文第一步中的返回信息和下面代码中的赋值可知,传入的 4 个参数分别为:o 即 oneplay 返回的 resourceInfo 其中一个 item,对应不同码率;c 是 live2 中的一个 item;r 是 delay;s 为 interval。
// 关键的 buildOnePlayLIVESubStreams 函数
{
key: "buildOnePlayLIVESubStreams",
value: function (e) {
var t = this,
i = this.webplayInfo.streams,
n = this.webplayInfo.streamDetails,
a = null;
if (this.webplayInfo.rtmpStreamDetails && (a = this.webplayInfo.rtmpStreamDetails),
e === c && !a)
return null;
var r = n.delay,
s = n.interval,
o = null,
l = 0,
u = [],
d = function () {
var a = (o = i[l]).rid,
d = o.mt,
c = null;
if ((c = n.items.find(function (e) {
return e.rid === a
})) || (c = n.items.find(function (e) {
return e.mt === d
})),
c) {
if (e === f) {
var p = t.buildOnePlayLIVESubStreamsDetails(o, c, r, s);
u.push(p)
} else if (e === h) {
// 传入的4个参数都来是oneplay请求
var _ = t.buildOnePlayHLSLIVESubStreamsDetails(o, c, r, s);
u.push(_)
}
} else {
var v = {
bitrate: o.bitrate,
width: o.width,
height: o.height,
fps: o.fps || 25,
watch: null == o.watch ? 1 : o.watch,
ft: o.ft,
vip: o.isVip,
mt: o.mt,
rid: o.rid
};
u.push(v)
}
};
for (l = 0; l < i.length; l++)
d();
return u.sort(function (e, t) {
return e.ft < t.ft ? -1 : 1
}),
u
}
}
四、buildOnePlayHLSLIVESubStreamsDetails 函数中找到了熟悉的 m3u8 字样和 32、33 行进行拼接的相关代码块,各种变量是啥都很清晰了,就不再一一列出。generate3RdDecryptedFactor 只是把各种变量进行拼接,最后加上固定字符串 V8oo0Or1f047NaiMTxK123LMFuINTNeI 即为参数 p,接着进入函数 generate3RdDecryptK,它要用到 p 和 key,里面关键是函数 secure_key_decrypt_3rd,它只要 key 的前 32 位字符串和参数 p。继续单步执行看到 secure_key_decrypt_3rd 是个 AES 解密,ECB 模式、ZeroPadding,要注意这里待解密参数 e,是上面传入的 i.dt.key,拼接后的一长条字符串 p 才是密码,而函数 h(t) 前面定义过 h = a.a.SHA256,即先 sha-256 后再计算。
{
key: "buildOnePlayHLSLIVESubStreamsDetails",
value: function (t, i, n, a) {
var r = {};
r.host = i.dt.sh,
r.backhost = i.dt.bh,
r.rid = i.rid,
r.delay = n,
r.interval = a,
r.bwtype = i.dt.bwt,
r.ft = t.ft,
r.width = t.width,
r.height = t.height,
r.vip = t.isVip,
r.bitrate = t.bitrate,
r.mt = t.mt,
r.bid = this.webplayInfo && this.webplayInfo.channel && this.webplayInfo.channel.tbcid,
r.fid = this.webplayInfo && this.webplayInfo.channel && this.webplayInfo.channel.hjid,
r.nm = this.webplayInfo && this.webplayInfo.channel && this.webplayInfo.channel.nm;
var s = i.dt.bh,
o = i.dt.id,
l = i.dt.st,
d = i.dt.sh,
h = i.dt.iv,
c = i.dt.key,
f = i.dt.flag,
p = this.generate3RdDecryptedFactor(f, s, o, d, h, l),
_ = this.generate3RdDecryptK(c, p),
v = u.a.getRidFromRidSeg(r.rid),
m = e.generateCDNType(this.config),
g = e.getCDNVariables(_, m, this.config),
y = "//" + r.host + "/live/" + a + "/" + n + "/" + v + ".m3u8?playback=0&" + g,
E = "//" + r.backhost + "/live/" + a + "/" + n + "/" + v + ".m3u8?playback=0&" + g;
return r.url = y,
r.backURL = E,
r
}
},{
key: "generate3RdDecryptedFactor",
value: function (e, t, i, n, a, r) {
i = i || "";
var s = "";
switch (e) {
case 1:
s = "" + t;
break;
case 2:
s = "" + i;
break;
case 3:
/*省略部分*/
case 15:
s = "" + n + r + i + t
}
return (s += "" + a) + "" + o.a.APP_KEY_3RD
}
}, {
key: "generate3RdDecryptK",
value: function (e, t) {
var i = null,
n = e.split("-");
if (n.length > 0) {
var a = n[0];
i = o.a.secure_key_decrypt_3rd(a, t)
}
return i && (i = i + "-" + n[1] + "-" + n[2]),
i || (i = e),
i
}
}
// secure_key_decrypt_3rd 很明显是个AES解密,e是待解密文本,是上面传入的i.dt.key
: function (e, t) {
var i = a.a.enc.Hex.parse(e),
n = h(t),
r = a.a.lib.CipherParams;
return a.a.AES.decrypt(r.create({
ciphertext: i
}), n, {
mode: a.a.mode.ECB,
padding: a.a.pad.ZeroPadding,
formatter: a.a.format.OpenSSL
}).toString()
}
五、所有参数来源搞清楚了,最后得到变量 y 和 E 即这种形式的链接://migu.live.pptv.com/live/5/45/8c1a5ccf1269ee039997114060d8c6c4.m3u8?playback=0&k=daf9d7e503c68737edd5bd06510e8038-f2c9-1621133365&vvid=b0724467-cec4-426e-dc48-707cb4eaec75&type=mhpptv&o=0&sv=4.1.18。在调试过程中发现,除了这里还有好几处类似的拼接过程,分别对应PP体育里的“正在直播”“比赛录像”“集锦”几种视频形式。而前文提到的四种码率,蓝光需要登陆或VIP会员,无法直接获取。
六、当然,到这里还不算完,记得前面 http://oneplay.api.pptv.com/ups-service/play 请求时的参数 cipher 和 encryptParams 也是很长的加密字符串。同样的方式下断点,这次往上找。找到几个关键函数和传入的参数如下,其中 encryptParams 来自 secure_key_encryption_3des 是个 DES3 加密,CBC 模式、Pkcs7、随机key 和 iv 。cipher 是把 key 和 iv 拿去 cmdRSAEncrypt 即进行 RSA 加密,代码中已给定了公钥。
/*
传入参数如下形式,大部分都是固定的常量
params = {
'type': 'mhpptv',
'appId': 'pptv.web.h5',
'appPlt': 'web',
'appVer': '1.0.4',
'channel': 'sn.cultural',
'sdkVer': '1.5.0',
'cid': self.cid,
'allowFt': '0,1,2,3',
'rf': 0,
'ppi': '302c3530',
'o': 0,
'ahl_ver': 1,
'ahl_random': '374b7d5d453b2c4d2e2e327452434168',
'ahl_signa': '552aed5c0f2d2e561cd55991925ae817add78ceb86ede3ecac08dd4df6a31f78',
'version': 1,
'streamFormat': 1,
'videoFormat': 'm3u8',
'vvId': '295b5e4a-4a77-442a-8594-36c47c87d6c5',
}
*/
// getWebPlayInfoAddr函数相关代码,
{
var g = c.a.get3desKeyRandom(n);
n = "cipher=" + encodeURIComponent(g.cipher),
n += "&encryptParams=" + encodeURIComponent(g.encryptParams)
}
return u + "?" + (n += "&vvId=" + (i.vvid || h.a.generateUUID())) + "&format=jsonp"
}
// get3desKeyRandom 函数
get3desKeyRandom: function (e) {
var t = this.getRamNumber(48),
i = this.getRamNumber(16),
n = this.secure_key_encryption_3des(e, t, i);
return {
getRamNumber_48: t,
getRamNumber_16: i,
cipher: this.cmdRSAEncrypt(t + "," + i),
encryptParams: n
}
}
// secure_key_encryption_3des 是个DES3加密
secure_key_encryption_3des: function (e, t, i) {
var n = a.a.enc.Hex.parse(t);
return a.a.TripleDES.encrypt(e, n, {
iv: a.a.enc.Hex.parse(i),
mode: a.a.mode.CBC,
padding: a.a.pad.Pkcs7
}).toString()
}
// cmdRSAEncrypt 是个RSA加密,给定了公钥
cmdRSAEncrypt: function (e) {
return c.setPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqe6XLQF2JmXWgfh09t8TTZsOb6bnj+duiWw4G7pd5Uo1/DN7Xij3Tys9E7XBX0gdXKYI9j+6Fr45bM28fzl4AxUxnhzmbExRt1NJarDGMKo49ViRg1VbL+Wh9kRi+rAxBisdRiP2JEAL+Awqu80chZxxdyoI1k3fSLoZsv/PGkwolE71qsEM4BO1J9RWNp0wlNGqgR+bTwLKkoe7oiZaKaMsSBWNIBDkwgGKFJZzXMXMnqGsDmfbdi32j6hW9DdrxjCx/i9Nzahd1TWVnw9O1AHL5PD5kM3HzqkAewBu38sZxw8DSGYqG0fgVAQtiLHhlD/19F4NKxqL8IVCinMBHQIDAQAB"),
c.encrypt(e)
}
七、实际测试过程中,直接传原始参数请求 oneplay 也能成功,不需要加密过程。参数中只有一个 cid 是变化的,从 http://sportlive.suning.com/slsp-web/cms/competitionschedule/v1/detail/section.do?sectionid=sectionid 的响应中获取,sectionid 即直播间 url 里的 sectionid。
最后 Python 实现代码也不贴在博文中了,同样放在博主的 GitHub 仓库 real-url 中。总结下整个调试和 Python 实现过程的几点注意事项:
1. 上面的 JavaScript 代码全在 player-major.js 这个文件中,七万多行格式化时很卡,用 fiddler 的 AutoResponder 替换后会好很多。
2. Des 加密时 key 和 iv 用 a2b_hex 将十六进制转为字符串。
3. RSA 加密时的公钥,如果直接用字符串形式放在代码中时,要保证为标准格式:前后分别加上 -----BEGIN PRIVATE KEY-----\n 和 \n-----END PRIVATE KEY-----,千万记得前后的换行符,否则可能报错 valueerror: rsa key format is not supported 或是 ValueError: Not a valid PEM pre boundary
4. aes-256 加解密的密钥长度是 32 位。上面进行转换时用 pycryptodome 库中的 Crypto.Hash 里 SHA256 这个类。
5. 请求 oneplay 时 headers 要带上 User-Agent。
(全文完)