前言
爬蟲一直是軟件工程師里看起來比較神秘高深的一門學(xué)問,它讓人們想起黑客,以及SEO等等。
目前市面上也有專門的爬蟲工程師,并且在大企業(yè)的大數(shù)據(jù)部門,大數(shù)據(jù)工程師們也會(huì)兼任一些爬取競對(duì)數(shù)據(jù)的工作,當(dāng)然也有專門做安全的工程師應(yīng)對(duì)爬蟲的危害。所以爬蟲真的那么高深莫測(cè)嗎?下面就來揭開它的神秘面紗,帶你入門node爬蟲!
我們的目標(biāo)是:爬取鏈家官網(wǎng)租房市場(chǎng)相關(guān)數(shù)據(jù),并形成可視化圖表
在這之前,我們先普及一些爬蟲的相關(guān)知識(shí):
爬蟲的概念
網(wǎng)絡(luò)爬蟲(Web crawler),是一種按照一定的規(guī)則,自動(dòng)地抓取萬維網(wǎng)信息的程序或者腳本。(百度百科)
爬蟲的分類
通用網(wǎng)絡(luò)爬蟲(全網(wǎng)爬蟲)
爬行對(duì)象從一些 種子URL 擴(kuò)充到整個(gè) Web,主要為門戶站點(diǎn)搜索引擎和大型 Web 服務(wù)提供商采集數(shù)據(jù)。
聚焦網(wǎng)絡(luò)爬蟲(主題網(wǎng)絡(luò)爬蟲)
是 指選擇性 地爬行那些與預(yù)先定義好的主題相關(guān)頁面的網(wǎng)絡(luò)爬蟲。
增量式網(wǎng)絡(luò)爬蟲
指對(duì)已下載網(wǎng)頁采取增量式更新和只爬行新產(chǎn)生的或者已經(jīng)發(fā)生變化網(wǎng)頁 的爬蟲,它能夠在一定程度上保證所爬行的頁面是盡可能新的頁面。
Deep Web 爬蟲
爬行對(duì)象是一些在用戶填入關(guān)鍵字搜索或登錄后才能訪問到的深層網(wǎng)頁信息的爬蟲。
爬蟲工作原理
從種子URL進(jìn)入,獲取待抓取URL隊(duì)列,再進(jìn)入各URL網(wǎng)頁,進(jìn)行信息抓取并存儲(chǔ),對(duì)隊(duì)列進(jìn)行入隊(duì)和出隊(duì)操作。
爬蟲的爬行及反爬策略
爬行策略
網(wǎng)頁的抓取策略可以分為深度優(yōu)先、廣度優(yōu)先和最佳優(yōu)先三種。深度優(yōu)先在很多情況下會(huì)導(dǎo)致爬蟲的陷入(trapped)問題,目前常見的是廣度優(yōu)先和最佳優(yōu)先方法。
-
廣度優(yōu)先:
廣度優(yōu)先搜索策略是指在抓取過程中,在完成當(dāng)前層次的搜索后,才進(jìn)行下一層次的搜索。該算法的設(shè)計(jì)和實(shí)現(xiàn)相對(duì)簡單。在目前為覆蓋盡可能多的網(wǎng)頁,一般使用廣度優(yōu)先搜索方法。也有很多研究將廣度優(yōu)先搜索策略應(yīng)用于聚焦爬蟲中。其基本思想是認(rèn)為與初始URL在一定鏈接距離內(nèi)的網(wǎng)頁具有主題相關(guān)性的概率很大。另外一種方法是將廣度優(yōu)先搜索與網(wǎng)頁過濾技術(shù)結(jié)合使用,先用廣度優(yōu)先策略抓取網(wǎng)頁,再將其中無關(guān)的網(wǎng)頁過濾掉。這些方法的缺點(diǎn)在于,隨著抓取網(wǎng)頁的增多,大量的無關(guān)網(wǎng)頁將被下載并過濾,算法的效率將變低。 -
最佳優(yōu)先:
最佳優(yōu)先搜索策略按照一定的網(wǎng)頁分析算法,預(yù)測(cè)候選URL與目標(biāo)網(wǎng)頁的相似度,或與主題的相關(guān)性,并選取評(píng)價(jià)最好的一個(gè)或幾個(gè)URL進(jìn)行抓取。它只訪問經(jīng)過網(wǎng)頁分析算法預(yù)測(cè)為“有用”的網(wǎng)頁。存在的一個(gè)問題是,在爬蟲抓取路徑上的很多相關(guān)網(wǎng)頁可能被忽略,因?yàn)樽罴褍?yōu)先策略是一種局部最優(yōu)搜索算法。因此需要將最佳優(yōu)先結(jié)合具體的應(yīng)用進(jìn)行改進(jìn),以跳出局部最優(yōu)點(diǎn)。將在第4節(jié)中結(jié)合網(wǎng)頁分析算法作具體的討論。研究表明,這樣的閉環(huán)調(diào)整可以將無關(guān)網(wǎng)頁數(shù)量降低30%~90%。 -
深度優(yōu)先:
深度優(yōu)先搜索策略從起始網(wǎng)頁開始,選擇一個(gè)URL進(jìn)入,分析這個(gè)網(wǎng)頁中的URL,選擇一個(gè)再進(jìn)入。如此一個(gè)鏈接一個(gè)鏈接地抓取下去,直到處理完一條路線之后再處理下一條路線。深度優(yōu)先策略設(shè)計(jì)較為簡單。然而門戶網(wǎng)站提供的鏈接往往最具價(jià)值,PageRank也很高,但每深入一層,網(wǎng)頁價(jià)值和PageRank都會(huì)相應(yīng)地有所下降。這暗示了重要網(wǎng)頁通常距離種子較近,而過度深入抓取到的網(wǎng)頁卻價(jià)值很低。同時(shí),這種策略抓取深度直接影響著抓取命中率以及抓取效率,對(duì)抓取深度是該種策略的關(guān)鍵。相對(duì)于其他兩種策略而言。此種策略很少被使用。
反爬策略
后端的反爬策略一般是通過限制IP訪問頻率以及接口請(qǐng)求頻率來反爬,而前端的反爬策略五花八門,讓人大開眼界:
-
FONT-FACE拼湊式:
代表: 貓眼
頁面
字體
頁面使用了font-face定義了字符集,并通過unicode去映射展示。
其中woff字體是網(wǎng)頁開放字體格式。
每次刷新頁面,字符集的url都會(huì)變化,加大爬取成本。
只能通過圖像識(shí)別(OCR)or爬取字符集去爬取相關(guān)信息。 -
back-ground拼湊式:
代表:美團(tuán)
頁面
背景圖
數(shù)字其實(shí)是圖片,根據(jù)不同的background偏移,顯示不同字符。類似精靈圖。
不同頁面,圖片的字符及順序都不同,增大了爬蟲難度,增加安全性。 -
字符干擾式:
代表:微信公眾號(hào)
頁面
下劃線部分為干擾文字,方框里為真實(shí)文字。
通過設(shè)置opacity: 0或者display: none的方式將干擾文字隱藏,起到反爬作用。 -
偽元素隱藏式:
代表: 汽車之家
頁面
把關(guān)鍵的廠商信息,做到了偽元素的content里。
爬蟲必須要解析css,拿到偽元素的content,提升爬蟲難度。 -
元素定位覆蓋式:
代表: 去哪兒
頁面
對(duì)于4位數(shù)字的機(jī)票價(jià)格,先用4個(gè)i標(biāo)簽渲染,再用兩個(gè)b標(biāo)簽通過絕對(duì)定位覆蓋故意展示錯(cuò)誤的i標(biāo)簽,最后在視覺上形成正確的價(jià)格。
爬蟲不僅要會(huì)解析css,還要會(huì)做數(shù)學(xué)題。
Coding
使用工具:
-
Node.js
—— 搭建后臺(tái)服務(wù)器 -
Express
—— 實(shí)現(xiàn)node.js的http封裝及使用 -
Superagent
—— 基于node的客戶端請(qǐng)求代理模塊 -
Cheerio
—— 基于node的網(wǎng)頁DOM元素操作模塊 -
Nightmare
—— 瀏覽器模擬自動(dòng)化庫 -
Ejs
—— ssr服務(wù)端渲染ejs模板 -
Echarts
—— 基于canvas的可視化圖表模塊
具體實(shí)現(xiàn)步驟:
1、Express啟動(dòng)http服務(wù),初始化ejs
2、分析目標(biāo)頁面DOM結(jié)構(gòu),找到目標(biāo)元素,使用工具請(qǐng)求目標(biāo)頁面并獲取數(shù)據(jù)
3、將數(shù)據(jù)注入ejs模板,并形成可視化圖表
4、使用自動(dòng)化工具模擬瀏覽器與用戶行為進(jìn)行測(cè)試
分析DOM結(jié)構(gòu)說明:
這個(gè)頁面就是所謂的種子URL,我想要每個(gè)城區(qū)的數(shù)據(jù),就需要進(jìn)入到每個(gè)區(qū)域去獲取數(shù)據(jù),也就是URL隊(duì)列,那么就需要獲取每個(gè)區(qū)域的DOM元素里的URL:
$('.filter .filter__wrapper ul[data-target=area] li>a').each((index, ele) => {
...
})
核心代碼:
// 核心js
const superagent = require('superagent')
const express = require('express')
var router = express.Router()
require('node-jsx').install()
const app = express()
const url = require('url')
const cheerio = require('cheerio')
const fs = require('fs')
const Nightmare = require('nightmare') // 自動(dòng)化測(cè)試包,處理動(dòng)態(tài)頁面
const nightmare = Nightmare({ show: true }) // show:true 顯示內(nèi)置模擬瀏覽器
//服務(wù)端渲染ejs模板
var ejs = require('ejs')
app.engine('.html',ejs.__express)
app.set('view engine','ejs')
let data = [] // 存放房源具體數(shù)據(jù)
let count = [] // 存放各區(qū)域房源數(shù)量
let allUrl = [] // 存放待抓取url隊(duì)列
//目標(biāo)網(wǎng)站
let lianjiaUrl = 'https://bj.lianjia.com/' // url前綴
let zufangUrl = 'https://bj.lianjia.com/zufang/' // 種子url1
let haidianUrl = 'https://bj.lianjia.com/zufang/haidian/rt200600000001/' // 種子url2
//分頁規(guī)律 https://bj.lianjia.com/zufang/pg2/#contentList
// #content .content__list--item--main .content__list--item--title a .text()
// href地址
let server = app.listen(3001, function () {
let host = server.address().address
let port = server.address().port
console.log('Your App is running at http://%s:%s', host, port)
})
// 獲取各區(qū)域房屋套數(shù)
superagent.get(zufangUrl).end((err, res) => {
if (err) throw err
let $ = cheerio.load(res.text)
$('.filter .filter__wrapper ul[data-target=area] li>a').each((index, ele) => {
let $ele = $(ele)
let href = url.resolve(lianjiaUrl, $ele.attr('href'))
superagent.get(href).end((err, res) => {
if (err) throw err
let $ = cheerio.load(res.text)
let houseData = {
'name': $('.filter .filter__wrapper ul:nth-child(2) li.strong>a').text(),
'value': $('.content .content__article .content__title .content__title--hl').text()
}
count.push(houseData)
})
})
})
// 獲取海淀首頁所有房源鏈接元素
superagent.get(haidianUrl).end((err,res)=>{
if(err) return console.log(err)
let $ = cheerio.load(res.text)
$('#content .content__list--item .content__list--item--main>p:first-child>a:first-child').each((index, ele)=>{
let $ele = $(ele)
// 拼接單獨(dú)房源url
let href = url.resolve(lianjiaUrl, $ele.attr('href'))
allUrl.push(href)
superagent.get(href).end((err, res)=>{
if(err){
return console.log(err)
}
let $ = cheerio.load(res.text)
//標(biāo)題 .content p.content__title text()
//租金 #aside .content__aside--title span內(nèi)的文字
//格局 .content__aside__list .content__article__table span:nth-child(2) i.typ旁邊的元素
//平方數(shù) .content__aside__list .content__article__table span:nth-child(2) i.area 旁邊的元素
// orient旁邊的元素是房間的朝向
//房源上架時(shí)間 content__subtitle 房源上架時(shí)間 截取10位
let title = $('div.content .content__title:first-child').text()
let money = $('#aside .content__aside--title>span:first-child').text()
let houseType = $('#aside .content__aside__list .content__article__table>span').eq(1).last().html()
let area = $('#aside .content__aside__list .content__article__table>span').eq(2).last().html()
houseType = houseType.substr(houseType.indexOf('</i>') + 4)
area = area.substr(area.indexOf('</i>')+4)
let code = $('#aside .content__aside__list .content__aside__list--bottom:first-child').attr('housecode')
let upTime = $('div.content .content__subtitle').html()
upTime = upTime.substr(upTime.indexOf('<i class="hide">')+ 16, upTime.indexOf('<i class="house_code">') - 28)
upTime = upTime.substr(upTime.indexOf('</i>') + 4)
// console.log(unescape(upTime.replace(/&#x/g,'%u').replace(/;/g,'')))
let houseData = {
'title':title,
'houseCode': code,
'money':money,
'href': href,
'houseType':unescape(houseType.replace(/&#x/g,'%u').replace(/;/g,'')),
'area': unescape(area.replace(/&#x/g,'%u').replace(/;/g,'')),
'upTime':unescape(upTime.replace(/&#x/g,'%u').replace(/;/g,'')),
}
fs.appendFile('data/result1.json', `${JSON.stringify(houseData)},` ,'utf-8', function (err) {
if(err) throw new Error("appendFile failed...")
//console.log("數(shù)據(jù)寫入success...")
})
// console.log(houseData)
data.push(houseData)
})
})
})
app.set('views', './views')
// app.set('view engine', 'pug')
app.get('/',(req, res)=>{
// res.send('Hello World!')
res.render('index', { name: '鏈家爬蟲數(shù)據(jù)可視化', data: data})
})
app.post('/fetch',function(req,res){
res.json({data: data, count: count})
})
app.get('/show',(req, res) =>{
res.send({
data: data
})
})
// 核心ejs模板
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title><%= name %></title>
</head>
<style>
</style>
<body>
<h1><%= name %></h1>
<div>
<div id="main" style="width: 600px;height:400px;">123</div>
<div id="submain" style="width: 600px;height:400px;">123</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/echarts/4.2.1/echarts-en.common.min.js"></script>
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
<script type="text/javascript">
$.ajax({
type: 'POST',
url: '/fetch'
}).done(function (results) {
console.log('results', results); //=>params
const areaRange = ['西城', '東城', '海淀', '朝陽', '昌平', '通州', '大興']
// 計(jì)算一、二、三居均價(jià)及面積
let oneRoomPrice = [];
let oneRoomArea = [];
let twoRoomPrice = [];
let twoRoomArea = [];
results.data.forEach(v => {
if (v.title.includes('1室')) {
oneRoomPrice.push(parseInt(v.money))
oneRoomArea.push(parseInt(v.area))
} else if (v.title.includes('2室')) {
twoRoomPrice.push(parseInt(v.money))
twoRoomArea.push(parseInt(v.area))
}
})
function average (data) {
return parseInt(data.reduce((a, b) => a + b) / data.length)
}
// 基于準(zhǔn)備好的dom,初始化echarts實(shí)例
var myChart = echarts.init(document.getElementById('main'));
var pieChart = echarts.init(document.getElementById('submain'));
// 指定圖表的配置項(xiàng)和數(shù)據(jù)
var option = {
color: ['#4cabce', '#e5323e'],
title: {
text: '海淀區(qū)房屋租賃情況統(tǒng)計(jì)'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['均價(jià)', '面積']
},
xAxis: {
axisTick: {show: true},
data: ['一居', '兩居']
},
yAxis: [{type: 'value'}],
series: [{
name: '均價(jià)',
type: 'bar',
data: [average(oneRoomPrice), average(twoRoomPrice)]
}, {
name: '面積',
type: 'bar',
data: [average(oneRoomArea) * 100, average(twoRoomArea) * 100]
}]
};
var pieOption = {
backgroundColor: '#2c343c',
title: {
text: '北京各區(qū)域房源數(shù)量統(tǒng)計(jì)',
left: 'center',
top: 20,
textStyle: {
color: '#ccc'
}
},
tooltip : {
trigger: 'item',
formatter: "{a} <br/> : {c} (ifhziew%)"
},
visualMap: {
show: false,
min: 80,
max: 600,
inRange: {
colorLightness: [0, 1]
}
},
series : [
{
name:'訪問來源',
type:'pie',
radius : '55%',
center: ['50%', '50%'],
data:results.count.filter(v => areaRange.includes(v.name)).sort(function (a, b) { return a.value - b.value; }),
roseType: 'radius',
label: {
normal: {
textStyle: {
color: 'rgba(255, 255, 255, 0.3)'
}
}
},
labelLine: {
normal: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)'
},
smooth: 0.2,
length: 10,
length2: 20
}
},
itemStyle: {
normal: {
color: '#c23531',
shadowBlur: 200,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
animationType: 'scale',
animationEasing: 'elasticOut',
animationDelay: function (idx) {
return Math.random() * 200;
}
}
]
};
// 使用剛指定的配置項(xiàng)和數(shù)據(jù)顯示圖表。
myChart.setOption(option);
pieChart.setOption(pieOption);
})
</script>
</body>
</html>
拿到的數(shù)據(jù)是這樣的:
形成可視化:
瀏覽器自動(dòng)化測(cè)試神器:Nightmare
特異功能:
- 內(nèi)置模擬瀏覽器,掌控一切
- 等待DOM元素出現(xiàn),應(yīng)對(duì)異步加載
- 模擬用戶行為,自動(dòng)輸入文本
- 模擬用戶行為,自動(dòng)點(diǎn)擊元素
- 在客戶端注入JS腳本并執(zhí)行
。。。
// 通過瀏覽器自動(dòng)化庫獲取數(shù)據(jù)
nightmare
.goto(zufangUrl)
.wait('.filter .filter__wrapper ul[data-target=area] li>a')
.type('.search__wrap input.search__input', '海淀第一海景房')
// .click('.filter .filter__wrapper ul[data-target=area] li:nth-child(2)>a')
.evaluate(() => document.querySelector(".wrapper").innerHTML)
.then(htmlStr => {
let $ = cheerio.load(htmlStr)
$('.filter .filter__wrapper ul[data-target=area] li>a').each((index, ele) => {
let $ele = $(ele)
let href = url.resolve(lianjiaUrl, $ele.attr('href'))
superagent.get(href).end((err, res) => {
if (err) throw err
let $ = cheerio.load(res.text)
let houseData = {
'name': $('.filter .filter__wrapper ul:nth-child(2) li.strong>a').text(),
'value': $('.content .content__article .content__title .content__title--hl').text()
}
count.push(houseData)
})
})
})
.catch(error => {
console.log(`抓取失敗 - ${error}`)
})
它可以打開一個(gè)無頭的模擬瀏覽器窗口,去進(jìn)行各種常規(guī)瀏覽器不能進(jìn)行的操作,比如模擬用戶輸入內(nèi)容:
總結(jié)
- 概念:抓取萬維網(wǎng)數(shù)據(jù)的執(zhí)行腳本
- 工作原理:從種子url進(jìn)入,展開工作
- 爬行策略:廣度優(yōu)先、最佳優(yōu)先、深度優(yōu)先
- 反爬策略:增大爬行成本,但無法完全防止
- Coding:使用各種工具對(duì)目標(biāo)網(wǎng)頁進(jìn)行爬取
- 自動(dòng)化測(cè)試工具:內(nèi)置瀏覽器,模擬用戶行為
關(guān)于爬蟲,還有很多深入的有趣的東西可以研究,比如身份驗(yàn)證以及其他反爬攻防戰(zhàn),歡迎各位共同探討!