利用node實現(xiàn)的小爬蟲以及有關(guān)異步操作的一些思考
??在搭建唯厘UED blog的時候一直想實現(xiàn)一個功能,就是類似于bing首頁每天的壁紙都不一樣,于是就考慮使用node做個小爬蟲來實現(xiàn)這個功能并把它應(yīng)用在管理后臺的登陸頁面上。下面主要來分享一下實現(xiàn)的過程和遇到的一些坑以及一些思考。
相關(guān)工具庫
??首先介紹一下實現(xiàn)這個功能所使用到的一些工具庫。
- cheerio: Node.js 版的jQuery
- https:封裝了一個HTPPS服務(wù)器和一個簡易的HTTPS客戶端
- iconv-lite:解決爬取網(wǎng)頁出現(xiàn)亂碼
??這是一個簡單的小爬蟲,那么,要爬取所需要的數(shù)據(jù),首先要分析頁面的結(jié)構(gòu)。這次爬取的是必應(yīng)壁紙(https://bing.ioliu.cn/),可以看到頁面的結(jié)構(gòu)如下圖

??那么我們的目標(biāo)很明確,只需要爬取img標(biāo)簽里面的src屬性值就可以了。那么首先,當(dāng)然是要爬取到頁面的html結(jié)構(gòu),再進行篩選,代碼如下
function get(url) {
https.get(url, (sres) => {
const chunks = []
sres.on('data', (chunk) => {
chunks.push(chunk)
})
sres.on('end', () => {
const html = iconv.decode(Buffer.concat(chunks), 'UTF-8')
const $ = cheerio.load(html, { decodeEntities: false })
})
})
}
??ondata事件獲取到的結(jié)果是一堆Buffer數(shù)據(jù)流,如下圖,那么我們需要把這一堆數(shù)據(jù)轉(zhuǎn)換成DOM,這個時候就需要使用cheerio,cheerio是為服務(wù)器特別定制的,快速、靈活、實施的jQuery核心實現(xiàn)。簡單來說,就是它能夠把這一堆堆buffer數(shù)據(jù)流轉(zhuǎn)變?yōu)镈OM,并且支持使用jquery的語法來進行DOM操作。

??在上述ondata事件中補充以下代碼,如果有數(shù)據(jù),那么就爬取成功了。但是就這么容易成功了嗎?文章短小一般都不是我的尿性,先劇透,下面代碼執(zhí)行完之后的結(jié)果是undefined。

console.log($('.mark').css('background-image'))
??ok,我們分析一下原因,當(dāng)進去這個圖片頁面的時候,可以發(fā)現(xiàn)其使用了懶加載的效果,那么就意味著,圖片鏈接是由js動態(tài)添加上去的,再去看一下js代碼(如下),果不其然。這里就遇到了第一個坑,大部分簡單的爬蟲并沒有辦法獲取得到動態(tài)生成的內(nèi)容,要實現(xiàn)獲取動態(tài)內(nèi)容的效果,需要使用PhantomJS,有興趣的可以深入了解一下,由于時間關(guān)系,以及社區(qū)上反映的的PhantomJS的性能問題,這里就不使用PhantomJS。

??既然直接爬取的途徑斷了,那么我們能不能試一下間接爬取,實行曲線救國?先分析分析一下一張完整大圖的路徑和縮略圖的路徑有什么不一樣的地方
大圖:http://images.ioliu.cn/bing/Shanghai_ZH-CN10665657954_1920x1080.jpg
縮略圖: https://bing.ioliu.cn/photo/Shanghai_ZH-CN10665657954?force=home_1
??可以看到,這兩者不同的只是照片的名字之外的地方,那么我們需要截取照片的名字,再把域名和分辨率進行拼接,這樣就能獲取得到一張大圖的真實路徑。

