尧图精选

展会数据抓取实战:并发线程安全与电话验证全解析

🕒 发布时间:2026/10/2 15:29:47 📁 来源:尧图网络
前阵子接了个法国展会项目客户要把法国FIP展官网上的参展商数据全部整理下来包括展位号、企业简介、官网链接和联系方式。刚开始我觉得这不就是个爬虫嘛requests一拉正则一匹配就完事了真正上手才发现这站点把爬虫开发里常见的坑几乎全踩了一遍并发线程安全、国际电话验证、多页面深度爬取、二级页面解析随便拎出来一个都能让刚入行的朋友卡上大半天。这篇文章我把整个攻坚过程从头到尾写一遍代码思路、参数计算、踩坑记录都在里面算是给后面遇到同类展会爬虫的朋友一份可以直接照抄的作业。1. 项目背景与整体设计1.1 FIP展数据抓取的需求拆解先说清楚这个项目的实际需求。法国FIP展是一个有十几年历史的工业贸易类展会官网在展会开幕前会陆续放出参展商名单每家企业一个独立详情页包含公司名称、官网地址、展位号、简介、所在行业分类和联系电话。客户要的是这个数据来做行业线索分析数量级在三千多家参展商上下字段不算复杂但必须完整。我一开始以为这种展会网站结构很简单毕竟不是电商平台不会有太强的反爬。结果打开页面之后发现几个麻烦事列表页分页非常深翻到最后有几十页详情页内容不是全都直接渲染在首屏部分字段要通过一个“查看完整资料”的交互才能看到而这个交互会触发国际电话验证要求输入能接收验证码的手机号才能放行。这直接把项目的难度从一个入门级爬虫拉到了中高级实战水平。这个项目适合谁来参考呢如果你在写爬虫时遇到过并发请求数据错乱、网站需要验证才能看完整内容、要处理几十上百个分页并且还要层层进详情页解析那这篇文章的思路会很有帮助。即使你现在只写过单线程的简单爬虫后面关于线程池和任务队列的设计也可以直接作为进阶的起点。1.2 技术选型为什么是 requests 组合 ThreadPoolExecutor技术选型上我没有上 Scrapy也没用 Playwright。原因很简单这个站点是服务端渲染详情页的数据都在 HTML 源码里不需要跑浏览器脚本页面总量也就几千个请求用 Scrapy 有点杀鸡用牛刀反而要处理 twisted 的调试成本。requests 加 lxml 足够配合 Python 自带的 concurrent.futures 做并发成本最低、可控性最强。考虑到标题里明确提到的“并发线程安全”我需要特别说明一下 requests.Session 在多线程下的使用边界。requests.Session 本身不是线程安全的对象同一个 Session 被多个线程同时发请求可能遇到连接池串线、Cookie 状态错乱的问题。所以我的做法是每个线程创建自己的 Session而不是全局共享一个。这样虽然多花一点点资源但每个线程的 Cookie、连接池都是独立的完全没有竞争条件。这个决策在后面跑了几千个请求后证明是非常明智的。1.3 整体架构与数据流向项目整体分四层任务生成层、并发抓取层、内容解析层、数据存储层。任务生成层从展会首页拿到所有“行业分类”的入口链接再根据每个分类的分页数量生成完整的列表页 URL 列表。并发抓取层使用 ThreadPoolExecutor 管理一个固定大小的线程池每个线程负责从任务队列里取 URL、发请求、控制频率并把响应结果交给解析层。内容解析层用 lxml 解析列表页的详情页链接再抓取详情页并提取字段。数据存储层统一落到 SQLite带 upsert 逻辑方便断点续跑。这四层的设计里最核心的一个思路是任务与抓取解耦。也就是说线程不关心它抓的 URL 是列表页还是详情页只要任务里带上抓取后的回调方式即可。这样即使后面要扩展新的入口也只需要在任务生成层加规则抓取层完全不用动。2. 并发线程安全从单线程到线程池的改造之道2.1 单线程抓取到底有多慢动手写并发之前我先用单线程跑了一轮摸底。三千多家企业算上列表页和详情页总共大概四千个 URL。每次请求带完整的浏览器头平均耗时 1.8 秒到 2.5 秒加上页面解析和去重逻辑串行跑一轮要 2 个小时以上。这个时间不是不能接受但项目是有交付期限的而且一旦中间出现超时重试时间还要再翻倍。最关键的是这个站点有电话验证的门槛验证是带状态的操作单线程处理起来非常别扭一旦会话状态乱了就要重新验证。这时并发的价值就很明显了。把线程数提高到 8理论耗时可以压缩到四分之一甚至更短。但我得先说清楚线程数并不是越大越好。线程数从 8 加到 16抓取速度确实还能提但到了 20 以上站点开始频繁返回 429 状态码服务器触发了限流策略。实际压测下来这个法国展务系统对单 IP 的建议请求速率大约是每秒 4 到 6 个请求折合成线程池配置就是 6 到 8 个线程每个线程请求完成后固定 sleep 0.3 秒。这样一个简单的速率控制就保证了既快又不触发反爬。2.2 线程池与共享状态的安全管理并发爬虫里最容易出问题的不是请求本身而是共享状态。我一开始犯过一个错误用一个全局列表来记录已访问的 URL每次去重之前先判断 in 列表抓到新链接后 append 进去。单线程下这个逻辑一点问题没有但多线程下 append 和 in 不是原子操作两个线程可能同时判断同一个 URL 为“未访问”然后就重复抓取浪费请求不说还会把详情页的同一份数据写进数据库两遍。正确的做法是给所有共享可变状态加锁或者干脆选择线程安全的容器。我这里的方案是用一个 threading.Lock 保护一个 set 集合。每次任务取出 URL 后先抢锁判断 URL 是否在集合里如果不在就加入集合再放行请求。抢锁的粒度很小只在判断和插入的那一瞬间并不会拖慢整体抓取速度。代码大致是这样import threading _seen_urls set() _seen_lock threading.Lock() def is_duplicate(url: str) - bool: with _seen_lock: if url in _seen_urls: return True _seen_urls.add(url) return False除了 URL 去重还有一个共享状态是统计计数器。我要实时打印“已抓取 / 总数”的进度如果用一个普通的整数变量多个线程同时累加会出现计数丢失。Python 的 GIL 能保证单条字节码的原子性但 操作不是单条字节码实际测试中确实出现过计数少于真实请求数的情况。解决方案也简单用一个锁保护的计数函数或者直接用 queue.Queue 来汇总进度消息让主线程统一打印。2.3 实际代码线程安全的抓取任务分发整个并发抓取的核心我用的是 concurrent.futures.ThreadPoolExecutor。它的用法很简单map 方法可以直接把任务列表分发到线程池里执行但 map 会一次性把所有 URL 都交给线程池不方便做去重所以我改成了 submit 加队列的组合方式。from concurrent.futures import ThreadPoolExecutor, as_completed import queue import threading import time import requests task_queue queue.Queue() result_list [] result_lock threading.Lock() def worker(session: requests.Session, task: dict): url task[url] if is_duplicate(url): return None for retry in range(3): try: resp session.get(url, timeout15) if resp.status_code 200: return parse_task(resp, task) if resp.status_code in (403, 429): time.sleep(2 * (retry 1)) continue except requests.RequestException: time.sleep(1 retry) return None with ThreadPoolExecutor(max_workers8) as executor: futures [] for task in generate_tasks(): futures.append(executor.submit(worker, create_session(), task)) for future in as_completed(futures): data future.result() if data: with result_lock: result_list.append(data)这段代码里的几个细节值得说清楚。worker 函数里每次请求都会走重试逻辑特别是 403 和 429 这两个状态码它们代表站点已经认出你是机器人或者流量超了此时不能立刻重试必须退避一段时间。我设置的退避策略是 2 秒乘重试次数重试三次后还不行就放弃这个 URL统一记到一个失败日志里后续单独补跑。create_session 函数里会设置统一的请求头包括 User-Agent、Accept、Accept-Language 和 Referer。这里的 Referer 不是随便填的如果你从列表页点进详情页浏览器会带上列表页的 URL 作为 Referer。我在爬虫里同样设置了这个逻辑让服务端的日志看起来像正常的站内浏览行为。这是请求头层面最基本但最有效的伪装手段。2.4 并发爬虫常见坑盘点并发这关过了之后还有几个隐蔽的坑。第一个是连接数限制。requests.Session 默认的连接池大小在 urllib3 里是 10并发 8 个线程时够用但如果把线程数调到 16 而连接池还是 10多余的请求会排队等待连接释放实际速度反而不升。可以用 HTTPAdapter 手动调大连接池session.mount(https://, requests.adapters.HTTPAdapter(pool_connections20, pool_maxsize20))第二个坑是线程里抛异常导致整个解析断掉。requests.get 在超时或连接重置时会抛异常如果你在 worker 函数里没有接住as_completed 拿 result 时会把异常重新抛到主线程程序直接崩掉。所以 worker 内部一定要 try except 全部接住宁可这个 URL 返回 None也不能让线程炸了。第三个坑是 DNS 解析的并发限制。某些云环境或者本地网络对同一个域名的并发 DNS 查询有数量限制线程多了会出现间歇性的 DNS 解析失败。当时我在本地跑没有这个现象部署到客户的服务器上之后日志里突然冒出大量 socket.gaierror排查了很久才定位到是并发 DNS 高导致的。解决办法是在 session 的 HTTPAdapter 里指定一个公共 DNS 服务或者加个自定义的 resolver也可以简单粗暴地降低并发线程数看目标服务器上稳定到几个合适。3. 国际电话验证注册、验证码与持续会话3.1 为什么展会网站也需要电话验证说实话我第一次遇到这个需求的时候也愣了一下。法国FIP展官网本身是一个公开信息平台按理说参展商名单是公开数据为什么查看完整资料还需要电话验证后来看了一下网站的注册服务条款发现这个验证的定位不是反爬虫而是欧洲这边数据保护法规要求下的用户识别机制用于记录谁在大量查看企业联系方式防止这些信息被批量抓去做营销骚扰。这个出发点我能理解但对我们这个项目来说它确实成了一个必须解决的技术关卡。这里的第一个原则我想放到最前面讲我们不做任何破解验证码或绕过验证的操作。整个电话验证流程我们是正常走完的只不过用程序把注册、收短信、填验证码这套人工重复劳动自动化了。这也是合法规避重复操作的工程方法跟那些恶意绕过完全不是一回事。如果你接到类似需求我强烈建议先判断一下目标数据是否有公开访问权限。反正我们只抓公开的参展商信息电话验证是平台用来记录访问者身份的主动流程走完验证之后拿到的是本身就应该公开的内容没有越界。3.2 国际号码格式与验证码接收电话验证的第一个坎是号码格式。平台注册页面要求填写 E.164 格式的国际号码也就是国家代码加不带前导零的本地号码。法国的国家代码是 33本地手机号通常以 06 或 07 开头转换后就是去掉前面的 0再加上 33。比如本地号码 06 12 34 56 78国际格式就是 33612345678。因为项目需要测试环境里的多个账号我准备了几张可以收国际短信的卡配合一个短信接收平台来自动化处理。验证码短信发到手机号后平台会把短信内容回调到我们的一个接口上。我在本地写了一个小脚本监听这个回调从短信文本里用正则解析出验证码然后立刻提交到网站的验证接口。import re import requests def extract_code(sms_text: str) - str: # 常见的验证码短信格式Votre code de validation est 482913. match re.search(r(?:code|Code)[^0-9]{0,20}([0-9]{6}), sms_text) return match.group(1) if match else def submit_verification(phone: str, sms_text: str): code extract_code(sms_text) return requests.post( https://www.fip-example.fr/api/verify, json{phone: phone, code: code}, headers{Authorization: fBearer {token}} )这里有个细节不同国家的短信到达时间不一样法国的国际短信通常会慢一点所以脚本里必须加超时等待逻辑短信回调超过 90 秒没有收到就重新发送。我最后用的是一个 3 分钟循环每 15 秒检查一次回调接口90 秒还没到就触发短信重发。重发的次数也不能太多同一个号码在 10 分钟内最多允许重发 3 次这是平台上写的限制触发多了号码可能会被临时冻结。3.3 验证完成后如何保持会话状态验证通过不等于万事大吉关键是后续的几百个详情页请求都必须带着验证通过的状态。这里涉及到 Token 和 Cookie 的持久化管理。验证接口返回一个 JWT Token存到 Session 的 Header 里即可。但 Token 是有过期时间的法国这个平台的 Token 有效期是 2 小时超过之后访问详情页会静默跳回验证引导页乍一看页面还是返回 200实际上内容已经错位了。为了解决这个问题我在每个请求的解析结果里做了内容校验。详情页如果包含“验证您的手机号”这类关键字就判定为会话过期立刻把这个 URL 放回重试队列同时触发一次重新验证。这样一个线程池里只要有一个线程发现会话问题其他线程都会在后续请求中读到同样的失败标记通过一个共享的会话状态变量让所有线程统一切换新的 Token。这里又回到前面讲的线程安全了会话状态本身也是共享可变状态必须用锁或者以不可变方式更新。3.4 验证流程自动化中的细节经验自动化电话验证还有几个实操细节值得记录。第一手机号验证完之后平台会把这个号和一个设备指纹绑定如果后续请求的 User-Agent 变了可能会触发二次验证。解决方案是验证阶段用哪个请求头组合验证完的 Cookie 就绑定哪个组合不要中途换 User-Agent。第二同一个手机号不要高频验证多个账号多个账号关联同一个手机号很容易触发账号风控。我当时准备了多个接收号码来分散。第三所有验证码相关的短信内容不要打印到日志里防止信息泄露我在生产环境跑的时候把短信内容都做了脱敏只保留验证码提取结果。提示电话验证这个流程本质上是在模拟真实用户的注册和验证行为。如果你要做自动化请务必确认自己有权使用目标平台的服务且仅用于合法合规的数据采集。批量注册、恶意验证、绕过限制这些行为从来不在讨论范围内。4. 多页面深度爬取从展会首页到参展商列表4.1 页面路径规划与抓取顺序在写任何爬取代码之前花半小时把站点路径画清楚是非常值得的。法国FIP展官网的结构是典型的三层树状首页根据行业领域划分出十几个大分类每个分类下面有一个多页的参展商列表列表项的标题链接指向企业详情页。这样一个三层结构抓取顺序天然就是先首页拿分类、再分类翻列表、最后列表进详情。这里有一个很多人会忽略的点抓取顺序和任务队列的优先级之间需要设计。你当然可以先把所有分类和所有分页全部算出来一次性丢几千个 URL 进任务队列然后让线程池自由调度。这种方式写起来最简单但它有个问题如果站点限流你很可能会先抓完 A 分类的全部列表页和详情页还没碰到 B 分类就被限流了。更平衡的做法是广度优先尽量均摊让任务生成层先按分类分组再轮转地把每个分类的列表页加入队列保证各分类的请求在时间上是交织的。def generate_tasks(): category_links fetch_category_links() for category_url in category_links: page_count get_page_count(category_url) for page in range(1, page_count 1): task_queue.put({ type: list, url: f{category_url}?page{page}, })每个列表页解析出的详情页 URL 并不直接加入队列而是先经过一轮去重再按列表页解析完成的顺序统一入队。这样做的原因是详情页的 URL 里可能含有重复参数比如有些链接带 utm_source 这种追踪参数实际指向的是同一个详情页URL 去重必须做规范化处理把追踪参数去掉之后再判断。4.2 分页处理与翻页边界条件FIP展列表页的分页方式是比较传统的 ?pageN 形式没有异步加载没有动态翻页。但分页有一个非常坑的地方分类的页数会随着展会开幕日期临近而增加昨天抓的时候这个分类可能只有 8 页今天再抓就成了 11 页。所以页数不能写死每个分类都要在抓第一页时动态解析出总页数并且做完一个分类后随时可能回源刷新一次页数。解析总页数的时候页面底部会显示“Page 1 of 12”或者“1 / 12”之类的文本。我优先用 XPath 定位分页导航的最后一个链接从它的 href 参数里解析出 page12 中的数字。如果最后一个链接不是数字而是“下一页”那就继续往前一个找。这种边界情况在真实站点里特别常见如果直接取最后一个链接的数字很容易拿到一个很乱的字符串。另外我在写分页循环的时候故意做了个保护同一个列表 URL 如果生成的分页任务超过 200 页就直接报错停止。因为一般展会列表不可能超过这个数量级出现 200 页以上基本是网站出问题了或者是解析 XPath 猜到了错误位置导致把整个页面的其他链接都当成下一页在遍历。加上这个保护能避免一个解析错误吃掉整个任务队列表。4.3 请求频率控制与反爬应对技巧多页面深度爬取的一个核心矛盾是速度与安全。展会列表页的请求频率其实不需要太高因为列表页本身只有几十个详情页才是大头。但详情页的请求频率如果和列表页一样就太保守了。我的做法是分类型设置频率列表页之间的间隔控制在 1.5 秒到 2 秒详情页之间的间隔控制在 0.3 秒到 0.5 秒同时对两类请求设置不同的线程池。list_executor ThreadPoolExecutor(max_workers3) detail_executor ThreadPoolExecutor(max_workers6)为什么要分开呢因为列表页如果在短时间内翻得太快特别容易被识别为脚本行为因为正常人类用户不可能隔一两秒就翻一页列表。详情页的间隔短一点反而不容易被识别因为用户可能同时打开多个企业详情页来看。这个设计是基于对站点用户行为模型的分析而不是拍脑袋定出来的。UA 轮换也值得说两句。我没有用网上那种几百个 UA 池乱轮换的方式因为轮换太频繁反而是反爬特征正常用户的 UA 是长期稳定的。我准备了三个 UA 头Firefox、Chrome、Safari 各一个每隔 200 个请求轮换一次模拟一个用户在不同设备上浏览的行为。实测下来比每请求换一次 UA 的稳定性要好很多。4.4 断点续爬与异常重试机制多页面深度爬取过程中网络异常是必然事件。我第一版爬虫跑了一半晚上服务器重启了一下进程没了已经抓过的几百个 URL 因为只存在内存里全部作废。这个教训把我逼着实现了完整的断点续爬机制。断点续爬的核心是持久化一套“已访问 URL”的集合。我直接用一个独立的 SQLite 表来维护表结构很简单url TEXT PRIMARY KEYstatus INTEGER0 表示待重试1 表示成功2 表示失败。每次线程从队列取 URL 时先查这个表不在表里或者 status 为 0 的才继续抓取。每次请求完成后实时更新状态。CREATE TABLE IF NOT EXISTS visited ( url TEXT PRIMARY KEY, status INTEGER DEFAULT 0, fetched_at TEXT );这样即使程序中途崩了重新启动后只需要扫描一遍 visited 表里 status2 的 URL把它们重新放回队列即可。这个设计在展会网站后期多次增量更新的场景里非常有用我可以只抓新增的分页和详情页完全不用重跑全站。5. 二级页面解析从列表页到详情页的数据提取5.1 列表页字段与详情页字段的映射关系二级页面解析是这个项目里最像“手工活”的部分。列表页里每个参展商卡片显示的字段有限通常只有企业名称、展位号和行业分类。客户真正需要的公司简介、官网地址、联系电话全都藏在详情页里。所以解析的逻辑分成两步先从列表页拿到详情页链接再请求详情页补齐剩余字段。我在这一步做了一个小表来管理字段状态。列表页能拿到的字段标记为基础字段详情页能拿到的标记为扩展字段两边解析完成后按企业名称加展位号作为唯一键做合并。为什么要用这个组合键而不是详情页 URL因为客户后面要跟自己的 CRM 数据对齐展位号是企业参展的最稳定标识比 URL 可靠得多。解析列表页我用的是 lxml 加 XPath。以列表卡片为例典型结构是一个 div 带着 class 名 exhibitor-card内部有 h3 标题、a 链接、span 标签里的展位号。对应的 XPath 表达式是card_nodes html.xpath(//div[contains(class, exhibitor-card)]) for card in card_nodes: name card.xpath(.//h3//text())[0].strip() booth card.xpath(.//span[contains(class, booth)]/text())[0].strip() detail_link card.xpath(.//a[contains(class, title-link)]/href)[0] detail_url urljoin(base_url, detail_link)这里有个小坑XPath 取到的文本默认是列表里散落的字符串如果一个元素内部有子标签直接 text() 会拿到多个字符串片段。所以我在几乎所有文本提取后都做了一次 join 和 strip比如名称字段实际提取时可能得到 [“公司名”, “ », ”] 这样的列表需要用 .join 拼起来再清洗。诚实地说在这个项目里我后来干脆用 CSS 选择器重写了一部分解析逻辑因为 lxml 的 HTML 解析器对个别不合法标签的处理导致匹配失败的情况时有发生而 CSS 选择器的容错性稍微好一点。5.2 详情页解析XPath 与 CSS 选择器的选择详情页的解析更像是一次性定制开发因为每个字段都有自己独有的页面位置。企业简介藏在一个带有 snippet 类的段落里官网地址在“Website”按钮的 href 属性里联系电话是渲染在 header 右侧的一串文本。整体来说 CSS 选择器更适合这种明确类名结构的页面。def parse_detail(html): tree lxml.html.fromstring(html) company tree.cssselect(h1.company-name)[0].text_content().strip() intro tree.cssselect(p.snippet[itempropdescription]) website tree.cssselect(a[class*website-btn]) phone_node tree.cssselect(div.phone-number) return { company: company, intro: intro[0].text_content().strip() if intro else , website: website[0].get(href) if website else , phone: clean_phone(phone_node[0].text_content()) if phone_node else , }选择 CSS 选择器而不是 XPath 还有一个原因是可读性。对于一个后期要维护的爬虫项目来说一段 CSS 选择器比一长串 XPath 更容易让后来者看明白这行代码到底在提取什么。当然如果你面对的页面结构很混乱XPath 的灵活性更高很多情况下两种选择器可以混着用。我不觉得必须二选一我的原则是按页面特点来哪个写着舒服就用哪个。5.3 法语特殊字符与编码处理页面是法语的这里有个很多中文开发者第一次接触会懵的编码问题。刚开始我把 HTML 直接用 requests 的 text 属性读取结果日志里全是乱码。原因是 responses 的编码判断在某些时候不靠谱站点没有声明 charset 或者声明的是 ISO-8859-1但实际内容用了 UTF-8。解决方式简单粗暴用 resp.content 拿到原始 bytes再用指定的编码去 decode。html resp.content.decode(utf-8, errorsreplace)编码问题解决之后第二个考验是法语字符的处理。法国企业名称里充满了 é、è、ê、à、ç 这样的带重音字母还有像“SARL”、“EURL”这种公司后缀。我的清洗函数里专门处理了两件事把弯引号与撇号替换为直引号把 NBSP 不换行空格替换为普通空格。不处理的话这些字符会在后续写入 CSV 和数据库时产生不可见的格式问题客户拿去对账的时候才发现字段里混进了特殊空白排查起来非常痛苦。def clean_french_text(text: str) - str: normalized (text.replace(\u00a0, ) .replace(\u2019, ) .replace(\u201c, ) .replace(\u201d, )) return .join(normalized.split())最后一步的 join 和 split 是一个小技巧它会把所有连续空白统一成一个空格同时去掉字符串首尾的空白。这段三行代码的清洗逻辑几乎被我应用在每一个字段上事实证明它解决了九成以上的脏数据问题。5.4 数据清洗与结构化存储数据解析完成只是完成了半个工作另一半是数据落到数据库里。我用了 SQLite因为本机操作无需额外服务客户交付时直接给一个 db 文件即可。表结构提前定义好所有字段都允许为空因为法国展会数据并不总是完整宁可存空字符串也不要因为缺少某个字段导致一条记录写不进去。存储层我强烈推荐用 INSERT OR REPLACE 而不是先 SELECT 再 INSERT。原因很简单先查再写不是一个原子操作多线程同时对同一个主键做检查时会出现写两次的竞争条件。INSERT OR REPLACE 在 SQLite 里是原子性的完全规避了这个问题。INSERT OR REPLACE INTO exhibitors ( id, company, booth, category, website, phone, intro, detail_url ) VALUES (?, ?, ?, ?, ?, ?, ?, ?);这里的 id 我用的是公司名加展位号的哈希值方便后续更新时定位同一条记录。整个跑完后我导出一份 CSV 和一份 Excel 给客户编码统一用 UTF-8 with BOM这样在 Windows 上用 Excel 打开不会乱码。这个看起来微不足道的细节其实是交付环节最容易翻车的地方。6. 常见问题排查与技术要点回顾6.1 高频异常与排查方案速查表整个项目跑下来我记录了一批高频异常在这里整理成一个速查表方便遇到同样问题的兄弟直接对照排查。异常现象可能原因排查与解决办法连续出现 403User-Agent 特征太明显或 IP 被周期标记换用完整浏览器请求头检查是否触发限流降低并发后观察429 频繁请求速率超过站点阈值调大请求间隔减少线程数或实现全局限速器超时但页面能访问单次请求连接时间过长设置 connect 和 read 超时分离重试时逐步加大 timeout解析结果错乱编码判断错误或解析器版本差异用 resp.content 显式 decode统一清理特殊字符电话验证反复弹出Token 过期或会话被风控检查请求头是否变化验证 Token 过期时间走重新验证流程重复数据写入去重集合未加锁或主键设置不当加锁或改用 SQLite INSERT OR REPLACE 原子写入这里最值得说的是 403 和 429 的区别。403 代表服务端已经决定不给你内容了通常跟 UA、Cookie、IP 信誉有关429 代表你请求太快触发了限流按规范应该读取响应头里的 Retry-After 字段等足时间再继续。我在代码里针对这两个状态码单独做了分支处理而不是统一走普通重试逻辑效果会好很多。6.2 性能调优的实测数据项目开始到结束我在不同并发参数下做了几轮对比测试。把测试结果原原本本放在这里你会发现所谓的性能调优其实就是找到目标站点能接受的边界。线程数请求间隔1000 个详情页耗时429 次数40.8s约 5 分钟080.3s约 1.5 分钟2160.2s约 1 分钟1480.5s约 2.2 分钟0最终我选择的是 8 线程加 0.4 秒间隔的组合耗时和稳定性都比较理想。这组数据也说明一个道理爬虫的速度瓶颈往往不是你本机的网络或 CPU而是目标站点对你的容忍度。与其追求极限速度不如找一个长期稳定不封号的平衡点。6.3 这个项目的后续扩展方向FIP展爬虫跑完第一轮之后客户又提出了几个自然会延伸到的需求。一个是每年展会更新时自动增量抓取这就需要我前面说的断点续爬机制配合定时任务。另一个是参展商数据落库后要做行业分布分析和规模统计这部分其实就是把 SQLite 数据导出后交给数据分析团队。技术上如果这个项目要做得更工业化我会考虑把任务队列换成真正的分布式队列比如用 Redis 的 list 结构来分发 URL这样抓取层的节点可以横向扩容。再进一步就是引入代理池但代理池用在这种低频小量级的展会上意义不大反而增加了请求的不可控性所以我一直没有在这个项目里上代理。说实话做爬虫最重要的是尺度感。知道目标数据是公开的知道抓取频率在对方承受范围内知道哪些验证流程要老老实实走完而不是绕过。这个法国FIP展项目能顺利交付靠的不是什么高级逆向技术而是把并发安全、会话处理、页面解析这些基础功做扎实了。整个过程踩过的坑我都在上面的章节里写出来了希望能帮你少走一些弯路。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →