
站点A的列表页接口返回的是明文JSON只是每个请求都必须带一个sign参数而sign是把请求参数、时间戳、固定密钥拼接后做MD5再取8位。验签逻辑不复杂但密钥藏在JS里每次加载页面时会动态生成。我的做法是用Playwright加载一次页面从localStorage里把密钥捞出来然后后面的数据请求直接走requests带着正确的sign去调接口。实测下来这种方式比用Playwright逐个翻页快太多了——页面渲染的耗时完全省掉了。import hashlib, time, requests def build_sign(params: dict): raw .join(f{k}{params[k]} for k in sorted(params)) SECRET_KEY return hashlib.md5(raw.encode()).hexdigest()[:8] def fetch_list(page): params {page: page, size: 50, ts: int(time.time())} params[sign] build_sign(params) resp requests.get(API_LIST_URL, paramsparams, headersBASE_HEADERS) return resp.json()SECRET_KEY是运行程序时先启动一个Playwright实例去目标页里取的存到内存里供后续使用。这个过程看起来笨但每次会话只需要做一次开销完全可以接受。注意别把密钥写死在代码里因为你一旦重新部署、站点更新了JS密钥可能就失效了到时候排查起来极其痛苦。3.5 站点C反爬最凶的评论区JS渲染站点C是三个站里反爬最凶的评论区是典型的重度JS渲染。直接requests请求页面HTML里连评论数据的容器都是空的数据全靠页面加载后的一段又一段异步请求拼出来。而且这个站的请求频率限制非常敏感时间间隔小于3秒就会在响应里塞一个脚本删除页面内容直接让你拿到一张空壳页面。我用的方案是Playwright 滚动加载。评论区采用的是滚动到底部才加载下一页的交互模式所以脚本里必须模拟真实的滚动行为。这里有个关键点不能直接page.evaluate(window.scrollTo(0, document.body.scrollHeight))一下完事那样加载太快网站很容易判定异常。要用page.mouse.wheel()配合小步滚动每次滚动一点停一下再滚模拟真人阅读。async def crawl_comments_url(page, url): await page.goto(url, wait_untilnetworkidle) seen set() for _ in range(60): await page.mouse.wheel(0, 1200) await asyncio.sleep(1.5) # 获取当前已渲染出的评论节点 items await page.query_selector_all(.comment-item) for item in items: cid await item.get_attribute(data-id) if cid not in seen: text await item.inner_text(p.content) # 解析、存储 seen.add(cid) if no_new_items: break滚动步长和间隔这两个参数是这套方案的核心我调了好几轮。步长太大、间隔太短页面还没渲染完就滚过去了会漏数据步长太小抓一整页的耗时翻倍。最终步长1200px、间隔1.5秒是稳定性和速度的平衡点。实际测试中Playwright自带的auto_wait确实能等元素出现但对滚动加载型页面几乎不起作用因为元素始终在DOM里只是内容在变。3.6 数据清洗与落库三站数据最终要归一化存储。因为三个站的数据结构差异很大文章、评价、评论我没有强求统一的数据库表结构而是加了一层source字段区分来源保留每站原始字段的同时抽了公共字段标题、作者/用户、时间、内容、链接做索引。存储用的是MongoDB原因是字段结构灵活爬虫数据经常加字段如果强制上MySQL每次需求变动都要改表维护成本太高。入库前必须做一件事字段类型统一。站点A的时间是时间戳、站点B是ISO字符串、站点C是昨天这种相对时间统统转成datetime对象再存。一开始我以为无所谓后来做按时间筛选的报表时才发现坑当时只能写脚本重建白白浪费两天时间。3.7 多站并发调度的实际效果三个站的爬虫写完后调度部分我是用Python的concurrent.futures.ThreadPoolExecutor实现的max_workers3每站一个worker。主线程负责往一个queue.Queue里按优先级塞任务三个worker各自消费互不干扰。因为三个站的频率限制不一样所以每个worker内部有独立的速率控制器不会出现一个站点太慢拖累其它站点的情况。实际跑了一周每天采集量稳定在几万条级别全程只被打过两次验证码都是站点C在凌晨三点左右触发的。解决方案是错峰调度把站点C的任务集中放在凌晨2点到6点之外。三站并发真的不是三个while True叠一起这么简单任务队列、独立限速、错峰、异常重试每一层都要单独设计任何一个环节偷懒最后都会变成半夜爬起来清封禁IP的代价。4. 常见问题排查与避坑实录做了这么多次采集我把自己最常遇到的几个问题和排查思路直接整理成一张速查表方便你对号入座。症状可能原因排查方法处理方案请求返回200但页面内容是验证码单IP请求频次超标检查当前IP的请求频率、统计固定时间窗口内请求数换IP、休息30-60分钟把频次降到阈值以下一个批次里有大量请求超时目标站已经对该IP做了连接层限制抓包看SYN包是否正常走完更换出网IP缩小采集并发代码逻辑没变但突然所有请求都被重定向目标站更新了请求头校验规则对比新版请求和旧版请求的完整Headers差异重新从浏览器复制一套完整Headers并替换页面能正常访问但关键数据字段全为空目标站把重要字段挪到了接口里页面数据被字符替换打开DevTools的Network面板看请求瀑布找真实数据来源直接采集数据接口丢弃静态HTML解析偶尔成功偶尔失败规律不定单个IP在临界频次徘徊时好时坏记录每个IP的成功率和失败码降低总体频率把代理池的IP轮换逻辑从每次请求改成每N次请求4.1 永远先确认是内容问题还是反爬问题排查顺序很重要。很多新手一遇到采集结果不对第一反应就是我被反爬了其实大部分时候只是页面结构变了。我的排查顺序固定如下打开无头浏览器手动访问目标URL肉眼确认内容真实存在。查看返回的HTML里数据节点是否存在、Class名字是否变化。如果HTML里数据在检查解析逻辑如果数据不在才进入反爬排查流程。反爬排查再依次看Headers、频率、IP三个维度。这一步能帮你省掉大量无谓的代理池切换操作。我见过太多团队每次页面改版都急着换IP结果只是CSS类名从article改成了post纯属浪费。4.2 封禁后的恢复策略一旦确定被封正确的处理顺序是停掉采集任务先让这个IP安静至少30分钟再通过低频率试探性请求确认解封再逐步恢复采集。千万别一停就立刻换IP接着冲那只是换个方式继续撞墙。我的经验是封禁恢复后采集频率先降到正常值的50%跑半小时确认没有再次被封再慢慢调回去。4.3 反爬对抗的最终心得写了这么多年代码我最大的体会是反爬对抗的本质不是破解而是别让对方感到异常。你不需要把每一个反爬策略都硬碰硬地解掉你需要的是把自己的流量伪装成最普通的那一类用户。保持正常的访问节奏、用真实浏览器的Header组合、避免千篇一律的并发特征——做到这几点绝大多数反爬对你来说都不是问题。我之前踩过最大的坑就是把反爬当成了纯粹的加密题一天到晚研究加密算法结果真正决定成败的反而是一开始没在意的Headers完整性和请求频率。**项目上线前先花一天时间观察真实用户的流量特征比花一周研究绕过方案有用得多。**这一点在爬虫领域永远不会过时。最后再分享一个实用小技巧所有采集任务务必保留一份原始响应内容存档只做解析不做二次加工。因为解析逻辑可以重写但原始数据一旦没留页面改版后你想复盘连历史数据都找不回来。这个习惯在后续调试和维护时能帮你省出太多时间。