前阵子接了个法国展会项目,客户要把法国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, timeout=15) 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_workers=8) 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_connections=20, pool_maxsize=20))第二个坑是线程里抛异常导致整个解析断掉。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": f"Bearer {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展列表页的分页方式是比较传统的 ?page=N 形式,没有异步加载,没有动态翻页。但分页有一个非常坑的地方:分类的页数会随着展会开幕日期临近而增加,昨天抓的时候这个分类可能只有 8 页,今天再抓就成了 11 页。所以页数不能写死,每个分类都要在抓第一页时动态解析出总页数,并且做完一个分类后随时可能回源刷新一次页数。
解析总页数的时候,页面底部会显示“Page 1 of 12”或者“1 / 12”之类的文本。我优先用 XPath 定位分页导航的最后一个链接,从它的 href 参数里解析出 page=12 中的数字。如果最后一个链接不是数字而是“下一页”,那就继续往前一个找。这种边界情况在真实站点里特别常见,如果直接取最后一个链接的数字,很容易拿到一个很乱的字符串。
另外,我在写分页循环的时候故意做了个保护:同一个列表 URL 如果生成的分页任务超过 200 页,就直接报错停止。因为一般展会列表不可能超过这个数量级,出现 200 页以上基本是网站出问题了,或者是解析 XPath 猜到了错误位置,导致把整个页面的其他链接都当成下一页在遍历。加上这个保护能避免一个解析错误吃掉整个任务队列表。
4.3 请求频率控制与反爬应对技巧
多页面深度爬取的一个核心矛盾是速度与安全。展会列表页的请求频率其实不需要太高,因为列表页本身只有几十个,详情页才是大头。但详情页的请求频率如果和列表页一样,就太保守了。我的做法是分类型设置频率:列表页之间的间隔控制在 1.5 秒到 2 秒,详情页之间的间隔控制在 0.3 秒到 0.5 秒,同时对两类请求设置不同的线程池。
list_executor = ThreadPoolExecutor(max_workers=3) detail_executor = ThreadPoolExecutor(max_workers=6)为什么要分开呢?因为列表页如果在短时间内翻得太快,特别容易被识别为脚本行为,因为正常人类用户不可能隔一两秒就翻一页列表。详情页的间隔短一点反而不容易被识别,因为用户可能同时打开多个企业详情页来看。这个设计是基于对站点用户行为模型的分析,而不是拍脑袋定出来的。
UA 轮换也值得说两句。我没有用网上那种几百个 UA 池乱轮换的方式,因为轮换太频繁反而是反爬特征,正常用户的 UA 是长期稳定的。我准备了三个 UA 头,Firefox、Chrome、Safari 各一个,每隔 200 个请求轮换一次,模拟一个用户在不同设备上浏览的行为。实测下来比每请求换一次 UA 的稳定性要好很多。
4.4 断点续爬与异常重试机制
多页面深度爬取过程中,网络异常是必然事件。我第一版爬虫跑了一半,晚上服务器重启了一下,进程没了,已经抓过的几百个 URL 因为只存在内存里,全部作废。这个教训把我逼着实现了完整的断点续爬机制。
断点续爬的核心是持久化一套“已访问 URL”的集合。我直接用一个独立的 SQLite 表来维护,表结构很简单:url TEXT PRIMARY KEY,status INTEGER(0 表示待重试,1 表示成功,2 表示失败)。每次线程从队列取 URL 时,先查这个表,不在表里或者 status 为 0 的才继续抓取。每次请求完成后,实时更新状态。
CREATE TABLE IF NOT EXISTS visited ( url TEXT PRIMARY KEY, status INTEGER DEFAULT 0, fetched_at TEXT );这样即使程序中途崩了,重新启动后只需要扫描一遍 visited 表里 status=2 的 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[itemprop='description']") 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", errors="replace")编码问题解决之后,第二个考验是法语字符的处理。法国企业名称里充满了 é、è、ê、à、ç 这样的带重音字母,还有像“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 高频异常与排查方案速查表
整个项目跑下来,我记录了一批高频异常,在这里整理成一个速查表,方便遇到同样问题的兄弟直接对照排查。
| 异常现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 连续出现 403 | User-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 次数 |
|---|---|---|---|
| 4 | 0.8s | 约 5 分钟 | 0 |
| 8 | 0.3s | 约 1.5 分钟 | 2 |
| 16 | 0.2s | 约 1 分钟 | 14 |
| 8 | 0.5s | 约 2.2 分钟 | 0 |
最终我选择的是 8 线程加 0.4 秒间隔的组合,耗时和稳定性都比较理想。这组数据也说明一个道理:爬虫的速度瓶颈往往不是你本机的网络或 CPU,而是目标站点对你的容忍度。与其追求极限速度,不如找一个长期稳定不封号的平衡点。
6.3 这个项目的后续扩展方向
FIP展爬虫跑完第一轮之后,客户又提出了几个自然会延伸到的需求。一个是每年展会更新时自动增量抓取,这就需要我前面说的断点续爬机制配合定时任务。另一个是参展商数据落库后要做行业分布分析和规模统计,这部分其实就是把 SQLite 数据导出后交给数据分析团队。
技术上如果这个项目要做得更工业化,我会考虑把任务队列换成真正的分布式队列,比如用 Redis 的 list 结构来分发 URL,这样抓取层的节点可以横向扩容。再进一步就是引入代理池,但代理池用在这种低频小量级的展会上意义不大,反而增加了请求的不可控性,所以我一直没有在这个项目里上代理。
说实话,做爬虫最重要的是尺度感。知道目标数据是公开的,知道抓取频率在对方承受范围内,知道哪些验证流程要老老实实走完而不是绕过。这个法国FIP展项目能顺利交付,靠的不是什么高级逆向技术,而是把并发安全、会话处理、页面解析这些基础功做扎实了。整个过程踩过的坑,我都在上面的章节里写出来了,希望能帮你少走一些弯路。