副業(yè)1000元,文末有代碼,制作一個谷歌瀏覽器插件,實現(xiàn)網(wǎng)頁數(shù)據(jù)爬蟲
一、什么是瀏覽器插件
瀏覽器插件,基于瀏覽器的原有功能,另外增加新功能的工具,是可定制瀏覽體驗的小型軟件程序,讓用戶可以根據(jù)個人需要或偏好來定制瀏覽器。
如攔截網(wǎng)頁中的廣告、劃詞翻譯、倍速視頻等等。
Chrome、edge等瀏覽器中都有專門的插件下載商店。 受某些原因限制,Chrome服務(wù)并不能正常訪問
這里提供幾個常用的瀏覽器插件下載地址:
Chrome插件,谷歌瀏覽器插件下載,chrome谷歌商店插件crx應(yīng)用推薦與下載-擴展迷
Chrome插件,Chrome商店,谷歌瀏覽器插件下載,谷歌商店 - Chrome插件網(wǎng)
極簡插件_Chrome擴展插件商店_優(yōu)質(zhì)crx應(yīng)用下載
有興趣的小伙伴可以進入網(wǎng)站看看有沒有感興趣的、滿足自己定制化需求的插件。
如何開發(fā)瀏覽器插件
假如小伙伴找了半天,發(fā)現(xiàn)網(wǎng)上并沒有滿足自身需求的插件,那么你是否會考慮自己做一個呢?
其實這并不是什么高大上的事,網(wǎng)上那么多好用的瀏覽器插件都是開發(fā)者基于Chrome開放出的瀏覽器插件api完成的。
Chrome已經(jīng)把可能用到的各種“磚”封裝好開放出來了,那我們只需要按照一定的規(guī)則“搬”就行了啊。
開發(fā)插件之前,我們首先要了解的是插件都有哪些能力,什么能實現(xiàn),什么不能實現(xiàn)。
如果你想點一點就把15寸的筆記本屏放大到30寸,用完讓屏幕再縮小回去,那這插件肯定是實現(xiàn)不了的。
相關(guān)文檔:
chrome谷歌瀏覽器開發(fā)文檔
360瀏覽器綜述--擴展開發(fā)文檔
瀏覽器擴展 - Mozilla | MDN
以上都是通篇文檔,可能對新手并不友好,讀起來枯燥無味。
下面推薦一個能快速上手的博客,同時感謝該作者的技術(shù)輸出。Chrome插件(擴展)開發(fā)全攻略(干貨)
常見爬蟲方法的對比
后面我們會實現(xiàn)一個爬蟲功能的插件。 在開始實戰(zhàn)之前,我們可以先聊一聊常見爬蟲能力的優(yōu)缺點。
api接口 該方法速度快,容易上手,會任意編程語言都可以實現(xiàn),且操作用戶對此無感知。
但同時也有很大的缺點,這種方法很難同時發(fā)起用戶行為收集請求,有些產(chǎn)品會通過這些行為收集接口分析用戶的操作,如果邏輯變化,需要手動更新代碼到客戶處。
如果只有數(shù)據(jù)接口請求,沒有統(tǒng)計接口請求,很容易被判定為爬蟲,從而產(chǎn)生一系列負面影響。
有些產(chǎn)品還會有加密代碼,需要一些逆向工作,這就更進一步提高這種方法爬取數(shù)據(jù)的難度了。
Selenium 該方法是通過運行測試的開源工具實現(xiàn)的,常見編程語言都有對應(yīng)的工具,相較于第一種方法有著更廣范圍的應(yīng)用場景。
該方法通過啟動相關(guān)驅(qū)動支持的真實的瀏覽器,盡可能的模擬用戶操作,相關(guān)行為分析會自動請求,幾乎不需要逆向,一定程度上填補了第一種方法的弊端。
但同時該方法也有弊端,需要給客戶機安裝運行環(huán)境和客戶的Chrome瀏覽器升級等問題。
瀏覽器升級可能導(dǎo)致Selenium驅(qū)動版本和瀏覽器版本不匹配,程序就會運行失敗。邏輯變化需要手動更新到客戶處。
該方式也會被產(chǎn)品方識別出是程序啟動而不是真實用戶啟動的瀏覽器,從而產(chǎn)生負面影響。
瀏覽器插件 該方法是通過瀏覽器的開放能力實現(xiàn)的,是用戶啟動的真實瀏覽器,進一步填充了前兩種方法的弊端,通過各種形式的腳本實現(xiàn)復(fù)雜的操作。
可以發(fā)布到像app發(fā)布到應(yīng)用商店一樣發(fā)布到瀏覽器應(yīng)用商店,且提供線上更新功能。
該方法必須得會JavaScript腳本語言,同時熟知瀏覽器的開放能力,增加了學(xué)習(xí)難度。
該方法仍可以被產(chǎn)品識別出,如使用 MutationObserver 方法檢測出dom變化等。
沒有完美的方法,只有更適合的方法,不同場景使用不同的技術(shù)應(yīng)對不同的困難即可。
實現(xiàn)一個瀏覽器爬蟲插件
介紹完常見爬蟲的區(qū)別后,接下來,我們就開始實現(xiàn)一個瀏覽器爬蟲插件。
此處假設(shè)小伙伴已經(jīng)閱讀上述推薦的博客并基本熟悉瀏覽器插件的能力。
需求:爬取10頁boss直聘網(wǎng)站上全國范圍內(nèi)Python崗位的招聘信息。
拆解需求:
目標(biāo)網(wǎng)站:
boss直聘網(wǎng)站
篩選條件:
城市:全國 關(guān)鍵詞:Python
數(shù)量:
1-10頁內(nèi)的全部數(shù)據(jù)
url地址:https://www.zhipin.com/web/geek/job?query=Python&city=100010000&page=1

