针对cloudflare防护的爬虫策略——CDP

1 前提概要

使用requests爬虫时经常碰到cloudflare防护的网站,即使从浏览器中复制包含cookie在内的所有请求参数扔到requests的headers中,返回的仍然是"Just a moment..."对你发出无情的嘲讽,这是因为CF还要验证浏览器指纹,这不是简单地加个UA参数就能解决的。

2 思路

既然CF必须真实地浏览器才能验证通过,那我们就用真实的浏览器去爬取就行了。与正常访问网站不同的是,我们不需要浏览器渲染网页,不需要访问dom中的资源文件,只需要借助浏览器的网络访问能力,可以把浏览器当成我们爬虫的代理网关。

控制浏览器访问指定网页需要用到浏览器的CDP(Chrome DevTools Protocol)功能,可以通过指定端口,控制浏览器的行为。python中可以通过playwright去操控浏览器。

正常通过playwright操控时,经常用到的是

python 复制代码
resp = page.goto('https://www.baidu.com')
print(resp.text()) # 原始html
print(page.content()) # 选然后的html

上述方式相当于在浏览器地址栏内输入了指定网址然后回车,在代码执行期间,我们能看到浏览器加载网页的情况。浏览器加载网页耗费了大量的性能与时间,但我们不需要渲染网页,不导航 Page、不执行网页、不加载图片/CSS/JS,而是让已经连接的 Chrome 通过 CDP 网络层直接 GET URL,再从 IO.read 读取响应体。CDP 本身提供了 Network.loadNetworkResource,并支持 includeCredentials;Playwright 也支持通过 new_cdp_session() 直接发送原始 CDP 命令。

相当于劫持了浏览器的网络层。

参考:CDP Network

cdpsession

3 部署CDP

不建议使用真实浏览器去开启CDP。我的做法是启用Windows沙盒,使用沙盒内的浏览器启用CDP。或者可以使用虚拟机也行。Windows沙盒其实就是Windows自带的能够快速部署的虚拟机。

3.1 启用Windows沙盒

使用真实浏览器或了解Windows沙盒的可以跳过这一节。

在Windows搜索栏内搜索"启用或关闭Windows功能"

勾选Windows沙盒

确认后等待重启,重启后能够搜到windows sandbox的程序,直接使用就行。

3.2 启用CDP

在沙盒内的edge浏览器所在目录执行以下命令:

bash 复制代码
.\msedge.exe --remote-debugging-port=9222 --user-data-dir="C:\Users\WDAGUtilityAccount\Documents\edgedata"

这两个参数是必须的,端口与数据目录都能自己指定。

测试一下CDP是否正常,在浏览器内输入以下url:

bash 复制代码
http://127.0.0.1:9222/json

有内容输出就说明CDP正常启用

3.3 端口转发

CDP仅绑定在127.0.0.1这个IP上,外部无法访问。比如物理机启用的CDP,能在本机通过127.0.0.1访问CDP,但是无法通过物理机的IP(比如192.168.1.2)访问。

需要利用另外一个端口(例如9223),将这个端口的流量转发到9222上。

bash 复制代码
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=9223 connectaddress=127.0.0.1 connectport=9222

此时仍然无法远程访问CDP,因为中间还拦着一道Windows防火墙。需要添加防火墙规则,放行9223端口的流量。

bash 复制代码
netsh advfirewall firewall add rule name="CDP 9223" dir=in action=allow protocol=TCP localport=9223

此时,可以在宿主机上访问沙盒内的CDP。

参考:端口转发

4 代码

平时爬虫时经常使用到多线程加速,CDP中我们可以启用多个异步IO的任务达到同样加速的效果,架构如下

bash 复制代码
一个 Python 进程
    │
    ├── 一个 async_playwright
    │
    ├── 一个 CDP Chrome connection
    │
    ├── 一个 CDP Session
    │
    └── 8 个 asyncio Task
            ├── Task 0 → Network.loadNetworkResource
            ├── Task 1 → Network.loadNetworkResource
            ├── Task 2 → Network.loadNetworkResource
            ...
            └── Task 7 → Network.loadNetworkResource

async_playwright
      ↓
async CDP session
      ↓
asyncio Task 0 ─┐
asyncio Task 1 ─┤
asyncio Task 2 ─┤
...             ├── await cdp.send()
asyncio Task 7 ─┘
python 复制代码
import asyncio
import base64
import os

from playwright.async_api import async_playwright

CDP_URL = "http://172.26.70.255:9223"
INPUT_FILE = "detail_page_urls.txt"
OUTPUT_DIR = r"Y:\detail_pages"
WORKER_COUNT = 8
MIN_SIZE = 10 * 1024


# 读取任务
def load_tasks():
    tasks = []
    with open(INPUT_FILE, "r", encoding="utf-8") as f:
        for line_no, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            parts = line.split(None, 1)
            if len(parts) != 2:
                print(f"[跳过] 第 {line_no} 行格式错误:{line}")
                continue
            file_name, url = parts
            tasks.append((file_name, url))
    return tasks


