前阵子接了个展会数据采集的活儿,目标很明确:把泰国InterPlas展的全部展商名单拿下来。InterPlas是曼谷塑料橡胶行业的年度大展,参展商覆盖东南亚一带的上下游企业,甲方要的就是展商公司名、展位号、官网、国家、展品简介这五类字段,总量大概在四千多条。我一开始以为这就是个常规爬虫项目——列表页、详情页、翻页、解析、入库,半天收工。结果打开官网就傻眼了:展商页面是纯前端渲染的,列表数据根本不在HTML里,而是由一个叫Algolia的搜索服务异步拉回来的。这直接把我从“写爬虫”拽进了“逆向API”的深水区,连带暴露出多语言国家过滤、重复数据合并、批量插入回滚这几个平时不太注意的坑。把这些关卡挨个拆开讲清楚,也算给后来人留个参考。
1. 项目全貌与难点拆解
1.1 需求还原与初始情报
这个项目的原始需求非常简单,甲方原话是“帮我把泰国塑料展的参展企业名单整理成Excel,要带官网和国家”。展开之后,具体字段是:
表格里还需要展商简介和产品分类,但官网展商列表页最多只显示公司名和展位号,点进详情页才有官网、简介、国家这些字段。所以数据源天然分为列表页和详情页两层,靠展位号关联。
我最初的计划是requests + BeautifulSoup直接遍历所有列表页。结果打开浏览器一看,列表页的HTML里只有一个空壳容器,展商名的DOM节点在Network面板里才看得到——数据是页面加载后由JavaScript异步请求接口拿到的。顺着HAR里的请求扫了一遍,所有列表和详情的数据都指向了同一个服务商:Algolia。
如果没接触过Algolia,你可能会以为这是某种需要“逆向破解”的加密接口。实际上Algolia本质上是一个SaaS化的搜索后端,很多展会官网、招聘网站、SaaS落地页都用它来做前端搜索和数据渲染。前端通过它提供的instantsearch.js把搜索条件封装成POST请求,发到Algolia的服务器,服务端直接把命中的JSON返回给浏览器渲染。这意味着什么?意味着数据虽然不在静态HTML里,但也并没有做任何加密,只是以异步请求的方式传输罢了。
1.2 四个难关为什么是这四个
按常规理解,爬虫项目最怕的是IP封禁、验证码、动态Token这些“反爬机关”。但这次真正卡住我的反而是四个看起来不那么“硬核”的问题:
第一关是Algolia API逆向:搞不清请求参数结构、分页逻辑和鉴权规则,就拉不全数据。第二关是多语言国家过滤:泰国展商填的是泰文国家名,中国展商有的填中文、有的填英文,还有一部分干脆不填国家字段,直接过滤就漏数据。第三关是重复数据合并:同一个展商在中英文两套列表里各出现一次,Algolia的索引里也可能按展区和产品类别重复收录,不去重导出的Excel没法看。第四关是批量插入回滚:四千多条数据不可能一条条插库,但批量插入时中间任何一条报错都会让整个批次变得不干不净,没有事务保证,后续清洗比爬虫本身更痛苦。
这四关恰好串成了一条完整链路:先解决“数据从哪来”,再解决“数据怎么清”,然后解决“数据怎么合并”,最后解决“数据怎么落地”。每一关单独拿出来都不算太难,但合在一起,缺少任何一环都会让项目卡壳。
2. Algolia API逆向实录:从Network面板到全量拉取
2.1 从浏览器Network面板锁定Algolia请求
打开展会官网展商列表页,按F12进入开发者工具,切到Network面板,刷新页面并在搜索框里随便输入一个关键词,比如“plastic”。观察新增的XHR请求,过滤Type为xhr,会看到形如下面的请求:
https://xxxxxxx-dsn.algolia.net/1/indexes/*/queries?x-algolia-agent=...域名前缀那一长串字母数字就是Application ID,/1/indexes/*/queries是Algolia的全索引批量查询接口。点开这个请求的Headers,真正有用的信息就三样:
x-algolia-api-key:一串Base64风格的密钥x-algolia-application-id:对应域名中的那串IDx-algolia-agent:客户端标识,通常包含浏览器版本和instantsearch.js版本
这三个参数中,api-key和application-id就是你说的“API逆向”核心。Algolia的鉴权逻辑是“应用ID + API Key”配对。绝大多数展会官网在配置Algolia时只用了搜索权限的Key,目的就是让前端能够全量读取索引数据。也就是说,这个Key就是API的钥匙,拿到它你就能以和网页完全相同的身份去请求数据。
2.2 还原SearchParams并实现全量拉取
再看这个请求的Payload,Algolia的POST请求体是JSON格式,核心部分如下:
{ "requests": [ { "indexName": "exhibitors_all", "params": "query=plastic&hitsPerPage=100&page=0&facets=%5B%5D&tagFilters=" } ] }indexName就是你要访问的索引名,params是URL编码的查询参数。其中最关键的参数是三个:
query:搜索关键词,留空字符串就能匹配所有记录hitsPerPage:每页返回条数,Algolia官方上限是1000page:页码,从0开始
把query改成空串、hitsPerPage改成1000、page从0开始递增去请求,响应体返回的就是完整的JSON数组。Algolia会返回一个nbPages字段,就是这个索引的总页数,拿它当循环结束条件即可。下面是我实际用的Python拉取脚本:
import requests import urllib.parse APP_ID = "你的application-id" API_KEY = "你的api-key" INDEX_NAME = "exhibitors_all" BASE_URL = f"https://{APP_ID}-dsn.algolia.net/1/indexes/{INDEX_NAME}/query" headers = { "X-Algolia-Application-Id": APP_ID, "X-Algolia-API-Key": API_KEY, "Content-Type": "application/json;charset=UTF-8", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } all_hits = [] page = 0 while True: params = urllib.parse.urlencode({ "query": "", "hitsPerPage": 1000, "page": page }) payload = {"requests": [{"indexName": INDEX_NAME, "params": params}]} resp = requests.post(BASE_URL, json=payload, headers=headers, timeout=15) data = resp.json() hits = data["results"][0]["hits"] nb_pages = data["results"][0]["nbPages"] all_hits.extend(hits) print(f"page {page} done, got {len(hits)} hits") page += 1 if page >= nb_pages: break print(f"total: {len(all_hits)}")把这个脚本跑完,四千多条展商记录就全部躺在了内存里。注意有个细节:Algolia的单次hitsPerPage上限就是1000,你传2000它也会被钳制到1000,所以不要试图一次拉完。还有facets参数可以不管,那是给前端筛选功能用的,全量拉取时没必要传。
2.3 鉴权参数与限流规避细节
Algolia这类服务虽然公开了Key,但并不意味着可以无限制地轰炸。实测下来有两个隐性限制:一是部分Key会在服务端绑定Referer或者允许的域名列表,如果你直接把请求头里的Referer去掉,服务端可能返回403;二是Algolia对单IP的并发和QPS有一定容忍度,但短时间高频请求还是会触发限流,返回429状态码。
我的做法是在Headers里带上浏览器级别的Referer和Origin,让服务端认为请求来自展会官网本身。同时把请求频率控制在每秒三到五次,加一个简单的time.sleep(0.2)循环节流。实际上四千条数据用1000条一页去拉,只需要五页请求,哪怕每页间隔两秒,一分钟内也拉完了,根本不需要担心限流。
注意:这里提一个合规上的边界。这类公开展会网站的Algolia Key属于“客户端公开凭据”,数据本身也是公开展示在官网上的,逆向获取公开信息在技术层面是常见的做法。但使用时要把握尺度:只拿项目需要的公开字段,不采集个人隐私数据,不用于商业二次分发,抓取频率控制在合理范围,避免给对方服务器造成压力。
2.4 细节坑:Algolia返回字段与HTML页字段不一致
顺带说一个容易被忽略的坑。Algolia索引里的JSON字段命名和前端页面展示的字段不完全一致。页面上展商简介字段叫description,Algolia里可能叫content;页面上展位号字段叫booth_no,Algolia里可能直接叫booth。我的做法是把所有hits先落一份JSON存到本地,然后对着官网详情页做字段映射,宁可多花十分钟搞清楚映射,也不要写死在代码里后面返工。
3. 多语言国家过滤:泰英中三语国别归一化
3.1 国家字段的脏数据形态
数据拉下来之后,接下来要处理的不是解析而是清洗。Algolia返回的每条展商记录中,country字段的实际值五花八门:
- “Thailand”“China”“Japan”等标准英文名
- “ประเทศไทย”“จีน”“ญี่ปุ่น”等泰文名
- “泰国”“中国”“日本”等中文名
- “Bangkok, Thailand”“Shanghai, China”这种城市加国家的混合写法
- 部分记录干脆缺失国家字段
如果直接拿这个字段去分组统计或过滤,Excel表格里会出现同一个国家被拆成四五行的情况。这个环节的目标就是:把所有非标准写法归一化成统一的中文“国家”字段,同时把不是“泰国”的展商全部保留,因为甲方要的是全部展商名录,国家字段只是后续按国家筛选用的维度。
3.2 三语映射表与归一化实现
我的方案是建立一张“多语言国家映射表”,覆盖中、英、泰三种语言,逐个做字符级映射。比如:
| 标准中文 | 英文 | 泰文 | 常见混合写法 |
|---|---|---|---|
| 泰国 | Thailand | ประเทศไทย | Bangkok, Thailand |
| 中国 | China | จีน | China (Mainland) |
| 日本 | Japan | ญี่ปุ่น | Tokyo, Japan |
| 韩国 | Korea / South Korea | เกาหลีใต้ | Seoul, Korea |
| 德国 | Germany | เยอรมนี | Frankfurt, Germany |
| 美国 | USA / United States | สหรัฐอเมริกา | New York, USA |
实现的时候,先做一个全小写的精确匹配表,匹配不到就用正则去抓国家名关键词,再匹配不到就归入“未知国家”,最后人工过一遍未知清单。下面是我实际写的清洗函数:
import re CN_MAP = { "thailand": "泰国", "china": "中国", "japan": "日本", "korea": "韩国", "south korea": "韩国", "germany": "德国", "usa": "美国", "united states": "美国", "singapore": "新加坡", "malaysia": "马来西亚", "vietnam": "越南", "indonesia": "印度尼西亚", } TH_MAP = { "ประเทศไทย": "泰国", "จีน": "中国", "ญี่ปุ่น": "日本", "เกาหลีใต้": "韩国", "เยอรมนี": "德国", "สหรัฐอเมริกา": "美国", "สิงคโปร์": "新加坡", "มาเลเซีย": "马来西亚", "เวียดนาม": "越南", } def normalize_country(raw): if not raw: return None raw = raw.strip().lower() if raw in CN_MAP: return CN_MAP[raw] if raw in TH_MAP: return TH_MAP[raw] # 抓取国家关键词 for key, val in CN_MAP.items(): if re.search(r"\b" + re.escape(key) + r"\b", raw): return val for key, val in TH_MAP.items(): if key in raw: return val # 特殊情况:街道地址中含有国家名 return None这个函数的返回值只有“标准中文国家名”和“None”两种,这就保证了后续无论做透视表还是筛选,国家维度的口径都是一致的。处理完这轮,两千多条记录里大约有三十多条无法自动识别的国家,我直接导出到一个csv文件手工看了一遍,发现主要是小语种国家名和展会机构地址混在一起,手工补齐映射表后再跑一遍归一化,清洗率就接近百分之百了。
3.3 非展商类型过滤的隐藏需求
国家归一化解决之后,还有一个隐藏需求:甲方要的是展商名录,但Algolia索引里还有可能混入非展商对象。比如InterPlas展的官网除了展商,可能还有媒体合作方、行业协会、专业观众服务商、展馆服务公司等类型。
来看实际数据里的一个典型案例:
{ "company_name": "Bangkok Plastic Industry Association", "booth": "", "country": "Thailand", "category": "association", "description": "Association of plastic manufacturers..." }这种记录就不能当展商导出。我的做法是根据Algolia里的category或type字段先区分,如果索引里没有这个字段,就靠公司名后缀去降噪。比如公司名包含“Association”“Association”“สมาคม”“Media”“Co., Ltd.”之后,还要结合booth字段是否为空来判断。整理了一条简单规则:category字段是association/media/service的剔除,booth为空且公司名含协会关键词的剔除。校验之后,最终展商总数比原始hits数少了百分之十几,这在预算范围内。
4. 重复数据合并策略:从URL指纹到公司名相似度
4.1 重复数据三大来源
清洗完国家字段,紧接着就是去重。展会数据重复的来源通常有三个。
第一个来源是多语言列表重复。很多展会网站有英文版和泰文版两套列表,Algolia索引里同一个公司可能被收录两次,公司名一个是英文写法、一个是泰文写法,但展位号是同一个。
第二个来源是展区分类重复。同一个展商可能在“塑料机械”“模具”“原材料”等多个分类中分别出现,Algolia的索引会为它生成多条记录。
第三个来源是官网数据同步异常。展会主办方本身的数据库就存在同一公司名不同大小写、不同后缀的脏数据,Algolia只是原样同步了而已。
4.2 唯一键设计与相似度合并
去重不能只靠“公司名完全相等”判断,因为同一个公司在不同记录里的写法可能截然不同。我的做法是把去重分为三层。
第一层用展位号做精确唯一键。展会数据最大的特征就是展位号和公司是一一对应的,如果两条记录的展位号相同,百分之九十九是同一家。第二层用官网域名归一化做匹配。取website字段去掉协议、路径、www.前缀,比如https://www.thaiplastic.com/contact归一化成thaiplastic.com,域名相同视为同一家公司。第三层用公司名模糊匹配兜底,主要针对展位号和官网都缺失的记录。
模糊匹配我用的是rapidfuzz库的token_sort_ratio算法,它会把两个字符串切分成token以后排序比对,适合处理“Thai Plastic Co., Ltd.”和“Thai Plastic Company Limited”这种词序颠倒、简写不同的情况。阈值我反复试下来定在了88%,低于这个值误判率上升,高于这个值漏判率上升。
from rapidfuzz import fuzz def is_duplicate(name1, name2, threshold=88): return fuzz.token_sort_ratio(name1, name2) >= threshold实际运行时,先用展位号去重,拿不定的再丢给模糊匹配,最后人工审视模糊匹配的边界case。一轮下来,三千多条候选数据被压缩成了两千七百多条。
4.3 字段级合并优先级
同一个公司去重合并时,不同记录的字段完整度不一样。有的记录只有公司名和展位号,有的记录则带官网、简介、产品分类、详细地址。合并的指导思想是:信息全的字段优先保留,描述类字段取最长值,名称类字段手动指定优先级。
我的合并规则是这样的:
- 公司名:优先保留官方英文名,其次是泰文名,最后是中文名
- 官网:优先保留带
https://协议的完整URL,剔除纯javascript:或空值 - 展位号:多条记录的展位号一致就保留,不一致则全部保留并以
/连接 - 简介:取所有记录中长度最长的,因为通常最长的简介包含的公司业务信息最全
- 国家:统一用归一化后的中文国家名
这里有个细节,去重合并后的公司名如果是泰文,甲方拿到Excel里可能辨认可读性差。我的做法是额外加一列“英文名”,如果某条记录的泰文名没有对应的英文名,就用unidecode库转成拉丁字母近似写法,转不出来的就保留原文。
5. 批量插入回滚:事务边界与异常吞没的坑
5.1 批量插入为什么需要事务
数据清洗完毕,到了入库环节。四千条数据逐条INSERT显然不现实,常规做法是拼成批量插入语句一次性执行。但批量插入有个经典问题:第500条数据违反了唯一键约束,前499条已经插入成功,此时数据库处于什么状态?
如果用的是不带事务的逐条执行,那前499条就在库里了,第500条以后全部没插入。下游无论做数据统计还是生成Excel,都会基于一个“残血”数据集。如果配合Excel生成逻辑,更会出现Excel里明明有500条,数据库里只有499条的对账差异。
所以批量插入的场景必须有事务保护:要么全部成功提交,要么全部失败回滚,不接受中间态。这是ACID里“原子性”最直接的应用。
5.2 Python + MySQL/SQLite回滚落地
我先说SQLite的落地方案。Python自带的sqlite3模块默认是自动提交模式,每条语句单独提交。如果你手动执行批量插入,一定要把整个批次包在一个显式事务里:
import sqlite3 conn = sqlite3.connect("interplas.db") try: conn.execute("BEGIN") cur = conn.cursor() for item in clean_data: cur.execute( "INSERT INTO exhibitors(company_name, booth, website, country, description) VALUES(?, ?, ?, ?, ?)", (item["company_name"], item["booth"], item["website"], item["country"], item["description"]) ) conn.commit() print("inserted:", len(clean_data)) except Exception as e: conn.rollback() print("rollback, error:", e) finally: conn.close()关键就是conn.execute("BEGIN")开启事务、conn.commit()提交、except里conn.rollback()回滚。只要有任何一条INSERT抛出异常,整个批次都会被倒回去。
如果是MySQL,Python里通常用pymysql或mysql-connector-python。MySQL的InnoDB引擎天然支持事务,但要注意连接对象的autocommit默认值。pymysql默认是False,也就是说所有语句都在事务里,直到你显式commit()。如果你不主动开启事务也不主动提交,程序结束时未提交的事务会被连接关闭时隐式回滚,这也容易造成“为什么数据没进去”的疑惑。
5.3 易踩的坑:隐式提交与异常吞没
我在这次项目里踩了两个和回滚相关的坑。
第一个坑是MySQL的隐式提交。如果你的批量插入语句里不小心混入了CREATE TABLE、ALTER TABLE这类DDL语句,MySQL会自动触发一次隐式提交,把之前事务里的数据固化。实际代码里,创建临时表做数据校验时尤其容易踩这个坑。所以我后来严格规定:建表、加索引这类DDL必须在事务代码之前单独执行,绝对不能插在批量INSERT的事务步骤里。
第二个坑更隐蔽,是异常被吞没。我在第一版代码里为了“防止一个脏数据影响整个批次”,在循环里给每条INSERT包了try...except,出错就continue。当时觉得这样很“健壮”,但实际上把整个批量插入变成了前499条提交后500条跳过的半成品,而且因为异常被吞掉,你根本不知道哪条数据有问题。后来我改成:先做一轮数据预校验,把能预见的问题排除掉,然后在事务里让INSERT真正地抛出异常,让事务机制去处理整体的回滚。
预校验的逻辑很简单:查一下去重后的数据里,有没有公司名或展位号出现空值,有没有字符串长度超过数据库字段长度的记录,有没有明显是None但数据库该字段标了NOT NULL的。这些数据如果在预校验里就过滤掉,事务里的INSERT就几乎不会失败,回滚也就成了一个安全网而不是常态路径。
6. 整条链路复盘与实战Tips
6.1 从逆向到入库的完整执行顺序
走完这四关,整个项目从“看页面”到“数据落库”的执行顺序其实非常清晰。我把自己的操作流程整理成了下面这张路线图,后面再遇到同类项目可以直接套用:
- 打开页面,先看Network面板,判断数据是服务端渲染还是API异步加载,如果是Algolia直接复用之前的方案
- 从HAR中提取Application ID、API Key、IndexName,先用单页请求验证参数结构
- 构造
hitsPerPage=1000的全量拉取脚本,把原始JSON存一份到本地做备份 - 分析字段,建字段映射表,国家字段跑多语言归一化
- 按展位号、域名、公司名三层逻辑去重,记录去重前后的数量差异
- 批量插入前先做预校验,再用事务包住整个批次
- 入库后抽样十条,和官网原始页面比对,确认字段映射和清洗结果无误
这套流程的耗时分布很有意思:逆向Algolia大概四十分钟,写拉取脚本二十分钟,两者加起来不到一个小时。但国家字段清洗加人工review花了半天,重复数据合并和字段优先级规则又占了大半天。爬虫本身从来不是瓶颈,数据的规整才是。
6.2 可复用技巧与迁移场景
这四关的解决方案不只能用在InterPlas这一个展会上。Algolia作为SaaS搜索服务,被大量展会、招聘网站、电商导航站和企业名录站使用。判断标准很简单:你在Network面板里看到请求地址包含algolia.net,请求头里有X-Algolia-Application-Id,那就说明这个站的数据通道是Algolia。这时候只需要替换Application ID、API Key和IndexName三个值,同一个拉取脚本就能直接复用。
多语言国家过滤和重复数据合并在任何“外贸展会数据”“跨国企业名录”类项目里都能复用。我的建议是提前维护一版中英泰三语国家映射表,遇到新的国家就往里补充,长期积累下来,再遇到东南亚展会的数据清洗就能做到一次跑完、零人工review。
6.3 我最后想分享的两个小经验
经验一:不要一开始就写“优雅的生产级代码”。第一版脚本先怎么快怎么来,用requests裸调接口、把清洗逻辑全部堆在一个文件里,先把端到端流程跑通,确认数据链路没问题,再回头拆分函数、加日志、做配置化。否则很容易陷入“清洗逻辑写了三天还没看到一条数据入库”的窘境。
经验二:每次清洗和合并都留一个中间产物。我习惯在每个环节结束都导出一份CSV或JSON,比如“原始hits”“归一化后”“去重后”“入库前”。这样做的好处是,后面发现数据对不上的时候,可以很快定位是哪一步出了问题,而不是对着最终数据库里的一堆记录干瞪眼。这次项目里我就靠中间产物发现了国家字段清洗时漏掉了两个泰文地名变体,五分钟就修好了。
数据落地那几天我其实一直在想一个问题:为什么这些展会官网不直接把展商列表渲染在页面里,反而要用Algolia绕一圈?后来想通了,因为Algolia自带的搜索、筛选、分页组件太好用了,前端开发基本不用写逻辑。但对爬虫来说,这个“绕一圈”反而把原本存粹的HTML解析变成了一次API逆向。如果你也遇到了类似的技术选型导致的数据抓取难题,希望这篇纪实能让你少走几个弯路。