Node系列-爬虫踩坑笔记
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 <allChildren.length; 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<any>}*/
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 <allChildren.length; 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;i<priceContain.length;i++){if(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(文末会贴这个函数的代码):自如网站对价格这个元素增加了反爬策略,所有与价格有关的数字都是通过截取网页中暗藏着的一张随机数字图片中的某一部分来展示的。这么说可能比较难以理解,直接上图。
上图箭头标注出来的是一串随机数组成的图片,左侧的价格信息(比如 ¥7290 )都是通过计算相对位移截取的这串数字中的某一个数字来显示。我的思路是通过百度AI开放平台把图片中的10位文字识别出来,然后按照规律( 偏移/30 + 1 )将“价格”标签中的相对位移转化成真实的数字(不过经过实际检测,百度的sdk能够正确识别出10位数字的时候不多。。。正在考虑优化策略。比如把这个图片文字变成黑色,底部变成白色)。
- 细心的同学可能会观察到这个函数的返回值是 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<any>}*/
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 <allChildren.length; 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;i<priceContain.length;i++){if(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 |
---|---|
weixinjie1993@gmail.com | |
W2006292 | |
github | github.com/weixinjie |
blog | juejin.im/user/57673c… |
Node系列-爬虫踩坑笔记相关推荐
- uniapp引入vantweapp踩坑笔记
vue-cli创建uniapp项目引入vantweapp踩坑笔记 uni-app中引入vantweapp vue-cli创建uniapp项目引入vantweapp踩坑笔记 一.环境准备 二.项目搭建 ...
- iphone se 一代 不完美越狱 14.6 视频壁纸教程(踩坑笔记)
iphone se 一代 不完美越狱 14.6 加 视频壁纸教程-踩坑笔记 越狱流程 1.爱思助手制作启动u盘 坑点: 2.越狱好后 视频壁纸软件 1.源 2.软件安装 越狱流程 1.爱思助手制作启动 ...
- Linux内核踩坑笔记
systemtap embedded C踩坑笔记戳这: https://blog.csdn.net/qq_41961459/article/details/103093912 task_struct的 ...
- 阿里云部署Tiny Tiny RSS踩坑笔记
阿里云部署Tiny Tiny RSS踩坑笔记 前言 入坑了RSS,之前的配置是阿里云部署RSSHub,配合Inoreader进行文章阅读,详情见RSS入坑指南.阿里云部署RSSHub踩坑笔记.在202 ...
- 「Java」基于Mirai的qq机器人开发踩坑笔记(其一)
目录 0. 前置操作 I. 安装MCL II. MCL自动登录配置 III. 安装IDEA插件 1. 新建Mirai项目 2. 编写主类 3. 添加外部依赖 4. IDEA运行 5. 插件打包 6. ...
- 「Java」基于Mirai的qq机器人开发踩坑笔记(其二)
目录 0. 配置机器人 1. onLoad方法 2. onEnable方法 3. 消息属性 4. 消息监听 I. 好友消息 II. 群聊消息 III. 无差别消息 5. 发送消息 I. 文本消息 II ...
- 昆仑通态触摸屏1003故障码,踩坑笔记
昆仑通态触摸屏1003故障码,踩坑笔记 第一次使用这个昆仑通态触摸屏,使用modbusRTU与金田变频器做通讯. 触摸屏在线后报1003通讯错误代码,现象是控制指令正常,但是读取不正常.读取变频器状态 ...
- EDUSOHO踩坑笔记之四十二:资讯
EDUSOHO踩坑笔记之四十二:资讯 获取资讯列表信息 GET /articles/{id} 权限 老API,需要认证 参数 字段 是否必填 描述 sort string 否 排序,'created' ...
- EDUSOHO踩坑笔记之三十三:班级
EDUSOHO踩坑笔记之三十三:班级 班级 班级 获取班级信息 获取班级列表 班级成员 获取班级计划 加入班级 营销平台加入班级 班级 班级 获取班级信息 GET /classrooms/{class ...
最新文章
- python装饰器+迭代器+生成器
- TensorFlow中Session.run和Tensor.eval的区别
- 多标签图像分类--HCP: A Flexible CNN Framework for Multi-Label Image Classification
- 互联网协议 — TCP — 拥塞控制(网络质量保障)
- python 办公自动化-python办公自动化:Excel操作入门
- 【约束布局】ConstraintLayout 偏移 ( Bias ) 计算方式详解 ( 缝隙比例 | 计算公式 | 图解 | 测量图 + 公式 )
- 推荐 8 个常用 Spring Boot 项目
- DataTables中设置某些列不进行排序
- 2021-11-16数据结构
- php面试专题---2、常量及数据类型考点
- 排球计分程序功能说明书
- 警告:Establishing SSL connection without server’s identity verification is not recommended
- Web实训项目--网页设计(附源码)
- 项目管理中的配置管理
- 出现问题请与你的系统管理员联系		照片出现问题请与你的系统管理员		照片出现问题请与系统管理员联系 无法打开应用请与管理员联系
- 每天定时检测404链接
- 关于机器学习,我总结了可能是目前最全面最无痛的入门路径和资源!
- 华为P20 Pro对比iPhone X:谁更能拍出人像高级美?
- 【行业案例】域乎科技:“数”写长三角一体化的加速度——域乎科技链接未来,做数字时代的奠基人(智慧上海2020总第24篇)
- JD2016版首页改版前端总结(转载整理)
热门文章
- 计算机房 危险源辨识,消防安全重点部位不仅要根据火灾危险源的辨识来确..._消防考试_帮考网...
- 大数据分析的四个关键环节
- 微信小程序软键盘回车事件
- EF数据迁移命令总结
- 用css给video视频标签上添加渐变效果
- 如何快速高效的清理虚拟机硬盘空间?
- 关于MySQL的二次安装问题
- 听Polychain Capital创始人Olaf Carlson- Wee讲述他为何愿意为Celo背书
- Cesium中获取地形三角网并进行土方计算
- 鸿蒙系统需要备份,华为鸿蒙系统正式发布之后,还需要面临三个问题