Node + TypeScript 实战肺炎疫情实时动态数据爬虫

Node + TypeScript 实战肺炎疫情实时动态数据爬虫

爬虫

来自维基百科

爬虫,网络爬虫(英语:Web Crawler),也叫网络蜘蛛(Spider)。

通俗一点讲,就是一段自动化的代码,它会模拟人的行为,去浏览一些网站,然后把需要的、有价值的信息拿回来。

我们这篇文章的目标是把全国新冠肺炎疫情实时动态:https://ncov.dxy.cn/ncovh5/view/pneumonia 这个网页的信息爬取下来。

爬虫背后也有巨大的商业价值,比如:

  • 百度:利用爬虫,每天放出无数爬虫到各个网站,把他们的信息抓回来,然后等着你搜索需要的信息;
  • 抢票平台:我们授权自己的 12306 账号密码给抢票平台,它不断刷新 12306 网站的火车余票。一旦发现有票,就马上帮我们拍下来。

爬取的目标绝大多数情况下“要么是网页,要么是 App”,所以本文只介绍网页。

流行的网页架构

本文将其划分为了两种类别,即服务端渲染和客户端渲染,下面将介绍对于两种网页我们该怎么爬取。

服务端渲染

浏览的页面,是由服务器渲染后直接返回的,有效信息包含在请求的 HTML 页面里面,比如豆瓣电影 Top 250 这个站点。

我们打开网页,右键显示源代码,可以看到,里面有我们需要的电影名、评分、主演、导演等信息。

这就简单了,对于服务端渲染的页面,HTML 里面已经有我们需要的信息,我们只需要解析 HTML 即可。代码如下:

用 node 内置的 HTTPS 模块发出一个 GET 请求(因为我们在浏览器里面输入豆瓣的 URL 进行访问,也是 GET 请求),在数据响应结束事件,即 end 事件里,借助第三方的 cheerio 模块解析 HTML,之后我们就像使用 jQuery 一样,选中信息所在的 HTML 节点,组织数据即可。

function spiderMovie(index) {
  // 开始请求 豆瓣地址
  https.get('https://movie.douban.com/top250?start=' + index, function (res) {
    var pageSize = 25;
    var html = '';
    var movies = [];
    res.setEncoding('utf-8');
    res.on('data', function (chunk) {
      html += chunk;
    });
    res.on('end', function () {
      var $ = cheerio.load(html);
      $('.item').each(function () {
        // 限制 当前 item 下面的 .pic img
        var picUrl = $('.pic img', this).attr('src');
        var movie = {
          title: $('.title', this).text(),
          star: $('.info .star .rating\_num', this).text(),
          link: $('a', this).attr('href'),
          picUrl: picUrl
        };
        if (movie) {
          movies.push(movie);
        }
        downloadImg('./img/', movie.picUrl);
      });
      saveData('./data/movie-data' + (index / pageSize) + '.json', movies);
    });
  }).on('error', function (err) {
    console.log(err);
  });
}

在上面的代码中,我们已经获取数据,现在 我们爬取的电影数据保存在本地为 JSON 文件,电影封面将其下载到本地。

/\*\* \* 下载图片 @param { string } imgDir 存放图片的文件夹 @param { string } url 图片的URL地址 \*/
function downloadImg(imgDir, url) {
  // 先得到 流
  https.get(url, function (res) {
    res.pipe(fs.createWriteStream(imgDir + path.basename(url)))
  }).on('error', function (err) {
    console.log(err);
  });
}
/\*\* \* \* @param {\*} path path 保存数据的文件夹 \* @param {\*} movies 电影信息数组 \*/
function saveData(path, movies) {
  console.log(movies);
  fs.writeFile(path, JSON.stringify(movies, null, ' '), function (err) {
    if (err) {
      return console.log(err);
    }
    console.log('Data saved');
  });
}
spiderMovie(0);

大功告成。

客户端渲染

页面的主要内容由 JavaScript 渲染而成,和客户端渲染不同的就是,我们需要的信息不再是服务端返回在 HTML 里面,而是 JavaScript 生成。

比如现在我们有一个需求:

爬取本平台,即 gitbook-chat 所有 Chat 的前三页数据。

在使用中我们也有感受,打开 https://gitbook.cn/ 服务端返回的 Chat 数据,只有第一页的,后面两页的数据需要等我们滚动到底部才会加载的,那么后面两页的数据就属于需要 JavaScript 发出请求构造页面的,后面两页就属于客户端渲染。

