跳到主要内容

实战 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 then
proxyFetchGet 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 {<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 循环读取每个视频的无水印地址就大功告成了