美文网首页爬虫
聚焦Python分布式爬虫必学框架 Scrapy 打造搜索引擎

聚焦Python分布式爬虫必学框架 Scrapy 打造搜索引擎

作者: 江湖十年 | 来源:发表于2018-07-07 14:03 被阅读52次

明确爬取网站

伯乐在线:http://www.jobbole.com/

搭建虚拟环境

豆瓣源:https://pypi.doubanio.com/simple/
可以通过如下命令安装 Python 库,指定国内豆瓣源(加上 -i 参数和豆瓣源网址)

pip install -i https://pypi.doubanio.com/simple/ scrapy

创建项目

  • 通过如下命令创建项目
scrapy startproject ArticleSpider
image.png
  • 用 PyCharm 打开项目
image.png
  • 创建爬虫(记得一定要先 cd 到 ArticleSpider 目录)
scrapy genspider jobbole blog.jobbole.com
image.png

注意:这地方创建爬虫时允许爬虫爬取的域设置为 blog.jobbole.com,后面爬取 Python 板块所有文章的时候改成了 jobbole.com,因为 Python 板块所有文章列表页 URL 是 http://python.jobbole.com/all-posts/,不然会被 Scrapy 过滤掉,不能爬取

  • 在 PyCharm 中查看新建的爬虫
image.png
  • 为了能在 PyCharm 中断点调试 Scrapy 爬虫项目,在 scrapy.cfg 同级目录(也就是项目根目录 ArticleSpider目录)下创建 main.py 文件用于启动 scrapy 项目
# main.py

import os
import sys

from scrapy.cmdline import execute


# 将当前项目目录追加到 Python 搜索模块路径
# os.path.abspath(__file__) 是当前文件
# os.path.dirname(os.path.abspath(__file__)) 是获取当前文件所在目录名
sys.path.append(os.path.dirname(os.path.abspath(__file__)))

# 执行命令启动爬虫
execute(["scrapy", "crawl", 'jobbole'])

image.png
  • settings.py 中关闭 robots 协议
image.png
  • 现在在爬虫文件 jobbole.py 中 parse 方法内打一个断点,Debug 运行 main.py 文件,看下 response 接收到了什么
image.png
  • 至此,项目创建以及初步配置已经完成

现在通过以下三个方面学习一下 XPath

image.png
  1. XPath 简介
image.png
  1. XPath 术语
image.png
  1. XPath 语法
image.png image.png image.png
在 Scrapy Shell 中测试 XPath
  • 随意找一篇文章详情页网址进行测试,命令行窗口运行 Scrapy Shell
scrapy shell http://blog.jobbole.com/114041/
image.png
  • 测试提取文章标题
image.png image.png
通过 XPath 提取项目中所有需要提取的文章内容
  • 文章标题
image.png
  • 文章发布日期
image.png
  • 文章点赞数、收藏数、评论数
image.png
  • 文章内容
image.png
  • 文章标签
image.png
  • XPath 提取数据完整代码
    def parse(self, response):
        title = response.xpath('//div[@class="entry-header"]/h1/text()').extract_first()
        create_date = response.xpath('//p[@class="entry-meta-hide-on-mobile"]/text()').extract_first().replace("·", "").strip()
        praise_nums = response.xpath('//span[contains(@chass, vote-post-up)]/h10/text()').extract_first()
        fav_nums = response.xpath('//div[@class="post-adds"]//span[2]/text()').extract_first()
        re_find = re.findall('.*?(\d+).*', fav_nums)
        if re_find:
            fav_nums = re_find[0]
        comment_nums = response.xpath('//div[@class="post-adds"]//a[@href="#article-comment"]/span/text()').extract_first()
        re_find = re.findall('.*?(\d+).*', comment_nums)
        if re_find:
            comment_nums = re_find[0]
        content = response.xpath('//div[@class="entry"]').extract_first()
        tag_list = response.xpath('//p[@class="entry-meta-hide-on-mobile"]/a/text()').extract()
        tag_list = [tag for tag in tag_list if not tag.strip().endswith('评论')]
        tags = ','.join(tag_list)

css 选择器

image.png image.png image.png
  • scrapy shell 下测试 css 选择器
