#从脚本到独立程序:Python + Playwright 批量抓取的完整踩坑记录

requests 做数据抓取,遇到 JS 渲染的页面直接抓瞎。用 Selenium 吧,启动慢、内存占用高,打包成 EXE 后体积爆炸。

Playwright 算是目前比较均衡的选择:启动快、API 现代、自动等待省心。但当你把它和 PyInstaller 打包到一起时,坑才开始出现。

这篇文章记录一个完整的流程:从 Playwright 抓取脚本,到打包成双击运行的 EXE,中间踩过的坑和解决办法。


一、为什么选 Playwright 而不是 requests + BeautifulSoup?

requests + BeautifulSoup 的方案在静态页面时代没问题。但现在大部分网站的数据都是 Ajax 加载,页面源码里根本看不到表格内容。加上登录态、滑块验证这些反爬机制,纯 HTTP 请求方案基本出局。

Playwright 的优势:

  • 真正的浏览器内核,能执行 JavaScript,自动等待元素加载
  • 内置的自动重试和智能等待,不用手动写 time.sleep()
  • 支持多浏览器(Chromium、Firefox、WebKit),测试兼容性方便
  • 录制功能可以自动生成代码,快速上手

代价也很明显:打包后的体积会大很多。后面会详细说。


二、核心抓取逻辑:一个完整的示例

先上代码,再拆解思路。

python 复制代码
# spider.py
import asyncio
import csv
import os
from datetime import datetime
from playwright.async_api import async_playwright


