提示

返回博客列表

Python 爬虫总被封?从 TLS 指纹到浏览器指纹——反检测的 7 层技术栈

Python 爬虫总被封?从 TLS 指纹到浏览器指纹——反检测的 7 层技术栈

你写了一个视频下载器,跑了没两天就被平台封了 IP。换了代理,过两天又封了。加了 User-Agent,还是封。你开始怀疑:平台是不是能认出我的 Python 脚本?答案是——能,而且很轻松。 你的 requests 库发出的请求,和 Chrome 浏览器发出的请求,在服务器眼里几乎是两个物种:TLS 握手指纹不同、HTTP/2 帧格式不同、请求头顺序不同、甚至 TCP 拥塞控制的行为模式都不同。这篇文章从底层到上层,把 Python 爬虫被检测的 7 个层面逐一拆开,每个层面给出实战对抗方案。

TL;DR:反检测分 7 层:① TLS 指纹(JA3/JA4)→ 用 curl_cffi 或 tls_client 伪装 Chrome 指纹;② HTTP/2 帧 → 用支持 h2 的库;③ 请求头顺序 → 保持和浏览器一致;④ Cookie/Session → 模拟完整的登录态;⑤ JS 挑战 → 必要时上 Playwright;⑥ 浏览器指纹 → headless 检测对抗;⑦ IP/行为分析 → 住宅代理 + 随机延迟。对视频下载器来说,前 4 层搞定基本够用,后 3 层是极端情况。

目录

一、你的 requests 和 Chrome 到底差在哪

先看一个事实。用 requests.get() 访问 https://httpbin.org/headers 和用 Chrome 访问同一个地址,服务端看到的差异:

requests 的请求:
GET /headers HTTP/1.1
Host: httpbin.org
User-Agent: python-requests/2.31.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive

Chrome 的请求:
GET /headers HTTP/2
Host: httpbin.org
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...
Accept: text/html,application/xhtml+xml,...
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Sec-Ch-Ua: "Chromium";v="120", "Google Chrome";v="120"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Windows"
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1

差异远不止 User-Agent 这一行。从传输层到应用层,requests 和 Chrome 有本质区别。大型平台(B 站、YouTube、抖音)的反爬系统,至少会检查其中 3-4 个层面。

二、第一层:TLS 指纹——最容易被忽略的检测维度

2.1 什么是 TLS 指纹

当你访问一个 HTTPS 网站时,浏览器和服务器之间首先要进行 TLS 握手。这个握手过程中,客户端会发送一个 ClientHello 消息,其中包含:

- 支持的 TLS 版本(1.2, 1.3)
- 支持的加密套件列表(几十个)
- 支持的椭圆曲线
- 扩展列表(SNI, ALPN, supported_groups 等)

不同的 HTTP 库发送的 ClientHello 消息内容不同。 这些内容的组合形成一个唯一的"指纹"——JA3 指纹(或新的 JA4 指纹)。

2.2 Python 库的 TLS 指纹对比

TLS 实现 JA3 指纹特征 被检测风险
requests (urllib3) OpenSSL Python 特有指纹 ⚠️ 高
httpx OpenSSL Python 特有指纹 ⚠️ 高
aiohttp OpenSSL Python 特有指纹 ⚠️ 高
curl_cffi curl + TLS 伪装 可伪装 Chrome/Firefox ✅ 低
tls_client 自定义 TLS 可伪装 Chrome ✅ 低
Playwright/浏览器 真实浏览器 原生 Chrome 指纹 ✅ 极低

你可以用这个网站测试自己库的 TLS 指纹:https://tls.peet.ws/api/all

2.3 解决方案:curl_cffi

curl_cffi 是目前 Python 生态里最成熟的 TLS 伪装方案。它的用法和 requests 几乎一样:

# 安装
# pip install curl_cffi

from curl_cffi import requests

# 伪装成 Chrome 120
resp = requests.get('https://httpbin.org/headers',
                    impersonate='chrome120')

# 支持的伪装目标
# chrome120, chrome110, chrome107, chrome101, chrome100
# firefox117, firefox110
# safari17_0, safari15_5
# edge101, edge99

curl_cffi 的底层是 libcurl,但修改了 TLS 握手逻辑,让 ClientHello 的加密套件顺序、扩展列表、椭圆曲线等与目标浏览器完全一致。这样 CDN 层面的 TLS 指纹检测就无法区分你的脚本和真实浏览器了。

2.4 适用于 aiohttp 的替代方案

