被源网站风控的应对方案之请求重放(三)
写在开篇
当我们 采集的数据来源于源网站的API & 请求接口与用户地址栏有对应的唯一关键值 & 短时间内无法逐个突破风控关键点 时,“请求重放”就出场了。
PS. 如果说上一章讲述的 请求头设置 是采集过程中的后置部分的话,那么本章就是数据采集过程的前置篇。因为,只有探索到了真正的请求及请求参数,才能落实到请求里去,所以,这一章的内容讲完,你应该就可以将采集流程串起来&实践数据采集了哦~
数据重放与插件
正如《 浏览器插件数据采集时,被源网站风控的应对方案(一) 》一文中所阐述的,“ 请求重放 ”的现实依据是:用户正常打开源网站时,源网站上的请求可以正常通过源网站的风控系统。据此,我们只需将合规的请求及参数存储下来并重新请求一次,就可以获取到目标数据了。
结合Chrome Extension,这个过程就涉及到了两个问题:
- 请求及请求参数从哪里来?
- 请求及请求参数又如何重放?
首先,我们来讲讲“从哪里获取请求内容”。
现在,请你再由“上面阐述的、用户正常操作的交互流程”发散思维:用户是 自行 打开源网页进行浏览时,可以看到完整的数据。那么,如果,Chrome Extension能为我们 自动的、默默的 打开页面&等我们拿到请求信息后再关掉TAB的话就好了(静默打开 是为了用户侧没有那么强烈的交互感官刺激)。
而对此,Chrome Extension当然是拥有操控TAB的能力的,所以第一个问题“光靠人为操作的流程太过繁琐&不便捷”从理论上已经解决。那,又如何获取到请求内容呢?这就涉及到了Service Worker层的
chrome.webRequest.onBeforeSendHeaders.addListener 监听设置技术点啦。本章就不对这块内容做详述介绍了,基本原理就是为某一类请求设置一种监听:当某种请求出现时,获取到这种请求及该请求的参数。
// replayRequestUtil.js文件,该文件主要用来处理重放请求的监听设置+请求重放
// 全局变量,在service worker休眠时会被清除哦~
const _tempRequestStorage = {};
export function addOnBeforeSendHeaderListener() {
/**
* 以下这段代码是用检测是否有监听函数的;
*
* 如果addOnBeforeSendHeaderListener该函数只是在service worker启动或休眠启动时执行的话,这段代码就不需要(本次示例采用的是这种方案,所以注释了代码)
* 如果addOnBeforeSendHeaderListener该函数是由content层等入口触发(即,可能在同一段service worker被激活的时间段内执行多次的),则需要这段代码
*
* const _existListeners = chrome.webRequest.onBeforeSendHeaders.hasListeners();
* if (_existListeners) {
* return;
* }
*
* */
/**
* 以下设置监听回调函数时,我们模拟的场景是:
*
* 用户浏览器地址栏中的地址是:https://www.fun8.top/detail.html?itemId=10001
* 页面请求数据的API地址是(为了区分主域名,也可以让读者区分):https://www.fun8.top/api/get-detail?id=10001
*
* */
const _apiUrlReg = /\/\/www\.fun8\.top\/api\/get\-detail/; // 这个变量可以作为系统级别的常量,我放在这里是为了较好的展示demo
chrome.webRequest.onBeforeSendHeaders.addListener(
(details) => {
// 因为一般会指定多种类别的地址,所以必须更细粒度的验证、获取请求参数 & 存储
if (details.url && _apiUrlReg.test(details.url)) {
console.log('进入监听回调函数&命中目标地址', details);
const _url = new URL(details.url);
// 需要从请求链接中找到唯一性指征参数,这是使用“请求重放”的必要条件,否则会造成数据混乱
const _id = _url.searchParams.get('id');
if (_id) {
const _hKey = JSON.stringify({
tabId: details.tabId,
url: _url.origin + _url.pathname,
id: _id,
});
// 这里我们使用的是全局变量,因为设计方案“打开TAB-请求获得/存储-请求重放-关闭TAB”是一气呵成的,理论上不会涉及到Service Worker的休眠;
// 如果你的技术方案是需要先存储再经过较长时间(比如5分钟)再使用的,请使用chrome.storage或IndexDb这种持久性存储介质,因为全局变量在service worker的休眠时会被清除;
_tempRequestStorage[_hKey] = {
url: details.url,
headers: details.requestHeaders || [],
};
}
}
},
{
urls: [
'https://www.fun8.top/api/*', // 因为这里指定的是一系列符合通配符的API地址(注意不是用户地址栏的地址),所以在回调函数中需要再细颗粒度的验证&获取数据
'https://api.fun8.top/item/*', // 可以指定多种地址
],
},
[
'requestHeaders', // 指定需要在拦截时将requestHeaders返回给回调函数,如果不设置的话,就不能在回调函数中获取到哦~
'extraHeaders', // 一定要设置这个,不然就取不到origin这种关键请求头了哦~
],
);
console.log('设置请求头监听函数完成');
}
// service-worker.js文件 入口处,添加设置监听代码
import { addOnBeforeSendHeaderListener } from '@/utils/replayRequestUtil';
addOnBeforeSendHeaderListener();
说完了“获取请求”相关内容,接下来的“请求重放”就简单了:不过就是从存储介质中将数据提取出来,再用fetch等浏览器API执行一下请求一下就可以了~
// replayRequestUtil.js文件
import { delay } from 'lodash';
import { _fetchApi } from '@/utils/chromeDeclarativeNetRequestUtil'; // 这是上一章中的函数,本节中就直接拿过来用了
export function sleep(millisecond) {
return new Promise((resolve) => {
delay(() => {
resolve({});
}, millisecond);
});
}
// 这个函数一般由content层触发
// url: https://www.fun8.top/detail.html?itemId=10001
export async function autoOpenTabAndGetData(url) {
if (!/\/\/www\.fun8\.top\/detail\.html/.test(url)) {
return { success: false, message: '请求链接出错' };
}
const _url = new URL(url);
const _itemId = _url.searchParams.get('itemId');
if (!_itemId) {
return { success: false, message: '请求链接出错-2' };
}
console.log('新TAB准备打开');
const _tab = await chrome.tabs.create({ active: false, url }); // 自动打开页面,active:false 代表的是静默打开tab页
console.log(`新TAB已打开,TAB ID - ${_tab.id}`);
let _tempRequestInfo;
let _checkLoopIndex = 0;
while (_checkLoopIndex < 20) {
// 在等待的这2秒中,因为tab页的打开,所以chrome.webRequest.onBeforeSendHeaders的监听函数会被执行到
// 每2秒检测一次是否已经拿到头部信息,一般在第一个2秒的时候就拿到了的
// 另外,如果40秒还拿不到请求头的,就可以放弃了
await sleep(2000);
console.log(`第${_checkLoopIndex}验证`);
// 这里利用唯一性键值的取值与上面的设置形成了闭环
const _hKey = JSON.stringify({
tabId: _tab.id,
url: 'https://www.fun8.top/api/get-detail',
id: _itemId,
});
++_checkLoopIndex;
if (_tempRequestStorage[_hKey]) {
_tempRequestInfo = _tempRequestStorage[_hKey];
console.log(`获取到请求信息 - 第${_checkLoopIndex}验证`);
break; // 拿到请求信息就可以了
}
}
console.log(`TAB-${_tab.id}准备关闭`);
// 这里异步执行就行~
chrome.tabs.remove([_tab.id], () => {
console.log(`TAB-${_tab.id}已关闭`);
});
if (!_tempRequestInfo) {
return { success: false, message: '请求信息获取失败' };
}
// 从监听函数中获取到的headers信息是[{name:'xxx',value:'xxxx'}]格式的,但是_fetchApi需要得是对象格式的,所以处理一下
// 这块代码可以去上一篇中的_fetchApi兼容,我这里为了少贴代码所以在这里处理了一下
const _headers = {};
for (const _item of _tempRequestInfo?.headers || []) {
_headers[_item.name.toLowerCase()] = _item.value;
}
console.log(`重放请求开始`);
const _response = await _fetchApi({ url: _tempRequestInfo.url, headers: _headers });
const _result = await _response.json();
console.log(`重放请求结束,请求结果:${_result}`);
return { success: true, message: '请求成功', result: _result };
}
// content.js文件 - 向页面上嵌入一个按钮,用于执行代码
const _container = document.getElementById('head');
const _btnDom = document.createElement('button');
_btnDom.innerHTML = '点击';
_container.appendChild(_btnDom);
_btnDom.addEventListener('click', async function () {
// contentToServiceWorkerByLongConnection这个函数就是使用长链接通信通道向service worker发送消息
contentToServiceWorkerByLongConnection({
command: 'batchCollectItemData',
payload: {
urlList: [
'https://www.fun8.top/detail.html?itemId=10001',
'https://www.fun8.top/detail.html?itemId=10002',
'https://www.fun8.top/detail.html?itemId=10003',
],
},
});
});
// service-worker.js文件
async function _batchCollectItemData(payload, port) {
const { urlList } = payload;
for (const _url of urlList) {
const _result = await autoOpenTabAndGetData(_url);
console.log('请求完毕', _result.success, _result.result);
// 一般情况下,获取到数据之后会直接抛给服务端或第三方存储系统;
// 另外,或为了交互、或为了保持content与service长链接通信通道,一般,我们会每次请求完给content层回复一条消息;
// 这块逻辑和本章的内容没有很直接的关系,不过是从技术方案的完整性考虑才加上的代码
port.postMessage({
command: 'resolveBatchCollectItemData',
payload: {
url: _url,
},
});
// 一定要注意设置请求间隔,不要给源网站造成困扰哦~
await sleep(10000);
}
}
chrome.runtime.onConnect.addListener(function (port) {
port.onMessage.addListener(function (message) {
if(message.command === 'batchCollectItemData'){
_batchCollectItemData(payload, port);
}
});
});
好啦,到此,从Chrome Extension出发,实践“请求重放”方案就到此为止啦~接下来,我来展示下我本地的交互Demo~(PS.你可以对照上面的代码与视频中的日志理解)
写在结尾
除去代码,本章其实很简短,但是涵盖了“Chrome Extension自动操控TAB与请求拦截”等实践性较强的内容,所以,如果想较好的理解本章知识点,请务必实践一遍或至少对照视频与代码理解一遍(如有问题,请留言哦~);
另外,最后着重说明两点,也是上述技术方案中并未涉及持久化存储,而是设计成简单的“ 自动打开TAB-获取请求及参数-重放请求-关闭TAB ”流程的两个原因:
- 很多时候,源网站的风控系统都会检测请求时间,如果你将一个请求存储下来&很长一段时间之后再去使用的话,很有可能这个请求会失效;
- 因为“请求重放时,请求参数与之前的一次请求完全一致”,如果源网站有意要拦截的话是十分简单的,所以大家一定要控制好重放次数。一般,一个请求重放一次就可以丢弃了;
最后的最后,打个小广告:本章内容只适用于“源网站数据来源于API的数据获取,而非来源于页面dom结构的数据获取”,如果想知道如何快速&尽量少被风控的同学可以期待一下我的下一篇文章哦~;