T3出行数据工程实战:从采集到可视化的完整链路
T3出行这个平台的slogan是“更聪明、更安全、更舒心”但对开发者来说更关心的是“能不能拿到数据、能不能算对距离、能不能跑通地图链路”。t3code这个号说白了就是围绕T3出行生态做技术延伸——从网约车数据采集、地图坐标处理到运力热力分析和前端可视化一整条链路我都自己跑过一遍。这篇文章算是一个阶段性的工程复盘把踩过的坑、验证过的方案、还有那些容易让人卡住的技术细节一次性交代清楚。说它是“抄作业指南”也好说是“避坑实录”也好我个人觉得对三类人最有用一是想拿真实出行数据做研究分析的技术爱好者二是在做网约车平台周边产品比如比价、调度、区域分析的独立开发者三是刚接触地图服务和坐标转换、想找个完整案例练手的同学。项目本身不难但细节非常磨人我把那些文档里不写的坑都填平你顺着走就能省掉至少一周的试错时间。1. 整体设计与思路拆解t3code到底解决了什么问题1.1 为什么起了t3code这么个项目先说个实打实的痛点。网约车数据对做城市研究、商业选址、交通分析的人来说是块肥肉但想拿到干净又合规的样本比想象中麻烦很多。官方开放平台不是随便注册就给接口的个人开发者想申请企业级权限基本没戏。而自己一个个手动记录又不现实时间成本太高数据量也不够分析用。t3code这个名字看起来像是个编程项目实际定位其实是“T3出行场景的技术解决方案集”。我把它拆成了四件事稳定地拿到指定城市、指定区域的车辆实时分布样本把拿到的原始经纬度统一转成同一套坐标系避免地图展示和数据计算对不上基于清洗后的数据做供需热度、运力分布、平均接驾距离等分析用Web页面把分析结果直观可视化方便自己看、也方便给别人演示换句话说这不是一个纯爬虫项目也不是一个纯数据分析项目而是从头到尾打通“采集 - 清洗 - 计算 - 呈现”的数据闭环。我在实际开发过程中最大的体会是采集只是最外围的体力活真正拉开差距的是坐标处理和数据分析这两段。很多人在网上发的所谓“网约车爬虫”demo只展示了数据能存进Excel完全没有处理的环节那其实只能算完成了10%。1.2 技术选型为什么用了这套组合而不是其他方案整体技术栈我选了Python全家桶不是因为它最酷而是这套方案在“快速实现 调试方便 后续可扩展”三个维度上平衡得最好。具体是这样搭配的模块选型选择的理由数据采集requestsSession轻量能精细控制请求头和频率比scrapy这种重框架更适合小规模定时任务坐标转换自研工具模块两个经纬度偏移算法而已不需要引入庞大的GIS库数据分析pandasnumpy清洗、聚合、统计一套搞定配合matplotlib还能顺便出图存储SQLite数据量撑死每天十几万条SQLite完全扛得住还免了部署数据库的麻烦可视化Flask EChartsFlask做后端极轻ECharts地图和热力图组件成熟原生支持GeoJSON格式有人可能会问为什么不直接用高德的开放API去拿周边车辆数据原因有两个。第一高德开放平台的“周边检索”接口返回的是POI点不是实时车辆状态语义完全不同第二真正的车辆分布数据只有网约车平台自己有外部服务商拿不到同等粒度的数据。所以绕不开自建采集通道这条路线。实际开发中我也考虑过用Node.js或者Go重写一遍采集模块但后来发现Python在数据清洗阶段实在太省心了用pandas一行就能完成的groupby聚合换成其他语言至少要写十几行循环。如果你打算长期做出行数据分析Python这套路线能让你把精力集中在业务逻辑上而不是被语言细节拖住。2. 核心细节解析与实操要点五个关键模块逐个拆开讲2.1 数据采集模块请求头、频率与增量策略采集模块是整个项目的地基地基不稳后面全垮。这个模块里最容易出问题的就是请求头伪装。默认的requests库发出去的用户代理是python-requests/x.x.x这种特征太明显了服务端一看到基本就会拒绝响应甚至激活风控。我一开始没太在意结果请求发出的瞬间就收到了403后来规规矩矩把浏览器环境的User-Agent、Accept、Accept-Language、Referer全部带上才恢复正常。另一个关键点是采集频率。网约车平台的实时数据接口通常都有访问频控短时间高频请求不仅拿不到更多数据反而容易导致当前IP被临时限制。我的实测经验是单账号轮询同一城市的不同区域网格时每次请求间隔控制在2到4秒比较稳妥如果并发超过5个线程同时跑基本不出半小时就会被限流。增量策略也很重要。实时车辆分布数据是不断刷新的同一辆车在两次请求间隔内可能从A区跑到B区。如果每次都全量存储数据冗余度太高还会干扰后续的“平均速度”计算。我在落库前会做一次去重处理以“车牌哈希 15分钟时间窗口”作为唯一键同一辆车在窗口内重复出现就只更新最新位置不计为新记录。这样既能保证轨迹的连续性又能避免脏数据膨胀。2.2 坐标系统一别再傻傻分不清WGS-84、GCJ-02和BD-09这个模块是整个t3code项目里技术含量最高、也最容易被忽略的部分。很多人以为自己拿到的经纬度直接在地图上标点就行了其实这里藏着一个天坑。国内的地图服务商出于精度要求对真实坐标做了加偏处理形成了各自的专属坐标系WGS-84GPS直接输出的原始坐标国际通用但直接在国内地图上展示会产生几十到几百米的偏移GCJ-02俗称“火星坐标系”高德、腾讯地图用的都是这套它是WGS-84加密偏移后的结果BD-09百度地图专用的二次加密坐标系又比GCJ-02偏移了一层t3code接到的车辆位置数据到底落在哪个坐标系取决于平台用了哪家地图服务。这个不能靠猜我当时的验证方法是把拿到的一个已知地标经纬度放到高德地图和百度地图上分别做逆地理编码看哪个返回的地址与实际位置完全吻合就说明数据对应的是哪套坐标系。这个验证方法也推荐你自己测试一次因为同一个平台在不同时期切换地图服务商的情况是真实存在过的。明确了坐标系之后就要做转换。WGS-84转GCJ-02有一整套公开的偏移算法核心是计算经纬度的偏置量然后加到原始坐标上。这个算法的典型精度在1到2米范围内做城市级别的热度分析完全够用。GCJ-02转BD-09则是在火星坐标基础上再做一次极坐标偏移转换公式很简单但方向不能搞反否则数据漂移到完全不可用的程度。我在项目里封装了一个CoordConverter工具类包含三个方向的双向转换方法。实际操作中最重要的一个经验是全项目统一在采集入库阶段就完成坐标转换后续所有模块只使用转换后的坐标。不要在展示层、计算层各转一次那样既浪费算力又容易出现转错方向这种隐蔽bug。2.3 距离计算Haversine公式和坐标系的选择关系很多时候我们要算“两点之间的距离”比如某辆车距离一个热门商圈有多远。很多新手会直接用geopy.distance这类库但如果你是在做定时任务、需要对几千个点做两两距离计算这种高精度库的性能就不太够了。我选择自己实现Haversine公式来做球面距离计算。这个公式假设地球是一个标准球体虽然精度上不如考虑了椭球参数的Vincenty公式但在城市尺度下误差只有0.5%左右完全不影响结论方向。但这里有个容易踩坑的地方如果你的两个坐标中有一个是GCJ-02另一个是WGS-84直接套Haversine公式计算结果最多会偏差几十米。必须先把两套坐标系统一再代入公式计算。更隐蔽的是如果两套坐标都做了加密比如GCJ-02和BD-09混用计算出来的距离可能偏离真实距离好几十米在判断“是否在1公里服务圈内”这种应用场景下直接导致误判。所以我把坐标转换和距离计算在代码里做成了强绑定逻辑。任何想要调用距离函数的地方必须先经过坐标验证模块确认两个点的坐标系字段是一致的。这个设计在一开始写代码的时候觉得多此一举但后来的实战里帮我挡掉了至少三次数据口径不一致的坑。2.4 数据分析不只看车辆数更要算供需比数据采集和处理都只是手段分析才是输出价值的地方。我t3code项目里的分析面不是简单地统计“哪片区域车多哪片车少”而是从一个经营者的角度去看数据。最核心的指标有三个运力密度区域内车辆数除以区域面积反映这里的运力供给强度供需热度指数结合签到车辆增速和区域内POI热度等级粗略估算一个相对热力值平均接驾距离用区域内车辆两两距离排序取中位数反映用户叫车后大概要等多久特别说一下“平均接驾距离”这个指标。网约车平台通常会给用户展示“附近X辆车”来建立预期但从数据分析角度看距离最近的车辆不一定会接单因为司机也有自己的接单偏好。我自己的处理方式是计算第25百分位的接驾距离作为“主流体验距离”这个数比平均值更有参考意义因为极端的远车会拉高平均线掩盖真实体验。分析结果我全部落成CSV和SQLite视图方便随时查询。最终在可视化页面里展示的就是这一套加工后的派生指标而不是原始坐标点。2.5 前端可视化ECharts热力图和地图下钻的方案t3code的可视化层选的是ECharts而不是Leaflet或Mapbox这类纯地图库原因很简单ECharts的heatmap系列在展示城市级运力密度时有天然的视觉优势配置起来也不复杂。城市轮廓我用的是高德地图开放平台下载的GeoJSON数据加载到ECharts里通过registerMap注册成地图实例。然后以行政区为单位用visualMap组件做颜色梯度映射区域颜色从浅到深表示车辆密度从低到高。这样一张图就能看出整座城市的运力热点分布。实时刷新我用的是前端定时3秒一次的setInterval拉取后端接口后端从SQLite读取最近一个时间切片的数据并返回JSON。这个方案在数据量不大几千条的时候表现很流畅但如果之后你想支撑多用户访问建议把SQLite换成PostgreSQL再引入Redis做缓存层避免每次请求都打到库里。这里提醒一句GeoJSON文件有的大几十兆不适合直接放在前端加载。我先用简化工具把坐标精度从6位降到4位体积至少能瘦身80%视觉上几乎看不出差异加载速度却快了很多倍。3. 实操过程与核心环节实现从零搭建t3code的完整记录3.1 环境准备与项目目录规划我的开发环境是Windows WSL2Python版用的是3.10。虽然Python 3.12早就发布了但我实测下来部分依赖包在3.12上还有兼容性问题3.10在稳定性和生态支持上是更稳妥的选择。项目目录结构我一开始就规划好了后面一直受益于此t3code/ ├── collector/ │ ├── session_manager.py # 请求会话管理维护登录态和请求头 │ ├── grid_fetcher.py # 网格区域采集逻辑 │ └── filters.py # 数据去重和异常值过滤 ├── geo/ │ ├── coord_converter.py # 三种坐标系的互转 │ └── distance.py # Haversine距离计算 ├── analyzer/ │ ├── density.py # 运力密度计算 │ ├── supply_demand.py # 供需热度评估 │ └── eta.py # 接驾距离估算 ├── web/ │ ├── app.py # Flask应用入口 │ ├── templates/ │ └── static/ ├── data/ │ ├── raw/ # 原始JSON落盘 │ └── processed/ # 清洗转换后的数据 └── config.py # 全局配置城市、网格大小、请求参数这个结构分成四层很清晰采集层不关心数据怎么用数据处理层只做转换不采集分析层从库里拿数算指标Web层负责把结果呈现出来。各层之间用数据文件和数据库解耦替换任何一层都不用动其他代码。3.2 采集模块实现登录态维护与网格轮询先写会话管理的基类核心是维护一个持久的requests.Session对象这样既能保留服务端下发的Cookie又能统一设置代理和请求头。import requests from fake_useragent import UserAgent class SessionManager: def __init__(self): self.session requests.Session() self.ua UserAgent() def get_headers(self, refererNone): headers { User-Agent: self.ua.random, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Origin: https://www.example.com, Referer: referer or https://www.example.com/, } return headers def get(self, url, **kwargs): kwargs.setdefault(headers, self.get_headers()) resp self.session.get(url, timeout10, **kwargs) resp.raise_for_status() return resp.json()fake_useragent库能随机生成真实的浏览器UA每次请求用不同的UA字符串配合上Session持久化Cookie整体存活率比固定UA高了一个量级。网格采集策略核心是把城市划分成若干个等大小的矩形网格每个网格对应一次请求。城市中心坐标、网格边长、横向纵向数量都放在config.py里集中管理。import time import json from collector.session_manager import SessionManager class GridFetcher: def __init__(self, center_lat, center_lng, grid_size0.01): self.center_lat center_lat self.center_lng center_lng self.grid_size grid_size # 大约1km左右 self.sm SessionManager() def generate_grid_points(self, rows10, cols10): min_lat self.center_lat - rows * self.grid_size / 2 min_lng self.center_lng - cols * self.grid_size / 2 points [] for r in range(rows): for c in range(cols): lat min_lat r * self.grid_size lng min_lng c * self.grid_size points.append((round(lat, 6), round(lng, 6))) return points def fetch_grid(self, lat, lng): # 这里构造实际请求参数按平台接口的字段说明 params { latitude: lat, longitude: lng, } data self.sm.get(https://api.example.com/v1/vehicles, paramsparams) return data.get(vehicles, [])网格边长的选择直接影响到一次全城扫描的目标数量。0.01度大约对应1公里多一点点对于做城市级热力分析来说这个粒度刚好——太密会导致重复车辆过多太疏又会漏掉小范围的热点区域。采集主循环里我做了两件额外的事一是随机打乱网格遍历顺序避免每次都从城市中心向边缘推进形成规律性二是每两个网格请求之间使用随机等待时长。这两点对降低风控触发概率很有效。3.3 坐标转换与距离计算核心工具类的代码补全坐标转换是整个项目里最需要细心的地方。这里是WGS-84转GCJ-02的关键实现import math class CoordConverter: def __init__(self): self.a 6378245.0 self.ee 0.00669342162296594323 def out_of_china(self, lng, lat): return not (72.004 lng 137.8347 and 0.8293 lat 55.8271) def _transform_lat(self, x, y): ret -100.0 2.0 * x 3.0 * y 0.2 * y * y 0.1 * x * y 0.2 * math.sqrt(abs(x)) ret (20.0 * math.sin(6.0 * x * math.pi) 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 ret (20.0 * math.sin(y * math.pi) 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0 ret (160.0 * math.sin(y / 12.0 * math.pi) 320 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0 return ret def _transform_lng(self, x, y): ret 300.0 x 2.0 * y 0.1 * x * x 0.1 * x * y 0.1 * math.sqrt(abs(x)) ret (20.0 * math.sin(6.0 * x * math.pi) 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 ret (20.0 * math.sin(x * math.pi) 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0 ret (150.0 * math.sin(x / 12.0 * math.pi) 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0 return ret def wgs84_to_gcj02(self, lng, lat): if self.out_of_china(lng, lat): return lng, lat d_lat self._transform_lat(lng - 105.0, lat - 35.0) d_lng self._transform_lng(lng - 105.0, lat - 35.0) rad_lat lat / 180.0 * math.pi magic math.sin(rad_lat) magic 1 - self.ee * magic * magic sqrt_magic math.sqrt(magic) d_lat (d_lat * 180.0) / ((self.a * (1 - self.ee)) / (magic * sqrt_magic) * math.pi) d_lng (d_lng * 180.0) / (self.a / sqrt_magic * math.cos(rad_lat) * math.pi) return lng d_lng, lat d_lat这段代码里最容易出bug的地方是三角函数的输入单位。Python的math.sin默认接收弧度值代码里乘完math.pi之后已经是弧度了但初学者经常忘记这个转换导致算出来的偏移量完全不对。如果你运行上面的代码发现坐标偏移了上百度先检查这里。GCJ-02转BD-09相对简单很多博客都有现成公式核心是让原本WGS-84的坐标经过二次加密后能直接投射到百度地图的底图坐标系里。这段代码有点长我就不全文贴了但使用的时候务必注意输入参数顺序我因为传参顺序颠倒导致数据歪到了隔壁城市排查了大半天才反应过来。Haversine距离计算公式的实现有这么几个思路值得记录。公式本身不复杂但numpy向量化优化明显几千个点两两算距离时速度可以快几十倍import numpy as np def haversine_np(lat1, lon1, lat2, lon2): R 6371.0 lat1, lon1, lat2, lon2 map(np.radians, [lat1, lon1, lat2, lon2]) dlat lat2 - lat1 dlon lon2 - lon1 a np.sin(dlat / 2) ** 2 np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2 return 2 * R * np.arcsin(np.sqrt(a))这里返回的结果单位是公里。如果你要的是米把地球半径换成一个乘以1000的版本即可。3.4 分析模块从SQLite取数到产出热力JSON分析模块的运行逻辑是定时任务每5分钟跑一次从SQLite最近的时间切片里取出所有车辆坐标点经过坐标转换和逆地理编码后按行政区汇总。具体步骤我整理成了一段伪代码# 1. 从SQLite取最近时间窗口内的全部车辆记录 records db.query(SELECT DISTINCT car_hash, lat, lng FROM vehicles WHERE ts now - interval 5 min) # 2. 将每条记录坐标统一为GCJ-02 converter CoordConverter() normalized [converter.wgs84_to_gcj02(r.lng, r.lat) for r in records] # 3. 按网格聚合计算每个网格的车辆数 grid_counts {} for lng, lat in normalized: key (round(lng, 3), round(lat, 3)) grid_counts[key] grid_counts.get(key, 0) 1 # 4. 计算供需热度指数、平均距离等派生指标 hotspots compute_hotspots(grid_counts) # 5. 输出为后端接口要用的JSON格式 with open(data/processed/hotspots.json, w) as f: json.dump(hotspots, f, ensure_asciiFalse)聚合粒度这里用round(lng, 3)约等于100米左右的单元格做热力图展示时视觉颗粒度刚好。如果你的城市范围特别大毫米级坐标点数量庞大可以放宽到round(..., 2)缩小聚合粒度减少前端渲染压力。这个定时任务我用的是系统级别的cron而不是在Python代码里写死循环。原因很简单cron能自动处理程序崩溃后的拉起还有日志记录比Python里的while True sleep稳定得多。命令我放在crontab里的形式是*/5 * * * * cd /opt/t3code /usr/bin/python3 main.py logs/cron.log 213.5 Web展示层半小时搭好一个运力热力看板Web层用Flask实现整体代码量很小。后端只做一个事读取hotspots.json文件并返回给前端不承担任何计算任务。from flask import Flask, jsonify, render_template import json app Flask(__name__) app.route(/) def index(): return render_template(index.html) app.route(/api/hotspots) def hotspots(): with open(data/processed/hotspots.json, r, encodingutf-8) as f: data json.load(f) return jsonify(data) if __name__ __main__: app.run(host0.0.0.0, port8000, debugFalse)前端页面里放了一个ECharts实例配置项里核心是series里的type: map配合heatmap视觉映射。这个组合能实现颜色渐变的同时保留区域边界线条视觉效果非常直观。ECharts加载GeoJSON的方式如下fetch(/static/geojson/city.json) .then(res res.json()) .then(geoJson { echarts.registerMap(city, geoJson); const chart echarts.init(document.getElementById(map)); chart.setOption({ series: [{ type: map, map: city, data: hotspotData }], visualMap: { min: 0, max: 50, type: continuous, inRange: { color: [#ffffff, #ffd080, #ff6e40, #d73027] } } }); });这个方案里有个值得注意的细节visualMap的max不能写死建议根据当前时间段的最大车辆数动态计算否则凌晨两点的数据全部冲进高色阶根本看不出梯度差异。我用后端在生成JSON时同时输出一个max字段前端直接用这个值赋给visualMap.max问题就解决了。热力图模式下单个点的权重等于该车辆的“热度评分”评分由车辆周围500米内的POI类型综合算出——如果旁边是商场、写字楼评分高如果是空旷路段评分低。这样画出来的热力图不是“车在哪哪红”而是“哪里打车需求可能更大”语义上更接近网约车平台的真实配单逻辑。4. 常见问题与排查技巧实录t3code开发中那些真实的坑4.1 高频报错的定位与解决速查表开发和试运行期间我遇到过不少问题整理了一张排查速查表每个问题都标了解析思路遇到同类的可以直接按图索骥。问题现象根本原因解决办法请求返回403日志里无其他信息User-Agent特征明显被服务端识别为脚本换成完整浏览器UA带上Referer和Origin采集跑半小时后返回空数据IP被临时风控请求频率过高调大网格请求间隔到3秒以上降低并发线程数地图上车辆点位全部偏到海里坐标系没转直接展示GPS原始坐标叠加到GCJ底图入库前统一调用wgs84_to_gcj02转换计算出的距离比实际长了几百米两个点的坐标系混用了GCJ-02和BD-09在距离计算入口加坐标校验强制统一坐标系热力图颜色全一样没有层次visualMap的max值固定写死超出范围动态从数据中获取最大值作为scale上限SQLite打不开报数据库锁定采集线程和分析线程同时写库加写入锁或用WAL模式开启并发读前端地图加载时白屏控制台报GeoJSON parse error下载的GeoJSON是压缩包没解压确保加载的是标准GeoJSON格式字段名为coordinates且为数组4.2 凌晨低峰期数据异常对脏数据进行人工审查的教训有个数据问题必须单独拿出来说。第一次连续跑了一周后我发现在凌晨2点到5点这个时段车子数量在某些行政区变成了0而相邻区域又非常密集。第一反应是采集漏了后来仔细检查才发现是数据清洗时把“在线”和“空闲”状态弄混了——凌晨时段很多车处于“下线”状态不应该计入运力指标。从那之后我在采集模块里增加了一个status字段过滤逻辑只有状态为“在线”且“可接单”的车辆才进入后续分析。这个教训让我明白一件事实时数据项目里业务语义比代码逻辑更难发现错误脏数据往往不是技术问题而是你对业务的理解不够深。数据审查不能只靠自动化校验定期人工抽样看几组原始记录对发现隐藏bug很有帮助。4.3 坐标转换精度评估这个方案的误差到底有多大我在上线前专门做了精度验证选了城市里20个地标点先用GPS设备去实地记录WGS-84坐标再在对应地图上用App手动标记同一地点的GCJ-02坐标对两组数据的转换结果进行对比。最终结果是80%的样本点误差在3米以内剩下的点误差不超过8米。考虑到我的用途是城市级热力分析这个精度绰绰有余。但要说明白的是如果你做的是车道级导航或者车道偏移提醒这类精度敏感功能那必须用RTK或高精度地图数据不能用这套开源算法扛这是天生的天花板不是调参能解决的。4.4 反爬风控升级后的应对思路网约车平台的反爬风控也是动态变化的。我遇到过一次比较猛的风控升级之前用的纯requests方案突然大面积失效所有请求都返回验证码页面。我当时的应对策略分三步先排查是不是IP段被集体拉黑了用手机热点换IP测试确认IP没问题后检查页面响应内容发现接口返回体结构变了需要在请求头里带额外的加密参数最后基于分析结果把请求频率从原来的并发5线程降到了单线程轮询虽然采集时长翻倍但稳定性大幅提升。这里要重点说明合规性问题自行采集任何平台数据都必须在法律和平台规则允许的范围内进行。我的做法是严格控制数据量和使用方式只采集分析所需的最小样本量不做任何形式的恶意攻击或高频抓取。你如果打算复现这个项目也请保持同样的克制。涉及个人信息的数据坚决不碰这个是底线。5. 写在最后的几点个人经验t3code这个项目做到现在最大的收获不是技术能力的提升而是对“数据工程全链路”这件事有了完整的手感。很多人觉得爬虫就是发个请求拿个JSON但真实世界里工程的大头其实花在数据处理的一致性、稳定性和可观测性上。坐标系转换、状态字段过滤、时间窗口去重这些看似不起眼的细节才是决定分析结论靠不靠谱的基石。如果你也想从零搭一套类似的出行数据分析工具我的建议是先别急着追求“好看”的界面先把一条最窄的通路走通——手动拿一次数据、落库、在Python里手动算一遍指标、再手动画一张静态图。整条链路顺了之后再去考虑定时任务、前端框架、多城市扩展这些增量复杂度。反过来一上来就搭分布式调度八成会被枝节问题淹没最后代码删删改改浪费大量时间。最后分享一个小技巧每次采集任务完成后额外备份一份原始JSON不要只保留清洗后的数据。因为会有一天你发现清洗逻辑写错了原始数据还在就能重新跑一遍不用重新采集。这个习惯已经救了我好几次也推荐你养成。