详解 Python 异步爬虫框架:基于 Pyppeteer 的通用网页爬取模块设计

详解 Python 异步爬虫框架:基于 Pyppeteer 的通用网页爬取模块设计

在数据采集领域,异步爬虫凭借其高效的并发处理能力成为主流方案。本文将深度解析一个基于 Pyppeteer 的通用异步爬虫脚本,从模块设计、核心功能到扩展应用,全方位拆解其实现逻辑与最佳实践。

一、项目整体架构与设计理念

该爬虫脚本采用模块化设计思想,将功能拆分为基础配置层工具函数层核心爬取层执行控制层四个部分,具备以下特性:

  • 异步非阻塞:基于 asyncio 和 Pyppeteer 实现高效页面渲染与数据采集
  • 多格式存储:支持 JSON/CSV/Excel 三种数据持久化方式
  • 鲁棒性设计:完善的异常处理、超时控制和元素安全操作
  • 通用性强:通过配置化方式适配不同网站的爬取需求
  • 反反爬优化:隐藏 webdriver 特征、自定义 UA 等基础反检测手段

二、模块逐行解析

2.1 基础配置模块(环境初始化)

import asyncio
import logging
import os
import json
import csv
from datetime import datetime
from pyppeteer import launch
from pyppeteer.errors import TimeoutError, NetworkError
from collections import namedtuple
from typing import List, Optional, Tuple

核心作用

  • 导入核心依赖库,定义类型注解保证代码健壮性
  • 编码声明确保中文处理正常
  • 引入 Pyppeteer 相关异常类,为后续异常处理做准备
2.1.1 日志配置子模块
# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('crawler.log', encoding='utf-8'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

设计亮点

  • 双输出渠道:同时写入日志文件和控制台输出
  • UTF-8 编码:避免日志中中文乱码
  • 标准化格式:包含时间戳、日志级别、消息内容,便于问题定位
2.1.2 目录初始化子模块
# 创建数据保存目录
DATA_DIR = 'crawled_data'
if not os.path.exists(DATA_DIR):
    os.makedirs(DATA_DIR)

核心价值

  • 确保数据存储目录存在,避免文件写入时的路径错误
  • 集中管理爬取数据,便于后续数据处理

2.2 系统适配工具模块

2.2.1 屏幕尺寸获取函数
def screen_size() -> Tuple[int, int]:
    """使用tkinter获取屏幕大小"""
    try:
        import tkinter
        tk = tkinter.Tk()
        tk.withdraw()
        width = tk.winfo_screenwidth()
        height = tk.winfo_screenheight()
        tk.quit()
        return width, height
    except Exception as e:
        logger.warning(f"获取屏幕尺寸失败,使用默认尺寸: {e}")
        return 1920, 1080

设计思路

  • 自适应屏幕分辨率:让浏览器视口匹配实际屏幕,避免页面布局异常
  • 异常降级处理:获取失败时使用默认 1920x1080 分辨率,保证程序可用性
2.2.2 Chrome 路径检测函数
def get_chrome_path() -> Optional[str]:
    """获取本地Chrome/Chromium路径"""
    if os.path.exists('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'):
        return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
    elif os.path.exists('/usr/bin/google-chrome'):
        return '/usr/bin/google-chrome'
    elif os.path.exists('C:/Program Files/Google/Chrome/Application/chrome.exe'):
        return 'C:/Program Files/Google/Chrome/Application/chrome.exe'
    return None

核心作用

  • 跨平台兼容:自动检测 macOS/Linux/Windows 系统的 Chrome 路径
  • 性能优化:使用本地 Chrome 而非 Pyppeteer 内置的 Chromium,提升稳定性

2.3 数据持久化模块

该模块提供三种数据存储方式,满足不同场景的数据分析需求。

2.3.1 JSON 存储函数
def save_to_json(data: List[dict], filename: str) -> None:
    """保存数据到JSON文件"""
    try:
        filepath = os.path.join(DATA_DIR, filename)
        # 确保中文正常显示
        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=4)
        logger.info(f"数据已保存到JSON文件: {filepath}")
    except Exception as e:
        logger.error(f"保存JSON失败: {e}")

关键特性

  • ensure_ascii=False:保留中文字符的原始形态
  • indent=4:格式化输出,便于人工查看
  • 完整的异常捕获与日志记录
