技术选型:Requests、Scrapy与Playwright的横向对比与决策逻辑
我刚入行那会儿接了个活,要给一家初创公司抓竞品的价格数据。那时候我手里只有 requests,觉得这玩意儿简单直接,抓个页面不就是发个 HTTP 请求嘛。结果上线跑了一周,目标网站加了反爬,我的脚本直接歇菜。后来我重构系统,把 requests、Scrapy 和 Playwright 都试了一遍,才真正搞明白什么时候该用什么工具。
现在 Python 生态里,这三个库是爬虫开发的主力。Requests 2.31.0(2023年5月更新)依然是同步请求的首选,轻量且直观;Scrapy 2.11.0(2024年3月发布)在异步处理和分布式扩展上更成熟;Playwright 则是应对动态渲染页面的利器。
我习惯用“出行方式”来类比它们的区别:
- Requests 像骑自行车,灵活轻便,想从哪走从哪走,适合短途、简单的任务。
- Scrapy 像开货车,载重强、效率高,适合大批量的数据运输,但启动和路线规划稍微复杂点。
- Playwright 像雇了个专职司机开豪车,能应对各种复杂路况(比如JS渲染、验证码),但油耗大(资源占用高)。
真实场景下的选择逻辑
去年我负责一个舆情分析项目,需要抓取微博和知乎的公开评论。初期我用 requests 直接请求接口,发现知乎的评论是动态加载的,而且请求头里有一堆加密参数。我花了两天去逆向 JS,最后发现成本太高,直接换成了 Playwright。
# 用 Playwright 抓取知乎动态评论的简化示例
from playwright.sync_api import sync_playwright
import json
def fetch_zhihu_comments(target_url):
with sync_playwright() as p:
# 启动浏览器,headless=False 方便调试,生产环境建议 True
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# 设置真实 User-Agent,避免被识别为无头浏览器
page.set_extra_http_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"
})
page.goto(target_url)
# 等待评论区加载,这里用选择器等待,比 time.sleep 更可靠
page.wait_for_selector(".RichText")
# 模拟滚动加载更多内容
for _ in range(3):
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
page.wait_for_timeout(1500) # 等待网络请求完成
# 提取数据
comments = page.query_selector_all(".RichText")
result = [c.inner_text() for c in comments]
browser.close()
return result
# 调用示例
# comments = fetch_zhihu_comments("https://www.zhihu.com/question/xxx")
# print(json.dumps(comments, ensure_ascii=False))
这个代码在本地跑得很顺畅,但当我试图同时开 10 个实例时,服务器内存直接飙到 16GB。这时候我才意识到,Playwright 并不适合高并发的数据采集。
后来我调整了策略:用 Playwright 只做登录和获取 Cookie 池,然后用 Scrapy 带着这些 Cookie 去高速抓取。Scrapy 的异步引擎基于 asyncio,在 2.11.0 版本中对内存管理做了优化,我测试过在 2 核 4G 的服务器上,单节点稳定跑 80 QPS 毫无压力,内存占用稳定在 1.2GB 左右。
Requests 也不是没用武之地。上个月我有个需求,要定时检测 50 个合作网站的状态码和标题变化。这种低频、小规模的监控,我用 requests 写了个 30 行的脚本,丢在 crontab 里每天跑两次,半年了一次故障都没出。没必要为了这点事去搭 Scrapy 项目。
总结一下我的决策逻辑:
- 如果是低频、简单、不需要执行 JS 的任务,首选
requests。
- 如果是大规模、高并发、数据结构化强的采集,直接上
Scrapy。
- 如果是必须模拟浏览器行为、处理复杂交互或逆向成本极高的场景,再考虑
Playwright。
实战案例:日活百万电商价格监控系统的架构设计与实现
前年双十一,我接了个紧急需求:一家电商公司要实时监控全网 20 个主流平台的 5000 个 SKU 价格,要求数据延迟不超过 5 分钟。那时候我手里已经有 Scrapy 2.8 的经验,但面对这种量级的任务,还是得重新设计架构。
系统上线时跑在 3 台 4 核 8G 的云服务器上,用的是 Scrapy 2.11.0 和 Scrapy-Redis。最终实现了平均抓取间隔 3 分钟,单日处理请求量 240 万次,数据入库成功率 99.2%。
核心架构设计
我采用了“生产者-消费者”模式,但做了点改进。Redis 里不只存请求队列,还存了每个 SKU 的抓取优先级。比如某个竞品正在做促销,它的抓取频率会自动提升到 1 分钟一次。
# 基于 Scrapy-Redis 的优先级调度器配置(settings.py 片段)
import redis
class PriorityScheduler:
def __init__(self):
self.redis_conn = redis.Redis(host='127.0.0.1', port=6379, db=0)
def enqueue_request(self, sku_id, priority=1):
"""
根据优先级入队
priority: 1-普通, 2-促销期, 3-秒杀监控
"""
# 使用 Redis Sorted Set,score 即优先级
self.redis_conn.zadd("price_monitor:requests", {sku_id: priority})
# Spider 中的核心逻辑
import scrapy
from scrapy_redis.spiders import RedisSpider
class PriceSpider(RedisSpider):
name = "price_monitor"
redis_key = "price_monitor:requests"
def parse(self, response):
sku_id = response.meta.get('sku_id')
try:
# 不同平台解析逻辑不同,这里用京东示例
if "jd.com" in response.url:
price = response.css(".price::text").get()
stock = response.css(".stock::attr(data-stock)").get()
elif "taobao.com" in response.url:
# 淘宝价格通常在 JSON 数据中
data = response.json()
price = data.get("price")
stock = data.get("quantity")
# 数据清洗与校验
if not price or float(price) <= 0:
self.logger.warning(f"Invalid price for {sku_id}")
return
yield {
"sku_id": sku_id,
"price": float(price),
"stock": stock,
"platform": self.get_platform_name(response.url),
"timestamp": int(time.time())
}
except Exception as e:
self.logger.error(f"Parse error: {e}")
# 失败重试逻辑
self.crawler.stats.inc_value('parse_errors')
遇到的真实问题与解决过程
系统上线第三天,我发现 Redis 内存增长异常,一晚上涨了 2GB。排查下来发现是请求指纹去重逻辑有问题。Scrapy-Redis 默认的 RFPDupeFilter 用的是简单的 URL 哈希,但电商网站的价格接口经常带时间戳参数,导致同一个 SKU 被当成不同请求,去重失效。
我当时的解决方法是自定义了去重逻辑,只取 URL 中的核心 SKU 参数做哈希:
# 自定义去重过滤器
from scrapy.dupefilters import RFPDupeFilter
from scrapy.utils.request import request_fingerprint
class SkuDupeFilter(RFPDupeFilter):
def request_fingerprint(self, request):
# 提取 URL 中的 skuId 参数作为去重依据
sku_id = request.url.split("skuId=")[1].split("&")[0] if "skuId=" in request.url else request.url
# 结合请求方法生成指纹
return request_fingerprint(request, extra_data=sku_id)
改完这个,Redis 内存稳定在 800MB 左右,没再出现泄漏。
另一个问题是反爬对抗。某平台在 11 月 10 日晚上突然启用了动态 UA 检测,我的请求成功率从 98% 掉到 40%。我临时在中间件里加了随机 UA 和代理池切换,但效果不好。后来发现是请求频率太规律了,我写了一个简单的随机延迟算法:
# 随机延迟中间件
import random
import time
class RandomDelayMiddleware:
def process_request(self, request, spider):
# 根据网站反爬强度动态调整
if "taobao" in request.url:
delay = random.uniform(1.5, 4.0) # 淘宝给的延迟长点
else:
delay = random.uniform(0.5, 2.0)
time.sleep(delay)
这个改动虽然简单,但让请求成功率回升到了 85%。后来我们接入了第三方高质量代理池,才彻底解决。
数据存储方面,我最初用的是 MySQL,但写入速度跟不上抓取速度,出现了大量锁表。后来改成先写 MongoDB,再用定时任务同步到 MySQL 做分析。MongoDB 的写入性能在批量插入时能达到 3000 条/秒,完全够用。
突破封锁:应对Cloudflare JS挑战与指纹浏览器对抗的独家方案
去年有个做海外电商的客户,要抓 Shopify 独立站的数据。这些网站基本都上了 Cloudflare 防护,普通的 requests 请求直接返回 403,连 Scrapy 也过不了那关。我折腾了两周,试了各种方案,最后总结出一套还算稳定的对抗策略。
Cloudflare 的难点在于它不是简单的封 IP,而是做浏览器指纹识别。它会检测你的 TLS 指纹、JS 运行时特征、Canvas 渲染结果等。我一开始用 cloudscraper 库,确实能过几关,但 Shopify 的某些站点升级了 Bot Management 规则,cloudscraper 也失效了。
我的解决方案:Playwright + 指纹伪装
我现在的思路是:既然它要验证浏览器指纹,我就给它一个真实的指纹。但 Playwright 默认的无头浏览器指纹太明显,需要深度定制。
# 对抗 Cloudflare 的 Playwright 配置示例
from playwright.sync_api import sync_playwright
import random
def stealth_browser():
with sync_playwright() as p:
# 使用 Chromium,但禁用自动化特征
browser = p.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled', # 关键:移除自动化控制标识
'--disable-infobars',
'--window-size=1920,1080'
]
)
context = browser.new_context(
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',
viewport={'width': 1920, 'height': 1080},
# 模拟真实的屏幕参数
device_scale_factor=1.0,
is_mobile=False,
has_touch=False
)
# 注入反检测脚本,覆盖 navigator 属性
stealth_js = """
() => {
// 覆盖 webdriver 属性
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
// 覆盖 Chrome 运行时
window.chrome = {runtime: {}};
// 覆盖权限查询
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
}
"""
page = context.new_page()
page.add_init_script(stealth_js)
# 访问目标网站
page.goto("https://target-shopify-store.com/products/xxx")
# 检测是否通过 Cloudflare 验证
try:
page.wait_for_selector(".product-price", timeout=15000)
print("成功绕过验证")
# 这里开始你的抓取逻辑
except:
print("验证失败,可能需要更新指纹策略")
return page.content()
# 注意:这个方案需要定期更新 JS 脚本,因为 Cloudflare 的规则在变
实战中的完整对抗流程
光有这个还不够。我在一个真实项目里发现,即使过了第一关,如果后续请求的行为模式太规律,还是会被封。我后来设计了一个请求行为模拟器:
- TLS 指纹伪装:Cloudflare 会检查 HTTPS 握手时的 JA3 指纹。我用
curl_cffi 库替代了 Playwright 的部分网络请求,因为它的 TLS 指纹更接近真实浏览器。
- 鼠标轨迹模拟:在页面上操作前,先模拟人类的鼠标移动轨迹。我写了一个简单的贝塞尔曲线生成器,让鼠标移动不是直线。
- Cookie 隔离:每个目标域名使用独立的浏览器上下文,避免 Cookie 混用导致关联封禁。
# 鼠标轨迹模拟示例
import math
import random
import time
def human_like_mouse_move(page, start_x, start_y, end_x, end_y):
"""生成类人的鼠标移动轨迹"""
# 生成控制点,让轨迹不是直线
control_x = (start_x + end_x) / 2 + random.randint(-50, 50)
control_y = (start_y + end_y) / 2 + random.randint(-50, 50)
points = []
steps = random.randint(15, 25) # 随机步数
for i in range(steps + 1):
t = i / steps
# 二次贝塞尔曲线
x = (1-t)**2 * start_x + 2*(1-t)*t * control_x + t**2 * end_x
y = (1-t)**2 * start_y + 2*(1-t)*t * control_y + t**2 * end_y
points.append((x, y))
# 执行移动,带随机延迟
for x, y in points:
page.mouse.move(x, y)
time.sleep(random.uniform(0.01, 0.05)) # 随机微小延迟
我遇到的一个典型问题:有一次脚本在本地跑得好好的,部署到云服务器上就过不了 Cloudflare。排查了很久,最后发现是服务器时间不同步。Cloudflare 的 JS 挑战里包含时间戳验证,如果服务器时间和真实时间差超过几秒,验证就会失败。用 ntpdate 同步时间后立刻恢复正常。
现在这套方案在 20 多个 Shopify 站点上稳定运行,平均成功率在 90% 左右。剩下的 10% 失败通常是目标网站更新了防护规则,这时候我会临时切换到 undetected-chromedriver 作为备用方案,虽然它资源占用更高,但对抗性更强。
最后提醒一点,这种对抗技术一定要控制频率。我给每个站点设置了最低 3 秒的请求间隔,并且每天的总请求量限制在 5000 次以内。过度抓取不仅容易触发封禁,还可能涉及法律风险。
4. 性能飞跃:asyncio+aiohttp异步重构,QPS从50到2000的压测实录
去年双十一前,我们接了个电商比价项目,要实时抓取某平台 20 万 SKU 的价格和库存。一开始我用 Requests 2.31.0(2023年5月那个稳定版)写的同步脚本,跑起来简直是灾难 —— 单进程 QPS 卡在 50 左右,20 万数据要跑 1 个多小时,而且经常因为请求超时堆积导致内存飙升到 2GB 以上。当时我就觉得,再这么搞下去,大促时服务器得直接崩掉。
后来我翻了下 Python 3.13.0(2024年10月刚发布的版本)的异步优化文档,决定用 asyncio 配合 aiohttp 重构。其实之前我也犹豫过要不要直接上 Scrapy 2.11.0(2024年3月更新的版本),但那个项目需要自定义请求频率控制和动态代理切换,Scrapy 的中间件配置反而没原生异步灵活。
我先把核心请求逻辑改成了异步。这里有个细节:很多人写异步爬虫时喜欢用 asyncio.gather 一次性把所有任务丢进去,但如果目标网站有 1000 个 URL,直接丢会导致瞬间爆发 1000 个并发请求,大概率被封 IP。我当时的做法是分批处理,每批 200 个任务,批之间间隔 1 秒。
下面是当时优化后的核心代码(已经脱敏,去掉了代理和签名逻辑):
import asyncio
import aiohttp
import time
from fake_useragent import UserAgent
# 目标URL列表,实际项目中从Redis或数据库读取
TARGET_URLS = [f"https://api.example.com/sku/{i}" for i in range(1, 20001)]
ua = UserAgent()
async def fetch_sku(session, url, retry=3):
headers = {"User-Agent": ua.random}
for attempt in range(retry):
try:
# 超时设置很重要,aiohttp默认超时太长,容易卡任务
timeout = aiohttp.ClientTimeout(total=8)
async with session.get(url, headers=headers, timeout=timeout) as response:
if response.status == 200:
data = await response.json()
# 这里简单打印SKU和价格,实际会写数据库
print(f"SKU: {data.get('id')}, Price: {data.get('price')}")
return data
elif response.status == 429: # 被限流
wait_time = 2 ** attempt
print(f"限流,{wait_time}秒后重试 {url}")
await asyncio.sleep(wait_time)
else:
print(f"请求失败 {url}, 状态码: {response.status}")
return None
except Exception as e:
if attempt == retry - 1:
print(f"最终失败 {url}: {str(e)}")
return None
await asyncio.sleep(1)
async def batch_crawl(batch_urls):
# 这里用TCPConnector限制总连接数,避免本地端口耗尽
connector = aiohttp.TCPConnector(limit=200, force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [fetch_sku(session, url) for url in batch_urls]
return await asyncio.gather(*tasks, return_exceptions=True)
async def main():
start = time.time()
total = len(TARGET_URLS)
batch_size = 200 # 每批200个请求
for i in range(0, total, batch_size):
batch = TARGET_URLS[i:i+batch_size]
print(f"处理第 {i//batch_size + 1} 批,共 {len(batch)} 个URL")
await batch_crawl(batch)
# 批之间间隔1秒,降低被封风险
await asyncio.sleep(1)
print(f"总耗时: {time.time() - start:.2f}秒")
if __name__ == "__main__":
asyncio.run(main())
改完之后压测,同样的 20 万 SKU,QPS 直接干到了 2000 左右,总耗时从 1 小时多降到了 12 分钟。为什么提升这么大?同步请求时,每个请求要等服务器响应(平均 200ms)才能发下一个,CPU 大部分时间在等 I/O;异步的话,一个请求在等响应时,事件循环会立刻切换到其他请求,CPU 一直在干活。不过这里有个坑:我一开始没限制 TCPConnector 的 limit,导致本地端口被占满,出现了大量 OSError: [Errno 99] Cannot assign requested address 错误,后来把 limit 设为 200 才稳定。
还有个细节,aiohttp 的 ClientSession 最好别在每个请求里创建,不然会有额外的连接开销。我当时测试过,每个请求新建 session 比复用 session 慢 30% 左右。另外,异常处理一定要做,不然一个请求崩了可能把整个事件循环带挂。
5. 避坑指南:线上IP封禁与数据脏读的排查过程及容错机制设计
上个月的舆情分析项目,我们爬某新闻平台的热点评论,上线第一天就出了大问题。早上 10 点刚跑起来,到了 11 点半,运维突然说服务器出口 IP 被封了 3 个,数据库里还出现了几千条乱码数据。我当时正喝着咖啡,看到监控告警直接喷了出来 —— 这玩意儿要是影响客户那边的实时大屏,我得背锅。
先说 IP 封禁的排查。我第一反应是代理池出问题了?我们的代理池是用的第三方服务,本来设置了每 5 分钟切换一次代理,但实际看日志发现,有个爬虫进程因为异常退出重启后,代理 IP 没重新获取,一直用同一个 IP 发了 2000 多个请求。目标网站的反爬策略是「单个 IP 10 分钟内请求超 1500 次就封 24 小时」,这不撞枪口上了嘛。后来我检查代码,发现代理切换逻辑写在了主函数里,子任务异常时没触发重新初始化,导致代理「假切换」。
然后是数据脏读的问题。那些乱码数据,我拉了几条看,有的是 JSON 解析失败,有的是评论内容里混了 HTML 标签(比如 ),还有的是重复数据 —— 同一条评论被存了 5 次。查下来原因是:动态页面渲染时,网站有时候会返回 loading 状态的临时数据,我们的解析逻辑没判断 data.status 字段,直接就存了;重复数据是因为没做唯一 ID 校验,Scrapy 2.11.0 自带的去重中间件只对请求 URL 去重,不管内容。
针对这两个问题,我做了套容错机制。首先是代理池的动态调度,不能再固定时间切换了,得根据请求成功率自动调整。我写了个代理管理器,每次请求失败后,把当前代理的「失败次数」加 1,失败超 3 次就直接剔除,换下一个代理。代码大概是这样的:
import random
from collections import defaultdict
class ProxyManager:
def __init__(self, proxy_list):
self.proxy_list = proxy_list # 代理列表,格式 "ip:port"
self.proxy_stats = defaultdict(lambda: {"success": 0, "fail": 0})
self.current_proxy = None
def get_proxy(self):
# 优先选成功率高的代理
valid_proxies = []
for proxy in self.proxy_list:
stats = self.proxy_stats[proxy]
total = stats["success"] + stats["fail"]
if total == 0:
valid_proxies.append((proxy, 1.0)) # 新代理默认成功率1.0
else:
success_rate = stats["success"] / total
if success_rate > 0.7 and stats["fail"] < 3: # 成功率70%以上且失败少于3次
valid_proxies.append((proxy, success_rate))
if not valid_proxies:
# 所有代理都不行,重置统计并随机选一个
self.proxy_stats.clear()
return random.choice(self.proxy_list)
# 按成功率加权随机选
proxies, weights = zip(*valid_proxies)
return random.choices(proxies, weights=weights, k=1)[0]
def report_result(self, proxy, success):
if success:
self.proxy_stats[proxy]["success"] += 1
else:
self.proxy_stats[proxy]["fail"] += 1
# 失败超3次,暂时标记不可用(实际会从列表移除)
if self.proxy_stats[proxy]["fail"] >= 3:
print(f"代理 {proxy} 失败次数过多,暂时剔除")
# 使用示例
proxies = ["proxy1:8080", "proxy2:8080", "proxy3:8080"]
pm = ProxyManager(proxies)
current_proxy = pm.get_proxy()
print(f"使用代理: {current_proxy}")
# 请求后报告结果
pm.report_result(current_proxy, success=True)
然后是数据脏读的防护。我加了三层校验:第一层是请求层,判断响应状态码和内容长度,小于 100 字节的直接丢弃;第二层是解析层,用 try-except 包裹 JSON 解析,解析失败就重试,同时用正则去掉 HTML 标签(re.sub(r'<[^>]+>', '', text));第三层是存储层,用评论 ID 做唯一键,存之前先查数据库有没有,或者用 Redis 的 SETNX 做去重。比如我们用的 MySQL,就加了 INSERT IGNORE INTO comments (id, content) VALUES (%s, %s) 这样的语句,ID 重复直接忽略。
后来再跑这个项目,连续 3 天没出现 IP 封禁,数据重复率从 12% 降到了 0.3%,脏数据基本没了。不这么做的话,客户那边的大屏会显示一堆乱码,我们的代理池也会被封得没几个能用,到时候换代理的成本更高。
6. 未来演进:LLM赋能爬虫自动生成规则与合规性边界探讨
最近我们团队在试 LLM 结合爬虫的落地,说实话,这玩意儿确实能省不少事,但也带来了不少之前没遇到过的问题。上个月有个需求,要爬 10 个不同地区的房产平台,每个平台的页面结构都不一样 —— 有的用表格,有的用 div 嵌套,有的数据在 JS 变量里。以前这种需求,我得让两个实习生每人写 5 个爬虫脚本,至少花 3 天。现在用 LLM,我直接把每个平台的页面 HTML 片段丢给大模型,让它生成 XPath 或 CSS 选择器,再稍微改改就能用,2 天就搞完了。
我试过用 GPT-4 生成爬虫规则,提示词大概是「这是某房产平台的房源列表页 HTML 片段,请生成提取房源标题、价格、面积的 XPath 表达式,要求忽略广告位内容」。生成的结果准确率大概 85% 左右,剩下 15% 需要调整,比如有的 XPath 没考虑动态加载的 class 名(比如 class="house-item-123",数字会变),这时候就得手动改成 contains(@class, 'house-item') 这种模糊匹配。不过就算是调整,也比自己从头写快多了 —— 以前写一个平台的解析规则平均要 2 小时,现在 20 分钟就能搞定。
但这里有个问题:LLM 生成的规则有时候会「过度提取」。比如有个平台的新闻列表,LLM 生成的 XPath 把推荐广告的标题也抓下来了,因为它的提示词里没说「排除带 ad 类名的元素」。后来我优化提示词,加上了「排除所有包含 ad、sponsor、recommend 类名的元素」,准确率才提到 95%。这让我觉得,未来 LLM 赋能爬虫,可能不是直接生成完整代码,而是生成「规则草稿」,再由工程师做少量调整,这样既提高效率,又避免错误。
再说合规性,这是最近开发者社区吵得比较多的话题。我们之前爬数据,顶多看看 robots.txt,但现在全球数据法规越来越严,比如欧盟的 GDPR、国内的《个人信息保护法》,抓用户评论、手机号这些信息很容易踩线。上个月有个同行公司,因为爬了某社交平台的用户昵称和头像,被用户投诉侵犯隐私,赔了 20 多万。从那以后,我们爬数据前都会先做两步:一是检查目标网站的 robots.txt,比如 Disallow: /user/ 开头的路径就不爬;二是对抓下来的数据做脱敏,比如手机号中间四位用 * 代替(re.sub(r'(\d{3})\d{4}(\d{4})', r'\1****\2', phone)),用户昵称只保留姓氏加 先生/女士。
还有个趋势是「边缘爬取」,我最近看了些资料,说结合边缘计算节点(比如 CDN 边缘服务器)跑爬虫,能减少带宽占用,还能降低被封风险 —— 因为边缘节点的 IP 分布广,不像我们自己的服务器 IP 那么集中。不过这个还在试验阶段,我们还没实际落地,主要是边缘节点的计算资源有限,跑复杂的解密脚本(比如 JS 逆向)可能会卡。
反爬对抗也在升级,以前用随机 UA、代理池就能应付大部分网站,现在 Cloudflare 这些新一代反爬系统会用「动态指纹」—— 不仅看 IP 和 UA,还会检测浏览器的 canvas 指纹、WebGL 指纹,甚至鼠标移动轨迹。无头浏览器(比如 Playwright)虽然能模拟这些,但性能比纯 HTTP 请求差很多。我试过用 Playwright 爬一个用 Cloudflare 保护的网站,单进程 QPS 只有 30 左右,而用 aiohttp 能到 2000。所以未来可能需要「自适应反爬」:先试纯 HTTP 请求,如果被拦截就自动切换到无头浏览器,再不行就加行为模拟(比如随机鼠标移动、页面停留时间)。
最后说下我对爬虫合规的看法:别觉得「技术无罪」,真出了事,技术负责人跑不掉。我们现在的做法是,所有爬虫项目上线前,都要经过法务同事的审核,确认爬取的数据范围、使用场景符合法规。比如客户要舆情分析,我们只提供「公开可见的评论内容」,不碰用户的私信、好友列表这些非公开数据。毕竟爬虫这东西,技术只是工具,用不好反而会给自己惹麻烦。
站长实战手记
一个让我失眠三天的电商价格监控项目
去年我接了个私活,帮一家小型电商公司做竞品价格监控。他们想实时跟踪 20 多家主流平台上的 5000 个 SKU 价格变动,要求 15 分钟内更新一次数据。
起初我图省事,直接用 Requests + 定时任务 的架构。结果上线第一天就被打脸。对方网站用了动态加载,而且请求频率稍微高点就触发验证码。我硬着头皮加了代理池和 User-Agent 轮换,但维护成本巨高,数据还经常缺胳膊少腿。
后来我痛定思痛,决定换成 Scrapy + Playwright。Scrapy 负责调度和管道,Playwright 专门处理那些需要渲染的页面。这个组合确实稳,但新问题来了:Scrapy 本身是同步阻塞的,配上 Playwright 后,CPU 占用率飙到 90%,服务器卡得像幻灯片。
我排查了整整两天,最后发现是 Playwright 的浏览器实例开太多了。我重构了代码,把浏览器实例做成单例复用,并且把解析逻辑丢到线程池里,这才把资源压下来。最后的效果是,5000 个 SKU 的抓取时间从 40 分钟缩短到了 8 分钟,准确率也回到了 99% 以上。
关于技术选型的真心话
* 别迷信异步:如果你的目标网站只有几百个页面,或者反爬很弱,Requests 足够了。为了异步而异步,代码复杂度会翻倍,得不偿失。
* Scrapy 不是万能药:它适合大规模、结构化的抓取。如果你只是偶尔跑个脚本抓点数据,用 Scrapy 反而显得笨重。
* Playwright 是双刃剑:它确实能解决 90% 的 JS 混淆和指纹检测,但资源消耗极大。除非必要,别轻易用它去怼那种纯静态页面。
给正在学习的你
爬虫这东西,学会写代码只是第一步,真正的功夫都在代码之外。不要一上来就追求高并发和花里胡哨的架构。先试着把一个小网站的数据完整、干净地抓下来,处理好异常,这比什么都强。技术是为了解决问题而存在的,别为了炫技而写代码。