跟着崔庆才学爬虫2:基础爬虫案例实战

前言

我们之前已经学习爬虫基本库,也对库的使用进行了基本的学习,现在就让我们用一篇实战来加深一下爬虫的具体用法。

准备工作
  1. 爬虫开始前,我们需要先进行如下准备工作。
  2. 安装好python3,最低版本3.6,且能成功运行python3程序。
  3. 了解python多进程的基本原理。
  4. 了解pythonHTTP请求库requests的基本用法。
  5. 了解正则表达式的用法和python中re的用法

爬取目标

本节我们还是用书中的网站进行演示,需要爬取的网站链接为https://ssr1.scrape.center/,这个网站包含了一些电影信息

 网站首页展示了一个由多个电影组成的列表,其中包含了封面,名称,分类,上映时间,评分等等。同时列表还支持翻页,点击页码会跳转响应页面。

如果我们点击其中一部电影,会进入该电影的详情页面,如图

 这个页面的信息会更详细,包含了剧情简介,导演,演员等信息。

接下类就是本章节的学习目标

  1. 利用requests库爬取这个站点的每一页的电影列表,顺着列表再爬取每个电影的详情页。
  2. 利用正则提取每部电影的名称,封面,类别,上映时间,评分,剧情简介等。
  3. 以上爬取的内容保存为json格式文件
  4. 使用多进程实现爬虫的加速
爬取列表页

第一步爬取要从列表页开始,就像进入家里要从门进入一样,首先观察列表页的结构和翻页规则。再浏览器中访问Scrape | Movie网址,然后打开浏览器开发者工具

 观察每一个电影信息区块对应的HTML以及进去到详情的URL,可以发现每部电影对应的区块都是一个div节点,这些节点的class属性都有el-card这个值,每个列表页有10个这样的div节点,也就对应10部电影信息。

接下来在分析一下如何从列表页进入到详情页,我们选中第一部电影看下结果·

 可以看到这个名称实际上是一个h2节点,内部的文字就是电影标题,h2节点外面包含了一个a节点,这个a节点带有点href属性,这就是一个超链接,其中href的值就是/detail/1,这是一个相对网站的根URLScrape | Movieicon-default.png?t=N7T8https://ssr1.scrape.center/的路径,加上/detail/1就构成了一个完整的详情页。这样我们只需要提取这个href属性就可以构造出详情页进行网页爬取了。

接下来我们分析翻页的逻辑,网站拖到最下方,可以看到分页页码。

 可以看到一共有102条数据,页码最大为11。

单击第二页

 可以发现网站的URL变成了Scrape | Movieicon-default.png?t=N7T8https://ssr1.scrape.center/page/2,相比根URL多了一个/page/2这部分内容,网页的结构还是和之前一样,可以和第一页一样处理。接下来我们依次点击3,4,5这些网页,可以发现一个规律,除了首页外,其他的列表页的URL最后分别为/page/3,/page/4,/page/5。所以/page后面跟着的就是列表页的页码,当然第一页也是一样,我们可以再根URL后面加上/page/1,一样可以访问,只不过网站做了处理,默认页码是1,所以第一次显示的是第一页的内容。

ok,以上就是对列表页爬取的分析,下面我们就要着手对于列表页的爬取。

遍历所有页码,构造10页的索引页的URL。

从每一个索引页,分析提取出每个电影的详情页的URL。

下面我们就用代码来实现一下这个过程,首先我们要定义一些基础的变量,并引入一些必要的库,代码如下:

import requests
import re
import logging
from urllib.parse import urljoin
logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s - %(levelname)s: %(message)s')
BASE_URL="https://ssr1.scrape.center/"
TOTAL_PAGE = 11

这里首先引用requests库爬取页面,logging库用来输出信息指定输出格式,re库用来实现正则表达式解析,urljoin模块用来实现URL的拼接。

接下来我们定义日志的输出级别和输出格式,以及根URL(BASE_URL),TOTAL_PAGE为我们需要爬取的总页码。

完成这些工作,我们来实现一个页面爬取的方法,实现如下

def scrape_page(url):
    logging.info('scraping %s...',url)
    try:
        response= requests.get(url)
        if response.status_code==200:
            return response.text
        logging.error('get invalid status code %s while scraping %s',
                      response.status_code,url)
    except requests.RequestException:
        logging.error('error occurred while scraping %s',url,
                      exc_info=True)