2.3.2 CSV 存储函数
def save_to_csv(data: List[dict], filename: str) -> None:
    """保存数据到CSV文件"""
    try:
        if not data:
            logger.warning("无数据可保存到CSV")
            return

        filepath = os.path.join(DATA_DIR, filename)
        # CSV表头(取第一个数据的键)
        headers = data[0].keys()

        with open(filepath, 'w', newline='', encoding='utf-8-sig') as f:
            writer = csv.DictWriter(f, fieldnames=headers)
            writer.writeheader()
            writer.writerows(data)
        logger.info(f"数据已保存到CSV文件: {filepath}")
    except Exception as e:
        logger.error(f"保存CSV失败: {e}")

设计要点

  • utf-8-sig编码:解决 Excel 打开 CSV 时的中文乱码问题
  • newline='':避免 Windows 系统下的空行问题
  • 空数据检查:防止无数据时的写入错误
2.3.3 Excel 存储函数
def save_to_excel(data: List[dict], filename: str) -> None:
    """保存数据到Excel文件(需要安装openpyxl)"""
    try:
        from openpyxl import Workbook

        if not data:
            logger.warning("无数据可保存到Excel")
            return

        filepath = os.path.join(DATA_DIR, filename)
        wb = Workbook()
        ws = wb.active
        ws.title = "爬取数据"

        # 写入表头
        headers = list(data[0].keys())
        ws.append(headers)

        # 写入数据
        for row in data:
            ws.append(list(row.values()))

        wb.save(filepath)
        logger.info(f"数据已保存到Excel文件: {filepath}")
    except ImportError:
        logger.error("保存Excel需要安装openpyxl:pip3 install openpyxl")
    except Exception as e:
        logger.error(f"保存Excel失败: {e}")

特色处理

  • 懒加载依赖:仅在使用时导入 openpyxl,减少基础依赖
  • 友好的导入错误提示:明确告知安装方式
  • 工作表命名:提升 Excel 文件的可读性

2.4 页面操作工具模块

该模块封装了页面元素操作的通用方法,解决了 Pyppeteer 原生操作的不稳定性问题。

2.4.1 安全点击函数
async def safe_click(page, selector: str, timeout: int = 5000) -> bool:
    """安全点击元素"""
    try:
        await page.waitForSelector(selector, timeout=timeout)
        await page.click(selector)
        return True
    except TimeoutError:
        logger.warning(f"元素 {selector} 超时未出现")
        return False
    except Exception as e:
        logger.error(f"点击元素 {selector} 失败: {e}")
        return False

核心优化

  • 超时控制:避免无限等待元素出现
  • 布尔返回值:便于上层逻辑判断操作是否成功
  • 精准异常捕获:区分超时错误和其他错误类型
2.4.2 文本提取函数
async def extract_element_text(page, element, selector: str) -> Optional[str]:
    """安全提取元素文本"""
    try:
        elem = await element.querySelector(selector) if element else await page.querySelector(selector)
        if elem:
            text = await page.evaluate('(element) => element.textContent.trim()', elem)
            return text
        return None
    except Exception as e:
        logger.error(f"提取文本失败: {e}")
        return None

设计亮点

  • 双重查询支持:既可查询页面元素,也可查询子元素
  • 文本清洗:自动去除首尾空白字符
  • 空值处理:元素不存在时返回 None,避免程序崩溃
2.4.3 链接提取函数
async def extract_element_href(page, element, selector: str) -> Optional[str]:
    """安全提取元素链接"""
    try:
        elem = await element.querySelector(selector) if element else await page.querySelector(selector)
        if elem:
            href = await page.evaluate('(element) => element.href', elem)
            return href
        return None
    except Exception as e:
        logger.error(f"提取链接失败: {e}")
        return None

核心价值

  • 自动解析完整 URL:即使页面中是相对路径,也会返回绝对 URL
  • 与文本提取函数保持一致的接口设计,降低学习成本

2.5 核心爬取模块

2.5.1 详情页爬取函数
async def crawl_detail_page(browser, url: str, content_selector: str) -> Optional[str]:
    """爬取详情页内容"""
    detail_page = None
    try:
        detail_page = await browser.newPage()
        await detail_page.goto(url, timeout=30000)
        await detail_page.waitForSelector(content_selector, timeout=10000)
        element = await detail_page.querySelector(content_selector)
        if element:
            return await detail_page.evaluate('(element) => element.outerHTML', element)
        return None
    except Exception as e:
        logger.error(f"爬取详情页 {url} 失败: {e}")
        return None
    finally:
        if detail_page:
            await detail_page.close()

关键设计

  • 独立页面爬取:每个详情页使用新页面,避免相互干扰
  • finally 块确保页面关闭:防止浏览器进程泄漏
  • 获取完整 HTML:保留元素的完整结构,便于后续解析
