# 前言 提到爬蟲可能大多都會想到python,其實爬蟲的實現並不限制任何語言。 下麵我們就使用js來實現,後端為express,前端為vue3。 # 實現功能 話不多說,先看結果: ![image](https://img2023.cnblogs.com/blog/1769804/202308/1 ...
前言
提到爬蟲可能大多都會想到python,其實爬蟲的實現並不限制任何語言。
下麵我們就使用js來實現,後端為express,前端為vue3。
實現功能
話不多說,先看結果:
這是項目鏈接:https://gitee.com/xi1213/worm
項目用到的庫有:vue、axios、cheerio、cron、express、node-dev
計劃功能有:
- 微博熱榜爬取。
- 知乎熱榜爬取。
- B站排行榜爬取。
- 三個壁紙網站爬取。
- 隨機生成人臉。
- 爬取指定頁面所有圖片。
- 刪除爬取的數據。
- 定時任務(開發中)。
使用形式為:
雙擊打包出的exe(最好右鍵管理員運行,以防許可權不足)。
雙擊exe後會彈出node後端啟動的黑框。
自動在瀏覽器中打開操作界面(用戶界面)。
爬取出的數據在exe同級目錄下的exportData中。
具體實現
微博熱榜
打開微博官網,f12分析後臺請求,會發現它的熱榜數據列表在請求介面:https://weibo.com/ajax/side/hotSearch 中,無參。
在介面列表realtime中根據頁面信息,推測其欄位含義:
- word為關鍵字,
- category為類別,
- https://s.weibo.com/weibo?q=%23 + word為鏈接,
- num為熱度。
既然數據是現成的,那我們直接使用axios即可。
獲取到數據列表後將其遍歷拼接成指定格式的字元串,寫入txt,下麵是具體方法:
weibo.js
let axios = require('axios'),
writeTxt = require("../utils/writeTxt"),
{ addMsg } = require("../store/index");
//抓取weibo
async function weiboWorm(dir, time) {
let com = 'https://weibo.com';
addMsg(`${com} 爬取中...`)
let res = await axios.get(`${com}/ajax/side/hotSearch`);
//拼接數據
let strData = `微博熱榜\r\n爬取時間:${time}\r\n`
await res.data.data.realtime.forEach((l, index) => {
strData = strData +
'\r\n序號:' + (index + 1) + '\r\n' +
'關鍵字:' + l.word + '\r\n' +
'類別:' + l.category + '\r\n' +
'鏈接:https://s.weibo.com/weibo?q=%23' + l.word.replace(/\s+/g, "") + '\r\n' +
'熱度:' + l.num + '\r\n' +
'\r\n\r\n=================================================================================================================='
})
writeTxt(`${dir}/weibo_${Date.now()}.txt`, strData);//寫入txt
addMsg('$END');
}
module.exports = weiboWorm;
writeTxt.js
let fs = require('fs');
//寫入txt
function writeTxt(filePath, data) {
fs.writeFile(filePath, data, (err) => {
})
}
module.exports = writeTxt;
需要註意的是在windows中換行使用的是\r\n,在鏈接中需要去掉空格。
知乎熱榜
打開知乎官網,會發現它是需要登錄的。
f12後點擊左上角第二個按鈕,在瀏覽器中切換為手機佈局,刷新後即可不登錄顯示文章信息。
分析請求發現文章數據在請求介面:https://www.zhihu.com/api/v3/explore/guest/feeds 中,參數為limit,限制文章數。
根據頁面信息推測介面欄位含義:
- target.question.title為問題標題,
- https://www.zhihu.com/question/ + target.question.id為問題鏈接,
- target.question.answer_count為問答數,
- target.question.author.name為提問的用戶名,
- https://www.zhihu.com/org/ + target.question.author.url_token為提問的用戶鏈接,
- target.content為高贊回答的內容。
需要註意的是高贊回答的內容中有html的標簽,需要自己str.replace(/xxx/g,'')去除。
數據的具體獲取方法同微博類似。
B站排行榜
打開B站官網,找到排行榜,f12後發現數據在介面請求:https://api.bilibili.com/x/web-interface/ranking/v2 中,無參。
推測介面欄位含義:
- title為視頻標題,
- short_link_v2為視頻短鏈,
- stat.view為視頻瀏覽量,
- desc為視頻描述,
- pic為視頻封面,
- owner.name為視頻作者,
- pub_location為發佈地址,
- https://space.bilibili.com/ + owner.mid為作者鏈接。
數據的具體獲取方法同微博類似。
壁紙網站爬取
項目使用了下麵三個網站作為例子:
http://www.netbian.com/
https://www.logosc.cn/so/
https://bing.ioliu.cn/
具體思路如下:
- 用axios請求頁面。
- 將請求到的數據使用cheerio.load解析(cheerio為node中的jq,語法同jq)。
- f12分析需要的數據在什麼元素中,使用cheerio獲取到該目標元素。
- 獲取到元素中img的src內容。
- axios請求src(需要encodeURI轉碼,防止中文報錯),記得設置responseType為stream。
- 有分頁的需要考慮到動態改變url中的頁碼。
- 需要保證下載順序,一張圖片下載完成後才能下載另一張,否則下載量過大會有下載失敗的可能,使用for配合async與await即可。
具體實現代碼如下:
bian.js
let fs = require('fs'),
cheerio = require('cheerio'),
axios = require('axios'),
downloadImg = require("../utils/downloadImg.js"),
{ addMsg } = require("../store/index");
//抓取彼岸圖片
async function bianWorm(dir, pageNum) {
let page = pageNum,//抓取頁數
pagUrlList = [],
imgList = [],
index = 0,
com = 'https://pic.netbian.com';
addMsg(`${com} 爬取中...`)
for (let i = 1; i <= page; i++) {
let url = i == 1 ? `${com}/index.html` : `${com}/index_${i}.html`;
let res = await axios.get(url);
let $ = cheerio.load(res.data);//解析頁面
let slistEl = $('.slist');//找到元素列表
slistEl.find('a').each(async (j, e) => {
pagUrlList.push(`${com}${$(e).attr('href')}`);//獲取到頁面url列表
})
}
pagUrlList.forEach(async (p, i) => {
let pRes = await axios.get(p);
let p$ = cheerio.load(pRes.data);//解析頁面
let imgEl = p$('.photo-pic').find('img');//找到元素列表
let imgUrl = `${com}${imgEl.attr('src')}`;//獲取圖片url
imgList.push(imgUrl);
index++;
//迴圈的次數等於列表長度時獲取圖片
if (index == pagUrlList.length) {
let dirStr = `${dir}/bian_${Date.now()}`;
fs.mkdir(dirStr, (err) => { })
downloadImg(imgList, dirStr);//下載圖片
}
})
}
module.exports = bianWorm;
downloadImg.js
let fs = require('fs'),
axios = require('axios'),
{ addMsg } = require("../store/index");
//下載圖片
async function downloadImg(list, path) {
if (list.length == 0) {
addMsg('$END');
return;
}
// console.log(list.length);
for (let i = 0; i < list.length; i++) {
let url = encodeURI(list[i]);//轉碼,防止url中文報錯
try {
//計算下載的百分比
let percent = ((i + 1) / list.length * 100).toFixed(2);
let msgStr = `${percent}% 爬取中... ${url}`;
addMsg(msgStr);
if (i == list.length - 1) {
msgStr = `圖片爬取完成,共${list.length}項。`
addMsg(msgStr);
addMsg('$END');
}
let typeList = ['jpg', 'png', 'jpeg', 'gif', 'webp', 'svg', 'psd', 'bmp', 'tif', 'tiff', 'ico'];
let type = typeList.find((item) => {
return url.includes(item);
});//獲取圖片類型
(type == undefined) && (type = 'jpg');//判斷type是否為undefined
const imgPath = `${path}/${i + 1}.${type}`;//拼接本地路徑
const writer = fs.createWriteStream(imgPath);
const response = await axios
.get(url, { responseType: 'stream', timeout: 5000 }).catch(err => { });
response.data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
} catch (error) { }
}
}
module.exports = downloadImg;
值得註意的是需要保證準確獲取圖片資源的不同尾碼。
隨機生成人臉
這裡可沒有人臉演算法之類的,調用的是https://thispersondoesnotexist.com/ 站點的介面,此介面每次刷新可生成不同人臉。
axios請求介面後,使用fs的createWriteStream創建可寫流,將數據流寫入文件中,下麵是具體實現方法:
randomFace.js
let fs = require('fs'),
axios = require('axios'),
{ addMsg } = require("../store/index");
//生成隨機人臉
async function randomFace(dir, faceNum) {
let com = 'https://thispersondoesnotexist.com';
addMsg(`人臉生成中...`);
let dirStr = `${dir}/randomFace_${Date.now()}`;
fs.mkdir(dirStr, (err) => { })
for (let i = 1; i <= faceNum; i++) {
await axios.get(com, { responseType: 'stream' })
.then((resp) => {
const writer = fs.createWriteStream(`${dirStr}/${i}.jpg`);// 創建可寫流
resp.data.pipe(writer);// 將響應的數據流寫入文件
writer.on('finish', () => {
//計算下載的百分比
let percent = ((i) / faceNum * 100).toFixed(2);
let msgStr = `${percent}% 人臉生成中... ${dirStr}/${i}.jpg`;
addMsg(msgStr);
if (i == faceNum) {
msgStr = `人臉生成完成,共${faceNum}張。`
addMsg(msgStr);
addMsg('$END');
}
});
writer.on('error', (err) => { addMsg('$END'); });
})
}
}
module.exports = randomFace;
爬取指定頁面所有圖片
思路同上面獲取壁紙類似,只不過這次是獲取頁面所有的img標簽的src。
由於範圍擴大到所有頁面了,所以需要考慮的情況就會比較多。
有的src中是沒有http或者https的,有的src使用的是相對路徑,有的可能有中文字元,還有很多我沒考慮到的情況。
所以並不能爬取任意頁面的所有圖片,比如頁面載入過慢,或者用了懶載入、防盜鏈等技術。
下麵是我實現的方法:
allWebImg.js
let fs = require('fs'),
cheerio = require('cheerio'),
axios = require('axios'),
downloadImg = require("../utils/downloadImg.js"),
{ addMsg } = require("../store/index");
//網站所有圖片
async function allWebImgWorm(dir, com) {
let imgList = [];
addMsg(`${com} 爬取中...`);
let res = await axios.get(com).catch(err => { });
if (!res) {
addMsg('$END');
return
}
let $ = cheerio.load(res.data);//解析頁面
//獲取到頁面所有圖片標簽組成的列表
$('img').each(async (j, e) => {
let imgUrl = e.attribs.src;//獲取圖片鏈接
if (imgUrl) {
!imgUrl.includes('https') && (imgUrl = `https:${imgUrl}`);//判斷是否有https,沒有則加上
imgList.push(imgUrl);
}
})
let dirStr = `${dir}/allWebImg_${Date.now()}`;
fs.mkdir(dirStr, (err) => { })
downloadImg(imgList, dirStr);//下載圖片
}
module.exports = allWebImgWorm;
刪除爬取的數據
使用fs.unlinkSync刪除文件,fs.rmdirSync刪除目錄。
需要提前判斷文件夾是否存在。
需要遍歷文件,判斷是否為文件。為文件則刪除,否則遞歸遍歷。
下麵是我的方法:
deleteFiles.js
let fs = require('fs'),
path = require('path');
//刪除文件夾及文件夾下所有文件
const deleteFiles = (directory) => {
if (fs.existsSync(directory)) {
fs.readdirSync(directory).forEach((file) => {
const filePath = path.join(directory, file);
const stat = fs.statSync(filePath);
if (stat.isFile()) {
fs.unlinkSync(filePath);
} else if (stat.isDirectory()) {
deleteFiles(filePath);
}
});
if (fs.readdirSync(directory).length === 0) {
fs.rmdirSync(directory);
}
}
fs.mkdir('./exportData', (err) => { })
};
module.exports = deleteFiles;
定時任務
項目中該功能正在開發中,只放了一個按鈕,但思路已有了。
在node中的定時操作可用cron實現。
下麵是一個小例子,每隔10秒列印一次1:
const cron = require('cron');
async function startTask() {
let cronJob = new cron.CronJob(
//秒、分、時、天、月、周
//通配符:,(時間點)-(時間域)*(所有值)/(周期性,/之前的0與*等效)?(不確定)
'0/10 * * * * *',
async () => {
console.log(1);
},
null,
true,
'Asia/Shanghai'//時區標識符
);
};
註意事項
Server-Sent Events(SSE)
該項目中前後端數據交互介面大多使用的是get請求,但有一個除外,反顯爬取進度的介面:/getTaskState。
該介面使用的是SSE,爬取的進度與鏈接是實時顯示的。
最近火熱的ChatGPT的流式輸出(像人打字一樣一個字一個字的顯示)使用的便是這個。
SSE雖然與WebSocket一樣都是長鏈接,但不同的是,WebSocket為雙工通信(伺服器與客戶端雙向通信),SSE為單工通信(只能伺服器向客戶端單向通信)。
項目中node服務端發送數據是這樣的:
// 事件流獲取任務狀態
app.get('/getTaskState', async (req, res, next) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
let sendStr = ''//發送的消息
let id = setInterval(() => {
msgList = getMsg();
//消息列表不為空且最後一條消息不等於上一次發送的消息才能執行
if (msgList.length != 0 && msgList[msgList.length - 1] != sendStr) {
sendStr = msgList[msgList.length - 1];
console.log('\x1B[32m%s\x1B[0m', sendStr)
res.write(`data: ${sendStr}\n\n`);//發送消息
}
}, 10);
req.on('close', () => {
clearMsg();//清空消息
res.end();//結束響應
clearInterval(id);//清除定時器(否則記憶體泄漏)
});
});
需要在res.writeHead中Content-Type設置為text/event-stream即表示使用SSE發送數據。
res.write('data: test\n\n')即表示發送消息:test,每次發送消息需要以data:開頭,\n\n結尾。
使用setInterval控制消息發送頻率。
需要在服務端監聽何時關閉,使用req.on('close',()=>{})。
監聽到關閉時執行響應結束res.end()與清除定時器clearInterval(id)。
在vue客戶端接收數據是這樣的:
//事件流獲取任務狀態
const getTaskState = () => {
stateMsg.value = "";
isState.value = true;
let eventSource = new EventSource(origin.value + '/getTaskState');
eventSource.onmessage = (event) => {
if (event.data != '$END') {
stateMsg.value = event.data;
} else {
eventSource.close();//關閉連接(防止瀏覽器3秒重連)
stateMsg.value = '執行完成!是否打開數據文件夾?';
isState.value = false;
setTimeout(() => {
confirm(stateMsg.value) &&
axios.get(origin.value + '/openDir').then(res => { })//打開數據文件夾
}, 100);
}
};
//處理錯誤
eventSource.onerror = (err) => {
eventSource.close();//關閉連接
stateMsg.value = ''
isState.value = false;
};
};
直接在方法中new一個EventSource(url),這是H5中新提出的對象,可用於接收伺服器發送的事件流數據。
使用EventSource接收數據,直接在onmessage中獲取event.data即可。
關閉連接記得使用eventSource.close()方法,因為伺服器單方面關閉連接會觸發瀏覽器3秒重連。
處理錯誤使用eventSource.onerror方法。
關於關閉SSE連接的時機,這是由node服務端決定的。
我在後端有一個store專門用於存儲消息數據:
store/index.js
let msgList = [];//消息列表
function addMsg(msg) {
msgList.push(msg);
}
function getMsg() {
return msgList;
}
function clearMsg() {
//清空msgList中元素
msgList = [];
}
module.exports = {
addMsg,
getMsg,
clearMsg
};
- 在爬取數據時,後端會計算爬取的進度,將生成的消息字元串push到msgList列表中,每隔10ms發送給前端msgList列表中的最後一個元素。
- 當後端數據爬取完成時會向msgList中push存入指定字元串:$END,表示獲取完成。
- 當前端識別到獲取的消息為$END時,關閉連接。
- 後端監聽到前端連接被關閉,則後端也關閉連接。
pkg打包
全局安裝pkg時最好網路環境為可訪問github的環境,否則你只能手動下載那個失敗的包再扔到指定路徑。
pkg安裝完成後需要在package.json中配置一番(主要是配置assets,將public與需要的依賴包打包進exe中)。
這是我的package.json配置:
{
"name": "worm",
"version": "0.1.3",
"description": "",
"bin": "./index.js",
"scripts": {
"start": "node-dev ./index.js",
"dist": "node pkg-build.js"
},
"pkg": {
"icon": "./public/img/icon.ico",
"assets": [
"public/**/*",
"node_modules/axios/**/*.*",
"node_modules/cheerio/**/*.*",
"node_modules/cron/**/*.*",
"node_modules/express/**/*.*"
]
},
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^0.27.2",
"cheerio": "^1.0.0-rc.12",
"cron": "^2.3.1",
"express": "^4.18.2",
"node-dev": "^8.0.0"
}
}
我的打包命令是通過scripts中的dist在pkg-build.js中引入的,因為我需要將版本號輸出在打包出的exe文件名中。
若打包命令直接寫在package.json的scripts中會無法讀取打包進程中項目的version。
這是我的pkg-build.js:
//只有通過node xxx.js方式執行的命令才能獲取到package.json的version
const pkg = require('./package.json'),
{ execSync } = require('child_process');
const outputName = `dist/worm_v${pkg.version}.exe`;//拼接文件路徑
const pkgCommand = `pkg . --output=${outputName} --target=win --compress=GZip`;//打包命令
execSync(pkgCommand);//執行打包命令
上面命令中的output表示輸出路徑(包含exe文件名),target表示打包的平臺,compress表示壓縮格式。
需要註意的是使用pkg打包時,項目中axios的版本不能太高。
否則即使你將axios寫在pkg的打包配置里也無濟於事,我使用的axios版本為0.27.2。
解決跨域
我node使用的是express,直接在header中配置Access-Control-Allow-Origin為* 即可。
app.all('*', (req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");//允許所有來源訪問(設置跨域)
res.header("Access-Control-Allow-Headers", "X-Requested-With,Content-Type");//允許訪問的響應頭
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");//允許訪問的方法
res.header("X-Powered-By", ' 3.2.1');//響應頭
res.header("Content-Type", "application/json;charset=utf-8");//響應類型
next();
});
child_process模塊
眾所周知,node是單線程運行的,在主線程中執行大量計算任務時會產生無響應的問題。
但node內置的child_process模塊卻可以創建新的進程,在新進程中執行操作不會影響到主進程的運行。
在此項目中自動打開瀏覽器、打開指定文件夾、執行打包命令用的就是它。
// 打開數據文件夾
app.get('/openDir', (req, res) => {
res.send('ok');
//打開文件夾,exe環境下需要使用exe所在目錄
let filePath = isPkg ?
`${path.dirname(process.execPath)}${dir.replace('./', '\\')}` :
path.resolve(__dirname, dir);
exec(`start ${filePath}`);
});
//監聽埠
app.listen(port, () => {
let url = `http://${ipStr}:${port}`;
isPkg && exec(`start ${url}`);//打包環境下自動打開瀏覽器
//判斷是否存在exportData文件夾,沒有則創建
fs.exists(dir, async (exists) => {
!exists && fs.mkdir(dir, (err) => { });
})
console.log(
'\x1B[31m%s\x1B[0m',
`\n
${time} 爬蟲服務開啟!\n
運行過程中禁止點擊此視窗!\n
如需關閉爬蟲關閉此視窗即可!\n`
);
});
設置靜態資源
前端使用vue開發時,需要將vue.config.js中的publicPath配置設置為./之後再打包。
將vue打包後dist內的文件拷貝到node項目的public目錄下。
需要在express設置請求頭之前使用static(path.join(__dirname, './public'))設置靜態資源:
const app = express();
const isPkg = process.pkg;//判斷是否為打包環境
const port = isPkg ? 2222 : 1111;//埠
const ipStr = getLocalIp();//獲取本機ip
let time = getFormatTime();//獲取格式化時間
let dir = './exportData';
app.use(express.json());//解析json格式
app.use(express.static(path.join(__dirname, './public')));//設置靜態資源
app.all('*', (req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");//允許所有來源訪問(設置跨域)
res.header("Access-Control-Allow-Headers", "X-Requested-With,Content-Type");//允許訪問的響應頭
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");//允許訪問的方法
res.header("X-Powered-By", ' 3.2.1');//響應頭
res.header("Content-Type", "application/json;charset=utf-8");//響應類型
next();
});
關於運行時的黑框
雙擊exe時,不僅會彈出瀏覽器用戶頁面,還會彈出黑框,點擊黑框內部還會暫停程式運行。
我有想過使用pm2守護進程幹掉黑框,但想到關閉爬蟲時只需關閉黑框即可,便留下了黑框。
限制爬取次數
做人,特別是做開發,你得有道德,你把人家網站給玩兒崩了這好嗎(′⌒`)?
沒有任何東西是無限制的,我的限制是放在前端的(可能不太嚴謹),以爬取壁紙為例,調用inputLimit(num),入參為執行次數,方法是這樣的:
//輸入限制
const inputLimit = (pageNum) => {
let val = prompt(`輸入執行次數(小於等於${pageNum})`, "");
if (val == null || isNaN(val) || parseInt(val) < 1 || parseInt(val) > pageNum) {
return false;
}
return parseInt(val);
};
//彼岸壁紙
const bianWorm = () => {
let val = inputLimit(10);
if (val) {
axios.get(origin.value + '/bianWorm?pageNum=' + val).then(res => { });
getTaskState();
}
};
後端獲取到pageNum參數後,以此作為執行爬蟲邏輯的迴圈依據。
結語
這是我第一次用js玩兒爬蟲,很多地方可能不太完善,還請大佬們指出,謝謝啦!
此項目僅供學習研究,勿作他用。
原文鏈接:https://xiblogs.top/?id=60