# node - 爬虫初体验

# 背景介绍

# 爬虫

爬虫,也叫网络蜘蛛(spider),是一种用来自动浏览万维网的网络机器人。是搜索引擎的重要组成部分,搜索引擎通过爬虫来更新自身的网站内容或其对其它网站的索引。搜索引擎将爬虫所访问的页面保存下来,然后生成索引供用户搜索。

# robots.txt 协议

robots.txt 是一个文本文件,告诉搜索引擎的爬虫,网站中的哪些内容是不应被爬虫爬取的,哪些内容是可以被爬取的,爬虫就会按照文件中的内容来确定访问范围。比如 Disallow /login, /admin 这些涉及用户敏感信息的路径,就可以告诉爬虫,这些路径是禁止爬取的(律师函警告)。

所以,当一个搜索蜘蛛访问一个站点时,它会首先检查该站点根目录下是否存在robots.txt,如果存在,搜索机器人就会按照该文件中的内容来确定访问的范围;如果该文件不存在,搜索蜘蛛将能尝试访问网站上所有的页面。

robots.txt 放在网站的根目录下。

这个协议不是规范,只是个约定。

爬虫应该应该在不影响目标站点运行的情况下进行爬取。

基本写法

【User-agent】

爬虫种类,可以使用通配符 *,表示所欲的搜索机器人

User-agent: * 
1

百度的搜索机器人

User-agent: Baiduspider
1

【Disallow】

不允许爬取的目录

下面代码表示禁止爬寻admin目录下面的目录

Disallow: /admin/
1

下面代码表示禁止抓取网页所有的.jpg格式的图片

Disallow: /.jpg$
1

下面代码表示禁止爬取ab文件夹下面的adc.html文件

Disallow: /ab/adc.html 
1

下面代码表示禁止访问网站中所有包含问号 (?) 的网址

Disallow: /*?* 
1

下面代码表示禁止访问网站中所有页面

Disallow: /
1

【Allow】   下面代码表示允许访问以".html"为后缀的URL

Allow: .html$
1

下面代码表示允许爬寻tmp的整个目录

Allow: /tmp
1

截图 豆瓣的 robots.txt

豆瓣的 robots.txt

# 爬虫的基本流程

主要针对网站内容的爬取

  • 在不影响目标站点运行的情况下,进行爬取
  1. 抓取数据
  2. 存储数据
  3. 渲染数据

下面以掘金 (opens new window)上看到的抓取 豆瓣电影热映影片 为栗。

# 抓取数据

目录结构

截图 简单的目录结构

目录结构

.
├─ src               // 抓取数据
│  ├─ db.js          // 数据库操作
│  ├─ index.js
│  ├─ read.js
│  └─ write.js
├─ web               // 渲染数据
│  ├─ views
│  │  └─ index.html
│  └─ server.js      
└─ package.json      // 项目依赖
1
2
3
4
5
6
7
8
9
10
11

获取内容

根据前端后端交互的区别,这里分为两种情况

  1. 单页面应用 页面主要通过 ajax 请求数据之后进行渲染。现在搜索引擎的蜘蛛还不能有效爬取这种网站的内容,所以一般的内容网站都不得采用这种方式。这种网站的数据抓取相对轻松,只要 f12 找到 api,需要的数据就基本已经获取到了。也可是使用一些代理抓包工具来查看接口,如 Fiddler/Charles。

  2. 服务端渲染 页面在服务端基本渲染好了,访问的时候直接返回的 html 代码。这种网站就需要先爬取然后解析。

其实就是通过请求 url ,获取到页面的 html 代码,然后从 html 里面获取数据,可以把 html 转化为 DOM 来获取,也可以正则匹配。

这里使用 request 或者加强版 request-promise 来请求,cheerio 来转化 html 代码。

然后,通过观察豆瓣电影页面,正在热映模块的 DOM 结构,找到读取内容的方法

// 引入请求库
const rp = require('request-promise');
// 用来把 html 转化为 DOM
const cheerio = require('cheerio');

const opts = {
  url: 'https://movie.douban.com'
};

rp(opts).then(res =>{
  // res 就是目标页面请求回来的 html 代码
  // load 返回的是一个类似 jq 的对象,操作 DOM 也和完全按照 jq 的方式。
  const $ = cheerio.load(res);
  let result = []; // 结果数组
  
  // 定位到 正在热映电影 模块
  $('#screening li.ui-slide-item').each((index, item) => {
        let ele = $(item);
        // 获取 名称|评分|地址|封面|id
        let name = ele.data('title');
        let score = ele.data('rate') || '暂无评分';
        let href = ele.find('.poster a').attr('href');
        let image = ele.find('img').attr('src');
        let id = href && href.match(/(\d+)/)[1];
  
        if (!name || !image || !href) return;
  
        result.push({
          name,
          score,
          href,
          image,
          id
        });
      });
  
  return result;
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

豆瓣电影 - 热映 DOM 结构

总结

  • 通过 request-promise 获取 html 代码
  • cheerio 将 html 转化成 DOM
  • 操作 DOM 获取需要的电影数据(名称|评分|地址|封面|id)
  • 返回包含所有电影数据的数组

# 存储数据

获取到了数据,就要把数据存储起来,方便后期处理和使用。也是爬虫最主要的目的,好不容易获取到了当然要存起来。

这里选择 mongodb 。

  1. 安装 mongodb;
  2. 启动 mongodb 服务;
  3. 数据库连接
const mongoose = require('mongoose');
/**
*  数据库连接
*/
const config = {
  db: 'mongodb://localhost/movie_douban',
  options: {
    poolSize: 50,
    auto_reconnect: true,
  }
};
const database = app => {
  // mongoose.set('debug', true);
  // 连接数据库
  mongoose.connect(config.db, config.options);
  // 连接中断重连
  mongoose.connection.on('disconnected', (err) => {
    console.error(err);
    mongoose.connect(config.db);
  });
  // 连接错误
  mongoose.connection.on('error', err => {
    console.error(err);
  });
  // 连接成功
  mongoose.connection.on('open', async () => {
    console.log('Connected to mongoose');
  });
};
database();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  1. 定义 Schema
const mongoose = require('mongoose');
/**
 * 定义电影字段
 */