如果你的项目用了 asyncio,需要异步 + TLS 伪装:

# 方案 1:tls_client(同步,但可以配合 run_in_executor)
import tls_client

session = tls_client.Session(
    client_identifier='chrome_120',
    random_tls_extension_order=True
)
resp = session.get('https://httpbin.org/headers')


# 方案 2:curl_cffi 的异步模式
from curl_cffi.requests import AsyncSession
import asyncio

async def fetch():
    async with AsyncSession() as s:
        resp = await s.get('https://httpbin.org/headers',
                           impersonate='chrome120')
        return resp.text

asyncio.run(fetch())

三、第二层:HTTP/2 与请求头指纹

3.1 请求头的顺序和格式

浏览器的请求头有严格的顺序和格式要求。requests 默认发送的请求头不仅内容不同,顺序也不对

# requests 默认(反爬系统一眼就能看出)
headers = {
    'User-Agent': '...',
    'Accept': '*/*',           # ← 太宽泛
}
# 缺少:Accept-Language, Sec-Ch-Ua, Sec-Fetch-* 等

# 正确的浏览器请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                  'AppleWebKit/537.36 (KHTML, like Gecko) '
                  'Chrome/120.0.0.0 Safari/537.36',
    'Accept': 'text/html,application/xhtml+xml,'
              'application/xml;q=0.9,image/webp,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
    'Accept-Encoding': 'gzip, deflate, br',
    'Cache-Control': 'no-cache',
    'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", '
                 '"Google Chrome";v="120"',
    'Sec-Ch-Ua-Mobile': '?0',
    'Sec-Ch-Ua-Platform': '"Windows"',
    'Sec-Fetch-Dest': 'document',
    'Sec-Fetch-Mode': 'navigate',
    'Sec-Fetch-Site': 'none',
    'Sec-Fetch-User': '?1',
    'Upgrade-Insecure-Requests': '1',
}

关键点Sec-* 系列请求头是 Chrome 独有的安全策略标记。如果你的请求没有这些头,或者值不对,反爬系统可以判定"这不是浏览器发出的请求"。

3.2 HTTP/2 的必要性

很多大型 CDN(Cloudflare、Akamai、阿里云 CDN)对 HTTP/1.1 的请求会更加严格审查——因为现在几乎所有浏览器都默认用 HTTP/2 或 HTTP/3。如果你的脚本还在用 HTTP/1.1,本身就暴露了"非浏览器"身份。

# requests 默认使用 HTTP/1.1
# 需要额外安装支持 HTTP/2 的适配器

# 方案 1:httpx(支持 HTTP/2)
import httpx

client = httpx.Client(http2=True)
resp = client.get('https://httpbin.org/headers')

# 方案 2:curl_cffi(默认 HTTP/2)
from curl_cffi import requests as curl_requests
resp = curl_requests.get('https://httpbin.org/headers',
                         impersonate='chrome120')

四、第三层:Cookie 与 Session 管理

4.1 不仅仅是"带 Cookie"

平台反爬系统不光检查"有没有 Cookie",还会检查 Cookie 的使用模式:

正常浏览器:
1. 首次访问 → 无 Cookie → 服务器 Set-Cookie: session_id=xxx
2. 后续请求 → 带上 session_id → 服务器识别为同一会话

爬虫常见错误:
1. 首次请求 → 带了一个固定的 Cookie(从配置文件读的)
2. 服务器发现:这个 session_id 上次在另一个 IP 用过了
3. 判定:可能是盗用的 Cookie → 拒绝或标记

4.2 正确做法

from curl_cffi import requests

# 使用 Session 自动管理 Cookie
session = requests.Session(impersonate='chrome120')

# 先访问首页(获取初始 Cookie,建立会话)
session.get('https://www.example.com')

# 然后再请求视频接口(Cookie 自动带上,且是"新鲜"的)
resp = session.get('https://www.example.com/api/video/12345')

# 如果需要登录态,模拟登录
session.post('https://www.example.com/login', data={
    'username': '...',
    'password': '...',
})

关键原则:让 Cookie 从"浏览器行为"中自然产生,而不是从配置文件里硬塞进去。

五、第四层:JavaScript 挑战与无头浏览器

5.1 什么时候需要浏览器

当平台使用了以下技术时,纯 HTTP 库无法处理:

  • Cloudflare Turnstile / 5 秒盾:需要执行 JS 并提交验证结果
  • WebDriver 检测:B 站等平台的播放页会检测 navigator.webdriver
  • wasm 计算:YouTube 的 n-sig 签名(yt-dlp 已经帮我们处理了)
  • Canvas 指纹:通过 Canvas 2D 渲染差异识别浏览器

