前言
上學的時候自己寫過一些爬蟲代碼,比較簡陋,基於HttpRequest請求獲取地址返回的信息,再根據正則表達式抓取想要的內容。那時候爬的網站大多都是靜態的,直接獲取直接爬即可,而且也沒有什么限制。但是現在網站的安全越來越完善,各種機器識別,打碼,爬蟲也要越來越只能才行了。
前段時間有需求要簡單爬取美團商家的數據,做了一些分析,實踐,在這里總結分享。
美團商家頁分析
1、城市大全可以很容易的在這個頁面爬出來 http://www.meituan.com/index/changecity/initiative
2、每個城市一個地址,例如深圳:http://sz.meituan.com/category/meishi
3、可以按照分類、區域、人數來分類
4、商家列表是動態JS加載的,並且會有很多頁數
5、根據商家列表再進入商家詳情獲取數據
這樣爬取流程即為
1、進去城市美食頁
2、抓取分類,循環選擇分類
3、抓取區域,循環選擇區域
4、抓取人數,循環選擇人數
5、判斷是否有下一頁按鈕,循環進入下一頁
6、進入詳情頁抓取,提交之后continue
需要爬取的數據有(這里沒有按人數爬)
CREATE TABLE `test_mt` (
`Id` int(11) NOT NULL AUTO_INCREMENT,
`city` varchar(10) NOT NULL DEFAULT '' COMMENT '城市',
`cate` varchar(15) NOT NULL DEFAULT '' COMMENT '分類',
`area` varchar(15) NOT NULL DEFAULT '' COMMENT '區域',
`poi` varchar(15) NOT NULL DEFAULT '' COMMENT '商圈',
`name` varchar(30) NOT NULL DEFAULT '' COMMENT '店名',
`addr` varchar(50) NOT NULL DEFAULT '' COMMENT '地址',
`tel` varchar(30) NOT NULL DEFAULT '' COMMENT '聯系方式',
`rj` int(11) NOT NULL DEFAULT '0' COMMENT '人均',
`rate` float(2,1) NOT NULL DEFAULT '0.0' COMMENT '評價',
`rate_count` int(11) NOT NULL DEFAULT '0' COMMENT '評價數',
`recom_food` varchar(512) NOT NULL DEFAULT '' COMMENT '特色菜',
`desc` varchar(512) NOT NULL DEFAULT '' COMMENT '門店介紹',
PRIMARY KEY (`Id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
爬蟲工具選取
為了快速實現功能,直接找現有的開源工具,說到爬蟲,python首屈一指,所以先以此嘗試
pysipder
https://github.com/binux/pyspider
最開始嘗試了pysipder,主要是因為國人寫的,先支持國產,准確的說pyspider已經是一個非常強大的爬蟲框架了,具體內容官網查看,經過試用之后,感覺有一些殺雞用牛刀的感覺,再來pyspider默認只支持抓取靜態頁,對於js加載的美團列表,難度就大了很多,中間嘗試考慮通過獲取cookie,模擬接口操作,但是協議解析起麻煩又耗時,坑肯定又多,就放棄了。
pyspider的js加載是通過配合phantomjs實現的,但是據說有內存泄露的問題,要不定期的重啟phantomjs,試用之后發現並不很順手(也許是python不熟),特別是模擬點擊操作很麻煩導致回調地獄的出現,所以考慮再多試用幾款工具選型。
scrapy
https://scrapy.org/
20K的star代表了它的強大,試用過程中發現和pysipder大同小異,遇到的問題也大同小異,也跳過了。
nightmare
https://github.com/segmentio/nightmare
之前用的時候git上star貌似還不多,目前已經13K了,准確的說nightmare一款基於electron(曾經使用PhantomJS,后面改用Electron)的高度封裝web自動化測試工具,當然也可以用來做一些簡易的爬蟲,api及其簡單,缺點就是模擬真人操作,這樣的爬蟲效率非常低,但是對於高安全性的網站來說,這樣的操作也最為安全,防止被封。
經過試用之后,最后決定采用nightmare進行爬蟲。
同步任務
需要注意的是evaluate函數返回的是promise,即為異步回調函數,可以配合co庫,進行yield操作,即可用同步模式進行異步操作。
var Nightmare = require('nightmare'),
nightmare = Nightmare(),
co = require('co');
var run = function*(){
var results = [];
for(var i=0; i<5; i++){
var result = yield nightmare.goto('http://example.org').evaluate(function(){ return 123;});
results.push(result);
}
return results;
}
co(run).then(function(results){
console.dir(results);
console.log('done');
});
js動態加載
nightmare是模擬操作,相當於開了個瀏覽器,所以這都不是什么問題,但需要注意的是,列表如果數量太多,其實是分頁加載的,第一次只加載十個,下滑再加載10個,加載完整頁之后,還有下一頁按鈕,至於當前頁的分頁,需要滑動到低,才會加載完,所以還需要模擬滑動操作scrollTo,滑動過程中動態加載數據,有時候會有不成功的情況(可能是由於官方限制?),所以偶爾會漏過一些商家。
中斷繼續
為了避免異常導致從頭開始爬,可以每次在catch的時候保存當前的狀態值,下次啟動讀取,然后接着爬即可。
//記錄執行步驟,中斷后下次繼續
//分類
let stepLog = {
cate_index: 0,
//區域
location_index: 8,
//商圈
area_index: 0,
}
function writeLog (log) {
fs.writeFileSync('./steplog.log', JSON.stringify(log));
}
function readLog () {
let json = fs.readFileSync('./steplog.log');
return JSON.parse(json);
}
爬坑總結
1、爬蟲檢測限制非常嚴格,搞不好就403,隔天才恢復,間隔時間很重要,示例代碼的參數已經比較穩定。
2、數據基本上都是動態加載,加載接口又要cookie,又要post首次加載的各種參數,這也是為什么難爬,之前考慮過用PhantomJS,但是API過於復雜,無界面,不方便調試,nightmare基於electron這方面簡直是神器,模擬人操作,又防封,又便捷。
3、nightmare不會記錄cookie,所以如果有時候爬久了,關閉再開會403,但是瀏覽器正常,是cookie導致的,可以訪問一些美團其他頁面,先加載cookie再跳轉到需要爬的頁面即可。
4、由於默認情況不會記錄cookie,所以需要的話可以再結束的時候getcookie序列化成json保存成文件,下次開啟的時候再進行初始化。
5、中斷繼續,也可以把各種狀態參數序列化成json保存,下次啟動初始化,即可從中斷的地方繼續開始。
6、可以不需要正則,直接用dom選擇器進行html元素查詢。
7、效率確實不高,但也沒啥好辦法,爬一個城市大概花4-5天。
示例代碼
注意post提交服務器地址改為自己的接口,如果需要保存本地,需自行處理。
代碼保存直接運行即可 node **.js
。
PS:中間變量命名有些隨意,請見諒。
var Nightmare = require('nightmare');
var fs = require('fs');
//nightmare = Nightmare({ show: true }),
var co = require('co');
var http = require('http');
var city = '南昌';
var run = function* () {
let nightmare = Nightmare({ show: true, waitTimeout: 60000 });
//記錄執行步驟,中斷后下次繼續
//分類
let stepLog = {
cate_index: 0,
//區域
location_index: 8,
//商圈
area_index: 0,
}
if (fs.existsSync('./steplog.log')) {
stepLog = readLog();
}
//writeLog(stepLog);
//獲取地區美食分類,先進主頁是為了獲取cookie防止被封
let result1 = yield nightmare.goto('http://nc.meituan.com/').wait(5000)
.goto('http://nc.meituan.com/category/meishi')
.wait('div.filter-label-list.filter-section.category-filter-wrapper.first-filter ul.inline-block-list')
.evaluate(function () {
let arr_a = document.querySelectorAll('div.filter-label-list.filter-section.category-filter-wrapper.first-filter ul.inline-block-list li a');
let str = '';
//過濾全部,代金券
for (var index = 2; index < arr_a.length; index++) {
var element = arr_a[index];
str += element.href + ',';
}
return str;
})
let arr_a1 = result1.split(',');
console.log(arr_a1);
var temp_index1 = stepLog.cate_index;
for (var index1 = temp_index1; index1 < arr_a1.length; index1++) {
stepLog.cate_index = index1;
//獲取美食分類之后,獲取地區
var element1 = arr_a1[index1];
if (element1 != '') {
try {
let result2 = yield nightmare
.wait(10000)
.goto(element1)
.wait('ul.inline-block-list.J-filter-list.filter-list--fold')
.evaluate(function () {
let arr_a = document.querySelectorAll('ul.inline-block-list.J-filter-list.filter-list--fold li a');
let str = '';
//過濾全部,地鐵2
for (var index = 2; index < arr_a.length; index++) {
var element = arr_a[index];
str += element.href + ',';
}
return str
});
let arr_a2 = result2.split(',');
console.log(arr_a2);
var temp_index2 = stepLog.location_index;
for (var index2 = temp_index2; index2 < arr_a2.length; index2++) {
stepLog.location_index = index2;
//獲取地區之后,獲取商圈
var element2 = arr_a2[index2];
if (element2 != '') {
try {
let result3 = yield nightmare
.wait(10000)
.goto(element2)
.wait('ul.inline-block-list.J-area-block')
.evaluate(function () {
let arr_a = document.querySelectorAll('ul.inline-block-list.J-area-block li a');
let str = '';
//商圈下標,過濾全部
for (var index = 1; index < arr_a.length; index++) {
var element = arr_a[index];
str += element.href + ',';
}
return str
});
arr_a3 = result3.split(',');
console.log(arr_a3);
var temp_index3 = stepLog.area_index;
for (var index3 = temp_index3; index3 < arr_a3.length; index3++) {
stepLog.area_index = index3;
//獲取商圈店鋪信息
var element3 = arr_a3[index3];
if (element3 != '') {
let nextPage = 'undefined';
do {
let url = '';
if (nextPage == 'undefined')
url = element3;
else
url = nextPage;
try {
let result4 = yield nightmare
.wait(10000)
.goto(url)
.wait('#content').wait(2000)
.scrollTo(716, 0).wait(5000).scrollTo(716 * 2, 0).wait(5000).scrollTo(716 * 3, 0).wait(5000).scrollTo(716 * 4, 0).wait(5000).scrollTo(716 * 5, 0).wait(5000).scrollTo(716 * 6, 0).wait(5000).scrollTo(716 * 7, 0).wait(5000).scrollTo(716 * 8, 0).wait(5000).scrollTo(716 * 9, 0).wait(5000).scrollTo(716 * 10, 0).wait(5000).scrollTo(716 * 11, 0).wait(5000).scrollTo(716 * 12, 0).wait(5000).scrollTo(716 * 13, 0).wait(5000).scrollTo(716 * 14, 0).wait(5000).scrollTo(716 * 15, 0).wait(5000).scrollTo(716 * 16, 0).wait(5000).scrollTo(716 * 17, 0).wait(5000).scrollTo(716 * 18, 0).wait(5000).scrollTo(716 * 19, 0).wait(5000).scrollTo(716 * 20, 0).wait(5000)
.evaluate(function () {
let arr_a = document.querySelectorAll('div.poi-tile-nodeal');
let str = '';
for (var index = 0; index < arr_a.length; index++) {
var element = arr_a[index];
let sp_rj = element.querySelector('div.poi-tile__money span.avg span');
//人均
let rj = 0;
if (sp_rj != null) {
let str_rj = sp_rj.innerText;
rj = parseInt(str_rj.substr(1, rj.length));
}
console.log(index);
let url = '';
let elelink = element.querySelector('a.poi-tile__head.J-mtad-link');
if (elelink != null) {
//鏈接地址
url = elelink.href;
console.log(url);
}
str += url + '|' + rj + ',';
}
let href = document.querySelector('li.next a') ? document.querySelector('li.next a').href : 'undefined'
return str + '^' + href;
});
console.log(result4);
temp4 = result4.split('^');
nextPage = temp4[1];
arr_a4 = temp4[0].split(',');
for (var index4 = 0; index4 < arr_a4.length; index4++) {
var element4 = arr_a4[index4];
if (element4 != '') {
try {
let temp = element4.split('|');
let url5 = '';
if (temp[0] != '') {
url5 = temp[0];
} else {
continue;
}
//獲取店鋪詳細信息
let result5 = yield nightmare
.wait(5000)
.goto(url5)
.wait('div.poi-section.poi-section--shop')
.evaluate(function () {
let query = document.querySelectorAll('div.component-bread-nav a');
let cate = query[2].innerText; console.log(cate);
let area = query[3].innerText; console.log(area);
let poi = '';
if (query[4] != undefined) {
poi = query[4].innerText; console.log(poi);
}
query = document.querySelector('div.summary');
let name = query.querySelector('h2 span.title').innerText; console.log(name);
let addr = query.querySelector('span.geo').innerText; console.log(addr);
let tel = query.querySelector('div.fs-section__left p:nth-child(3)').innerText; console.log(tel);
let rate = '';
if (query.querySelector('span.biz-level strong') != undefined) {
rate = query.querySelector('span.biz-level strong').innerText; console.log(rate);
}
let rate_count = query.querySelector('a.num.rate-count').innerText; console.log(rate_count);
let recom_food = '';
query = document.querySelectorAll('div.menu__items table tbody td');
for (var index = 0; index < query.length; index++) {
var element = query[index];
recom_food += element.innerText + ',';
}
desc = document.querySelector('div.poi-section.poi-section--shop div div').innerText;
return cate + '|' + area + '|' + poi + '|' + name + '|' + addr + '|' + tel + '|' + rate + '|' + rate_count + '|' + recom_food + '|' + desc
});
console.log(result5);
postResult(result5 + '|' + city + '|' + temp[1]);
} catch (e) {
console.log(e);
writeLog(stepLog);
continue;
}
}
}
} catch (e) {
console.log(e);
writeLog(stepLog);
continue;
}
} while (nextPage != 'undefined')
}
}
stepLog.area_index = 1;
} catch (e) {
console.log(e);
writeLog(stepLog);
continue;
}
}
}
stepLog.location_index = 2;
} catch (e) {
console.log(e);
writeLog(stepLog);
continue;
}
}
}
stepLog.cate_index = 2;
}
function postResult (postData) {
options = {
hostname: '你的提交域名',
port: 80,
path: '/admin/test/upload',
method: 'POST',
headers: {
'Content-Type': 'raw',
'Content-Length': Buffer.byteLength(postData)
}
};
req = http.request(options, (res) => {
//console.log(`STATUS: ${res.statusCode}`);
//console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
res.setEncoding('utf8');
res.on('data', (chunk) => {
console.log(`BODY: ${chunk}`);
});
res.on('end', () => {
console.log('No more data in response.');
});
});
req.on('error', (e) => {
console.error(`problem with request: ${e.message}`);
});
// write data to request body
req.write(postData);
req.end();
}
function writeLog (log) {
fs.writeFileSync('./steplog.log', JSON.stringify(log));
}
function readLog () {
let json = fs.readFileSync('./steplog.log');
return JSON.parse(json);
}
function start () {
co(run).then(function () {
console.log('done');
}).catch(function (err) {
console.log(new Date().toUTCString());
console.error(err);
start();
});
}
start();
爬取結果如下:
如有更好的方案,歡迎交流。
附件列表