難點分析
使用什么腳本類型
插件有injected、content、popup、background、devtools 5種類型的腳本,不同類型擁有不同的能力,相互之間的通信方式也不盡相同。
所以首先需要根據(jù)需求結(jié)合具體類型腳本的能力來確定使用什么腳本。
popup肯定是需要的,給文件指定名稱和下達開始爬取的命令時要用到該類型腳本。
此處已確定popup腳本,其他類型待定。

攔截網(wǎng)絡(luò)請求
經(jīng)分析,從dom結(jié)構(gòu)中獲取數(shù)據(jù)不靠譜。 如,跳轉(zhuǎn)鏈接,某些產(chǎn)品的鏈接并不放在dom中,而是通過點擊事件句柄判斷按鈕的index、id等唯一標(biāo)識,從js作用域中找到對應(yīng)的鏈接進行跳轉(zhuǎn)。
那么就需要考慮怎么能攔截到網(wǎng)絡(luò)請求了。插件的核心是不同類型js腳本,不同類型的腳本能力不同,需結(jié)合實際考慮。
在5種腳本類型對比可知,只有injected、devtools、background可以攔截到網(wǎng)絡(luò)請求。但background拿不到響應(yīng)體,故拋棄。
devtools功能很強大,它可以模擬出一個和開發(fā)者工具(F12)-網(wǎng)絡(luò)(network)功能幾乎一樣的面板,但實現(xiàn)起來會相對復(fù)雜。
前端同學(xué)常用的React Developer Tools、vue-tools調(diào)試面板就是使用該技術(shù)開發(fā)的。

