基于Scrapy的网络爬虫案例

前言

这是在公司实习时,领导给出的一个数据爬取需求。(所以涉及法律问题请找公司的麻烦,不要联系我😂)

虽然我之前也干过网络爬虫,但都是在比较完整的框架下进行实现:以前只需要实现URL解析和网页源码抽取接口,其他像网络爬虫配置,数据存储,数据抽取,数据导出,任务启动和停止等模块只需要鼠标click一下或者根本就是“黑盒”的,不需要我操心。。

这一次,我基本是从“0”开始。所以在完成任务的过程中学到了很多。(当然也还不够多)

由于爬取的网站是一个经典的“列表-详情”结构,且爬取难度不难(网站没有设置反爬),作为爬虫初学者很合适,所以我把该任务的实现过程记录下来,方便自己学习。

本篇博客将记录我在实现该任务时,学到的知识、遇到的困难、对应的解决方案。

通过阅读本篇博客你将获得

  • 使用Scrapy进行简单的网页数据爬取
  • 使用BeautifulSoup解析HTML
  • 使用Pandas做简单的数据读取、去重、分析操作

不能获得:

  • Scrapy高级使用、框架结构、底层原理
  • 破解数据采集时遇到的反爬

废话比较多。先来看一下阅读本文需要的前置知识。

阅读本文需要的前置知识

阅读本篇博客需要一定的前置知识。我将列举一些,可能不全。

  • 网络爬虫:什么是网络爬虫
  • //Conda:什么是Conda,Conda安装、环境创建等
  • Python 基础:基础数据结构及操作、文件I/O操作
  • PyCharm使用:如何配置开发环境,连接远程环境进行开发等
  • //Linux 服务器基本命令
  • HTML基础、CSS选择器等前端基础知识。
  • XPath数据定位、正则表达式匹配等知识。

如果你有以上基础,那么阅读本篇博客基本没有障碍。

下面介绍我的工作环境。

工作环境

软件名称 备注 版本
Windows 操作系统 10
PyCharm 开发IDE 2019.3.5
Ananconda Python环境管理和包管理工具 懒得写了
Scrapy Python开源模块,爬虫框架 懒得写了
BeautifulSoup Python开源模块,解析HTML 懒得写了
Pandas Python开源模块,这里用它做简单的数据提取 懒得写了

下面介绍具体需求。

需求

爬取网站 孕育百科 各分类下的孕育问答。结果以CSV格式给出。

流程

打开 孕育百科 可以看到有很多孕育百科的分类,选择一个分类,比如,这里我们选择【准备怀孕】。

分类

新打开的网页为https://www.yunyubaike.com/beiyun/

可以看到这里有一些子分类:

分类和子分类

这些分类和子分类下有很多孕育百科问答,我们点击一个问题的超链接,在弹出的详情页中给出问题的答案,如图:

孕育百科-详情页

把这些问题和答案“抽取”到到CSV文件中,作为最终结果。

分析

首先我们需要分析该网站,例如网站数据量大小,网站网页结构,网站跳转方式,数据传输方式,数据是否加密,网站反爬严不严重等等。

可以抓包、写网页下载代码测试一下。

据此指定网站采集策略,是使用本地IP就可以采集还是需要设置一批代理IP,数据文件是存放到本地还是需要存放到Hadoop,是否需要可视化(Selenium)采集等等。

采集策略

  1. 先采集分类(首页)URL;
  2. 根据分类(首页)URL不断循环生成分页(列表页);
  3. 爬取每个列表页中的详情URL;
  4. 下载详情URL的HTML文件,保存到本地;
  5. 解析本地HTML,生成CSV文件。

实现

采集分类

仔细分析网站可以发现,在采集分类存在两个坑。

  • 不是所有分类都是孕育百科:对于不是分类的URL可以直接丢弃。
  • 子分类下和分类存在重复:去重。

由于子分类和分类不是很多,这里使用的手工采集。

虽说手工采集,但还是需要一点小小的技巧。这里不是挨个复制网页源码中的分类连接,而是通过Chrome的一个插件XPath Helper

该插件支持通过写XPath的方式批量获得信息。