image.png image.png
  • CSS 选择器提取数据完整代码
    def parse(self, response):
        # 通过 CSS 选择器提取字段信息
        title = response.css('div.entry-header > h1::text').extract_first()
        create_date = response.css('p.entry-meta-hide-on-mobile::text').extract_first().replace("·", "").strip()
        praise_nums = response.css('div.post-adds > span.vote-post-up > h10::text').extract_first()
        fav_nums = response.css('div.post-adds > span.bookmark-btn::text').extract_first()
        re_find = re.findall('.*?(\d+).*', fav_nums)
        if re_find:
            fav_nums = re_find[0]
        comment_nums = response.css('div.post-adds > a[href="#article-comment"] span::text').extract_first()
        re_find = re.findall('.*?(\d+).*', comment_nums)
        if re_find:
            comment_nums = re_find[0]
        content = response.css('div.entry').extract_first()
        tag_list = response.css('p.entry-meta-hide-on-mobile a::text').extract()
        tag_list = [tag for tag in tag_list if not tag.strip().endswith('评论')]
        tags = ','.join(tag_list)

编写爬虫文件 spiders/jobbole.py

分析如何进行所有文章爬取

这里以爬取 Python 分类下全部文章为例,进行爬取
URL:http://python.jobbole.com/all-posts/

image.png image.png
  • 文章列表页,共有 82 页,每一页共有 20 篇文章,如果有下一页则会有下一页按钮,所以爬虫要做的就是在每一个列表页获取 20 篇文章详情页链接,和下一页的链接
image.png
  • 爬虫代码如下

整体思路:从文章列表页第一页 http://python.jobbole.com/all-posts/ 开始爬,通过 parse 方法进行解析,parse 方法做两件事:①提取出 20 篇文章的详情页 URL 链接,依次交给 parse_detail 方法进行解析,提取字段数据;②提取下一页 URL 链接,再次交给 parse 方法进行解析

class JobboleSpider(scrapy.Spider):
    name = 'jobbole'
    allowed_domains = ['jobbole.com']
    start_urls = ['http://python.jobbole.com/all-posts/']  # http://blog.jobbole.com/114041/

    def parse(self, response):
        """
            1. 提取文章列表页中所有文章详情页链接,并交给 parse_detail 方法进行解析
            2. 提取下一页链接,并交给 Scrapy 进行下载
        Args:
            response: 响应信息
        Yields:
            1. 文章详情页链接,交给 parse_detail 解析
            2. 下一页链接,交给 Scrapy 下载
        """
        post_nodes = response.xpath('//div[@id="archive"]')
        for post_node in post_nodes:
            post_url = post_node.xpath('.//div[@class="post-meta"]//a[@class="archive-title"]/@href').extract_first('')
            front_img_url = post_node.xpath('.//div[@class="post-thumb"]//img/@src').extract_first('')
            yield scrapy.Request(url=urljoin(response.url, post_url), callback=self.parse_detail,
                                 meta={'front_img_url': front_img_url})
        next_url = response.xpath('//a[@class="next page-numbers"]/@href').extract_first()
        if next_url:
            yield scrapy.Request(url=next_url, callback=self.parse)

    def parse_detail(self, response):
        """
            1. 解析文章详情页页面信息
            2. 将解析出来的数据交给 pipelines 文件进行处理
        Args:
            response: 响应信息
        Yields:
            item
        """
        # 通过 XPath 提取字段信息
        title = response.xpath('//div[@class="entry-header"]/h1/text()').extract_first()
        create_date = response.xpath('//p[@class="entry-meta-hide-on-mobile"]/text()').extract_first().replace("·", "").strip()
        praise_nums = response.xpath('//span[contains(@chass, vote-post-up)]/h10/text()').extract_first()
        fav_nums = response.xpath('//div[@class="post-adds"]//span[2]/text()').extract_first()
        re_find = re.findall('.*?(\d+).*', fav_nums)
        if re_find:
            fav_nums = int(re_find[0])
        else:
            fav_nums = 0
        comment_nums = response.xpath('//div[@class="post-adds"]//a[@href="#article-comment"]/span/text()').extract_first()
        re_find = re.findall('.*?(\d+).*', comment_nums)
        if re_find:
            comment_nums = int(re_find[0])
        else:
            comment_nums = 0
        content = response.xpath('//div[@class="entry"]').extract_first()
        tag_list = response.xpath('//p[@class="entry-meta-hide-on-mobile"]/a/text()').extract()
        tag_list = [tag for tag in tag_list if not tag.strip().endswith('评论')]
        tags = ','.join(tag_list)

将爬取数据保存到 JSON 文件

  • 先列出完整的 jobbole.py 代码
# ArticleSpider/spiders/jobbole.py

# -*- coding: utf-8 -*-
import re
from urllib.parse import urljoin

import scrapy
from ArticleSpider.items import JobBoleArticleItem

from ArticleSpider.utils.common import get_md5


class JobboleSpider(scrapy.Spider):
    name = 'jobbole'
    allowed_domains = ['jobbole.com']
    start_urls = ['http://python.jobbole.com/all-posts/']  # http://blog.jobbole.com/114041/

    def parse(self, response):
        """
            1. 提取文章列表页中所有文章详情页链接,并交给 parse_detail 方法进行解析
            2. 提取下一页链接,并交给 Scrapy 进行下载
        Args:
            response: 响应信息
        Yields:
            1. 文章详情页链接,交给 parse_detail 解析
            2. 下一页链接,交给 Scrapy 下载
        """
        post_nodes = response.xpath('//div[@id="archive"]')
        for post_node in post_nodes:
            post_url = post_node.xpath('.//div[@class="post-meta"]//a[@class="archive-title"]/@href').extract_first('')
            front_img_url = post_node.xpath('.//div[@class="post-thumb"]//img/@src').extract_first('')
            yield scrapy.Request(url=urljoin(response.url, post_url), callback=self.parse_detail,
                                 meta={'front_img_url': front_img_url})
        next_url = response.xpath('//a[@class="next page-numbers"]/@href').extract_first()
        if next_url:
            yield scrapy.Request(url=next_url, callback=self.parse)

    def parse_detail(self, response):
        """
            1. 解析文章详情页页面信息
            2. 将解析出来的数据交给 pipelines 文件进行处理
        Args:
            response: 响应信息
        Yields:
            item
        """
        item = JobBoleArticleItem()

        # # 通过 XPath 提取字段信息
        # title = response.xpath('//div[@class="entry-header"]/h1/text()').extract_first()
        # create_date = response.xpath('//p[@class="entry-meta-hide-on-mobile"]/text()').extract_first().replace("·", "").strip()
        # praise_nums = response.xpath('//span[contains(@chass, vote-post-up)]/h10/text()').extract_first()
        # fav_nums = response.xpath('//div[@class="post-adds"]//span[2]/text()').extract_first()
        # re_find = re.findall('.*?(\d+).*', fav_nums)
        # if re_find:
        #     fav_nums = int(re_find[0])
        # else:
        #     fav_nums = 0
        # comment_nums = response.xpath('//div[@class="post-adds"]//a[@href="#article-comment"]/span/text()').extract_first()
        # re_find = re.findall('.*?(\d+).*', comment_nums)
        # if re_find:
        #     comment_nums = int(re_find[0])
        # else:
        #     comment_nums = 0
        # content = response.xpath('//div[@class="entry"]').extract_first()
        # tag_list = response.xpath('//p[@class="entry-meta-hide-on-mobile"]/a/text()').extract()
        # tag_list = [tag for tag in tag_list if not tag.strip().endswith('评论')]
        # tags = ','.join(tag_list)

        # 通过 CSS 选择器提取字段信息
        title = response.css('div.entry-header > h1::text').extract_first()
        create_date = response.css('p.entry-meta-hide-on-mobile::text').extract_first().replace("·", "").strip()
        praise_nums = response.css('div.post-adds > span.vote-post-up > h10::text').extract_first()
        fav_nums = response.css('div.post-adds > span.bookmark-btn::text').extract_first()
        re_find = re.findall('.*?(\d+).*', fav_nums)
        if re_find:
            fav_nums = int(re_find[0])
        else:
            fav_nums = 0
        comment_nums = response.css('div.post-adds > a[href="#article-comment"] span::text').extract_first()
        re_find = re.findall('.*?(\d+).*', comment_nums)
        if re_find:
            comment_nums = int(re_find[0])
        else:
            comment_nums = 0
        content = response.css('div.entry').extract_first()
        tag_list = response.css('p.entry-meta-hide-on-mobile a::text').extract()
        tag_list = [tag for tag in tag_list if not tag.strip().endswith('评论')]
        tags = ','.join(tag_list)

        item['title'] = title
        item['create_date'] = create_date
        item['url'] = response.url
        item['url_object_id'] = get_md5(response.url)
        item['front_img_url'] = [response.meta.get('front_img_url')]
        item['praise_nums'] = praise_nums
        item['comment_nums'] = comment_nums
        item['fav_nums'] = fav_nums
        item['tags'] = tags
        item['content'] = content

        yield item

定义 ITEM
# ArticleSpider/items.py
import scrapy