对于客户端渲染,我们可以模拟浏览器执行,即模拟浏览器滚动到底部,可以借助 Headless Chrome puppeteer,无界面的浏览器。

const puppeteer = require('puppeteer');
(async () =\> {
  const browser = await puppeteer.launch({
    headless: false,
    timeout: 5000
  })

  const page = await browser.newPage()

  await page.goto('https://gitbook.cn/', {
    waitUntil: 'load'
  })

  // 初始服务端返回的 内容
  const totalChatLists = await page.evaluate(async () => {
    console.log('evaluate里面的代码会在浏览器执行');
    const sleep = (time) =\> new Promise((resolve, reject) =\> {
      setTimeout(() =\> {
        resolve();
      }, time);
    })
    const scroll = () =\> {
      const list = document.querySelectorAll('.chat\_list .chat\_item');
      console.log(list.length, document.body.clientHeight);
      // 平滑滚动到底部一次
      window.scrollTo({
        top: document.getElementById('chatItemContainer').clientHeight + 72,
        behavior: 'smooth'
      })
    }
    // 滚动第一次
    scroll();
    await sleep(2000);
    // 滚动第二次
    scroll();
    await sleep(2000);
    const chatlists = document.querySelectorAll('.chat\_list .chat\_item')
    return Promise.resolve([...chatlists].map(chatEl =\> {
      const url = chatEl.getAttribute('href');
      const title = chatEl.querySelector('h2').innerText;
      return {
        url, title
      }
    }));
  });
  console.log(totalChatLists.length, totalChatLists);
})()

Puppeteer 的 API 很见名知意,打开一个页面,然后 调用 await page.evaluate,这里面的代码会在 页面里面执行,我们在里面用 JS 模拟滚动底部,每次会间隔 2s,滚动完毕,我们需要的内容也加载完毕,选取即可。

结果如图:共 60 条数据。

目标网站爬取

打开 https://ncov.dxy.cn/ncovh5/view/pneumonia,右键显示源代码,虽然 HTML 里面没有我们想要的数据,但是 script 里面却有我们想要的数据。

类似于:

\<body\>
    \<div\>
    \</div\>
    \<script\> try { window.getListByCountryTypeService2 = [{}, {}] } catch() {} \</script\>
\</body\>

把数据返回在 前端,同时还放置于 window 对象下面,这是经典的 React 服务端渲染,处理前后端状态管理的方式。如上代码所示相当于定义了一个 getListByCountryTypeService2 的全局变量。

我们只要在控制台里面输入在 script 下面的变量名既可以拿到数据。

如下图:

截止作者写作之日(2020-02-09),共整理出来如下字段:

网页内容 变量名 疫情数据概览 getStatisticsService 全国数据 getListByCountryTypeService1 全球数据 getListByCountryTypeService2 疾病知识 getWikiList 实时播报 getTimelineService (只有前 5 个数据) 辟谣与防护 getIndexRumorList (只有前 10 个数据) 防护知识合辑 getIndexRecommendList (只有前 5 个数据)

其中有些数据不完整,需要跳到新的页面,才能看到完整的数据,表格中已经说明。

下一步,继续分析,完整的数据怎么爬取。

以“辟谣与防护”为例,我们继续分析,来到 谣言排行榜:https://ncov.dxy.cn/ncovh5/view/pneumonia_rumors?from=dxy&source=undefined

很明显了,所有的谣言,都是请求了一个 JSON 接口,其余两个同理。整理可得如下:

接口     辟谣与防护 https://file1.dxycdn.com/2020/0130/454/3393874921745912507-115.json?t=26354242   防护知识合辑 https://file1.dxycdn.com/2020/0130/542/3393874921746319236-115.json?t=26342815   实时播报 https://file1.dxycdn.com/2020/0130/492/3393874921745912795-115.json?t=26342813    

综上所述:对于已经把全局数据放到全局变量的 数据,我们直接获取即可。对于全局变量里面数据不完整的,我们可以请求 JSON 接口。

确定需求

对于爬虫,我们想控制一下频率,每隔半个小时,爬取一次,爬完数据放到 MongoDB(NoSQL 数据库)里面。

每当前端需要查询数据,我们都从 MongoDB 里面查询返回。

