项目简介

使用scrapy+selenium+mysql实现简书文章整站爬取(动态数据)以及数据写入数据库。
项目github地址:https://github.com/lemonSuanSuan/jianshu-scrapy

实现思路

先解惑:为啥要用到selenium?

偶尔,也许小小的眼睛会充满大大的疑惑:代码并没有错,爬取数据的时候有些数据却死活获取不到。其实就是因为那是动态加载的数据,右键-查看网页源代码只能看到当前url获取的内容,看不到动态加载的数据。
现在前后端分离,从后台获取的数据一般都是采取动态加载。对于一些无法通过当前页面url获取的数据,就需要用到Selenium了,它是web应用程序自动化测试工具,能够模拟用户行为,获取动态数据加载后的网页源代码。

scrapy+selenium+mysql整体思路就是:

1.在scrapy框架的下载器中间件中,利用selenium处理请求并拿到完全加载的网页源代码
2.将网页源代码封装成response,在中间件的process_request函数中返回,这样就会直接返回数据全部加载的页面源码给爬虫引擎,request也不会发送到下载器那边去,也就是selenium代替了下载器。
3.最后使用管道pipelines将数据存进数据库。

准备工作

需要安装的第三方库

使用python包管理工具pip下载即可
pip install scrapy
pip install selenium
pip install pymysql

安装mysql

官网下载太慢,所以使用国内镜像源站点下载
这里我使用的是清华的镜像源:https://mirrors.tuna.tsinghua.edu.cn/
进mysql目录,根据自己的电脑系统选择合适的版本下载安装即可(比如windows 64位则ctrl+F搜索winx64,选择最新版的zip包)

image.png
下载谷歌浏览器驱动器chromedriver.exe

根据自己使用的浏览器去下载相应版本的驱动器即可,把它放到python安装目录下,也就是python.exe所在的目录下(放这里是因为不指定路径的话,默认到这里来找驱动程序)

项目构建及具体代码实现

image.png
1.新建项目
scrapy startproject jianshu
2.切换到项目目录下,新建爬虫

-t crawl表示使用CrawlSpider模板。因为这个爬取需要提取链接并且进入详情页获取数据,所以使用CrawlSpider类而不是Spider类

cd jianshu
scrapy genspider –t crawl js “jianshu.com”
3.修改一下settings.py里的一些相关配置

1)设置将遵循机器人协议更改为False。True的话会先在爬取的网站根目录下找robots.txt,如果没有就不抓取内容就返回了,所以更改为False,以防找不到不爬取就返回。

# Obey robots.txt rules
ROBOTSTXT_OBEY = False

2)设置默认请求头User-Agent,伪装一下身份。

# Override the default request headers:
DEFAULT_REQUEST_HEADERS = {
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  'Accept-Language': 'en',
  'user-agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

3)设置下载延迟,不要给人家服务器造成太大压力,适当可以设置延迟一两秒,两三秒……

# Configure a delay for requests for the same website (default: 0)
# See https://docs.scrapy.org/en/latest/topics/settings.html#download-delay
# See also autothrottle settings and docs
DOWNLOAD_DELAY = 1
4.items.py 定义item容器

根据寄几的的需要定义相应字段,你想要爬取的数据最后都需要装到这里面来传给管道pipelines

import scrapy
class JianshuItem(scrapy.Item):
    # 文章id
    article_id = scrapy.Field()
    # 标题
    title = scrapy.Field()
    # 文章内容
    content = scrapy.Field()
    # 作者
    author = scrapy.Field()
    # 头像
    avatar = scrapy.Field()
    # 发布时间
    pub_time = scrapy.Field()
    # 原始url
    origin_url = scrapy.Field()
    # 字数
    word_count = scrapy.Field()
    # 阅读量
    read_count = scrapy.Field()
    # 评论数
    comment_count = scrapy.Field()
    # 点赞数
    like_count = scrapy.Field()
    # 所属专题
    subjects = scrapy.Field()
5.js.py 爬虫代码

1)爬取整站的文章的方法:爬到首页推荐的文章链接,进入后继续提取每个页面出现的推荐文章详情页的链接(所以要匹配规则的参数follow=True)
2)分析需要提取的url,得出每个详情页的链接匹配规则应为/p/和后面跟12个小写字母和数字的组合
3)可以看到其实从页面上获取的并不是完整的链接,是/p/xxxxxxxxxxxx这种形式,但是提取器会自动补全链接,进入LinkExtractor代码可以看到它会拿response的base_url拼接,就是会被自动拼接成https://www.jianshu.com/p/xxxxxxxxxxxx,非常贴心
4)关于具体的元素数据获取,根据自己的需求以及熟悉的获取方法来即可。这里用的xpath,个人觉得比较简单的方法。