# 均匀分成 count 份
def split_tasks(tasks, count):
    result = []
    total = len(tasks)
    base = total // count
    remainder = total % count
    start = 0
    for i in range(count):
        size = base
        if i < remainder:
            size += 1
        end = start + size
        result.append(tasks[start:end])
        start = end
    return result


# 读取 CDP Stream
async def read_stream(cdp, handle):
    chunks = []
    try:
        while True:
            result = await cdp.send(
                "IO.read",
                {
                    "handle": handle,
                    "size": 1024 * 1024,
                }
            )

            data = result.get("data", "")
            if data:
                if result.get("base64Encoded", False):
                    chunks.append(base64.b64decode(data))
                else:
                    chunks.append(data.encode("utf-8"))
            if result.get("eof", False):
                break
    finally:
        try:
            await cdp.send(
                "IO.close",
                {
                    "handle": handle
                }
            )

        except Exception:
            pass
    return b"".join(chunks)


# 通过 CDP Network 下载一个 URL
async def download_one(cdp, frame_id, file_name, url, worker_id):
    html_file = os.path.join(OUTPUT_DIR, file_name)

    # 已经存在
    if os.path.exists(html_file):
        print(f"[Worker {worker_id}] {file_name} 已存在,跳过")
        return "exists"
    try:
        # ----------------------------------------------------
        # 核心:
        #
        # 不 page.goto()
        # 不 view-source:
        #
        # 直接使用 Chrome Network 层请求
        # ----------------------------------------------------
        result = await cdp.send(
            "Network.loadNetworkResource",
            {
                "frameId": frame_id,
                "url": url,
                "options": {
                    "disableCache": False,
                    # 使用浏览器 credentials
                    "includeCredentials": True,
                },
            }
        )

        resource = result.get(
            "resource",
            {}
        )

        # 网络请求失败
        if not resource.get("success", False):
            error_name = resource.get("netErrorName", "")
            error_code = resource.get("netError", "")

            print(
                f"[Worker {worker_id}] "
                f"{file_name} 下载异常:"
                f"Network error "
                f"{error_name} "
                f"({error_code})"
            )
            return "stop"
        status_code = int(resource.get("httpStatusCode", 0))

        # 获取 stream
        stream = resource.get("stream")

        if not stream:
            print(
                f"[Worker {worker_id}] "
                f"{file_name} 下载异常:"
                f"Chrome 没有返回 stream"
            )
            return "error"

        # 读取 response body
        body = await read_stream(cdp, stream)
        size = len(body)

        # 异常文件处理
        # 小于 10KB
        if size < MIN_SIZE:
            html = body.decode("utf-8", errors="ignore")

            # -----------------------------------------------
            # Server Error:
            # 虽然小于 10KB,但是保存
            # -----------------------------------------------
            if "OK" in html:
                with open(html_file, "wb") as f:
                    f.write(body)
                print(
                    f"[Worker {worker_id}] "
                    f"{file_name} size Error,"
                    f"按规则保存,"
                    f"{size / 1024:.2f} KB"
                )
                return "ok"

            # -----------------------------------------------
            # 其他小文件:
            # 不保存,停止当前 worker
            # -----------------------------------------------
            print(
                f"[Worker {worker_id}] "
                f"{file_name} 下载异常:"
                f"{size / 1024:.2f} KB,"
                f"不是 Server Error,"
                f"停止当前 Worker"
            )
            return "stop"

        # 正常保存
        with open(html_file, "wb") as f:
            f.write(body)

        print(
            f"[Worker {worker_id}] "
            f"{file_name} OK "
            f"{size / 1024:.2f} KB "
            f"HTTP {status_code}"
        )
        return "success"


    except Exception as e:
        print(
            f"[Worker {worker_id}] "
            f"{file_name} 下载异常:"
            f"{repr(e)}"
        )
        return "error"


# Worker
async def worker(worker_id, tasks, cdp, frame_id):
    print(f"[Worker {worker_id}] 开始,任务数:{len(tasks)}")
    for file_name, url in tasks:
        result = await download_one(cdp, frame_id, file_name, url, worker_id)

        # ----------------------------------------------------
        # 遇到小于 10KB 且无OK
        # 停止当前 Worker
        # ----------------------------------------------------
        if result == "stop":
            break
    print(f"[Worker {worker_id}] 结束")