考虑到不仅要爬取列表页,还要爬取详情页,所以我们定义了一个比较通用的爬取页面的方法,叫做scrape_page,它接收一个参数url,返回页面的HTML代码。上面代码首先判断状态码是不是200,如果是,就直接返回页面的HTML代码,如果不是,则输出错误日志信息。另外,这里实现了requests的异常处理,如果出现了爬取异常,就会输出对应的错误信息,我们将logging库中的error方法里的exc_info参数设置为True,可以打印出Traceback错误堆栈信息。

好了,在有了scrape_page方法之后,我们来给这个方法传入一个url,如果情况正常,他就可以返回页面的HTML代码了。

在scrape_page方法的基础上,我们来定义列表页的爬取方法,实现如下:

def scrape_index(page):
    index_url=f'{BASE_URL}/page/{page}'
    return scrape_page(index_url)

方法的名称叫做scrape_index,这个方法会接受一个page参数,即列表页的页码。我们在方法里实现了对列表页的URL的拼接,然后调用scrape_page方法爬取即可。这样就可以得到列表页的HTML代码了。

在获取到HTML代码后,我们就要对获得的HTML方法进行解析,获得其中的数据,首先就是列表页中每部电影的详情页的URL,实现如下:

def parse_index(html):
    pattern= re.compile('<a.*?href="(.*?)".*?class="name">')
    items = re.findall(pattern,html)
    if not items:
        return []
    for item in items:
        detail_url = urljoin(BASE_URL,item)
        logging.info('get detail url %s', detail_url)
        yield detail_url

这里我们定义了pasre_index方法,它接收了一个参数html,即列表页的HTML代码。在parse_index方法里我们首先定义了一个提取标题超链接href属性的正则表达式,内容为

pattern= re.compile('<a.*?href="(.*?)".*?class="name">')

 这里我们使用的是非贪婪模式.*?来匹配任意字符,同时在href属性的引号之间使用了分组匹配(.*?)正则表达式,这样我们就能在匹配结果获取到href的属性值,正则表达式后面紧跟着"class=name",用来表示这个<a>节点是代表电影名称的节点。

现在有了正则表达式,那么怎么提取列表页所有的href的值呢?使用re库自带的findall方法就可以了,第一个参数传入这个正则表达式构造的pattern对象,第二个参数传入html,这样findall方法便会搜索html中所有能与正则表达式相匹配的内容,之后把匹配结果返回,并赋值为items。如果items为空,那么就直接返回空列表,如果items不为空,那么直接遍历就可以了。

遍历items得到的item就是我们之前说道的/detail/1这样的结果,由于这不是一个完整的URL,所以需要借助urljoin方法把BASE_URL和href拼接到一起,获得详情页的完整URL,得到的结果就是类似”Scrape | Movie“这样的结果,最后调用yield返回即可。

现在我们通过调用parse_index方法,往其中传入列表页的HTML代码,就可以获得列表页中所有的详情页URL了。

接下来我们对上面所写到的方法串联起来,实现如下:

def main():
    for page in range(1,TOTAL_PAGE+1):
        index_html=scrape_index(page)
        detail_urls=parse_index(index_html)
        logging.info('detail urls %s',list(detail_urls))
if __name__ == "__main__":
    main()

运行结果

2023-12-03 19:30:14,820 - INFO: scraping https://ssr1.scrape.center/page/1...
2023-12-03 19:30:26,234 - INFO: get detail url https://ssr1.scrape.center/detail/1
2023-12-03 19:30:26,234 - INFO: get detail url https://ssr1.scrape.center/detail/2
2023-12-03 19:30:26,235 - INFO: get detail url https://ssr1.scrape.center/detail/3
2023-12-03 19:30:26,235 - INFO: get detail url https://ssr1.scrape.center/detail/4
2023-12-03 19:30:26,235 - INFO: get detail url https://ssr1.scrape.center/detail/5
2023-12-03 19:30:26,235 - INFO: get detail url https://ssr1.scrape.center/detail/6
2023-12-03 19:30:26,236 - INFO: get detail url https://ssr1.scrape.center/detail/7
2023-12-03 19:30:26,236 - INFO: get detail url https://ssr1.scrape.center/detail/8
2023-12-03 19:30:26,236 - INFO: get detail url https://ssr1.scrape.center/detail/9
2023-12-03 19:30:26,236 - INFO: get detail url https://ssr1.scrape.center/detail/10
2023-12-03 19:30:26,236 - INFO: detail urls ['https://ssr1.scrape.center/detail/1', 'https://ssr1.scrape.center/detail/2', 'https://ssr1.scrape.center/detail/3', 'https://ssr1.scrape.center/detail/4', 'https://ssr1.scrape.center/detail/5', 'https://ssr1.scrape.center/detail/6', 'https://ssr1.scrape.center/detail/7', 'https://ssr1.scrape.center/detail/8', 'https://ssr1.scrape.center/detail/9', 'https://ssr1.scrape.center/detail/10']

 这里运行结果比较多,只粘贴了一部分。

