ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SPA展会网站数据抓取实战:前端路由、JSON清洗与分页参数处理

SPA展会网站数据抓取实战:前端路由、JSON清洗与分页参数处理 1. 这个展会网站的难点先拆开看这个项目一开始其实并不复杂我想把某个在尼日利亚举办的行业展会参展商信息拿下来整理成一张可筛选的表格后续用来做参展决策和潜在客户分析。展会官网用的是典型的前端路由动态渲染模式打开页面源码只有一个空壳div idapp/div所有列表内容都是页面脚本加载完成后才塞进 DOM 的。更麻烦的是接口返回的 JSON 里又内嵌了大量 HTML 标签展位信息也不是一个干净的字段而是分散的字符串翻页的时候参数还被前端“固定”成了page1。前端路由、JSON 内嵌 HTML、展位信息数组化、分页参数固定化这四个问题叠在一块光靠简单requests BeautifulSoup是搞不定的。这篇文章不是从零开始的前端教程也不讲某个特定平台的“黑科技”。我把实际排查链路和最终落地方案完整写下来包括为什么直接请求 URL 拿不到数据、JSON 里的 HTML 应该按什么顺序洗、展位信息为什么要拆成数组、翻页参数为什么不能盲目递增。如果你要抓取的是 SPA 架构下的展会目录、企业黄页、商品列表这类站点这篇文章应该能帮你少走不少弯路。1.1 数据不在页面源码里而在异步接口里一开始我也习惯性地用requests.get去抓列表页 URL结果拿到的是不到 2KB 的 HTML 骨架。div idapp/div孤零零地躺在body里下面一串script src.../script。这不是网站故意封我而是前后端分离架构的正常表现服务器只返回一个应用外壳真正的数据由前端 JavaScript 请求接口后动态填充。打开 Chrome DevTools进到 Network 面板勾选 Fetch/XHR然后在页面上点“下一页”很快就看到了真正干活的请求/api/exhibitor/list。它返回的不是 HTML而是一段 JSON里面装着参展商名称、简介、展位信息、国家、邮箱等字段。也就是说列表页的 URL 只是前端路由用来“换脸”的数据一直在接口层。这个认知很关键。很多人卡在前端路由动态渲染这件事上以为必须用 Selenium 或 Playwright 把整个页面渲染出来再抓。但如果你能先抓到 XHR 接口事情就已经成功了一半。渲染出来的 DOM 只是接口数据经过模板处理后的结果直接吃接口反而更干净、更快。1.2 四道关卡不是独立的而是串在一起的这个站点最坑的地方在于四件事是串在一起的。光解决前端路由不够因为接口返回的 JSON 字段里混着 HTML光清洗 HTML 也不够因为展位信息不是标准字段就算前面都处理完翻页时如果只改page参数数据会一直重复。四道难关必须一起打通否则爬下来的数据没法直接用。我把最终方案拆成一条流水线先确认数据源接口 → 稳定翻页拿到全量 JSON → 清洗 JSON 内嵌 HTML → 把展位信息数组化 → 落到数据库或 Excel。下面按攻关顺序逐个说。2. 攻关一前端路由动态渲染别跟 view-source 较劲2.1 前端路由到底做了什么这个站点的列表页 URL 看起来是规整的目录结构比如/exhibitors/2。你很容易以为这只是普通的静态页换数字就能翻页。但实际上页面地址的变化是由前端路由控制的JavaScript 监听浏览器的 History API路径变了就重新渲染对应组件组件再去请求接口拿数据。整个过程不会触发整页刷新服务器也不会因为 URL 变化就重新生成 HTML。所以直接requests.get(/exhibitors/2)拿到的还是那个空壳页面。这不是“反爬”只是前端路由的特征。遇到这种情况正确做法不是去模拟点击、等渲染、再抓 DOM而是先拆出数据接口。2.2 两条可行路径我选了更稳的一条处理前端路由动态渲染通常有两条路第一条是用 Playwright 或 Selenium 这类浏览器自动化工具把页面真实渲染出来然后等待某个列表节点出现再读取 DOM。这条路适合接口加密得很死、或者接口数据不足以支撑业务需求的情况。缺点也很明显每个页面都要开浏览器、加载一堆静态资源、等图片和广告加载速度慢资源占用高页面一多还容易崩溃。第二条是直接从 DevTools 里定位 XHR 请求然后模拟这个请求拿 JSON。我实际测试下来绝大多数“看起来很难”的 SPA 站点只要不是专门做了接口签名XHR 请求都是可以直接复用的。这个项目的数据源就是一个普通的 POST 接口我用requests模拟就能拿到和页面完全一致的 JSON。速度比浏览器渲染快一个数量级代码也更简洁。下面是核心的接口请求逻辑import requests import time API_URL https://expo.example.ng/api/exhibitor/list session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36, Referer: https://expo.example.ng/exhibitors, Content-Type: application/json, }) payload { page: 1, pageSize: 20, offset: 0, sort: id asc, } resp session.post(API_URL, jsonpayload, timeout15) data resp.json() print(data[data][total])要注意一点请求头里的Referer最好保持和浏览器一致。有些站点会校验来源页面少一个 Referer 就可能被拒。User-Agent用当前主流浏览器的版本即可不需要伪造得很复杂。2.3 接口带签名时的兜底方案如果你遇到的站点在接口请求里加了签名参数比如token、sign、timestamp每次都变那就不能单纯用requests硬刚。我这里的兜底方案是用 Playwright 打开页面等接口请求发生从浏览器上下文里把 Cookie 和 LocalStorage 里的凭证取出来再交给requests去做后续的批量请求。from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://expo.example.ng/exhibitors) page.wait_for_selector(.exhibitor-card) cookies page.context.cookies() token page.evaluate(localStorage.getItem(token)) browser.close()拿到 Cookie 和 token 之后放到requests的 session 里即可。这一步的关键是不要让浏览器去做所有翻页操作只让它负责“取钥匙”。钥匙拿到后后面 100 多页数据用requests拉速度和稳定性都会好很多。提示如果站点有明确的 robots.txt 或用户协议限制请先确认自己的采集行为是否被允许。我这边抓取的是公开展会信息且只做一次性数据整理风险相对可控。3. 攻关二JSON 内嵌 HTML 的清洗顺序错一步就得返工3.1 为什么 JSON 里会嵌 HTML找到数据接口后我本以为万事大吉结果一看 JSON 里的description字段人傻了。返回内容长这样{ companyName: ABC Packaging Ltd, description: Leading manufacturer of bpackaging/b machinesbr/Est. 1998 amp; ISO certified, boothInfo: Hall 1 Stand 12, Hall 2 Stand 45 }description字段里既有b、br/这样的 HTML 标签又有amp;之类的 HTML 实体。这种数据通常来自后端的富文本编辑器或者运营人员直接粘贴了带格式的内容。前端渲染时会把它当 HTML 插入页面但我们要存进表格或数据库时需要把它清洗成纯文本。这里最容易犯的错误是先用正则re.sub(r[^], , text)去标签再去处理amp;。如果原始 JSON 字符串里其实带着\u003c这样的 Unicode 转义或者标签和实体混在一起顺序错了就会留下残留标签或乱码。3.2 清洗脚本与顺序正确顺序是先让json.loads()把 JSON 字符串还原成 Python 字符串再用html.unescape()做实体解码最后用 BeautifulSoup 去标签并保留换行结构。import json import re import html from bs4 import BeautifulSoup def clean_html_to_text(raw): if not raw: return # 注意raw 已经是 JSON 解码后的字符串不要再去 json.loads text html.unescape(raw) soup BeautifulSoup(text, html.parser) for br in soup.find_all(br): br.replace_with(\n) for p in soup.find_all([p, div, li]): p.append(\n) text soup.get_text(\n, stripTrue) text re.sub(r\n{3,}, \n\n, text) return text.strip()为什么不用正则去标签因为 HTML 结构千奇百怪正则处理br/、p、div、li很容易漏掉边界情况还会把script之类的标签内容误删。BeautifulSoup 会解析真实 DOM 结构对文本和标签区分得清楚得多。另外一个细节是br/要替换成换行符p和div要在内容末尾补换行否则多行文本会挤成一行后期在 Excel 里根本没法看。常见问题对照表原始 JSON 里的字符串问题清洗后Packaging bMachine/b amp; Spare Parts标签和实体混在一起Packaging Machine Spare PartsLine1br/Line2换行标签被直接删掉两行内容黏在一起Line1换行Line2nbsp;nbsp;Welcome空格实体残留在词首Welcome3.3 清洗时容易踩的两个细节第一个细节是html.unescape必须在剥标签之前做。如果字符串里是lt;bgt;先用正则去标签会匹配不到因为还没有还原成真正的尖括号。等html.unescape之后lt;bgt;变成了b标签才真正出现这时候再去剥标签才能生效。第二个细节是不要过度清洗。公司简介里偶尔会有有意保留的换行比如地址、电话分行。简单粗暴地去掉所有换行会损失信息。我在清洗时不会把所有\n都拍平而是至少保留一个换行让表格里的简介不至于变成一坨长字符串。4. 攻关三展位信息数组化一对多字段就该拆开4.1 原始展位信息的三种形态展位信息是这个项目里最 dirty 的字段之一。不同参展商的数据格式五花八门我归纳下来大概有三种形态第一种是标准数组比如booths: [Hall 1 Stand 12, Hall 2 Stand 45]。这种最好处理遍历数组即可。第二种是单字符串比如Hall 1 Stand 12, Hall 2 Stand 45或Hall 1 Stand 12 | Hall 2 Stand 45。需要用分隔符切分。第三种是“半结构化”比如Hall 1, Stand 12或Hall 1- Stand 12。如果直接按逗号切分会把“Hall 1”和“Stand 12”拆成两个独立展位造成数据错乱。我的处理思路是先把所有展位信息统一转成数组再从每个数组项里提取“展馆”和“展位号”两个字段最终生成结构化的展位记录。4.2 解析成结构化数组我写了一个parse_booths函数核心逻辑分成两步第一步把各种形态的输入统一转成数组第二步用正则提取展馆和展位号。import re def parse_booths(value): if isinstance(value, list): raw_items value elif isinstance(value, str): # 兼容逗号、中文逗号、分号、竖线、多个空格等分隔符 raw_items re.split(r[,;、|]|\s{2,}, value) else: return [] booths [] for raw in raw_items: raw raw.strip() if not raw: continue # 匹配 Hall 1 Stand 12、Hall 1- Stand 12、Pavilion B Booth 2C45 等格式 m re.search( r(?:Hall|Pavilion|Zone|Bay)?[:\s]*([A-Za-z0-9-]) r[\s\-:/] r(?:Stand|Booth|No\.?)?[\s:]*([A-Za-z0-9.\-]), raw, re.IGNORECASE ) if m: booths.append({ hall: m.group(1), booth_no: m.group(2), }) else: # 实在匹配不到就保留原始字符串方便人工复查 booths.append({hall: , booth_no: raw}) return booths这个正则虽然不能覆盖所有情况但它能处理我在实际数据里见到的 90% 格式。剩余那些不规则数据我会保留原始字符串放进一个单独字段后面人工核对总比静默丢失强。4.3 为什么要为此单独建表参展商和展位是典型的一对多关系。一个参展商可能同时出现在 Hall 1 的 Stand 12 和 Hall 2 的 Stand 45。如果直接把所有展位塞到参展商表的同一个字段里比如12,45后面做筛选、统计、透视表时都会很痛苦。我最终建了两张表一张exhibitors存公司基本信息一张booths存展位记录两张表用exhibitor_id关联。这样我可以在 BI 工具里直接按“展馆”筛选统计每个展馆有多少参展商也可以按“展位号”反查公司。和挤在一个单元格里相比这种数组化拆分是后期能高效分析的前提。5. 攻关四分页参数固定化不是设备问题是参数错位5.1 前两页正常、第三页重复的排查过程分页这个问题我最开始完全没意识到。按照常规思路我写了一个循环for page in range(1, 6): payload { page: page, pageSize: 20, offset: 0, } resp session.post(API_URL, jsonpayload, timeout15) records resp.json()[data][records] print(page, len(records), records[0][companyName])结果前两页看起来正常到第三页时数据开始和第二页重复第四页又回到第一页。我一度以为是服务器缓存问题或者自己请求太频繁被限流。后来在 DevTools 里手动点击“下一页”仔细看接口请求的 payload才发现了问题前端不管翻到第几页传给接口的分页参数都是page1真正变化的是offset字段。也就是说列表页路由上的页码只是给用户看的后端分页不认这个page参数。如果我只递增page后端始终当成第一页处理数据当然不翻篇。5.2 真正的分页驱动参数是 offset这类设计在前后端分离的站点里很常见前端路由负责展示路径接口参数负责真实数据切片。分页参数看起来“固定化”了实际上是前端把page固定成 1用offset来告诉后端“从第几条开始取”。搞清楚这点后翻页逻辑就很简单了page永远传 1offset从 0 开始每次加上pageSize。我用响应里的total算出总页数然后循环拉取。first_data session.post(API_URL, json{page: 1, pageSize: 20, offset: 0, sort: id asc}).json()[data] total first_data[total] page_size 20 offset_count (total page_size - 1) // page_size all_records [] for index in range(offset_count): offset index * page_size payload { page: 1, pageSize: page_size, offset: offset, sort: id asc, } resp session.post(API_URL, jsonpayload, timeout15) records resp.json()[data][records] all_records.extend(records) time.sleep(0.3)5.3 用 total 计算翻页次数并保持排序稳定这里有两个容易忽略的细节。第一total必须从第一页响应里拿不要自己在循环里累计。第二请求参数里最好固定排序字段比如sort: id asc。如果不固定排序页面之间数据可能因为后端默认排序不稳定而出现重复或遗漏尤其是抓取过程中有新的参展商入库时。如果后端特别不稳定分页还可能遇到“数据已删除导致某条数据被跳过”的问题。这个时候更稳妥的方案是改用游标翻页每页记录最后一个 id下一页请求把它作为起始条件。不过那套方案要看后端是否支持我当时用的offset 固定排序已经足够稳定。请求频率也需要注意。我给每个请求之间加了 0.3 秒等待并且做了失败重试连续失败 3 次就停 5 秒再继续。爬虫不是越快越好稳定地把数据拿完才是目标。6. 四道关卡串起来完整爬虫流水线与实测结果6.1 整体代码骨架把四道关卡的解法拼起来最终爬虫的骨架大概是这样的def fetch_page(offset, page_size20): payload { page: 1, pageSize: page_size, offset: offset, sort: id asc, } for attempt in range(3): try: resp session.post(API_URL, jsonpayload, timeout15) resp.raise_for_status() return resp.json()[data] except Exception: time.sleep(1.5 ** attempt) return None def run(): first fetch_page(0) total first[total] page_count (total 19) // 20 all_exhibitors [] all_booths [] for idx in range(page_count): data fetch_page(idx * 20) if not data: continue for item in data[records]: exhibitor_id item[id] clean_desc clean_html_to_text(item.get(description, )) exhibitors_row { id: exhibitor_id, company_name: item[companyName], category: item.get(category, ), country: item.get(country, ), description: clean_desc, } all_exhibitors.append(exhibitors_row) for booth in parse_booths(item.get(boothInfo, )): all_booths.append({ exhibitor_id: exhibitor_id, hall: booth[hall], booth_no: booth[booth_no], }) time.sleep(0.3) # 写入 SQLite / Excel / JSONL按需选择这个流程没有用任何数据库框架也没有复杂调度。核心就是四个步骤固定分页参数循环请求 → JSON 反序列化 → 清洗 HTML → 展位数组化。每一页拿到数据后立即处理并追加到内存列表全部完成后一次性落库。6.2 实测数据与性能这次项目最终的实测数据如下指标结果参展商总数3271 家展位关系记录数4128 条平均每家参展商的展位数1.26 个实际请求次数165 次1 次获取总数 164 次拉数据单页数据量20 条净耗时约 31 分钟因为单页只有 20 条数据总页数到了 164 页受限于 0.3 秒间隔整体耗时在半小时左右。如果你不需要太频繁地更新这个节奏完全够用。如果想把速度提上来可以在确认接口稳定后把间隔降到 0.1 秒但我不建议无脑并发容易被限流甚至封 IP。7. 换成其他展会网站这套经验能复用多少7.1 通用排查清单做完这个项目后我复盘了一下发现这套经验可以抽象成一张通用排查清单以后遇到类似的 SPA 目录站都能直接套用看到div idapp/div空壳页面不要急着上浏览器自动化先开 DevTools 的 Network 面板把 Fetch/XHR 请求翻一遍找到真正返回数据的接口。拿到接口后先看返回 JSON 的结构确认total、records、pageSize这些字段。没有total的接口优先找hasNext、totalPages或nextCursor。字段里有 HTML 标签时按“JSON 解码 → HTML 实体解码 → 剥标签 → 整理空白”的顺序清洗。遇到一对多字段比如展位、产品分类、电话列表一律拆分到独立表或独立数组不要塞在一个字符串里。翻页时不要只盯着page参数要确认后端真正依赖的分页参数是什么可能是offset、cursor也可能是lastId。固定排序字段是防止翻页重复和遗漏的土办法但很有效。7.2 爬虫的节奏与边界有几次踩坑后我也意识到爬虫要控制节奏不能为了速度牺牲稳定性。这个项目里我刻意把请求间隔控制在 300 毫秒虽然慢了一点但 165 个请求全部成功没有触发任何限流。如果一上来就开 20 个线程并发怼接口大概率中间就会断。另外抓下来的数据一定要留原始 JSON 备份。我每成功请求一页就追加一行到本地 JSONL 文件。这样即使中途程序崩了也能从断点继续不用重新跑全量。实践下来这比在内存里攒一堆列表再统一落库要稳妥得多。如果你也准备抓类似的展会目录类站点先从最后一个“下一页”的 XHR 请求开始看准参数比先写一长串解析规则更省时间。很多看似“高技术含量”的站点难点其实不在反爬而在数据结构不够直白把数据结构摸清了问题就解决了一大半。
返回列表