5.2 Playwright 反检测配置

from playwright.sync_api import sync_playwright

def create_stealth_browser():
    """创建一个反检测的浏览器实例"""
    p = sync_playwright().start()

    browser = p.chromium.launch(
        headless=True,
        args=[
            '--disable-blink-features=AutomationControlled',  # 移除自动化标记
            '--no-sandbox',
            '--disable-infobars',
            '--disable-dev-shm-usage',
        ]
    )

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

    # 注入反检测脚本
    page = context.new_page()

    # 隐藏 webdriver 标记
    page.add_init_script("""
        Object.defineProperty(navigator, 'webdriver', {
            get: () => undefined
        });

        // 伪装 chrome.runtime
        window.chrome = {
            runtime: {}
        };

        // 伪装 plugins 和 mimeTypes
        Object.defineProperty(navigator, 'plugins', {
            get: () => [1, 2, 3, 4, 5]
        });

        // 移除 headless 相关的 User-Agent 标记
        Object.defineProperty(navigator, 'languages', {
            get: () => ['zh-CN', 'zh', 'en']
        });
    """)

    return page


# 使用
page = create_stealth_browser()
page.goto('https://www.example.com/video/12345')

# 等待视频元素加载
page.wait_for_selector('video', timeout=10000)

# 获取视频地址
video_src = page.evaluate("""
    () => document.querySelector('video').src
""")
print(f"视频地址: {video_src}")

page.close()

六、第五层:浏览器指纹与 Headless 检测

即使用了无头浏览器,平台还能通过以下方式检测:

6.1 Headless Chrome 的暴露点

// 平台的反爬 JS 可能检测这些:

// 1. navigator.webdriver(最常见的检测点)
navigator.webdriver === true  // Headless 默认 true

// 2. Chrome 自动化标记
navigator.userAgent.includes('HeadlessChrome')

// 3. 权限请求行为
navigator.permissions.query({name: 'notifications'})
// Headless 中返回的状态和普通 Chrome 不同

// 4. WebGL 渲染器
// Headless 的 WebGL 渲染器是 "SwiftShader",普通 Chrome 是显卡型号

// 5. 屏幕分辨率
// Headless 默认视口很小

6.2 用 playwright-stealth 一键隐藏

pip install playwright-stealth
from playwright.sync_api import sync_playwright
from playwright_stealth import stealth_sync

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()

    # 一键应用所有反检测措施
    stealth_sync(page)

    page.goto('https://bot.sannysoft.com')  # 检测网站
    page.screenshot(path='stealth_test.png')

七、第六层:IP 信誉与行为分析

7.1 IP 层面的检测

即使前 5 层全过了,IP 也可能被标记:

IP 类型 被检测风险 说明
机房 IP(阿里云/腾讯云/AWS) ⚠️ 高 大量爬虫使用,信誉分低
数据中心代理 ⚠️ 高 和机房 IP 一样的问题
住宅代理 ✅ 低 看起来像普通家庭宽带
移动代理 ✅ 极低 4G/5G 出口,最难被封

7.2 行为层面的检测

正常用户的行为特征:
- 先浏览首页 → 搜索 → 点击结果 → 看视频
- 看视频的时间分布合理(不会 24 小时不间断)
- 鼠标有移动轨迹(即使不点击)
- 滚动行为有自然的不规律性

爬虫的行为特征:
- 直接请求视频接口(跳过首页和搜索)
- 24 小时不间断下载
- 没有任何鼠标/滚动事件
- 请求间隔精确一致(比如每次都间隔 1.000 秒)

7.3 行为伪装

import random
import time
import asyncio

class HumanLikeDownloader:
    """模拟人类行为的下载器"""

    def __init__(self):
        self.request_count = 0
        self.session_start = time.time()

    async def human_delay(self):
        """随机延迟,模拟人类的思考时间"""
        # 大部分时间 0.5-2 秒,偶尔 5-10 秒
        if random.random() < 0.1:  # 10% 概率较长延迟
            delay = random.uniform(5, 10)
        else:
            delay = random.uniform(0.5, 2)

        await asyncio.sleep(delay)

    async def should_take_break(self):
        """模拟人类休息:每下载 20 个视频休息 5-15 分钟"""
        if self.request_count > 0 and self.request_count % 20 == 0:
            break_time = random.uniform(300, 900)  # 5-15 分钟
            print(f"模拟休息 {break_time/60:.0f} 分钟...")
            await asyncio.sleep(break_time)

    async def fetch_with_behavior(self, session, url, page_url=None):
        """模拟人类行为的请求"""
        await self.human_delay()
        await self.should_take_break()

        headers = {
            'Referer': page_url or 'https://www.example.com/',
            'Origin': 'https://www.example.com',
        }

        self.request_count += 1
        return await session.get(url, headers=headers)

