ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

多源采集与反爬实战:三站并行爬虫的架构设计与工程优化

2026/10/3 3:51:48 拓冰建站 浏览量
多源采集与反爬实战:三站并行爬虫的架构设计与工程优化 说实话我第一次看到“为了提高反爬虫能力同时从三个网站爬取信息”这个标题时第一个反应是这哥们是不是被某个网站的反爬策略折磨疯了第二个反应才是正经的——这句话其实藏着两层意思一是要对抗反爬虫二是要做多源采集。这恰好是爬虫工程里两个最核心的命题请求能不能发出去数据能不能拿回来且拿得全。我见过太多人手里攥着一个requests就能跑遍全网结果遇到第一个反爬就蒙了。还有人能写单站爬虫但一涉及“同时爬三个站”就不知道怎么组织代码线程不敢开队列不会用数据撞车了也不知道怎么处理。这篇就按我自己的实战习惯来把多源采集和反爬对抗这两件事掰开揉碎讲清楚不搞花架子全部是能用得上的方案。1. 多源采集与反爬能力先搞清楚你到底在对抗什么1.1 单一数据源到底有多脆弱先问你一个问题你手头有一个数据需求譬如说某个商品的行情、某类资讯的聚合、某个社区的热帖如果只有一个数据来源会发生什么我用一个特别惨痛的经历来说吧。早年间我接过一个数据服务的小项目需求是采集某个垂直领域的内容当时图省事就只盯了一个信息源。刚开始一切美好requests一开数据哗哗地来。结果某天下午对方服务端加了道风控我的请求指纹直接被识别IP被封了一片。当时我的采集任务全线飘红数据库里好几天没进过新数据。甲方催老板骂我还不知道问题出在哪——因为单源采集的容错率就是零源挂了一切归零。多源采集解决的不只是“数据量”的问题它解决的是“数据连续性”的问题。A站今天封你B站没封C站还能跑你的采集管道不会断。而且很多场景下单一站点根本不可能给你完整数据A站有甲类字段B站有乙类字段C站有丙类字段彼此还是互补关系。这就引出“同时抓三个网站”的真实价值——它不是炫技是为了韧性也是为了数据完整性。1.2 反爬虫能力到底是什么能力很多人一说“提高反爬虫能力”第一反应就是换IP、伪装UA、上代理。其实这是误解。单个技巧只能解决单点问题真正的反爬对抗能力是一个组合能力我把它拆成四层请求层怎么让你的请求看起来像真人包括UA、请求头、Cookie、请求节奏。调度层怎么在并发、频率、时间分布上规避风控别在短时间内造成明显的机器行为特征。解析层怎么应对各种反解析手段包括JS渲染、动态Token、字体反爬、数据加密。补偿层被封了怎么办验证码怎么处理失败任务怎么自动恢复。我见过太多人卡在第二层和第三层之间。requests写得很溜正则用得飞起但并发一开就被封为什么因为没做调度层的控制。也有的人并发控制做得挺好结果对面网站上了JS渲染requests拿到的HTML里根本没有数据又卡壳了。所以这篇博文的思路也很简单我们假设有三个风格完全不同的“榜样网站”——哪怕你实际爬的站点跟它们不完全一样方法论是可以平移的。1.3 三个网站三种挑战一套框架我给自己设计了一个训练场景同时从三个网站采集同类信息这三个网站分别代表爬虫最常见的三类对手站点A标准JSON API型。数据和接口结构清晰但对请求头校验严格容易识别异常频率。站点B登录态校验型。部分数据需要携带Cookie才能访问且Cookie有有效期。站点C纯前端渲染型。HTML里没有数据数据全部靠JS异步加载渲染。这三个站点的反爬策略完全不同把这三个搞定市面上七成以上的常规爬虫场景你都能应付。更关键的是我还要把它们放进同一个调度框架里做到“一个入口触发、三个源并行采集、数据归并去重入库”。这就是爬虫工程化的雏形了。2. 三个真实的爬取对象与各自的应对策略2.1 站点AJSON API接口类的“请求头校验型”站点先说说第一个站点。这类站点的典型特征就是接口友好结构清晰你甚至能通过浏览器的开发者工具直接找到返回JSON的API地址。难点在于接口越友好风控往往藏在看不见的地方。以我实际经验来看这类站点最爱校验三样东西User-Agent、Referer、以及请求频率。UA不对直接拒绝Referer不对当作非法请求频率太快触发风控返回假数据。我的应对策略分三步走第一步真实UA池。不要用requests默认的UA那玩意儿一秒钟暴露机器身份。提前准备一个UA池每次请求随机切换池子越大越好至少几十个主流浏览器的UA打底。import random UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64; rv:118.0) Gecko/20100101 Firefox/118.0, # 更多UA... ] def random_ua(): return random.choice(UA_POOL)第二步Referer伪装。从哪儿来就要伪装得像从哪儿来。如果API对应的页面是商品详情页Referer就应该是那个详情页的URL而不是直接裸调API。很多站点会校验这个字段。第三步频率控制。这里有一个特别实用的经验对所有API接口请求随机延时加一个抖动区间。我常用的方案是“2到4秒随机延时”在某些比较敏感的站点上会放宽到“5到8秒”。不要每秒都发请求——你一个人正常浏览网页时也不可能做到每秒点一次。import time import random def friendly_delay(lo2.0, hi4.0): time.sleep(random.uniform(lo, hi)) friendly_delay()2.2 站点B登录态与Cookie校验型站点第二个站点需要点技术支持。它的反爬逻辑是数据接口本身不拒绝匿名请求但返回的数据是残缺的完整的核心字段必须登录后才能拿到。这类站点最常见的坑是Cookie过期。很多人手动登录后把Cookie一拷写死在代码里跑一天发现Cookie失效了整个爬虫就废了。我的做法是把Cookie管理纳入采集框架思路是这样人工登录一次用浏览器开发者工具拿到登录后的Cookie把它作为“初始凭证”。将Cookie存入配置文件或Redis设置过期预警比如每2小时检查一次。如果发现Cookie失效比如接口返回登录跳转特征立即触发一次半自动登录流程或告警而不是让整个任务静默失败。在代码层面我习惯写一个带Cookie管理能力的会话类每次请求前动态从存储里读取Cookieimport requests import pickle import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) def get_cookie_from_storage(): cookie_data r.get(site_b_cookie) if cookie_data: return requests.utils.cookiejar_from_dict(eval(cookie_data)) return None def fetch_site_b(url, headers): session requests.Session() session.cookies get_cookie_from_storage() resp session.get(url, headersheaders, timeout10) if login in resp.url or resp.status_code 302: raise Exception(Cookie过期需要重新登录) return resp注意一个小细节登录后的Cookie整个生命周期里千万不要做太多跨账号操作。同一个Cookie高频访问你的目标页面没问题但如果你同时用这个Cookie去访问不该访问的接口比如用户列表、订单接口风控很容易盯上你。2.3 站点C纯JS渲染型站点第三个站点是最能劝退新手的类型。requests拿到的HTML源码里你会发现数据区域空空如也只有一堆JS脚本。数据是浏览器执行完JS之后动态填充上去的。这种站点的正确思路有两类低配方案是分析前端JS直接找到它背后的数据接口用requests模拟调用高配方案是直接上无头浏览器让浏览器替你把JS跑完再提取渲染后的内容。低配方案省资源但分析成本高而且JS一旦混淆或加密分析起来能让人怀疑人生。高配方案稳定且通杀代价是开销大、速度慢。我建议的方案是优先尝试低配接口分析不出来就切高配。用Playwright写一个最基础的渲染抓取是很快的from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://site-c.example.com/data, timeout30000) page.wait_for_selector(.data-list, timeout10000) html page.content() browser.close()这里面的关键是wait_for_selector它是在等JS渲染完成之后再取HTML而不是页面加载完就立刻抓很多新手栽在这一步——页面还没渲染完代码已经执行完了抓到的还是空壳。3. 反爬对抗的核心细节请求头、代理池与请求节奏3.1 从请求头到浏览器指纹的全链路伪装三言两语说不完请求头里的门道挑几个最容易出问题的讲。第一个是Accept-Language。默认的requests请求头里经常缺这个字段有经验的站点的风控系统可以从这里的缺失判断出你是程序。正确的做法是补上比如zh-CN,zh;q0.9,en;q0.8。第二个是Accept-Encoding。requests如果不设置服务器很可能会返回未压缩的内容这在流量里也能暴露出你是程序的特征。建议统一设为gzip, deflate, br。第三个是Upgrade-Insecure-Requests很多站点的HTTPS请求需要这个头为1缺失的话某些网关会直接拦截。更激进的站点还会做TLS指纹校验这种用requests就很难绕过了。办法是用curl_cffi这类能模拟真实TLS指纹的库把底层传输特征伪装成Chrome。from curl_cffi import requests as cffi_requests resp cffi_requests.get(https://site-a.example.com/api/data, impersonatechrome120)注意curl_cffi的impersonate参数是直接模拟浏览器TLS指纹的跟普通requests的效果天差地别。凡是遇到HTTP/2指纹校验的站点这一手基本都是必备。3.2 代理池要不要上怎么上代理池适合什么场景答案是高并发采集或者目标站对单个IP有严格频率限制。有人会觉得代理是万能的其实不是。我在实操中发现代理的可靠性决定了你采集任务的稳定性。免费的代理你看着能通发几个请求就断或者延迟爆炸你的任务会把大量时间浪费在重试上。自用代理池搭建注意事项尽量选稳定付费的住宅代理或长连接代理免费代理只适合测试。为代理池配置质量检测定期剔除失效和慢速代理。不要把代理当唯一解。对频率敏感的站点控制并发比乱换IP更有效。一个合格的带代理请求函数大概是这样的PROXIES [ http://user:passproxy1.example.com:8080, http://user:passproxy2.example.com:8080, ] def make_request(build_func, retries3): for attempt in range(retries): proxy random.choice(PROXIES) try: session requests.Session() session.proxies {http: proxy, https: proxy} resp session.get(build_func(), headersbuild_headers(), timeout15) if resp.status_code 200: return resp except requests.RequestException: continue return None注意里面有几个关键动作一是随机选代理二是设置超时三是失败重试。这三件事缺一不可尤其超时我见过太多因为没有设timeout导致线程卡死的案例。3.3 请求节奏让流量看起来像人请求节奏是我最想强调的一环。很多人写爬虫代码是对的逻辑是通的但一跑起来还是被秒封问题往往就出在节奏上。粗暴的“并发20、每秒20个请求”在一分钟内能拿很多数据但你的访问行为在服务器看来就是同步刷屏。真人用户不可能这样做。合理的节奏应该是锯齿状的请求间隔随机有小有大偶尔断一下偶尔连续两三下。规模化采集时我习惯用一个请求调度器来控制频率而不是在业务代码里到处塞sleepimport threading class RateLimiter: def __init__(self, min_interval1.0, max_interval3.0): self.min_interval min_interval self.max_interval max_interval self.last_request_time 0 self.lock threading.Lock() def wait(self): with self.lock: elapsed time.time() - self.last_request_time target_wait random.uniform(self.min_interval, self.max_interval) if elapsed target_wait: time.sleep(target_wait - elapsed) self.last_request_time time.time()说实话这一个类就够解决大多数“为什么被封”的问题了。把RateLimiter传给所有线程共享它内部用锁保证全局的请求时间间隔被控制住比每个线程自己sleep管用得多不会出现十个线程同时发请求的“齐射效应”。4. 并发架构三个站点怎么“同时”抓取4.1 为什么不用简单的死循环套三个站点有的同学会说“同时爬三个网站”那不就是写三个for循环吗顺序执行不就行了这里有个严重的误解。如果三个网站是顺序爬的A站爬到一半遇到反爬卡住B站C站全都干等着。这就是“单线程堵塞”你的总耗时取决于最慢的那个站点。真正的“同时”应该是并发的三个网站各自独立推进A站歇着不影响B站跑。所以需要的不是三个循环而是一个并发调度框架。最简单的方案是Python标准库自带的能力不需要引入复杂的框架一句if __name__ main就可以跑的方案是什么用concurrent.futures.ThreadPoolExecutor配合任务队列。4.2 统一任务模型把三个站点抽象成同一个东西在写并发之前先统一任务模型。不管哪个网站它们的采集流程本质上都是三步构造请求、解析数据、保存结果。差异只在于每一步的具体实现。所以我习惯先把三个采集器抽象成接口一致的实现class BaseSpider: site_name def fetch(self, task): raise NotImplementedError def parse(self, resp): raise NotImplementedError def save(self, items): raise NotImplementedError然后为每个站点写一个子类重写对应方法。这样就实现了“调度器只关心接口不关心站点细节”的设计。以后再加D站E站只需要再写一个子类框架代码一行不用改。4.3 用线程池做并发调度最简方案核心调度代码可以写得非常短from concurrent.futures import ThreadPoolExecutor, as_completed def run_all_sites(tasks_by_site): with ThreadPoolExecutor(max_workers6) as executor: future_map {} for site_name, task_list in tasks_by_site.items(): spider get_spider(site_name) for task in task_list: future executor.submit(spider.run, task) future_map[future] site_name for future in as_completed(future_map): site_name future_map[future] try: result future.result() print(f[{site_name}] 完成: {result}) except Exception as exc: print(f[{site_name}] 失败: {exc})核心逻辑不难理解把三个站点的任务全部扔进同一个线程池谁的future先完成就先处理谁互不等待。max_workers可以根据机器的负载去调但我个人的经验是爬虫更适合IO密集型场景线程数可以开到CPU核数的4到8倍因为大部分时间都消耗在网络等待上。4.4 数据竞争问题三个源同时写同一个表怎么办多线程一开数据竞争的问题立刻浮出水面。三个站点同时解析同时往数据库里写如果不做任何同步可能出现数据错乱、重复入库、唯一键冲突。解决方案其实很标准用队列做缓冲。采集线程只负责把数据放进统一的队列单独起一个写入线程从队列里取数据批量入库。这样写库操作串行化了不会互相干扰而且入库效率更高。import queue import threading data_queue queue.Queue() def producer(spider): # 采集线程把解析后的数据放入队列 data_queue.put(spider.parse_result) def consumer(): # 消费线程批量写库 while True: item data_queue.get() if item is None: break save_to_db(item)这就是非常经典的生产者-消费者模式也是多源爬虫控制数据冲突的底层解法。老实说这套东西就是消息队列的迷你版够用到数据量上千万之前都不过时。5. 数据落库与去重合并三个源拿到的同一份数据怎么办5.1 统一数据Schema先定标准再写代码三个网站抓回来的是同一类信息但字段名、字段格式都不一样拿回来直接入库就是灾难。比如同一款商品的价格A站返回的是price: 199.9B站返回的是price: 199.9元C站干脆放在HTML的data属性里。入库之前必须统一规格和类型。我常用方案是建一个统一的清洗管道ETL转换三步走字段名映射、格式标准化、异常值兜底。def normalize_item(raw, source): return { title: str(raw.get(title, )).strip(), price: parse_price(raw.get(price, 0)), source: source, raw_link: raw.get(url), fetched_at: time.time(), }别小看这块数据清洗的工作量往往比写爬虫还大。我在实际项目里清洗代码的量通常是爬虫代码的两倍以上。5.2 去重策略同一条数据两个站都有留哪份三站采集最经典的场景是同一个目标比如同一件商品、同一篇文章、同一个人物在A、B、C三个站同时存在你会拿到三条内容相似但来源不同的记录。去重不能只看标题标题有细微差异就可能漏掉。我的做法是用“业务主键”比如商品的平台ID、文章的标准URL、内容的哈希指纹来作为唯一标识。存库时建立唯一索引第二次插入直接忽略或更新。CREATE TABLE unified_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, unique_key TEXT UNIQUE NOT NULL, title TEXT, content TEXT, source TEXT, fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );配合这个唯一索引入库时用INSERT OR IGNORESQLite或者ON CONFLICT DO NOTHINGPostgreSQL天然就把重复的数据挡在门外。这是最简单也最可靠的做法。5.3 增量更新每次全量重爬是浪费生命第一次全量采集完之后每天怎么更新如果你还在每天把三站全量重爬一遍风控风险和时间成本都会成倍增加。改增量采集是必然。增量采集的核心是时间戳判断只在当前任务里拉取最近更新的数据。具体到代码层面就是给每个数据源记录一个last_sync_time请求时只取这个时间之后的数据。A站有更新时间字段就用它的没有的话就把当前时间往前推一小时作为粗略起点。6. 上线之后一定会踩的坑以及排查思路6.1 IP被封的第一反应千万别是换代理很多新人一看到“IP被封”或者“403 Forbidden”第一反应就是赶紧换代理。实际上更重要的第一步是判断封禁范围你是被封IP还是被封UA还是因为请求指纹被识别这三个的处理方向完全不同。我的排查习惯是逐层做实验用同一个代理访问目标站点换不同UA、不同Headers看哪个组合能从403变成200。多数情况下你会发现根本不是IP的问题是某个请求头写得太“机器人”了。有个很典型的坑是Connection请求头默认requests会带上keep-alive某些站点会专门针对这个做校验。6.2 验证码突然出现先别急着接入打码平台遇到验证码就慌了上来就接一堆打码平台的API这其实是走弯路。90%的情况你的验证码问题都出在别的环节请求频率太快、Cookie异常、操作轨迹异常。正确的做法是先把频率降到普通用户水平把UA和请求头补齐把访问路径调整成“打开首页-停留-点击目标页-停留-跳转API”这种模拟轨迹。很多时候把节奏放慢验证码自然就不出现了。我自己的原则是能靠控制频率解决的问题绝不花钱去接打码平台。6.3 数据忽然为空看看是不是反爬返回了假数据这是个特别容易掉进去的坑尤其对站点的API接口来说。有些站点不会直接拒绝你而是给你返回一堆看起来正常但实际是空壳或假数据的JSON。字段有值却全是空字符串或默认值。所以我在采集管道里加了一层“数据合理性校验”。比如统计字段的非空率检查关键数值是否在合理范围内。连续几条记录都是空值说明大概率被风控了立刻触发任务暂停和告警而不是继续空跑浪费资源和时间。def validate_items(items): non_empty_rate sum(1 for it in items if it.get(title)) / len(items) if non_empty_rate 0.7: raise RuntimeError(数据非空率低于70%疑似被反爬拦截)这个校验可能一分钟就写好但它救过我好几次让我在数据源出问题的时候能马上感知而不是等数据质量报表出来复盘。6.4 站点的反爬是动态变化的应急预案要在平时准备最后说一条经验心得反爬是你的对手它在持续进化。上周有效的方案这周可能就被识别了。所以你上线一个多源爬虫不能只盯着“爬”这一件事还要关注“监测”和“切换”。我的做法是给每个源录一个“健康状态”指标包括单次任务成功率、平均延迟、数据非空率、验证码出现频率。任何指标异常系统自动降级该源的任务量把压力转移到其他正常的源上。这就是多源架构最大的价值——它天然地给了你一条退路。7. 爬虫工程化的最后一公里合规与边界感写到这里技术层面的东西讲得差不多了。但有几句话我必须说在前面这是爬虫工程师的基本素养。第一尊重目标网站的robots协议与用户协议。robots.txt是技术层面的君子协定虽然不写进代码它拦不住你但遵守它是做这行的底线。你在采集前要先去看目标站的robots.txt页面里写了Allow/Disallow什么路径心里要有数。第二控制自用与商用的边界。自用学习、做技术验证没问题但涉及商业用途的数据采集一定要评估法律风险尤其是涉及个人信息、原创内容这类敏感数据。我自己的原则是个人公开信息可以有限度采集但涉及隐私、版权归属明确的创作内容谨慎再谨慎。第三采集频率要让对方站点无感。无感是什么意思就是不占用对方太多服务器资源不影响对方正常用户的访问体验。我自己定的规矩是并发不要顶满延迟不要归零所有的采集行为把自己假设成一个“比较勤快的真实用户”就好。最后说一点个人对“提高反爬虫能力”这个说法的理解。很多人觉得这是搞对抗、搞攻防但干了这么多年我的体会是真正的反爬能力从来不是“怎么绕过去”而是“怎么在对方的规则框架下用工程手段稳定地拿到你需要的数据”。你的对抗能力核心不在于破解了多少校验而在于你的采集管道能不能在风控变动时快速自愈你的架构能不能在单点被封时继续运行你的数据质量能不能在复杂环境里保持可靠。这三个网站的项目做完之后我最大的收获不是写了几千行代码而是建立了一套从请求调度到数据落库再到异常自愈的完整方法论。往小了说这是一次爬虫实战往大了说这是一次对“系统稳定性”和“工程容错”的实弹演练。这套东西放之四海而皆准哪怕你明天不爬这三个网站了换成任何数据采集场景骨架依然成立。