const Schema = mongoose.Schema;
const MovieSchema = new Schema({
  name: String,
  score: String,
  href: String,
  image: String,
  id: Number
});
MovieSchema.statics = {
  // 获取
  async getMovie (id) {
    const movie = await this.findOne({
      id: id
    }).exec();
    if (movie) console.log('数据库查询到movie', id, movie.name);
    else console.log('数据库查询movie', id, '没有');
    return movie;
  },
  // 保存
  async saveMovie (data) {
    let id = +data.id;
    let movie = await this.getMovie(id);
    if (movie) {
      //  更新
      movie = await this.update({ id }, movie);
    } else {
      // 保存
      movie = await this.create(data);
    }
    return movie;
  },
  async getMovieQuery (query) {
    let movies = await this.find(query).exec();
    if (!movies) return [];
    return movies;
  }
};
// 获取movie的数据模型
const Movie = mongoose.model('Movie', MovieSchema);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
  1. 写入数据库
const mongoose = require('mongoose');
// 获取movie的数据模型
const Movie = mongoose.model('Movie');
const write = async (movies) => {
  for (let movie of movies) {
    // 通过 saveMovie 保存或者更新电影
    await Movie.saveMovie(movie);
  }
};
1
2
3
4
5
6
7
8
9

# 渲染数据

提供一个出口,把爬取到的数据展示出来。拿到了数据就可以随便玩儿了。

这里使用 express + ejs 渲染。

ejs 模板 //index.html

<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <meta name="referrer" content="no-referrer">
  <title>热映的电影</title>
  <style>
  
  </style>
</head>
<body>
<div class="container">
  <h2 class="caption">正在热映的电影</h2>
  <ul class="list">
    <% for(let i=0;i < movies.length; i++){
    let movie = movies[i];
    %>
    <li>
      <a href="<%=movie.href%>" target="_blank">
        <img src="<%=movie.image%>"/>
        <p class="title"><%=movie.name%></p>
        <p class="score <%= movie.scoreClass %>">评分:<%=movie.score%></p>
      </a>
    </li>
    <% } %>
  </ul>
</div>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

server.js

const express = require('express');
const mongoose = require('mongoose');
const path = require('path');
const database = require('../src/db');
const app = express();

// 连接数据库
database();
const Movie = mongoose.model('Movie');
// 设置模板引擎
app.set('view engine', 'html');
app.set('views', path.join(__dirname, 'views'));
app.engine('html', require('ejs').__express);

