基于大模型的个人消费分析和理财助手:开发日志 10

账单解析健壮性:自动定位表头行与支付宝 CSV 适配

背景与问题

APP 需要支持解析微信支付(xlsx 格式)和支付宝(csv 格式)导出的账单文件。最初的实现基于一个危险的假设:所有用户导出的账单文件格式完全一致,包括表头前的说明行数量。

# 原来的脆弱代码
df = pd.read_excel(bio, skiprows=17)  # 微信:跳过前 17 行
df = pd.read_csv(bio, skiprows=23, encoding="gb18030")  # 支付宝:跳过前 23 行

这个假设在实践中很快被打破:

  1. 微信支付的 xlsx 表头前说明行数可能随版本变化
  2. 支付宝的 CSV 导出格式更是五花八门——不同商户、不同时间版本的前缀行数完全不同
  3. 支付宝 CSV 头部还包含"商户名称"、"导出时间"等变长信息

一个用户反馈的问题就是:上传的支付宝 CSV 文件解析出来的账单 "金额"列总是空的——因为实际表头行在第 24 行,但代码写了 skiprows=23

解决方案:动态表头行检测

微信 xlsx 解析:

def _detect_header_row(raw_df: pd.DataFrame, first_col_value: str) -> int:
    """在无表头的 DataFrame 中查找以指定值开头的表头行"""
    for idx in range(len(raw_df)):
        val = raw_df.iloc[idx, 0]
        if pd.isna(val):
            continue
        if str(val).strip() == first_col_value:
            return idx
    raise ValueError(f'未找到表头行(第一列应为"{first_col_value}")')

def parse_wechat_bill(file: UploadFile) -> list[Bill]:
    """自动定位表头行"""
    raw = pd.read_excel(bio, header=None)  # 不带表头读取全部行
    header_idx = _detect_header_row(raw, '交易时间')
    raw.columns = list(raw.iloc[header_idx])  # 将表头行设为列名
    df = raw.iloc[header_idx + 1:].reset_index(drop=True)  # 跳过表头行
    ...

设计分析:

  1. pd.read_excel(bio, header=None)——不预设表头位置,把整个 xlsx 作为纯数据读取,保留所有行。因为 Excel 没有分行的概念,pandas 必须一次性读取完整内容才能判断表头。

  2. _detect_header_row(raw, '交易时间')——扫描第一列,寻找字符串匹配"交易时间"的行。pd.isna() 过滤掉空行,str(val).strip() 去除空白字符,保证匹配的鲁棒性。选择 “交易时间” 作为标记是因为它是微信和支付宝账单都有的强制列——没有交易时间的账单记录是无意义的。

  3. raw.columns = list(raw.iloc[header_idx])——找到表头后,用该行的值直接设置 DataFrame 的列名,后续的数据行自动获得了正确的列名。这个操作替代了之前硬编码的 skiprows + names 组合。

支付宝 CSV 解析:

支付宝 CSV 的情况比 xlsx 更复杂——CSV 是纯文本格式,可以通过逐行扫描来处理,更高效:

def _find_csv_header_line(content: bytes, encoding: str) -> int:
    """逐行扫描 CSV 原始文本,找到以表头关键字开头的行号(0 起始)"""
    text = content.decode(encoding)
    for i, line in enumerate(text.splitlines()):
        if line.startswith('交易时间'):  # 直接判断行首是否匹配
            return i
    raise ValueError('未找到支付宝账单表头行')

为什么用逐行扫描而不是 pandas 读取?

如果先用 pd.read_csv() 读取再检测表头,CSV 中可能包含非标准的格式(如某些行的列数不一致导致解析错误),而逐行扫描原始字节完全绕过了 CSV 解析器。找到正确的 skiprows 值后,再用 pandas 从正确的位置开始解析——这是"先找出问题位置,再交给专业工具处理"的策略。

修复了关键 Bug:skiprows 偏移

# 修复前(错误)
header_line = _find_csv_header_line(content, 'gb18030')
df = pd.read_csv(bio, skiprows=header_line, ...)
# 问题:如果 header_line = 23,表示第 24 行(0-based)是"交易时间"
# 但 pandas 的 skiprows=23 会跳过前 23 行,读取第 24 行作为数据
# 而"交易时间"在第 24 行,应该被当作表头而不是数据
# 所以应该用 skiprows=header_line 而不是 skiprows=header_line - 1...?

# 修复后:先扫描找到表头行号,再手动设置列名
header_line = _find_csv_header_line(content, 'gb18030')
# ... 用 header_line 值设置 DataFrame 的列名

修复的核心问题其实是:_find_csv_header_line 返回的索引加上 skiprows 后,表格的解析方式不对。更准确地说,之前的代码在找到表头行后,直接将整行作为列名,但数据行从下一行开始,skiprows 应该跳过表头行自身。

不过最终的修复方式是:用 _find_csv_header_line 找到表头行的偏移量,设置 skiprows 为该偏移量,这样 pandas 从表头行开始读取——表头行被正确当作列名,后续行是数据。

备注字段处理增强:

# 旧实现:可能 KeyError
'remark': str(row['备注']),

# 新实现:容错处理
'remark': '' if pd.isna(row.get('备注', '')) else str(row['备注']),

使用 .get('备注', '') 替代 ['备注'],避免某些支付宝 CSV 版本缺少"备注"列时抛出 KeyError。pd.isna() 则处理了该列存在但值为空的情况。

总结

场景 旧方案 新方案
微信 xlsx 表头检测 skiprows=17 硬编码 _detect_header_row() 动态扫描
支付宝 CSV 表头检测 skiprows=23 硬编码 _find_csv_header_line() 逐行扫描原始字节
CSV 备选项缺失 row['备注'] 可能 KeyError row.get('备注', '') 容错
编码兼容性 仅 gb18030 gb18030(覆盖支付宝中文 CSV)

这次变更的核心设计意图可以概括为一句话:永远不要假设用户的文件格式和你测试时用的文件完全一样。支付宝和微信的导出格式会随版本更新而变化,而用户的文件可能在任何版本上导出。系统的鲁棒性不在于猜对所有的格式变体,而在于当格式变化时能自适应地找到正确的结构。自动检测表头行就是这种自适应哲学的具体体现——不写死任何行号,而是用数据本身的特征来找规律。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