例如我们要获取上图【分类和子分类】中的子分类。先查看这部分的网页源码

子分类网页源码

打开XPath Helper,写入XPath获得结果。

XPath Helper截图

最终获得大小分类共72个。

列表页翻页采集详情页URL

每个分类下有很多翻页,通过不断的翻页采集所有的详情URL。

翻页

这里我一开始偷懒,用的“八爪鱼采集器”。后来发现这软件虽然不需要写代码,但是对于大于1W的数据需要收费,就放弃了。

后续我基于Scrapy爬虫框架完成采集。

新建Scrapy项目

首先我们需要新建一个Scrapy项目(前提是已经安装了Scrapy)

scrapy startproject yybkSpider

其中, yybkSpider为项目名称,可以看到将会创建一个yybkSpider文件夹,目录结构大致如下:

下面来简单介绍一下各个主要文件的作用:

yybkSpider/
scrapy.cfg
yybkSpider/
__init__.py
items.py
pipelines.py
settings.py
spiders/
__init__.py

这里介绍我认为比较重要的文件:

  • settings.py: 项目的配置文件,默认下载的网页是Unicode,需要在这里配置为UTF-8编码。
  • yybkSpider/: 项目的Python模块,将会从这里引用代码。
  • yybkSpider/items.py: 保存数据的实体,有点像Java Bean。
  • yybkSpider/yybkSpider/: 存储爬虫代码目录。

修改配置

这里默认下载的网页为UTF-8,所以需要在settings.py中增加配置FEED_EXPORT_ENCODING = 'utf-8'

还可以修改下载的线程数。但是我查阅了网上的相关资料,好像作用并不大。由于对Python和Scrapy不是很了解,这里直接给出连接:

制作爬虫

紧接着,制作一个爬虫:

  • Input:所有分类首页
  • Output:
    • 所有列表页
    • 每个列表页的详情URL和其他信息。

进入yybkSpider/目录下,输入命令创建爬虫:

scrapy genspider yybk_list "yunyubaike.com"
  • yybk_list是爬虫的名称
  • "yunyubaike.com"指出该爬虫爬取的域名,超出域名的不会爬取。

之后可以看到在yybkSpider/yybkSpider/生成了一个文件yybk_list.py,它默认增加了下列代码:

import scrapy

class YybkListSpider(scrapy.Spider):
name = 'yybk_list'
allowed_domains = ['yunyubaike.com']
start_urls = []

def parse(self, response):
pass

name = “” :这个爬虫的识别名称,必须是唯一的,在不同的爬虫必须定义不同的名字。

allow_domains = [] 是搜索的域名范围,也就是爬虫的约束区域,规定爬虫只爬取这个域名下的网页,不存在的URL会被忽略。

start_urls = [] :爬取的URL列表。爬虫从这里开始抓取数据,所以,第一次下载的数据将会从这些urls开始。其他子URL将会从这些起始URL中继承性生成。

parse(self, response) :解析的方法,每个初始URL完成下载后将被调用,调用的时候传入从每一个URL传回的Response对象来作为唯一参数,主要作用如下:

  • 负责解析返回的网页数据(response.body),提取结构化数据(生成item)
  • 生成需要下一页的URL请求。

接下来我们完成该爬虫。首先start_urls不是一个而是72个分类首页URL。再者我们要在parse方法中完成两件事:

  1. 不断生成下一页
  2. 对于每个列表页解析其中的数据。

下面是完整代码,这部分参考了官方文档:https://docs.scrapy.org/en/latest/intro/overview.html。

这里有几个地方值得注意:

  1. 通过response.url获得当前采集的URL
  2. response.xpath('')返回的是一组元素
  3. 我把response.xpath('')[0].get()都改写了,原因是如果response.xpath('')为空在执行[0]操作就会报错,导致爬取失败
  4. 使用get()而不是exract()。原因如下:

get() 、getall() 是新版本的方法,extract() 、extract_first()是旧版本的方法。

前者更好用,取不到就返回None,后者取不到就raise一个错误。

  1. 想要在当前元素下继续通过XPath选取元素,需要在XPath开头加一个.
  2. 注意yield的使用
  3. 这里采集了很多“多余”的字段,例如页码,总数,当前url。这是为了后续验证采集的数据是否完整正确而增加的冗余字段。