class JobBoleArticleItem(scrapy.Item):
    title = scrapy.Field()  # 文章标题
    create_date = scrapy.Field()  # 文章发布日期
    url = scrapy.Field()  # 文章 URL
    url_object_id = scrapy.Field()  # 文章 URL 的 MD5 值
    front_img_url = scrapy.Field()  # 文章封面图(文章列表页显示的封面图,通常是文章详情第一张图片)
    front_img_path = scrapy.Field()  # 文章封面图存放路径
    praise_nums = scrapy.Field()  # 点赞数
    comment_nums = scrapy.Field()  # 评论数
    fav_nums = scrapy.Field()  # 收藏数
    tags = scrapy.Field()  # 文章标签
    content = scrapy.Field()  # 文章内容
处理 url_object_id 字段

url_object_id 字段用来存储文章 URL 的 MD5 值,所以要单独写一个函数用来获取 URL 的 MD5 值

  • 在 ArticleSpider/ 目录下新建 utils/ 目录(Python Package),在 utils/ 目录下新建 common.py 文件,在 common.py 中写获取 URL 的 MD5 值的函数
import hashlib


def get_md5(url):
    if isinstance(url, str):
        url = url.encode('utf-8')
    md5 = hashlib.md5()
    md5.update(url)
    return md5.hexdigest()


if __name__ == '__main__':
    md5 = get_md5('http://python.jobbole.com/89187/')
    print(md5)
image.png
  • 在 spiders/jobbole.py 中只需要引入 get_md5 函数,并通过 get_md5 获取 URL 的 MD5 值即可
from ArticleSpider.utils.common import get_md5

item['url_object_id'] = get_md5(response.url)
处理 front_img_url 和 front_img_path 字段

这两个字段分别用来存储文章列表页的文章封面图 URL,以及图片存储到本地后的地址

  • 处理 front_img_url

上面爬虫中文件中 parse 方法中 meta={'front_img_url': front_img_url} 信息就是用来传递 封面图 URL 的,因为 front_img_url 图片在文章列表页,而不是在文章详情页,而 parse 方法就是解析文章列表页的数据,所以先在 parse 方法中得到 front_img_url,然后通过Scrapy 解析方法间传递数据的办法,即通过在scrapy.Request 中加入 meta 将数据传递给 callback 方法 parse_detail,在 parse_detail 方法中接收 front_img_url 即可

image.png

在 parse_detail 方法中接收 front_img_url

item['front_img_url'] = response.meta.get('front_img_url')
处理 front_img_path 字段

处理 front_img_path 字段分两步,①下载图片,②将图片路径信息存储到这个字段
利用 Scrapy 自带的图片处理管道文件即可下载图片

下载图片到本地
①在 ArticleSpider/ 目录下新建 images/ 目录用于存储图片

image.png

②配置 settings.py 文件

# ArticleSpider/settings.py

ITEM_PIPELINES = {
    'ArticleSpider.pipelines.ArticlespiderPipeline': 300,
    'scrapy.pipelines.images.ImagesPipeline': 100,  # 添加图片处理 pipeline
}

# 获取项目目录
project_dir = os.path.dirname(os.path.abspath(__file__))
# 指定需要下载的图片字段,这个字段必须是可迭代对象,这里 front_img_url 就是图片字段名
IMAGES_URLS_FIELD = 'front_img_url'
# 指定图片存储路径,这里将项目目录与 images 目录进行拼接,作为图片存储路径
IMAGES_STORE = os.path.join(project_dir, 'images')

③将 front_img_url 字段的值改成列表,注意,要想使用 Scrapy 自动下载图片,在配置好 settings.py 文件后,需要下载的字段的值的类型一定要是可迭代的才可以,这里设为列表,即使只有一个图片 URL 链接,也要设置为列表,不然 Scrapy 下载图片的时候会报错

item['front_img_url'] = [response.meta.get('front_img_url')]

④安装 Pillow 库(Scrapy 下载图片依赖这个库)

pip install -i https://pypi.doubanio.com/simple/ Pillow

⑤运行项目已经可以下载图片了,图片被存储到 ArticleSpider/images/full 目录下,full/ 目录是 Scrapy 自动帮我们生成的,并且图片都是以 MD5 值来命名的

image.png

提取下载后图片本地路径

  • 查看 scrapy.pipelines.images.ImagesPipeline 源码,发现图片路径可以在 item_completed 函数的 results 变量中获得

    image.png
  • 自定义管道文件处理类 ArticleImagePipeline 继承自 scrapy.pipelines.images.ImagesPipeline,用来处理图片下载,并重写父类的 item_completed 方法,以获取图片路径,并保存到字段 front_img_path 中
    ①定义 ArticleImagePipeline

# ArticleSpider/pipelines.py

from scrapy.pipelines.images import ImagesPipeline

