1. 写在前面
上个月写了一篇《我的大前端之旅》,里面介绍了一下我对大前端时代到来的一点个人观点。简单来说,我更喜欢把自己的未来规划成一专多能的工程师,毕竟技多不压身,在深入研究本职领域的前提下多涉猎一下其他的领域对自己的成长总是有益处的。
先概括一下本文的主要内容:
- 目标: 通过做一个更加复杂的爬虫模块加深对 JavaScript 这门语言的理解,也加深对 Node 这门技术的理解。
- 方法论: 《我的大前端之旅》里面介绍到的知识点(JS基本语法、Node、Cherrio等)。
- 结果:把 自如 的北京地区房产信息爬取下来。
2. 分析目标网站,制定爬取策略
先说结论(房产类网站可通用):
- 打开目标平台的首页,把对应的地标(比如:东城-崇文门)信息抓取下来
- 分析目标平台二级页的URL地址拼接规则,用第一条抓取下来的地标信息进行二级页URL地址拼接
- 写抓取二级页的爬虫代码,对爬取结果进行存储。
2.1.1 抓取地标信息
简单抽取一下具体的爬取步骤,以自如(北京地区)为例:
通过主页的布局,可以看到房产类的网站基本上都是上方是地标(比如:东城-崇文门),下面是该地标附近的房产信息。所以通过分析这块的网页结构就可以抓到所有的地标信息。
2.1.2 拼接二级页面的URL
以自如网站为例,比如我们想看安定门的租房信息,直接在首页的搜索框中输入“安定门”然后点击搜索按钮。
通过上图我标红的两个地方可以看到,二级页的地址就是 地标+page(当前是第几页)。链家的二级页也是一样的,这里就不贴图了。3.开始写代码
根据上一小节的方法论,开始动手写代码。这里以自如为例(自如的信息比链家难爬,但是原理都是通用的)。
3.1 爬取首页地标信息
打开自如首页,打开 Chrome 的开发者工具,开始分析网页元素。
通过Chrome的 element选择器 我们很快可以定位到 “东城” 这个元素的位置。此元素的 class 为 tag ,打开此元素下面的 class 为 con 的 div ,我们发现,“东城”包含的所有地标信息都被包裹在此 div 中。由于所有的地标信息都是 a 标签包裹,所以我们可以写出抓取地标信息的核心代码。 let allParentLocation = $('ul.clearfix.filterList', 'dl.clearfix.zIndex6');
for (let i = 1; i < allParentLocation.children().length; i++) {
let parentLocation = allParentLocation.children().eq(i);
let parentLocationText = parentLocation.children().eq(0).text(); // 东城 西城...
let allChildren = $(parentLocation.children().eq(1)).find('a');
for (let j = 1; j let childrenLocationText = allChildren.eq(j).text(); //子行政区
//TODO 上面的childrenLocationText变量就是地标信息
}
}
复制代码
3.2 拼接二级页的地址
如2.1.2所述,自如二级页面基本上是 baseUrl+地标+page 组成。所以咱们可以完善一下3.1中的代码。下面我们封装一个函数用来解析地标并且生成所有二级页地址的数组。注:这个函数返回的是一个 Promise ,后面会用 async 函数来组织所有 Promise 。
/**
* 获取行政区
* @param data
* @returns {Promise}
*/
function parseLocationAndInitTargetPath(data) {
let targetPaths = [];
let promise = new Promise(function (resolve, reject) {
let $ = cheerio.load(data);
let allParentLocation = $('ul.clearfix.filterList', 'dl.clearfix.zIndex6');
for (let i = 1; i < allParentLocation.children().length; i++) {
let parentLocation = allParentLocation.children().eq(i);
let parentLocationText = parentLocation.children().eq(0).text(); // 东城 西城...
let allChildren = $(parentLocation.children().eq(1)).find('a');
for (let j = 1; j let childrenLocationText = allChildren.eq(j).text(); //子行政区
let encodeChildrenLocationText = encodeURI(childrenLocationText);
for (let page = 1; page < 50; page++) { //只获取前50页的数据
targetPaths.push(`${basePath}qwd=${encodeChildrenLocationText}&p=${page}`);
}
}
}
resolve(targetPaths);
});
return promise;
}
复制代码
3.3 解析二级页
先观察一下二级页的布局,例如我们想把图片、标题、tags、价格这几个信息抓取下来。
同样的,我们可以写出如下核心代码。/**
* 解析每一条的数据
*/
async function parseItemData(targetPaths) {
let promises = [];
for (let path of targetPaths) {
let data = await getHtmlSource(path);
let allText = '';
try{
allText = await ziRoomPriceUtil.getTextFromImage(data);
}catch(err){
console.log('抓取失败--->>> '+path);
continue;
}
let promise = new Promise((resolve, reject) => {
let $ = cheerio.load(data);
let result = $('#houseList');
let allResults = [];
for (let i = 0; i < result.children().length; i++) {
let item = result.children().eq(i);
let imgSrc = $('img', item).attr('src');
let title = $('a', $('.txt', item)).eq(0).text();
let detail = $('a', $('.txt', item)).eq(1).text();
let label = '';
$('span', $('.txt', item)).each(function (i, elem) {
label = label + ' ' + $(this).text();
});
let price = '';
if (allText.length !== 10) {
price = '未抓取到价格信息'+allText;
}else{
let priceContain = $('span', $('.priceDetail', item));
for(let i = 0;iif(i === 0 || i === priceContain.length-1){
price = price +' '+ priceContain.eq(i).text(); //首位: ¥ 末尾: 每月/每季度
}else {
price = price + ziRoomPriceUtil.style2Price(priceContain.eq(i).attr('style'),allText);
}
}
}
allResults.push({'imgSrc':imgSrc,'title':title,'detail':detail,'label':label,'price':price});
}
resolve(allResults);
});
promises.push(promise);
}
return Promise.all(promises);
}
复制代码
注意 上面有几个点需要解释一下
- getHtmlSource 函数(文末会贴这个函数的代码):这个函数是用 PhantomJS 来模拟浏览器做渲染。这里解释一下 PhantomJS 简单来说 PhantomJS 就是一个没有界面的Web浏览器,用它可以更好的模拟用户操作(比如可以抓取需要ajax异步渲染的dom节点)。但是 PhantomJS 是一个单独的进程,跟Node不是一个进程,所以在 Node 中使用 PhantomJS 的话就得单独跑一个子进程,然后 Node 跟这个子进程通信把 PhantomJS 抓取到的网页 Source 拿到再做解析。不过与子进程做通信这件事比较复杂,暂时还不想深入研究,所以我就用了 amir20 开发的 phantomjs-node 。 phantomjs-node 是可以作为node的一个子模块安装的,虽然用法跟 PhantomJS 还是有点区别,但是应付我们的需求足够了。
- ziRoomPriceUtil.getTextFromImage(文末会贴这个函数的代码):自如网站对价格这个元素增加了反爬策略,所有与价格有关的数字都是通过截取网页中暗藏着的一张随机数字图片中的某一部分来展示的。这么说可能比较难以理解,直接上图。
- 细心的同学可能会观察到这个函数的返回值是 Promise.all(promises) 。这其实是ES6中把一个 Promise 数组合并成一个 Promsie 的方式,合并后的 Promise 调用 then 方法后返回的是一个数组,此数组的顺序跟合并之前 Promise 数组的顺序是一致。这里有两点需要注意: 1. Promise.all 接受的Promise数组如果其中有一个 Promise 执行失败,则 Promise.all 返回 reject ,我的解决方案是传入到 Promise.all 中的所有 Promise 都使用 resolve 来返回信息,比如失败的时候可以使用 resolve('error') 这样保证 Promise.all 可以正常执行,执行完毕后通过检查各个 Promsie 的返回结果来判断该 Promise 是否是成功的状态。2. Promise.all 是支持并发的,如果你想限制他的并发数量,可以使用第三方库 tiny-async-pool,这个库的原理是通过 Promise.race 来控制 Promise 数组的实例化。
3.4 整理一下所有代码
3.4.1 爬虫主体类 SpliderZiroom.js
//自如爬虫脚本 http://www.ziroom.com/
let schedule = require('node-schedule');
let superagent = require('superagent');
let cheerio = require('cheerio');
let charset = require('superagent-charset'); //解决乱码问题:
charset(superagent);
let ziRoomPriceUtil = require('../utils/ZiRoomPriceUtil');
var phantom = require("phantom");
var _ph, _page, _outObj;
let basePath = 'http://www.ziroom.com/z/nl/z3.html?';
/**
* 使用phantom获取网页源码
* @param path
* @param callback
*/
function getHtmlSource(path) {
let promise = new Promise(function (resolve, reject) {
phantom.create().then(function (ph) {
_ph = ph;
return _ph.createPage();
}).then(function (page) {
_page = page;
return _page.open(path);
}).then(function (status) {
return _page.property('content')
}).then(function (content) {
resolve(content);
_page.close();
_ph.exit();
}).catch(function (e) {
console.log(e);
});
});
return promise;
}
/**
* 获取行政区
* @param data
* @returns {Promise}
*/
function parseLocationAndInitTargetPath(data) {
let targetPaths = [];
let promise = new Promise(function (resolve, reject) {
let $ = cheerio.load(data);
let allParentLocation = $('ul.clearfix.filterList', 'dl.clearfix.zIndex6');
for (let i = 1; i < allParentLocation.children().length; i++) {
let parentLocation = allParentLocation.children().eq(i);
let parentLocationText = parentLocation.children().eq(0).text(); // 东城 西城...
let allChildren = $(parentLocation.children().eq(1)).find('a');
for (let j = 1; j let childrenLocationText = allChildren.eq(j).text(); //子行政区
let encodeChildrenLocationText = encodeURI(childrenLocationText);
for (let page = 1; page < 50; page++) { //只获取前三页的数据
targetPaths.push(`${basePath}qwd=${encodeChildrenLocationText}&p=${page}`);
}
}
}
resolve(targetPaths);
});
return promise;
}
/**
* 解析每一条的数据
*/
async function parseItemData(targetPaths) {
let promises = [];
for (let path of targetPaths) {
let data = await getHtmlSource(path);
let allText = '';
try{
allText = await ziRoomPriceUtil.getTextFromImage(data);
}catch(err){
console.log('抓取失败--->>> '+path);
continue;
}
let promise = new Promise((resolve, reject) => {
let $ = cheerio.load(data);
let result = $('#houseList');
let allResults = [];
for (let i = 0; i < result.children().length; i++) {
let item = result.children().eq(i);
let imgSrc = $('img', item).attr('src');
let title = $('a', $('.txt', item)).eq(0).text();
let detail = $('a', $('.txt', item)).eq(1).text();
let label = '';
$('span', $('.txt', item)).each(function (i, elem) {
label = label + ' ' + $(this).text();
});
let price = '';
if (allText.length !== 10) {
price = '未抓取到价格信息'+allText;
}else{
let priceContain = $('span', $('.priceDetail', item));
for(let i = 0;iif(i === 0 || i === priceContain.length-1){
price = price +' '+ priceContain.eq(i).text(); //首位: ¥ 末尾: 每月/每季度
}else {
price = price + ziRoomPriceUtil.style2Price(priceContain.eq(i).attr('style'),allText);
}
}
}
allResults.push({'imgSrc':imgSrc,'title':title,'detail':detail,'label':label,'price':price});
}
resolve(allResults);
});
promises.push(promise);
}
return Promise.all(promises);
}
/**
* 初始化目标网页
*/
async function init() {
let basePathSource = await getHtmlSource(basePath);
let targetPaths = await parseLocationAndInitTargetPath(basePathSource);
let result = await parseItemData(targetPaths);
return result ;
}
/**
* 开始爬取
*/
function startSplider() {
console.log('自如爬虫已启动...');
let startTime = new Date();
init().then(function (data) {
let endTime = new Date();
console.log('自如爬虫执行完毕 共消耗时间'+(endTime - startTime)/1000+'秒');
}, function (error) {
console.log(error);
});
}
startSplider();
// module.exports = {
// startSplider,
// };
复制代码
3.4.2 自如价格转化工具类 ZiRoomPriceUtil.js
let md5=require("md5")
let baiduAiUtil = require('./BaiduAiUtil');
function style2Price(style,allText) {
let position = style.match('[1-9]\\d*')/30;
return allText.substr(position,1);
}
function getTextFromImage(pageSrouce) {
let promise = new Promise(function (resolve, reject) {
try {
let matchStr = pageSrouce.match('static8.ziroom.com/phoenix/pc/images/price/[^\\s]+.png')[0];
let path = `http://${matchStr}`;
baiduAiUtil.identifyImageByUrl(path).then(function(result) {
resolve(result.words_result[0].words);
}).catch(function(err) {
// 如果发生网络错误
reject(err)
});
} catch (err) {
reject(err);
}
});
return promise;
}
module.exports = {
style2Price,
getTextFromImage
}
复制代码
3.4.3 百度AI开放平台识别工具类 BaiduAiUtil.js
let fs = require('fs');
let AipOcrClient = require("baidu-aip-sdk").ocr;
// 设置APPID/AK/SK
let APP_ID = "需替换你的 APPID";
let API_KEY = "需替换你的 AK";
let SECRET_KEY = "需替换你的 SK";
// 新建一个对象,建议只保存一个对象调用服务接口
let client = new AipOcrClient(APP_ID, API_KEY, SECRET_KEY);
/**
* 通过本地文件识别数据
* @param imagePath 本地file path
* @returns {Promise}
*/
function identifyImageByFile(imagePath){
let image = fs.readFileSync(imagePath).toString("base64");
return client.generalBasic(image);
}
/**
* 通过远程url识别数据
* @param url 远程url地址
* @returns {Promise}
*/
function identifyImageByUrl(url){
return client.generalBasicUrl(url);
}
module.exports = {
identifyImageByUrl,
identifyImageByFile
}
复制代码
运行代码查看结果
注:这是我存到mysql中的爬取结果,由于 Node 链接 Mysql 不是本文重点,所以没贴代码。你可以选择把 startSplider 函数获取到的结果放到文件里、MongooDB 或者其他地方。
4. 写在最后
这段时间写了很多各大网站的爬虫代码,发现很多工作量是重复的。比如:租房类的网站大部分都是 先爬地标再爬二级页 这种套路。本着 “以可配置为荣 以硬编码为耻” 的程序员价值观,后期会考虑把爬虫模块做成可配置的。这里跟大家分享一个开源库: 牛咖 。
About Me
contact way | value |
---|---|
[email protected] | |
W2006292 | |
github | github.com/weixinjie |
blog | juejin.im/user/57673c… |