Python3 爬虫实战教程:静态网站全站爬取

1. 前言

不想啃复杂的解析库 / 框架?原生 Python 也能搞定全站爬取!

如果你刚接触爬虫,看到 BeautifulSoup、Scrapy 一长串配置就头疼——别慌,今天我们用 requests + 内置正则 re 就能完成一个完整的公开练手电影站项目。从列表页抓链接、详情页提取字段,到 JSON 存储,最后再加个多进程提速。目标站反爬很宽松,非常适合上手练习。

通过这个案例你会掌握:

  • 轻量级 HTTP 请求的封装
  • 静态 HTML 的快速正则解析
  • 「列表页 → 详情页」的分层跳转逻辑
  • 多进程并行爬取,效率翻倍
  • 入门级爬虫常见的 “坑” 与修复方法

2. 技术准备

先搭个极简环境,几乎不用额外装东西:

  1. 确保本地有 Python 3.6+(兼容 f-string 和多进程 Pool 的常规写法)
  2. 安装核心依赖:
pip install requests

💡 用到的库都是轻量级 / 原生自带的

  • requests:比 urllib 写起来爽 10 倍的 HTTP 库
  • re:Python 内置正则,解析简单静态 HTML 足够
  • logging:记录爬取状态,调试必备
  • json / os:数据存储和文件管理
  • multiprocessing:多进程提速(练手站单进程也行,但学了总有用)

3. 目标网站分析

练手站地址:https://ssr1.scrape.center/
打开一看,结构非常清晰,简直是给爬虫练习准备的模板。

3.1 页面结构

  • 列表页:URL 规则固定为 /page/{页码},从 1 到 10(对应后面代码中的 TOTAL_PAGE),每页 10 部电影
  • 详情页:每个电影卡片的电影名是一个带 class="name"<a> 标签,href 是相对路径,需要拼接根域名 BASE_URL

3.2 要抓的字段

详情页里包含这些公开信息:

  • 封面图片 URL
  • 电影名称
  • 电影类别(标签数组)
  • 上映时间
  • 评分
  • 剧情简介

4. 带坑修复的完整代码

下面直接给出最终可用的完整版,里面修复了几个新手容易遇到的坑(转义警告、死等、文件名非法字符等),逻辑也更规范。你可以直接复制运行,边看注释边理解。

import json
from os import makedirs
from os.path import exists
import requests
import logging
import re
from urllib.parse import urljoin
import multiprocessing

# 日志配置:记录时间、级别、内容,方便观察运行情况
logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s - %(levelname)s: %(message)s')

# 核心常量
BASE_URL = 'https://ssr1.scrape.center'
TOTAL_PAGE = 10
RESULTS_DIR = 'results'  # 存放 JSON 的文件夹
if not exists(RESULTS_DIR):
    makedirs(RESULTS_DIR)


def scrape_page(url):
    """
    通用「套娃」爬取函数:统一处理请求、状态码、异常
    :param url: 目标 URL
    :return: 成功返回 HTML 文本,失败返回 None
    """
    logging.info('正在爬取 %s...', url)
    try:
        # 坑1:增加 timeout(10秒)防止请求永远不返回,卡住程序
        response = requests.get(url, timeout=10)
        if response.status_code == 200:
            return response.text
        logging.error('无效状态码 %d,URL:%s', response.status_code, url)
    except requests.RequestException:
        logging.error('请求异常!URL:%s', url, exc_info=True)


def scrape_index(page):
    """
    构造并爬取指定页码的列表页
    :param page: 页码
    :return: 列表页 HTML
    """
    index_url = f'{BASE_URL}/page/{page}'
    return scrape_page(index_url)