class ArticleImagePipeline(ImagesPipeline):
    def item_completed(self, results, item, info):
        for ok, v in results:
            image_file_path = v['path']
            item['front_img_path'] = image_file_path
        return item
image.png

②配置 settings.py 文件,注释掉 scrapy.pipelines.images.ImagesPipeline,添加自定义管道文件 ArticleSpider.pipelines.ArticleImagePipeline

# ArticleSpider/settings.py

ITEM_PIPELINES = {
    'ArticleSpider.pipelines.ArticlespiderPipeline': 300,
    # 'scrapy.pipelines.images.ImagesPipeline': 100,  # 添加图片处理 pipeline
    'ArticleSpider.pipelines.ArticleImagePipeline': 100,
}

③通过断点调试发现图片路径已经存储进来了,因为 ArticleSpider.pipelines.ArticleImagePipeline 执行顺序值设定的是 100,ArticleSpider.pipelines.ArticlespiderPipeline 执行顺序值设定的是 300,所以程序运行到这里的时候 item['front_img_path'] 已经被成功赋值

image.png
所有字段信息全部可以正确提取,现在就可以将数据存储到 JSON 文件了
  • 还是通过管道文件还处理,自定义 JsonWithEncodingPipeline 类,用来将数据存储到 JSON 文件
    ①pipelines.py 完整代码
# ArticleSpider/pipelines.py

import json

from scrapy.pipelines.images import ImagesPipeline


class ArticlespiderPipeline(object):
    def process_item(self, item, spider):
        return item


class ArticleImagePipeline(ImagesPipeline):
    def item_completed(self, results, item, info):
        for ok, v in results:
            image_file_path = v['path']
            item['front_img_path'] = image_file_path
        return item


class JsonWithEncodingPipeline(object):
    def __init__(self):
        self.file = open('article.json', 'a', encoding='utf-8')

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

    def close_spider(self):
        self.file.close()

settings.py 配置

# ArticleSpider/settings.py

ITEM_PIPELINES = {
    'ArticleSpider.pipelines.ArticlespiderPipeline': 300,
    # 'scrapy.pipelines.images.ImagesPipeline': 100,  # 添加图片处理 pipeline
    'ArticleSpider.pipelines.ArticleImagePipeline': 100,
    'ArticleSpider.pipelines.JsonWithEncodingPipeline': 200,
}
  • 现在运行爬虫,图片下载成功,数据存入 article.json 文件
image.png image.png image.png

将数据存储到 MySQL

数据库设计
image.png image.png

praise_nums / comment_nums / fav_nums 3 个字段设置默认值为 0,url_object_id 字段设为主键,content 字段为 longtext 类型

存储数据到 MySQL
  • 需要修改 jobbole.py 中的 create_date 字段,通过 CSS 选择器提取出来的时字符串,数据库表结构里面这个字段设计的时 datetime 类型,所以要将 create_date 字段转换成 datetime 类型
# ArticleSpider/spiders/jobbole.py

        create_date = response.css('p.entry-meta-hide-on-mobile::text').extract_first().replace("·", "").strip()
        try:
            create_date = datetime.strptime(create_date, '%Y/%m/%d')
        except Exception as e:
            create_date = datetime.now().date()
  • 定义管道文件 MySQLPipeline 用来将数据存储到 MySQL
# ArticleSpider/pipelines.py

import pymysql

class MySQLPipeline(object):
    def __init__(self):
        self.conn = pymysql.connect('127.0.0.1', 'pythonic', 'pythonic', 'Articles', charset='utf8')
        self.cursor = self.conn.cursor()

    def process_item(self, item, spider):
        insert_sql = '''
                    insert into jobbole_article(title,create_date,url,url_object_id,front_image_url,
                                                front_image_path,praise_nums,comment_nums,fav_nums,tags,content)
                    values(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
                '''
        self.cursor.execute(insert_sql, (
            item['title'], item['create_date'], item['url'], item['url_object_id'],
            item['front_img_url'], item['front_img_url'], item['praise_nums'],
            item['comment_nums'], item['fav_nums'], item['tags'], item['content']
        ))
        self.conn.commit()

    def close_spider(self):
        self.cursor.close()
        self.conn.close()
  • 配置 settings.py 文件

这里注释掉存储 JSON 文件的管道 JsonWithEncodingPipeline,如果不注释掉是会报错的,因为现在 create_date 字段是 datetime 类型,而不再是 字符串,是不能转换成 JSON 的

# ArticleSpider/settings.py