# -*- coding: utf-8 -*-
import scrapy
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from jianshu.items import JianshuItem

class JsSpider(CrawlSpider):
    name = 'js'
    allowed_domains = ['jianshu.com']
    # 从首页开始爬取
    start_urls = ['https://www.jianshu.com/']
    rules = (
        Rule(LinkExtractor(allow=r'/p/[0-9a-z]{12}'), callback='parse_detail', follow=True),
    )

    def parse_detail(self, response):
        # 文章id,可从url获取
        article_id = response.url.split('/')[-1]
        # 标题
        title = response.xpath('//h1[@class="_1RuRku"]/text()').get()
        # 内容,这里把内容的标签也保存下来
        content = response.xpath('//article').get()
        # 作者
        author = response.xpath('//a[@class="_1OhGeD"]/text()').get()
        # 头像
        avatar = response.xpath('//img[@class="_13D2Eh"]/@src').get()
        # 发布时间
        pub_time = response.xpath('//div[@class="s-dsoj"]/time/text()').get()
        # 字数和阅读量没有可供筛选的条件,并且他们前面有个span有些页面有有些页面没有,所以倒数着来取
        # 字数
        word_count = response.xpath('//div[@class="s-dsoj"]/span[last()-1]/text()').get()
        word_count = word_count.split()[-1]
        # 阅读量
        read_count = response.xpath('//div[@class="s-dsoj"]/span[last()]/text()').get()
        read_count = read_count.split()[-1]

        # 评论数,span中含有注释签,所以需要getall()才能获取到后面的数字
        comment_count = response.xpath('//div[@class="-pXE92"]/div[1]/span//text()').getall()[-1]
        # 点赞数,没有点赞数的话没有任何数字,所以自己判断一下给它赋0
        like_count = response.xpath('//div[@class="-pXE92"]/div[2]/span//text()').getall()
        if len(like_count) == 1:
            like_count = '0'
        else:
            like_count = like_count[-1]

        # 所属专题
        subjects = response.xpath('//div[contains(@class, "_2Nttfz")]/a/span/text()').getall()
        # getall()返回的是一个列表,将专题列表转换成以逗号分隔的字符串。
        subjects = ','.join(subjects)

        # url
        origin_url = response.url

        item = JianshuItem(
            article_id=article_id,
            title=title,
            content=content,
            author=author,
            avatar=avatar,
            pub_time=pub_time,
            word_count=word_count,
            read_count=read_count,
            comment_count=comment_count,
            like_count=like_count,
            subjects=subjects,
            origin_url=origin_url
        )

        yield item

6.middlewares.py 中间件

1)写一个下载器中间件,导入selenium的webdriver,在中间件的process_request函数中驱动打开页面,并模拟一些操作,使得想要的数据都加载进来,然后获取网页源代码,将网页源代码封装成response返回给爬虫引擎。

# 导入selenium的webdriver
from selenium import webdriver
import time
from scrapy.http.response.html import HtmlResponse
import random


class SeleniumDownloaderMiddleware(object):

    def __init__(self):
        # Selenium Webdriver是通过各种浏览器的驱动(web driver)来驱动浏览器的
        # 指定chrom的驱动(需要自己去下载chromedriver.exe)
        # Selenium到指定的路径将chromedriver程序运行起来,然后chromedriver就会去驱动chrome浏览器了
        # 默认是会到python环境里去找。因为我放到了环境变量那个路径下,所以也可以不指定路径
        # self.driver = webdriver.Chrome(r'C:UsersAdministratorAppDataLocalProgramsPythonPython38chromedriver.exe')
        self.driver = webdriver.Chrome()

    def process_request(self, request, spider):
        # 驱动打开请求页面
        self.driver.get(request.url)
        # 因为是异步请求,为避免未加载完成就获取的情况,等待1秒
        time.sleep(1)
        # 所属专题如果没有全部显示的话,需要模拟操作才能获取所有数据
        # 如果有“展示更多”按钮,点击,直到没有了,退出循环。如果一开始就没有该元素,会抛出异常,捕获异常。
        try:
            while True:
                # 定位元素
                show_more = self.driver.find_element_by_class_name("H7E3vT")
                # 点击该元素。因为直接show_more.click()失效了,所以通过执行js语句来点击
                self.driver.execute_script("arguments[0].click();", show_more)
                print('获取到并点击了该元素')
                time.sleep(0.5)
                if not show_more:
                    break
        except Exception as e:
            # 打印查看抛出的异常
            print(e)
        time.sleep(1)
        # 获取网页源代码
        source = self.driver.page_source
        # 将网页源代码封装成response返回给引擎。HtmlResponse需要传递的参数可以ctrl+B进入代码看。driver.current_url是当前页面url
        response = HtmlResponse(self.driver.current_url, body=source, request=request, encoding='utf-8')
        return response

