基础爬虫案例实战

准备工作

  1. Python3,3.6以上的版本
  2. 了解Python多进程的基本原理
  3. 了解PythonHTTp请求库requests的基本用法
  4. 了解正则表达式的用法和正则表达式re的基本用法

爬取目标

静态网站案例,包含一些电影信息。

 电影详情页

需完成的目标:

  • requests爬取站点的每一页电影列表,顺着列表再爬取每个列表的详情页
  • 用正则表达式提取每页的名称、封面等内容
  • 把爬取的内容存为JSON文件
  • 使用多线程实现爬取的加速

爬取列表页

观察列表页的结构和翻页规则

 

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

查看从列表页进入详情页查看网站源码。

h2节点中对应的是电影标题。h2节点外面包含一个a节点,a节点带有href属性,可以得到电影详情页的URL。提取这个href属性就能构造出详情页的URL进行爬取。

分析翻页的逻辑:拉倒最下方可以看到分页页码。

 点击第二页发现URL变成了https://ssr1.scrape.center/page/2,相比根URL多了/page/2

定义一些基础变量,并引入一些基本的库

import requests
import logging
import re
from urllib.parse import urljoin

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
BASE_URL = 'https://ssr1.scrape.center'
TOTAL_PAGES = 10

requests库用来爬取页面,logging库用来输出信息,re库用来实现正则表达式解析,urljoin模块来做url的拼接。定义了日志输出级别和输出格式,以及BASE_URL为当前站点的根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代码

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

scrape_index方法接收一个page参数,即列表页的页码,在方法里面进行了列表页的URL拼接,然后调用scrape_page方法就能获取列表页的HTML代码

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

获取HTML代码之后,下一步就是解析列表页,并得到每部电影详情页的URL

def parse_html(html):
    # 提取标题超链接href属性
    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

定义parse_index方法,接收一个参数html。在parse_index方法里先定义一个提取标题链接的href属性的正则表达式

<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,得到的结果就是类似https://ssr1.scrape.center/detail/1这样的完整URL,最后调用yield返回即可。

调用parse_index方法,传入列表页的HTML代码,就可以获得该列表的所有电影的详情页URL。

def main():
    for page in range(1, TOTAL_PAGES + 1):
        index_html = scrape_index(page)
        detail_urls = parse_index(index_html)
        logging.info('get detail url %s', list(detail_urls))

定义一个main方法,完成对上面函数的调用。main方法中首先使用range方法遍历了所有页码,得到的page就是1-10;接着把page变量传给scrape_index方法,得到列表页的HTML;把得到的HTML赋值为index_html变量。接下来将index_html变量传给parse_index方法,得到所有电影的详情页URL,并赋值给detail_urls,结果是一个生成器,调用list方法就可以将其输出。

程序首先爬取了第一页列表页,然后得到了对应详情页的每个URL,接着再爬取第2页、第3页,一直到第10页,依次输出了每一页的详情页URL。

爬取详情页

 我们要提取的内容和对应的节点信息如下

封面:img节点,class属性为cover

名称:h2节点,内容是电影名称

类别:span节点,内容是电影类别。span节点的外侧是button节点,外侧是class为categories的div节点

上映时间:span节点,内容包含上映时间,外侧是class为info的节点。

评分:p节点,内容为电影评分。p节点的class的属性为score

剧情简介:p节点,内容是剧情简介,外侧是class为drama的div节点

已经成功获取详情页的URL,下面定义一个详情页的爬取方法:

def scrape_detail(url):
    return scrape_page(url)

定义一个scrape_detail方法,接收一个参数url,通过调用scrape_page方法获得网页的源代码。上面已经实现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.search(categories_pattern, html) else None
    published_at = re.search(published_at_pattern, html).group(1).strip() 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': scor
    }

定义了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节点的文本即可,由于提取的结果是字符串,还需要转换成浮点数。

上述字段都提取完后,构造一个字典并返回。这样就完成了详情页的提取和分析。

改写main方法,增加对scrape_detail方法和parse_detail方法的调用,改写如下:

def main():
    for page in range(1, TOTAL_PAGES + 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_detail和parse_detail方法;最后得到了每个详情页的提取结果,赋值为data并输出。

 保存数据

成功提取详情页信息之后,要把信息保存起来,先暂时存储为文本格式,可以定义一个JSON文本。

import json
from os import makedirs
from os.path import exists

RESULTS_DIR = 'results'
exists(RESULTS_DIR) or makedirs(RESULTS_DIR)

def save_data(data):
    name = data.get('name')
    data_path = f'{RESULTS_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数据的格式显得更加美观。

将main()方法改写一下:

def main():
    for page in range(1, TOTAL_PAGES + 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方法,其中还加了一些日志信息。

 多进程加速

由于整个爬取是单进程,而且只能逐条爬取,因此速度较慢可以使用多进程进行爬取。

由于一共有10页详情页,且这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_PAGES + 1)
    pool.map(main, pages)
    pool.close()
    pool.join()

首先给main方法添加一个参数page,用以表示列表页的页码。接着声明一个进程池,并声明pages为所有需要遍历的页码,即1-10。最后调用map方法,第一个参数就是需要被调用的参数,第二个参数就是pages,即需要遍历的页码。

这样就会遍历pages中的内容,把1-10这十个页码分别传递给main方法,并把每次的调用分别变成一个进程,加入进程池中,进程池会根据当前运行环境来决定运行多少个进程。例如计算机cpu有8个核,那么进程池的大小就会默认设置为8,这样就会有8个进程并行运行。

posted @ 2024-05-24 02:11  JJJhr  阅读(7)  评论(0编辑  收藏  举报