Python网络爬虫

1. 概述

本文主要介绍网络爬虫,采用的实现语言为Python,目的在于阐述网络爬虫的原理和实现,并且对目前常见的爬虫技术进行扩展。
主要的内容有:

  • 爬取Hi运动网站实例
  • 介绍Scrapy框架,将其项目转化成Scrapy项目
  • 介绍反爬虫相关
  • 对百度贴吧模拟登录,获取用户关注的贴吧、自动发帖、自动签到功能

2. 基础概念

  • 爬虫:通过一系列的脚本模仿用户访问web页面,用来获取相关数据的方式。
  • 浏览器:获取远程的HTML+JS+CSS文件,通过引擎渲染成可视化页面。
  • TCP/IP模型:有四个抽象的层次,描述了总体的设计大纲。物理层/实体层,链接层,网络层,传输层,应用层。
  • HTTP和HTTPS:http是超文本传输协议,信息是明文传输;https则是具有安全性的ssl加密传输协议。
  • GET和POST请求:根据HTTP标准,HTTP请求可以使用多种请求方法。GET请求指定的页面信息,并返回实体主体;POST请求向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或已有资源的修改。
  • Cookie:是客户端的解决方案,是由服务器发给客户端的特殊信息,而这些信息以文本文件的方式存放在客户端,然后客户端每次向服务器发送请求的时候都会带上这些特殊的信息。
  • Session:是另一种记录客户状态的机制,不同的是cookie保存在客户端浏览器中,而session保存在服务器上。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上。

3. 简单爬虫实例

下面使用Python语言实现一个简单的爬虫程序,爬取的网站是Hi运动。爬取的目标是:

  • 获取健身动作视频链接
  • 获取健身动作相关信息,包括健身部位、动作要领等
  • 保存成数据库

3.1 观察页面DOM结构

在编写爬虫之前,需要先了解所爬页面的大致DOM结构。所谓DOM就是文档对象模型,在网页中把数据组织在一个树形结构中,方便组织管理。通过解析这个树形结构,获取HTML标签内部的值就能够得到我们想要的数据。

首先打开网址四足俯卧撑,然后右键查看源码,可以看到此HTML页面的源码,相关数据都在此数据里。按Ctrl+F搜索字符video查找一下有关视频播放的信息,发现在页面的下方,<script> 标签有关的json数据中包含有对应信息。

3-1.png

之后为了更好查看这段json信息,使用JSON在线解析工具

3-1-2.png

完整的json数据如下:

{
  "id":"1",
  "create_time":"2016-03-29 17:30:46",
  "name":"四足俯卧撑",
  "eng_name":"",
  "description":"1、挺胸收腹,躯干与地面平行
2、双手与肩同宽,始终保持腰背挺直,控制肘部紧贴身体两侧",
  "difficulty":"6",
  "muscle_id":"27",
  "coach_gender":"0",
  "group_id":"2",
  "category_id":"2",
  "created_from":"0",
  "status":"1",
  "ext_info":null,
  "web_mvideo_id":null,
  "web_fvideo_id":null,
  "extra_equipment_type":null,
  "equipment_ids":"3",
  "tpoints":"6",
  "equipments":[
    {
      "id":"3",
      "create_time":"2016-03-29 16:59:17",
      "name":"徒手训练",
      "pic":"",
      "show_type":"0",
      "equipment_group":"0",
      "sort_num":"0"
    }
  ],
  "trainingPoints":"胸部",
  "difficulty_name":"初级",
  "gender_group":{
    "male":true,
    "female":true
  },
  "category_name":"上肢",
  "muscle_name":"胸大肌",
  "otherMuscles":[
    {
      "exercise_id":"1",
      "muscle_id":"17"
    }
  ],
  "exe_explain_pic":[
    {
      "name":null,
      "url":"http://image.yy.com/ojiastoreimage/1462264660160_am__len94877.jpg",
      "desc":"1、挺胸收腹,躯干与地面平行"
    },
    {
      "name":null,
      "url":"http://image.yy.com/ojiastoreimage/1462264665645_am__len98161.jpg",
      "desc":"
1、双手与肩同宽,始终保持腰背挺直,控制肘部紧贴身体两侧"
    }
  ],
  "video_id":"7033",
  "pic":"https://w2.dwstatic.com/yy/ojiastoreimage/20160429_7a5105bb4b153caaf030c490ce36d5a1.jpg",
  "gif":"https://w2.dwstatic.com/yy/ojiastorevideos/a0ced8d3263db3e6cbf774d516c5eec5.gif",
  "detail_video_id":null,
  "muscle_pic":"",
  "video_url":"https://dw-w6.dwstatic.com/49/1/1617/1838324-101-1461899919.mp4",
  "detail_video_url":null,
  "split_time":null,
  "muscle_front_img":"https://w2.dwstatic.com/yy/ojiastoreimage/1477640202013_am_",
  "muscle_back_img":"https://w2.dwstatic.com/yy/ojiastoreimage/1477640203030_am_",
  "definition_group":[
    {
      "definition":1300,
      "name":"超清",
      "url":"https://dw-w6.dwstatic.com/50/1/1617/1838324-100-1461899919.mp4",
      "length":2760,
      "size":"300458",
      "is_default":false
    },
    {
      "definition":"yuanhua",
      "name":"原画",
      "url":"https://dw-w6.dwstatic.com/49/1/1617/1838324-101-1461899919.mp4",
      "length":2760,
      "size":"709332",
      "is_default":true
    }
  ]
}

