
最近在做一个小工具想把公开的疫情辟谣信息归集起来做关键词检索方便身边一些长辈快速核对消息真伪。目标页面打开后条目列表清清楚楚标题、来源、发布时间、摘要全都有我当时心想这还不简单直接requests.get()拿回 HTML 解析就行。结果源码一下来嗅到了一个熟悉的问题——整个页面里连一条辟谣标题都搜不到HTML 就像个空壳。这种“页面能看到、源码里找不到”的情况就是典型的动态数据加载。页面上的内容并不是服务器直接写在 HTML 里的而是浏览器加载完框架页后额外发起请求从一个接口拿到 JSON 数据再动态渲染出来的。而这篇文章要解决的正是围绕这类页面的爬虫实战问题怎么定位那个数据接口、怎么构造请求、怎么解析接口返回的“{}类型”也就是 JSON 对象数据以及最后怎么把结果落成可用的表格。内容偏实战适合已经会用requests和正则、但被动态加载卡住的朋友。1. 目标拆解为什么页面有数据源码里却什么都搜不到1.1 前后端分离数据不在 HTML 里的根本原因早期很多网站是服务端渲染用户在浏览器里看到的 HTML 是服务器拼好之后一次性返回的爬虫用requests拿回来就能直接提取内容。但现在很多信息发布类页面采用的是前后端分离架构后端只负责提供数据接口前端代码通过 JavaScript 在浏览器里异步请求接口再把数据渲染到页面上。疫情辟谣专区这类页面在技术上通常就是一个列表页 一个 JSON 接口的组合。后端返回的 HTML 只包含页面骨架、样式引用和脚本文件辟谣条目的真实内容全部藏在接口响应里。所以如果我们只盯着 HTML 源码当然什么都搜不到。理解这一点之后爬虫思路就要从“解析 HTML”切换到“找接口、请求接口、解析 JSON”。1.2 三种常见的动态加载形式动态加载在实战中常表现为三种形式。第一种是 XHR / Fetch 异步请求页面加载后自动向后端要数据这是最常见的形式。第二种是“加载更多”或“下一页”按钮触发的分页请求点击按钮后浏览器发起新请求并追加渲染数据辟谣专区大多属于这种。第三种是滚动懒加载页面滚动到底部才触发请求常见于资讯类信息流。无论是哪种形式它们共同的特点是数据通过 HTTP 接口返回响应体几乎都是 JSON 格式。所以我们做爬虫的关键动作就变成了两条——先找到那个接口再解析接口返回的 JSON 数据。这也是标题里“动态数据加载解析{}类型”最核心的含义。1.3 “{}类型”到底是什么标题里的{}指的就是 JSON 对象。JSON 的完整名称是 JavaScript Object Notation它的对象结构用花括号包裹内部是一组组键值对比如{ code: 200, message: success, data: { list: [ { id: 1001, title: 示例辟谣标题, publish_time: 2023-01-01 10:00:00 } ], total: 1 } }JSON 对象在 Python 里用json模块解析之后会变成字典dict所以不少开发者习惯调侃说“解析{}类型”说的其实就是解析 JSON 对象并从中取数据的过程。理解了这一点后面所有代码就都围绕着一个核心问题展开怎么安全、准确地把data.list里的每一条辟谣信息取出来。2. 浏览器抓包锁定 JSON 接口的完整操作路径2.1 Network 面板从“加载更多”按钮顺藤摸瓜我用的是 Chrome 的开发者工具这套方法在 Edge、Firefox 里也通用。操作路径很直观打开目标页面按 F12 进入开发者工具切到Network面板。在筛选栏勾选Fetch/XHR这样面板里只会显示异步请求过滤掉图片、CSS、字体等静态资源。点击页面上的“加载更多”或翻页按钮观察面板里新增的请求。逐个点开新增请求重点看Preview或Response标签页凡是预览里能看到辟谣条目数据的就是我们要找的接口。实际操作时我一般会先把页面滚动到最底部然后点击“加载更多”按钮让面板同时出现两到三个新请求再去甄别哪一个才是真实数据源。有的站点会同时发送埋点统计请求、用户行为请求、数据请求它们的响应体一眼就能分辨出来——数据请求的 Preview 里一定能看到与页面上对应的列表内容。2.2 对照页面字段确认接口返回的 JSON 结构这一步是整个抓包过程里最需要耐心的环节。找到候选接口后先在 Preview 里展开 JSON然后和页面上显示的辟谣条目逐项对照页面上的标题对应 JSON 里的哪个字段来源对应哪个字段发布时间又对应哪个字段。我建议用文字记录下字段映射关系。比如页面上“发布时间”在接口里叫publish_time页面上“摘要”在接口里叫summary。别小看这个对照过程后面写解析代码时全靠这份映射。字段名字的大小写、下划线分隔还是驼峰命名都会直接影响代码取值。另外要留意有些接口会把列表数据包在两层或三层嵌套里比如data.list或result.records外层还有code、message等状态字段。把这些层次关系也记下来后面取值时思路就非常清晰。2.3 把接口要素记录成一张请求清单抓包不是光看一眼就完事我习惯把接口的关键信息整理成一张表后续写代码时直接照着抄。下面是一份示例地址和参数我都做了虚构替换真实项目里换成你自己抓包得到的值即可项值请求方式GET请求地址https://example.com/api/covid/rumor/list请求参数page1page_size10请求头User-Agent: Mozilla/5.0 ... Chrome/120.0Referer: https://example.com/rumorX-Requested-With: XMLHttpRequest响应格式JSON外层含code、message、data这份清单里请求地址和参数决定了能不能拿到数据请求头则决定了服务器怎么看待你的请求。接下来就进入请求构造环节。3. 请求头与参数构造让服务器把你当成正常访客3.1 必备请求头与各自的作用很多新手写爬虫只写一个User-Agent就发请求遇到接口返回 403 或空数据时完全摸不着头脑。实际上动态数据接口通常需要完整的请求头才能正常返回。我用到的核心请求头有三个User-Agent标识浏览器类型和版本。服务器靠它判断访问者是不是真实浏览器最省事的办法就是直接复制自己浏览器里的 UA。Referer告诉服务器“我是从哪个页面跳转过来的”。很多后端接口会校验 Referer 是否来自自身站点不带它容易触发防盗链逻辑。X-Requested-With值为XMLHttpRequest时表示当前请求是一个 Ajax 请求部分后端会据此返回 JSON 而不是 HTML 页面。这三个字段组合起来基本能让服务器把脚本请求当成普通访客。构造请求的代码长这样import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://example.com/rumor, X-Requested-With: XMLHttpRequest, Accept: application/json, text/plain, */* } API_URL https://example.com/api/covid/rumor/list params { page: 1, page_size: 10 } resp requests.get(API_URL, headersHEADERS, paramsparams, timeout10) print(resp.status_code) print(resp.text[:500])注意代码里我给requests.get加了timeout10这是个好习惯能防止请求卡住导致程序假死。设置几秒超时连接失败时快速报错比干等着强太多。3.2 参数里page与page_size的意义动态加载列表页普遍采用分页参数控制数据批次。page表示页码从 1 开始递增page_size表示每页返回多少条通常由页面写死比如 10 或 20。我们点击“加载更多”时浏览器实际上就是发了一次page2或page3的请求。个别站点不用page而是用offset偏移量比如第二页offset10、第三页offset20。这两种分页方式的逻辑都能理解关键是要在抓包时对比两个相邻请求的参数差异看清楚page变了还是offset变了。这一步不做扎实后面的翻页循环就会写错。3.3 用 Session 保持会话避免 Cookie 失效有些接口虽然第一次请求能直接返回数据但多翻几页之后突然返回空内容或者状态码变成 401这大概率是登录态缺失或 Cookie 失效。遇到这种情况我建议改用requests.Session()它会自动保存服务端返回的 Cookie并在后续请求中自动带上。session requests.Session() resp session.get(API_URL, headersHEADERS, paramsparams, timeout10)Session 在爬虫里的作用就像是浏览器保持登录状态一样只要第一次请求成功后面翻页请求都会携带同样的身份标识。如果接口做了强鉴权比如需要登录后才能访问那还得先从浏览器里复制一份 Cookie 放到请求头里或者先用 Session 模拟一次登录流程。辟谣专区这类公开信息页面通常不用登录但保留这个思路遇到带鉴权的页面时不至于卡住。4. 解析 {} 类型响应从 JSON 对象里安全取值4.1resp.json()与json.loads()的选择拿到接口响应文本后解析 JSON 的路径有两条一条是直接用resp.json()这是requests库内置的方法另一条是先取resp.text再调用json.loads()。两者本质没有区别resp.json()底层调用的就是json.loads()。我习惯直接用resp.json()省一行代码。但要注意如果接口返回的Content-Type不是标准 JSON或者返回内容里夹杂了额外字符resp.json()偶尔会解析失败。更稳妥的写法是先打印resp.text[:500]确认响应体格式再决定用哪种方式解析。另外有些站点会返回 JSONP 格式比如callback({...})这种不能直接解析需要先把外层函数调用去掉或者用正则把括号里的 JSON 片段提取出来。4.2 剥洋葱式取值.get()在手健壮性我有解析 JSON 时最容易出的问题不是语法错误而是字段取值时对结构不熟悉导致的报错。比如直接写resp.json()[data][list][0][title]如果某个中间字段缺失或者值为null程序立刻抛TypeError或KeyError。我的习惯是“剥洋葱式”取值先判断最外层状态再逐层向下取。代码模板如下data resp.json() if data.get(code) ! 200: print(接口返回异常, data.get(message)) return items (data.get(data) or {}).get(list) or [] for item in items: title item.get(title, ) publish_time item.get(publish_time, ) source item.get(source, )这里的关键是使用.get()方法而不是直接[]取下标。.get()在字段不存在时返回默认值对于内容型接口非常适合因为不同条目可能缺不同字段缺了顶多显示空字符串程序不会崩。(data.get(data) or {})这种写法则防止了data字段为None时继续往下取值的风险。4.3 翻页循环什么时候该停知道了page和page_size之后翻页循环的框架很简单从第 1 页开始每次页码加 1持续请求直到没有数据为止。有的接口会返回总页数total_pages那可以直接按已知页数循环如果接口不返回就用“当前页数据为空则停止”的判断条件。我倾向于用后一种因为它的适应性更强即使接口改动导致总页数计算逻辑变化也不会影响程序正常退出。一个典型的翻页解析片段page 1 all_items [] while True: params {page: page, page_size: 10} resp session.get(API_URL, headersHEADERS, paramsparams, timeout10) data resp.json() items (data.get(data) or {}).get(list) or [] if not items: break all_items.extend(items) page 1注意跳出循环的判断我用的是if not items它同时覆盖了两种情况接口返回空列表或者接口返回异常导致列表为None。4.4 JSON 里的null到 Python 变成None这是一个特别容易被新手忽略的细节。JSON 中的空值写作null解析到 Python 后就变成了None。如果你把某个字段直接拼进字符串里结果可能是来源None这种半截数据看起来很奇怪。处理办法很简单凡是可能为空的字段统一用.get(key, )给出默认值再做一次空值判断source item.get(source) or item.get(source)返回None时or 会把它替换成空字符串这样后续拼接、写入都不容易出错。这个小习惯在内容型接口上特别管用。5. 数据落盘与去重把接口内容变成可复用的 CSV 表格5.1 CSV 字段设计与 UTF-8-sig 编码接口数据解析出来后下一步就是存储。如果只是想快速查看结果打印到控制台就够了但如果要长期积累数据做分析CSV 是最轻量好用的落盘方式。设计 CSV 的字段时要结合业务场景。辟谣信息通常关注唯一 ID、标题、来源、发布时间、摘要、原文链接。字段不要贪多够用即可后续处理起来也更方便。写入时有一个坑必须提醒用 Excel 打开 CSV 时中文容易乱码。解决方案是写文件时指定encodingutf-8-sig这个编码会在文件开头加一个 BOM 标记Excel 能正确识别。如果只用默认的utf-8Excel 大概率会把中文显示成乱码。写入代码import csv import os OUTPUT_FILE rumor_list.csv file_exists os.path.isfile(OUTPUT_FILE) with open(OUTPUT_FILE, a, newline, encodingutf-8-sig) as f: writer csv.writer(f) if not file_exists: writer.writerow([id, title, source, publish_time, summary]) for item in items: writer.writerow([ item.get(id, ), item.get(title, ), item.get(source, ), item.get(publish_time, ), item.get(summary, ) ])newline是为了防止写入时出现空行这也是 Python 写 CSV 的标准配置。5.2 去重靠 ID 靠标题都行翻页抓取时重复数据几乎是必然出现的。有些接口的列表里会混入置顶条目不同页重复出现有些接口在数据更新时会把旧数据又重新返回一遍。去重逻辑必须在爬虫里内置。最简单的去重方案是维护一个seen集合把已处理条目的唯一标识加进去。如果接口提供 ID 字段就用 ID 去重如果没有 ID就用标题去重。实际操作中标题重复的情况其实很少用标题做键基本可靠。seen set() for item in items: key item.get(id) or item.get(title) if key not in seen: seen.add(key) # 继续处理并写入这一步花不了几行代码但能让最终数据质量提升一个档次。5.3 断点续爬与中途落盘如果目标页面的数据量很大比如几千条一次性全抓完再写入会有风险中途网络抖动一次前面所有进度全丢。我的做法是每抓完一页就落盘一次边抓边存这样即使程序中断已经写进去的数据也不会丢失。更进一步还可以把当前进度保存下来。我习惯在程序里维护一个last_page变量每成功抓完一页就把它更新到一个本地记录文件里。下次启动时读取这个记录从停下的页码继续。对于需要持续更新的信息页面这种做法非常实用——第一次抓历史全量后续每天跑增量只需要从上次结束的页码继续即可。当然增量场景更优雅的做法是通过发布时间过滤只抓取最近一天的新增条目。这个视接口是否支持按时间过滤而定如果接口不支持断点续爬就是最可靠的兜底方案。6. 实测避雷翻页参数、签名、限流和字段改版的坑6.1 “加载更多”和“下一页”参数经常不是同一套逻辑我在这个项目里踩到的第一个坑就是翻页逻辑。页面上有一个“加载更多”按钮点击后抓包发现请求参数确实从page1变成了page2看起来很正常。但当我准备写循环时又把页面底部的“下一页”按钮点了一下发现它发起的请求参数是offset10。也就是说同一个列表页面内部有两种翻页入口两套参数模型。接口可能同时支持两种模式也可能只支持其中一种。我当时如果只看“加载更多”就开写之后换到真实场景大概率会错位。务必多试几个入口对比参数差异再确定循环里用哪套参数。6.2 带签名的接口请求过期问题另一个坑是签名参数。目标接口的请求 URL 里带了一个_sign字段一开始我把浏览器里复制出来的完整 URL 直接拿来请求居然能返回数据就天真地以为这个签名是固定的。结果过了一个多小时再跑突然全部返回空内容状态码倒是 200。后来检查发现_sign是根据当前时间戳加一段固定密钥生成的有效期很短。浏览器的请求之所以能成功是因为我复制 URL 时它刚生成。解决办法是看前端 JavaScript 里的签名逻辑用 Python 重新实现一遍。这里不展开加密算法的具体内容只强调一个排查思路当请求头、参数都对但请求结果不稳定时优先怀疑签名是否过期。6.3 限流与封禁的应对策略爬虫和服务器之间也是讲“礼仪”的。请求频率太快哪怕目标页面是公开信息也可能触发限流。常见的表现是请求几次之后突然返回 403或者毫无规律地返回 429Too Many Requests。我的实测经验是抓这种公开信息类列表页单线程加随机延时完全够用。每次翻页之间用time.sleep(random.uniform(1, 2))把请求节奏控制在 1 到 3 秒一次基本不会触发限流。千万不要一上来就开多线程并发大多数封禁案例都源于脚本里没有任何间隔。如果已经触发了限流正确做法是降低频率或者暂停一段时间再继续。重试时也要讲究策略连续失败三次就停一下别用暴力重试继续给服务器制造压力。6.4 字段改版接口悄悄改名你都不知道内容平台的接口字段并不是一成不变的。跑了一段时间之后我发现抓下来的数据突然全是空标题排查了半天才发现接口里原来的publish_time字段改叫create_time了。因为代码里一直用.get(publish_time, )字段缺失时静默返回空串程序不报错很容易让人误以为接口正常。这提醒我建立一个数据体检习惯比如每次跑完后抽查最新一条数据的发布时间如果距离当前日期太远或者关键字段为空率异常升高那就很有可能是接口字段变了。另外解析代码里对关键字段要少用静默默认值可以用日志记录字段缺失情况方便尽早发现问题。6.5 贴一份可以直接改的完整脚本把前面所有内容整合起来给出一份相对完整的脚本框架。地址、请求头等请按自己抓包得到的信息替换import csv import os import random import time import requests API_URL https://example.com/api/covid/rumor/list OUTPUT_FILE rumor_list.csv PAGE_SIZE 10 HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://example.com/rumor, X-Requested-With: XMLHttpRequest, Accept: application/json, text/plain, */* } def fetch_page(session, page): params {page: page, page_size: PAGE_SIZE} for attempt in range(3): try: resp session.get(API_URL, headersHEADERS, paramsparams, timeout10) if resp.status_code 200: return resp.json() except requests.RequestException as e: print(f第{page}页第{attempt 1}次请求失败{e}) time.sleep(2) return None def parse_items(data): if not data or data.get(code) ! 200: return [] return (data.get(data) or {}).get(list) or [] def save_items(items): file_exists os.path.isfile(OUTPUT_FILE) with open(OUTPUT_FILE, a, newline, encodingutf-8-sig) as f: writer csv.writer(f) if not file_exists: writer.writerow([id, title, source, publish_time, summary]) for item in items: writer.writerow([ item.get(id, ), item.get(title, ), item.get(source, ), item.get(publish_time, ), item.get(summary, ) ]) def main(): session requests.Session() page 1 seen set() while True: data fetch_page(session, page) items parse_items(data) if not items: print(f第{page}页无数据停止抓取) break new_items [] for item in items: key item.get(id) or item.get(title) if key not in seen: seen.add(key) new_items.append(item) if new_items: save_items(new_items) print(f第{page}页完成新增 {len(new_items)} 条) page 1 time.sleep(random.uniform(1, 2)) if __name__ __main__: main()这个脚本已经足够支撑一个中小型信息列表页的全量抓取需求。如果你要抓的页面结构更复杂比如登录后才能访问、或者接口带动态签名在它的基础上再加鉴权和签名逻辑即可。做完这个实战项目我再遇到“页面有数据、源码找不到”的页面就完全不慌了。核心思路其实反反复复就是那几步打开开发者工具抓包、找到 JSON 接口、构造带齐请求头的请求、用.get()安全取值、边抓边存。动态数据加载表面上看比静态页面难但只要把数据链路的逻辑摸透它反而比解析一堆嵌套div更直接——毕竟 JSON 结构再复杂也没有 HTML 标签层级复杂。最后再分享一个小技巧把抓包得到的接口地址保留在笔记里下次页面改版时快速对比接口变化能帮你省下大量重新定位的时间。