import scrapy

class YybkListSpider(scrapy.Spider):
name = 'yybk_list'
allowed_domains = ['yunyubaike.com']
# 一共是72个URL,这里没有列全
start_urls = ['https://www.yunyubaike.com/yunqi/',
'https://www.yunyubaike.com/yichuanyousheng/',
'https://www.yunyubaike.com/beiyun/']

def parse(self, response):
domain_url = "https://www.yunyubaike.com"
# items = []
this_url = response.url
page_nums = response.xpath("//div[@class='pagebar']/b/text()")
page_num = ''
if page_nums:
page_num = page_nums[0].get()
# page_num = response.xpath("//div[@class='pagebar']/b/text()")[0].get()
counts = response.xpath("//div[@class='pagebar']/a[@title]/b/text()")
count=''
if counts:
count = counts[0].get()
# count = response.xpath("//div[@class='pagebar']/a[@title]/b/text()")[0].get()
for li in response.xpath("//div[@class='list']/ul/li"):
titles = li.xpath('.//h2/a/text()')
title = ''
if titles:
title = titles[0].get()
detail_urls = li.xpath('.//h2/a/@href')
detail_url = ''
if detail_urls:
detail_url = detail_urls[0].get()
# detail_url = li.xpath('.//h2/a/@href')[0].get()
yield {
'this_url': this_url, # 当前采集的URL
'page_num': page_num, # 第几页
'count': count, # 该分类下的问答总数
'title': title, # 问答标题
'detail_url': domain_url + detail_url # 详情URL
}
# 生成下一页
next_pages = response.xpath("//a[text()='下一页']/@href")
if next_pages:
next_page = next_pages[0].get()
# next_page = response.xpath("//a[text()='下一页']/@href")[0].get()
if next_page is not None:
yield response.follow(domain_url + next_page, self.parse) # 将下一页的连接传递给爬虫继续解析。

pass

启动爬虫

最后启动爬虫,保存数据。在项目根目录下运行

scrapy crawl yybk_list -o urls.json

可以发现,在项目根目录多出了urls.json文件,里面是我们保存的所有列表数据。

我们也可以保存为其他格式。这里不再赘述。

这样我们就得到了大约27W的详情连接。下面我们把这些连接对应的HTML保存到本地。

下载详情网页源码

制作爬虫

进入yybkSpider/目录下,输入命令创建爬虫:

scrapy genspider yybk_detail_down "yunyubaike.com"

和上面类似,在yybkSpider/yybkSpider/yybk_detail_down.py写入代码:

这里需要注意:

  1. start_urls是需要从文件中读取的
  2. 为了读数据、取数据方便,我使用了pands
  3. 获得网页源码,使用response.text
  4. 这里没有对网页源码直接解析,而是先保存,再在后续解析。提高容错。
import scrapy
import pandas as pd
import os


class YybkDetailDownSpider(scrapy.Spider):
name = 'yybk_detail_down'
allowed_domains = ['yunyubaike.com']
df = pd.read_json("yybkSpider/urls.json")
# 273179条数据
# 取detail_url列,去重,转换成list
start_urls = df['detail_url'].drop_duplicates().tolist()

def parse(self, response):
url = response.url
# 获得网页源码
content = response.text

file_name = url.replace("https://www.yunyubaike.com/", "")
file_name = 'html/' + file_name.replace('/', '_') # html/huaiyun_wiki_270163.html

print("保存文件:" + file_name)
with open(file_name, 'w') as f:
f.write(content)
pass

此外,我担心detail_url格式是否一致,所以在Python Shell中执行了如下代码:

>>> import pandas as pd
>>> df = pd.read_json("yybkSpider/urls.json")
>>> df[df['detail_url'].str.match(r'https://www.yunyubaike.com/\w+/\d+\.html') == False]

执行结果为空,这证明detail_url格式一致。

启动爬虫

紧接着启动爬虫,和上面类似:

scrapy crawl yybk_detail_down

由于这里不需要生成文件,所以没有-o