ITEM_PIPELINES = {
    'ArticleSpider.pipelines.ArticlespiderPipeline': 300,
    # 'scrapy.pipelines.images.ImagesPipeline': 100,  # 添加图片处理 pipeline
    'ArticleSpider.pipelines.ArticleImagePipeline': 100,
    # 'ArticleSpider.pipelines.JsonWithEncodingPipeline': 200,
    'ArticleSpider.pipelines.MySQLPipeline': 200,

}
  • 运行爬虫,数据可以成功存入 MySQL
image.png image.png
利用 Twisted 将数据异步插入到 MySQL

为什么要使用 Twisted?因为爬虫爬取页面信息的速度远大于数据库存储的速度,而仅仅使用 connect / cursor 进行数据库的写入操作,是同步的操作做,数据没有 commit 写入数据库之前,后面代码就不会执行,阻塞状态,直到一次数据全部成功写入数据库,才会继续执行代码,所以出现爬虫等待数据写入的过程,拖累效率
Twisted 是一个异步框架,使用 Twisted 就可以将数据异步插入数据库

  • 定义管道文件 MySQLTwistedPipeline 用来将数据 异步 存储到 MySQL
# ArticleSpider/pipelines.py

import pymysql
from twisted.enterprise import adbapi

class MySQLTwistedPipeline(object):

    def __init__(self, dbpool):
        self.dbpool = dbpool

    # Scrapy 提供了一个类方法可以直接获取 settings.py 文件中的配置信息
    @classmethod
    def from_settings(cls, settings):
        db_params = dict(
            host=settings['MYSQL_HOST'],
            user=settings['MYSQL_USER'],
            password=settings['MYSQL_PASSWORD'],
            database=settings['MYSQL_DATABASE'],
            port=settings['MYSQL_PORT'],
            charset='utf8',
            cursorclass=pymysql.cursors.DictCursor,
        )

        # Twister 只是提供了一个异步的容器,并没有提供数据库连接,所以连接数据库还是要用 pymysql 进行连接
        # adbapi 可以将 MySQL 的操作变为异步
        # ConnectionPool 第一个参数是我们连接数据库所使用的 库名,这里是连接 MySQL 用的 pymysql
        # 第二个参数就是 pymysql 连接操作数据库所需的参数,这里将参数组装成字典 db_params,当作关键字参数传递进去
        dbpool = adbapi.ConnectionPool('pymysql', **db_params)
        return cls(dbpool)

    def process_item(self, item, spider):
        # 使用 Twisted 将 MYSQL 插入变成异步
        # 执行 runInteraction 方法的时候会返回一个 query 对象,专门用来处理异常
        query = self.dbpool.runInteraction(self.do_insert, item)
        # 添加错误处理方法到 query 对象
        # addErrback 第一个参数是处理异常的方法,后面的参数是这个方法所需的参数
        # 因为定义的 handle_error 方法需要接收 item、spider 参数,所以这里需要传递
        query.addErrback(self.handle_error, item, spider)

    def do_insert(self, cursor, item):
        # 执行具体的插入操作
        # 这里已经不需要手动 commit 了,Twisted 会自动 commit
        insert_sql = '''
                        insert into jobbole_article(title,create_date,url,url_object_id,front_image_url,
                                                    front_image_path,praise_nums,comment_nums,fav_nums,tags,content)
                        values(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
                    '''
        cursor.execute(insert_sql, (
            item['title'], item['create_date'], item['url'], item['url_object_id'],
            item['front_img_url'], item['front_img_url'], item['praise_nums'],
            item['comment_nums'], item['fav_nums'], item['tags'], item['content']
        ))

    def handle_error(self, failure, item, spider):
        # 异常处理方法,处理异步插入数据库时产生的异常
        # failure 参数不需要我们自己传递,出现异常会自动将异常当作这个参数传递进来
        print(f'出现异常:{failure}')

  • 配置 settings.py 文件
# ArticleSpider/settings.py

ITEM_PIPELINES = {
    'ArticleSpider.pipelines.ArticlespiderPipeline': 300,
    # 'scrapy.pipelines.images.ImagesPipeline': 100,  # 添加图片处理 pipeline
    'ArticleSpider.pipelines.ArticleImagePipeline': 100,
    # 'ArticleSpider.pipelines.JsonWithEncodingPipeline': 200,
    # 'ArticleSpider.pipelines.MySQLPipeline': 200,
    'ArticleSpider.pipelines.MySQLTwistedPipeline': 200,
}

# MySQL 配置
MYSQL_HOST = '127.0.0.1'
MYSQL_PORT = 3306
MYSQL_USER = 'pythonic'
MYSQL_PASSWORD = 'pythonic'
MYSQL_DATABASE = 'Articles'

  • 运行爬虫,测试数据是否可以成功存入 MySQL