2)到settings.py中设置启用该中间件。也就是到相应的地方把注释取消掉,把中间件名字改成自己写的那个就好。

# Enable or disable downloader middlewares
# See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html
DOWNLOADER_MIDDLEWARES = {
    'jianshu.middlewares.SeleniumDownloaderMiddleware': 3,
}
7.mysql建立数据库和表格

根据自己的需求创建表格以存放相应数据。对应上面代码需要存储的数据的表格sql语句如下

DROP TABLE IF EXISTS `article`;
CREATE TABLE `article` (
  `id` int NOT NULL AUTO_INCREMENT,
  `article_id` varchar(20) DEFAULT NULL,
  `title` varchar(255) DEFAULT NULL,
  `content` longtext,
  `author` varchar(255) DEFAULT NULL,
  `avatar` varchar(255) DEFAULT NULL,
  `pub_time` datetime DEFAULT NULL,
  `origin_url` varchar(255) DEFAULT NULL,
  `word_count` varchar(11) DEFAULT '00000000000',
  `read_count` varchar(11) DEFAULT '00000000000',
  `comment_count` varchar(11) DEFAULT '00000000000',
  `like_count` varchar(11) DEFAULT '00000000000',
  `subjects` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
  `creat_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
8.pipelines.py 将数据写入数据库

两种方法,一个同步写入,一个异步写入(效率高)
记得要到settings.py开启相应的管道,用哪个就开哪个。

ITEM_PIPELINES = {
    # 同步
   # 'jianshu.pipelines.JianshuPipeline': 300,
    # 异步
   'jianshu.pipelines.JianshuTwistedPipeline': 300,

}

1)方法一:同步写入数据到数据库
使用pymysql提供的模块及方法进行数据库连接及操作。
所谓同步就是数据库I/O操作会阻塞线程,执行sql插入数据要等待提交后才会执行后面的代码,比如下面代码中要等到self.conn.commit()执行了才会去执行return item

import pymysql
from twisted.enterprise import adbapi
from pymysql import cursors


# 将数据插入数据库
# 方法一:同步
class JianshuPipeline(object):
    def __init__(self):
        dbparams = {
            'host': '127.0.0.1',
            'port': 3306,
            'user': 'root',
            'password': '123456',
            'database': 'jianshu',
            'charset': 'utf8mb4'
            # mysql相关时,注意utf-8要写'utf8'.
            # 这里因为subjects字段可能有4个字节的符号,所以指定编码为utf8mb4
        }
        # 连接数据库
        # ** dbparams代表关键字参数,传入一个字典,函数会解包,它会把dictionary中所有键值对转换为关键字参数传进去
        self.conn = pymysql.connect(**dbparams)
        # 创建游标,用于执行数据库操作
        self.cursor = self.conn.cursor()
        self._sql = None

    # 写一个sql语句模板
    # @property是python内置的装饰器,会将方法转换为属性,目的是使得传入的参数可以得到校验
    # %s为字符串格式参数占位符
    @property
    def sql(self):
        if not self._sql:
            self._sql = """
            insert into article(id,article_id,title,content,author,avatar,pub_time,origin_url,word_count,
            read_count,comment_count,like_count,subjects) values(null,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            """
            return self._sql
        return self._sql

    def process_item(self, item, spider):
        # 让游标执行sql语句,注意要传进sql语句里的参数有多个时要放在一个元组中传递,单个则可以直接传递
        self.cursor.execute(self.sql, (item['article_id'], item['title'], item['content'],
                                       item['author'], item['avatar'], item['pub_time'],
                                       item['origin_url'], item['word_count'], item['read_count'],
                                       item['comment_count'], item['like_count'], item['subjects']))
        # 提交当前事务
        self.conn.commit()
        return item

2)方法二:异步写入数据到数据库
利用scrapy的底层框架twisted提供的adbqpi实现异步操纵数据库数据。网络的下载速度和数据库的I/O速度不一样,有可能会发生下载快,但是写入数据库速度慢的情况,造成线程的堵塞,所以把插入数据库操作变成异步的,提高效率。

# 方法二:异步(利用scrapy框架的底层twisted框架的adbpai模块实现异步操纵数据库)
class JianshuTwistedPipeline(object):
    def __init__(self):
        dbparams = {
            'host': '127.0.0.1',
            'port': 3306,
            'user': 'root',
            'password': '123456',
            'database': 'jianshu',
            'charset': 'utf8mb4',
            'cursorclass': cursors.DictCursor
            # 与方法一不同,这里需要指定游标类,否则不知道使用哪个游标

        }
        # 定义连接池,连接数据库.参数一:mysql的驱动;参数二:连接mysql的配置信息
        self.dbpool = adbapi.ConnectionPool('pymysql', **dbparams)
        self._sql = None

    @property
    def sql(self):
        if not self._sql:
            self._sql = """
            insert into article(id,article_id,title,content,author,avatar,pub_time,origin_url,word_count,
            read_count,comment_count,like_count,subjects) values(null,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            """
            return self._sql
        return self._sql

    def process_item(self, item, spider):
        # runInteraction()把self.insert_item()变为异步执行函数
        # 参数1:在异步任务中要执行的函数insert_item
        # 参数2:给传递参数item.
        defer = self.dbpool.runInteraction(self.insert_item, item)
        # 给defer添加错误处理,self.dbpool.runInteraction(self.insert_item(), item)出错的话,就会调用self.handle_error函数
        defer.addErrback(self.handle_error, item, spider)
        return item

    # 定义一个插入数据的函数
    # 注意:函数insert_item接收的第一个参数是runInteraction()传递的一个Transaction对象,类似cursor,
    # 所以insert_item函数的时候cursor和item的位置不要搞错啦
    def insert_item(self, cursor, item):
        params = (item['article_id'], item['title'], item['content'],
                                       item['author'], item['avatar'], item['pub_time'],
                                       item['origin_url'], item['word_count'], item['read_count'],
                                       item['comment_count'], item['like_count'], item['subjects'])
        cursor.execute(self.sql, params)

    def handle_error(self, error, item, spider):
        print('-' * 20 + '出错啦' + '-' * 20)
        print(error)
        print('-' * 20 + '出错啦' + '-' * 20)
9.项目运行

为了方便,项目目录下新建start.py,放入执行命令,然后运行该文件即可

from scrapy import cmdline
cmdline.execute("scrapy crawl js".split())

爬过的坑

1)有时subjects字段插入数据库有问题
错误:
pymysql.err.InternalError: (1366, “Incorrect string value: ‘xF0x9Fx8Dx81xE6x96…’ for column ‘subjects’ at row 1”)
原因及解决办法:
安装mysql的时候设置的默认编码为utf8,但是mysql中的utf8并不是真正意义上的utf8,它最多只有3个字节,但是subjects字段中有一些4个字节的符号,所以就报错了,要把相应的编码改成utf8mb4才行。但是为了避免以后还会遇到这样的情况,直接更改mysql默认编码为utf8mb4,也就是更改my.ini文件,然后重启服务就好
2)selenium中获取元素后点击事件click无效
我也不知道为什么无效啊
解决办法,使用driver的execute_script()方法执行js语句来完成点击
用driver的execute_script()方法来比较稳妥吧。

#定位元素
show_more = self.driver.find_element_by_class_name("H7E3vT")
# 失效 
 show_more.click()
# 有效
self.driver.execute_script("arguments[0].click();", show_more)

3)数据表设计问题:字数word_count和阅读数read_count这些字段插入在数据库时报错
原因及解决办法:
在数据库表格中将这些字段设置了类型为int,但是因为获取到的数字大于三位数时是以每三位逗号分隔的形式显示的,这样是无法转换成整型的。emmm,我也没有一定要这些字段为整型的需求,所以改为varchar就好。

总结

对于框架中的模块啊方法啊,不明白为何这样的,ctrl+B进入源码分析往往比百度来的更快狠准啊,要说多少遍,落泪。

文章来源于互联网:python简书爬虫完整代码及详细笔记(scrapy+selenium+mysql)

发表评论