爬虫工程化:Playwright + 反反爬 + 数据清洗管道实战

文章目录

    • [1. 从 requests 到 Playwright:静态页面的时代结束了](#1. 从 requests 到 Playwright:静态页面的时代结束了)
    • [2. Playwright 核心操作:从导航到数据提取](#2. Playwright 核心操作:从导航到数据提取)
      • [2.1 元素定位与数据提取](#2.1 元素定位与数据提取)
      • [2.2 交互操作](#2.2 交互操作)
    • [3. 等待策略:自动化爬虫的可靠性基石](#3. 等待策略:自动化爬虫的可靠性基石)
    • [4. 网络拦截:在协议层操作数据](#4. 网络拦截:在协议层操作数据)
    • [5. JS 渲染内容提取:Playwright 独有能力的边界](#5. JS 渲染内容提取:Playwright 独有能力的边界)
    • [6. 反反爬对抗:检测原理与绕过方案](#6. 反反爬对抗:检测原理与绕过方案)
      • [6.1 browser_type.launch() 参数优化](#6.1 browser_type.launch() 参数优化)
      • [6.2 stealth.js 注入](#6.2 stealth.js 注入)
    • [7. IP 代理池:可用的代理从哪里来](#7. IP 代理池:可用的代理从哪里来)
    • [8. 请求频率控制:模拟人类行为](#8. 请求频率控制:模拟人类行为)
    • [9. 多浏览器上下文与并发](#9. 多浏览器上下文与并发)
    • [10. 数据清洗管道](#10. 数据清洗管道)
    • [11. 实战:豆瓣电影 Top250 完整爬取](#11. 实战:豆瓣电影 Top250 完整爬取)
    • 总结

1. 从 requests 到 Playwright:静态页面的时代结束了

requests.get(url) 获取的是服务器返回的第一帧 HTML------这对于 2015 年以前的网站完全够用。但现代网站的页面结构已经发生了根本变化:核心内容通过 AJAX 异步加载,列表通过无限滚动渲染,价格和库存通过 WebSocket 实时更新。requests 拿到的 HTML 里只有 <div id="app"></div> 和一个打包后的 JavaScript 文件。

Playwright 解决了这个问题的方式不同于 Selenium:它不是"控制浏览器",而是"让浏览器按照用户的行为执行操作"。这种设计哲学带来了三个工程上的优势:自动等待(元素出现再操作,不需要手动 sleep)、网络拦截(在请求/响应层直接操作数据)、以及多浏览器上下文隔离(同一个浏览器实例内的多个独立会话)。

python 复制代码
# requests 看到的内容
import requests
resp = requests.get("https://movie.douban.com/top250")
print(len(resp.text))  # ~10KB------只有框架,没有电影列表

# Playwright 看到的内容
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://movie.douban.com/top250")
    page.wait_for_selector(".item")  # 等待电影卡片加载
    content = page.content()
    print(len(content))  # ~500KB------包含完整的 25 部电影信息
    browser.close()

2. Playwright 核心操作:从导航到数据提取

2.1 元素定位与数据提取

Playwright 提供了多层次的定位策略,按推荐优先级排列:

python 复制代码
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://movie.douban.com/top250")

    # 层次一:语义定位(最稳定)
    page.get_by_role("heading", name="豆瓣电影 Top 250")
    page.get_by_text("评分")

    # 层次二:CSS 选择器(最常用)
    items = page.query_selector_all(".grid_view .item")
    for item in items:
        title = item.query_selector(".title").inner_text()
        rating = item.query_selector(".rating_num").inner_text()
        quote = item.query_selector(".quote")
        quote_text = quote.inner_text() if quote else ""

    # 层次三:XPath(复杂条件时使用)
    high_rated = page.query_selector_all(
        "//div[contains(@class, 'item')][.//span[@class='rating_num' and number(text())>=9.0]]"
    )

    browser.close()

定位策略的稳定性从高到低为:get_by_role/get_by_text > CSS 选择器 > XPath。语义定位不受 CSS 类名变更影响;CSS 选择器简洁且性能好;XPath 最灵活但最脆弱------网站改版时 XPath 往往第一个失效。

2.2 交互操作

python 复制代码
# 翻页(模拟真实用户行为)
page.click("text=后页")  # 文字定位

# 下拉加载更多(无限滚动)
last_height = page.evaluate("document.body.scrollHeight")
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
page.wait_for_timeout(2000)  # 等待新内容加载

# 表单填写
page.fill("input[name='search']", "Python")
page.press("input[name='search']", "Enter")

3. 等待策略:自动化爬虫的可靠性基石

#mermaid-svg-uC1pH0cZmBFTdwHB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uC1pH0cZmBFTdwHB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uC1pH0cZmBFTdwHB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uC1pH0cZmBFTdwHB .error-icon{fill:#552222;}#mermaid-svg-uC1pH0cZmBFTdwHB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uC1pH0cZmBFTdwHB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uC1pH0cZmBFTdwHB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uC1pH0cZmBFTdwHB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uC1pH0cZmBFTdwHB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uC1pH0cZmBFTdwHB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uC1pH0cZmBFTdwHB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uC1pH0cZmBFTdwHB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uC1pH0cZmBFTdwHB .marker.cross{stroke:#333333;}#mermaid-svg-uC1pH0cZmBFTdwHB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uC1pH0cZmBFTdwHB p{margin:0;}#mermaid-svg-uC1pH0cZmBFTdwHB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uC1pH0cZmBFTdwHB .cluster-label text{fill:#333;}#mermaid-svg-uC1pH0cZmBFTdwHB .cluster-label span{color:#333;}#mermaid-svg-uC1pH0cZmBFTdwHB .cluster-label span p{background-color:transparent;}#mermaid-svg-uC1pH0cZmBFTdwHB .label text,#mermaid-svg-uC1pH0cZmBFTdwHB span{fill:#333;color:#333;}#mermaid-svg-uC1pH0cZmBFTdwHB .node rect,#mermaid-svg-uC1pH0cZmBFTdwHB .node circle,#mermaid-svg-uC1pH0cZmBFTdwHB .node ellipse,#mermaid-svg-uC1pH0cZmBFTdwHB .node polygon,#mermaid-svg-uC1pH0cZmBFTdwHB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uC1pH0cZmBFTdwHB .rough-node .label text,#mermaid-svg-uC1pH0cZmBFTdwHB .node .label text,#mermaid-svg-uC1pH0cZmBFTdwHB .image-shape .label,#mermaid-svg-uC1pH0cZmBFTdwHB .icon-shape .label{text-anchor:middle;}#mermaid-svg-uC1pH0cZmBFTdwHB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uC1pH0cZmBFTdwHB .rough-node .label,#mermaid-svg-uC1pH0cZmBFTdwHB .node .label,#mermaid-svg-uC1pH0cZmBFTdwHB .image-shape .label,#mermaid-svg-uC1pH0cZmBFTdwHB .icon-shape .label{text-align:center;}#mermaid-svg-uC1pH0cZmBFTdwHB .node.clickable{cursor:pointer;}#mermaid-svg-uC1pH0cZmBFTdwHB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uC1pH0cZmBFTdwHB .arrowheadPath{fill:#333333;}#mermaid-svg-uC1pH0cZmBFTdwHB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uC1pH0cZmBFTdwHB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uC1pH0cZmBFTdwHB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uC1pH0cZmBFTdwHB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uC1pH0cZmBFTdwHB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uC1pH0cZmBFTdwHB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uC1pH0cZmBFTdwHB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uC1pH0cZmBFTdwHB .cluster text{fill:#333;}#mermaid-svg-uC1pH0cZmBFTdwHB .cluster span{color:#333;}#mermaid-svg-uC1pH0cZmBFTdwHB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uC1pH0cZmBFTdwHB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uC1pH0cZmBFTdwHB rect.text{fill:none;stroke-width:0;}#mermaid-svg-uC1pH0cZmBFTdwHB .icon-shape,#mermaid-svg-uC1pH0cZmBFTdwHB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uC1pH0cZmBFTdwHB .icon-shape p,#mermaid-svg-uC1pH0cZmBFTdwHB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uC1pH0cZmBFTdwHB .icon-shape .label rect,#mermaid-svg-uC1pH0cZmBFTdwHB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uC1pH0cZmBFTdwHB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uC1pH0cZmBFTdwHB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uC1pH0cZmBFTdwHB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 元素可见
网络静默
断言等待
固定延迟

超时




超时
触发操作
等待策略
wait_for_selector

selector, state=visible
wait_for_load_state

networkidle
expect locator

to_be_visible
page.wait_for_timeout

不推荐
元素出现在 DOM 中?
继续执行
抛出 TimeoutError
0 个网络请求进行中?
500ms 内无新请求?
满足断言条件?

三种推荐等待策略的使用场景:

python 复制代码
# 场景一:等待特定元素出现(翻页后的新内容)
page.click(".paginator .next a")
page.wait_for_selector(".item:nth-child(1)", state="visible")

# 场景二:等待所有网络请求完成(SPA 页面加载)
page.goto("https://example.com/app")
page.wait_for_load_state("networkidle")

# 场景三:断言等待(Playwright 内置重试机制)
from playwright.sync_api import expect
expect(page.locator(".result-count")).to_contain_text("共 250 条")

wait_for_timeout(2000)(死等 2 秒)是最不可靠的等待方式------请求可能在 2001ms 后完成导致超时,也可能在 30ms 后完成却在白等 1970ms。前三种策略都在网络条件良好时立即返回,只有必要时才会等待。


4. 网络拦截:在协议层操作数据

page.route() 是 Playwright 最强大的功能之一------它允许在浏览器发出请求或收到响应之前进行拦截和修改:

python 复制代码
# 拦截特定 API 请求并返回 Mock 数据
def mock_api(route):
    if "api/recommend" in route.request.url:
        route.fulfill(
            status=200,
            content_type="application/json",
            body='{"items": [], "reason": "mock"}',
        )
    else:
        route.continue_()

page.route("**/*", mock_api)

# 修改请求头(拦截并重新发送)
def add_headers(route):
    headers = route.request.headers
    headers["X-Custom-Source"] = "playwright-scraper"
    route.continue_(headers=headers)

page.route("**/api/**", add_headers)

网络拦截的实际应用场景:

  • Mock 响应:在开发阶段绕过验证码接口,用固定数据测试后续的数据清洗逻辑
  • 请求统计:记录页面加载过程中所有的 API 调用及其耗时,分析哪些接口是瓶颈
  • 图片/字体拦截:拦截不必要的资源请求,加速页面加载和减少流量消耗
python 复制代码
# 拦截图片和字体,加速爬取
page.route("**/*.{png,jpg,jpeg,gif,svg,woff,woff2}", lambda route: route.abort())

对于纯数据采集的爬虫,图片和字体请求不仅浪费带宽,还会增加页面渲染时间。在导航前注册这个拦截规则,页面的 networkidle 事件会提前数秒触发。


5. JS 渲染内容提取:Playwright 独有能力的边界

当 CSS 选择器和 XPath 都无法定位到目标元素时(如动态插入的内容、Shadow DOM 内部的元素),page.evaluate() 提供了在浏览器上下文中执行任意 JavaScript 的能力:

python 复制代码
# 提取用 CSS 选择器难以获取的数据
prices = page.evaluate("""() => {
    return Array.from(document.querySelectorAll('.price-item'))
        .map(el => ({
            text: el.innerText,
            value: parseFloat(el.dataset.price),
        }));
}""")

# 滚动到页面底部触发懒加载
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")

# 修改页面元素(移除遮挡的弹窗)
page.evaluate("document.querySelector('.modal-overlay')?.remove()")

page.evaluate() 的执行上下文是整个页面的 JavaScript 环境------它可以访问所有全局变量、修改 DOM、调用页面中加载的任何库。这一能力的代价是失去了 Playwright 的自动等待和重试机制------evaluate 中访问的元素如果不存在会直接抛出 JavaScript 错误而不是等待出现。


6. 反反爬对抗:检测原理与绕过方案

现代网站的反爬系统通过多个维度的特征来判断访问者是人还是机器人。对抗的核心思路不是"隐藏所有特征"(这不可能),而是"让特征分布尽可能接近真实浏览器"。
#mermaid-svg-Udd3N3g3WlSjrR3L{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Udd3N3g3WlSjrR3L .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Udd3N3g3WlSjrR3L .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Udd3N3g3WlSjrR3L .error-icon{fill:#552222;}#mermaid-svg-Udd3N3g3WlSjrR3L .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Udd3N3g3WlSjrR3L .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Udd3N3g3WlSjrR3L .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Udd3N3g3WlSjrR3L .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Udd3N3g3WlSjrR3L .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Udd3N3g3WlSjrR3L .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Udd3N3g3WlSjrR3L .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Udd3N3g3WlSjrR3L .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Udd3N3g3WlSjrR3L .marker.cross{stroke:#333333;}#mermaid-svg-Udd3N3g3WlSjrR3L svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Udd3N3g3WlSjrR3L p{margin:0;}#mermaid-svg-Udd3N3g3WlSjrR3L .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Udd3N3g3WlSjrR3L .cluster-label text{fill:#333;}#mermaid-svg-Udd3N3g3WlSjrR3L .cluster-label span{color:#333;}#mermaid-svg-Udd3N3g3WlSjrR3L .cluster-label span p{background-color:transparent;}#mermaid-svg-Udd3N3g3WlSjrR3L .label text,#mermaid-svg-Udd3N3g3WlSjrR3L span{fill:#333;color:#333;}#mermaid-svg-Udd3N3g3WlSjrR3L .node rect,#mermaid-svg-Udd3N3g3WlSjrR3L .node circle,#mermaid-svg-Udd3N3g3WlSjrR3L .node ellipse,#mermaid-svg-Udd3N3g3WlSjrR3L .node polygon,#mermaid-svg-Udd3N3g3WlSjrR3L .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Udd3N3g3WlSjrR3L .rough-node .label text,#mermaid-svg-Udd3N3g3WlSjrR3L .node .label text,#mermaid-svg-Udd3N3g3WlSjrR3L .image-shape .label,#mermaid-svg-Udd3N3g3WlSjrR3L .icon-shape .label{text-anchor:middle;}#mermaid-svg-Udd3N3g3WlSjrR3L .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Udd3N3g3WlSjrR3L .rough-node .label,#mermaid-svg-Udd3N3g3WlSjrR3L .node .label,#mermaid-svg-Udd3N3g3WlSjrR3L .image-shape .label,#mermaid-svg-Udd3N3g3WlSjrR3L .icon-shape .label{text-align:center;}#mermaid-svg-Udd3N3g3WlSjrR3L .node.clickable{cursor:pointer;}#mermaid-svg-Udd3N3g3WlSjrR3L .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Udd3N3g3WlSjrR3L .arrowheadPath{fill:#333333;}#mermaid-svg-Udd3N3g3WlSjrR3L .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Udd3N3g3WlSjrR3L .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Udd3N3g3WlSjrR3L .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Udd3N3g3WlSjrR3L .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Udd3N3g3WlSjrR3L .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Udd3N3g3WlSjrR3L .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Udd3N3g3WlSjrR3L .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Udd3N3g3WlSjrR3L .cluster text{fill:#333;}#mermaid-svg-Udd3N3g3WlSjrR3L .cluster span{color:#333;}#mermaid-svg-Udd3N3g3WlSjrR3L div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Udd3N3g3WlSjrR3L .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Udd3N3g3WlSjrR3L rect.text{fill:none;stroke-width:0;}#mermaid-svg-Udd3N3g3WlSjrR3L .icon-shape,#mermaid-svg-Udd3N3g3WlSjrR3L .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Udd3N3g3WlSjrR3L .icon-shape p,#mermaid-svg-Udd3N3g3WlSjrR3L .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Udd3N3g3WlSjrR3L .icon-shape .label rect,#mermaid-svg-Udd3N3g3WlSjrR3L .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Udd3N3g3WlSjrR3L .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Udd3N3g3WlSjrR3L .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Udd3N3g3WlSjrR3L :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 反爬检测维度
HTTP 层
JS 运行时层
行为层
网络层
User-Agent 不一致
缺少标准请求头

Accept-Language/Sec-*
navigator.webdriver = true
Chrome DevTools Protocol 特征
Canvas/WebGL 指纹异常
操作过于规律

固定间隔点击
鼠标轨迹缺失
单一 IP 高频请求
数据中心 IP 段识别

6.1 browser_type.launch() 参数优化

python 复制代码
browser = p.chromium.launch(
    headless=False,       # 调试阶段用有头模式
    args=[
        "--disable-blink-features=AutomationControlled",  # 隐藏自动化标志
        "--no-sandbox",
        "--disable-dev-shm-usage",
    ],
)

context = browser.new_context(
    user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
               "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
    viewport={"width": 1920, "height": 1080},
    locale="zh-CN",
    timezone_id="Asia/Shanghai",
)

--disable-blink-features=AutomationControlled 是 Chromium 特有的启动参数,它阻止浏览器在 navigator.webdriver 中暴露 true 值------这是大多数反爬系统检测自动化工具的第一道防线。

6.2 stealth.js 注入

对于更严格的反爬系统,需要注入 puppeteer-extra-plugin-stealth 的 Playwright 等价脚本,在页面加载之前覆盖更多检测点:

python 复制代码
# 在页面加载前注入对抗脚本
page.add_init_script("""
    // 覆盖 navigator.webdriver
    Object.defineProperty(navigator, 'webdriver', { get: () => false });

    // 覆盖 chrome.runtime
    window.chrome = { runtime: {} };

    // 覆盖 permissions
    const originalQuery = window.navigator.permissions.query;
    window.navigator.permissions.query = (parameters) => (
        parameters.name === 'notifications' ?
        Promise.resolve({ state: Notification.permission }) :
        originalQuery(parameters)
    );
""")

检测点与绕过方案的对应关系:

检测维度 检测方法 Playwright 暴露的特征 绕过方案
navigator.webdriver JS 读取属性 true --disable-blink-features + init_script 覆写
Chrome DevTools Protocol 检测 __playwright 全局变量 存在调试协议连接 stealth.js 清理全局变量
User-Agent 一致性 对比 navigator.userAgent 与请求头 可能不一致 browser.new_context(user_agent=...)
Canvas 指纹 渲染相同图形对比像素 无头模式渲染差异 headless=False 或用 headless="new"
WebGL 指纹 检测 GPU 信息 虚拟 GPU stealth.js 伪造 GPU 信息

7. IP 代理池:可用的代理从哪里来

免费代理的生命周期通常只有几分钟到几小时,直接使用会导致大量请求失败。构建一个代理池的流程是:获取 → 验证 → 评分 → 使用 → 淘汰。

python 复制代码
import asyncio
import random
import httpx
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class Proxy:
    url: str
    success: int = 0
    fail: int = 0
    avg_latency: float = 0.0
    last_used: float = 0.0

    @property
    def score(self) -> float:
        total = self.success + self.fail
        if total == 0:
            return 0.5
        return self.success / total

class ProxyPool:
    def __init__(self, max_proxies: int = 50):
        self._proxies: dict[str, Proxy] = {}
        self._max = max_proxies

    def add(self, proxy_url: str):
        if proxy_url not in self._proxies and len(self._proxies) < self._max:
            self._proxies[proxy_url] = Proxy(url=proxy_url)

    async def validate(self, proxy: Proxy) -> bool:
        try:
            async with httpx.AsyncClient(
                proxy=proxy.url,
                timeout=httpx.Timeout(connect=5.0, read=10.0),
            ) as client:
                start = asyncio.get_event_loop().time()
                resp = await client.get("https://httpbin.org/ip")
                proxy.avg_latency = asyncio.get_event_loop().time() - start
                proxy.success += 1
                return resp.status_code == 200
        except Exception:
            proxy.fail += 1
            return False

    def get_best(self) -> Optional[str]:
        available = [p for p in self._proxies.values() if p.score >= 0.5]
        if not available:
            return None
        available.sort(key=lambda p: (p.score, -p.avg_latency), reverse=True)
        return available[0].url

    def remove_bad(self):
        self._proxies = {
            k: v for k, v in self._proxies.items()
            if v.fail < 5 or v.score > 0.3
        }

评分机制的核心逻辑是:成功率权重高于延迟,因为一个慢但可用的代理比一个快但可能被封的代理更有价值。当连续失败 5 次后,代理被剔除出池。


8. 请求频率控制:模拟人类行为

固定间隔的 time.sleep(2) 是最容易被反爬系统识别的模式------真实用户的操作间隔呈随机分布而非恒定值。

python 复制代码
import time
import random
import math

def human_delay(base: float = 2.0, jitter: float = 0.5):
    """生成符合正态分布的人类操作间隔"""
    delay = abs(random.gauss(base, base * jitter))
    # 加一个随机长暂停(模拟阅读内容)
    if random.random() < 0.15:
        delay += random.uniform(3, 8)
    time.sleep(delay)

# 翻页时使用不同的延迟策略
def paginate_delay(page_number: int):
    """翻页越深,延迟越大(模拟用户疲劳)"""
    base = 2.0 + page_number * 0.5  # 第 1 页等 2s,第 10 页等 7s
    human_delay(base=base)

正态分布延迟 + 随机长暂停的组合让请求间隔的时间序列呈现类人特征------大部分请求间隔在 2-3 秒,偶尔有一个 8 秒的"阅读间隙"。

自适应降速是另一个关键策略:当服务器返回 403/429 时,不仅需要重试,还需要降低整体爬取速度:

python 复制代码
class AdaptiveRateLimiter:
    def __init__(self, base_delay: float = 2.0):
        self.base_delay = base_delay
        self.current_multiplier = 1.0
        self.consecutive_failures = 0

    def on_success(self):
        self.consecutive_failures = 0
        self.current_multiplier = max(1.0, self.current_multiplier * 0.9)

    def on_failure(self):
        self.consecutive_failures += 1
        self.current_multiplier = min(10.0, self.current_multiplier * 2.0)

    def wait(self):
        delay = abs(random.gauss(
            self.base_delay * self.current_multiplier,
            self.base_delay * self.current_multiplier * 0.3,
        ))
        time.sleep(delay)

每次失败将延迟翻倍(最多 10 倍),每次成功将延迟逐渐恢复到基准值。这种机制在不触发反爬系统的情况下最大化爬取速度。


9. 多浏览器上下文与并发

Playwright 的 browser_context 是独立的浏览器会话------每个 Context 有独立的 Cookie、LocalStorage 和缓存,但共享同一个浏览器进程:

python 复制代码
import asyncio
from playwright.async_api import async_playwright

async def scrape_page(context, url: str, page_num: int):
    page = await context.new_page()
    await page.goto(url, wait_until="networkidle")
    items = await page.query_selector_all(".item")
    data = []
    for item in items:
        title = await item.query_selector(".title")
        data.append(await title.inner_text() if title else "")
    await page.close()
    return {"page": page_num, "items": data}

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        contexts = [await browser.new_context() for _ in range(3)]

        # 3 个 Context 并发爬取 3 页
        tasks = [
            scrape_page(contexts[i], f"https://example.com?page={i+1}", i+1)
            for i in range(3)
        ]
        results = await asyncio.gather(*tasks)

        for context in contexts:
            await context.close()
        await browser.close()

asyncio.run(main())

单个 Context 的并发限制在于 Cookie 隔离------如果目标网站通过 Cookie 追踪会话,同一 Context 内的多个页面会共享 Cookie,可能触发频率限制。多个 Context 各自独立,但受限于浏览器进程的内存消耗。实际工程中的推荐配置是"一个浏览器进程 + 3-5 个 Context = 3-5 个并发页"。


10. 数据清洗管道

从网页提取的原始数据需要经过标准化处理才能入库:

python 复制代码
import re
from datetime import datetime

def clean_movie_data(raw_items: list[dict]) -> list[dict]:
    """豆瓣电影数据清洗管道"""
    cleaned = []

    for item in raw_items:
        # 1. 字段标准化:去除空白字符
        title = item.get("title", "").strip().replace("\xa0", " ")
        if not title:
            continue

        # 2. 类型转换与默认值
        try:
            rating = float(item.get("rating", "0"))
        except (ValueError, TypeError):
            rating = 0.0

        # 3. 正则提取结构化信息
        info_text = item.get("info", "")
        year_match = re.search(r"(\d{4})", info_text)
        year = int(year_match.group(1)) if year_match else None

        country_match = re.search(r"/\s*([\u4e00-\u9fa5]+)\s*/\s*$", info_text)
        country = country_match.group(1) if country_match else ""

        # 4. 去重(基于 URL 唯一性)
        url = item.get("url", "")
        if any(c["url"] == url for c in cleaned):
            continue

        # 5. 缺失值填充
        cleaned.append({
            "title": title,
            "rating": rating,
            "year": year,
            "country": country or "未知",
            "url": url,
            "crawled_at": datetime.now().isoformat(),
        })

    return cleaned

数据清洗管道的设计原则:每一步只做一件事。字段标准化、类型转换、正则提取、去重、缺失值填充各自独立------出现问题时能立即定位到具体环节,而不是在一个布满嵌套 if 的函数里逐行排查。

关于文件数据的标准化处理(CSV/JSON/Parquet 的转换和内存优化),在本系列上一篇数据处理文章中已有详细阐述,这里的数据清洗管道产出的正是标准化的结构化数据------可以直接写入 Parquet 文件供后续分析使用。


11. 实战:豆瓣电影 Top250 完整爬取

综合以上所有技术,构建一个完整的豆瓣电影 Top250 爬虫:

python 复制代码
import json
import time
import random
from playwright.sync_api import sync_playwright

def scrape_douban_top250():
    movies = []
    page_num = 0

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True, args=[
            "--disable-blink-features=AutomationControlled",
        ])
        context = browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                       "AppleWebKit/537.36 (KHTML, like Gecko) "
                       "Chrome/125.0.0.0 Safari/537.36",
            viewport={"width": 1920, "height": 1080},
        )
        page = context.new_page()

        # 拦截非必要资源
        page.route("**/*.{png,jpg,gif,svg,woff2}", lambda r: r.abort())

        for start in range(0, 250, 25):
            page_num += 1
            url = f"https://movie.douban.com/top250?start={start}&filter="
            page.goto(url, wait_until="networkidle")

            # 等待电影列表加载
            page.wait_for_selector(".grid_view .item", state="visible")

            items = page.query_selector_all(".grid_view .item")
            for item in items:
                title_el = item.query_selector(".title")
                rating_el = item.query_selector(".rating_num")
                quote_el = item.query_selector(".quote")

                movies.append({
                    "title": title_el.inner_text() if title_el else "",
                    "rating": float(rating_el.inner_text()) if rating_el else 0.0,
                    "quote": quote_el.inner_text() if quote_el else "",
                })

            print(f"第 {page_num} 页完成,累计 {len(movies)} 部")

            # 仿人类延迟
            delay = abs(random.gauss(3.0, 1.0))
            if random.random() < 0.2:
                delay += random.uniform(4, 10)
            time.sleep(delay)

        context.close()
        browser.close()

    # 分析
    avg_rating = sum(m["rating"] for m in movies) / len(movies)
    high_rated = [m for m in movies if m["rating"] >= 9.0]

    print(f"\n=== 豆瓣电影 Top250 爬取完成 ===")
    print(f"共 {len(movies)} 部电影")
    print(f"平均评分: {avg_rating:.2f}")
    print(f"9 分以上: {len(high_rated)} 部")

    # 导出
    with open("douban_top250.json", "w", encoding="utf-8") as f:
        json.dump(movies, f, ensure_ascii=False, indent=2)

scrape_douban_top250()

这个完整的爬虫覆盖了本文的核心知识点:Playwright 核心操作、网络拦截加速、反反爬对抗(User-Agent + 启动参数)、人类行为模拟(高斯分布延迟 + 随机长暂停)、以及最终的数据结构化导出。


总结

requests.get() 到 Playwright 的完整爬虫工程化,核心的转变不在于工具本身,而在于对"网站如何检测自动化访问"的理解。反爬系统的检测维度覆盖了 HTTP 层、JS 运行时、行为模式和网络层------对抗方案需要从这四个维度同时入手,单一层面的绕过(如只改 User-Agent)在成熟的商业反爬系统面前形同虚设。

爬虫工程化的五个关键决策:

  1. 等待策略wait_for_selector() > wait_for_load_state() > wait_for_timeout() ------让浏览器告诉程序"什么时候可以操作",而不是盲猜时间
  2. 网络拦截page.route() 在协议层操作------拦截资源加速、Mock 接口解耦、捕获 API 数据
  3. 反爬对抗:启动参数 + stealth.js + User-Agent 一致性------三维度同时覆盖
  4. 频率控制:高斯分布随机延迟 + 自适应降速------让请求模式接近人类
  5. 数据管道:字段标准化 → 类型转换 → 去重 → 缺失值填充------四步流水线

如果这篇文章中的爬虫工程化实践对数据采集工作有帮助,欢迎点赞、收藏、关注。持续输出高质量技术内容离不开读者的支持。

相关推荐
AI玫瑰助手1 小时前
Python函数:函数的返回值(return)与多值返回
开发语言·python·信息可视化
花果山~~程序猿1 小时前
快速认识python项目的虚拟环境
开发语言·python
basketball6161 小时前
Go语言从入门到进阶:8. 接口
开发语言·后端·golang
gCode Teacher 格码致知1 小时前
Python教学:字符编码的四种环境-由Deepseek产生
开发语言·python
铁链鞭策大师1 小时前
JavaEE之多线程
java·开发语言·java-ee
我是唐青枫1 小时前
Java Optional 实战指南:优雅处理空值与链式转换
java·开发语言
小江的记录本1 小时前
【JVM虚拟机】类加载机制:类加载器、双亲委派模型、好处、破坏双亲委派的场景(附《思维导图》+《面试高频考点清单》)
java·jvm·spring boot·后端·python·spring·面试
basketball6161 小时前
设计模式入门:2. 工厂模式详解 C++实现
开发语言·c++·设计模式
Lumbrologist1 小时前
【C++】零基础入门 · 第 16 节:智能指针
开发语言·c++