发现即使出现异常,程序会继续往下执行,数据可以成功插入

image.png image.png image.png

ItemLoader

修改 jobbole.py 的 parse_detail 方法
# ArticleSpider/spiders/jobbole.py

import scrapy
from scrapy.loader import ItemLoader  # 首先导入 ItemLoader
from ArticleSpider.items import JobBoleArticleLoadItem
# from ArticleSpider.items import JobBoleArticleItem

from ArticleSpider.utils.common import get_md5


class JobboleSpider(scrapy.Spider):
    name = 'jobbole'
    allowed_domains = ['jobbole.com']
    start_urls = ['http://python.jobbole.com/all-posts/']  # http://blog.jobbole.com/114041/

    def parse(self, response):
        ...

    def parse_detail(self, response):
        """
            1. 解析文章详情页页面信息
            2. 将解析出来的数据交给 pipelines 文件进行处理
        Args:
            response: 响应信息
        Yields:
            item
        """
        # 通过 ItemLoader 加载 Item
        front_img_url = [response.meta.get('front_img_url')]
        # ItemLoader 实际上提供了一个容器,有两个主要参数,item 参数是要实例化的 item 类,response 参数是爬虫返回的 response
        item_loader = ItemLoader(item=JobBoleArticleLoadItem(), response=response)
        # ItemLoader 提供了三种方式来加载字段,XPath、CSS 选择器 或者直接将之添加进来
        # 第一个参数是字段名,第二个参数是字段值
        item_loader.add_xpath('title', '//div[@class="entry-header"]/h1/text()')
        item_loader.add_xpath('create_date', '//p[@class="entry-meta-hide-on-mobile"]/text()')
        item_loader.add_value('url', response.url)
        item_loader.add_value('url_object_id', get_md5(response.url))
        item_loader.add_value('front_img_url', front_img_url)
        item_loader.add_css('praise_nums', 'div.post-adds > span.vote-post-up > h10::text')
        item_loader.add_css('comment_nums', 'div.post-adds > a[href="#article-comment"] span::text')
        item_loader.add_css('fav_nums', 'div.post-adds > span.bookmark-btn::text')
        item_loader.add_css('tags', 'p.entry-meta-hide-on-mobile a::text')
        item_loader.add_css('content', 'div.entry')

        item = item_loader.load_item()

        yield item
修改 items.py

Scrapy 的 item 字段并没有 Django 的丰富,事实上它只提供了一种字段类型 scrapy.Field(),但其实 scrapy 还是提供了一些处理方法在里面,每个字段都有一个 input_processor 方法,和一个 output_processor 方法,可以对字段数据进行处理

常用方法:

  • MapCompose:接收无限个函数,会将字段中的值依次作用于各个函数
  • TakeFirst:返回列表中第一个非空值,和 extract_first() 效果一样
  • Join:将列表中各个值拼接成字符串,同 python 的 join 内建函数
# ArticleSpider/items.py

from scrapy.loader.processors import MapCompose, TakeFirst, Join

def add_jobbole(value):
    return f'{value} --jobbole'


class JobBoleArticleLoadItem(scrapy.Item):
    title = scrapy.Field(
        # 两个函数会依次作用于 title 字段中的每一个值,可以看到函数是支持 lambda 表达式
        input_processor=MapCompose(add_jobbole, lambda x: x+'[article]'),
        output_processor=TakeFirst()
    )
    ...

断点调试可以看到两个函数都生效了,依次作用于 title 字段

image.png

这样,所有的字段处理逻辑其实都可以写在 items.py 文件中,而爬虫 jobbole.py 文件 中 parse_detail 函数只是提取数据,不写具体数据清洗的逻辑,这样代码更加简洁合理。
但是还有一个问题,其实之前不用 ItemLoader 的代码也可以看出,每次提取字段都要写一次 extract_first(),比较麻烦,而且现在用了 ItemLoader 其实还是每个字段写了一遍,output_processor=TakeFirst()。为了解决这个问题,可以自定义 ItemLoader 类,并且继承自 ItemLoader,定义一个默认的 output_processor 方法,让其默认等于 TakeFirst(),就可以了,如果我们不想在其中某一个字段使用此方法,就可以在这个字段中覆写 output_processor 方法,这样就屏蔽掉了默认 output_processor=TakeFirst(),而是用自定义的方法

# ArticleSpider/items.py

from scrapy.loader import ItemLoader