通过这段json就可以直接获取到我们想要的数据了:

  • video_url:视频链接
  • name:动作名称
  • difficulty_name:动作级别
  • training_points:锻炼部位
  • muscle_name:锻炼的肌肉
  • description:动作要领

3.2 Requests库使用

当已经了解了所爬取页面的DOM结构之后,下面就开始编写爬虫程序。

首先介绍Python的网络操作库——Requests。
Requests 提供了HTTP很多功能,几乎涵盖了当今 Web 服务的需求,比如:

  • HTTP 请求与相应
  • 浏览器式的 SSL 验证
  • 身份认证
  • Keep-Alive & 连接池
  • 带持久 Cookie 的会话
  • 流下载
  • 文件分块上传

安装方法pip install requests,简单地使用如下:

>>> r = requests.get('https://www.hiyd.com/dongzuo/1/', timeout=5))
>>> r.status_code
200
>>> r.encoding
'utf-8'
>>> r.text
'<!doctype html>
<html>
<head>
    <meta charset="utf-8">... ...'

更多其他功能,请见官方文档

据此编写爬虫的联网操作:

import requests
class Fitness:

    def get_info(self, url):
        r = requests.get(url, timeout=5)
        print(r.text)


if __name__ == "__main__":
    fitness = Fitness()
    fitness.get_info('https://www.hiyd.com/dongzuo/1/')

分析代码,通过get请求获取response,然后调用text属性获取页面数据,我们就可以根据此数据进行解析。

3.3 BeautifulSoup库使用

Beautiful Soup 是一个可以从HTML或XML文件中提取数据的Python库。它能够通过你喜欢的转换器实现惯用的文档导航,查找,修改文档的方式。安装方法pip install beautifulsoup4,简单实用如下:

from bs4 import BeautifulSoup
import requests

r = requests.get('https://www.hiyd.com/dongzuo/1/', timeout=5)
soup = BeautifulSoup(r.text, "html.parser")
print(soup.title)
# <title>四足俯卧撑正确动作要领_四足俯卧撑视频GIF图解_Hi运动健身网</title>
print(soup.a)
# <a class="o-header_logo" href="https://www.hiyd.com/">
# <img src="/static/img/logo3.png?218f9b39b0457ae3"/>
# </a>
print(soup.a['href'])
# https://www.hiyd.com/
print(soup.find_all('a'))
# [<a class="o-header_logo" href="https://www.hiyd.com/">
# <img src="/static/img/logo3.png?218f9b39b0457ae3"/>
# </a>, <a class="item" href="//www.hiyd.com/dongzuo/" target="_self">健...
print(soup.find(id='group_exercise'))
# <div class="menu-group group-tp2 group-expand" id="group_exercise">
# <div class="group-hd">
# <i></i><h3>训练动作</h3><em></em>
# </div>
# <div class="group-bd">
# <div class="menu-item instrument">... ...
print(soup.find_all("script"))
# [<script>
# SITE_URL = "/";
# </script>, <script src="/static/js/libs/seajs.utils.js?7c9ae9ca1b254cde"></script>, <script>
#     seajs.use(['ouj_sdk'], function(sdk) {
#         sdk.init();
#     });
# </script>, <script>... ...

更多其他功能,请见官方文档

接着3.2中的代码,使用BeautifulSoup完成解析数据

from bs4 import BeautifulSoup
import requests
import re
import json


class Fitness:
  headers = {
      'User-Agent': 'Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US; rv:1.9.1.6) Gecko/20091201 Firefox/3.5.6'
  }
    def get_info(self, url):
        r = requests.get(url, headers=self.headers, timeout=5)
        soup = BeautifulSoup(r.text, "html.parser")
        text = str(soup.find_all("script")[-1])
        data = json.loads(re.search(r'e.init\((.+?)\);', text).group(1))
        print(data)

if __name__ == "__main__":
    fitness = Fitness()
    fitness.get_info('https://www.hiyd.com/dongzuo/1/')

其中,data = json.loads(re.search(r'e.init\((.+?)\);', text).group(1))含义是,将获取到的数据通过正则表达式取出中间的json文本数据,再通过json.loads方法将json数据转换为python的dict数据。得到的data数据为:

{'muscle_front_img': 'https://w2.dwstatic.com/yy/ojiastoreimage/1477640202013_am_', 'coach_gender': '0', 'extra_equipment_type': None, 'created_from': '0', 'status': '1', 'difficulty_name': '初级', 'tpoints': '6', 'detail_video_url': None, 'muscle_id': '27', 'trainingPoints': '胸部', 'definition_group': [{'is_default': False, 'url': 'https://dw-w6.dwstatic.com/50/1/1617/1838324-100-1461899919.mp4', 'length': 2760, 'definition': 1300, 'name': '超清', 'size': '300458'}, {'is_default': True, 'url': 'https://dw-w6.dwstatic.com/49/1/1617/1838324-101-1461899919.mp4', 'length': 2760, 'definition': 'yuanhua', 'name': '原画', 'size': '709332'}], 'description': '1、挺胸收腹,躯干与地面平行\r\n2、双手与肩同宽,始终保持腰背挺直,控制肘部紧贴身体两侧', 'video_url': 'https://dw-w6.dwstatic.com/49/1/1617/1838324-101-1461899919.mp4', 'muscle_pic': '', 'video_id': '7033', 'split_time': None, 'eng_name': '', 'otherMuscles': [{'muscle_id': '17', 'exercise_id': '1'}], 'equipments': [{'pic': '', 'show_type': '0', 'create_time': '2016-03-29 16:59:17', 'name': '徒手训练', 'sort_num': '0', 'equipment_group': '0', 'id': '3'}], 'web_mvideo_id': None, 'ext_info': None, 'muscle_back_img': 'https://w2.dwstatic.com/yy/ojiastoreimage/1477640203030_am_', 'gender_group': {'male': True, 'female': True}, 'gif': 'https://w2.dwstatic.com/yy/ojiastorevideos/a0ced8d3263db3e6cbf774d516c5eec5.gif', 'category_name': '上肢', 'difficulty': '6', 'category_id': '2', 'group_id': '2', 'pic': 'https://w2.dwstatic.com/yy/ojiastoreimage/20160429_7a5105bb4b153caaf030c490ce36d5a1.jpg', 'web_fvideo_id': None, 'create_time': '2016-03-29 17:30:46', 'muscle_name': '胸大肌', 'name': '四足俯卧撑', 'id': '1', 'exe_explain_pic': [{'name': None, 'url': 'http://image.yy.com/ojiastoreimage/1462264660160_am__len94877.jpg', 'desc': '1、挺胸收腹,躯干与地面平行'}, {'name': None, 'url': 'http://image.yy.com/ojiastoreimage/1462264665645_am__len98161.jpg', 'desc': '\r1、双手与肩同宽,始终保持腰背挺直,控制肘部紧贴身体两侧'}], 'detail_video_id': None, 'equipment_ids': '3'}