完整代碼
const basicUrl = 'http://images.ioliu.cn/bing'
let picLinkArray = []
/*
*params url: 需要爬取的鏈接
*/
function get(url) {
https.get(url, (sres) => {
const chunks = []
sres.on('data', (chunk) => {
console.log(chunk)
chunks.push(chunk)
})
sres.on('end', () => {
const html = iconv.decode(Buffer.concat(chunks), 'UTF-8')
const $ = cheerio.load(html, { decodeEntities: false })
const tagArray = $('.item a.mark')
const oriLinkArray = []
for (let i = 0; i < tagArray.length; i += 1) {
oriLinkArray.push(tagArray[i].attribs.href)
}
for (let i = 0; i < oriLinkArray.length; i += 1) {
const link = oriLinkArray[i]
const index = oriLinkArray[i].indexOf('force')
const picLink = oriLinkArray[i].slice(0, index - 1).replace('/photo', '').concat('_1920x1080.jpg')
picLinkArray.push(basicUrl + picLink)
}
})
})
}
??這樣,我們就實現(xiàn)了獲取大圖的函數(shù),接下來只需要循環(huán)執(zhí)行,便可以獲取得到前10頁的大圖
for (let i = 1; i < 10; i += 1) {
get('https://bing.ioliu.cn?p=' + i)
}
??到這里,爬蟲基本就完成了。很簡單的一個功能,順帶提一下另一個坑,之前爬蟲函數(shù)是寫在管理后臺一個單獨的js文件里并在login組件require進來使用,但這樣一來存在一個異步的問題,二來因為同源策略存在跨域的問題,因此換了一個思路,寫在了node層里面,這樣就避免了跨域的問題,但是異步的問題還是有一些麻煩的,下面以實現(xiàn)登錄這個功能為例來說明蛋疼的地方,順便集思廣益,看看有木有什么更優(yōu)雅的辦法來解決這個問題。
??首先簡單說明一下當(dāng)前整個項目所采用的結(jié)構(gòu),當(dāng)前采用的是操作方法和路由操作分離,對數(shù)據(jù)庫的操作方法都寫在controller文件夾下的js文件里面,同時controller會暴露一個接口給路由層。
??在美迪科項目里面,這種方法是可行的,因為lowdb的鏈?zhǔn)讲僮魇峭降?,這樣能夠順利保證數(shù)據(jù)庫的操作結(jié)果能夠順利返回到路由層,然后由路由層來進行下一步操作,如下圖。但在mongoose + mongodb的組合里,mongoose有關(guān)數(shù)據(jù)庫的結(jié)果操作都放在了一個回調(diào)函數(shù)里,這樣要把數(shù)據(jù)庫的操作結(jié)果傳遞到route層就有點蛋疼。經(jīng)過摸索,目前的解決方案有兩種。

1.返回一個promise對象給路由層
controller里面的login方法
login(user) {
var promise = userdb.find({ "username": user.username, "password": user.password }).exec()
return promise
}
路由里面的操作
router.post('/api/admin/login', (req, res, next) => {
const promise = frontEndAdmin.login(req.body)
promise.then(
function(result) {
if (result.length === 0) {
res.json(returnData('error', 503, '唯厘這里沒有這個人哦', {}))
}
}
)
res.json(data)
})
}
??這種方法的缺點也很明顯,就是對數(shù)據(jù)庫的操作和路由的操作緊密結(jié)合,并沒有很好的解耦,顯然不是我們需要的,同樣的也嘗試過使用node的eventEmitter,但也有這種問題。我們需要的僅是數(shù)據(jù)庫的操作結(jié)果,并且根據(jù)這個結(jié)果來返回不同的接口信息。
2.把res對象傳遞到controller層
controller里面的login方法
login(user, res) {
userdb.find(
{ "username": user.username, "password": user.password },
function (err, person) {
console.log(person)
if (err) {
res.json(returnData('error', 500, '出錯啦,去懟寫接口的人', {}))
}
if (person.length === 0) {
res.json(returnData('error', 503, '唯厘這里沒有這個人哦', {}))
} else {
res.json(returnData(null, null, null, {}))
}
}
)
路由里面的方法
router.post('/api/admin/login', (req, res, next) => {
frontEndAdmin.login(req.body, res)
}
??這樣咋一看似乎簡潔明了了許多,但是路由的信息操作混進了controller層,也并沒有完全的解耦,目前采用的是第二種方法。
??經(jīng)過新一輪的摸索,截止2017年09月27日18:54:59,上述問題作廢,終于探索出一個完美的解決方案,之前一直是想嘗試使用async + await這個方案的,但無奈本機node版本達不到要求,也因為同時進行的項目比較多,擔(dān)心升級后會有什么副作用,但最后還是一狠心把node版本給升級了,這樣就可以使用這個組合了。貼代碼貼代碼
controller里面的login方法
login(user) {
var promise = userdb.find({ "username": user.username, "password": user.password }).exec().then(
(result) => {
if (result.length === 0) {
return returnData('error', 503, '唯厘這里沒有這個人哦', {})
}
}
)
return promise
)
路由里面的方法
router.post('/api/admin/login', async(req, res, next) => {
const data = await frontEndAdmin.login(req.body)
res.json(data)
})
??完結(jié),撒花。