class ArticleItemLoader(ItemLoader):
    """
        自定义 ItemLoader,继承自 Scrapy 的 ItemLoader
        来改变 ItemLoader 的默认 output_processor 方法
    """
    default_output_processor = TakeFirst()
  • 已经自定义了 ItemLoader 类,所以现在在 jobbole.py 中实例化 ItemLoader 的时候就要使用自定义的 ItemLoaderArticleItemLoader

# -*- coding: utf-8 -*-
import re
from urllib.parse import urljoin
from datetime import datetime

import scrapy
# from scrapy.loader import ItemLoader  # 不在使用 scrapy 提供的 ItemLoader,而是使用自定义的 ArticleItemLoader
from ArticleSpider.items import JobBoleArticleLoadItem, ArticleItemLoader
from ArticleSpider.items import JobBoleArticleItem

from ArticleSpider.utils.common import get_md5


class JobboleSpider(scrapy.Spider):
    name = 'jobbole'
    allowed_domains = ['jobbole.com']
    start_urls = ['http://python.jobbole.com/all-posts/']  # http://blog.jobbole.com/114041/

    def parse(self, response):
       ...

    def parse_detail(self, response):
        # 通过 ItemLoader 加载 Item
        front_img_url = [response.meta.get('front_img_url')]
        # 这里改用自定义的 ArticleItemLoader
        item_loader = ArticleItemLoader(item=JobBoleArticleLoadItem(), response=response)
        item_loader.add_xpath('title', '//div[@class="entry-header"]/h1/text()')
        item_loader.add_xpath('create_date', '//p[@class="entry-meta-hide-on-mobile"]/text()')
        item_loader.add_value('url', response.url)
        item_loader.add_value('url_object_id', get_md5(response.url))
        item_loader.add_value('front_img_url', front_img_url)
        item_loader.add_css('praise_nums', 'div.post-adds > span.vote-post-up > h10::text')
        item_loader.add_css('comment_nums', 'div.post-adds > a[href="#article-comment"] span::text')
        item_loader.add_css('fav_nums', 'div.post-adds > span.bookmark-btn::text')
        item_loader.add_css('tags', 'p.entry-meta-hide-on-mobile a::text')
        item_loader.add_css('content', 'div.entry')

        item = item_loader.load_item()

        yield item

  • items.py 完整代码

# -*- coding: utf-8 -*-

# Define here the models for your scraped items
#
# See documentation in:
# https://doc.scrapy.org/en/latest/topics/items.html
import re
from datetime import datetime

import scrapy
from scrapy.loader import ItemLoader
from scrapy.loader.processors import MapCompose, TakeFirst, Join


def convert_date(value):
    """
        将字符串转换成日期
    """
    try:
        return datetime.strptime(value, '%Y/%m/%d')
    except Exception as e:
        return datetime.now().date()


def take_nums(value):
    """
        提取数字
    """
    re_find = re.findall('.*?(\d+).*', value)
    if re_find:
        return int(re_find[0])
    else:
        return 0


def remove_tags_comment(value):
    """
        移除 tags 中的评论
    """
    return '' if '评论' in value else value


class ArticleItemLoader(ItemLoader):
    """
        自定义 ItemLoader,继承自 Scrapy 的 ItemLoader
        来改变 ItemLoader 的默认 output_processor 方法
    """
    default_output_processor = TakeFirst()


class JobBoleArticleLoadItem(scrapy.Item):
    title = scrapy.Field()
    create_date = scrapy.Field(
        input_processor=MapCompose(convert_date)
    )
    url = scrapy.Field()
    url_object_id = scrapy.Field()
    front_img_url = scrapy.Field(
        # 因为 使用 scrapy 自带的下载图片管道图片字段必须是可迭代的
        # 所以这里不再使用默认的 output_processor=TakeFirst()
        # 而是通过 lambda 表达式返回一个列表,因为本来传进来
        # 就是一个列表,所以什么也不需要做,只需要直接将值重新返回
        output_processor=MapCompose(lambda x: x)
    )
    front_img_path = scrapy.Field()
    praise_nums = scrapy.Field(
        input_processor=MapCompose(take_nums)
    )
    comment_nums = scrapy.Field(
        input_processor=MapCompose(take_nums)
    )
    fav_nums = scrapy.Field(
        input_processor=MapCompose(take_nums)
    )
    tags = scrapy.Field(
        input_processor=MapCompose(remove_tags_comment),
        output_processor=Join(',')
    )
    content = scrapy.Field()

相关文章

网友评论

    本文标题:聚焦Python分布式爬虫必学框架 Scrapy 打造搜索引擎

    本文链接:https://www.haomeiwen.com/subject/nwiehftx.html