历史数据处理记录。 本文以 2022 年 lsjpjg.com 查询结果的结构为例,讲解离线清洗和观测报价统计。当前服务接口及数据授权未经验证;示例用于自己合法取得的数据,不提供当前报价查询能力。
一、原始数据包含两种日期
当年请求按出发日期、出发城市和到达城市提交,响应包含 price、discount、qry_dt 等字段。qry_dt 是历史查询日期,出发日期则由请求上下文补入;两者不能互换。同一航班在不同查询日期会出现多份报价。


连续取得空结果并不能证明航线不存在,还应区分请求失败、解析失败与业务返回空列表。不要从全部响应中删除 /,否则会破坏 URL 或日期等合法字符串;宽泛异常也不能直接转成“无数据”。下文只处理离线样本,不依赖该站当前接口。
二、原清洗代码中的错误
('全价' or '经典') in text 在 Python 中只检查“全价”,不会检查“经典”。应写成 any(word in text for word in ('全价', '经典'))。不过这两个词是否等于可比较的公布全价,还要看源数据定义,不能仅凭文本推断。
原程序在循环中删除 DataFrame 行,合并时没有重建索引,重复索引可能影响多行;保存时又把 DataFrame result 当作文件名,与字符串相加会报错。筛选只检查日期差大于阈值,也漏掉查询日期晚于出发日期的异常记录。
更关键的是,price / discount * 10 只有在折扣和价格来自同一票种、折扣单位明确且税费口径相同时才成立。不同报价的“全价”平均值与另一组报价的最低价混合后计算折扣,统计含义不清楚。下例直接计算观测报价,不再制造推算全价。
三、完整离线清洗示例
准备 raw.json,它是对象列表,每条都有 price、discount、qry_dt 和 出发日期。两种日期都要求 YYYY-MM-DD;带时间戳的数据先明确时区并规范化日期。若原响应没有出发日期,先按取得数据时的请求上下文补入,不能从查询日期猜测。
python -m pip install pandas
保存为 clean_quotes.py:
from pathlib import Path
from datetime import date, timedelta
import json
import math
import pandas as pd
def date_range(start, end):
first = date.fromisoformat(start)
last = date.fromisoformat(end)
if first > last:
raise ValueError('开始日期不能晚于结束日期')
return [(first + timedelta(days=i)).isoformat()
for i in range((last - first).days + 1)]
def parse_records(text):
value = json.loads(text.lstrip('\ufeff'))
if not isinstance(value, list) or not all(isinstance(row, dict) for row in value):
raise ValueError('输入必须为 JSON 对象列表')
return value
def clean_quotes(records, interval=8):
if interval < 0:
raise ValueError('interval 必须非负')
frame = pd.DataFrame(records)
required = {'price', 'discount', 'qry_dt', '出发日期'}
missing = required - set(frame.columns)
if missing:
raise ValueError(f'缺少字段:{sorted(missing)}')
frame = frame.reset_index(drop=True).copy()
frame['price'] = pd.to_numeric(frame['price'], errors='coerce')
queried = pd.to_datetime(frame['qry_dt'], format='%Y-%m-%d', errors='coerce')
departed = pd.to_datetime(frame['出发日期'], format='%Y-%m-%d', errors='coerce')
frame['日期差'] = (departed - queried).dt.days
economy = frame['discount'].astype('string').str.contains('经济', na=False)
finite = frame['price'].map(lambda x: pd.notna(x) and math.isfinite(x) and x > 0)
valid_dates = queried.notna() & departed.notna()
in_window = frame['日期差'].between(0, interval)
valid = economy & finite & valid_dates & in_window
rejected = frame.loc[~valid].copy()
rejected['非经济舱'] = ~economy.loc[~valid]
rejected['价格无效'] = ~finite.loc[~valid]
rejected['日期无效'] = ~valid_dates.loc[~valid]
rejected['超出查询窗口'] = ~in_window.loc[~valid]
clean = frame.loc[valid].copy()
clean['出发日期'] = departed.loc[valid].dt.strftime('%Y-%m-%d')
summary = clean.groupby('出发日期')['price'].agg(
观测最低报价='min', 观测平均报价='mean', 样本数='count').reset_index()
return clean, rejected, summary
if __name__ == '__main__':
records = parse_records(Path('raw.json').read_text(encoding='utf-8'))
clean, rejected, summary = clean_quotes(records, interval=8)
output = Path('clean_output')
output.mkdir(exist_ok=False)
clean.to_csv(output / 'quotes.csv', index=False, encoding='utf-8-sig')
rejected.to_csv(output / 'rejected.csv', index=False, encoding='utf-8-sig')
summary.to_csv(output / 'summary.csv', index=False, encoding='utf-8-sig')
print(f'有效 {len(clean)} 条,排除 {len(rejected)} 条,输出目录:{output}')
空输入或缺字段时明确报错,不把无法判断的输入当成“没有航班”。输出目录必须是新目录,避免覆盖上次的结果。批量拼接自己已取得的数据时,用 pd.concat(frames, ignore_index=True) 解决重复索引;仍应保留源文件和查询上下文用于追踪。pandas concat、JSON 解码
四、怎么解释输出
interval=8 表示查询日在出发日前 0 到 8 天,不是每 8 天抽样,也不是当前售卖日期。输出的“观测平均报价”会受航班、票种、供应商和查询次数影响;它不是当日市场成交均价。如果重复采样多,就会改变均值权重,应按分析目标决定唯一键与采样频率,不能盲目按航班号去重。
税费、成人/儿童、是否含行李和退改条件不同的报价,合并前要核实口径。没有这些字段时应注明缺失,不把价格变化全部解释为航司定价变化。pandas 分组统计

清洗结果反映输入样本中的观测报价,不代表数据来源完整或实时,不能据此直接作出当前购票决策。
评论区