可以看到,程序首先爬取了第一列表页,然后得到了对应详情页的每个URL,接着再爬取第二页,一直到页码结束为止,依次输出了每一页的URL,这就意味着我们成功获取了所有电影的详情页URL。

爬取详情页

根据上面的代码,我们已经成功获取到了所有详情页的URL,下一步就是解析详情页了,并且对我们想要的信息进行提取。

首先观察详情页的html代码

 经过分析,我们想要提取的内容和对应的节点信息如下

  • 封面:是一个img节点,其class属性是cover
  • 名称:是一个h2节点,其内容即使电影名
  • 类别:是span节点,其内容就是电影类别,span节点的外侧是button节点,再外侧就是class为categories的div节点
  • 上映时间:是span节点,其内容是上映时间,外侧是class为info的div节点,另外提取结果还多了上映二字,我们可以用正则表达式把日期提取出来。
  • 评分:是一个p节点,内容就是电影评分,p节点的class属性就是scroe
  • 剧情简介:是一个p节点,其内容就是剧情简介,其外侧是class为drama的div节点

以上就是我们所有要.提取的内容,看着有些复杂,不过有正则表达式在手,都可以轻松搞定。

接着实现一下代码。

上面我们已经成功获取了详情页URL,下面就是定义一个详情页的爬取方法了,实现如下:

def scrape_detail(url):
    return scrape_page(url)

这里定义了一个scrape_detail方法,接收一个参数url,并通过调用scrape_page方法获得网页源代码,由于我们刚才实现了scrape_page方法,所以这里不需要再写一遍页面爬取的逻辑,直接调用即可,做到了代码复用。

另外有人会说,这个scrape_detail方法只调用了scrape_page方法,而没有别的功能,那么爬取详情页直接用scrape_page方法不就好了,有必要再单独定义scrape_detail方法吗?是有必要的,单独定义一个scrape_detail方法再逻辑上更加清晰,而且以后如果想对scrape_detail方法进行改动,例如添加日志输出,增加预处理,都是可以在scrape_detail里实现的,而不改动scrape_page方法,灵活性上会更好。

详情页的爬取方法已经实现了,接着就是对详情页的解析了,实现如下:

def parse_detail(html):
    cover_pattern=re.compile('class="item.*?<img.*?src="(.*?)".*?class="cover">',re.S)
    name_pattern=re.compile('<h2.*?>(.*?)</h2>')
    categories_pattern=re.compile('<button.*?category.*?><span>(.*?)</span>.*?</button>',re.S)
    published_at_pattern=re.compile('(\d{4}-\d{2}-\d{2})\s?上映')
    drama_pattern=re.compile('<div.*?drama.*?>.*?<p.*?>(.*?)</p>',re.S)
    score_pattern=re.compile('<p.*?score.*?>(.*?)<.*?></p>',re.S)
    cover= re.search(cover_pattern,html).group(1).strip() if re.search(cover_pattern,html)else None
    name= re.search(name_pattern,html).group(1).strip() if re.search(name_pattern,html) else None
    categories=re.findall(categories_pattern,html) if re.findall(categories_pattern,html)else []
    published_at=re.search(published_at_pattern,html).group(1) if re.search(published_at_pattern,html)else None
    drama=re.search(drama_pattern,html).group(1).strip() if re.search(drama_pattern,html)else None
    score=float(re.search(score_pattern,html)).group(1).strip() if re.search(score_pattern,html) else None
    return {
        'cover':cover,
        'name':name,
        'categories':categories,
        'published_at':published_at,
        'drama':drama,
        'score':score
    }

这里我们定义了parse_detail方法,用于解析详情页,它接受了一个参数html,解析其中的内容,并以字典的形式返回结果,每个字段解析情况如下所述。

  • cover:封面,其值是带有cover这个class的img节点的src属性的值,所以src的 内容使用(.*?)来表示即可,在img节点的前面我们加上一个用来区别位置的标识符,如item,由于结果只有一个,因此写好正则表达式后用search方法提取即可。
  • name:名称,其值是h2节点的文本值,因此可以直接在h2标签的中间使用(.*?)表示,因为结果只有一个,所以写好正则表达式后同样用search方法提取即可。
  • categories:类别,我们注意到每个category的值都是button节点里面span节点的值,所以写好表示button节点的正则表达式后,直接在其内部span标签中间使用(.*?)表示即可,因为结果有多个,所以这里使用findall方法提取,结果是一个列表。
  • published_at:上映时间,由于每个上映时间信息都包含“上映”二字,日期又都是一个规整的格式,所以对于上映时间的提取,我们直接使用标准年月日的正则表达式(\d{4}-\d{2}-\d{2})即可,因为结果只有一个,所以直接使用search方法提取即可。
  • drama:直接提取class为drama的节点内部的p节点的文本即可,同样使用search方法提取。
  • score:直接提取class为score的p节点文本即可,由于提取结果是字符串,因此还需要把它转化成浮点数,即float类型

 上面的字段提取完毕之后,构造一个字典并返回。