技术栈整理:

  • 后端服务:Koa、Koa-router
  • 代码编写:TypeScript
  • 数据持久化:MongoDB

项目需要安装 MongoDB,以及 npm 安装 TypeScript。

npm install -g typescript

数据爬取

项目初始化:

// step1
npm init -y
// step2: 新建:tsconfig.json 内容如下:
{
  "compilerOptions": {
    /\* Basic Options \*/
    "target": "ES2017",                          /\* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. \*/
    "module": "commonjs",                     /\* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. \*/
    "outDir": "./dist",                        /\* Redirect output structure to the directory. \*/
    "rootDir": "./src",                       /\* Specify the root directory of input files. Use to control the output directory structure with --outDir. \*/

    /\* Strict Type-Checking Options \*/
    "strict": true,                           /\* Enable all strict type-checking options. \*/

    /\* Additional Checks \*/

    /\* Module Resolution Options \*/
    "esModuleInterop": true,                  /\* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. \*/

    /\* Source Map Options \*/

    /\* Experimental Options \*/
    "experimentalDecorators": true,        /\* Enables experimental support for ES7 decorators. \*/
    "emitDecoratorMetadata": true,         /\* Enables experimental support for emitting type metadata for decorators. \*/

    /\* Advanced Options \*/
    "forceConsistentCasingInFileNames": true  /\* Disallow inconsistently-cased references to the same file. \*/
  }
}

tsconfig.json 指明打包到 dist 目录,源码在 src 目录。

建立如下目录结构:

.
├── README.md
├── article.md
├── config.json
├── dist
├── package.json
├── src
│   ├── index.ts
│   ├── model
│   │   └── model.ts
│   ├── module
│   │   ├── getListByCountryTypeService1.ts
│   │   └── getStatisticsService.ts
│   ├── types
│   │   └── types.ts
│   └── util
│       ├── db.ts
│       ├── query.ts
│       ├── request.ts
│       └── schedule.ts
└── tsconfig.json

我们先开始 util 目录下的代码 db.ts,负责数据库相关的:

// 需要 npm i @types/mongoose mongoose 
import mongoose from 'mongoose';

const mongodbUrl: string = 'mongodb://127.0.0.1:27017/2019-nCoV'

const connection = mongoose.createConnection(mongodbUrl);

export default connection;

大家在接下来的步骤里面,导入什么包,那么就安装什么包, 上面得代码需要安装两个 @types/mongoose mongoose,一个是我们依赖的,还有一个 @types,这是因为有些第三方模块不是 ts 写得,所以有另外的一个模块(@types)对他们定义类型。如果运行的时候发现报了如下错误,不用惊慌,缺少依赖包,用 npm 安装即可。

src/index.ts:1:17 - error TS2307: Cannot find module 'koa'.
1 import Koa from 'koa';

回到正题,上面的 db.ts,根据 MongoDB 启动的 URL,建立与 MongoDB 的连接,有了连接,我们可以用连接建立模型(Model),模型可以定以数据库里面存储的字段。

在 model.ts 里面:

/\*\* \* 数据总览 \*/
import mongoose from 'mongoose';
import connection from '../util/db';
import { VariableKey, ModelCacheType } from '../types/types';

const Schema = mongoose.Schema;
// 
const modelCache: ModelCacheType = {}
// 定义模型
const schema = new Schema({
  inDate: Date,
  data: {}
});

export default function (modelName: VariableKey): mongoose.Model\<any\> | undefined {
  if (modelCache[modelName]) {
    return modelCache[modelName];
  } else {
      // 创建模型
    const model = connection.model(modelName, schema);
    modelCache[modelName] = model
    return model;
  }
}
export {
  modelCache
}

用 schema 定义模型,我们存储得比较简单一个 日期字段,一个数据字段,日期字段可以在疫情结束后对每天产生的数据回溯。

const modelCache: ModelCacheType = {} 定义了一个 模型的缓存,类型是ModelCacheType,可以避免重复创建。

那么我们现在需要 ModelCacheType 这一个类型,在 types.ts 里面:

import mongoose from 'mongoose';
// 爬取数据的字段,我们在上面整理出表格了
enum VariableKey {
  getStatisticsService =  'getStatisticsService',
  getListByCountryTypeService1 = 'getListByCountryTypeService1',
  getListByCountryTypeService2 = 'getListByCountryTypeService2',
  getTimelineService = 'getTimelineService',
  getIndexRumorList = 'getIndexRumorList',
  getIndexRecommendList = 'getIndexRecommendList',
  getWikiList = 'getWikiList'
}
// 模型的缓存 这一个对象里面可以存储哪些字段,每一个字段的类型都是 mongoose.Model
interface ModelCacheType {
  getStatisticsService?: mongoose.Model<any>,
  getListByCountryTypeService1?: mongoose.Model<any>,
  getListByCountryTypeService2?: mongoose.Model<any>,
  getWikiList?: mongoose.Model<any>,
  getTimelineService?: mongoose.Model<any>,
  getIndexRumorList?: mongoose.Model<any>,
  getIndexRecommendList?: mongoose.Model<any>
}

interface JsonListObj {
  modelName: string,
  url: VariableKey
}
interface jsonListType {

}
// 需要爬取的数据,我们把它定义为一个数组
const dataList: VariableKey[] = [
  VariableKey.getIndexRecommendList,
  VariableKey.getListByCountryTypeService1,
  VariableKey.getListByCountryTypeService2,
  VariableKey.getIndexRumorList,
  VariableKey.getStatisticsService,
  VariableKey.getTimelineService,
  VariableKey.getWikiList
] 
export {
  VariableKey,
  ModelCacheType,
  JsonListObj,
  dataList
}

有了数据库连接,定义了数据的模型,那么我们就可以开始爬取数据,我们还需要一个请求方法,爬去完放到数据库里面了。

在 request.ts:

import request from 'request';
// 把 request 封装为 Promise
function req(url: string, option: request.CoreOptions) {
  return new Promise((resolve: (res: any) =\> void, reject) => {
    request(url, option, (err, res, body: any) => {
      if (err) {
        reject(err);
      }
      else {
        resolve(body)
      }
    })
  })
}
export default req;

在 schedule.ts:

import jsdom from "jsdom";
import dayJS from 'dayjs';
import request from './request';
import modelCreate from '../model/model';
import { VariableKey, JsonListObj } from '../types/types';

const { JSDOM } = jsdom;

// 四个基本的数据 无分页
const dataLists: VariableKey[] = [
  VariableKey.getStatisticsService,
  VariableKey.getListByCountryTypeService1,
  VariableKey.getListByCountryTypeService2,
  VariableKey.getWikiList
]
/\*\* \* 长列表 数据不完整 \* 0: 辟谣与防护 \* 1: 防护知识合辑 \* 3: 实时播报 \*/

const jsonList = [
  {
    modelName: VariableKey.getIndexRumorList,
    url: 'https://file1.dxycdn.com/2020/0130/454/3393874921745912507-115.json?t=26343751'
  },
  {
    modelName: VariableKey.getIndexRecommendList,
    url: 'https://file1.dxycdn.com/2020/0130/542/3393874921746319236-115.json?t=26342815'
  },
  {
    modelName: VariableKey.getTimelineService,
    url: 'https://file1.dxycdn.com/2020/0130/492/3393874921745912795-115.json?t=26342813'
  }
]
function modelCreateByData(modelName: VariableKey, data: object): void {
  // 创建模型
  const model = modelCreate(modelName);
  const inDate = dayJS().format('YYYY-MM-DD HH:mm:ss')
  if (model) {
   // 插入数据
    model.create({ inDate: inDate, data: data })
  }
}
async function crawler(): Promise\<void\> {
  try {
    console.log('正在访问 dxy');
    const html: string = await request('https://ncov.dxy.cn/ncovh5/view/pneumonia', {})
    拿到 html
    const dom = new JSDOM(html, { runScripts: 'dangerously' });
    const window = (dom.window as any)
    dataLists.map((modelName) =\> {
      modelCreateByData(modelName, window[modelName]);
    })
    for (let urlObj of jsonList) {
      const jsonListObj = urlObj;
      const data = await request(jsonListObj.url, { json: true })
      const modelName: VariableKey = jsonListObj.modelName
      modelCreateByData(modelName, data.data);
    }
  } catch (error) {

  }
}
function run(): void {
  crawler();
  setInterval(() =\> {
    crawler();
  }, 1000 * 60 * 30)
}
export {
  run
}

schedule.ts,负责获取数据的工作,这里借助 jsdom,帮我们在 node 端构建 dom 环境,浏览器端 window 下有一些全局变量,node 端通过 jsdom,我们也可以在 window 下获取变量,默认启动会执行一次,每隔 30 分钟又会爬取一次。