八、实战:一个高隐匿的视频下载器框架

整合上面所有技术:

from curl_cffi import requests
import time
import random
import os

class StealthVideoDownloader:
    """高隐匿视频下载器"""

    def __init__(self, proxy=None):
        self.session = requests.Session(impersonate='chrome120')

        # 完整浏览器请求头
        self.session.headers.update({
            'Accept': 'text/html,application/xhtml+xml,application/xml;'
                      'q=0.9,image/webp,*/*;q=0.8',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
            'Accept-Encoding': 'gzip, deflate, br',
            'Cache-Control': 'no-cache',
            'Sec-Ch-Ua': '"Chromium";v="120", "Google Chrome";v="120"',
            'Sec-Ch-Ua-Mobile': '?0',
            'Sec-Ch-Ua-Platform': '"Windows"',
            'Sec-Fetch-Dest': 'empty',
            'Sec-Fetch-Mode': 'cors',
            'Sec-Fetch-Site': 'same-origin',
        })

        if proxy:
            self.session.proxies = {
                'http': proxy,
                'https': proxy,
            }

        self.request_count = 0

    def _human_delay(self):
        """随机人类延迟"""
        time.sleep(random.uniform(0.5, 1.5))

    def _get_referer(self, url_or_page):
        """自动构造 Referer"""
        if url_or_page.startswith('http'):
            return url_or_page
        return f'https://www.example.com/video/{url_or_page}'

    def download_video(self, video_url, page_url, output_path):
        """下载视频(完整鉴权 + 伪装)"""
        self._human_delay()

        # 检查是否需要换 IP
        if self.request_count > 50:
            print("请求数过多,建议切换代理")

        headers = {
            'Referer': self._get_referer(page_url),
            'Origin': self._get_referer(page_url).rsplit('/', 1)[0],
        }

        resp = self.session.get(video_url, headers=headers, stream=True)

        if resp.status_code == 403:
            # 尝试加强鉴权
            resp = self._escalate_auth(video_url, page_url)

        resp.raise_for_status()

        total = int(resp.headers.get('content-length', 0))
        downloaded = 0

        os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
        with open(output_path, 'wb') as f:
            for chunk in resp.iter_content(chunk_size=8192):
                f.write(chunk)
                downloaded += len(chunk)

        self.request_count += 1
        return output_path

    def _escalate_auth(self, url, page_url):
        """逐级加强鉴权"""
        # 级别 1:加 Origin
        resp = self.session.get(url, headers={
            'Referer': page_url,
            'Origin': page_url.rsplit('/', 1)[0],
        })
        if resp.status_code == 200:
            return resp

        # 级别 2:重新访问首页获取 Cookie
        self.session.get(page_url)
        resp = self.session.get(url, headers={'Referer': page_url})
        if resp.status_code == 200:
            return resp

        raise PermissionError(f"无法绕过鉴权,最终状态码: {resp.status_code}")

九、合规与温馨提示

  • 本文讨论的反检测技术仅用于合法授权的数据采集个人学习研究
  • 绕过平台的反爬机制可能违反其服务条款,请在使用前阅读目标平台的 robots.txt 和 ToS
  • 高频率请求可能对目标服务器造成压力,请合理控制请求频率
  • 使用代理时注意代理服务商的合规性,避免使用通过非法手段获取的代理 IP
  • 更多法律讨论见 下载视频算侵权吗?聊聊个人备份与版权的那条线

反爬和反检测是一场持续的军备竞赛。今天能用的伪装手段,明天可能就被检测出来。所以最重要的不是"找到终极方案",而是建立一套监控机制——定期用检测工具(如 tls.peet.ws、bot.sannysoft.com)检查你的脚本是否暴露了痕迹,一旦发现新的检测点,及时更新伪装策略。

本文由 VidDown 技术博客原创发布。VidDown 的解析引擎内置了上述多层反检测技术,让你在下载视频时不会被平台误判为爬虫。访问 VidDown 了解更多。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部