之后,我们就可以通过这个dict数据取出想要的数据了。

3.4 页面跳转

以上实现的功能是取出单一页面的数据,但是可以看到Hi运动动作库中还有很多个视频页面需要提取,此时就需要为爬虫程序添加页面跳转的功能。
我们再继续观察'https://www.hiyd.com/dongzuo/'页面,可以发现源码中带有<div class="cont">的属于当前页所有动作的详细链接,获取这些链接传给get_info方法就像之前实现的逻辑获取视频详细信息了。
实现代码:

def get_pages(self, url):
    r = requests.get(url, headers=self.headers, timeout=5)
    soup = BeautifulSoup(r.text, "html.parser")
    for x in soup.find_all("div", class_="cont"):
        print(x.a.get('href'))

其中,soup.find_all("div", class_="cont")是取得所有带有class="cont"的div标签(因为class是python自有的,所以BeautifulSoup用class_进行替代),然后将得到的list结果进行迭代,获取子标签a的href属性值。得到的结果:

/dongzuo/1/
/dongzuo/2/
/dongzuo/3/
/dongzuo/4/
/dongzuo/5/
/dongzuo/6/
/dongzuo/7/
/dongzuo/8/
/dongzuo/9/
/dongzuo/10/
/dongzuo/11/
/dongzuo/12/
/dongzuo/13/
/dongzuo/14/
/dongzuo/15/
/dongzuo/16/
/dongzuo/17/
/dongzuo/18/
/dongzuo/19/
/dongzuo/20/

上面实现的是获取动作库中第一页的所有视频链接,但是还需要提取其余80页数据。分析动作库页面源码,可以看到<a href="/dongzuo/?page=2" onclick="_pageClick('next')" rel="next" title="下一页">下一页</a>显示了当存在下一页时,下一页的页面链接数据,而在第80页时没有此数据。据此,我们就可以获取到下一页的链接地址。

host = 'https://www.hiyd.com'
def get_pages(self, url):
    r = requests.get(url, headers=self.headers, timeout=5)
    soup = BeautifulSoup(r.text, "html.parser")
    for x in soup.find_all("div", class_="cont"):
        self.get_info(self.host + x.a.get('href'))
    next_page_url = str(soup.find("a", rel="next").get('href'))
    if next_page_url is not None and next_page_url != "/dongzuo/?page=3":
        self.get_pages(self.host + next_page_url)

获取的next_page_url如果不为None就递归调用get_pages方法,此处加的next_page_url != "/dongzuo/?page=3"是加一个页数限制,避免一下取80页。
至此,页面跳转和页面数据解析的功能已经全部实现完成,下面就是对数据进行存储。

3.5 保存数据

下面介绍两种保存方式,一是保存视频文件,二是将信息保存到数据库中。

3.5.1 保存视频文件

requests库支持流下载,查看文档原始响应内容 章节。

3-5-1.png

根据文档,实现视频下载逻辑:

def download(self, name, url):
    if os.path.exists("./video") is False:
        os.makedirs("./video")
    with requests.get(url, stream=True) as response:
        with open("./video/" + name + ".mp4", "wb") as file:
            for data in response.iter_content(chunk_size=1024):
                file.write(data)

首先在当前目录下创建video文件夹,然后用with语句调用requests.get(url, stream=True)(with语句的作用是保证response会调用close方法。在请求中把 stream 设为 True,Requests 无法将连接释放回连接池,除非你消耗了所有的数据,或者调用了 Response.close。)。接着调用open方法设置二进制写入方式,将response的迭代数据写入到文件中。

3.5.2 保存数据库

数据库选择Python自带比较简单的Sqlite数据库,无需安装驱动即可使用。相关使用方法,请见SQLite - Python.

下面是数据库相关操作的实现逻辑:

def __init__(self):
    self.conn = sqlite3.connect('fitness_test.db')
    self.create_db()

def create_db(self):
    self.conn.execute("CREATE TABLE IF NOT EXISTS fitness (id INTEGER PRIMARY KEY, "
                      "name TEXT, "
                      "muscle_name TEXT, "
                      "description TEXT, "
                      "video_url TEXT);")

