实战 Fetch 劫持快手批量读取
本节将利用 Fetch 劫持以及 Promise 实现快手无水印视频的链接批量获取。
注意由于为了思路上的流畅,所以会忽略与本章无关的细节代码。
实战
实战链接: ರ 小芷儿 ರ
通过抓包,我们发现所有请求地址都以 https://live.kuaishou.com/live_graphql 开头
这说明根据提交的内容来判断操作,
根据对比,请求由 operationName 的属性来决定
根据多次抓包和返回数据的对比,确定了首屏数据的 operationName 名字为 privateFeedsQuery
除去首屏读取,第二页开始的 operationName 名字为 publicFeedsQuery
返回的数据并没有视频地址,只有封面图片,如左下图所示
封面图片无水印截图
但是这里返回了 id,所以这里我们需要另想办法
经过测试发现通过手机分享的单个视频 url 是无水印的
宝贝 看完了吗~ "夹子音 "高能夹子音 "夹子音变装 https://v.kuaishou.com/dPkfN2 复制此消息,打开【快手】直接观看!
视频截图由由上图所示
通过抓包分析可知 operationName 名字为 visionVideoDetail 时,返回了无水印的视频地址
路径为 data.visionVideoDetail.photo.photoUrl
提交数据如下
{ "operationName": "visionVideoDetail", "variables": { "photoId": "3xqmst68mjpue66", "page": "detail" }, "query": "query visionVideoDetail($photoId: String, $type: String, $page: String, $webPageArea: String) {\n visionVideoDetail(photoId: $photoId, type: $type, page: $page, webPageArea: $webPageArea) {\n status\n type\n author {\n id\n name\n following\n headerUrl\n __typename\n }\n photo {\n id\n duration\n caption\n likeCount\n realLikeCount\n coverUrl\n photoUrl\n liked\n timestamp\n expTag\n llsid\n viewCount\n videoRatio\n stereoType\n croppedPhotoUrl\n manifest {\n mediaType\n businessType\n version\n adaptationSet {\n id\n duration\n representation {\n id\n defaultSelect\n backupUrl\n codecs\n url\n height\n width\n avgBitrate\n maxBitrate\n m3u8Slice\n qualityType\n qualityLabel\n frameRate\n featureP2sp\n hidden\n disableAdaptive\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n tags {\n type\n name\n __typename\n }\n commentLimit {\n canAddComment\n __typename\n }\n llsid\n danmakuSwitch\n __typename\n }\n}\n"}
这里可以看到 photoid 是变化的,其他应该没有什么变化。
photoid 在我们之前的第一个页面可以看到,那我们可以先构造一个获取无水印的获取函数
function getKusiShowVideo(id) { return new Promise((resolve, reject) => { // 这里需要注意的是如果我们把获取的数据直接放到xhr内\n会出现换行的情况 // 所以我们需要对这类字符进行转义,把\n改成\\n,这样提交的时候会把\\n转义成\n。 GM_xmlhttpRequest({ url: "https://www.kuaishou.com/graphql", method: "POST", data: '{"operationName":"visionVideoDetail","variables":{"photoId":"' + id + '","page":"detail"},"query":"query visionVideoDetail($photoId: String, $type: String, $page: String, $webPageArea: String) {\\n visionVideoDetail(photoId: $photoId, type: $type, page: $page, webPageArea: $webPageArea) {\\n status\\n type\\n author {\\n id\\n name\\n following\\n headerUrl\\n __typename\\n }\\n photo {\\n id\\n duration\\n caption\\n likeCount\\n realLikeCount\\n coverUrl\\n photoUrl\\n liked\\n timestamp\\n expTag\\n llsid\\n viewCount\\n videoRatio\\n stereoType\\n croppedPhotoUrl\\n manifest {\\n mediaType\\n businessType\\n version\\n adaptationSet {\\n id\\n duration\\n representation {\\n id\\n defaultSelect\\n backupUrl\\n codecs\\n url\\n height\\n width\\n avgBitrate\\n maxBitrate\\n m3u8Slice\\n qualityType\\n qualityLabel\\n frameRate\\n featureP2sp\\n hidden\\n disableAdaptive\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n tags {\\n type\\n name\\n __typename\\n }\\n commentLimit {\\n canAddComment\\n __typename\\n }\\n llsid\\n danmakuSwitch\\n __typename\\n }\\n}\\n"}', headers: { "Content-type": "application/json", }, onload: function (xhr) { let obj = JSON.parse(xhr.responseText); let res = obj.data.visionVideoDetail.photo; if (res === null) { resolve(status:'error',) } downloadurl.push(res.photoUrl); resolve({ status:'success', data:res.photoUrl }); }, }); });}
然后继续像知乎劫持一样用 fetch 劫持,这里因为名字不同,但是内容是一致的,
所以我先判断前缀部分是什么,然后再进行统一处理。
并将所有的 id 以及图片地址(获取列表的时候,图片会直接返回给我们)都保存起来。
因为 fetch 如何劫持我们已经演示过了,所以这里直接展示劫持
let oldFetch = fetch;function hookFetch(...args) { return new Promise((resolve, reject) => { oldFetch.call(this, ...args).then((response) => { if ( args.length === 2 && args[0].indexOf && args[0].indexOf("/graphql") !== -1 && (args[1].body.indexOf("privateFeedsQuery") !== -1 || args[1].body.indexOf("publicFeedsQuery") !== -1) ) { console.log("劫持了json函数"); const oldJson = response.json; response.json = function () { console.log("触发了json函数调用"); return new Promise((resolve, reject) => { oldJson.apply(this, arguments).then((result) => { resolve(result); }); }); }; } resolve(response); }); });}window.fetch = hookFetch;
提示这个时候发现提示了劫持了json函数,但是却没有提示触发了json函数调用,
这是因为快手很有可能并没有使用 json 函数,
这个时候尝试去逆向网页到底用了什么函数是比较复杂的,
我这里更推荐使用 Proxy 来探测都读取到了什么函数。
function proxyeFactory(response) { const handler = { get: function (target, property, receiver) { const result = Reflect.get(target, property); console.log("proxyFetchGet", property); return result; }, }; const proxyResponseObject = new Proxy(response, handler); return proxyResponseObject;}let oldFetch = fetch;function hookFetch(...args) { return new Promise((resolve, reject) => { oldFetch.call(this, ...args).then((response) => { if ( args.length === 2 && args[0].indexOf && args[0].indexOf("/graphql") !== -1 && (args[1].body.indexOf("privateFeedsQuery") !== -1 || args[1].body.indexOf("publicFeedsQuery") !== -1) ) { console.log("劫持了json函数"); const oldJson = response.json; response.json = function () { console.log("json is run"); return new Promise((resolve, reject) => { oldJson.apply(this, arguments).then((result) => { resolve(result); }); }); }; } resolve(proxyeFactory(response)); }); });}window.fetch = hookFetch;
提示有一点注意的是,
在 get 函数内只推荐打印 target 和 property,在书写 Proxy 一定要谨慎,
只获取自己所需要的数据以及谨慎编写代码,
防止输出过多或者操作代码再次触发 Proxy 函数,形成无限递归
这个时候导致无法显示网页是很正常的,因为我们的 Response 对象变成了 Proxy,
注意如果 Fetch 需要调用 this,
由于而此时的 this 是 Proxy,
因此会触发报错,无法显示页面,我们只看输出即可。
proxyFetchGet thenproxyFetchGet clone
其中 then 是因为 resolve 会判断 Promise 而触发的,我们可以忽略,然后发现还调用 clone,clone 是克隆一个新的 Response,所以我们的 Proxy 还需要对 clone 处理
所以我们更新下 ProxyFactory 函数
function proxyResponseFactory(response) { const handler = { get: function (target, property, receiver) { const result = Reflect.get(target, property); console.log("proxyFetchGet", property); if (typeof result === "function") { return (...args) => { let funcResult = result.call(target, ...args); console.log("childFetchFunction", property, funcResult); if (property === "clone") { funcResult = proxyResponseFactory(funcResult); } return funcResult; }; } return result; }, }; const proxyResponseObject = new Proxy(response, handler); return proxyResponseObject;}
我们此时看一下,发现页面正常显示了,然后看一下 f12
childFetchFunction text Promise {
这个时候可以证明存在两个关键点
我们需要对 clone 函数进行处理
我们不能去劫持 json 函数,应该去劫持 text 函数。
为什么得到了数据,不直接使用此处想引出 Proxy 劫持的概念,同时,应该劫持网页自身所调用的函数,
不能只顾着抄示例,如果实际开发其实直接 clone 并且 json 一下也是没问题的。
那么我们需要同时劫持 clone 函数,防止克隆后函数失效,并且不再劫持 json,去劫持 text 函数,代码如下
let oldFetch = fetch;function hookFetch(...args) { return new Promise((resolve, reject) => { oldFetch.call(this, ...args).then((response) => { if ( args.length === 2 && args[0].indexOf && args[0].indexOf("/graphql") !== -1 && (args[1].body.indexOf("privateFeedsQuery") !== -1 || args[1].body.indexOf("publicFeedsQuery") !== -1) ) { console.log("劫持了json函数"); const oldText = response.text; const hookText = function () { console.log("text is run"); return new Promise((resolve, reject) => { oldText.apply(this, arguments).then((result) => { console.log("劫持到了文本", result); resolve(result); }); }); }; response.text = hookText; const oldClone = response.clone; const hookClone = function () { let result = oldClone.apply(this, arguments); result.clone = hookClone; result.text = hookText; return result; }; response.clone = hookClone; } resolve(response); }); });}window.fetch = hookFetch;
输出
text is run劫持到了文本{"data":视频数据(包含视频id标识)}
根据视频数据解码并且将每一个视频的 id 放到一个数组内以及按钮绘制比较简单,这里我们这里直接忽略
默认假设每一个劫持到的视频的 id 标识都存储到了 idList 中
当点击按钮后,直接触发 StatToGetVideo 函数,
然后根据 id 循环调用 getKusiShowVideo 来获取无水印视频地址
async function StatToGetVideo() { alert("已开始,不要重复点击!"); let imgnumber = 0; const downloadurl=[] for (let index = 0; index < idList.length; index++) { let id = idList[index]; let result = await getKusiShowVideo(id); if(result.status===='success'){ downloadurl.push(result.data) } } GM_setClipboard([...downloadurl].join("\n")); alert("共成功:"+downloadurl.length+'个');}
那么通过 Fetch 劫持得到视频 id 并且利用 Promise 循环读取每个视频的无水印地址就大功告成了