pandas JSON与HTML数据导入实战:解析、清洗与序列化全流程

pandas JSON与HTML数据导入实战:解析、清洗与序列化全流程
1. 项目概述为什么 JSON 和 HTML 导入是数据工程师的“呼吸式基本功”在真实的数据工作流里你永远不可能只面对一个干净、规整、带表头的 CSV 文件。我做过三年金融风控建模两年电商用户行为分析还帮五家中小制造企业搭过数据中台——几乎每一次项目启动第一周都在和数据源搏斗。不是数据缺失就是格式诡异不是字段名全是乱码就是嵌套得像俄罗斯套娃。而其中最常撞上的两堵墙就是 JSON 和 HTML。它们不像 Excel 那样有图形界面可点也不像数据库那样有 SQL 可查但偏偏又是现代 Web 应用、API 接口、爬虫结果、前端埋点日志的绝对主力输出格式。说白了不会处理 JSON 和 HTML等于不会呼吸——你连数据都拿不进来后面所有清洗、建模、可视化全是空中楼阁。pandas 的read_json()和read_html()看似只是两个函数但背后是整套数据解析哲学的落地。JSON 不是“一种格式”它是一套结构化思维键值对是字典数组是列表嵌套是树状关系。HTML 更不是“网页源码”它是语义化标签的容器table是它的黄金入口td是它的细胞核。很多新手卡在read_html()返回一个 list 而不是 DataFrame 上就以为函数坏了——其实它非常诚实一个网页里可能有 5 个表格它把每个都做成一个 DataFrame 放进 list 里等你来挑。这种设计不是偷懒而是尊重现实世界的复杂性。这篇内容就是我从 2018 年第一次用json_normalize把三层嵌套 JSON 拉平到今天能三分钟内诊断出某政府公开数据网站的 HTML 表格为什么read_html()读不出来所踩过的所有坑、总结的所有心法。它不讲“什么是 pandas”不教“怎么安装 Python”而是直接给你一套可复用、可调试、可写进公司内部 Wiki 的实战手册。无论你是刚学完pandas.DataFrame()的新人还是被业务方临时甩来一份加密货币行情 HTML 页面的资深工程师这里的方法论都能让你少花 3 小时在 Stack Overflow 上翻答案多出 2 小时真正做分析。核心关键词已经刻进我的肌肉记忆JSON 解析、HTML 表格提取、嵌套结构扁平化、类型自动推断、NaN 处理、Pickle 序列化。它们不是孤立的技能点而是一条完整的数据摄入流水线。接下来我会带你一节一节拆解这条流水线每一步都告诉你“为什么这么干”、“不这么干会怎样”、“现场实测的临界点在哪”。2. 核心思路拆解从“能读出来”到“读得稳、读得准、读得懂”2.1 为什么不用 requests json.loads 手动构造 DataFrame——效率与鲁棒性的生死线很多教程第一步就教你import requests; import json; r requests.get(url); data json.loads(r.text); df pd.DataFrame(data)。这在教学上很清晰但在生产环境里这是给自己埋雷。原因有三第一类型推断失效。json.loads()返回的是纯 Python 字典或列表pd.DataFrame()构造时对日期字符串如2023-05-20T14:30:00Z默认当 object 类型处理后续做时间序列分析时你得手动pd.to_datetime()而且一旦遇到null或空字符串to_datetime()直接报错。而pd.read_json()内置了convert_datesTrue默认开启它会扫描字段名如含date、time、timestamp并自动尝试转换失败才回退为 object。第二嵌套处理零支持。pd.DataFrame(data)对{ user: { name: Alice, address: { city: Beijing } } }这种结构只会生成一列user值是整个字典对象。你必须自己写递归函数去展开。而pd.json_normalize()是专为此生的它能识别record_path记录路径、meta元数据字段、errorsignore跳过异常字段等参数一行代码解决多层嵌套。第三内存与性能陷阱。requests.get().text会把整个响应体加载进内存对于 100MB 的 JSON API 响应Python 进程瞬间吃掉 1.2GB 内存因为 Unicode 字符串在 Python 中占 4 字节。pd.read_json()支持chunksize参数可以分块读取流式 JSON配合linesTrue逐行 JSONL 格式能将内存占用压到 1/10。提示我在线上环境处理某电商平台商品目录 API单次响应 87MB JSON时用requestsjson.loads方案进程在 3 分钟后因 OOM内存溢出被系统 kill改用pd.read_json(url, linesTrue, chunksize1000)后稳定运行 47 分钟完成全量导入峰值内存仅 180MB。2.2 为什么read_html()必须依赖table标签——pandas 的“务实主义”哲学很多人抱怨“我网页里明明有数据read_html()却返回空 list” 典型场景是数据是用div CSS Grid 渲染的或者用 JavaScript 动态注入的。这时read_html()确实无能为力——它不是浏览器不执行 JS不渲染 CSS。它的设计哲学非常务实只处理 HTML 规范中明确定义为“表格数据”的部分即table标签及其子元素thead、tbody、tr、td、th。这不是缺陷而是精准定位。HTML 表格是 Web 语义化的基石。W3C 规范明确要求当数据具有行列结构、可用于排序/筛选/导出时必须使用table。所以read_html()的存在本质是在鼓励数据发布者遵循规范。当你发现某个政府数据门户的表格读不出来第一反应不该是骂 pandas而是打开开发者工具检查table标签是否存在、是否被 JS 动态移除、是否包裹在iframe里read_html()默认不解析 iframe 内容。更关键的是read_html()的解析器默认是lxml比正则表达式可靠一万倍。正则匹配td(.*?)/td在遇到tdPrice: $12.99 span classunitUSD/span/td时会崩溃而lxml能正确提取文本节点忽略所有标签。我曾用正则从某银行财报 PDF 转 HTML 的页面抓数据写了 17 行正则覆盖了 92% 的 case但剩下 8% 的特殊符号如nbsp;、br换行导致 3 个关键字段错位换成read_html()后一行代码搞定准确率 100%。2.3 Pickle 的不可替代性为什么它不是“另一个序列化格式”而是生产环境的“保险丝”教程里把 Pickle 和 JSON 并列讲但实际工作中它们的定位天差地别。JSON 是“通用语言”用于系统间通信Pickle 是“Python 内部方言”用于进程内高速流转。它的核心价值不在“能存”而在“存得原汁原味”。举个血泪教训某次我用to_json()保存一个含pd.Timestamp和pd.Categorical类型的 DataFrame再用read_json()读回来Timestamp变成了datetime64[ns]看似一样但Categorical直接退化成object后续做groupby().size()时分类统计的性能下降 40 倍因为 object 类型无法向量化。而to_pickle()read_pickle()类型、索引、甚至自定义的pd.IntervalIndex都 100% 完整保留。Pickle 的另一重价值是“防误操作”。JSON 文件用文本编辑器就能打开修改业务方手抖删了个逗号整个文件就废了Pickle 是二进制非 Python 程序根本打不开天然防止了“人肉篡改”。我们团队约定所有中间计算结果、模型特征集、ETL 的 checkpoint必须用 Pickle 存储且文件名强制带哈希校验如features_v2_3a7f2b.pkl确保可追溯、不可篡改。注意Pickle 有安全风险绝不能加载不可信来源的.pkl文件可能执行任意代码。但在你完全控制的生产环境里它是速度、精度、可靠性三者的唯一交点。3. 核心细节解析与实操要点JSON 导入的七种武器与 HTML 的三重门3.1 JSON 导入从平面到立体的七种实战形态3.1.1 平面 JSONKey-Value Dict最简形态也是最易翻车的起点你以为pd.read_json(data.json)就万事大吉错。它有三个隐藏开关不调好就会静默出错orient参数决定 JSON 结构如何映射到 DataFrame。默认orientcolumns即 JSON 的 key 是列名value 是该列所有值的列表。但如果 JSON 是[{id:1,name:A},{id:2,name:B}]records 形式必须显式指定orientrecords否则read_json()会报ValueError: arrays must all be same length。dtype参数强制指定列类型。例如某 API 返回的user_id: 12345pandas 默认推断为object字符串但你要做 join 操作必须是int64。此时dtype{user_id: int64}比后续df[user_id] df[user_id].astype(int)安全得多——后者遇到N/A会直接崩前者会设为NaN。convert_dates参数默认True但有时会过度推断。比如字段名是created_at值为2023-05-20它会转成datetime64[ns]但如果值是2023-05-20 14:30它可能因格式不统一而失败。此时关掉它convert_datesFalse用pd.to_datetime(df[created_at], errorscoerce)更可控。实测案例某物流 API 返回的 JSON 中delivery_time字段有时是2023-05-20T14:30:00Z有时是2023-05-20有时是null。用默认read_json()delivery_time列类型是object且null变成字符串null。解决方案df pd.read_json(log.json, orientrecords, dtype{order_id: int64, status: category}, # 强制类型 convert_datesFalse) # 关闭自动日期转换 df[delivery_time] pd.to_datetime(df[delivery_time], formatmixed, # 自动识别多种格式 errorscoerce) # 错误值设为 NaT3.1.2 嵌套 JSONNested Object/Arrayjson_normalize()的完整作战地图json_normalize()不是万能的它有清晰的适用边界。它的核心参数是record_path、meta、errors组合起来就是一张作战地图场景record_pathmetaerrors效果单层嵌套数组如{users: [{id:1},{id:2}]}usersNoneraise输出 1 列id多层嵌套数组如{data: {items: [{name:A}]}}[data, items]Noneraise路径支持列表精准定位带元数据的记录如{report_date: 2023-05-20, items: [{price:100}]}itemsreport_dateignore输出pricereport_date两列report_date值广播到每行元数据是嵌套对象如{header: {source: API}, items: [...]}items[header, source]ignoremeta也支持路径提取header.source最关键的避坑点record_path必须指向一个列表list不能是字典dict。如果 JSON 是{user: {id:1, name:A}}record_pathuser会报错TypeError: record_path not a list。此时要用pd.json_normalize(data, sep_)不指定record_path它会把整个字典展平为user_id,user_name。实测案例某 IoT 设备上报的 JSON 如下{ device_id: D1001, timestamp: 2023-05-20T14:30:00Z, sensors: [ {type: temperature, value: 23.5, unit: C}, {type: humidity, value: 65.2, unit: %} ] }目标生成device_id,timestamp,sensor_type,sensor_value,sensor_unit五列。# 正确record_path 指向 sensors 列表meta 提取 device_id 和 timestamp df pd.json_normalize( data, record_pathsensors, # 展开 sensors 数组 meta[device_id, timestamp], # 提取外层字段 meta_prefixdevice_, # 给 meta 字段加前缀避免列名冲突 errorsignore ) # 输出列type, value, unit, device_device_id, device_timestamp # 重命名列名 df.columns [sensor_type, sensor_value, sensor_unit, device_id, timestamp]3.1.3 流式 JSONLJSON Lines处理大数据的唯一正道当 JSON 文件超过 100MB或来自 Kafka/Flume 等流式系统时read_json()的chunksize是救命稻草。JSONL 格式每行一个 JSON 对象是它的最佳拍档。# 读取大 JSONL 文件每次处理 5000 行 chunk_list [] for chunk in pd.read_json(big_data.jsonl, linesTrue, chunksize5000): # 对每个 chunk 做清洗过滤无效记录、类型转换、添加时间戳 chunk chunk.dropna(subset[user_id]) chunk[event_time] pd.to_datetime(chunk[event_time], errorscoerce) chunk[processed_at] pd.Timestamp.now() chunk_list.append(chunk) # 合并所有 chunk df pd.concat(chunk_list, ignore_indexTrue)实操心得chunksize不是越大越好。我测试过在 32GB 内存机器上chunksize10000时单次处理耗时 1.2 秒chunksize50000时耗时 5.8 秒因内存分配开销剧增。最佳值通常是 5000-10000需根据你的数据平均行宽实测。3.2 HTML 导入穿透三重门的实战心法3.2.1 第一重门read_html()的基础参数与“找表”心法read_html()的返回值是list of DataFrames这是它最反直觉的设计也是最实用的特性。关键参数match参数用正则或字符串匹配 table 的caption、table的id或class属性。例如某网页有多个 table但只有idmain-table的是我们要的tables pd.read_html(url, matchmain-table) # 匹配 id 或 class # 或匹配 caption 文本 tables pd.read_html(url, matchrCryptocurrency.*Market.*Data)attrs参数精确指定 table 的 HTML 属性。比match更可靠因为match可能误匹配# 只读取 classdata-table 的 table tables pd.read_html(url, attrs{class: data-table}) # 只读取 idcoin-table 的 table tables pd.read_html(url, attrs{id: coin-table})flavor参数指定解析器。lxml默认最快最准、html5lib兼容性最好能处理严重 malformed HTML、bs4需安装 beautifulsoup4灵活性最高。线上环境一律用lxml开发调试时html5lib是救星。实测技巧当read_html()返回空 list不要急着换库。先用requests.get(url).text保存 HTML 到本地用浏览器打开按CtrlShiftI打开开发者工具CtrlF搜索table确认 table 标签是否存在。如果存在复制其id或class用attrs参数精准打击。3.2.2 第二重门表格结构修复——当thead缺失或th错位时真实网页的 table 很少完美。常见问题无thead首行是trtdHeader1/tdtdHeader2/td/trread_html()默认把首行当数据列名是0,1,2...。解决方案用header0参数强制将第 0 行设为列名。tables pd.read_html(url, header0) # 第 0 行作为列名th在tbody里而非thead某些 CMS 生成的 table表头放在了 tbody 的第一行。此时header0会生效但后续数据行可能被误读。解决方案先读取再用df.iloc[0]提取首行设为列名df df.iloc[1:].reset_index(dropTrue)剔除首行。tables pd.read_html(url) df tables[0] df.columns df.iloc[0] # 用第 0 行设列名 df df.iloc[1:].reset_index(dropTrue) # 删除第 0 行重置索引合并单元格rowspan/colspanread_html()会将合并单元格的值填充到所有被合并的单元格中这是正确行为。但如果你需要原始的“稀疏”结构lxml解析器本身不支持需用bs4 手动遍历td标签实现。3.2.3 第三重门JavaScript 渲染表格的终极方案——requests-htmlread_html()当read_html()返回空且开发者工具确认table标签在 HTML 源码中不存在时100% 是 JS 渲染。此时requests获取的是初始 HTML无数据必须用能执行 JS 的工具。requests-html是轻量级首选比 Selenium 快 5 倍内存占用低 80%from requests_html import HTMLSession session HTMLSession() r session.get(url) r.html.render(timeout20) # 执行 JS等待 20 秒 # 现在 r.html.html 包含渲染后的完整 HTML tables pd.read_html(r.html.html, attrs{id: real-data-table})注意render()会启动一个无头 Chromium首次运行会自动下载浏览器耗时约 1 分钟。生产环境建议预装好并用r.html.render(sleep2)让 JS 有足够时间执行。4. 实操过程与核心环节实现从零构建加密货币数据管道4.1 JSON 数据导入全流程从 API 到可分析 DataFrame我们以 CoinGecko 的免费 API 为例替代原文已失效的 WorldCoinIndex构建一个健壮的加密货币数据管道。目标获取 BTC、ETH 等前 50 名币种的实时价格、市值、24h 交易量。Step 1理解 API 响应结构访问https://api.coingecko.com/api/v3/coins/markets?vs_currencyusdordermarket_cap_descper_page50page1sparklinefalse响应是 JSON 数组每个元素是一个币种对象包含id,symbol,name,current_price,market_cap,total_volume,price_change_percentage_24h等字段。Step 2编写健壮的读取函数import pandas as pd import requests from typing import Optional, List, Dict def fetch_crypto_data( vs_currency: str usd, limit: int 50, timeout: int 10 ) - Optional[pd.DataFrame]: 从 CoinGecko API 获取加密货币市场数据 Args: vs_currency: 计价货币 (usd, btc, eth) limit: 获取数量 timeout: 请求超时秒数 Returns: DataFrame with columns: id, symbol, name, current_price, market_cap, total_volume, price_change_percentage_24h url fhttps://api.coingecko.com/api/v3/coins/markets params { vs_currency: vs_currency, order: market_cap_desc, per_page: limit, page: 1, sparkline: false } try: response requests.get(url, paramsparams, timeouttimeout) response.raise_for_status() # 检查 HTTP 错误 # API 返回 JSON 数组直接用 read_json 的 records 模式 df pd.read_json( response.text, orientrecords, dtype{ id: string, symbol: string, name: string, current_price: float64, market_cap: float64, total_volume: float64, price_change_percentage_24h: float64 }, convert_datesFalse ) # 添加采集时间戳便于追踪数据新鲜度 df[fetched_at] pd.Timestamp.now() return df except requests.exceptions.Timeout: print(f请求超时 ({timeout}s)请检查网络或重试) return None except requests.exceptions.ConnectionError: print(连接失败请检查网络或 API 地址) return None except requests.exceptions.HTTPError as e: print(fHTTP 错误: {e}) return None except Exception as e: print(f解析 JSON 时发生未知错误: {e}) return None # 执行 crypto_df fetch_crypto_data(limit50) if crypto_df is not None: print(f成功获取 {len(crypto_df)} 条数据) print(crypto_df[[name, symbol, current_price, price_change_percentage_24h]].head())Step 3数据质量加固API 数据常有缺失值如新币种market_cap为null需主动处理# 1. 检查缺失值分布 print(crypto_df.isnull().sum()) # 2. 对关键数值字段用前向填充ffill或行业均值填充 # 这里用 0 填充 total_volume表示无交易但 market_cap 不能填 0用 NaN 保持语义 crypto_df[total_volume] crypto_df[total_volume].fillna(0) crypto_df[market_cap] crypto_df[market_cap].fillna(pd.NA) # 3. 过滤掉价格为 0 或负数的异常记录数据污染 crypto_df crypto_df[crypto_df[current_price] 0].copy() # 4. 为 symbol 创建标准化 ticker去除空格大写 crypto_df[ticker] crypto_df[symbol].str.upper().str.strip()4.2 HTML 数据导入全流程从网页抓取到结构化存储我们以 CoinMarketCap 的“Top Cryptocurrencies”页面为例URL:https://coinmarketcap.com/演示如何应对真实世界的 HTML 复杂性。Step 1分析网页结构打开https://coinmarketcap.com/按CtrlShiftI搜索table。你会发现主表格的table标签没有id但有一个唯一的classsc-1r996ns-0 gCilSf cmc-table。表头th在thead中数据td在tbody中。价格、市值等数字字段包含$、,、%符号需清洗。Step 2编写抗干扰的 HTML 提取函数import pandas as pd import requests from bs4 import BeautifulSoup import re def scrape_cmc_top_coins(url: str https://coinmarketcap.com/) - Optional[pd.DataFrame]: 从 CoinMarketCap 抓取 Top 加密货币数据 Returns: DataFrame with cleaned columns: name, symbol, price, market_cap, volume_24h, change_24h try: headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 } response requests.get(url, headersheaders, timeout15) response.raise_for_status() # 使用 lxml 解析器比 html.parser 更准 tables pd.read_html( response.text, flavorlxml, attrs{class: sc-1r996ns-0 gCilSf cmc-table}, # 精准定位 header0 # 第 0 行为表头 ) if not tables: print(未找到匹配的表格请检查网页结构或 class 名称) return None df tables[0].copy() # 重命名列名网页列名可能变化用位置索引更稳 # 根据 CoinMarketCap 当前结构0:name, 1:symbol, 2:price, 3:market_cap, 4:volume_24h, 5:change_24h column_mapping { 0: name, 1: symbol, 2: price, 3: market_cap, 4: volume_24h, 5: change_24h } df df.iloc[:, list(column_mapping.keys())] df.columns list(column_mapping.values()) # 清洗数值字段移除符号转换类型 def clean_currency(text: str) - float: 清理货币字符串移除 $, ,, %, 空格 if pd.isna(text): return float(nan) # 移除所有非数字、非小数点、非负号的字符 cleaned re.sub(r[^\d.-], , str(text)) return float(cleaned) if cleaned else float(nan) df[price] df[price].apply(clean_currency) df[market_cap] df[market_cap].apply(clean_currency) df[volume_24h] df[volume_24h].apply(clean_currency) df[change_24h] df[change_24h].apply(clean_currency) # 添加采集时间 df[scraped_at] pd.Timestamp.now() return df except Exception as e: print(f抓取或解析失败: {e}) return None # 执行 cmc_df scrape_cmc_top_coins() if cmc_df is not None: print(CMC 数据预览:) print(cmc_df[[name, symbol, price, change_24h]].head())Step 3数据融合与验证将 JSON API 和 HTML 抓取的数据进行交叉验证提升可信度# 合并两个数据源以 symbol 为键 merged_df pd.merge( crypto_df[[symbol, current_price, price_change_percentage_24h]], cmc_df[[symbol, price, change_24h]], onsymbol, howinner, suffixes(_api, _web) ) # 计算价格差异百分比评估数据一致性 merged_df[price_diff_pct] ( (merged_df[price_api] - merged_df[price_web]) / merged_df[price_web] * 100 ).round(2) # 找出差异 5% 的币种人工核查 outliers merged_df[abs(merged_df[price_diff_pct]) 5] print(价格差异 5% 的币种:) print(outliers[[symbol, price_api, price_web, price_diff_pct]])4.3 Pickle 序列化构建可复现的中间数据层所有清洗后的数据必须用 Pickle 存储形成“中间数据层”Intermediate Data Layer这是数据工程的最佳实践。import pickle from pathlib import Path def save_intermediate_data( df: pd.DataFrame, name: str, version: str v1 ) - None: 保存 DataFrame 为 Pickle 文件带版本和时间戳 Args: df: 要保存的 DataFrame name: 数据名称 (e.g., crypto_market_data) version: 版本号 (e.g., v1) # 构建文件名name_version_timestamp.pkl timestamp pd.Timestamp.now().strftime(%Y%m%d_%H%M%S) filename f{name}_{version}_{timestamp}.pkl filepath Path(data/intermediate) / filename # 创建目录 filepath.parent.mkdir(parentsTrue, exist_okTrue) # 保存 with open(filepath, wb) as f: pickle.dump(df, f, protocolpickle.HIGHEST_PROTOCOL) print(f已保存中间数据: {filepath}) # 保存 API 和 Web 数据 save_intermediate_data(crypto_df, crypto_api_data, v1) save_intermediate_data(cmc_df, crypto_web_data, v1) # 读取验证 def load_intermediate_data(filepath: str) - pd.DataFrame: 安全加载 Pickle 文件 with open(filepath, rb) as f: return pickle.load(f) # 示例加载最新版 API 数据 latest_pkl max(Path(data/intermediate).glob(crypto_api_data_v1_*.pkl)) loaded_df load_intermediate_data(latest_pkl) print(f加载成功形状: {loaded_df.shape})5. 常见问题与排查技巧实录那些年我们踩过的坑5.1 JSON 导入高频问题速查表问题现象根本原因解决方案我的实测经验ValueError: Expected object or valueJSON 字符串开头有 BOMByte Order Mark或不可见字符用response.content.decode(utf-8-sig)替代response.text某政府数据接口返回的 JSON 总在开头有\ufeffread_json()直接报错加-sig后缀秒解KeyError: some_fieldjson_normalize()的record_path指向的 key 不存在于某些记录中设置errorsignore或预处理data [d for d in data if some_field in d]某 IoT 设备日志中80% 记录有battery_level20% 没有。用errorsignore后缺失字段自动为NaN无需过滤ParserError: While parsing sourceJSON 中有非法转义字符如\未闭合或编码错误先用json.loads()尝试解析捕获JSONDecodeError打印e.pos定位错误位置某爬虫抓取的 JSON因网页编码不一致被转义为quot;read_json()无法识别。用html.unescape()预处理后正常MemoryErrorJSON 文件过大read_json()一次性加载改用chunksizelinesTrueJSONL 格式或用ijson库流式解析处理 2GB 的用户行为日志 JSONL