等待代码执行完毕后,可以发现在项目根目录产生了一个html/文件夹,下面保存了所有详情页的网页源码。

解析详情页

分析

打开一个详情页:

详情页

可以定位到:在<div class='article_content' />标签下,所有的带文字的p标签是我们需要的。每个p标签是一个段落,在保存的时候,段落信息是不能丢失的。也就是说换行符是需要的。

代码

这一步是脱离于Scrapy框架的。

自定义一个Python脚本:

import os
from bs4 import BeautifulSoup

input_dir = 'html/' # 上一步保存源码的文件夹路径
output_file_base_name = 'yybkSpider/output/yybk_{}.csv' # 生成的CSV文件的保存路径
MAX_ROWS = 50000 # 单个csv文件写入的最大行数

'''
解析方法,传入文件名
返回需要的字段
'''
def parse(file_name):
soup = BeautifulSoup(open(file_name, encoding='utf-8'))
# title解析
title = ''
h1 = soup.find_all("h1")
if h1:
title = h1[0].get_text()
# content解析
p_list = soup.select("div.article_content > p")
content = ''
for p in p_list:
p_text = p.get_text()
if p_text != '':
# 为了避免csv解析失败
p_text = p_text.replace(",", ",").replace('"', '')
content += p_text + "\n"
content = content.rstrip().lstrip()
return title, content
pass


def main():
htmls = os.listdir(input_dir)
# 计算最终生成的文件个数
file_num = (len(htmls) + MAX_ROWS - 1) // MAX_ROWS
for i in range(file_num):
# i从0开始
# 第i个文件保存 i*MAX_ROWS[下标] 到 (i+1)*MAX_ROWS - 1
start_index = i*MAX_ROWS
end_index = (i+1)*MAX_ROWS - 1
if end_index > len(htmls):
end_index = len(htmls)
with open(output_file_base_name.format(i), "w") as f:
print("parsing :start_index:{}, end_index:{}; saving as {}"
.format(start_index, end_index, output_file_base_name.format(i)))
f.write("article_id,content,title\n")
for html_name in htmls[start_index: end_index]:
title, content = parse(os.path.join(input_dir, html_name))
f.write(html_name + ',"' + content + '",' + title + '\n')
pass


if __name__ == '__main__':
main()

parse方法中需要注意以下几点:

  1. soup.select("div.article_content > p")这里使用了CSS选择器,制定了div下的直接子标签;
  2. p.get_text()获得p标签下的所有文字;
  3. 为了避免CSV解析失败,这里把英文逗号换成中文逗号,把英文的引号全部去掉。(CSV文件用英文逗号做字段分割;当我们需要把多行数据保存在一个字段中时,需要把多行数据用英文引号包起来);
  4. 使用BeautifulSoup解析HTML。

main方法中需要注意:

  1. 27W行保存到一个CSV文件中,我担心不好把控,所以我将27W行数据每5W行保存为一个CSV
  2. 当需要对除法结果四舍五入时,这样做比较简单:(被除数+除数-1)/除数

运行脚本

python xxx.py

最终导出6个CSV文件。任务完成。

源码下载

https://github.com/guoyujian/blog-resource/tree/main/yybkSpider

此外…

Scrapy Shell快速测试

在使用Scrapy的过程中,我想先传入一条URL测试返回的resonpse。经过查阅使用如下命令:

scrapy shell <url>

执行后,进入Python Console,内置的response对象即是\的response

在服务器运行

如果在本机执行爬虫代码,会占用较多的资源,也不方便。此时可以把爬虫代码同步到服务器运行。我使用PyChram连接远程服务器,在本机编写爬虫代码,然后同步到服务器中执行。(具体请自行百度)

参考资料

  1. Scrapy at a glance
  2. Scrapy 入门教程
  3. CSS选择器
  4. Scrapy shell
文章作者: Met Guo
文章链接: https://guoyujian.github.io/2022/01/09/%E5%9F%BA%E4%BA%8EScrapy%E7%9A%84%E7%BD%91%E7%BB%9C%E7%88%AC%E8%99%AB%E6%A1%88%E4%BE%8B/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Gmet's Blog