def save_db(self, data):
    self.conn.execute("INSERT INTO fitness (name, muscle_name, description, video_url) VALUES(?, ?, ?, ?)", data)

def close_db(self):
    self.conn.commit()
    self.conn.close()

__init__初始化方法中连接数据库,如果没有会在当前目录下创建数据库,然后创建对应的表结构。save_db方法,接收一个list参数,对应sql插入语句中的参数。close_db方法,提交数据库事务,并关闭数据库。

3.6 完整代码

至此,Hi运动爬虫demo的功能已经开发完成,下面是完整的代码:

import os

from bs4 import BeautifulSoup
import requests
import re
import sqlite3
import json


class Fitness:
    host = 'https://www.hiyd.com'

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US; rv:1.9.1.6) Gecko/20091201 Firefox/3.5.6'
    }

    def __init__(self):
        self.conn = sqlite3.connect('fitness_test.db')
        self.create_db()

    def get_pages(self, url):
        r = requests.get(url, headers=self.headers, timeout=5)
        soup = BeautifulSoup(r.text, "html.parser")
        for x in soup.find_all("div", class_="cont"):
            self.get_info(self.host + x.a.get('href'))
        next_page_url = str(soup.find("a", rel="next").get('href'))
        if next_page_url is not None and next_page_url != "/dongzuo/?page=3":
            self.get_pages(self.host + next_page_url)
        else:
            self.close_db()

    def get_info(self, url):
        r = requests.get(url, headers=self.headers, timeout=5)
        soup = BeautifulSoup(r.text, "html.parser")
        text = str(soup.find_all("script")[-1])
        data = json.loads(re.search(r'e.init\((.+?)\);', text).group(1))
        self.download(data['name'], data['video_url'])
        self.save_db([data['name'], data['muscle_name'], data['description'], data['video_url']])
        print("done " + data['name'])

    def download(self, name, url):
        if os.path.exists("./video") is False:
            os.makedirs("./video")
        with requests.get(url, stream=True) as response:
            with open("./video/" + name + ".mp4", "wb") as file:
                for data in response.iter_content(chunk_size=1024):
                    file.write(data)

    def create_db(self):
        self.conn.execute("CREATE TABLE IF NOT EXISTS fitness (id INTEGER PRIMARY KEY, "
                          "name TEXT, "
                          "muscle_name TEXT, "
                          "description TEXT, "
                          "video_url TEXT);")

    def save_db(self, data):
        self.conn.execute("INSERT INTO fitness (name, muscle_name, description, video_url) VALUES(?, ?, ?, ?)", data)

    def close_db(self):
        self.conn.commit()
        self.conn.close()


if __name__ == "__main__":
    fitness = Fitness()
    fitness.get_pages('https://www.hiyd.com/dongzuo/')

4. 拓展

从上面的章节来看,可能会觉得爬虫程序很简单,但是这只是对于个人学习的demo,到真正的工程化项目来说还有很大的差距。困难主要在于:

  • 数据量大:爬取大型网站时,可能会产生亿级的数据,如何高效存储、分析数据,如何将数据可视化等问题
  • 效率:对于高效率地爬取大量数据,如何实现构建高并发、分布式爬虫系统
  • 反爬虫策略:目前很多主流的网站都会有反爬虫策略,防止大量的爬虫对运维造成压力。如何处理IP被封,如何处理验证码等问题

可以看到,工程化的爬虫程序需要解决很多的实际问题,这些实际问题往往很难解决。所以入门爬虫的门槛很低,但是成长曲线很陡峭。接下来,介绍一些常见的爬虫进阶知识。

4.1 Scrapy框架

Scrapy是一个为了爬取网站数据,提取结构性数据而编写的应用框架。 可以应用在包括数据挖掘,信息处理或存储历史数据等一系列的程序中。安装方式pip install Scrapy(需要翻墙,并且根据提示安装依赖库),或者通过Anaconda来便捷安装scrapy。

下面将Hi运动爬虫程序转换成Scrapy项目。

4.1.1 创建项目

进入打算存储代码的目录中,运行下列命令
scrapy startproject fitness
该命令将会创建包含下列内容的SpiderExercise 目录:

SpiderExercise/
    scrapy.cfg
    fitness/
        __init__.py
        items.py
        pipelines.py
        settings.py
        spiders/
            __init__.py
            ...

这些文件分别是:

  • scrapy.cfg: 项目的配置文件
  • fitness/: 该项目的python模块。之后您将在此加入代码。
  • fitness/items.py: 项目中的item文件.
  • fitness/pipelines.py: 项目中的pipelines文件.
  • fitness/settings.py: 项目的设置文件.
  • fitness/spiders/: 放置spider代码的目录.

4.1.2 定义Item

Item 是保存爬取到的数据的容器;其使用方法和python字典类似,并且提供了额外保护机制来避免拼写错误导致的未定义字段错误。类似在ORM中做的一样,可以通过创建一个 scrapy.Item 类,并且定义类型为 scrapy.Field 的类属性来定义一个Item。

首先根据需要从http://www.hiyd.com/获取到的数据对item进行建模。 我们需要从hiyd中获取肌肉id、动作库名称等字段。对此,在item中定义相应的字段。编辑 fitness目录中的 items.py 文件:

from scrapy.item import Item, Field


class FitnessItem(Item):
    muscle_id = Field()
    name = Field()
    difficulty_name = Field()
    training_points = Field()
    category_name = Field()
    muscle_name = Field()
    equipments = Field()
    description = Field()
    video = Field()
    gif = Field()
    muscle_pic = Field()
    muscle_front_img = Field()
    muscle_back_img = Field()
    other_muscles = Field()