def parse_index(html):
    """
    解析列表页,返回详情页 URL 生成器(用 yield 节省内存)
    :param html: 列表页 HTML
    :return: 详情页 URL 生成器
    """
    if not html:
        # 坑2:空值保护,防止后续正则匹配报错
        return []
    # 坑3:正则字符串前加 r'' 前缀,避免转义警告
    pattern = re.compile(r'<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('获取到详情页链接:%s', detail_url)
        yield detail_url


def scrape_detail(url):
    """
    爬取详情页
    :param url: 详情页 URL
    :return: 详情页 HTML
    """
    return scrape_page(url)


def parse_detail(html):
    """
    解析详情页,返回电影信息字典
    :param html: 详情页 HTML
    :return: 电影信息字典
    """
    if not html:
        return None  # 空值保护

    # 逐个定义正则,加上 re.S 让 . 也能匹配换行符
    cover_pattern = re.compile(r'class="item.*?<img.*?src="(.*?)".*?class="cover">', re.S)
    name_pattern = re.compile(r'<h2.*?>(.*?)</h2>')
    categories_pattern = re.compile(r'<button.*?category.*?<span>(.*?)</span>.*?</button>', re.S)
    published_at_pattern = re.compile(r'(\d{4}-\d{2}-\d{2})\s?上映')
    drama_pattern = re.compile(r'<div.*?drama.*?>.*?<p.*?>(.*?)</p>', re.S)
    score_pattern = re.compile(r'<p.*?score.*?>(.*?)</p>', re.S)

    # 逐个提取字段,找不到就设默认值,保证程序不会崩
    name_match = re.search(name_pattern, html)
    name = name_match.group(1).strip() if name_match else "未命名电影"

    cover_match = re.search(cover_pattern, html)
    cover = cover_match.group(1).strip() if cover_match else None

    categories = re.findall(categories_pattern, html)

    pub_match = re.search(published_at_pattern, html)
    published_at = pub_match.group(1) if pub_match else None

    drama_match = re.search(drama_pattern, html)
    drama = drama_match.group(1).strip() if drama_match else None

    score_match = re.search(score_pattern, html)
    score = float(score_match.group(1).strip()) if score_match else None

    return {
        'cover': cover,
        'name': name,
        'categories': categories,
        'published_at': published_at,
        'drama': drama,
        'score': score
    }


def save_data(data):
    """
    保存单条电影数据到 JSON 文件
    :param data: 电影信息字典
    """
    if not data:
        return
    name = data.get('name')
    # 坑4:清洗文件名中的非法字符(Windows / Linux 通用)
    safe_name = re.sub(r'[\\/:*?"<>|]', '_', name)
    data_path = f'{RESULTS_DIR}/{safe_name}.json'

    # 坑5:用 with open 自动关闭文件,避免资源泄漏
    try:
        with open(data_path, 'w', encoding='utf-8') as f:
            # ensure_ascii=False 防止中文变成 \uxxxx
            json.dump(data, f, ensure_ascii=False, indent=2)
        logging.info('已成功保存:%s', data_path)
    except Exception as e:
        logging.error('保存失败!文件路径:%s,错误:%s', data_path, e)


def main(page):
    """
    单页主处理函数:串联整个单页流程
    :param 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)
        if data:
            logging.info('获取到电影数据:%s', data['name'])
            save_data(data)


if __name__ == '__main__':
    # 多进程并行爬取
    # 如果想单步调试,也可以直接循环:for page in range(1, TOTAL_PAGE + 1): main(page)
    pool = multiprocessing.Pool()
    pages = range(1, TOTAL_PAGE + 1)
    pool.map(main, pages)
    pool.close()  # 关闭进程池,不再接受新任务
    pool.join()   # 等待所有子进程完成

5. 核心流程拆解

为了让你更容易上手,我们把上面的代码拆成 3 层来看,每一层都只做一件事,职责清晰。

5.1 请求统一入口

不管是列表页还是详情页,都调用同一个 scrape_page() 函数。这样做的好处是:

  • 避免重复写 try...except 和状态码判断
  • 统一管理超时、异常日志,后期要加请求头或代理也只需改一处

5.2 分层解析

  • 列表页解析parse_index
    用正则匹配所有 class="name"<a> 标签,提取 href,再用 urljoin() 拼接出完整的详情页 URL。返回 生成器 而不是列表,可以边爬边解析,省内存。

  • 详情页解析parse_detail
    针对每一个想要抓取的字段写好对应的正则,加上 re.S 模式防止换行干扰匹配。如果某个字段没找到,用默认值替代,保证程序不会因为某个页面缺失数据而整段崩溃。

5.3 数据存储 + 多进程

  • 存储:以电影名作文件名,提前用正则把冒号、斜杠等非法字符替换成下划线。JSON 文件统一放在 results/ 文件夹下,方便后续检查。
  • 多进程:使用 multiprocessing.Pool() 把 10 个页面的任务分发给多个进程同时执行,比单进程串行访问快得多。

    ⚠️ 注意:pool.map() 会阻塞主进程,直到所有子任务结束;最后记得调用 pool.close()pool.join()


6. 进阶优化建议

目前这个版本已经可以拿来直接用,但如果将来你想爬更复杂的网站,可以考虑以下小优化:

  1. 加上合理的请求头
    大部分网站会检查 User-Agent,可以这样加上:

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
    }
    # 在 scrape_page 里改为:
    response = requests.get(url, headers=headers, timeout=10)
  2. 随机延迟
    在每次请求之间 time.sleep(1~3) 随机秒数,降低请求频率,避免触发反爬。

  3. 代理支持
    如果 IP 被封,可以引入代理池,在 requests.get() 里加上 proxies 参数。

  4. 断点续爬
    用一个简单的数据库或 JSON 文件记录已经成功爬取过的 URL 或电影名,程序中断后重启时可以跳过已抓取的部分。

  5. 替换解析库
    当页面结构变复杂、正则难以维护时,可以考虑换用 BeautifulSoup4lxml,让代码可读性更高。


7. 总结

今天的案例覆盖了 静态爬虫的核心流程:统一请求 → 分层解析 → 结构化存储,再配合多进程提速和 5 个实战中容易踩的坑的修复方案。整个项目只用到 Python 内置库加一个 requests,非常适合入门练手。

你可以试试把代码里的字段替换成自己感兴趣的内容,或者换个结构类似的公开练手站(比如一些书籍目录站、新闻列表站)跑跑看,把学到的思路真正用起来。

最后,再提醒一句:爬虫虽好,请勿爬取未经授权的数据,也不要对目标站造成过大的服务器压力哦