这样,我们就成功完成了详情页的提取和分析。

最后,稍微改写一下mian方法,增加对scrape_detail方法和parse_detail方法的调用,改写如下:

def main():
    for page in range(1,TOTAL_PAGE+1):
        index_html=scrape_index(page)
        detail_urls=parse_index(index_html)
        for detail_url in detail_urls:
            detail_html = scrape_detail(detail_url)
            data=parse_detail(detail_html)
            logging.info('get detail data %s',data)

这里我们首先遍历了detail_urls,获取了每个详情页的URL,然后依次调用scrape_datail和parse_detail方法;最后得到了每个详情页的提取结果,赋值data

运行结果如下:

023-12-04 22:44:00,255 - INFO: get detail data {'cover': 'https://p0.meituan.net/movie/15f1ac49b6d1ff7b71207672993ed6901536456.jpg@464w_644h_1e_1c', 'name': '怦然心动 - Flipped', 'categories': [], 'published_at': '2010-07-26', 'drama': '布莱斯(卡兰·麦克奥利菲 饰)全家搬到小镇,邻家女孩朱丽(玛德琳·卡罗尔 饰)前来帮忙。她对他一见钟情,心愿是获得他的吻。两人是同班同学,她一直想方设法接近他,但是他避之不及。她喜欢爬在高高的梧桐树上看风景。但因为施工,树被要被砍掉,她誓死捍卫,希望他并肩作战,但是他退缩了。她的事迹上了报纸,外公对她颇有好感,令他十分困惑。她凭借鸡下蛋的项目获得了科技展第一名,成了全场焦点,令他黯然失色。她把自家鸡蛋送给他,他听家人怀疑她家鸡蛋不卫生,便偷偷把鸡蛋丢掉。她得知真相,很伤心,两人关系跌入冰点。她跟家人诉说,引发争吵。原来父亲一直攒钱照顾傻弟弟,所以生活拮据。她理解了父母,自己动手,还得到了他外公的鼎力相助。他向她道歉,但是并未解决问题。他开始关注她。鸡蛋风波未平,家庭晚宴与午餐男孩评选又把两人扯在了一起……', 'score': 8.8}
2023-12-04 22:44:00,256 - INFO: get detail url https://ssr1.scrape.center/detail/60
2023-12-04 22:44:00,256 - INFO: scraping https://ssr1.scrape.center/detail/60...
2023-12-04 22:44:00,553 - INFO: get detail data {'cover': 'https://p0.meituan.net/movie/b0d97e4158b47d653d7a81d66f7dd3092146907.jpg@464w_644h_1e_1c', 'name': '驯龙高手 - How to Train Your Dragon', 'categories': [], 'published_at': '2010-05-14', 'drama': '维京岛国的少年小嗝嗝(杰伊·巴鲁切尔  配音)是部落统领伟大的斯托里克(杰拉德·巴特勒 配音)的儿子,他非常想像自己的父亲一样亲手屠龙——这些飞龙是岛上维京人放牧羊群的主要天敌——但他每次出现在部落屠龙的战斗中都只给大家徒增烦恼。在一次对抗飞龙的战斗中,希卡普偷偷用射龙器击伤了一只最神秘的“夜之怒龙”,并背着族人放生、豢养,甚至驯服了这只龙,还给它起名“无牙”。希卡普的神秘行径引起了一同训练屠龙技巧的女孩阿斯特丽德(亚美莉卡·费雷拉 配音)的怀疑。阿斯特丽德发现了希卡普的秘密,却同时被身骑“无牙”御风而飞的美妙体验所震撼。格雷决定在屠龙成人礼上向远征归来的斯托里克和族人讲明真相,说服大家放弃屠龙,却偏偏弄巧成拙,害得“无牙”被俘,一场更大的灾难就在眼前……', 'score': 8.8}
2023-12-04 22:44:00,554 - INFO: scraping https://ssr1.scrape.center/page/7...
2023-12-04 22:44:00,814 - INFO: get detail url https://ssr1.scrape.center/detail/61
2023-12-04 22:44:00,814 - INFO: scraping https://ssr1.scrape.center/detail/61...

