持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第23天, 点击查看活动详情
Spider实战
本文将讲解如何使用scrapy框架完成北京公交信息的获取。
目标网址为https://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)
1.相关技术介绍
scrapy是一个为了爬取网站数据,提取结构性数据而编写的应用框架,可以应用在包括数据挖掘,信息处理或存储历史数据等一列的程序中。
scrapy的操作流程如下:
1.选择网站->2.创建一个scrapy项目->3.创建一个spider->4.定义item ->5.编写spider->6.提取item->7.存取爬取的数据->8.执行项目。
下面将依次按照此流程来讲解scrapy框架的使用:
1.选择网站
本文选取的目标url为https://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为自己爬取的目标网址,本文为https://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.py
中robots
协议为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)
参考:https://github.com/scrapy/scrapy/issues/5555
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.https://blog.csdn.net/m0_59483606/article/details/122073900
2.https://blog.csdn.net/songrenqing/article/details/80665966