一开始这看起来可能有点复杂,但是通过定义item,可以很方便的使用Scrapy的其他方法。而这些方法需要知道item的定义。

4.1.3 提取Item

从网页中提取数据有很多方法。Scrapy使用了一种基于XPathCSS 表达式机制。
这里给出XPath表达式的例子及对应的含义:

  • /html/head/title: 选择HTML文档中<head>标签内的<title> 元素
  • /html/head/title/text(): 选择上面提到的<title>元素的文字
  • //td: 选择所有的 <td> 元素
  • //div[@class="mine"]: 选择所有具有 class="mine" 属性的 div 元素

上边仅仅是几个简单的XPath例子,XPath实际上要比这远远强大的多。具体请参考XPath 教程

为了配合XPath,Scrapy除了提供了 Selector 之外,还提供了方法来避免每次从response中提取数据时生成selector的麻烦。

Selector有四个基本的方法:

  • xpath(): 传入xpath表达式,返回该表达式所对应的所有节点的selector list列表 。
  • css(): 传入CSS表达式,返回该表达式所对应的所有节点的selector list列表.
  • extract(): 序列化该节点为unicode字符串并返回list。
  • re(): 根据传入的正则表达式对数据进行提取,返回unicode字符串list列表。

下面就通过XPath提取Hi运动网站提取数据:

  • 提取视频页面数据:text = response.xpath('//script').extract()[-1]
  • 提取动作库每页的链接:
for href in response.xpath('//div[@class="cont"]/a[@target="_blank"]/@href').extract():
    url = response.urljoin(href)
  • 提取下一页的链接:next_page_url = response.xpath('//a[@rel="next"]/@href').extract_first()

4.1.4 编写爬虫

Spider是用户编写用于从单个网站(或者一些网站)爬取数据的类。其包含了一个用于下载的初始URL,如何跟进网页中的链接以及如何分析页面中的内容, 提取生成 item 的方法。
为了创建一个Spider,必须继承 scrapy.Spider 类,且定义以下三个属性:

  • name: 用于区别Spider。 该名字必须是唯一的,您不可以为不同的Spider设定相同的名字。
  • start_urls: 包含了Spider在启动时进行爬取的url列表。 因此,第一个被获取到的页面将是其中之一。 后续的URL则从初始的URL获取到的数据中提取。
  • parse() 是spider的一个方法。 被调用时,每个初始URL完成下载后生成的 Response 对象将会作为唯一的参数传递给该函数。 该方法负责解析返回的数据(response data),提取数据(生成item)以及生成需要进一步处理的URL的 Request 对象。

以下为我们的第一个Spider代码,保存在fitness/spiders 目录下的 jirou.py 文件中:

import json

import scrapy

from fitness.items import FitnessItem


class DmozSpider(scrapy.Spider):
    name = "fitness"
    allowed_domains = ["hiyd.com"]
    start_urls = [
        "http://www.hiyd.com/dongzuo"
    ]

    def parse(self, response):
        for href in response.xpath('//div[@class="cont"]/a[@target="_blank"]/@href').extract():
            url = response.urljoin(href)
            yield scrapy.Request(url, callback=self.parse_info)
        next_page_url = response.xpath('//a[@rel="next"]/@href').extract_first()
        if next_page_url is not None and next_page_url != "/dongzuo/?page=2":
            yield scrapy.Request(response.urljoin(next_page_url))

    def parse_info(self, response):
        item = FitnessItem()
        text = response.xpath('//script').extract()[-1]
        data_text = text.split("e.init(")[1].split(");")[0]
        json_text = json.loads(data_text)
        other_muscle = json_text["otherMuscles"]
        temp = []
        if len(other_muscle) != 0:
            for x in other_muscle:
                temp.append(self.change_muscle_id(x["muscle_id"]))

        item['name'] = json_text["name"]
        item['difficulty_name'] = json_text["difficulty_name"]
        item['training_points'] = json_text["trainingPoints"]
        item['category_name'] = json_text["category_name"]
        item['muscle_name'] = json_text["muscle_name"]
        item['muscle_id'] = self.change_muscle_id(json_text["muscle_id"])
        item['other_muscles'] = ",".join(temp)
        item['equipments'] = ("徒手训练" if json_text["equipments"][0] is None else json_text["equipments"][0]["name"])
        item['description'] = json_text["description"]
        item['video'] = json_text["video_url"]
        item['gif'] = json_text["gif"]
        item['muscle_pic'] = json_text["muscle_pic"]
        item['muscle_front_img'] = json_text["muscle_front_img"]
        item['muscle_back_img'] = json_text["muscle_back_img"]
        item['file_urls'] = [json_text["video_url"]]
        yield item

    def change_muscle_id(self, muscle_id):
        """
        网站数据中,有些肌肉的id有错误,此方法是把错误的肌肉id纠正。
        :param muscle_id:
        :return:
        """
        unknown = ["27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37"]
        known = ["9", "21", "12", "19", "10", "16", "23", "20", "20", "16", "16"]
        if muscle_id in unknown:
            muscle_id = known[unknown.index(muscle_id)]
        return muscle_id

可以看到parse方法对每一页动作库的链接进行了递归处理,并且提取了下一页的链接,yield返回了一个函数生成器,方便Scrapy框架内部进行迭代处理。然后调用parse_info方法解析每个动作的详细信息。

Item 对象是自定义的python字典。可以使用标准的字典语法来获取到其每个字段的值。(字段即是我们之前用Field赋值的属性),一般来说,Spider将会将爬取到的数据以 Item 对象返回。