由于内容较多,省略后续内容

至此我们已经提取出每部电影的基本信息,包括封面名称

保存数据

成功提取到详情页信息之后,下一步就要把数据保存起来,由于我们还没有学习到数据库存储,所以临时先将数据保存成文本格式,这里我们可以一个条目定义一个json文本。

import json
from os import makedirs
from os.path import exists
RESULT_DIR = 'results'
exists(RESULT_DIR) or makedirs(RESULT_DIR)
def save_data(data):
    name=data.get('name')
    data_path=f'{RESULT_DIR}/{name}.json'
    json.dump(data,open(data_path,'w',encoding='utf-8'),
              ensure_ascii=False,indent=2)

 我们首先定义保存数据文件夹RESULTS_DIR,然后判断这个文件夹是否存在,如果不存在就创建一个。

接着我们定义了保存数据的方法save_data,其中显示获取数据的name字段,即电影名,将其仿作json的文件名称,然后构造json文件的路径,接着用json的dump方法将数据保存成文本格式,dump方法设置有两个参数,一个是ensure_ascii,值为False,可以保证中文字符能以正常的格式显示,而不是unicode,另一个是indent,值为2,设置了json数据的结果有两行缩进,让json文件更美观

加下了再美化一下mian方法就好了

def main():
    for page in range(1,TOTAL_PAGE+1):
        index_html=scrape_index(page)
        detail_urls=parse_index(index_html)
        for detail_url in detail_urls:
            detail_html = scrape_detail(detail_url)
            data=parse_detail(detail_html)
            logging.info('get detail data %s',data)
            logging.info('saving data to json file')
            save_data(data)
            logging.info('data saved successfully')

这就是加了对save_data方法调用的main方法,其中还加了日志信息

2023-12-05 20:56:35,305 - INFO: scraping https://ssr1.scrape.center/page/1...
2023-12-05 20:56:35,650 - INFO: get detail url https://ssr1.scrape.center/detail/1
2023-12-05 20:56:35,651 - INFO: scraping https://ssr1.scrape.center/detail/1...
2023-12-05 21:14:15,836 - INFO: get detail data {'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'name': '霸王别姬 - Farewell My Concubine', 'categories': [], 'published_at': '1993-07-26', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小 楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。', 'score': 9.5}

 同过结果,我们可以发现我们成功输出了数据,并且保存为了json格式文件的信息。

 多进程加速

由于整个爬虫是单进程的,而且只能逐条爬取,因此速度有些慢,那么有没有对整个爬取进程进行加速的方法呢,

前面说到过多进程的基本原理和使用,下面就来实践一下多进程爬取吧。

由于一共只有11个详情页,且这10页内容互不干扰,因此我们可以一页开一个进程来爬取,而且因为这10个列表页页码正好可以提前构造成一个列表,所以我们可选多进程里面的进程池Pool来实现这个过程。

这里我们需要改写一下main方法

import multiprocessing
def main(page):
    index_html=scrape_index(page)
    detail_urls=parse_index(index_html)
    for detail_url in detail_urls:
        detail_html = scrape_detail(detail_url)
        data=parse_detail(detail_html)
        logging.info('get detail data %s',data)
        logging.info('saving data to json file')
        save_data(data)
        logging.info('data saved successfully')
if __name__ == "__main__":
    pool = multiprocessing.Pool()
    pages=range(1,TOTAL_PAGE+1)
    pool.map(main,pages)
    pool.close()
    pool.join()

这里首先给main方法加了一个page参数,用以表示列表页的页码。接着声明一个进程池,并声明pages为所有需要遍历的页码,即1-11,最后用map方法,其第一个参数就是需要调用的参数,第二个参数就是pages,即需要遍历的页码。
这样就会依次遍历pages中的内容,把1-11这11个页码分别传递给main方法,并把每次的调用分别变成一个进程,加入进程池中,进程池会根据当前运行环境决定运行了多少个进程,如果电脑有八个核,那么进程池的大小就会默认设置为8,这样就会有8个进程并行运行。

最后的结果与之前类似,但是速度快了很多,可以清空之前保存的文件从新跑一下,数据依然可被保存成json文件。

好了,到此我们就完成了全站电影的爬取,并实现了多进程和保存。

具体代码详情见崔庆才老师的网站:https://github.com/Python3WebSpider/ScrapeSsr1

 最后和大家说一下,学习不是一蹴而就的事情,要循序渐进,如果实在看不懂的问题可以先搁置,以后再看懂同样的问题可以留心观察一下。

每天进步一点点,每天学习一点点

  • 21
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值