「Python」爬虫-9.Scrapy框架的初识-公交信息爬取

1,638 阅读13分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第23天, 点击查看活动详情


Spider实战

本文将讲解如何使用scrapy框架完成北京公交信息的获取。

目标网址为beijing.8684.cn/

在前文的爬虫实战中,已经讲解了如何使用requests和bs4爬取公交站点的信息,感兴趣的话可以先阅读一下「Python」爬虫实战系列-北京公交线路信息爬取(requests+bs4) - 掘金 (juejin.cn),在回来阅读这篇文章🫠。

关于爬虫系列文章,这里浅浅的罗列一下,欢迎阅读😶‍🌫️😶‍🌫️😶‍🌫️:

「Python」爬虫-1.入门知识简介 - 掘金 (juejin.cn)

「Python」爬虫-2.xpath解析和cookie,session - 掘金 (juejin.cn)

「Python」爬虫-3.防盗链处理 - 掘金 (juejin.cn)

「Python」爬虫-4.selenium的使用 - 掘金 (juejin.cn)

「Python」爬虫-5.m3u8(视频)文件的处理 - 掘金 (juejin.cn)

「Python」爬虫-6.爬虫效率的提高 - 掘金 (juejin.cn)

「Python」爬虫-7.验证码的识别 - 掘金 (juejin.cn)

「Python」爬虫-8.断点调试-网易云评论爬取 - 掘金 (juejin.cn)


1.相关技术介绍

scrapy是一个为了爬取网站数据,提取结构性数据而编写的应用框架,可以应用在包括数据挖掘,信息处理或存储历史数据等一列的程序中。

scrapy的操作流程如下:

1.选择网站->2.创建一个scrapy项目->3.创建一个spider->4.定义item ->5.编写spider->6.提取item->7.存取爬取的数据->8.执行项目。

下面将依次按照此流程来讲解scrapy框架的使用:

1.选择网站

本文选取的目标url为beijing.8684.cn/,通过之前的实验实战文章,相信读者对该网站已经有了初步的认识。

这里同样我们所需要爬取的信息为公交线路名称、公交的运营范围、运行时间、参考票价、公交所属的公司以及服务热线、公交来回线路的途径站点。

2.创建一个scrapy项目

在开始爬取之前,需要创建一个新的scrapy项目,切换到该项目路径的目录下,并执行scrapy startproject work,

