跳到主要内容

实战 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

由于而此时的 thisProxy

因此会触发报错,无法显示页面,我们只看输出即可。

proxyFetchGet then
proxyFetchGet clone

其中 then 是因为 resolve 会判断 Promise 而触发的,我们可以忽略,然后发现还调用 cloneclone 是克隆一个新的 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 {<pending>}[[Prototype]]: Promise[[PromiseState]]: "fulfilled"[[PromiseResult]]: "正确数据"

这个时候可以证明存在两个关键点

  1. 我们需要对 clone 函数进行处理
  2. 我们不能去劫持 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 循环读取每个视频的无水印地址就大功告成了