而对于 window 下数据不完整的,需要(代码已包含在schedule.ts,这里特地挑出来讲解一下):

for (let urlObj of jsonList) {
      const jsonListObj = urlObj;
      const data = await request(jsonListObj.url, { json: true })
      const modelName: VariableKey = jsonListObj.modelName
      modelCreateByData(modelName, data.data);
    }

在上文 schedule.ts 中已经定义 jsonList,存入了每一个需要请求的 URL,只要循环一一请求即可,存入数据的方法都是统一的。 万事俱备,在 index.ts 中:

import { run } from './util/schedule';
run();

启动:

// step1 第一个窗口 启动 typescript 监听文件跟新
tsc -w (tsc 命令需要安装 typescript)
// step2 第二个窗口 启动编译完的js文件
nodemon dist/index.js

可以看到 MongoDB 里面已经存入数据。

API 制作

我们规定 API 访问的路径和数据库模型一一对应:

kit-font-smoothing:antialiased;box-sizing:border-box;text-align:left;line-height:20px;padding:8px;vertical-align:top;font-weight:700;border-top:0px;outline:0px;">模型 /getStatisticsService getStatisticsService /getListByCountryTypeService1 getListByCountryTypeService1 /getListByCountryTypeService2 getListByCountryTypeService2 /getWikiList getWikiList /getTimelineService getTimelineService /getIndexRumorList getIndexRumorList /getIndexRecommendList getIndexRecommendList

这样我们根据 URL 就可以知道访问哪个模型,去模型里面吧对应的数据查询出来即可。

定义查询的 util 函数,在 query.ts:

import mongoose from 'mongoose';
// 根据模型,查询该模型的数据
export default async function query(model: mongoose.Model\<any\> | undefined) {
  return new Promise((resolve, reject) =\> {
    if (!model) return Promise.resolve({ msg: '暂无数据' });
    // 查询最后一条数据,因为一个模型里面有多条数据,选择最新入库的一条
    model
    .find({})
    .sort({ \_id:-1 })
    .limit(1)
    .exec((err: any, docs: mongoose.Document) =\> {
      if (err) {
        console.log('查询出错了', err)
        reject(err);
      }
      console.log('查询结果', docs);
      resolve(docs);
    })
  })
}

在 types.ts 的 dataList 里面我们定义了所有接口或者所有模型的数组:

const dataList: VariableKey[] = [
  VariableKey.getIndexRecommendList,
  VariableKey.getListByCountryTypeService1,
  VariableKey.getListByCountryTypeService2,
  VariableKey.getIndexRumorList,
  VariableKey.getStatisticsService,
  VariableKey.getTimelineService,
  VariableKey.getWikiList
] 

那么我们只要根据这个数组生成 API 即可,而且循环的时候我们也知道模型,根据模型查询的 query.ts 也准备好了,index.ts 全文如下:

import Koa from 'koa';
import Router from 'koa-router';
import fs from 'fs';
import path from 'path';
import { run } from './util/schedule';
import { dataList } from './types/types'
import query from './util/query';
import modelCreate from './model/model';

const packageJSON = require('../package.json')

const app: Koa = new Koa();
const router: Router = new Router();

app.use(async (ctx: Koa.BaseContext, next: Koa.Next) => {
  ctx.set({
    'Access-Control-Allow-Credentials': 'true',
    'Access-Control-Allow-Origin': ctx.headers.origin || '\*',
    'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type',
    'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS',
    'Content-Type': 'application/json; charset=utf-8'
  });
  await next();
})

run();

dataList.forEach(key =\> {
  const { modelCache }  = require('./model/model');
  router.get(`/${key}`, async (ctx) => {
    const model = modelCreate(key)
    const body = await query(model);
    ctx.body = body;
  })
})

app
  .use(router.routes())
  .use(router.allowedMethods());

const port = process.env.PORT || 3001;
app.listen(port, () => {
  console.log(`${packageJSON.name} is running ${port} port ....`);
})

尝试访问:

成功查出数据。


欢迎关注我的公众号,回复关键字“大礼包” ,将会有大礼相送!!! 祝各位面试成功!!!

发布了112 篇原创文章 · 获赞 2 · 访问量 5544

猜你喜欢

转载自blog.csdn.net/weixin_41818794/article/details/104524104