Nodejs 异步流程控制及若干细节

我有酒,你有故事吗?

今天接了个爬虫任务,主要是从网页上将数据爬下来,规整后导出到Excel。以前工作中的爬虫都是基于HttpClient+jsoup,很早就知道Nodejs有cheerio,HTML和JavaScript天生的一对,拿Nodejs去做网页爬虫很简单,有多简单呢?就这么说吧,和你用jQuery没什么两样。所以选择了Nodejs。

不涉及保密事件,故源码已托管GitHub

开工

mkdir crawler && cd crawler
npm init
MacBook-Pro:crawler$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg> --save` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
name: (crawler) crawler
version: (1.0.0) 
description: Nodejs crawler
entry point: (index.js) 
test command: 
git repository: 
keywords: 
author: jarvan4dev@163.com
license: (ISC) 
About to write to /Users/jarvan4dev/Documents/test/crawler/package.json:

{
  "name": "crawler",
  "version": "1.0.0",
  "description": "Nodejs crawler",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "jarvan4dev@163.com",
  "license": "ISC"
}


Is this ok? (yes) yes

npm install - 安装依赖包

npm i cheerio --save
npm i excel-export --save
npm i request --save 

简要介绍下这几个依赖包:

  1. cheerio,类似于Java中的jsoup,cheerio的部分就自己看吧。
  2. excel-export,Excel操作工具
  3. request,网络请求工具

小试牛刀

富德生命开刀

/**
 * Created by jarvan4dev on 2017/2/28.
 * 富德爬虫
 */
const http = require('http');
const cheerio = require('cheerio');
const exportUtils = require('../utils/exportUtils'); // 数据导出工具类

const fileName = 'fude.xlsx';
const companyName = '富德生命人寿保险股份有限公司';
const subName = '分公司';
const headers = [{
    caption: '省',
    type: 'string'
}, {
    caption: '分公司名称',
    type: 'string'
}, {
    caption: '营业场所',
    type: 'string'
}, {
    caption: '电话',
    type: 'string'
}];

let rows = [];

http.get('http://www.sino-life.com/publicinfo/fzjgyycsjlxdh/', res => {
    let html = '';
    res.setEncoding('utf-8'); //防止中文乱码
    res.on('data', data => {
        html += data;
    });
    res.on('end', () => {
        let $ = cheerio.load(html); //采用cheerio模块解析html
        $('.cantactTel').each((index, element) => {
            let province = $(element).find('h3').eq(0).text().replace(companyName, '').replace(subName, '');
            let subComName, location, tel;
            $(element).find('td').each((i, tdEle) => {
                switch (i % 3) {
                    case 0:
                        subComName = $(tdEle).text();
                        break;
                    case 1:
                        location = $(tdEle).text();
                        break;
                    case 2:
                        tel = $(tdEle).text();
                        break;
                }
                if (subComName && location && tel)
                    rows.push([province, subComName, location, tel]);
            });
        });
        exportUtils.excelWrite(headers, rows, fileName);
    })
});

可以看出Node原生的网络请求是异步的,这为后面的任务挖了个坑。

人保寿险深入

直接上代码

/**
 * Created by jarvan4dev on 2017/2/28.
 * 人保爬虫
 */
const http = require('http');
const cheerio = require('cheerio');
const request = require('request');

const companyName = '人保寿险';
const subName = '分公司';

const headers = [{
    caption: '省',
    type: 'string'
}, {
    caption: '机构名称',
    type: 'string'
}, {
    caption: '营业场所',
    type: 'string'
},{
    caption: '邮编',
    type: 'string'
}, {
    caption: '电话',
    type: 'string'
}];

const fileName = 'renbao.xlsx';

let rows = [];

const exportUtils = require('../utils/exportUtils');

http.get('http://www.picclife.com/aboutUsBranch.jhtml', res => {
    let html = '';
    res.setEncoding('utf-8'); //防止中文乱码
    res.on('data', data => {
        html += data;
    });
    res.on('end', () => {
        let $ = cheerio.load(html); //采用cheerio模块解析html
        $('.fgs_nr').each((index, element) => {
            let nextLink = $(element).find('.fgs_mc_qg a').attr('href').trim();
            let subComName = $(element).find('.fgs_mc_qg').attr('title').trim(); // 公司名称
            let province = subComName.replace(companyName, '').replace(subName, '').trim();
            let location = $(element).find('.fgs_add_qg').attr('title').trim();
            let zipCode = $(element).find('.fgs_zc_qg').text().trim();
            let tel = $(element).find('.fgs_tel_qg').attr('title').trim();
            rows.push([province, subComName, location, zipCode, tel]);
            http.get(nextLink, res => {
                let nextHtml = '';
                res.setEncoding('utf-8'); //防止中文乱码
                res.on('data', data => {
                    nextHtml += data;
                });
                res.on('end', () => {
                    let $$ = cheerio.load(nextHtml);
                    $$('.fgs_nr').each((i, ele) => {
                        subComName = $$(ele).find('.fgs_mc_qg2').attr('title').trim(); // 公司名称
                        location = $$(ele).find('.fgs_add_qg2').attr('title').trim();
                        zipCode = $$(ele).find('.fgs_zc_qg').text().trim();
                        tel = $$(ele).find('.fgs_tel_qg').attr('title').trim();
                        rows.push([province, subComName, location, zipCode, tel]);
                    });
                });
            });
        });
        exportUtils.excelWrite(headers, rows, fileName);
    });
});

是不是觉得大功告成?Too young too simple!别忘了http请求是异步的(request也是),这就明显有问题,在外层for循环中第一次执行rows.push([province, subComName, location, zipCode, tel])时,由于http请求是异步的,所以完全可能第二次请求还没完成,就执行了exportUtils.excelWrite(headers, rows, fileName)(ps: 在这里吐槽下简书代码不带行号的问题)。

下面看下我的改造,我的想法很简(chun)单(ben),我这样想的,既然第一for循环内部可能存在异步的http请求,那就让它从这里面脱离,直接让第一个for完全执行结束,注意看links变量。

/**
 * Created by jarvan4dev on 2017/2/28.
 * 人保爬虫
 */
const http = require('http');
const cheerio = require('cheerio');
const request = require('request');

const companyName = '人保寿险';
const subName = '分公司';

const headers = [{
    caption: '省',
    type: 'string'
}, {
    caption: '机构名称',
    type: 'string'
}, {
    caption: '营业场所',
    type: 'string'
},{
    caption: '邮编',
    type: 'string'
}, {
    caption: '电话',
    type: 'string'
}];

const fileName = 'renbao.xlsx';

let rows = [];
let links = [];

const exportUtils = require('../utils/exportUtils');

http.get('http://www.picclife.com/aboutUsBranch.jhtml', res => {
    let html = '';
    res.setEncoding('utf-8'); //防止中文乱码
    res.on('data', data => {
        html += data;
    });
    res.on('end', () => {
        let $ = cheerio.load(html); //采用cheerio模块解析html
        $('.fgs_nr').each((index, element) => {
            let nextLink = $(element).find('.fgs_mc_qg a').attr('href').trim();
            let subComName = $(element).find('.fgs_mc_qg').attr('title').trim(); // 公司名称
            let province = subComName.replace(companyName, '').replace(subName, '').trim();
            let location = $(element).find('.fgs_add_qg').attr('title').trim();
            let zipCode = $(element).find('.fgs_zc_qg').text().trim();
            let tel = $(element).find('.fgs_tel_qg').attr('title').trim();
            links.push({'province': province, 'nextLink': nextLink});
            rows.push([province, subComName, location, zipCode, tel]);
        });
        links.forEach((index, link) => {
            http.get(link, res => {
                let html = '';
                res.setEncoding('utf-8'); //防止中文乱码
                res.on('data', data => {
                    html += data;
                });
                res.on('end', () => {
                    let $ = cheerio.load(html);
                    $('.fgs_nr').each((i, element) => {
                        let subComName = $(element).find('.fgs_mc_qg2').attr('title').trim(); // 公司名称
                        let location = $(element).find('.fgs_add_qg2').attr('title').trim();
                        let zipCode = $(element).find('.fgs_zc_qg').text().trim();
                        let tel = $(element).find('.fgs_tel_qg').attr('title').trim();
                        rows.push([link.province, subComName, location, zipCode, tel]);
                    });
                });
            });
        });
        exportUtils.excelWrite(headers, rows, fileName);
    });
});

其实然并卵... 关于第二种写法,我想了一个解决办法:

  1. 借助node的EventEmitter,在第一个forEach结束的时候记录下第二个forEach应该执行的次数,即 变量links的长度,记做全局变量 count。在第二层for循环的res.on('end')中每完整执行一次网络请求并正确解析数据后,count--,当count减为0时触发一个事件,在外部监听这个事件,然后对数据进行处理即可。

  2. 参考网上解决方案:

function walk (path, handleFile, callback) {
  var len = 1,       // 文件|目录数,起始一个
      floor = 0;     // 第x个目录?

  function done () {
  // 完成任务, 运行回调函数
      if (--len === 0) {
          callback();
      }
  }

  function composeErr (err) {
  // 错误处理
      console.log('stat error');
      done();  // 以错误内容完成
  }

  function composeDir (path) {
  // 目录处理
      floor++;
      fs.readdir(path, function (err, files) {
          if (err) {
              console.log('read dir error');
              done();  // 目录完成 
              return;
          }
          len += files.length;  // 子文件|子目录计数
          files.forEach(function (filename) {
              compose(path + '/' + filename);  // 子内容新的操作
          });
          done();  // 目录完成
      });
  }

  function composeFile (path) {
  // 文件处理
      handleFile(path, floor);
      done();  // 文件完成
  }

  function compose (path) {
      fs.stat(path, function (err, stats) {
          if (err) {
              composeErr(err);
              return;
          }

          if (stats.isDirectory()) {
              composeDir(path);
              return;
          }

          composeFile(path);
      });
  }

  compose(path);
}

其实方式二就是自己实现异步流程控制,其实有更好的方法 --- async。

祭出杀器 --- async

使用async做异步流程控制,代码会优雅很多。

/**
 * Created by jarvan4dev on 2017/2/28.
 * 人保爬虫
 */
const http = require('http');
const cheerio = require('cheerio');
const async = require('async');

const companyName = '人保寿险';
const subName = '分公司';

const headers = [{
    caption: '省',
    type: 'string'
}, {
    caption: '机构名称',
    type: 'string'
}, {
    caption: '营业场所',
    type: 'string'
},{
    caption: '邮编',
    type: 'string'
}, {
    caption: '电话',
    type: 'string'
}];

const fileName = 'renbao.xlsx';

let rows = [];
let links = [];

const exportUtils = require('../utils/exportUtils');

http.get('http://www.picclife.com/aboutUsBranch.jhtml', res => {
    let html = '';
    res.setEncoding('utf-8'); //防止中文乱码
    res.on('data', data => {
        html += data;
    });
    res.on('end', () => {
        let $ = cheerio.load(html); //采用cheerio模块解析html
        $('.fgs_nr').each((index, element) => {
            let nextLink = $(element).find('.fgs_mc_qg a').attr('href').trim();
            let subComName = $(element).find('.fgs_mc_qg').attr('title').trim(); // 公司名称
            let province = subComName.replace(companyName, '').replace(subName, '').trim();
            let location = $(element).find('.fgs_add_qg').attr('title').trim();
            let zipCode = $(element).find('.fgs_zc_qg').text().trim();
            let tel = $(element).find('.fgs_tel_qg').attr('title').trim();
            links.push({'province': province, 'nextLink': nextLink});
            rows.push([province, subComName, location, zipCode, tel]);
        });
        async.each(links, (link, callback) => {
            http.get(link.nextLink, res => {
                let html = '';
                res.setEncoding('utf-8'); //防止中文乱码
                res.on('data', data => {
                    html += data;
                });
                res.on('end', () => {
                    let $ = cheerio.load(html);
                    $('.fgs_nr').each((i, element) => {
                        let subComName = $(element).find('.fgs_mc_qg2').attr('title').trim(); // 公司名称
                        let location = $(element).find('.fgs_add_qg2').attr('title').trim();
                        let zipCode = $(element).find('.fgs_zc_qg').text().trim();
                        let tel = $(element).find('.fgs_tel_qg').attr('title').trim();
                        rows.push([link.province, subComName, location, zipCode, tel]);
                    });
                    callback();
                });
            });
        }, err => {
            // 此时所有的循环结束
            exportUtils.excelWrite(headers, rows, fileName);
        });
    });
});

或者

/**
 * Created by jarvan4dev on 2017/2/28.
 * 人保爬虫
 */
const cheerio = require('cheerio');
const request = require('request');
const async = require('async');

const companyName = '人保寿险';
const subName = '分公司';

const headers = [{
    caption: '省',
    type: 'string'
}, {
    caption: '机构名称',
    type: 'string'
}, {
    caption: '营业场所',
    type: 'string'
},{
    caption: '邮编',
    type: 'string'
}, {
    caption: '电话',
    type: 'string'
}];

const fileName = 'renbao.xlsx';

let rows = [];

const exportUtils = require('../utils/exportUtils');

request('http://www.picclife.com/aboutUsBranch.jhtml', (error, response, body) => {
    let $ = cheerio.load(body); //采用cheerio模块解析html
    async.eachSeries($('.fgs_nr'), (element, callback) => {
        let nextLink = $(element).find('.fgs_mc_qg a').attr('href').trim();
        let subComName = $(element).find('.fgs_mc_qg').attr('title').trim(); // 公司名称
        let province = subComName.replace(companyName, '').replace(subName, '').trim();
        let location = $(element).find('.fgs_add_qg').attr('title').trim();
        let zipCode = $(element).find('.fgs_zc_qg').text().trim();
        let tel = $(element).find('.fgs_tel_qg').attr('title').trim();
        rows.push([province, subComName, location, zipCode, tel]);
        request(nextLink, (err, res, subBody) => {
            let $$ = cheerio.load(subBody);
            $$('.fgs_nr').each((i, ele) => {
                subComName = $$(ele).find('.fgs_mc_qg2').attr('title').trim(); // 公司名称
                location = $$(ele).find('.fgs_add_qg2').attr('title').trim();
                zipCode = $$(ele).find('.fgs_zc_qg').text().trim();
                tel = $$(ele).find('.fgs_tel_qg').attr('title').trim();
                rows.push([province, subComName, location, zipCode, tel]);
            });
            callback();
        });
    }, err => {
        console.log(rows.length);
        exportUtils.excelWrite(headers, rows, fileName);
    });
});

这两种写法只是方式一用的是原生http模块,方式二用的是request包,另外 请注意:each和eachSeries,后者是串行的,能够保证顺序。更多关于async请参看官方文档

参考文档:
Nodejs异步流程控制Async

关于excel-export

贴出我的导出文件的工具类吧!

/**
 * Created by jarvan4dev on 2017/2/28.
 */
const excelExport = require('excel-export');
const fs = require('fs');
const path = require('path');

// 导出Excel
exports.excelWrite = (headers, rows, fileName) => {
    let conf ={};
    conf.name = fileName;
    conf.cols = [];
    for(let i = 0; i < headers.length; i++){
        let col = {};
        col.caption = headers[i].caption;
        col.type = headers[i].type;
        conf.cols.push(col);
    }
    conf.rows = rows;
    let result = excelExport.execute(conf);
    let filePath = path.join('/Users/jarvan4dev/Documents', fileName);
 // appendFile 可以当文件不存在的时候自动创建
    fs.appendFile(filePath, result, 'binary',function(err){
        if(err){
            console.log(err);
        }
        console.log('saved')
    });
};

源码放在GitHub上,nodejs-crawler,动动手指,star一下!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容

  • Node基本 node的最大特性莫过于基于事件驱动的非阻塞I/O模型。 node通过事件驱动的方式处理请求,无须为...
    AkaTBS阅读 2,166评论 0 11
  • 从一个同步式编程风格迁移到Node平台(在Node架构中,持续传递风格和异步接口是常用方式),将是一件令人受挫...
    宫若石阅读 420评论 0 0
  • ##### URL模块 这个模块可以帮助我们解析url地址,从里面提取很多有用的内容供我们使用; 假设这是一个ur...
    浪流儿阅读 2,941评论 0 2
  • cmd命令: ./ 当前目录 ../ 上一级 dir 查看当前目录 ls 查看当前目录下文件 win...
    3hours阅读 545评论 0 1
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,633评论 18 139