写在开篇
正如《 浏览器插件数据采集时,被源网站风控的应对方案(一) 》一节中所阐述的:自动操控TAB页&解析dom 是采集源网站数据最稳妥的方案,主要是因为这个方案最贴近用户的操作路径,特别是当需要采集的数据都呈现在用户的可视网页上时。
本小节主要是用来展示如何使用Chrome Extension来落地整个技术方案。
技术方案设计与说明
整体流程其实比较简单,只有"Chrome Extension打开TAB页 -> Dom解析"两个步骤。
其中,"自动操控TAB页"这块流程比较明确,并且,上一小节中已经详细阐述过实现过程。而,"Dom解析"当下有两种比较稳妥的方案:
一种是Chrome.scripting结合第三方库cheerio的应用【推荐】;
另一种是,不借助第三方库&由Content层直接使用浏览器的dom API进行提取。
为了主题明确和精减单文的篇幅,所以本文我们先来讲讲第二种方案。那,为什么不先说推荐的方案呢?因为有对比才有伤害。

从以上流程图可以看出,本次方案涉及到的端比较多,有 服务端、插件端的各个Content层、插件端的Service Worker层;
其中,服务端的作用是 数据存储、与发起请求的Content层进行数据交互、接收来自Service Worker的数据请求;
另外,几个端的交互流程是这样的(请配合流程图一起享用):
- 发起请求的TAB-Content层携带批次ID、采集链接 向Service Worker发起"批量采集数据"的请求
- 一般情况下,批次ID由服务端产生,批次ID与采集链接的关联关系也存储在服务端;
- 为什么不直接在插件端进行存储呢?因为多个Content层与一个Service Worker交互会有并发问题:当Service Worker打开多个TAB后,即与多个Content进行交互,而多个Content层回复Service Worker的时机不可控问题会导致消息到达Service Worker的时机具有并发性,所以必须要有一个脱离这个环境的存储系统去做存储这件事情。
- 当Service Worker接收到"批量采集数据"的消息后,就开始逐个:
- 打开TAB页;
- 等待10秒之后,使用短连接通信通道向新TAB页的Content层发送消息(一般来说,一个网站10秒还打不开的,就可以直接废掉了);
- 新TAB页的Content层接收到消息之后使用浏览器的dom API进行数据提取;
- 无论提取成功与否,新TAB页的Content层都将数据信息反馈给Service Worker;
- Service Worker接收到信息之后向服务端抛出结果(需要注意,这里处理的进程和上面Service Worker操控TAB页的进程是不同的两个进程,后续会在代码中有清晰的体现);
- 服务端存储结果,该结果会由发起请求的Content层拉取得到;
- 当Service Worker将对所有可以新开的TAB操作过以上流程之后,需要定时监听所有的TAB页是否已经处理完毕,如果处理完毕的,则需要向服务端进行反馈"插件采集完毕"的消息;
- 这一步的目的是为了防止:插件端出现采集超时,服务端无法感知的问题;
- 在Service Worker循环处理数据采集的同时,发起请求的Content层需要定时去服务端拉取当前批次的结果;
以上流程还算清晰,复杂的是:因为涉及到的端比较多,所以里面出现的异常也会比较多,导致我们至少需要处理以下两个问题:
- 如果Service Worker的消息发出去了,但是Content层没有接收到怎么办?
这种情况属于消息丢失问题,一般,兜底策略设计为:Service Worker中记录消息发出时间,当达到一定的时间之后还没得到采集结果的将其判为"采集失败";
- Content层接收到了消息,但是Dom节点一直没有被加载出来 或 经过了较长时间(超过10秒)才被加载出来;
这种情况一般出现在源网站的采集节点是异步生成的情况下,这里的兜底策略很简单:循环延迟一段时间进行获取即可,当最后达到较长时间(比如,半分钟,具体还是要看源网站dom节点被生成的实际情况)还未获取到的,那么直接向Service Worker抛出"采集失败"的消息,否则,什么时候获取到了,什么时候向Service Worker回复"采集成功"的消息;
这里需要注意,不管是采集失败还是采集成功,理论上都应该向Service Worker回复一条携带"采集状态"的消息,一方面,之前被插件操控打开的TAB页需要再由Service Worker去关闭,另一方面,从交互层面出发,产品设计中应该注意在用户操作中应该给予用户明确且具有引导性的提示,而不是突然的中断导致用户不知所措;
方案落地与Demo展示
讲完了理论,当然要展示下代码实力了(代码按照上述技术流程依次展示)。本次我们的demo需求是:获取百度首页的搜索按钮中的文字"百度一下"。
首先,当然是在发起TAB页中插入一个发起请求的按钮了。
js
// content.js
import { v4 as uuidV4 } from 'uuid';
const _container = document.getElementById('ce-test-id');
const _btnDom = document.createElement('button');
_btnDom.innerHTML = '点击 BY DOM';
_container.appendChild(_btnDom);
_btnDom.addEventListener('click', async function () {
contentToServiceWorkerByLongConnection({
command: 'batchCollectDataByDom',
payload: {
batchId: uuidV4(), // 一般这个批次ID由服务端生成,这里只是为了演示方便,所以直接由前端产生
urlList: ['https://www.baidu.com/', 'https://www.baidu.com/'], // 这里为了测试,所以放了两个一样的地址,只是为了有更直挂的感受
},
});
});
接下来,是Service Worker处理"批量采集数据"的主线逻辑(次线逻辑是接收来自Content层的采集结果),这块逻辑的执行时间跨度是跨过了Content层解析Dom、Service Worker处理解析结果的时间线的,需要好好理解哦~
js
// service.worker.js
import { v4 as uuidV4 } from 'uuid';
import { isArray, isObject } from 'lodash';
// 从插件本地存储系统中获取数据
export async function getSingleLocalStorageValue(key, defaultValue = '') {
const _values = await chrome.storage.local.get([key]);
return _values[key] || defaultValue;
}
// 向插件本地存储系统中塞入数据
export async function setSingleLocalStorageValue(key, value) {
let _value = value;
if (isObject(value) || isArray(value)) {
_value = JSON.stringify(value);
}
await chrome.storage.local.set({ [key]: _value });
}
async function _batchCollectDataByDom(payload, port) {
const { batchId, urlList } = payload;
const _storageKey = `collectDataByDom-${batchId}`;
for (const _url of urlList) {
// 一般在这里做一些参数的校验、获取等操作
console.log('_batchCollectDataByDom-新TAB准备打开');
const _tab = await chrome.tabs.create({ active: false, url: _url }); // 自动打开页面,active:false 代表的是静默打开tab页
console.log(`_batchCollectDataByDom-新TAB已打开,TAB ID - ${_tab.id}`);
await sleep(10000); // 等待10秒,基本上这个时间段下,新打开的tab页已经注入了content层的js代码了
const _itemId = uuidV4(); // 为本次请求生成一个唯一的ID
const _itemMessage = {
url: _url,
batchId: batchId, // 批次ID
itemId: _itemId, // 本次请求的唯一ID
tabId: _tab.id,
status: 'running',
startTime: new Date().getTime(),
};
// 因为本方案涉及到了Service Worker与Content层的来回多次通信,所以需要借助存储系统进行数据交互
// 这里我们采用的是持久性存储:chrome.storage.local,主要是为了存储单个item的采集状态,以便于后续通知服务端/用户侧批次结束
const _currentCollectResult = await getSingleLocalStorageValue(_storageKey);
const _currentCollectList = JSON.parse(_currentCollectResult || '[]');
await setSingleLocalStorageValue(_storageKey, [
..._currentCollectList,
{
..._itemMessage,
},
]);
// 接下去就需要向新的TAB页的content层发送消息,获取数据
// 发送完本消息之后,针对一个采集链接来说其实已经完成了Service Worker的第一阶段的工作了
chrome.tabs.sendMessage(_tab.id, {
command: 'singleCollectDataByDom',
payload: _itemMessage,
});
console.log('_batchCollectDataByDom-向新TAB发送消息完毕');
// 或为了交互、或为了保持content与service长链接通信通道,一般,我们会每次请求阶段给发出采集请求的content层回复一条消息;
// 这块逻辑和本章的内容没有非常直接的关系,是技术方案的完整性考虑
port.postMessage({
command: 'resolveBatchCollectItemData',
payload: _itemMessage,
});
// 这个30秒的等待时间,一方面是为了不给源网站造成困扰,另一方面是一分钟以上的打开TAB页的时间间隔更加贴近用户操作路径;
// 如果可以做的更精细一些的话,可以产生一个随机数来做间隔时间
// 当然,根据不同的网站设置的间隔时间可以不一样的,一般可以在10-45秒直接选一个随机数
await sleep(30000);
}
// 当所有的采集请求都发送完毕之后,那么就可以循环检查采集结果的状态了,因为只有获取到了所有单个item的采集结果才能通知服务端/用户侧批次结束
// 这里我们采用的是轮询的方式,每隔10秒检查一次
let _checkLoopIndex = 0;
while (_checkLoopIndex < 60000 * urlList.length) {
// 或为了交互、或为了保持content与service长链接通信通道,一般,我们会每次请求阶段给发出采集请求的content层回复一条消息;
// 这块逻辑和本章的内容没有非常直接的关系,是技术方案的完整性考虑
port.postMessage({
command: 'resolveBatchCollectItemDataCheck',
payload: {
checkLoopIndex: _checkLoopIndex,
},
});
await sleep(10000);
console.log(`_batchCollectDataByDom-第${_checkLoopIndex + 1}次验证`);
_checkLoopIndex++;
const _currentCollectResult = await getSingleLocalStorageValue(_storageKey);
const _currentCollectList = JSON.parse(_currentCollectResult || '[]');
let _hasRunningItem = false;
for (const _item of _currentCollectList) {
if (_item.status === 'running' && new Date().getTime() - _item.startTime < 60000) {
// 如果有一个采集任务的状态是running,并且执行时间不超过1分钟的,那么就可以认为本次采集任务还没有完成
_hasRunningItem = true;
break;
}
}
if (!_hasRunningItem) {
break;
}
}
// 此时,这里的采集结果会有几种情况:
// 1、所有的采集任务都已经完成;
// 2、部分采集任务已经采集完成,部分超时了;针对超时的这部分可以额外再做一次重试,也可以直接给服务端/用户侧返回采集结果;
// 这里,我们就直接向服务端/用户侧返回"采集完毕"这个结果;
// 再由Content层向服务端发起请求询问整个采集结果如何了;
// 1. 向服务端发送请求:告知服务端本次采集批次已经采集完毕;
// const _result = await fetch('xxxxx',{
// method: 'POST',
// body: JSON.stringify({
// batchId,
// status: 'finish',
// }),
// headers: {
// 'Content-Type': 'application/json',
// },
// });
console.log('_batchCollectDataByDom-采集完毕');
// 2. 向Content层发送消息告知"本次采集完毕",请自行去服务端获取采集结果;
port.postMessage({
command: 'resolveBatchCollectDataByDom',
payload: {
batchId,
status: 'finish',
},
});
}
chrome.runtime.onConnect.addListener(function (port) {
port.onMessage.addListener(function (message) {
const { command, payload } = message;
if (command === 'batchCollectDataByDom') {
_batchCollectDataByDom(payload, port);
}
});
});
接着,展示Content层接收到Service Worker的短连接消息后,循环采集数据并上报给Service Worker。
js
// content.js
async function _singleCollectDataByDom(payload) {
const { url, batchId, itemId, tabId } = payload || {};
let _suDom;
let _byLoopIndex = 0;
while(_byLoopIndex < 6) {
_suDom = document.getElementById('su');
if (_suDom) {
break;
}
++_byLoopIndex;
await sleep(10000);
}
if (_suDom) {
const _value = _suDom.getAttribute('value');
if (_value) {
contentToServiceWorkerByLongConnection({
command: 'singleCollectDataByDom',
payload: {
url,
batchId,
itemId,
tabId,
status: 'success',
result: _value,
},
});
return;
}
}
// 其他错误信息解析,比如是否需要登陆、是否被风控之类的
// 解析到错误之后,需要将结果返回给service worker
}
chrome.runtime.onMessage.addListener(function (message) {
const { command, payload } = message || {};
if (command === 'singleCollectDataByDom') {
_singleCollectDataByDom(payload);
}
});
最后就是Service Worker接收Content层的采集结果。
js
// service.worker.js
async function _singleCollectDataByDom(payload) {
const {
// url,
batchId, // 批次ID
itemId, // 本次请求的唯一ID
tabId,
status,
result,
} = payload;
console.log('_singleCollectDataByDom-进入函数', payload);
// 注意,这个函数是会出现并发情况的,且chrome.storage.local中的结果不一定准,所以最终采集完毕会有一个采集时间的判断:
// 当这里一下子来了两个请求时,两个请求从chrome.storage.local中获取到的都是采集结果是A;
// 然后两个请求各自处理后的结果是B、C,且互相不包含,那么此时就会出现问题;
// 所以,一方面,"采集完毕"时机的判断中加入了采集时间的判断;另一方面,当一个item采集完毕之后,立即将数据反馈给服务端/用户侧
// 让服务端/用户侧自己整合数据并展示最终结果(推荐使用服务端,用户侧还会涉及到本函数的短连接通信,更繁琐)
// 我们这里向服务端发送请求:告知服务端本次采集批次已经采集完毕;
// const _result = await fetch('xxxxx',{
// method: 'POST',
// body: JSON.stringify({
// batchId,
// url: url,
// status: collectStatus,
// result: collectResult
// }),
// headers: {
// 'Content-Type': 'application/json',
// },
// });
const _storageKey = `collectDataByDom-${batchId}`;
const _currentCollectResult = await getSingleLocalStorageValue(_storageKey);
const _currentCollectList = JSON.parse(_currentCollectResult || '[]');
const _newestList = _currentCollectList.map((_item) => {
if (_item.itemId === itemId) {
return {
..._item,
endTime: new Date().getTime(),
status: status,
result: result,
};
}
return _item;
});
await setSingleLocalStorageValue(_storageKey, _newestList);
console.log('_singleCollectDataByDom-最新采集状态', _newestList);
// 将新打开的TAB页关闭
await chrome.tabs.remove([tabId]);
}
chrome.runtime.onConnect.addListener(function (port) {
port.onMessage.addListener(function (message) {
const { command, payload } = message;
if (command === 'singleCollectDataByDom') {
_singleCollectDataByDom(payload, port);
}
});
});
【👉 由于这里不能上传视频,麻烦移步我的博客去查看测试流程交互视频吧】
写在最后
如果你看完&理解了本小节所有内容的话,其实会发现:这个方案除了数据交互流程异步处理关键点比较难理解、流程繁琐之外也还行吧,但是在"大道至简"潮流下,我会更推荐下一小节展示的"cheerio + chrome.scripting"方案,那个方案的所有处理都在Service Worker层、完全可以同步去处理数据(同步对开发者更友好一些)。
那么,今天这个方案是真的没有用处了么?不是的,我在前几小节中曾讲到过一个案例:源网站改写了window.fetch API,直接将风控加密相关操作绑定在改写的window.fetch函数中,即,只要我们在插件的Content层调用window.fetch函数时,自动在请求中加入通过源网站的风控系统的参数。所以,结合我们今天的技术方案,是不是只要把dom解析的那段逻辑改成调用fetch函数去请求相关接口就可以了呢?
好啦,本小节到此结束,让我们期待一下下一节吧~