經(jīng)過權(quán)衡對比,使用更加簡單的injected來做網(wǎng)絡(luò)攔截。
此處已確定popup和injected兩種腳本。
通信
爬取過程很簡單,通信是一件復(fù)雜的事,詳情通信可參考上述文檔。
現(xiàn)在的流程是 injected攔截網(wǎng)絡(luò)請求 -> popup下達開始爬取的指令 -> injected開始執(zhí)行腳本收集數(shù)據(jù) -> injected清洗并導(dǎo)出數(shù)據(jù)。
現(xiàn)在確定的popup和injected兩種類型能滿足嗎?很遺憾,不能,通過上述博客中總結(jié)的通信方式可知,這兩種類型的腳本不能直接通信,也就是popup不能告訴injected可以開始收集數(shù)據(jù)了。
怎么實現(xiàn)呢?需要引入一個“中介”——content,作為popup和injected中間通信的橋梁。
現(xiàn)在的過程就變成了,injected攔截網(wǎng)絡(luò)請求 -> popup下達開始爬取的指令 -> content轉(zhuǎn)發(fā)指令-> injected開始執(zhí)行腳本收集數(shù)據(jù) -> injected清洗并導(dǎo)出數(shù)據(jù)
這里可以留一個小問題,最后一步可以使用content實現(xiàn)嗎,為什么不使用這種方式?
此處已確定popup和injected、content三種腳本。
代碼部分
確定腳本選型后就可以創(chuàng)建工程了,新建一個文件夾,創(chuàng)建如下的目錄結(jié)構(gòu):
boss-plugin
├─ html
│ └─ popup
│ ├─ popup.html // 點擊瀏覽器右上角插件,彈出popup,傳遞用戶指令
│ └─ popup.js
├─ js
│ ├─ content // content腳本通過manifest.json配置文件可以直接添加到頁面中
│ │ ├─ install.js // injected腳本并不能直接通過配置添加到頁面中,需要通過content執(zhí)行js代碼動態(tài)插入到dom中
│ │ └─ page.js // “中介角色”,轉(zhuǎn)發(fā)指令
│ └─ inject
│ ├─ network.js // 攔截網(wǎng)絡(luò)請求
│ ├─ page.js // 具體執(zhí)行收集、清洗、導(dǎo)出數(shù)據(jù)的邏輯
│ └─ pikazExcel.js // 導(dǎo)出數(shù)據(jù)為Excel的js類庫
└─ manifest.json // 瀏覽器識別插件配置的文件,必須
manifest.json
{
"name": "爬取boss數(shù)據(jù)",
"version": "1.0",
"manifest_version": 2,
"browser_action": {
"default_popup": "/html/popup/popup.html"
},
"content_scripts": [
{
"matches": ["*://www.zhipin.com/*"],
"js": ["/js/content/page.js", "/js/content/install.js"],
"run_at": "document_start"
}
],
"web_accessible_resources": [
"/js/inject/pikazExcel.js",
"/js/inject/page.js",
"/js/inject/network.js"
]
}
html/popup/popup.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
#box {
align-items: center;
}
#input-box {
display: flex;
align-items: center;
}
.label {
white-space: nowrap;
}
#btn-box {
padding-left: 50px;
padding-top: 10px;
}
</style>
</head>
<body>
<div id="box">
<div id="input-box">
<span class="label">文件名:</span>
<input id="filename" type="text" placeholder="可選,默認為當(dāng)前時間" />
</div>
<div id="btn-box">
<button id="export-btn">導(dǎo)出數(shù)據(jù)</button>
</div>
</div>
<script src="/html/popup/popup.js"></script>
</body>
</html>
html/popup/popup.js
function onClickExport() {
document.getElementById('export-btn').disabled = true
const filename = document.getElementById('filename').value
const cb = (tab) => {
chrome.tabs.sendMessage(tab.id, { action: "CHANGE_POPUP_ALLOW_DOWNLOAD", filename });
}
chrome.tabs.getSelected(null, cb);
}
document.getElementById('export-btn').onclick = onClickExport
js/content/install.js
setTimeout(() => {
const pageScript = document.createElement('script');
pageScript.setAttribute('type', 'text/javascript');
pageScript.setAttribute('src', chrome.extension.getURL("/js/inject/page.js"));
document.head.appendChild(pageScript);
const networkScript = document.createElement('script');
networkScript.setAttribute('type', 'text/javascript');
networkScript.setAttribute('src', chrome.extension.getURL('/js/inject/network.js'));
document.head.appendChild(networkScript);
const excelScript = document.createElement('script');
excelScript.setAttribute('type', 'text/javascript');
excelScript.setAttribute('src', chrome.extension.getURL("/js/inject/pikazExcel.js"));
document.head.appendChild(excelScript);
});
js/content/page.js
// 轉(zhuǎn)發(fā)popup指令 popup => content script => inject script
chrome.extension.onMessage.addListener(
function (request) {
if (request.action == "CHANGE_POPUP_ALLOW_DOWNLOAD") {
// popup 告訴頁面可以開始收集并下載數(shù)據(jù)了
window.postMessage({ action: 'CHANGE_POPUP_ALLOW_DOWNLOAD', popupAllowDownload: true, filename: request.filename }, '*');
}
}
);
js/inject/network.js
此處需要注意瀏覽器發(fā)起請求的兩種方式:xhr和fetch,前者使用較多,后者也在開發(fā)過程中見到過。
const _requestTools = {
formatQueryString(queryString = '') {
const result = {};
if (queryString.length > 0) {
queryString = queryString.split('?')[1].split('&');
for (let kv of queryString) {
kv = kv.split('=');
if (kv[0]) result[kv[0]] = decodeURIComponent(kv[1]);
}
}
return result
}
}
function _initXMLHttpRequest() {
// 攔截網(wǎng)絡(luò)請求方法1
const open = XMLHttpRequest.prototype.open;
const _targetApiList = [
'wapi/zpgeek/search/joblist.json'
]
XMLHttpRequest.prototype.open = function (...args) {
this.addEventListener('load', function () {
// 如果當(dāng)前url并不包含_targetApiList中任意一個地址,則阻止后續(xù)操作
if (!_targetApiList.find(item => this.responseURL.includes(item))) return
const result = {
responseHeaders: {},
responseData: {},
request: this,
status: this.status,
params: _requestTools.formatQueryString(this.responseURL)
}
// 格式化響應(yīng)頭
this.getAllResponseHeaders().split("\r\n").forEach((item) => {
const [key, value] = item.split(": ");
if (key) result.responseHeaders[key] = value;
});
if (result.responseHeaders["content-type"].includes("application/json")) {
// 如果響應(yīng)頭是content-type是json,則格式化響應(yīng)體
if (this.response?.length) result.responseData = JSON.parse(this.response);
}
_crawler.collectData(result)
})
return open.apply(this, args);
};
// 攔截網(wǎng)絡(luò)請求方法2
// 此處的方法攔截在目標(biāo)網(wǎng)站中并沒有遇到,在其他項目中遇到過,故添加在此做補充知識點。
const { fetch: originalFetch } = window;
window.fetch = async (...args) => {
let [resource, config] = args;
let response = await originalFetch(resource, config);
if (response.status === 200) {
response
.clone()
.json()
.then((data) => {
console.log('響應(yīng)數(shù)據(jù):', data)
});
}
return response;
};
}
_initXMLHttpRequest();
js/inject/page.js
// 因為inject js和頁面共享js作用域,為防止污染全局變量,故插件中變量名都以_開頭
class _Crawler {
constructor() {
this.downloadPageNum = 10 // 允許下載多少頁
this.filename = '' // 從popup傳進來的輸入的文件名
this.allowDownload = false // popup給出指令允許下載
this.collectionList = [] // 收集每頁請求得到的數(shù)據(jù)
}
/**
* 獲取當(dāng)前年-月-日 時:分:秒
* @returns string
*/
getTime() {
const time = new Date();
const timeInfo =
(time.getFullYear() + '-' + (time.getMonth() + 1) + '-' + time.getDate() + ' ' + time.getHours() + ':' + time.getMinutes() + ':' + time.getSeconds())
return timeInfo
}
// 生成隨機延遲秒數(shù), 默認3-4秒
getRandomTimeOut(x = 3000, y = 4000) {
return Math.round(Math.random() * (y - x) + x)
}
collectData(result) {
// 首次進來或搜索條件變化,清空收集結(jié)果
const currentPage = result.params.page * 1;
if (currentPage * 1 === 1) this.collectionList = []
if (!this.collectionList.find(el => result.request.responseURL.includes(el.responseURL))) {
const item = {
responseURL: result.request.responseURL,
responseData: result.responseData
}
this.collectionList.push(item)
}
// 如果沒有點擊導(dǎo)出按鈕,則阻止后續(xù)操作
if (!this.allowDownload) return
// 結(jié)束收集行為的條件,然后進行數(shù)據(jù)清洗和導(dǎo)出excel
if (currentPage >= this.downloadPageNum) {
const sheet = this.clearData()
this.download(sheet)
} else {
// 隨機3-4秒后進行點擊下一頁
// 這是寫爬蟲最基本的道德了,盡量在學(xué)習(xí)技術(shù)的同時,不要對目標(biāo)服務(wù)器產(chǎn)生壓力和影響其正常運行
const randomTimeout = this.getRandomTimeOut()
setTimeout(() => {
this.handleClickNext()
}, randomTimeout);
}
}
clearData() {
const headerAndKeyList = [
{
header: '崗位名稱',
key: 'jobName'
},
{
header: '地址',
key: 'jobAddress'
},
{
header: '薪資',
key: 'salaryDesc'
},
{
header: '經(jīng)驗',
key: 'jobExperience'
},
{
header: '學(xué)歷',
key: 'jobDegree'
},
{
header: '技術(shù)棧',
key: 'skills'
},
{
header: '公司名稱',
key: 'brandName'
},
{
header: '公司行業(yè)',
key: 'brandIndustry'
},
{
header: '公司融資階段',
key: 'brandStageName'
},
{
header: '公司規(guī)模',
key: 'brandScaleName'
},
{
header: '福利待遇',
key: 'welfareList'
},
]
const itemTableConfig = {
tHeader: headerAndKeyList.map(el => el.header),
keys: headerAndKeyList.map(el => el.key),
table: []
}
this.collectionList.forEach(el1 => {
el1.responseData.zpData.jobList.forEach(el2 => {
const { jobName, cityName, areaDistrict, businessDistrict, salaryDesc, jobExperience, jobDegree, skills, brandName, brandIndustry, brandStageName, brandScaleName, welfareList } = el2
const item = {
jobName,
jobAddress: `${cityName}·${areaDistrict}·${businessDistrict}`,
salaryDesc, jobExperience, jobDegree, skills, brandName, brandIndustry, brandStageName, brandScaleName, welfareList
}
itemTableConfig.table.push(item)
})
})
return [itemTableConfig]
}
download(sheet) {
const filename = this.filename || this.getTime()
window.pikazExcelJs.default.excelExport({
sheet,
filename,
beforeStart: (bookType, filename, sheet) => {
console.log("開始導(dǎo)出", bookType, sheet, filename);
},
}).then(() => {
this.filename = ''
this.allowDownload = false
this.collectionList = []
});
}
handleClickNext() {
const nextSelector = '.pagination-area .options-pages a:last-child'
const nextDom = document.querySelector(nextSelector)
nextDom.click()
// 如果目標(biāo)網(wǎng)站有收集用戶行為的接口,此處可添加模擬用戶操作,如滾動頁面、點擊某些元素
}
}
const _crawler = new _Crawler();
// 監(jiān)聽從popup發(fā)送的指令 popup => content script => inject script
window.addEventListener("message", function (e) {
if (e.data.action === 'CHANGE_POPUP_ALLOW_DOWNLOAD') {
_crawler.filename = e.data.filename
_crawler.allowDownload = true
_crawler.handleClickNext()
}
}, false);
js/inject/pikazExcel.js
文檔和下載地址:
https://www.npmjs.com/package/pikaz-excel-js
最后在Chrome瀏覽器中打開這個地址 chrome://extensions/
開啟開發(fā)者模式 -> 加載已解壓的擴展程序 -> 選擇剛才新建的文件夾 -> 確認導(dǎo)入

這時候就已經(jīng)把剛才編寫的導(dǎo)入到瀏覽器中了,打開目標(biāo)頁面
然后點擊紅框區(qū)域,輸入文件名(可選),點擊導(dǎo)出數(shù)據(jù),就可以開始爬取內(nèi)容了


最終效果:

參考鏈接,感謝以下鏈接提供相關(guān)技術(shù)的解決思路:
XMLHttpRequest 攔截處理
Chrome插件(擴展)開發(fā)全攻略(干貨)
pikaz-excel-js
代碼下載地址
鏈接:https://pan.baidu.com/s/1RHYE-CuZqmBJm7Wj9G4fYQ
提取碼:u5cp
如果想接副業(yè)單子
如果想跟著螞蟻老師做副業(yè)兼職,可以每晚22點來螞蟻老師抖音直播間:
抖音賬號:Python導(dǎo)師-螞蟻