4.1.5 保存爬取到的数据

采用 JSON 格式对爬取的数据进行序列化,生成 items.json 文件。编写fitness/pipelines.py,将之前返回的item数据输入到管道中,进行保存。


from scrapy.pipelines.files import FilesPipeline
from urllib.parse import urlparse
from os.path import basename, dirname, join
from scrapy.conf import settings


class FitnessPipeline(object):

    def __init__(self):
        self.file = open('jirou.json', 'w', encoding='utf-8')

    def process_item(self, item, spider):
        line = json.dumps(dict(item), ensure_ascii=False) + '\n'
        self.file.write(line)
        return item

    def spider_closed(self, spider):
        self.file.close()

可以看到,重写了process_item方法,将传入的参数item进行json格式转化,并且写入到文件中。重写spider_closed方法关闭文件流。

之后将管道配置到settings.py并且写入到文件中:

BOT_NAME = 'fitness'
BOT_VERSION = '1.0'

SPIDER_MODULES = ['fitness.spiders']
NEWSPIDER_MODULE = 'fitness.spiders'
#USER_AGENT = '%s/%s' % (BOT_NAME, BOT_VERSION)
USER_AGENT = 'User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 ' \
             '(KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36'
ITEM_PIPELINES = {
    'fitness.pipelines.FitnessPipeline': 300
}

4.1.6 开始爬取

执行命令scrapy crawl fitness, 可以看到生成了包含数据的json文件。

更多Scrapy框架功能,请见Scrapy文档

4.2 分布式爬虫

所谓的分布式爬虫,就是多台机器合作进行爬虫工作,提高工作效率。
分布式爬虫需要考虑的问题有:

  • 如何从一个统一的接口获取待抓取的URL?
  • 如何保证多台机器之间的排重操作?即保证不会出现多台机器同时抓取同一个URL。

Redis数据库是一种key-value数据库,所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行,单个操作是原子性的。而且Redis中自带的“消息队列”,方便来解决分布式爬虫的任务分配问题。

架构设计如下:

4-1.png

在Master端跑一个程序去生成所有任务(Request/url/ID)。Master端负责的是生产任务,并把任务去重、加入到待爬队列。Slaver只管从Master端拿任务去爬。
其中:

  • Master:跑一个程序去对应的网站上生成所有任务(Request/url/ID),并且把任务存入Redis数据库中,形成一个任务队列,同时去重任务。对应到Hi运动爬虫程序来说,相当于在一台机器中,将动作库页面的所有80页数据提取出来所有动作库视频的链接,并构造一个任务队列。
  • Slaver:对应各个具体爬虫工作的机器,只管从Master端拿任务去爬,并且将爬取的数据保存到一个数据库中。对应到Hi运动爬虫程序来说,相当于从Redis任务队列中取出一条视频页面链接进行详细信息的爬取,并存入数据库中。

具体实现比较复杂,感兴趣的可以通过Scrapy+Redis的方式进一步实现。

4.3 反爬虫策略

对于大型网站来说,如果有很多爬虫应用在短时间内大规模爬取的话,会对这个网站系统的运维造成很大的压力。而且有些网站的信息是不希望被他人爬取的,所以各个网站都会有自己的一套反爬策略。常见的反爬策略有:

  • 封IP:当某个IP地址短时间内大量访问,会被封禁IP地址。解决办法是通过使用大量的代理IP地址,或者将爬取的间隔时间进行随机数调整。
  • 验证码:提示用验证码操作。解决办法,通过获取所有验此网站证码信息,进行神经网络的分析,或者使用第三方打码平台。
  • Ajax动态请求:工作原理是从网页的 url 加载网页的源代码之后,会在浏览器里执行JavaScript程序。这些程序会加载出更多的内容,并把这些内容传输到网页中。解决办法,用Python模仿JavaScript中的逻辑获得原始的数据,或者使用PhantomJS+Selenium来模拟浏览器加载js。

爬虫与反爬虫是一个斗智斗勇的过程,每个网站的反爬策略都会有所不同,需要对其具体情况具体分析。

4.4 模拟登录

有些网站会强制登录才能查看一下信息,此时就需要爬虫程序去模拟用户登录。下面我们尝试模拟登录百度贴吧获取当前用户关注的贴吧列表。

首先打开百度贴吧,然后输入用户名和密码登录。然后打开浏览器的开发者模式F12,转到network选项卡。在左边的Name一栏找到当前的网址,选择右边的Headers选项卡,查看Request Headers,这里包含了该网站颁发给浏览器的cookie。最好运行你的程序前再登录。如果太早登录,或是把浏览器关了,很可能复制的那个cookie就过期无效了
cookie是保存在发起请求的客户端中,服务器利用cookie来区分不同的客户端。因为http是一种无状态的连接,当服务器一下子收到好几个请求时,是无法判断出哪些请求是同一个客户端发起的。而“访问登录后才能看到的页面”这一行为,恰恰需要客户端向服务器证明:“我是刚才登录过的那个客户端”。于是就需要cookie来标识客户端的身份,以存储它的信息(如登录状态)。当然,这也意味着,只要得到了别的客户端的cookie,我们就可以假冒成它来和服务器对话。我们先用浏览器登录,然后使用开发者工具查看cookie。接着在程序中携带该cookie向网站发送请求,就能让你的程序假扮成刚才登录的那个浏览器,得到只有登录后才能看到的页面。

4-4-1.png