2.5.2 主爬取函数
async def main(website) -> List[dict]:
    """主爬虫函数"""
    browser = None
    results = []

    try:
        # 启动浏览器
        launch_options = {
            'headless': False,
            'args': ['--no-sandbox', '--disable-dev-shm-usage', '--window-size=1920,1080'],
            'ignoreHTTPSErrors': True,
        }

        chrome_path = get_chrome_path()
        if chrome_path:
            launch_options['executablePath'] = chrome_path
            logger.info(f"使用本地Chrome: {chrome_path}")

        browser = await launch(launch_options)
        page = await browser.newPage()

        # 页面配置
        width, height = screen_size()
        await page.setViewport(viewport={"width": width, "height": height})
        await page.setJavaScriptEnabled(enabled=True)
        await page.setUserAgent(
            'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
            '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        )

        # 隐藏webdriver特征
        await page.evaluate('''() => {
            Object.defineProperties(navigator, {
                webdriver: { get: () => false },
                languages: { get: () => ['zh-CN', 'zh'] }
            });
            delete window.navigator.__proto__.webdriver;
        }''')

        # 访问目标网站
        await page.goto(website.url, timeout=30000)
        logger.info(f"成功访问: {website.url}")

        now_page = 0
        max_pages = 50

        while now_page < max_pages:
            now_page += 1
            logger.info(f"开始爬取第 {now_page} 页")

            try:
                # 滚动加载
                await page.evaluate('window.scrollBy(0, document.body.scrollHeight)')
                await asyncio.sleep(1)

                # 获取列表数据
                await page.waitForSelector(website.list_query, timeout=10000)
                li_list = await page.querySelectorAll(website.list_query)
                logger.info(f"第 {now_page} 页找到 {len(li_list)} 条记录")

                # 提取每条数据
                for index, li in enumerate(li_list):
                    try:
                        title_url = await extract_element_href(page, li, "a")
                        title_name = await extract_element_text(page, li, "a")
                        title_date = await extract_element_text(page, li, website.title_date_query)

                        if not title_url:
                            logger.warning(f"第 {now_page} 页第 {index + 1} 条记录无链接,跳过")
                            continue

                        # 爬取详情页
                        content_html = await crawl_detail_page(browser, title_url, website.content_query)

                        # 组装数据(核心:这里定义了要保存的字段)
                        item = {
                            '爬取时间': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                            '标题': title_name,
                            '链接': title_url,
                            '发布时间': title_date,
                            '内容长度': len(content_html) if content_html else 0,
                            '详情页HTML': content_html  # 完整的详情页内容
                        }
                        results.append(item)

                        # 控制台打印关键信息
                        logger.info(f"✅ 爬取成功: {title_name} | {title_date} | 内容长度: {item['内容长度']}")

                    except Exception as e:
                        logger.error(f"处理第 {now_page} 页第 {index + 1} 条记录失败: {e}")
                        continue

                # 翻页
                if website.next_page_query:
                    if not await safe_click(page, website.next_page_query):
                        logger.info("未找到下一页按钮,爬取完成")
                        break
                else:
                    break

                await asyncio.sleep(2)

            except Exception as e:
                logger.error(f"处理第 {now_page} 页失败: {e}")
                break

    except Exception as e:
        logger.error(f"爬虫主程序出错: {e}", exc_info=True)
    finally:
        if browser:
            # 关闭浏览器
            for _page in await browser.pages():
                await _page.close()
            await browser.close()

    return results

核心流程拆解

  1. 浏览器初始化

    • 配置启动参数,支持无沙箱模式、忽略 HTTPS 错误
    • 自动检测并使用本地 Chrome,提升兼容性
    • 设置视口大小、启用 JS、自定义 UA
  2. 反反爬配置

    • 重写 navigator.webdriver 属性,隐藏爬虫特征
    • 设置浏览器语言为中文,模拟真实用户
  3. 分页爬取逻辑

    • 最大页数限制(50 页),防止无限循环
    • 滚动加载触发页面数据加载
    • 列表数据提取与详情页异步爬取
    • 智能翻页:点击下一页按钮,失败则终止爬取
  4. 异常防护

    • 多层异常捕获:页面级、数据提取级、翻页级
    • 每条记录单独捕获异常,不影响整体爬取
    • 最终的 finally 块确保浏览器正确关闭

2.6 执行控制模块

def run_crawler(websites_config: List[tuple]) -> None:
    """运行爬虫并保存数据"""
    Websites = namedtuple('websites', ['url', 'list_query', 'title_date_query', 'content_query', 'next_page_query'])

    # 兼容Windows系统
    if os.name == 'nt':
        try:
            asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
        except AttributeError:
            pass

    # 遍历每个网站
    for idx, config in enumerate(websites_config):
        try:
            logger.info(f"\n========== 开始爬取第 {idx + 1} 个网站 ==========")
            website = Websites._make(config)

            # 运行爬虫
            loop = asyncio.get_event_loop()
            results = loop.run_until_complete(main(website))

            if not results:
                logger.warning("未爬取到任何数据")
                continue

            # 生成文件名(按时间戳命名,避免重复)
            timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
            base_filename = f"爬虫数据_网站{idx + 1}_{timestamp}"

            # 保存数据(按需选择格式)
            save_to_json(results, f"{base_filename}.json")  # 推荐:保留完整HTML,中文正常
            save_to_csv(results, f"{base_filename}.csv")  # 方便Excel打开
            # save_to_excel(results, f"{base_filename}.xlsx") # 需要安装:pip3 install openpyxl

            logger.info(f"第 {idx + 1} 个网站爬取完成,共 {len(results)} 条记录")
            logger.info(f"数据保存路径: {os.path.abspath(DATA_DIR)}")

        except Exception as e:
            logger.error(f"爬取第 {idx + 1} 个网站失败: {e}", exc_info=True)
            continue


if __name__ == '__main__':
    # 网站配置
    websites_config = [
        (
            'http://www.cqzbtb.cn/_jiaoyixinxi/',
            '.listbox ul',
            '.ys',
            '.article-wrap',
            "body > section > div > div.list-wrap.row > div.listpa > ul > li:nth-child(7)"
        ),
    ]

    # 运行爬虫
    run_crawler(websites_config)

核心功能

  1. 配置解析

    • 使用 namedtuple 规范化网站配置
    • 支持批量爬取多个网站
  2. 跨平台兼容

    • 针对 Windows 系统设置 asyncio 事件循环策略
    • 解决 Windows 下的异步运行问题
  3. 数据保存

    • 时间戳命名:避免文件覆盖
    • 多格式保存:满足不同分析需求
    • 绝对路径输出:便于用户查找文件
  4. 入口设计

    • 清晰的配置示例,便于用户修改
    • 独立的执行函数,便于集成到其他项目

三、核心优势与最佳实践

3.1 核心优势

  1. 高可用性:完善的异常处理和降级策略,确保程序不轻易崩溃
  2. 易扩展:模块化设计,可快速添加新的提取规则或存储格式
  3. 反反爬基础:内置 webdriver 隐藏、UA 伪装等基础反检测手段
  4. 配置化爬取:通过修改配置即可适配不同网站,无需改动核心代码
  5. 完整的日志体系:便于调试和问题定位

3.2 最佳实践建议

  1. 爬取频率控制:适当增加asyncio.sleep()时长,避免给目标网站造成压力
  2. 代理 IP 集成:添加代理配置,避免 IP 被封禁
  3. 验证码处理:集成 2Captcha 等验证码识别服务,应对验证码拦截
  4. 分布式扩展:结合 Celery 实现分布式爬取,提升效率
  5. 数据增量更新:增加数据去重逻辑,只爬取新增内容
  6. 配置文件化:将网站配置移至 JSON/YAML 文件,便于管理

四、应用场景与扩展方向

4.1 适用场景

  • 动态渲染页面爬取(SPA 应用、AJAX 加载内容)
  • 需要模拟用户交互的场景(点击、滚动、翻页)
  • 对数据完整性要求高的详情页爬取
  • 中小型网站的批量数据采集

4.2 扩展方向

  1. 数据清洗模块:添加 HTML 文本提取、数据格式化功能
  2. 进度监控:集成 tqdm 实现爬取进度可视化
  3. 断点续爬:保存爬取状态,支持中断后继续爬取
  4. 配置验证:添加配置项合法性检查,提前发现错误
  5. 邮件通知:爬取完成后发送邮件通知,包含爬取统计信息

五、总结

该爬虫脚本是一个设计精良的通用异步爬取框架,通过模块化设计实现了功能的解耦与复用,同时兼顾了鲁棒性和易用性。无论是用于学习异步爬虫开发,还是作为实际项目的基础框架,都具有很高的参考价值。

核心启示:

  • 好的爬虫框架应具备配置化、模块化、可扩展的特性
  • 异常处理是爬虫稳定性的关键,需覆盖所有可能的失败场景
  • 反反爬策略应循序渐进,从基础的特征隐藏开始
  • 数据持久化需考虑不同的使用场景,提供多格式支持

通过理解并掌握该框架的设计思想,开发者可以快速构建适应不同场景的爬虫应用,同时遵循合法合规的爬取原则,尊重网站的 robots.txt 协议和使用条款。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值