引言
说完了爬虫的一些基础,这一篇是记录如何爬取伯乐在线的所有文章的内容。
爬取策略
打开伯乐在线,可以看到这里,网站已经提供了所有文章的一个分类,也就不需要深度优先或者广度优先算法了。
然后分页提取的时候,选择把下一页的链接提取出来,这样当页面总数增加的时候也不需要去更改源码或者配置文件。而不是选择更改链接里面的页面数字。
创建Scrapy项目
安装Scrapy
安装Scrapy可以上这里下载。
如果中途提示需要什么依赖,也可以在里面下载。
创建
创建的时候,在你的Pycharm里面新建一个项目先,然后在这个项目的目录下面用Shift+鼠标右键
,打开Powershell
或者命令行,执行scrapy startproject 爬虫名字
,再用Pycharm打开就可以了。
遇到的问题
首先遇到了这样一个错误1
UserWarning: You do not have a working installation of the service_identity module: 'cannot import name 'opentype''.
提示我缺少一个包service_identity
,然后还是上刚刚的网站下载service_identity
这个库。
安装service_identity
这个库的时候报错:1
pyasn1-modules 0.2.1 has requirement pyasn1<0.5.0,>=0.4.1, but you'll have pyasn1 0.1.9 which is incompatible.
提示我pyasn1-modules
这个库版本太低。还是去刚刚的网站下载pyasn1
这个库,不是pyasn1-modules
。
安装pyasn1
的时候提示1
Cannot uninstall 'pyasn1'. It is a distutils installed project and thus we cannot accurately determine which files belong to it which would lead to only a partial uninstall.
然后使用了这个解决方案。不知道正确与否,但是好使。
Scrapy目录
- settings: 设置
- pipelines: 和数据存储有关的文件
- middlewares: 中间件是介入到Scrapy的spider处理机制的钩子框架
- items: 定义数据保存的格式
- spiders文件夹:具体的一个网站的爬虫文件
创建一个网站爬虫文件
在爬虫的根目录下面执行scrapy genspider jobbole blog.jobbole.com
,这是以伯乐在线为例的。可以看到spiders文件夹里面就有了一个jobbole.py
文件。
让Scrapy可以调试
在爬虫根目录下执行scrapy crawl jobbole
,如果没有报错,显示的是Spider closed (finished)
。就在根目录下面创建一个main.py
文件,结构如下:
写入下面内容:1
2
3
4
5
6from scrapy.cmdline import execute
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__))) # 得到目录
execute(["scrapy", "crawl", "jobbole"])
这样就可以在Pycharm里面调试Scrapy框架了。
PS:将settings.py
文件里面的ROBOTSTXT_OBEY = False
这一项注释去掉并改为False
。这一项的意思是是否根据网站的Robots协议
来爬取。
提取一个文章的内容
xpath语法
在网页中找到某一个东西的位置,就免不了要用xpath或者css,下面是一些常用语法:
xpath提取
打开一篇文章,用F12
看网页源码,用选择按钮,选择文章标题,然后右键生成xpath。
经过对比会发现,chrome和火狐浏览器生成的xpath不一样,而且火狐浏览器的xpath不能正确读取出标题。1
2chrome: //*[@id="post-114159"]/div[1]/h1
火狐浏览器:/html/body/div[1]/div[3]/巴拉巴拉
因为浏览器在F12
里面源码是经过浏览器渲染而来,定位不准。
PS:div的id必须是全局唯一的,通常可以用这个来定位div。或者calss也可以。
单页面调试
每次验证xpath是否正确的时候,都要重新启动一次框架,而启动一次框架很费时,所以Scrapy提供一种shell调试,可以对一个页面进行反复调试。
在爬虫项目根目录下执行scrapy shell 网址
,就可以进入一个交互,然后对单个页面进行反复调试而只用访问一次。
用xpath提取内容
- 标题
- 时间
- 正文
- 点赞数
- 评论数
- 收藏数
标题
首先在页面上按F12
打开分析框。然后用元素选择工具选择标题,如下图:
可以看到,这个标题是在h1
标签里面的,然后h1
标签上面有两个div
,第一个div
最后面有一个id
,所以我们就用这个id
来定位这个div
,进而定位标题的位置。
在之前说的单页调试shell
先读取一个页面,以这一页为例,就是:1
scrapy shell http://blog.jobbole.com/114159/
然后执行:1
2
3
4response.xpath('//*[@id="post-114159"]/div[1]/h1')
输出:
[<Selector xpath='//*[@id="post-114159"]/div[1]/h1' data='<h1>通过可写文件获取 Linux root 权限的 5 种方法</h1>'>]
可以看到输出为一个选择器,在Scrapy
里面,用xpath
选择的返回值是一个选择器,为了可以二次筛选。而且data
里面带有标签,这里在xpath
最后加上text()
可以去掉前后的标签,再用extract
方法读取这个data
,这个方法会忽略这个节点下面的所有子节点。1
2
3
4response.xpath('//*[@id="post-114159"]/div[1]/h1/text()').extract()
输出:
['通过可写文件获取 Linux root 权限的 5 种方法']
返回值是一个数组,取第一个,就得到了标题。1
title = response.xpath('//*[@id="post-114159"]/div[1]/h1/text()').extract()[0]
并把这个保存到title
变量里面,写到jobbole.py
的parse
方法里面。在最后会给出jobbole.py
的完整代码。
时间
同理可找到时间的位置:
在一个p
标签下面,可以看到这个p
标签有一个class
属性,查找可以知道,这个class
的值是惟一的,于是就可以用这个来定位。
还是现在shell
里面调试看看是不是正确的。1
2
3
4response.xpath("//p[@class='entry-meta-hide-on-mobile']/text()").extract()[0]
输出:
'\r\n\r\n 2018/06/26 · '
用strip
方法去除这些回车和空格,用replace
方法去除后面的·
,再调用strip
方法去除空格。写入爬虫项目里面去。
点赞数
还是一样找到点赞数的位置:
是在一个span
标签下面的,分析发现这个标签的class
中,vote-post-up
是全局唯一的,就用这个来定位。
还是现在shell
里面调试看看是不是正确的。1
2
3
4response.xpath("//span[contains(@class, 'vote-post-up')]/h10/text()").extract()[0]
输出:
'1'
这里面要用到一个contains
方法,是一个xpath
内置的,看这个span
标签的class
有很多个(每一个之间用空格分开了),直接用@class='vote-post-up'
是找不到的,因为class
并不只有这个,所以要用这个函数,表示某个属性里面包含这个值。
收藏数、评论数、正文
同理,就是收藏数评论数需要用正则表达式来匹配一下。
jobble.py文件全代码
1 | # -*- coding: utf-8 -*- |
用css提取内容
常见的css语法
jobble.py文件全代码
因为和xpath
类似,就不再一一介绍,直接给出全部代码。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31# -*- coding: utf-8 -*-
import scrapy
import re
class JobboleSpider(scrapy.Spider):
name = 'jobbole'
allowed_domains = ['blog.jobbole.com']
start_urls = ['http://blog.jobbole.com/114159/']
def parse(self, response):
title = response.css(".entry-header h1::text").extract()[0]
creat_date = response.css("p.entry-meta-hide-on-mobile::text").extract()[0].strip().strip().replace("·", "").strip()
praise_nums = response.css(".vote-post-up h10::text").extract()[0]
fav_nums = response.css(".bookmark-btn::text").extract()[0]
match_re = re.match(r".*?(\d+).*", fav_nums)
if match_re:
fav_nums = match_re.group(1)
else:
fav_nums = 0
comment_nums = response.css("a[href='#article-comment'] span::text").extract()[0]
match_re = re.match(r".*?(\d+).*", comment_nums)
if match_re:
comment_nums = match_re.group(1)
else:
comment_nums = 0
content = response.css("div.entry").extract()[0]
tags_list = response.css("p.entry-meta-hide-on-mobile a::text").extract()
tags_list = [element for element in tags_list if not element.strip().endswith("评论")]
tags = ','.join(tags_list)
pass
extract
方法可以更改为extract_first
,可以防止后面取数组第一个的时候异常报错。
所有文章URL提取
上面是针对某一面的字段解析,但是我们需要的是从第一面开始,提取出每一面的所有文章URL,然后进入具体文章URL并解析字段,然后再进入下一页,进行上述工作。
每一页URL提取
从这一页开始,可以找到每一个文章的URL是位于这样的位置:1
post_urls = response.css('#archive .floated-thumb .post-thumb a::attr(href)').extract()
交给Scrapy
下载则用其自带的一个类Resquest
:1
2
3
4from scrapy.http import Request
from urllib import parse
for post_url in post_urls:
yield Request(url=parse.urljoin(response.url, post_url), callback=self.parse_detail)
其中用上了parse
类,是因为有些网站的href
提取出来的并不是完整的链接,所以要用urljoin
方法拼出完整的链接。
下一页链接提取
1 | next_url = response.css(".next.page-numbers::attr(href)").extract_first("") |
jobble.py文件全代码
1 | # -*- coding: utf-8 -*- |
集中存储数据
提供的items
模块,就是用来集中保存数据的。在items
里面写一个新的class
,然后写入想保存的量。1
2
3
4
5
6
7
8
9
10
11
12class JobboleArticleItem(scrapy.Item):
title = scrapy.Field()
creat_date = scrapy.Field()
url = scrapy.Field()
url_object_id = scrapy.Field()
front_image_url = scrapy.Field()
front_image_path = scrapy.Field()
praise_nums = scrapy.Field()
comment_nums = scrapy.Field()
fav_nums = scrapy.Field()
tags = scrapy.Field()
content = scrapy.Field()
然后在jobbole.py
的parse_detail
方法最后加入:1
2
3
4
5
6
7
8
9
10
11
12article_item = JobboleArticleItem() # 记得在最开始import进来这个类
article_item["title"] = title
article_item["url"] = response.url
article_item["creat_date"] = creat_date
article_item["front_image_url"] = front_image_url
article_item["praise_nums"] = praise_nums
article_item["comment_nums"] = comment_nums
article_item["fav_nums"] = fav_nums
article_item["tags"] = tags
article_item["content"] = content
yield article_item
并反注释setting
里面的ITEM_PIPELINES
这个字段。
下载图片
想要下载图片,就还需要修改setting
里面的ITEM_PIPELINES
这个字段为:1
2
3
4
5
6
7ITEM_PIPELINES = {
'ArticleSpider.pipelines.ArticlespiderPipeline': 300,
'scrapy.pipelines.images.ImagesPipeline': 1,
}
IMAGES_URLS_FIELD = "front_image_url"
project_dir = os.path.abspath(os.path.dirname(__file__))
IMAGES_STORE = os.path.join(project_dir, 'images')
其实也就是加了一个字段在ITEM_PIPELINES
里面。然后IMAGES_URLS_FIELD
是告诉Scrapy
你要下载的图片链接,并保存到指定的路径IMAGES_STORE
里面去。
如果现在运行,理论上来说会把所有的图片都保存到image
这个文件夹下面。但是会报错,因为IMAGES_URLS_FIELD
这个字段后面的front_image_url
默认传进去的会是一个数组,所以要改前面的article_item["front_image_url"] = [front_image_url]
。
在setting
里面IMAGE_MIN_HEIGHT
和IMAGE_MIN_WIDTH
这两个字段可以设置保存的图片最小尺寸。
我们还要获取图片保存的一个路径,那么就需要在pipelines.py
里面重新写一个class
:1
2
3
4
5
6
7
8from scrapy.pipelines.images import ImagesPipeline
class ArticleImagePipeline(ImagesPipeline):
def item_completed(self, results, item, info):
for ok, value in results:
image_file_path = value['path']
item['front_image_path'] = image_file_path
return item
然后修改setting
让Scrapy
执行我们的pipelines
:1
2
3
4
5
6
7
8ITEM_PIPELINES = {
'ArticleSpider.pipelines.ArticlespiderPipeline': 300,
# 'scrapy.pipelines.images.ImagesPipeline': 1,
'ArticleSpider.pipelines.ArticleImagePipeline': 1,
}
IMAGES_URLS_FIELD = "front_image_url"
project_dir = os.path.abspath(os.path.dirname(__file__))
IMAGES_STORE = os.path.join(project_dir, 'images')
将刚刚的scrapy.pipelines.images.ImagesPipeline
注释掉,改为自己写的。
生成URL的MD5
这个相对来说比较简单,先在ArticleSpider
下面新建一个utils
包,然后新建一个common.py
文件目录结构如下
里面写入:1
2
3
4
5
6
7
8import hashlib
def get_md5(url):
if isinstance(url, str):
url = url.encode('utf8')
m = hashlib.md5()
m.update(url)
return m.hexdigest()
然后在jobbole.py
文件里面导入这个函数,再在parse_detail
里面新增一行:1
article_item["url_object_id"] = get_md5(response.url)
而url_object_id
这个字段之前就已经加在了items.py
文件中了。
保存Json格式文件
这个也是通过pipelines.py
文件里面新添一个class
,如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17from scrapy.exporters import JsonItemExporter
class JsonExporterPipeline(object):
# 调用Scrapy提供的json export到处json文件
def __init__(self):
self.file = open('articleexport.json', 'wb')
self.exporter = JsonItemExporter(self.file, encoding='utf8', ensure_ascii=False)
self.exporter.start_exporting()
def close_spider(self, spider):
# 结束时要关闭文件
self.exporter.finish_exporting()
self.file.close()
def process_item(self, item, spider):
self.exporter.export_item(item)
return item
然后同样要在settings
里面ITEM_PIPELINES
:1
2
3ITEM_PIPELINES = {
'ArticleSpider.pipelines.JsonExporterPipeline': 2,
}
ITEM_PIPELINES
里面后面的数字表示下面这些类的处理顺序。
保存数据到数据库
安装MySQL数据库服务,创建数据库就不多说了,可以使用navicat
这个软件来链接数据库。
数据库叫articlespider
,创建的表jobbole_article
如下:
还是同样,处理数据这一块都放在pipelines.py
文件里面,在里面写一个类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import MySQLdb
class MysqlPipeline(object):
# 采用同步的机制操作数据库
def __init__(self):
self.conn = MySQLdb.connect('地址', '用户名', '密码', '数据库名', charset='utf8', use_unicode=True)
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, comment_nums, fav_nums, praise_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_image_url'], item['front_image_path'], item['comment_nums'], item['fav_nums'], item['praise_nums'], item['tags'], item['content']))
self.conn.commit()
return item
但是这样的一种写法是一种同步机制,就是同步的插入数据库。这样就会产生一个问题,当数据库越来越大的时候,插入数据就会越来越慢,程序就回卡在excute
这一步,所以可以用Scrapy
的一种异步机制,来操作数据库,同样是写在pipelines.py
里面的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43from twisted.enterprise import adbapi
import MySQLdb
import MySQLdb.cursors
class MysqlTwistedPipeline(object):
# 异步操作插入数据库
# 这个方法会将setting传进来
def __init__(self, dbpool):
self.dbpool = dbpool
# 这个方法可以调用`settings`里面的参数,固定名称
def from_settings(cls, settings):
dbparms = dict(
host = settings['MYSQL_HOST'],
user = settings['MYSQL_USER'],
passwd = settings['MYSQL_PASSWORD'],
db = settings['MYSQL_DBNAME'],
charset = 'utf8',
cursorclass = MySQLdb.cursors.DictCursor,
use_unicode = True
)
dbpool = adbapi.ConnectionPool('MySQLdb', **dbparms)
return cls(dbpool)
def process_item(self, item, spider):
# 使用twisted将mysql插入变成异步执行
query = self.dbpool.runInteraction(self.do_insert, item)
query.addErrback(self.handle_error) # 处理异常
return item
def do_insert(self, cursor, item):
insert_sql = """
insert into jobbole_article(title, create_date, url, url_object_id, front_image_url, front_image_path, comment_nums, fav_nums, praise_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_image_url'], item['front_image_path'], item['comment_nums'], item['fav_nums'], item['praise_nums'], item['tags'], item['content']))
def handle_error(self, failure):
# 处理异步插入的异常
print(failure)
然后在settings
最后添加字段:1
2
3
4MYSQL_HOST = "地址"
MYSQL_DBNAME = "数据库名"
MYSQL_USER = "用户名"
MYSQL_PASSWORD = "密码"
上面两个任选一个都行,然后一样要配置settings
的ITEM_PIPELINES
,就不赘述了。
更精简的Item写法
以往的写法是将想要提取的字段通过css
或者xpath
选择器将其选出,然后再存到item
实例里面。但是等我们要提取的字段多了以后,维护起来就会是一场噩梦,所以介绍一种新的保存item
的工具,叫做ItemLoader
。
在jobbole.py
文件的parse_detail
方法中加入:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17from ArticleSpider.items import ArticleItemLoader
# Itemloader加载item
front_image_url = response.meta.get("front_image_url", "") # 封面图,用get不会抛异常
item_loader = ArticleItemLoader(item=JobboleArticleItem(), response=response)
item_loader.add_css('title', ".entry-header h1::text")
item_loader.add_value('url_object_id', get_md5(response.url))
item_loader.add_value('url', response.url)
item_loader.add_css('create_date', "p.entry-meta-hide-on-mobile::text")
item_loader.add_value('front_image_url', [front_image_url])
item_loader.add_css('praise_nums', ".vote-post-up h10::text")
item_loader.add_css('comment_nums', "a[href='#article-comment'] span::text")
item_loader.add_css('fav_nums', ".bookmark-btn::text")
item_loader.add_css('tags', "p.entry-meta-hide-on-mobile a::text")
item_loader.add_css('content', "div.entry")
article_item = item_loader.load_item()
这里面的用法看起来也很简单,就是先实例化了一个自定义的类,在下面给出,然后用这个实例的方法为每个字段添加css
或者xpath
选择器或者直接赋值,但是这里保存进去之后会变成一个list
,后面会介绍如何处理成我们想要的值。
并在items.py
文件中加入新的类:1
2
3
4
5from scrapy.loader import ItemLoader
class ArticleItemLoader(ItemLoader):
# 自定义itemloader
default_output_processor = TakeFirst()
这个类在刚刚的jobbole.py
文件中用到。
然后修改items.py
文件中JobboleArticleItem
这个类,并添加一些新的方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67import re
import datetime
from scrapy.loader.processors import MapCompose, TakeFirst, Join
class ArticleItemLoader(ItemLoader):
# 自定义itemloader
default_output_processor = TakeFirst()
def date_convert(value):
# 获取日期
try:
create_date = datetime.datetime.strptime(value, "%Y/%m/%d").date()
except Exception as e:
create_date = datetime.datetime.now().date()
return create_date
def get_nums(value):
# 获取评论数和收藏数
match_re = re.match(r".*?(\d+).*", value)
if match_re:
nums = int(match_re.group(1))
else:
nums = 0
return nums
def remove_comment_tags(value):
# 去除tags中的 评论 字样
if '评论' in value:
return ''
else:
return value
def return_value(value):
return value
class JobboleArticleItem(scrapy.Item):
title = scrapy.Field(
input_processor=MapCompose(return_value)
)
create_date = scrapy.Field(
input_processor=MapCompose(date_convert)
)
url = scrapy.Field()
url_object_id = scrapy.Field()
front_image_url = scrapy.Field(
output_processor=MapCompose(return_value)
)
front_image_path = scrapy.Field()
praise_nums = scrapy.Field(
input_processor=MapCompose(get_nums)
)
comment_nums = scrapy.Field(
input_processor=MapCompose(get_nums)
)
fav_nums = scrapy.Field(
input_processor=MapCompose(get_nums)
)
tags = scrapy.Field(
input_processor=MapCompose(remove_comment_tags),
output_processor=Join(',')
)
content = scrapy.Field()
刚刚说到,我们只是把字段和选择器匹配了起来,我们还需要对选择器返回的值进行处理,这里就需要给每个字段定义的时候,加上input_processor
,这个后面就是表示输入的值会被进行一些什么操作,比方说MapCompose
这个函数就是依次执行传进去的函数。
以create_date
为例,就是将前面删选的值,传进date_convert
函数,然后最后保存为一个list
,怎么让输出不是list
呢,就是刚刚添加的新的类ArticleItemLoader
,它继承了ItemLoader
重载了default_output_processor
这个值,TakeFirst
就是取list
的第一个值作为输出。
其中tags
的输出我们不希望是输出第一个,就用Join
函数将所有的标签连在一起,和python
自带的join
一样。
而front_image_url
不希望取第一个,而是保持list
输出,就用了一个什么都没做的函数覆盖默输出函数。
还有一点就是,要更改pipelines.py
中的下载文件类:1
2
3
4
5
6
7class ArticleImagePipeline(ImagesPipeline):
def item_completed(self, results, item, info):
if 'front_image_url' in item:
for ok, value in results:
image_file_path = value['path']
item['front_image_path'] = image_file_path
return item
所有代码
很想贴在这里,可是想一想太长了,就还是放在github上面吧。
后记
与其说是教程,笔记可能更加合适吧。如果大家有什么问题,欢迎留言~~~