然后我们在源代码中找到进入“我的贴吧”链接,<a class="media_left" style="" href="/home/main?un=fobkbmdo&fr=index" target="_blank">,里面的href属性是跳转链接,fobkbmdo是我的贴吧用户名。然后分析一下在“我的贴吧”页面的源码,

</span>爱逛的吧</h1><div class="clearfix u-f-wrap" id="forum_group_wrap">         <a data-fid="59099" target="_blank" locate="like_forums#ihome_v1" href="/f?kw=%E6%9D%8E%E6%AF%85&fr=home"         class="u-f-item unsign"><span>李毅</span><span class="forum_level lv2"></span></a>         <a data-fid="407248" target="_blank" locate="like_forums#ihome_v1" href="/f?kw=%E5%8D%95%E6%9C%BA%E6%B8%B8%E6%88%8F&fr=home"         class="u-f-item unsign"><span>单机游戏</span><span class="forum_level lv1"></span></a>         <a data-fid="113893" target="_blank" locate="like_forums#ihome_v1" href="/f?kw=%E6%98%BE%E5%8D%A1&fr=home"         class="u-f-item unsign"><span>显卡</span><span class="forum_level lv1"></span></a>         <a data-fid="543521" target="_blank" locate="like_forums#ihome_v1" href="/f?kw=%E6%8E%A8%E7%90%86&fr=home"         class="u-f-item unsign"><span>推理</span><span class="forum_level lv1"></span></a>         <a data-fid="825" target="_blank" locate="like_forums#ihome_v1" href="/f?kw=%E8%80%83%E7%A0%94&fr=home"         class="u-f-item unsign"><span>考研</span>

可以根据此提取出相关的信息,实现代码如下:

import requests
from bs4 import BeautifulSoup

url = 'http://tieba.baidu.com/home/main?un=fobkbmdo&fr=index'

cookie_str = '填充自己的cookie数据'
cookies = {}
for line in cookie_str.split(';'):
    key, value = line.split('=', 1)
    cookies[key] = value

headers = {
    'User-agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36'}

resp = requests.get(url, headers=headers, cookies=cookies)
soup = BeautifulSoup(str(resp.content.decode('utf-8')), "html.parser")
for x in soup.find_all("a", class_="u-f-item unsign"):
    print(x.span.text)

4.5 模拟请求

下面介绍通过爬虫模拟网络请求,以百度贴吧自动发帖为例。

首先选择进入一个贴吧,选择一个帖子,然后打开F12开发者模式,转到network选项卡。手动发表回复,查看network变化:

4-5-1.png

发现是多了一个add请求,点击查看发现具体的联网请求如下:

Request URL: https://tieba.baidu.com/f/commit/post/add
Request Method: POST
Status Code: 200 OK
Remote Address: 127.0.0.1:50578
Referrer Policy: no-referrer-when-downgrade

可以发现“发表”功能其实是发送了https://tieba.baidu.com/f/commit/post/addPOST请求。接着查看一下请求的数据:

4-5-2.png

上面这些数据就是我们需要用代码模拟的数据,其中我们只需要关心修改下面的数据即可:

  • kw:贴吧名称
  • fid:帖子id
  • tid:贴吧id
  • content:发表内容
  • floor_num:帖子层数

下面我们使用代码来模拟发表的功能:

import requests
from bs4 import BeautifulSoup
from selenium import webdriver

url_send = 'https://tieba.baidu.com/f/commit/post/add'

cookie_str = '输入你的登录cookie'
cookies = {}
for line in cookie_str.split(';'):
    key, value = line.split('=', 1)
    cookies[key] = value

headers = {
    'User-agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36'}


def send_posts():
    data = str2dic('ie=utf-8&kw=%E5%8D%95%E6%9C%BA%E6%B8%B8%E6%88%8F&fid=407248&tid=5649760137&vcode_md5=&floor_num=23&rich_text=1&tbs=f0f598aecd004a1c1524122217&content=%5Bbr%5D%E9%AD%94%E5%85%BD%E4%BA%89%E9%9C%B8&basilisk=1&files=%5B%5D&mouse_pwd=99%2C99%2C97%2C121%2C98%2C103%2C96%2C98%2C92%2C100%2C121%2C101%2C121%2C100%2C121%2C101%2C121%2C100%2C121%2C101%2C121%2C100%2C121%2C101%2C121%2C100%2C121%2C101%2C92%2C103%2C102%2C99%2C109%2C98%2C92%2C100%2C108%2C103%2C101%2C121%2C100%2C101%2C109%2C101%2C15241222546850&mouse_pwd_t=1524122254685&mouse_pwd_isclick=0&__type__=reply&_BSK=JVwSUGcLBF83AFZzQztEElBFCwIWaVcbHD5WBH8Vd2BpAHxNUnpmR1VVPDsXSAZLKBx9Dl42WSstG01BMAAGEWc4XCUIIBpRRVJrZ3oNIl5QKQhRNhUIBHEGfl4IaRMyIgVpfkRsWhklTQhRHBtRM3FLUlRrAhILbT57CywAWhAGDi4yemIQVFUiTgYGXTQ%2FPFV%2FWQZndFNGAG9iShwSWGl7PlIfIlFoa01WTXYHBgdnFAF9W2xHAF5XaXU0FFcNHn9XF3IFc2FgHHIYAGt%2BXRNSMSQBAQgHegplFExgCXBuS1BSaRNUGmdPEXpWDlMCWBMnJH8XRxINDEIUd1N2NmQJaA5WKiBNRQc8ZgccH1t9GW0GTGEPYmxMRFQBEwgJIEQRZUF%2BRgFdV3BmawlXWQxvXQYxRzM1fRIxXhFzZExMAW17Rl0ZS3MIOVUSI11rfAlVQX8TVEQ2AX46Ej8XVw9LJzsvV1lRUS4SVWlWKj8iVXwJQSgpGAYfLjIISwYeIEY7Wwl8SCYsGw8XaV5UTisQQXMVIwYcBgIrMC5NWVRSIhRDIRkqPzJRJAZcJ2gZGlAoOgFDXkUmWjZTFz4UKT8TBE8tWFdfKgdKcw0jFVEeDio5OEQHG1MoCVMnVDR8IVUiHFwnJREXUi97F05YBiVEPVUMIxprfA5TQX8Tc0IrRgF9TW4CAUhdZzEvSxZDVyIJBjFaFSQjWT4IG2BkBlVoMzYQRFwMaUswUBsNGDp8UkMKdBMeCzEHRjpNbgUCSF1lZmMXRRscIFYEfxckMSJZPAZAIhscOUVtPQMPBks9Gn0OXmENdWpPU1F3BhwHZxkBfVtsAkIfAml1PhRXDRwDMmoJF2pyPQFyVREzLFA2fX97Rl8bS3MKOUEQM0wuMRBBESRfQEQoXRp%2FGmwtXgsTLCE%2FBRZYWig6BjgXanImAXJVEQcRMTkRcXUHHAhTaVwtQRst')
    data['kw'] = '单机游戏'
    # 当前层数 - 1
    data['floor_num'] = 23
    data['content'] = '星际'
    data['title'] = '单机游戏'
    resp = requests.post(url_send, headers=headers, cookies=cookies, data=data)
    print(resp.text)