# 主程序
async def main():
    os.makedirs(OUTPUT_DIR, exist_ok=True)

    # 读取全部任务
    tasks = load_tasks()
    print(f"总任务数:{len(tasks)}")

    # 分成 WORKER_COUNT 份
    batches = split_tasks(tasks, WORKER_COUNT)
    for i, batch in enumerate(batches):
        print(f"Worker {i}: {len(batch)} 个任务")

    # 连接已有 Chrome
    async with async_playwright() as p:
        print(f"连接 CDP:{CDP_URL}")
        browser = await p.chromium.connect_over_cdp(CDP_URL, timeout=30_000)
        print("Chrome connected:", browser.is_connected())

        # 获取现有 BrowserContext
        if not browser.contexts:
            raise RuntimeError("CDP Chrome 没有 BrowserContext")
        context = browser.contexts[0]

        # 获取当前 Page
        pages = context.pages
        if not pages:
            raise RuntimeError("CDP Chrome 当前没有 Page")
        page = pages[0]
        print("当前 Page:", page.url)

        # ----------------------------------------------------
        # 建立 CDP Session
        #
        # 注意:
        # 这里整个程序只创建一个 session
        # 所有 asyncio worker 共用
        #
        # 不存在 Python Thread
        # 所以不会出现 greenlet 跨线程
        # ----------------------------------------------------
        cdp = await context.new_cdp_session(page)

        # 获取当前 frame ID
        frame_tree = await cdp.send("Page.getFrameTree")
        frame_id = (frame_tree["frameTree"]["frame"]["id"])

        print("Frame ID:", frame_id)

        # 创建 WORKER_COUNT 个 asyncio Worker
        workers = []
        for worker_id in range(WORKER_COUNT):
            task = asyncio.create_task(
                worker(worker_id, batches[worker_id], cdp, frame_id)
            )
            workers.append(task)

        # 等待 所有 Worker
        await asyncio.gather(*workers)
        print("\n全部 Worker 执行完成")

        # 只断开 CDP Session,不关闭 Chrome
        try:
            await cdp.detach()
        except Exception:
            pass


# Windows
if __name__ == "__main__":
    asyncio.run(main())

代码解释:

我将所有的爬虫任务收集到一个文件中,即代码中的INPUT_FILE变量,这个文件有多行,每行2列,用空格隔开,第一列是爬取后的结果保存到的文件名,文件保存到OUTPUT_DIR目录下,第二列是url。

代码中我定义了8个worker,每个worker负责INPUT_FILE中任务的1/8,当网络请求失败时,停止当前worker,其他worker不受影响。当所有worker异常停止或任务完成后,整个python进程结束。

代码中我还定义了一些异常处理,比如下载的文件小于10KB,需要检查其内容后再判断是否需要保存为文件。

5 执行爬虫

使用上述方法并不能完全避免CF的防护,因为浏览器指纹、cookie会过期,无人干预的情况下,某个时间点之后,所有的任务都会失败。

使用之前需要我们打开目标网站,手动过一遍CF的验证,注意这个标签页不能关闭。

6 扩展骚操作

在浏览器指纹、cookie过期后,爬虫脚本就空转或结束了,在CF拦截导致任务停止后,手动刷新目标网页能让爬虫重新工作,但我不可能盯着爬虫。通过CDP去刷新网页是过不了CF验证的。

我想到了自动化操作。

思路其实很简单,写一个脚本,操纵键鼠,让他能够刷新网页。再另起一个脚本,调用执行爬虫脚本与自动化脚本,在这两个任务中循环。

自动化脚本示例:

python 复制代码
from time import sleep
import pyautogui

if __name__ == "__main__":
    # 点击任务栏
    pyautogui.moveTo(1400, 1060, duration=0.5)
    pyautogui.click()
    # 点击沙盒
    pyautogui.moveTo(975, 1051, duration=0.5)
    pyautogui.click()
    # 新建标签页
    pyautogui.moveTo(1011, 165, duration=0.5)
    pyautogui.click()
    # 输入目标网址
    pyautogui.write("https://target.com")
    pyautogui.press("enter")
    sleep(10)
    # 鼠标滑动,模拟真人
    pyautogui.moveTo(1000, 440, duration=0.5)
    sleep(10)
    # 关闭标签页
    pyautogui.hotkey("ctrl", "w")

循环脚本示例

python 复制代码
import subprocess
import time

while True:
    print("开始执行 爬虫")
    subprocess.run(["python", "tasks.py"], check=True)

    print("爬虫 执行完成,开始执行 自动化")
    subprocess.run(["python", "refresh.py"], check=True)

    print("自动化 执行完成,重新执行 爬虫")
相关推荐
禹凕3 小时前
机器学习之数据清洗(Machine Learning about Data Cleaning)
人工智能·爬虫·python·机器学习·数据挖掘
数据狐(Datafox)5 小时前
淘宝商品详情API实战:多语言代购商城自动同步数据完整方案
开发语言·前端·数据库·爬虫·json
梅孔立7 天前
Fiddler 工具安装抓包 教程 https
爬虫
feasibility.7 天前
MediaCrawler媒体采集平台安装与使用
爬虫·抖音·小红书·微博·bili
Patrick在香港8 天前
Claude 工具调用返回空:8 次失败里只有 1 次状态码不对,其余全带 200
爬虫·python·api·claude·香港
小静AI工程实验室9 天前
Python 爬虫中文乱码排查:严格解码、UTF-8 BOM 与 JSON 转义的 12 项实验
字符编码·爬虫·python
咖啡星人k9 天前
从自建爬虫到托管工具:数据采集选型对照表
爬虫·数据采集·mcp
电商API_1800790524710 天前
拍照找同款是怎么实现的?淘宝以图搜图接口 item_search_img 对接实战
大数据·爬虫·数据挖掘·数据分析·代采api
Patrick在香港10 天前
Python 抓 0.91 GB 香港法例:Agent 的进度该写进磁盘,不是写进上下文
爬虫·python·api·claude·香港