(其中work为项目的名字,可以自拟,叫啥都可

观察work目录下的结构如下所示:

└─work
    │  scrapy.cfg  # 项目配置文件
    │
    └─work   # 该项目的python模块
        │  items.py  # 定义爬取的数据结构
        │  middlewares.py  # 定义爬取时的中间件
        │  pipelines.py  # 数据管道,将数据存入本地文件或存入数据库
        │  settings.py  # 项目的设置文件
        │  __init__.py
        │
        └─spiders  # 放置spider代码的目录
                __init__.py

3.创建一个spider。

work目录下,使用genspider语句,创建一个spider。格式如下:

scrapy genspider spider_name url

spider_name为自己程序的名字,自己为自己程序取名,不过分吧?

url为自己爬取的目标网址,本文为beijing.8684.cn/

4.定义item

item是保存爬取到的数据容器,使用方法和python字典类似,并提供了额外保护机制来避免拼写错误导致的未定义字段错误。

首先,根据从目标网站获取到的数据对item 进行建模,并在item中定义相应的字段,编辑work目录中的items.py文件。比如:

import scrapy
class MyItem(scrapy.Item):
    title = scrapy.Field()
    desc = scrapy.Field()

通过定义item,可以很方面的使用scrapy的其他方法,而这些方法需要知道item 的定义。

5.编写spider

spider是用户编写用于从单个网站爬取数据的类,其中包含了一个用于下载的初始url,以及如何跟进网页中的链接和分析页面中的内容,提取生成item的方法。

为了创建一个spider,必须继承scrapy.Spider类,并且必须定义以下三个属性:

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

6.提取item

从网页中提取数据的方法有很多,正如前面实验所用到的requests或者selenium等,scrapy使用了一种基于xpath和css表达式机制:scrapy selectors,这里给出常用xpath表达式对应的含义:

/html/head/title:选择html文档中<head>标签内的<title>元素。

/html/head/title/text():选择上面提到的<title>元素的文字。

//td:选择所有的<td>元素

//div[@class="mine"]:选择所有具有class="mine"属性的div元素。

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

selector有四个基本的方法;

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

7.存取爬取的数据

scrapy还集成了存储数据的方法,使用命令如下:

scrapy crawl work -o items.json

该命令将采用json格式对爬取的数据进行序列化,并生成对应的items.json文件。对于小规模的项目,这种存储方式较为灵活,如果对爬取到的item做更多更为复杂的操作就需要编写pipelines.py

8.执行项目

到当前项目的根目录,执行以下命令即可启动spider:

scrapy crawl work

scrapy为spider的start_urls属性中的每一个url创建了scrapy.Request对象,并将parse方法作为回调函数callback赋值给了Request.

Request对象经过调度,执行生成scrapy.HTTP.Response对象并送回给spider.parse()方法。

cd 到 /work/work/目录下,执行scrapy crawl BusCrawl也可以启动spider。


2.整体爬取过程分析

2.1 settings.py

根据上述相关技术介绍,新建一个work项目,并执行

genspider BusCrawl https://beijing.8684.cn/

通过genspider之后,就会发现项目的BusCrawl.py中自动为我们已经添加了一些代码:

import scrapy

class BuscrawlSpider(scrapy.Spider):
    name = 'BusCrawl'
    allowed_domains = ['beijing.8684.cn']
    start_urls = ['http://beijing.8684.cn']

    def __init__(self, name=None, **kwargs):
        super().__init__(name=None, **kwargs)

    def start_requests(self):
        pass
    def parse(self, response):
        pass

这里由于我们的公交线路连接需要拼接list?,所以在本文中start_urls直接修改为字符串的形式。

settings.py默认内容如下:

BOT_NAME = 'work'

SPIDER_MODULES = ['work.spiders']
NEWSPIDER_MODULE = 'work.spiders'


# Crawl responsibly by identifying yourself (and your website) on the user-agent
#USER_AGENT = 'work (+http://www.yourdomain.com)'

# Obey robots.txt rules
ROBOTSTXT_OBEY = False
# LOG_LEVEL = 'WARNING'

items.py默认内容如下:

# Define your item pipelines here
#
# Don't forget to add your pipeline to the ITEM_PIPELINES setting
# See: https://docs.scrapy.org/en/latest/topics/item-pipeline.html


# useful for handling different item types with a single interface
from itemadapter import ItemAdapter


class BusCrawlPipeline:
    def process_item(self, item, spider):
        return item

然后修改settings.pyrobots协议为False以及粘贴自己的headers

robots协议本来是爬虫应该遵循的,但是如果遵循的话,相信大部分信息都设置为不可爬取,那么,就爬不到啥东西了

本文的具体设置为如下:

ROBOTSTXT_OBEY = False

DEFAULT_REQUEST_HEADERS = {
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  'Accept-Language': 'en',
  "User-Agent": "xxx"
}

毕竟大部分的网站信息要是遵循robots协议好像都爬取不了。 user-agent直接自己去复制就好了,方法这里不再赘述。

middlewares.py本文不涉及,这里省略介绍。

2.2 BusCrawl.py

接下来需要写BusCrawl.py,可以看到scrapy已经为我们写好了部分函数,我们需要根据自己的需求进一步完善相关的代码文件。

2.2.1 start_requests(self)

先将start_urls切换为字符串的形式,因为我们爬取的具体信息url需要进一步构造。根据之前的分析,以每个数字或者字母开头的页面是直接在https://beijing.8684.cn/后加上list?。所以start_requests()函数如下:

def start_requests(self):
    for page in range(3):
        url = '{url}/list{page}'.format(url=self.start_urls, page=page + 1)
        yield FormRequest(url, callback=self.parse_index)

其中yield函数就是函数执行到这就结束了,并调用了FormRequest()函数,这个需要引入from scrapy import FormRequest,然后将对页面进行具体分析的函数名传递给callback,这样就可以调用parse_index函数了。(parse_index其实就是解析函数

注意这里的parse_index名字不是固定的,这里只是根据callback来调用具体的函数。

运行程序之后是可以看到在控制台输出了我们的list链接信息。

2.2.2 parse_index(self, response)

然后根据https://beijing.8684.cn/list?设计perse_index提取到每条具体线路的href中的详情页面。parse_index函数设计如下:

def parse_index(self, response):
    hrefs = response.xpath('//div[@class="list clearfix"]/a/@href').extract()
    for href in hrefs:
        detail_url = urljoin(self.start_urls, href)
        yield Request(detail_url, callback=self.parse_detail)

这里需要注意:提取每个list里面的线路具体链接的时候直接复制的xpath代码用不了,需要根据class名才能提取到,通过extract()即可提取到href的信息。

response.xpath('//div[@class="list clearfix"]/a/@href').extract()

extract():这个方法返回的是一个数组list

extract_first():这个方法返回的是一个string字符串,是list数组里面的第一个字符串。

yield Request(...)之后就可以看到详情界面的链接了,说明我们的函数写的十分正确!

使用yield之后,会默认在控制台打印信息,而不需要我们自己手动print打印了。

2.2.3 parse_detail(self, response)

提取到每条公交线路的详情页面的url之后,就可以开始对详情页面进行信息提取了。这里 的分析方法和之前的bs4或者xpath解析差不多,稍微改一改就可以直接拿过来用了。

这里遇到的问题和之前的类似,在提取公交的站点信息的时候,仍然会出现中间路线出现了终点站或起始站的情况,这里的解决方法和之前类似->遍历中间的站点,然后删去起始站点。

未处理前爬取的站点信息图如下:

具体的解析页面的函数设计如下:

def parse_detail(self, response):
    title = response.xpath('//h1[@class="title"]/span/text()').extract_first()  # 线路名称
    category = response.xpath('//a[@class="category"]/text()').extract_first()  # 线路类别
    time = response.xpath('//ul[@class="bus-desc"]/li[1]/text()').extract_first()  # 开车时间
    price = response.xpath('//ul[@class="bus-desc"]/li[2]/text()').extract_first()  # 参考票价
    company = response.xpath('//ul[@class="bus-desc"]/li[3]/a/text()').extract()  # 所属公司

    trip = response.xpath('//div[@class="trip"]/text()').extract()  # 开车方向

    path_go, path_back = None, None

    begin_station, end_station = trip[0].split('—')

    if len(trip) > 1:
        path_go = response.xpath('//div[@class="service-area"]/div[2]/ol/li/a/text()').extract()
        path_back = response.xpath('//div[@class="service-area"]/div[4]/ol/li/a/text()').extract()

        go_list = [begin_station] + [station for station in path_go[1:-1] if station != end_station] + [end_station]
        back_list = [end_station] + [station for station in path_back[1:-1] if station != begin_station] + [
            begin_station]

        path_go = {trip[0]: '->'.join(go_list)}
        path_back = {trip[1]: '->'.join(back_list)}
    else:
        path_go = response.xpath('//div[@class="bus-lzlist mb15"]/ol/li/a/text()').extract()
        go_list = [begin_station] + [station for station in path_go[1:-1] if station != end_station] + [end_station]
        path_go = {trip[0]: '->'.join(go_list)}

    item = {'title': title,
            'category': category,
            'time': time,
            'price': price,
            'company': company,
            'trip': trip,
            'path_go': path_go,
            'path_back': path_back,
            }
    bus_info = WorkItem()  # 实例化item
    for field in bus_info.fields:
        bus_info[field] = eval(field)
    yield bus_info

需要注意的是,并不是所有的公交线路的起始站点和终点站都不一样。在处理信息的过程中出现了起始站和终点站相同的情况,如下图的五间楼-五间楼

这里需要对其进行进一步的处理,这里直接添加的if判断,具体实现见上面的代码。。

bus_info = WorkItem()  # 实例化item
    for field in bus_info.fields:
        bus_info[field] = eval(field)
    yield bus_info

这一段代码可以抽象为一个模板,将自己需要提取的信息放到bus_info里面即可。然后yield bus_info。前面也提到过item的使用方法和字典类似。

2.3 items.py

接下来写`items.py:

items.py中的信息名称需要和parse_detail中的实例化的item名对应起来。

本文具体函数设计如下:

class WorkItem(scrapy.Item):
    title = scrapy.Field()
    category = scrapy.Field()
    time = scrapy.Field()
    price = scrapy.Field()
    company = scrapy.Field()
    trip = scrapy.Field()
    path_go = scrapy.Field()
    path_back = scrapy.Field()

🪄tips:

scrapy.Field(),类似于字典。在spider中实例化,即parse_detail()bus_info = WorkItem()实例化,然后取值的形式类似于字典,即代码中的bus_info[field] = eval(field)

2.4 pipelines.py

接下来需要设计写入到数据库的pipelines.py。本文以写入本地的MongoDB数据库为例,MongoDB数据库的版本为6.x。

首先在settings.py中添加

# 启用pipeline
ITEM_PIPELINES = {
   'work.pipelines.MyMongoPipeline': 300,
}

# 数据库相关设置
DB_HOST = 'localhost'
DB_NAME = 'bus_spider'
DB_COLLECTION_NAME = 'ipad'

ITEM_PIPELINES中的300是分配给每个类的整型值,确定了他们运行的顺序,item按数字从低到高的顺序,通过pipeline,通常将这些数字定义在0-1000范围内(0-1000随意设置,数值越低,组件的优先级越高)。

item pipiline组件是一个独立的Python类,其中process_item()方法必须实现,item pipeline一般应用在:

  • 验证爬取的数据(检查item包含某些字段,比如说name字段)
  • 查重(并丢弃)
  • 将爬取结果保存到文件或者数据库中

这里设计的pipelines.py是将数据存储到本地的MongoDB中,py连接MongoDB就不用详细说了,具体设计如下:

from pymongo import MongoClient
from .settings import *


class MyMongoPipeline:
    def __init__(self):
        self.client = MongoClient(host=DB_HOST)
        self.db = self.client.get_database(DB_NAME)
        self.collection = self.db[DB_COLLECTION_NAME]

    def process_item(self, item, spider):
        # 这个方法必须返回一个 Item 对象,被丢弃的item将不会被之后的pipeline组件所处理
        self.collection.insert(dict(item))
        return item

    def close_spider(self, spider):
        self.client.close()

如果想将数据存为json的格式,那么可以参考以下代码:

import json

class ItcastJsonPipeline(object):

    def __init__(self):
        self.file = open('xxx.json', 'wb')

    def process_item(self, item, spider):
        content = json.dumps(dict(item), ensure_ascii=False) + "\n"  # 这里先对item进行了转型-变为dict
        self.file.write(content)
        return item

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

3.代码部分

3.1 运行界面

爬取数据成功界面

mongodb数据查询界面

3.2 具体代码

# Buscrawl.py
import scrapy
from scrapy import FormRequest, Request
import logging

from urllib.parse import urljoin
from ..items import WorkItem

logging.getLogger("filelock").setLevel(logging.INFO)


class BuscrawlSpider(scrapy.Spider):
    name = 'BusCrawl'
    allowed_domains = ['beijing.8684.cn']
    start_urls = 'http://beijing.8684.cn'

    def __init__(self, name=None, **kwargs):
        super().__init__(name=None, **kwargs)

    def start_requests(self):
        for page in range(3):
            url = '{url}/list{page}'.format(url=self.start_urls, page=page + 1)
            yield FormRequest(url, callback=self.parse_index)

    def parse_index(self, response):
        hrefs = response.xpath('//div[@class="list clearfix"]/a/@href').extract()
        for href in hrefs:
            detail_url = urljoin(self.start_urls, href)
            yield Request(detail_url, callback=self.parse_detail)

    def parse_detail(self, response):
        title = response.xpath('//h1[@class="title"]/span/text()').extract_first()  # 线路名称
        category = response.xpath('//a[@class="category"]/text()').extract_first()  # 线路类别
        time = response.xpath('//ul[@class="bus-desc"]/li[1]/text()').extract_first()  # 开车时间
        price = response.xpath('//ul[@class="bus-desc"]/li[2]/text()').extract_first()  # 参考票价
        company = response.xpath('//ul[@class="bus-desc"]/li[3]/a/text()').extract()  # 所属公司

        trip = response.xpath('//div[@class="trip"]/text()').extract()  # 开车方向

        path_go, path_back = None, None

        begin_station, end_station = trip[0].split('—')

        if len(trip) > 1:
            path_go = response.xpath('//div[@class="service-area"]/div[2]/ol/li/a/text()').extract()
            path_back = response.xpath('//div[@class="service-area"]/div[4]/ol/li/a/text()').extract()

            go_list = [begin_station] + [station for station in path_go[1:-1] if station != end_station] + [end_station]
            back_list = [end_station] + [station for station in path_back[1:-1] if station != begin_station] + [
                begin_station]

            path_go = {trip[0]: '->'.join(go_list)}
            path_back = {trip[1]: '->'.join(back_list)}
        else:
            path_go = response.xpath('//div[@class="bus-lzlist mb15"]/ol/li/a/text()').extract()
            go_list = [begin_station] + [station for station in path_go[1:-1] if station != end_station] + [end_station]
            path_go = {trip[0]: '->'.join(go_list)}

        item = {'title': title,
                'category': category,
                'time': time,
                'price': price,
                'company': company,
                'trip': trip,
                'path_go': path_go,
                'path_back': path_back,
                }
        bus_info = WorkItem()
        for field in bus_info.fields:
            bus_info[field] = eval(field)
        yield bus_info
# items.py
import scrapy

class WorkItem(scrapy.Item):
    title = scrapy.Field()
    category = scrapy.Field()
    time = scrapy.Field()
    price = scrapy.Field()
    company = scrapy.Field()
    trip = scrapy.Field()
    path_go = scrapy.Field()
    path_back = scrapy.Field()
# pipelines.py
from pymongo import MongoClient
from .settings import *


class MyMongoPipeline:
    def __init__(self):
        self.client = MongoClient(host=DB_HOST)
        self.db = self.client.get_database(DB_NAME)
        self.collection = self.db[DB_COLLECTION_NAME]

    def process_item(self, item, spider):
        self.collection.insert(dict(item))
        return item

    def close_spider(self, spider):
        self.client.close()
# settings.py

BOT_NAME = 'work'

SPIDER_MODULES = ['work.spiders']
NEWSPIDER_MODULE = 'work.spiders'
ROBOTSTXT_OBEY = False

DEFAULT_REQUEST_HEADERS = {
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  'Accept-Language': 'en',
  "User-Agent": "xxx"
}

ITEM_PIPELINES = {
   'work.pipelines.MyMongoPipeline': 300,
}

DB_HOST = 'localhost'
DB_NAME = 'bus_spider'
DB_COLLECTION_NAME = 'ipad'

4.可能出现的报错:

1.找不到WorkPipeline

原因:自己修改了pipelines.py里面的类名,但是没有修改settings.py里面的pipelines名称。

解决方法settings.py中的ITEM_PIPELINES里面的.MyMongoPipeline一定要和pipelines.py里面的类名对上。

ITEM_PIPELINES = {
   'work.pipelines.MyMongoPipeline': 300,
}

2. [filelock] DEBUG: Attempting to acquire lock 4469884432 on ...... __a22fb8__tldextract-3.3.1/

原因:其实不是报错,如果不想在控制台的看到的话,直接设置一下logging的打印级别即可。

解决方法

import logging

logging.getLogger("filelock").setLevel(logging.INFO)

参考:github.com/scrapy/scra…

3..raise KeyError(f"{self.class.name} does not support field: {key}")

原因:KeyError: 'WorkItem does not support field: _id'

解决方法:将item转为dict类型即可,即在插入数据时使用:

self.collection.insert(dict(item))

参考链接

1.blog.csdn.net/m0_59483606…

2.blog.csdn.net/songrenqing…