被源网站风控的应对方案之请求重放(三)

102 阅读7分钟

写在开篇

当我们 采集的数据来源于源网站的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 ”流程的两个原因:

  1. 很多时候,源网站的风控系统都会检测请求时间,如果你将一个请求存储下来&很长一段时间之后再去使用的话,很有可能这个请求会失效;
  2. 因为“请求重放时,请求参数与之前的一次请求完全一致”,如果源网站有意要拦截的话是十分简单的,所以大家一定要控制好重放次数。一般,一个请求重放一次就可以丢弃了;

最后的最后,打个小广告:本章内容只适用于“源网站数据来源于API的数据获取,而非来源于页面dom结构的数据获取”,如果想知道如何快速&尽量少被风控的同学可以期待一下我的下一篇文章哦~;