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

作者:袖梨 2026-06-23

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

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

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

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


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

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

Playwright 的优势:

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

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


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

先上代码,再拆解思路。

 复制代码# 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 基础打包命令

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

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

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

 复制代码Executable doesn't exist at C:UsersxxxAppDataLocalms-playwrightchromium-xxxchrome-winchrome.exe

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

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

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

步骤如下:

 复制代码# 1. 设置环境变量,让浏览器安装到 Python 包目录下
set PLAYWRIGHT_BROWSERS_PATH=0# 2. 安装需要的浏览器(这里只装 Chromium,减小体积)
playwright install chromium# 3. 打包,带上 playwright 的 driver 目录
pyinstaller -F spider.py --add-data "venvLibsite-packagesplaywrightdriver;playwright/driver"

3.3 体积优化

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

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

3.4 路径处理

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

 复制代码# 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 做代码签名(需要购买证书,个人开发者成本较高)
  • 引导用户手动添加到白名单

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

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

 复制代码# 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 压缩
交付配置文件化,降低使用门槛

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

相关文章

精彩推荐