class OrderSpider:
    def __init__(self, username, password):
        self.username = username
        self.password = password
        self.results = []
        self.output_dir = os.path.join(os.path.dirname(__file__), "output")
        os.makedirs(self.output_dir, exist_ok=True)

    async def run(self):
        async with async_playwright() as p:
            # 启动浏览器,headless=True 表示无界面模式
            browser = await p.chromium.launch(headless=True)
            context = await browser.new_context(
                viewport={"width": 1920, "height": 1080},
                user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
            )
            page = await context.new_page()

            try:
                await self._login(page)
                await self._fetch_orders(page)
                self._save_to_csv()
                print(f"抓取完成,共 {len(self.results)} 条数据")
            finally:
                await browser.close()

    async def _login(self, page):
        """模拟登录流程"""
        await page.goto("https://example.com/login")
        
        # 等待页面加载完成
        await page.wait_for_selector('input[name="username"]')
        
        await page.fill('input[name="username"]', self.username)
        await page.fill('input[name="password"]', self.password)
        
        # 点击登录按钮
        await page.click('button[type="submit"]')
        
        # 等待登录后的页面元素出现,确认登录成功
        await page.wait_for_selector(".dashboard-header", timeout=10000)
        print("登录成功")

    async def _fetch_orders(self, page):
        """翻页抓取订单数据"""
        page_num = 1
        while True:
            print(f"正在抓取第 {page_num} 页...")
            
            # 等待表格加载
            await page.wait_for_selector("table.order-list tbody tr")
            
            rows = await page.query_selector_all("table.order-list tbody tr")
            
            for row in rows:
                cells = await row.query_selector_all("td")
                if len(cells) >= 5:
                    order_data = {
                        "order_id": await cells[0].inner_text(),
                        "buyer": await cells[1].inner_text(),
                        "amount": await cells[2].inner_text(),
                        "status": await cells[3].inner_text(),
                        "create_time": await cells[4].inner_text(),
                        "fetch_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                    }
                    self.results.append(order_data)
            
            # 检查是否有下一页
            next_btn = await page.query_selector("a.next-page")
            if not next_btn:
                break
            
            is_disabled = await next_btn.get_attribute("class")
            if "disabled" in (is_disabled or ""):
                break
            
            await next_btn.click()
            # 等待页面刷新
            await page.wait_for_load_state("networkidle")
            page_num += 1

    def _save_to_csv(self):
        """结果保存为 CSV"""
        if not self.results:
            print("没有数据需要保存")
            return
        
        filename = os.path.join(
            self.output_dir,
            f"orders_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
        )
        
        with open(filename, "w", newline="", encoding="utf-8-sig") as f:
            writer = csv.DictWriter(f, fieldnames=[
                "order_id", "buyer", "amount", "status", 
                "create_time", "fetch_time"
            ])
            writer.writeheader()
            writer.writerows(self.results)
        
        print(f"数据已保存至: {filename}")


if __name__ == "__main__":
    spider = OrderSpider(username="your_username", password="your_password")
    asyncio.run(spider.run())

几个关键设计点

1. 异步写法

Playwright 支持同步和异步两种 API。批量抓取时,异步能显著提升效率。上面用的是 async_playwright,配合 asyncio.run() 执行。

2. 智能等待

wait_for_selectorwait_for_load_state 是核心。不要用 time.sleep() 硬等,既慢又不稳定。Playwright 会自动轮询直到元素出现或超时。

3. 异常处理

实际生产环境中,网络波动、页面结构变化、反爬机制都会导致失败。建议加上:

  • 单页重试机制(失败自动重试 3 次)
  • 异常数据跳过,不中断整体流程
  • 抓取日志记录,方便排查问题

4. 数据存储

小批量用 CSV 足够。如果数据量大或需要后续分析,可以接 SQLite 或 MySQL。这里用 utf-8-sig 编码是为了让 Excel 打开中文不乱码。


三、打包成 EXE:最大的坑在这里

写脚本容易,打包才是噩梦的开始。

3.1 基础打包命令

r 复制代码
pip install pyinstaller
pyinstaller -F spider.py

-F 表示打包成单个文件。执行完会在 dist 目录生成 spider.exe

但这时候直接运行,大概率会报错:

rust 复制代码
Executable doesn't exist at C:\Users\xxx\AppData\Local\ms-playwright\chromium-xxx\chrome-win\chrome.exe

3.2 问题根源:浏览器没打包进去

Playwright 默认把浏览器安装在用户目录的 .local-browsers 文件夹里,PyInstaller 根本不知道这些文件的存在,自然不会打包。

解决方案:把浏览器"焊死"在程序里。

步骤如下:

csharp 复制代码
# 1. 设置环境变量,让浏览器安装到 Python 包目录下
set PLAYWRIGHT_BROWSERS_PATH=0

# 2. 安装需要的浏览器(这里只装 Chromium,减小体积)
playwright install chromium

# 3. 打包,带上 playwright 的 driver 目录
pyinstaller -F spider.py --add-data "venv\Lib\site-packages\playwright\driver;playwright/driver"

3.3 体积优化

按上面的方式打包,生成的 exe 大约 300-400MB。如果目标用户带宽有限,可以尝试:

  • 只打包用到的浏览器 :上面已经用了 playwright install chromium,如果不需要 Firefox 和 WebKit,就别装
  • --onedir 代替 --onefile:虽然分发麻烦点(一个文件夹),但启动速度更快,体积也能小一些
  • UPX 压缩pyinstaller 支持 --upx-dir 参数,用 UPX 压缩可执行文件
csharp 复制代码
pyinstaller -D spider.py --upx-dir "C:\upx" --add-data "venv\Lib\site-packages\playwright\driver;playwright/driver"

3.4 路径处理

打包后,程序内部的路径会变。建议加一个工具函数统一处理:

python 复制代码
# utils.py
import sys
import os


def get_resource_path(relative_path):
    """获取资源文件的绝对路径,兼容开发环境和打包后的环境"""
    if hasattr(sys, '_MEIPASS'):
        # PyInstaller 打包后的临时目录
        base_path = sys._MEIPASS
    else:
        base_path = os.path.abspath(".")
    return os.path.join(base_path, relative_path)

所有读取文件的地方都用这个函数,避免打包后找不到资源。

3.5 反病毒软件误报

PyInstaller 打包的程序经常被 Windows Defender 误报为病毒。这是因为它的自解压执行模式跟某些恶意软件类似。

缓解方案:

  • --onedir 模式,误报率比 --onefile
  • 给 exe 做代码签名(需要购买证书,个人开发者成本较高)
  • 引导用户手动添加到白名单

四、进阶:做成可配置的工具

上面的代码硬编码了用户名密码,实际交付时肯定不行。可以改成从配置文件读取:

lua 复制代码
# config.py
import json
import os


def load_config():
    config_path = os.path.join(os.path.dirname(__file__), "config.json")
    if not os.path.exists(config_path):
        return {}
    with open(config_path, "r", encoding="utf-8") as f:
        return json.load(f)


# config.json
{
    "username": "",
    "password": "",
    "headless": true,
    "max_pages": 10,
    "output_format": "csv"
}

打包时把 config.json 一起带上,用户只需要改配置文件就能用,不用碰代码。


五、另一种思路:低代码方案

如果你不想折腾代码、打包、环境配置这一套,或者需要给非技术人员使用,可以考虑低代码的自动化方案。

市面上有一些工具能把抓取流程可视化编排,直接导出成可执行程序,不用写一行代码。比如蓝印RPA支持 API 触发、定时执行、自定义界面、打包成 EXE 分发,还能设置授权和加密分享。数据全部保存在本地,不上传云端,适合对隐私有要求的场景。

这类工具的优势是:

  • 不用写代码,拖拽式编排流程
  • 一键打包成独立程序,对方电脑不用装任何环境
  • 支持定时任务和 API 调用,方便集成到现有系统
  • 个人使用没有时长限制,中小企业也能低成本上手

如果你只是偶尔有抓取需求,或者需要快速交付一个工具给别人用,这种方案比从零写代码省很多时间。


六、总结

环节 关键点
技术选型 Playwright 适合 JS 渲染页面,requests 适合静态页面
抓取逻辑 异步 + 智能等待 + 异常处理
打包 设置 PLAYWRIGHT_BROWSERS_PATH=0,用 --add-data 带上浏览器
体积 只装需要的浏览器,考虑 --onedir 或 UPX 压缩
交付 配置文件化,降低使用门槛

整个过程最耗时的不是写抓取逻辑,而是打包和测试兼容性。建议每改一步就打包测试一次,别等到最后才发现问题。

相关推荐
兵慌码乱15 小时前
基于 MediaPipe 与 PySide2 的手势交互音乐控制系统实现:轻量化视觉交互全流程解析
python·opencv·计算机视觉·人机交互·手势识别·mediapipe·pyside2
luckdewei18 小时前
FastAPI 资产管理系统实战:复杂 ORM 关联、Alembic 迁移与 N+1 查询优化
python
怕浪猫1 天前
Playwright 的 CDP Session 机制详解
浏览器·ai编程·自动化运维
aqi001 天前
15天学会AI应用开发(八)使用向量数据库实现RAG功能
人工智能·python·大模型·ai编程·ai应用
Csvn1 天前
`functools.lru_cache` —— 一行代码搞定缓存加速
后端·python
金銀銅鐵2 天前
[Python] 从《千字文》中随机挑选汉字
后端·python
cup112 天前
[技术复盘] Windows Python 打包实战:Nuitka 环境踩坑总结与 CI 自动化构建全指南
python·ai·环境变量·ci·nuitka·skill