def str2dic(text):
    idic = {}
    ilist = text.split('&')
    for item in ilist:
        name, value = item.split('=', 1)
        idic[name] = value
    return idic

send_posts()

当输出的内容为{"no":0,"err_code":0,"error":null,"data":{"autoMsg":"","fid":407248,"fname":"\u5355\u673a\u6e38\u620f","tid":5649760137,"is_login":1,"content":"\u661f\u9645","access_state":null,"vcode":{"need_vcode":0,"str_reason":"","captcha_vcode_str":"","captcha_code_type":0,"userstatevcode":0},"is_post_visible":0}}时,证明已经发表成功。

4-5-3.png

除了模拟发送请求外,还有另外一种方式:使用无头浏览器访问
在Python中可以使用Selenium库来调用浏览器,写在代码里的操作(打开网页、点击……)会变成浏览器忠实地执行。这个被控制的浏览器可以是Firefox,Chrome等,但最常用的还是PhantomJS这个无头(没有界面)浏览器。也就是说,通过模拟浏览器的点击、提交等操作来实现模拟人工处理。

安装selenium库:pip install seleniumPhantomJS浏览器下载

下面我们以百度贴吧的签到来体验一下。首先需要找到“签到”的标签:

4-5-4.png

然后右键,Copy, Copy XPath,获取到控件的XPath路径。

from selenium import webdriver

url_sign = 'https://tieba.baidu.com/f?kw=%E6%8E%A8%E7%90%86&fr=home'

cookie_str = '输入你的登录cookie'
cookies = {}
for line in cookie_str.split(';'):
    key, value = line.split('=', 1)
    cookies[key] = value

headers = {
    'User-agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36'}


def sign():
    browser = webdriver.PhantomJS('D:/phantomjs-2.1.1-windows/bin/phantomjs.exe')
    for key in cookies:
        c = {}
        c['name'] = key
        c['value'] = cookies[key]
        c['domain'] = '.baidu.com'
        c['path'] = '/'
        c['httponly'] = False
        c['secure'] = False
        browser.add_cookie(c)

    browser.get(url_sign)
    browser.implicitly_wait(3)

    sign_btn = browser.find_elements_by_xpath('//*[@id="signstar_wrapper"]/a')[0]
    sign_btn.click()
    sign_btn.click()

    print(browser.page_source.encode('utf-8').decode())

    browser.quit()

PhantomJS的cookie需要单独进行配置,填充一些必要的参数。接着就获取了“签到”的标签,并开始点击(不知道为什么,代码签到必须点击两下),然后退出浏览器。
执行完成后,刷新查看是否已经签到

4-5-5.png

5. 爬虫应用畅想

经过以上的介绍,可能已经对爬虫有所了解了。对于爬虫来说,其核心是数据源的获取,如果没有数据源就没有爬虫存在的意义。未来的生活可能离不开各种各样的数据,我们每天从不同的渠道获取数据,也会在各种场景中生产数据,对于数据的收集和分析可以帮助我们更好地行业规划和了解行业规律。所以,数据源的选取会给爬虫的应用插上想象的翅膀。下面我们就脑洞大开,想象一下应用爬虫技术会给我们提供什么样的帮助。

  • 汽车之家 + 爬虫:通过爬取各个车型的用户讨论内容,加上人工智能分析,对各个车型的购买人群做特性分析。总结出的归纳数据可供4s销售可以根据购买者的特征更加精准地推销适合的车型。
  • 美团/大众点评 + 爬虫:爬取每个城市美食口味的占比,最近开店较多的美食类型,结合GIS技术分析某一地段消费类型占比等。这些数据可以帮助饮食从业者更好地规划开店类型和选址
  • 驴妈妈/马蜂窝 + 爬虫:对每个城市的景点关键字进行爬取,可以包括最佳出行/观赏时间、路线选择、住宿和饮食安排等。可以对旅游业从业者或者游客更好地服务。

推荐阅读更多精彩内容