今年 4 月,工作室接了一单影视数据聚合的项目,客户要做全平台电影元数据的结构化采集,覆盖豆瓣、猫眼、淘票票、IMDb、TMDB 等十几个数据源,日均采集量在 30 万条左右。
第一版方案我们用 requests + BeautifulSoup 单机跑,结果第一天就崩了:三个站点直接 403,两个站点的列表数据走 GraphQL 分段加载,还有一个站的接口签名隔天就换。我盯着 Grafana 面板上 42% 的成功率发了半小时呆,心想这玩意儿肯定得重构。
先说结论,再说为什么。2026 年做全网电影信息爬取,难点不在"怎么发 HTTP 请求",在三个地方:TLS 指纹对抗、前端签名逆向、分布式调度的稳定性。下面逐个拆。
一、为什么 requests + BeautifulSoup 在今天行不通了
单站点爬虫的时代,一个 requests.get() 加 BeautifulSoup 解析就能搞定大部分页面。但"全网"意味着你得同时对付十几个数据源,每个站的反爬策略、异步加载方式、动态渲染技术栈都不一样。
2026 年主流电影站点的技术现状:
维度 |
技术现状 |
对采集的影响 |
渲染方式 |
SPA(React/Vue)+ SSR 混合渲染 |
首屏 HTML 拿不到全部数据,需要执行 JS |
反爬手段 |
TLS 指纹检测、WebDriver 检测、行为分析、验证码 |
requests 的 TLS 指纹和真实浏览器差异大,直接被拦 |
数据加载 |
GraphQL + WebSocket 推送 + 分段流式传输 |
接口不是简单的 GET JSON,需要模拟流式连接 |
接口鉴权 |
动态 Token、签名算法混淆、wasm 加密 |
签名算法藏在混淆 JS 里,隔段时间可能变 |
这张表说明一个问题:传统的 requests.get() + BeautifulSoup 模式,在四个维度上全部失效。不是某一个环节不够用,是每一层都对不上。
往下拆一层看,2026 年站点的反爬已经不是单点检测,而是多维交叉验证。你的 TLS 指纹对了,但 User-Agent 和 sec-ch-ua 头对不上,照样被标记。你的浏览器指纹对了,但请求频率不符合人类行为模式,行为分析模块一样会拦你。
二、整体架构:五层分离
踩完第一版的坑之后,我们把系统拆成了五层,各层职责独立,互不干扰:
┌─────────────────────────────────────────────────┐ │ 调度层 (Schedule Layer) │ │ Celery Beat / APScheduler → 定时/事件触发 │ ├─────────────────────────────────────────────────┤ │ 采集层 (Crawl Layer) │ │ Playwright 浏览器池 → 动态渲染 + 反爬对抗 │ │ httpx + curl_cffi → 纯 HTTP 快速通道 │ ├─────────────────────────────────────────────────┤ │ 解析层 (Parse Layer) │ │ Parsel / lxml → XPath & CSS 选择器 │ │ jmespath → JSON 路径提取 │ ├─────────────────────────────────────────────────┤ │ 清洗层 (Clean Layer) │ │ Pandas → 去重 / 标准化 / 字段映射 │ ├─────────────────────────────────────────────────┤ │ 存储层 (Storage Layer) │ │ PostgreSQL + Elasticsearch → 结构化 + 检索 │ │ 腾讯云 COS / MinIO → 静态资源 │ └─────────────────────────────────────────────────┘
这里有个设计决策要说一下。采集层我们用了双通道:纯 HTTP 通道(httpx + curl_cffi)走那些反爬没那么硬的站,浏览器通道(Playwright 池)留给必须渲染的站。经验上,大约 60%-70% 的页面用 HTTP 通道就能搞定,剩下的才需要上浏览器。这样做的好处是资源占用大幅降低,一个 Playwright 实例驻留内存 200-400MB,全走浏览器的话 32G 的机器也扛不住几十个并发。
核心依赖选型
# pyproject.toml (核心依赖) [tool.poetry.dependencies] python = "^3.12" playwright = "^1.48" # 浏览器自动化(含 stealth 补丁) curl-cffi = "^0.7" # TLS 指纹模拟 httpx = {extras = ["http2"], version = "^0.28"} # HTTP/2 异步客户端 parsel = "^1.9" # 类 scrapy 选择器 lxml = "^5.3" # 高性能 XML/HTML 解析 pydantic = "^2.10" # 数据模型校验 redis = "^5.2" # 去重布隆过滤器 + 任务队列 loguru = "^0.7" # 结构化日志
每个选型背后都有原因。curl_cffi 选 0.7+ 是因为从这个版本开始支持 Chrome 120 以上的 TLS 指纹模拟,低于这个版本的 impersonate 选项太少。httpx 开了 http2 extras,因为部分电影站的 API 已经只接受 HTTP/2 连接了,HTTP/1.1 直接给你 501。
三、关键技术细节
3.1 浏览器指纹对抗:TLS 层 + 浏览器层
这地方很多人踩坑。2026 年主流站点的反爬早就不是检测 navigator.webdriver 那么简单了。现在玩的是 TLS 指纹(JA3/JA4)和浏览器指纹哈希的多维交叉验证。
问题往往不在你 UA 伪装得像不像,而在你的 TLS 握手特征和真实浏览器对不对得上。Python 标准库的 ssl 模块生成的 TLS 指纹和 Chrome 差了十几项,有经验的站点风控系统一眼就能识别。
纯 HTTP 通道用 curl_cffi 模拟真实浏览器的 TLS 握手:
from curl_cffi import requests session = requests.Session() # 模拟 Chrome 130 的 TLS 指纹 # impersonate 会同时修改 TLS ClientHello、扩展顺序、密码套件列表 session.impersonate = "chrome130" resp = session.get( "https://example-movie-site.com/api/list", headers={ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/130.0.0.0 Safari/537.36" ), "sec-ch-ua": ( '"Chromium";v="130", "Google Chrome";v="130", "Not?A_Brand";v="99"' ), "sec-ch-ua-platform": '"Windows"', }, )
这里有个坑要说一下。impersonate 设置的是 TLS 层指纹,但 HTTP 层的 headers 还得你自己配。很多人以为设了 impersonate 就万事大吉,结果 User-Agent 还写着 Chrome 116,sec-ch-ua 里的版本号和 impersonate 的对不上,反而成了更明显的异常特征。三者的版本号必须一致。
对于必须走浏览器的场景(需要执行 JS、需要渲染后截图、需要交互操作),用 Playwright + stealth 补丁:
from playwright.async_api import async_playwright from playwright_stealth import stealth_async async def create_stealth_browser(): p = await async_playwright().start() browser = await p.chromium.launch( headless=True, args=[ "--disable-blink-features=AutomationControlled", # 去掉自动化标记 "--no-sandbox", ], ) context = await browser.new_context( viewport={"width": 1920, "height": 1080}, locale="zh-CN", timezone_id="Asia/Shanghai", ) page = await context.new_page() await stealth_async(page) # 注入反检测脚本,覆盖 navigator.webdriver 等属性 return browser, page
stealth 补丁能过大部分常规检测,但不是万能的。有些站点会用 Canvas 指纹、WebGL 指纹做二次验证,这种情况下需要在 context 里额外注入自定义脚本。我们工作室的做法是维护一份站点指纹配置表,每个站需要覆盖哪些属性都记下来,新站接入时照着配。
3.2 动态 Token 与签名破解
很多站点在请求参数里塞了动态签名字段(比如 sign、_token),生成逻辑通常被混淆后打包进 Webpack chunk。这事儿没有捷径,只能硬啃 JS。
我们工作室的定位流程分三步:
- 通过 Chrome DevTools 的 Overrides 功能替换混淆后的 JS 文件,注入 debugger 断点
- 使用 XHR/Fetch Breakpoints 在请求发出前拦截,沿调用栈回溯到签名生成函数
- 将定位到的签名函数提取为独立的 Node.js 模块,通过 Python 的 subprocess 或 PyMiniRacer 调用
import subprocess import json def compute_sign(params: dict) -> str: """通过 Node.js 子进程调用签名算法 为什么不用 PyMiniRacer 直接在 Python 里跑? 经验上 subprocess 方式更稳定,PyMiniRacer 在某些混淆 JS 的 V8 API 调用上会崩。 子进程的 5 秒超时是兜底,正常签名计算在 50ms 以内。 """ result = subprocess.run( ["node", "sign_worker.js"], input=json.dumps(params), capture_output=True, text=True, timeout=5, ) return result.stdout.strip()
经验上有个粗略规律:电影类站点的签名算法更新频率大概一到两个月一次,不是每周都变。但一旦变了你没跟上,采集就会静默失败,数据缺口可能要等几天后才能发现。所以签名这块必须加监控,签名计算失败率超过阈值就告警。
3.3 请求调度与频控
全站爬取面临的约束主要是两个:并发上限和 IP 信用分。这地方很多人踩坑,以为并发开得越高越好,结果触发风控,整个 IP 段都被拉黑。
一个够用的调度策略需要做两件事:控制请求速率,以及根据目标站点响应快慢动态调整并发。
import asyncio import time from dataclasses import dataclass, field @dataclass class RateLimiter: """基于令牌桶的自适应频控器 rate: 每秒允许的请求数,建议按站点单独配置 burst: 突发容量,允许短时间超出 rate 的请求数 """ rate: float # 每秒允许的请求数 burst: int = 10 # 突发容量 tokens: float = field(init=False) last_refill: float = field(default_factory=time.monotonic) def __post_init__(self): self.tokens = self.burst async def acquire(self): while True: now = time.monotonic() elapsed = now - self.last_refill self.tokens = min(self.burst, self.tokens + elapsed * self.rate) self.last_refill = now if self.tokens >= 1: self.tokens -= 1 return wait = (1 - self.tokens) / self.rate await asyncio.sleep(wait)
令牌桶的 rate 怎么定?经验上,国内电影站点单 IP 的安全 QPS 大概在 1-3 之间,超过这个范围被风控的概率明显上升。豆瓣这种对频率敏感的站,我们工作室配的是 1.5 QPS,猫眼可以放到 2.5 QPS。
另外建议把请求延时策略和站点响应时间动态绑定。响应变慢就自动降低并发,别硬顶着风控阈值跑。我们跑下来的数据是:开了自适应频控后,整体成功率从 65% 左右提升到 90% 以上,被封 IP 的频率降了大概 70%。
3.4 代理配置
代理这块我们踩过不少坑。最早用免费代理池,存活率低到没法用,维护探活脚本花的时间比写爬虫逻辑还多。后来试过自建 ADSL 拨号池,运维成本扛不住。
最后换成了亿牛云(16yun.cn)的隧道代理,整个接入就一行代码的事:
import httpx proxy_url = "http://username:password@proxy.16yun.cn:8080" async with httpx.AsyncClient(proxy=proxy_url) as client: data = await client.get("https://movie-site.com/api/list")
它给你一个固定地址,IP 调度、轮换、故障转移全在云端处理,你不用维护 IP 池也不用写探活脚本。我们这个项目用的标准版(¥399/月,30 万+ IP 池),每天 30 万条影片数据跑了一个多月,基本没遇到 IP 被封的情况。批量抓列表时每请求自动换 IP,需要会话保持的场景用 Proxy-Tunnel 分组,覆盖了常见的抓取需求。
3.5 数据去重:布隆过滤器
电影数据有天然的幂等性,同一部片子不该重复入库。小数据量下 SELECT COUNT(*) 去重没问题,但到亿级数据量,数据库的查询成本就扛不住了。
用 Redis 布隆过滤器,空间效率比 Set 高几个数量级,误判率可控在万分之一以下:
import redis import hashlib class DedupFilter: """基于 Redis 的布隆过滤器去重 用 6 个哈希函数 + 2^32 位 bitmap, 理论误判率约 0.01%,对电影数据去重足够了。 """ def __init__(self, redis_client: redis.Redis, key: str = "bf:movie"): self.redis = redis_client self.key = key def _hash(self, value: str) -> list[int]: """多个哈希函数""" md5 = hashlib.md5(value.encode()).hexdigest() sha = hashlib.sha1(value.encode()).hexdigest() return [ int(md5[i:i+8], 16) for i in range(0, 32, 8) ] + [ int(sha[i:i+8], 16) for i in range(0, 40, 8) ] def is_duplicate(self, item_id: str) -> bool: """返回 True 表示极大概率已存在""" offsets = self._hash(item_id) pipe = self.redis.pipeline() for offset in offsets: pipe.getbit(self.key, offset % (2**32)) results = pipe.execute() return all(results) def mark_seen(self, item_id: str): offsets = self._hash(item_id) pipe = self.redis.pipeline() for offset in offsets: pipe.setbit(self.key, offset % (2**32), 1) pipe.execute()
这里有个坑:布隆过滤器只能加不能删。如果一部电影从源站下架了,你想从去重表里移除它是做不到的。我们的做法是定期重建,每跑完一轮全量采集就清空 bitmap 重新标记。对于电影数据这种低频变更的场景,这个方案够用。
四、数据建模
十几个数据源,每个站的字段定义都不一样。豆瓣叫"评分",猫眼叫"观众评分",IMDb 叫"imdb_rating"。如果不做统一建模,下游清洗和检索会非常痛苦。
用 Pydantic 定义标准化的电影数据模型,把不同源的字段差异屏蔽在模型层:
from pydantic import BaseModel, HttpUrl from datetime import date from typing import Optional class MovieInfo(BaseModel): source: str # 数据来源(douban / maoyan / imdb ...) source_id: str # 源站唯一 ID title_cn: str # 中文片名 title_en: Optional[str] = None # 英文片名 release_year: Optional[int] = None # 上映年份 genres: list[str] = [] # 类型标签 directors: list[str] = [] # 导演 actors: list[str] = [] # 主演 rating: Optional[float] = None # 评分(统一为 10 分制) summary: Optional[str] = None # 简介 poster_url: Optional[HttpUrl] = None # 海报链接 detail_url: HttpUrl # 详情页链接 @property def dedup_key(self) -> str: return f"{self.source}:{self.source_id}"
Pydantic 的好处是入站就校验。字段类型不对、必填字段缺失,在 MovieInfo(**raw_data) 这一步就报错了,不会让脏数据流到下游。我们工作室的规矩是:每个数据源写一个 adapter 函数,负责把原始字段映射到 MovieInfo 的标准字段,适配逻辑集中在一处,后面加新数据源只写 adapter 就行。
五、工程化与运维
5.1 配置热更新
爬虫规则频繁变动是常态。豆瓣改个 class 名,你的 XPath 就失效了。如果选择器硬编码在代码里,每次改都要重新发版,太重了。
把 XPath 选择器、请求头模板抽到 YAML 配置文件里,配合文件监听实现热加载:
# config/sites/douban.yaml site: name: "豆瓣电影" base_url: "https://movie.douban.com" rate_limit: 1.5 # 请求/秒 concurrency: 3 # 并发数 selectors: movie_list: "//div[@class='item']" title: ".//span[@class='title']/text()" rating: ".//span[@class='rating_num']/text()" detail_link: ".//a/@href"
热加载的实现很简单,用 watchdog 监听配置目录的文件变更事件,变更后重新加载 YAML 到内存。重点是改完配置不用重启服务,运维同学不用半夜爬起来发版。
5.2 异常处理与重试
网络波动、验证码阻断、IP 封禁都是常事。没有重试机制的爬虫跑不了半天就得人工干预。
import tenacity @tenacity.retry( stop=tenacity.stop_after_attempt(4), wait=tenacity.wait_exponential(multiplier=1, min=2, max=60), retry=tenacity.retry_if_exception_type((ConnectionError, TimeoutError)), ) async def fetch_with_retry(url: str, client: httpx.AsyncClient) -> httpx.Response: resp = await client.get(url) if resp.status_code == 429: retry_after = int(resp.headers.get("Retry-After", 30)) raise ConnectionError(f"Rate limited, retry after {retry_after}s") resp.raise_for_status() return resp
这里有个细节要注意:429 状态码的处理。有些站点的 429 响应会带 Retry-After 头,告诉你多久后再试。尊重这个值比自己拍脑袋定重试间隔好得多。另外,指数退避的上限设到 60 秒是有意为之,超过 60 秒的等待在生产环境里基本等于超时,不如直接放弃这条 URL 放进失败队列等人工处理。
5.3 监控告警
爬虫跑起来之后,最怕的不是报错,是静默失效。页面结构变了,解析不报错但拿到的全是空值,数据悄悄缺失,等你发现的时候已经缺了好几天。
我们的监控分两层:
第一层是运行指标,包括 QPS、成功率、延迟 P99、异常计数,通过 Prometheus + Grafana 可视化,异常时推送到飞书。成功率掉到 80% 以下就自动告警,不用等人去盯面板。
第二层是结构巡检。对每个站点,对比最近两次抓取的 DOM 树哈希值,结构变化超过阈值就自动通知开发者更新解析规则。这个机制帮我们提前发现过好几次站点改版,比等数据缺失再排查省事得多。
六、法律与伦理边界
技术上确实能做到"全网电影信息的聚合",但有几条红线不能碰:
- 版权内容下载:未经授权的影视资源下载与传播属于违法行为,我们只采集公开的元数据(片名、评分、演员等),不碰资源文件
- robots.txt 合规:必须遵守目标站点的爬虫协议,被 Disallow 的路径不爬
- 速率控制:爬取行为不得影响目标站点的正常服务,单站点 QPS 控制在合理范围
- 用户隐私:不得采集用户的个人信息,包括评论、评分记录等用户行为数据
- 商业用途:采集的数据不得直接用于商业变现
合理的使用场景:学术研究、个人观影记录管理、公开影视元数据的聚合分析。这块怎么把握尺度,得看你们自己的业务定位,但底线是不碰版权内容、不影响目标站点正常运行。
七、总结
回溯一下这篇文章的核心观点:
- 2026 年的全网电影爬虫,难点在 TLS 指纹对抗、前端签名逆向、分布式调度稳定性,不在发请求本身
- 采集层双通道(HTTP + 浏览器)是资源效率最优的方案,60%-70% 的页面不需要上浏览器
- 频控要按站点单独配置,开了自适应频控后成功率从 65% 提升到 90% 以上
- 配置热更新和结构巡检是防止静默失效的两道保险,缺一不可
- 法律红线不能碰,只采公开元数据,不碰版权资源和用户隐私
这套方案是我们工作室在这个项目上踩出来的,不一定对所有人都适用。如果你的采集量级在日均几千条,单机 + requests + 免费代理可能就够了,别过度架构。但如果到了日均十万条以上、多数据源并行的规模,上面这套架构是跑得通的。
FAQ
Q:Playwright 和 Selenium 选哪个?
2026 年的建议是 Playwright。Selenium 的 WebDriver 协议天然带自动化特征,被检测的概率更高。Playwright 通过 CDP 直连浏览器,指纹特征更接近真实用户。另外 Playwright 的异步 API 在并发场景下性能更好,我们实测同样 10 个并发,Playwright 的内存占用比 Selenium 低 30% 左右。
Q:curl_cffi 和 httpx 为什么要一起用?
httpx 负责异步并发和 HTTP/2 支持,但它的 TLS 指纹是 Python 标准库生成的,和真实浏览器差太远。curl_cffi 底层用的是 libcurl,能精确模拟 Chrome 的 TLS 握手特征。两者配合的方式是:用 curl_cffi 的 Session 对象发请求,httpx 做连接池管理和异步调度。实际上更常见的做法是简单场景直接用 curl_cffi 的 Session,复杂场景才和 httpx 混用。
Q:签名算法多久会变一次?
经验上,电影类站点的签名算法更新频率大概一到两个月一次。不是每周都变,但一旦变了你没跟上,采集就会静默失败。建议把签名计算的成功率纳入监控,失败率突增就是算法可能变了。
Q:布隆过滤器的误判率怎么控制?
误判率和哈希函数数量、bitmap 大小有关。我们用 6 个哈希函数 + 2^32 位 bitmap,理论误判率约 0.01%。如果数据量更大,可以增加 bitmap 大小或哈希函数数量。不过 0.01% 对电影数据去重来说已经足够了,最坏情况是极少数电影被误判为已存在,少抓一条而已,不会导致数据错误。
Q:代理 IP 被封了怎么办?
隧道代理的好处是 IP 轮换在云端完成,你不用自己处理。如果某个目标站点对代理 IP 特别敏感(比如封了整个 IP 段),可以联系代理服务商调整出口策略,或者降低该站点的请求频率。我们跑了一个多月,亿牛云标准版基本没遇到 IP 被批量封的情况。
Q:这套架构需要多少服务器资源?
我们的配置是:1 台 32G 内存的主采集机(跑 Playwright 浏览器池 + HTTP 通道),1 台 8G 的 Redis 机器(布隆过滤器 + 任务队列),1 台 16G 的 PostgreSQL + Elasticsearch 机器。如果你的采集量级在日均 5 万条以下,单机 16G 就能跑。这块怎么权衡,得看你们自己的业务量级