
前端路由动态渲染、JSON内嵌HTML清洗、展位信息数组化、分页参数固定化——尼日利亚展会爬虫四大技术难关攻克纪实接到这个需求的时候我原以为又是一单普通的展会信息采集目标网站是尼日利亚某个大型展会的官方门户需要把参展商、展位号、展品类别、公司简介全部抓下来做成结构化表格。结果真动起手来发现水比想象中深得多。目标站点是一个典型的现代前端工程路由动态渲染、数据内嵌在JSON里、HTML片段藏得又深又乱展位号是各种自由文本格式分页参数还是写死的。整整一周时间我陆续踩完了这四个大坑也把每个坑背后的原理摸了个底朝天。这篇文章就把这次实战的完整过程记录下来——包括我怎么发现前端路由渲染让requests只能拿到空壳、怎么从JSON字段里把一段带样式带脚本的HTML清洗成干净文本、怎么把HALL 2, Stand 2C-04 / 2C-05这种混沌文本转成结构化数组、以及怎么用一个固定参数模板安全地翻完几千页数据源。涉及到的知识点不是什么高深算法但全是实打实的边界细节。我自己当初搜遍全网也没找到一篇能一次性讲完这些组合场景的文章如果你正在处理类似的动态站点数据采集这篇文章可以少走很多弯路。1. 项目背景与整体思路拆解1.1 需求方到底要什么需求方是一家做国际贸易撮合的平台他们看中了尼日利亚这个展会的参展商名录想要拿来做买家线索和行业分析。具体要求并不复杂每家展商的公司名称、简介、官网、联系方式、所属行业分类、展位号。数据总量不多展会规模大概两千家展商但是字段必须齐全尤其是展位号要结构化成可筛选的数组——他们后续要做按展馆筛选的交互界面不想看到一堆混合文本。这个需求表面看就是个“中等规模的结构化采集”但问题在于目标站点的技术栈。我打开网站一眼就看出不对页面加载速度快、URL切换无刷新、地址栏带路由参数这是典型的前端框架应用。再看网络面板首页返回的HTML里除了script标签几乎什么都没有所有可见的内容全是JavaScript在浏览器里动态渲染出来的。这意味着直接用requests.get拿HTML的老套路彻底失效得换思路。1.2 四个技术难点的定位与分析我把这次遇到的障碍归纳成四个核心问题也是后来完整方案的四个模块前端路由动态渲染页面内容由前端JS渲染初始HTML为空壳无法直接解析。解决方案是绕过渲染层直接定位后端数据接口。JSON内嵌HTML清洗后端接口返回的JSON字段里有多个字段的值是完整的HTML片段含图片标签、样式、脚本必须清洗并提取核心内容。展位信息数组化展位号并非统一编号而是Stand 4A-12、Hall 2, Stand 2C-04 / 2C-05这类自由文本甚至同一家有多个展位需要解析成规范化数组。分页参数固定化列表接口的分页参数看起来完全固定但翻页时部分参数会从响应正文中动态生成必须固定模板并跟随响应更新关键字段。整个项目最后选择的技术方案是Python requests 直接调用后端接口没有启用Playwright或Selenium这类重型浏览器自动化。这个选择后面会详细讲核心原因是能用接口解决的就不要渲染浏览器渲染浏览器不仅是资源消耗大而且慢、不稳定、容易被反爬识别。2. 前端路由动态渲染绕过渲染层直捣数据接口2.1 现象requests拿回来的空壳HTML刚开始我犯了个低级错误想当然地写了十几行requests代码去请求展会列表页然后把返回的HTML交给BeautifulSoup解析。结果控制台打出来一看整个页面结构只有几层div和一堆script标签正文区域空无一物。当时的想法是坏了这站上了“硬骨头”得用无头浏览器。但经验告诉我遇到动态渲染第一反应不应该立刻上浏览器自动化而是先做一件事打开浏览器开发者工具切到Network面板刷新页面看看客户端到底发出了哪些真实请求。这一步永远是最优先的因为不管前端怎么渲染数据一定是通过某个HTTP请求从后端拿到的只要找到这个请求就等于找到了数据的源头。2.2 从Network面板定位真实接口我按F12打开开发者工具过滤XHR请求刷新列表页果然看到了几个可疑的API请求。其中一个接口的返回内容就是我在页面上看到的展商列表数据。这就印证了标准的SPA模式前端框架先加载空壳HTML然后通过XHR请求向API接口要JSON数据再在浏览器端通过路由和组件把数据渲染成可见页面。对于爬虫来说JSON数据已经拿到页面渲染步骤完全可以跳过。这个发现直接改变了整个项目的走向。我不再需要跟DOM解析较劲只需要仔细分析这一个数据接口的请求参数和响应结构就可以完成全部数据采集。当然这里有一个关键前提接口是否有鉴权、签名或Cookie校验。我看了下请求头只有常规的Cookie和User-Agent没有自定义签名头这意味着直接构造请求的可行性很高。2.3 前端路由与API路径的对应关系做前端开发的朋友应该很清楚前端路由的路径和后端API的路径往往不是一一对应的。比如页面上显示的URL是/companies/45ef9a实际请求的后端接口可能是/api/exhibitor/detail中间还夹着一层Proxy或Nginx转发。前端路由的作用是针对浏览器内的视图切换而后端接口负责真正的数据交互。爬虫如果想要数据必须找到并请求后端接口而不是去模拟前端路由。这里有个细节值得记录这个站点的前端用了一种比较老的路由方式URL带#锚点类似example.com/#/companies/45ef9a。这种hash路由在爬虫场景下反而方便一些因为真正发请求的都是XHR跟URL的hash部分没有关系。如果是history路由模式前端路由路径直接反映在地址栏服务器还需要配置fallback但这同样影响不到API请求的捕获。总之锁定XHR请求就是关键。2.4 绕过渲染层的具体操作细节说说我当时怎么做的具体步骤第一步打开无痕窗口访问目标列表页避免本地Cookie干扰。第二步打开开发者工具的Network面板勾选Fetch/XHR过滤。第三步刷新页面记录所有XHR请求逐个查看返回值锁定包含展商数据的接口。第四步复制该接口的URL、请求方法、请求头、请求体在Python环境里尝试直接用requests复现。第五步如果复现成功且返回完整的JSON就直接进入数据解析阶段如果失败观察返回的状态码和错误信息判断是否需要补充Cookie、Referer或其他参数。我的实际结果比较顺利直接requests模拟请求就拿到了完整JSON。但这里有一个必须提醒的点很多动态站点在请求头里校验Referer和Origin如果直接裸请求会返回403。解决方案是在请求头里把这两个字段补上能解决的问题立刻少一半。提示遇到动态站先别急着上Selenium/Playwright。浏览器自动化是重型方案只有在接口被强加密、签名校验、或者数据完全靠JS渲染且无网络请求的情况下才考虑。优先从Network面板找XHR这是效率最高的路径。3. JSON内嵌HTML清洗先提取字段再净化文本3.1 接口返回里的“脏字段”前面的问题解决了新的麻烦接踵而至。这个接口返回的JSON结构很规整但其中有两个字段让我头大一个是简介字段一个是产品描述字段它们的值不是普通字符串而是完整的HTML片段。我看了一个样例里面满是div、span、img、p标签还有内联style样式甚至夹杂着script标签。这显然是把富文本编辑器里的内容直接序列化进了JSON没有做任何清洗。这种数据不能直接入库否则下游用起来全是问题。但对于爬虫来说也不能简单粗暴地把所有标签全部去掉因为里面有些信息是文本之外的富文本细节比如图片URL在 标签的src属性里链接的href可能指到公司官网加粗、换行等信息也隐含了文本的结构。我的处理原则是先提取需要保留的字段再做HTML转纯文本顺序不能颠倒。3.2 清洗管道的设计思路我给这个清洗步骤总结了一个三类处理框架第一类需要保留的属性。比如img的src、a的href、data-src等这些是原始结构化信息必须提前用XPath或正则提取出来。第二类需要移除的标签。包括script、style、iframe、noscript这些要么是脚本要么是样式对文本分析没有任何价值。第三类需要转换的标签。比如br转换为换行、p和div转换为段落分隔符、li转换为列表符号这些标签虽然没有可见文本但承载了排版语义不能直接丢弃。实际操作是先用BeautifulSoup解析HTML把img和a先摘出来保存然后把script和style的标签删掉最后调用get_text函数配合自定义分隔符把保留的文本用合理的方式拼出来。下面是我实际用的一段核心清洗代码不是什么复杂逻辑但边界情况都覆盖了import re from bs4 import BeautifulSoup def clean_html_to_text(raw_html): if not raw_html or not isinstance(raw_html, str): return # 1. 先提取图片 src存成列表备用 soup BeautifulSoup(raw_html, html.parser) img_srcs [img.get(src) or img.get(data-src) for img in soup.find_all(img)] img_srcs [src for src in img_srcs if src] # 2. 提取所有链接文本与 href links [(a.get_text(stripTrue), a.get(href)) for a in soup.find_all(a) if a.get(href)] # 3. 删除 script / style / iframe / noscript for tag in soup([script, style, iframe, noscript]): tag.decompose() # 4. 块级标签替换为换行 for tag in soup.find_all([p, div, br, li, tr, h1, h2, h3, h4]): tag.append(\n) # 5. 提取纯文本并压缩空白 text soup.get_text() text re.sub(r\n{3,}, \n\n, text) text re.sub(r[ \t]{2,}, , text) text text.strip() return { text: text, img_srcs: img_srcs, links: links, }这段代码最关键的是第四步和第五步的顺序先把块级标签替换为换行再统一压缩多余空白。如果顺序反了或者直接用get_text而不做任何预处理最后拿到的文本会是一坨没有任何分段的长字符串下游搜索和展示都很难用。3.3 转义字符与编码的坑除了标签本身JSON内嵌HTML还有一个隐形坑转义。在JSON字符串里HTML标签的尖括号是以\u003C和\u003E这类Unicode转义序列存在的比如description: \u003Cp\u003E...Python的json模块解析后会还原成和这倒没有问题。真正的问题是有些字段包含了类似\ud83c\udf89这种emoji代理对在Python3里如果直接操作字符串可能会遇到surrogateescape相关的问题。我当时用一个很简单的方式规避在读取JSON内容后统一用.encode(utf-8, errorsignore).decode(utf-8)做一次编码清洗。这个操作会把那些无法正常编码的代理对丢弃掉虽然会丢失个别emoji但对展商简介这种文本来说是完全可以接受的。另外还有换行符和制表符的问题。JSON里的换行不是浏览器里展示的\r\n可能是单独的一个\n也可能是\u2028这种Unicode行分隔符。清洗后统一replace成标准换行再结合上面的压缩逻辑就可以保证最终文本干净、规整、无乱码。3.4 清洗顺序的经验总结这里要特别强调一个顺序问题先提取属性再做标签删除再做文本转换。因为一旦先把所有标签全部strip掉图片地址和链接就永远丢失了再也无法从原始HTML里找回来。我最初写第一版清洗时就是因为顺序颠倒先删了所有标签结果发现产品描述里的图片地址全部丢失只能重新跑一遍原始数据。白白浪费了一轮请求之后我立刻调整了处理顺序把“先摘果子后砍树”作为清洗HTML的铁律。注意丢字段容易从零开始补数据难。凡是JSON里嵌HTML的字段处理前先把里面值得保留的属性图片、链接、表格数据提取到单独的字段再做标签剥离。4. 展位信息数组化从混沌文本到规范化数据结构4.1 展位号字段的原始形态第四个难点来自数据格式极不规整。这个展会的展位号字段在JSON里是一个字符串但实际内容五花八门。我整理了几个真实出现的样例Stand 4A-12Hall 2, Stand 2C-04 / 2C-05Stand 8B-20 8B-211E-14Hall 4 Booth No. 4C-31 (Main Booth)Stand 5A-01, 5A-02, 5A-03需求方要求把这个字段转换成数组比如[4A-12]、[2C-04, 2C-05]、[4C-31]。难点在于同一家展商可能租了一个或多个展位展位号可能出现Hall、Booth No.等前缀多个展位之间的分隔符有逗号、斜杠、符号、括号有些还带有(Main Booth)这样的备注信息。这个字段如果在清洗阶段不做数组化那么后续所有按展馆、按展位筛选的功能都无从谈起。因为Hall 2, Stand 2C-04 / 2C-05这个字符串无法直接用于筛选必须拆成结构化数组。4.2 数组化解析的核心逻辑我设计的解析逻辑分四步走第一步统一标点把全角逗号转半角把顿号转逗号把斜杠与符号都视为分隔符候选统一替换为逗号。第二步提取所有符合展位号模式的部分。展会展位号的规律是数字字母横线数字比如4A-12。用正则r[0-9][A-Za-z]-[0-9]可以覆盖。第三步处理括号里的备注信息比如(Main Booth)要剥离掉但是Hall 4这种馆名信息如果与展位号在同一处出现需要判断是否属于必保留字段。第四步去重、去空、按原顺序返回数组。下面是我实际用到的解析函数处理上面那些样例都没有问题import re def parse_booth_numbers(raw): if not raw or not isinstance(raw, str): return [] # 统一分隔符 text raw.replace(, ,) text text.replace(、, ,) text text.replace(/, ,) text text.replace(, ,) text text.replace( and , ,) # 提取展位号模式 pattern re.compile(r[0-9]{1,2}[A-Za-z][-\s]?[0-9]{2,3}) candidates pattern.findall(text) # 清洗去空格、统一横线、去重、保持顺序 result [] for item in candidates: item item.replace( , -) item re.sub(r([A-Za-z])-\s*, r\1-, item) item item.upper() if item not in result: result.append(item) return result这个函数看起来简单但有些边界情况还是得继续补。比如有的展位号写成4A-12 / 4A-13但中间不含逗号我的统一分隔符步骤会把它变成4A-12 , 4A-13正则再匹配就有两个结果。还有的写法是4A - 12带了空格这个也被我的正则捕获了后续统一为4A-12。4.3 空值与缺失值策略展位号字段还有一种情况是空的或者返回null。这可能是展商还没分配展位也可能是数据录入时的缺失。对于这种我的策略是返回空数组而不是一个包含None或者空字符串的数组。需求方在筛选界面可以直接通过展位号为空这一个自定义筛选项来处理。当然空值不能静默处理。我在项目里加了一个校验环节凡是展位号为空数组的记录单独记录到一个缺失清单里后期抽查人工补齐。这对数据质量的把控很重要。实操心得数组化解析的正则模式一定要基于真实的字段分布来定不能拍脑袋写。先拿100条样本数据跑一遍 pattern.findall统计有多少条能解析出结果、多少条落空再针对落空的样本单独调整。我第一版正则只匹配了纯数字字母数字的格式结果漏掉了一批带Booth No.前缀的记录后来加了一个宽松的二级匹配才补上。5. 分页参数固定化翻页机制里的隐藏雷区5.1 分页参数表面上“写死”了列表接口支持分页但我一看参数结构有点懵URL里的参数只有page、pageSize和一个sort字段而且sort的值是一个固定的长字符串看起来完全写死。正常套路应该是我只需要循环递增page就行这应该是最简单的情况。但是实际操作中我翻到第二页就发现数据重复了——第一页的展商和第二页的展商有一部分重合。这说明参数并不是表面上那么简单。虽然sort字符串看起来固定但这个值在某些情况下会被后端作为某种查询指纹参与数据定位。简单说这个接口的翻页机制并不是“page1返回第1-50条page2返回第51-100条”而是pageOffset0 pageLimit50、再配合一个随查询条件变化的游标。5.2 固定化参数模板的构造这个坑的具体解法是不再单纯依赖page递增而是把sort和pageSize设为常量然后在请求前从响应正文中动态提取一个游标字段与page参数一起提交。也就是说所谓分页参数固定化并不是把所有参数都写死而是把不变化的参数固定成一个模板把真正驱动翻页的字段在每次请求后更新。我构造的请求参数模板大概是这样的params { page: 1, # 动态递增 pageSize: 50, # 固定 sort: FIXED_STRING, # 固定值来自首次响应 lang: en, # 固定值 cursor: , # 首次请求为空后续从响应中提取 }翻页时不重新构造整个params字典而是在这个模板基础上只修改需要变动的key。这样做的好处是避免参数漂移如果每次循环都重新声明字典很容易漏带某个固定参数导致请求返回错误或重复数据。5.3 游标模式与常规分页的区分说到游标模式我需要补充一个判断经验怎么快速识别一个接口是常规页码分页还是游标分页。一个很简单的测试方法请求page1和page2然后对比两次结果是否有重叠或缺失。常规页码分页一般是通过LIMIT/OFFSET实现page2的数据应该从第51条开始没有重叠。游标模式则是基于排序字段的位置比如基于公司ID或更新时间这种模式下翻页参数往往不是数字而是类似eyJpZCI6...的字符串或一段加密文本。我这边的实际情况更隐蔽接口同时接受page和sort而sort字段的真实作用是把数据按照某个字段排序后端根据排序结果定位游标。如果不带sort翻页就会错乱。这解释了为什么看起来sort是固定值但不传不行。我需要做的就是把首次请求返回的sort值永久保存在后续所有分页请求中带上并且保持拼写完全一致。5.4 循环翻页中的限速与容错参数问题解决后翻页循环本身的稳定也需要注意。我设计了一个带重试和退避的翻页循环import time import random def fetch_all_pages(api_url, base_params, max_retries3): results [] page 1 while True: params base_params.copy() params[page] page response None for attempt in range(max_retries): try: response session.get(api_url, paramsparams, timeout15) if response.status_code 200: break else: raise Exception(fHTTP {response.status_code}) except Exception as e: if attempt max_retries - 1: raise e time.sleep(2 ** attempt random.random()) data response.json() batch data.get(data, []) if not batch: break results.extend(batch) page 1 time.sleep(0.5) # 礼貌限速避免触发反爬 return results关键点有两个第一每次请求时用base_params.copy()生成新参数避免污染基础模板第二重试的退避时间用指数退避加上随机抖动避免多个线程同时重试导致雪崩。我实测这个展会接口对请求频率比较敏感固定0.5秒的间隔比较稳定低于0.2秒就会开始返回429。注意有些人写翻页循环喜欢把limit/pageSize放进循环体内动态更新这其实是没必要的。分页固定化的本质是把与翻页无关的参数固化把与翻页相关的参数显式声明两者的边界要清晰。每次请求只动该动的字段其余保持不变这是分页逻辑稳定工作的前提。6. 常见问题与排查技巧实录6.1 高频问题速查表整个项目过程中我遇到过的典型问题远不止上面四个核心模块这里整理一个速查表方便你对照排查现象可能原因排查思路解决方案请求返回403缺少Referer/Origin等请求头对比浏览器Network面板的请求头补齐Referer、Origin、User-Agent返回200但JSON为空参数缺了sort等固定字段对比浏览器请求和代码请求的参数差异补全固定参数模板第二页数据与第一页重复翻页机制实为游标模式未传游标字段检查响应正文是否有cursor/token字段动态提取游标字段并传给下一页JSON里html字段带\u003C乱码转义序列未正确识别确认json.loads后的实际字符串Python的json.loads天然处理注意编码清洗清洗后文本挤成一团未处理块级标签分隔查看get_text输出是否有换行块级标签append(\n)展位号解析少数据正则模式覆盖不全面判统计失败样本增加二级宽松匹配模式请求偶发429请求频率过高检查响应头Retry-After退避重试固定限速反爬验证突然出现单一IP请求量过大检查验证码类型降低并发、更换UA、长间隔重跑6.2 日志与断点续爬设计说到最后一次项目实施有个经验值得重点分享日志和断点续爬绝对不能省。展会数据采集涉及几千页请求中途任何一次网络抖动、参数出错、反爬触发都可能导致中断。如果从头再跑一遍不只是浪费时间还可能因为重复请求触发更严格的反爬。我的做法是设计了一个简单的进度日志每成功抓取一个分页就记录当前已爬到的页码和最后一条数据的唯一标识。程序重启后先读取日志从断点处继续而不是从第一页重新开始。这个设计让长时间运行的采集任务有了很强的容错能力。具体实现也很简单不需要引入复杂的框架。就用一个JSON文件每次翻页成功后写入{ last_success_page: 87, last_cursor: xxxxx, total_fetched: 4350 }启动时读取这个文件如果存在就从上一次的last_success_page 1继续。我实测这个做法让整个采集从“必须一气呵成”变成“随时断、随时续”极大的降低了运维压力。6.3 数据校验的下游联动最后一个隐藏问题出现在数据交付环节。展位号数组化之后我给需求方交付的数据里有一个booths字段结构是字符串数组。需求方前端拿到这个字段可以直接渲染成标签列表也可以按数组长度判断“多展位展商”。但我发现接口原始数据里偶尔会有展位号带中文字段的情况比如主展位4A-12这在解析时会被正则漏掉导致该字段虽然已有4A-12但整条记录仍被判为缺展位信息。这类问题的根治办法是用两条校验逻辑同时检查先跑一次数组解析看结果是否为空再检查原始字段中是否存在展位号模式。如果前者为空但后者能匹配到模式记录到告警日志里人工介入处理。这种双保险避免了下游数据缺失。7. 个人经验总结动态站点采集的通用方法论这趟尼日利亚展会项目做完我自己复盘下来最大的收获不是某一行代码写得多漂亮而是把动态站点的采集思路彻底理顺了。以前遇到SPA站点第一反应就是上浏览器自动化觉得只有浏览器渲染出来的页面才是真实数据现在我的第一反应永远是打开Network面板找接口。只要找到接口后面的事情就简单了处理JSON、清洗HTML、解析自由文本、处理分页每个环节都有明确的步骤可走。还有个小技巧想分享处理任何动态站点时把POST请求和GET请求分别对待。很多SPA站点的数据接口不是GET而是POST参数放在请求体里此时如果要模拟请求就需要注意Content-Type是application/json还是application/x-www-form-urlencoded。我这次遇到的是GET请求但如果你处理的是POST接口记得先看编码格式再决定data还是json参数。最后再谈一点经验和审慎。爬虫是有边界的特别是针对海外展会网站的数据采集需要尊重目标网站的服务条款和数据使用规范。我在项目开始前就确认了该网站的公开数据接口没有设置明确的访问限制同时确保采集间隔合理、不恶意高频请求、不做过多数据存储和传播。做技术的底线是能力要强但使用时要克制。这次的项目让我对“接口优先、渲染兜底”的方法论有了更深的理解也希望大家在实际项目中既能把活干成也能把活干稳。