尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

展会数据采集实战:破解SPA前端路由、JSON清洗与分页参数难题

展会数据采集实战:破解SPA前端路由、JSON清洗与分页参数难题 接到这个需求的时候我扫了一眼标题心里就有了判断这是一次典型的“看似简单、上手就翻车”的展会数据采集。目标站点是尼日利亚拉各斯某国际工业展的企业名录客户想要的是企业名称、联系方式、展位编号、展品简介还要结构化成表格方便后续做市场分析。我当时想这不就是发个GET请求、用XPath提一下文本的活儿真正开工之后才发现事情没有那么乐观。前端路由动态渲染让页面变成了空壳JSON接口里塞满了HTML片段展位信息字段乱得五花八门连分页参数都跟常识对着干。这四个问题单拎出来都不算罕见但凑在同一面墙上就逼着我把整套采集方案重新捋了一遍。这篇文章就当作一次实战复盘把每一步的思路、每一处坑位都写清楚给以后要碰类似站点的兄弟一个参考。1. 项目背景与整体采集思路1.1 需求本身并不复杂客户的需求是抓取该展会官方网站上的参展商列表字段大致包括企业名称、所属行业、联系邮箱、官网地址、公司简介以及最重要的展位信息。展位信息之所以重要是因为客户要根据展馆和展位号做现场拜访路线规划数据必须能直接进Excel筛选排序。目标网站看起来是个标准的企业展示站首页有导航、有搜索框、有展商列表页。我最初估算的总工作量是半天因为这种展会官网大多是静态页面直接用requests加XPath就能把内容全部提出来。但真实情况是首页能正常加载点进列表页也能看到URL变化唯独页面正文区域一直是空白转圈状态。这时候我才意识到目标站用的是前端路由动态渲染也就是常说的SPA单页应用。页面上看到的内容全部由JavaScript在浏览器端拼装服务端返回的HTML只是一个空壳框架。如果继续沿用“下载HTML再解析”的思路会在这上面浪费大量时间。1.2 技术选型与思路调整碰到这样的站点常规选择有两个一是上Playwright或Selenium这类的无头浏览器等JS执行完再抓取渲染后的DOM二是直接F12打开开发者工具找到页面调用的数据接口绕过页面直取JSON。我选择了第二条路而且是在动手之前就确定的因为展会站的数据接口通常不会做太强的访问控制JSON结构也干净得多。相比之下无头浏览器虽然能拿到完整DOM但它消耗资源大、运行速度慢数据量一旦上去翻页采集的时间会被拖得很难看。工具方面保持简单Python 3 requests发请求lxml配合XPath做少量HTML解析BeautifulSoup用来清洗JSON里夹带的HTML片段json库做数据结构化。这套组合不需要安装重型框架遇到问题时排查链路也短。Scrapy当然也能用但为了一个中小规模的数据采集任务引入整套框架有点杀鸡用牛刀了。1.3 战前侦察先把接口地图画出来正式写代码之前我花了一个小时做侦察这一步对后面的所有工作都有决定性作用。操作很简单打开浏览器开发者工具切到Network面板勾选Fetch/XHR过滤然后在页面上操作翻页、搜索、进入详情页。每做一步操作观察网络请求列表里新增了哪些请求。很快我就发现列表页数据来自一个JSON接口翻页时URL里的参数会变化详情页的数据则来自另一个接口返回的JSON里description字段带着大量HTML标签搜索功能走的又是单独一套参数。把这些请求全部记录下来就等于画出了一张“数据地图”。到这里四大难关的轮廓已经全部出现了。接下来一个一个拆。2. 难关一前端路由动态渲染真实数据在XHR接口里2.1 为什么会碰到前端路由现在很多企业官网都已经从传统多页面转向了Vue、React这类框架。前端路由是这些框架的核心能力之一它让页面跳转不需要向服务器发起新的HTML请求而是通过JavaScript监听URL变化动态替换页面内容。这就产生了一个很迷惑人的现象你点击翻页浏览器地址栏的URL确实从/page/1变成了/page/2但服务端日志里可能压根没有这两个路径的请求记录。如果这时候你用requests去请求/page/2拿回来的只是同一个空壳HTML源码里连正文文字的影子都看不到。我一开始也确实走了点弯路用requests直接请求了翻页后的URL拿到HTML后才发现里面只有框架代码和打包后的JS文件路径。这时候强行用XPath提取内容只会得到一个空列表。2.2 排查过程跟着Network面板找数据源正确做法是放弃HTML转而去抓XHR请求。我把开发者工具里的网络请求列表从头翻了一遍过滤掉图片、字体、CSS这些静态资源后很快就锁定了几个返回JSON的接口。判断依据有三个接口路径语义清晰像是/api/exhibitor/list这种返回内容是JSON数组数组里每条记录都包含完整的展商字段URL里的查询参数恰好对应页面的筛选和翻页状态。在Network面板里点击任意一条JSON请求切到Preview页签我直接确认了这就是采集需要的数据源。这种做法其实就是把注意力从“页面长什么样”转移到“数据从哪里来”。前端路由再怎么折腾业务数据最终还是要通过HTTP接口返回只要找到这个接口页面渲染的逻辑就可以完全不管。提示如果页面是POST方式提交查询条件Network面板里同样能看到表单数据和响应内容。别看到是POST就发怵先用浏览器把一次完整请求的参数记录清楚再用requests复现一次大多数情况下都是能成功的。2.3 请求接口的注意事项找到接口之后我立刻用requests模拟了一次调用。第一次返回400原因是缺少了User-Agent。补上之后又遇到一次403这次是缺少Referer字段服务器要求请求必须像从页面内部发起的。解决办法很常规从Network面板的Headers里把浏览器自动携带的请求头完整复制过来。实际操作中下面这几个字段基本是必须的User-Agent标记客户端身份不能用默认的python-requests。Referer告诉服务器请求是从哪个页面跳转来的很多接口会校验这个。X-Requested-With部分站点要求值为XMLHttpRequest用来区分普通浏览器访问和Ajax调用。Cookie的情况要单独看。这个展会的列表接口不登录也能访问但为了保险我仍然使用requests.Session()保持整个会话稳定避免中途因为Cookie变化而丢失状态。用Session的好处还有一点翻页请求只需要带着同一个会话连续发送不需要每次手动拼接Cookie。实测下来连续调用200多个页面没有出现因为会话失效导致的中断。2.4 这段经验教会了我什么遇到SPA站点第一反应不应该是“上无头浏览器”而是先问一句数据后端在哪里前端路由动态渲染的真正难点不在于技术复杂度而在于思维惯性——总以为页面内容一定写在HTML里。实际上只要养成先看Network的习惯绝大多数SPA的数据接口都藏不住。当然也有少量站点会对数据接口做签名校验、加密参数之类的防护那种情况才需要无头浏览器或者逆向接口逻辑。但至少在这个尼日利亚展会项目里直连JSON接口的方案又快又稳全程没有动用重型工具。3. 难关二JSON内嵌HTML清洗从富文本到干净结构化字段3.1 JSON字段里为什么会有HTML直连接口后我拿到了一批结构规整的JSON数据。看起来每个展商的description字段是一大段带标签的HTML包括p、strong、br、a甚至还有内联的span style...。这些内容显然是网站后台用富文本编辑器录入的保存时会以HTML格式存入数据库接口返回时原样传给前端。这种设计在后端CMS系统里非常普遍但对于做数据分析的人来说就是脏数据。客户要的是“能放进Excel里的纯文本简介”不是带着一堆标签的网页源码。如果直接把这些HTML文本导进表格会看到一大堆标签字符数据完全不可用。我需要做的清洗其实有三层剔除HTML标签把正文内容提取出来。保留必要的段落结构例如把br和/p转换成语义清晰的行分隔符。处理HTML实体比如nbsp;转成空格amp;转成。3.2 清洗方案正则为主还是解析器为主我理解有网友会想清洗HTML不就是用正则替换掉.*?吗这个方案在标签规范的情况下可以但实际数据并不总是规范的。项目里碰到过未闭合的p标签、br/混合写法、标签属性里包含大于号的情况用正则很容易误删正文内容。比如a hrefhttps://a.com/?x1y2里的如果处理不当正文就可能被截断。所以我的首选方案是BeautifulSoup。它专为解析不规范的HTML而生底层有容错机制。解析之后用get_text()方法提取纯文本内部会自动处理大部分标签结构比手写正则要稳得多。处理代码大致是这个样子import json from bs4 import BeautifulSoup def clean_html_to_text(html_content): if not html_content: return soup BeautifulSoup(html_content, html.parser) # 将 br 标签替换为换行符保留段落层次 for br in soup.find_all(br): br.replace_with(\n) text soup.get_text() # 清理多余空行保留单倍换行 lines [line.strip() for line in text.splitlines()] return \n.join(line for line in lines if line)在JSON批量处理时只要对每个展商记录的description字段调用这个函数就能得到干净的纯文本简介。实测下来这段逻辑跑完了全部记录没有出现一条因为标签未闭合导致内容错乱的情况。3.3 顺手解决掉文本编码和实体问题清洗过程中还遇到两个小坑值得单列出来。第一是编码。JSON接口返回的数据里中文很少但法文、英文内容不少经常出现\u00e9这类Unicode转义序列。requests拿到响应后我首先做json.loadsPython会自动把Unicode转义还原成真实字符但前提是在读写文件时注意编码设置。保存结果文件时我用了ensure_asciiFalsewith open(exhibitors.json, w, encodingutf-8) as f: json.dump(all_exhibitors, f, ensure_asciiFalse, indent2)如果不加ensure_asciiFalse输出的JSON里全是\u开头的转义符号虽然能被工具正常解析但人眼看起来极痛苦不利于后续用文本编辑器抽查数据质量。第二是HTML实体。清洗后的文本里出现过nbsp;、ndash;、amp;等实体。BeautifulSoup的get_text()会自动把实体转换为对应字符例如amp;变成nbsp;变成不换行空格。之后我又统一把不换行空格替换为普通空格进一步保证导入Excel时不会出现异常空白。提示清洗后的文本如果还要导入WPS表格或Excel注意文本里的换行符会影响单元格显示。建议在导出CSV时把换行符统一替换为空格或分号避免一个单元格的内容被拆到下一行。4. 难关三展位信息数组化把混乱字段整理成结构化数组4.1 展位字段的真实形态展位信息是本项目数据模型里最关键也最乱的字段。接口返回的原始字段叫booth_info里面内容五花八门。我抽样看了一批记录出现过的形式包括Hall 1, Booth 1-05Stand A12B馆Booth 1-05 1-06Hall 3, Stand C08/C09直接是空字符串或者null偶尔还有整段英文句子比如“Located in Hall 2, see our booth number 2-11”客户的需求是把每个展商的展位拆成多行一个公司如果有两个展位就输出两行方便后续做路线规划。这就意味着不能把booth_info当一个字符串存起来必须解析成数组结构。数据结构我定成了这样[ { company: Company A, booths: [ {hall: Hall 1, booth_number: 1-05}, {hall: Hall 1, booth_number: 1-06} ] } ]4.2 解析策略做归一化而不是死磕正则实现这一步的时候我没有一上来就写极端复杂的正则表达式而是先做了文本归一化。归一化的规则如下把所有分隔符号统一、/、、、;、逗号都替换为统一的标记比如半角逗号。识别馆名模式Hall、Stand等关键词之后的编号分别提取。对每个展商记录先按统一分隔符把整体拆分再对每一段匹配展位号的正则。展位号的正则核心是匹配“数字-数字”这个形态import re booth_pattern re.compile(r(\d-\d)) hall_pattern re.compile(r(Hall|Stand|B馆)\s*([A-Za-z0-9]), re.IGNORECASE)为什么用\d-\d作为核心匹配因为从抽样数据来看这种展会的展位编号基本保持“区域编号-展位序号”的格式例如1-05代表1号馆05号位。抓住了这个主干格式再配合馆名信息一个结构清晰的数组就能组装出来了。4.3 边界情况与容错写解析器的时候我专门为边界情况补了几层防护。空值处理是必须的。接口返回null或者空字符串时直接返回一个空数组。客户问“为什么这家公司没有展位”的时候数据里能明确体现出来而不是报错中断。馆名缺失的情况也要考虑。部分记录只写了Stand C08没有明确的馆号这种就把hall字段留空保留原始的stand编码。还有极端不规整的句子正则匹配不到任何展位号时我做了兜底把整个原文放进exhibition_raw字段。这样数据不会丢失后续需要人工复查时也有出处。处理完成后我做了两次全量统计一次是统计解析成功且展位号格式合法的记录占比另一次是随机抽取50条和官网页面人工核对。第一步的占比大概在97%以上第二步人工抽查全部一致。这个精度已经足够支持客户的现场拜访计划了。5. 难关四分页参数固定化翻页不是增加页码那么简单5.1 异常的分页行为列表接口的URL参数里有一个page字段正常理解是page1取第一页page2取第二页。但实际测试的时候page2返回的内容和page1完全一样。这个现象非常奇怪一开始我以为是后端缓存又反复加了Cache-Control相关的请求头去试依然没有变化。直到我对比了浏览器里点击翻页时发出去的真实请求才发现问题所在接口确实接收page参数但它另外还依赖一个叫position的参数在page变化的同时position会同步变成一个看起来没有规律的长字符串。我尝试直接按浏览器抓到的参数原样重放请求是成功的。但如果把page改成3而position保持不变返回的仍是第二页的内容。这说明真正控制分页的其实是position参数page只是一个给人看的展示字段。5.2 固定化策略找出稳定不变的部分我把连续三次翻页的URL参数全部并排列出来逐个字段对比很快就发现规律sort、lang、pageSize这三个参数在三次请求中完全一致它们是固定不变的page虽然递增但当position正确时它并不影响结果真正每次都会变化的是position。这里的“分页参数固定化”指的就是把那些确定不变的参数做成固定模板会话期间所有请求都携带同一套参数值只更新position和一个不影响结果的展示性page计数。这样做的好处是避免模板里莫名其妙的参数动态变化也降低了接口把请求判定为异常的风险。具体实现我用了这样的循环逻辑position for i in range(1, 300): params { page: i, position: position, pageSize: 50, sort: default, lang: en, } resp session.get(api_url, paramsparams, headersheaders) data resp.json() items data.get(list, []) if not items: break all_exhibitors.extend(items) position data.get(next_position, ) if not position: break关键点在于每次请求后从响应体的next_position字段取出下一页的游标再把它作为下一次请求的position值。这种基于游标的分页方式保证了即使排在前面的展商信息有增删也不会漏掉数据或重复抓取。5.3 请求节奏与终止条件分页循环写好之后还有一个必须控制的点请求频率。展会网站的服务器性能未知连续快速发送大量请求轻则触发反爬限制重则给对方造成不必要的压力。我在每次请求之间加入了随机延时import time import random time.sleep(random.uniform(1.2, 2.5))延时范围设置成1.2到2.5秒既不会让采集过程慢得离谱也能保持一种接近人工浏览的访问节奏。实测跑完全部数据用了大概10来分钟全程没有出现一次请求被拒绝的情况。终止条件除了上面代码里判断的空列表和空next_position我还加了一道保护如果连续3次请求拿到的position与上一次完全相同就主动停下并报警防止陷入死循环白白消耗请求。6. 常见问题与实战避坑清单6.1 高频故障速查整个项目下来我整理了一份问题速查表以后同类项目可以直接对照排查现象可能原因解决方式返回HTML里没有内容前端路由动态渲染数据由JS加载打开Network面板找XHR/JSON接口JSON字段全是HTML标签富文本编辑器存储格式BeautifulSoup清洗不要硬用正则分页请求返回相同数据真正分页参数不是page对比多次请求差异找游标类参数请求返回403缺少Referer或User-Agent从浏览器完整复制请求头输出JSON里全是\u转义写入时没关闭ensure_ascii写入参数设ensure_asciiFalse展位号解析错乱分隔符和格式未归一化先统一分隔符再按核心正则提取6.2 编码相关的一个印象深刻的坑中途我碰到过一次比较隐蔽的编码问题。从接口拿到的JSON里有一批展商名称是全大写的法文其中夹杂着重音字符例如É。requests在获取响应后我直接用了resp.json()去解析这本来没有问题。问题出在我中途为了调试先用resp.text在控制台打印过一次控制台环境默认编码把字符显示成了乱码导致我一度以为接口数据有问题。后来验证下来接口本身是安全的是我查看方式错了。这里给新手一个建议调试时多用文件输出或者用JSON格式化工具去查看字符不要直接信任控制台打印的显示结果。想要快速查看JSON结构也可以把文本复制到带JSON视图的编辑器里比人眼盯控制台舒服得多。字段清洗同理。清洗后建议立刻做一次长度和内容分布统计确认没有异常字符再进行后续导出。6.3 数据校验交付前必须做的一道工序全部数据采集并清洗完成后我没有急着交付而是做了一轮完整的数据质量校验。校验分了三个层面总量核对、抽样核对和格式一致性检查。总量核对比较简单把计数结果与官网列表页显示的条数对比误差控制在个位数以内。抽样核对是从结果文件里随机抽取50条再次打开官网逐条人工比对展位号和企业名确认清洗过程没有改变内容。格式一致性检查则是检查每个字段值的类型确保所有展商的booths字段都是数组而不是漏成了原始字符串。恰恰是这一轮校验帮我发现了一个此前忽略的问题有个别展商的booth_info字段里包含的是“TBC”这种待定文本被正则当成了无效内容。这些数据在网页上是明确展示“展位待定”的单纯丢弃会造成信息缺失。我在最终字段里为这种状态增加了一个booth_status标记写入了“TBC”原文。客户看到后也理解了这种待定状态后续反而帮他们避免了一次现场扑空。最后再分享一个我从这个项目里沉淀下来的习惯无论清洗逻辑多完善永远保留一份接口返回的原始JSON存档清洗脚本只在副本上反复操作。原始数据是数据源头的“底片”一旦清洗逻辑发现遗漏随时可以重新跑一遍不用再重新爬一次网站。这个习惯在后续几个类似展会项目中帮了我大忙算是这次尼日利亚展会爬虫之外最有价值的收获。
返回列表