// 首页路由
app.get('/', async (req, res) => {
  let movies = await Movie.getMovieQuery({});
  res.render('index', {
    movies: movies.filter(i => {
      let score = parseFloat(i.score);
      i.score = score;
      if (score >= 9) {
        i.scoreClass = 'c-1';
      } else if (score >= 8) {
        i.scoreClass = 'c-2';
      } else {
        i.scoreClass = 'c-3';
      }
      // 评分大于 7.5 的才返回
      return score && score >= 7.5;
    }).sort((a, b) => b.score - a.score)
  });
});

app.listen(2333);
console.log('app start at http://localhost:2333');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

最终效果图 正在热映的电影

# 反爬虫

主要介绍4种。

  1. 非浏览器检查 浏览器发起的请求,在请求头里面都携带各种信息,比如 User-Agent。而直接在服务端发起的请求是不会携带。识别 Headers 里面没有包含 User-Agent。

  2. 限制 IP 同一 IP 短时间内过于频繁的访问。按照用户的访问行为进行限制,比如一秒钟内限制5次请求,1小时500次请求。超过次数就限制 ip 的访问。

  3. 弹验证码 检测到异常访问的时候,弹出验证码,验证是人为行为还是机器访问。 栗子:google 搜索

  4. 限制登录 限制页面需要登录后才可以访问,同一账号登录后请求过于频繁,采取封号的行为来限制。

# 防反爬虫

针对前面提到的反爬虫策略进行调整方案。

  1. 非浏览器检查 请求头里面没有 User-Agent ,那就设置一个。 request-promise 代码示例
const rp = require('request-promise');
const opts = {
  url: 'https://movie.douban.com',
  headers:{
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36'
  }  
};

rp(opts).then(res=>console.log(res));
1
2
3
4
5
6
7
8
9
  1. 限制 IP 为了短时间的获得多的数据,只能高频率的访问。所以只能使用代理来进行访问,访问次数临近上限的时候就更换代理服务器。 可选的方案一般有:
  • 免费代理:度娘一下,有很多提供免费代理服务器的网站。但是可用率一般比较低。
  • 付费代理:可以购买一下付费的代理服务服务器。可用率据说会高一点,因为要给钱,没用过。
  • 代理池:就是多找几个免费代理,爬取更多的免费代理来使用。
  • ADSL 拨号代理:使用 ADSL 拨号主机搭建http请求代理的服务。据说比较稳定,还是要花钱,没用过。 搭建方法可以参考 (opens new window)

使用代理服务器进行请求的栗子:

const rp = require('request-promise');
const opts = {
  url: 'https://movie.douban.com',
  proxy: 'http://代理服务器ip:端口'
};

rp(opts).then(res=>console.log(res));
1
2
3
4
5
6
7
  1. 弹验证码 验证码分为非常多种,如普通图形验证码、算术题验证码、滑动验证码、点触验证码、手机验证码、扫二维码等。 对于普通图形验证码,可以使用 OCR 识别。 其他的,简单方便的话可以对接收费的打码平台。

  2. 限制登录 限制了登录就莫得办法咯。

  • 同样的数据,找一下不需要的接口来获取。
  • 维护 Cookies ,使用批量账号模拟登录,请求的时候随机带上 Cookies。 rp 官网栗子
const rp = require('request-promise');
const tough = require('tough-cookie');

// Easy creation of the cookie - see tough-cookie docs for details
// 使用 tough-cookie 创建一个类 cookie 对象 
let cookie = new tough.Cookie({
    key: "some_key",
    value: "some_value",
    domain: 'api.mydomain.com',
    httpOnly: true,
    maxAge: 31536000
});

// Put cookie in an jar which can be used across multiple requests
// 把 cookie 放在 rp 的请求配置里,所有的请求都会带上 cookie 
let cookiejar = rp.jar();
cookiejar.setCookie(cookie, 'https://api.mydomain.com');
// ...all requests to https://api.mydomain.com will include the cookie

let options = {
    uri: 'https://api.mydomain.com/...',
    jar: cookiejar // Tells rp to include cookies in jar that match uri
};
rp(options)
    .then(function (body) {
        // Request succeeded...
    })
    .catch(function (err) {
        // Request failed...
    });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 优化

  1. 多进程
  2. 控制并发

# 最后

所有分享仅用于技术学习。

# 参考

上次更新: 6/2/2020, 10:05:55 PM