把八家金店放进一张榜单:异构金价采集与行情展示
做一个金价页面不难,难的是持续从结构各异、随时会变化的品牌官网取到数据,再把不同字段口径整理成用户能够横向比较的行情。
一、行情产品的核心是数据链路
「金价排行榜」覆盖周大福、六福珠宝、周六福、周生生、老庙黄金、老凤祥、周大生和潮宏基。数据来源既有 JSON API,也有普通 HTML 页面,字段名称、价格分类和更新时间格式均不相同。
它后来成为「零碎百宝箱」里最早稳定运行的行情工具,也为其他数据型功能验证了整条采集链路。
完整链路可以拆为四层:
flowchart LR
Sources[品牌官网/API] --> Adapters[品牌采集适配器]
Adapters --> Normalize[统一价格数组]
Normalize --> History[(BrandPrice 历史快照)]
History --> API[排行榜与详情 API]
API --> MiniProgram[小程序品牌卡/趋势图]网页选择器变化、单个品牌超时、某类价格暂时缺失,都不应该让整张榜单不可用。因此采集端的首要目标不是“写一个万能解析器”,而是隔离变化。
二、每个数据源一个适配器
总调度器只维护品牌代码到采集函数的映射:
crawler_map = {
'chow-tai-fook': chow_tai_fook.crawl,
'luk-fook': luk_fook.crawl,
'chow-sang-sang': chow_sang_sang.crawl,
'lao-miao': lao_miao.crawl,
# ...
}
for brand in brands:
try:
crawler_map[brand.code](brand)
except Exception as exc:
print(f'采集 {brand.name} 失败: {exc}')每个适配器自行处理请求方式、Header、超时、HTML 选择器或 JSON 路径,并最终写入统一的 BrandPrice。这种朴素的映射比一个充满条件分支的采集类更容易维护:某个网站改版,只修改对应文件;某个品牌失败,其他品牌仍能继续更新。
当前适配器使用 Requests 访问数据源,HTML 页面由 BeautifulSoup 解析,JSON 接口直接读取结构化字段。所有网络请求都设置超时,避免一次无响应占住整个调度周期。
三、统一“外壳”,保留价格明细差异
品牌之间并不存在完全一致的价格模型。有的提供足金饰品、投资金条和回收价,有的还区分铂金或地区。强行设计几十个可空数据库列,会让每增加一个品牌都修改表结构。
项目采用稳定外壳加 JSON 明细:
Brand
id, code, name, logo, remark
BrandPrice
brand_id, price(JSON), unit,
update_time, update_date, record_time其中 price 保存归一化后的价格项数组,每项包含展示名称与数值。关系型字段负责品牌关联、日期过滤和排序,JSON 保存来源之间不可避免的差异。
这个设计并非意味着“任何内容都塞进 JSON”。排行榜需要跨品牌比较,因此 API 会从价格数组中提取统一的 benchmark_price。详情页则保留完整分类,让用户看到该品牌实际提供的价格口径。
换句话说,统一发生在查询目标上,而不是抹平所有源数据。
四、按日快照比只保留当前值更有价值
系统为每个品牌、每个日期维护一条 BrandPrice。定时任务在日内再次采到数据时更新当天记录,跨日后才创建新记录。这样既不会让小时级重复数据无限增长,又能保留每日历史,并同时支持两类查询:
- 排行榜:每个品牌只取最新一条快照,再按基准价排序;
- 品牌详情:按时间倒序读取快照,形成历史趋势。
update_time 优先表达来源给出的更新时间,来源未提供或无法解析时回退到系统时间;record_time 表达系统最近一次实际写入的时间。二者分开很重要:来源可能数小时不更新,但采集任务仍在正常运行。排障时可以据此区分“采集器停了”和“上游没有新行情”。
如果数据规模继续增长,还可以沿着现有模型增加两项约束:
- 在应用层按日 upsert 的基础上,为
(brand_id, update_date)增加数据库唯一约束,防止并发采集插入重复日记录; - 为排行榜查询建立
(brand_id, update_time)组合索引;如果未来改为小时级留档,再对旧数据做日级归档。
五、调度失败要局部化
服务启动时初始化品牌元数据,随后 APScheduler 在每小时指定分钟执行全量采集。每个品牌拥有独立的 try/except,所以某一来源失败只会留下旧快照,不会中断其他来源。
sequenceDiagram
participant S as APScheduler
participant C as GoldCrawler
participant A as 品牌 A
participant B as 品牌 B
participant DB as MySQL
S->>C: 每小时触发
C->>A: 请求并解析
A-->>C: 超时/结构变化
Note over C: 记录失败,继续下一品牌
C->>B: 请求并解析
B-->>C: 归一化价格
C->>DB: 写入 B 的新快照这体现了行情聚合的一个关键原则:陈旧但标明时间的数据,通常好过整页不可用。前端必须展示更新时间,用户才能判断数据新鲜度;后端则应进一步为连续失败次数、最后成功时间和异常价格波动增加监控。
需要特别注意,当前调度器包含“应用启动立即采集一次”的行为。单进程容器中很直接,但若未来改为多 worker WSGI 或多个副本,每个进程都可能启动调度器。届时应将采集任务拆为独立 worker,或使用分布式锁保证单实例执行。
六、接口层负责生成“可比较视图”
排行榜接口不是把数据库行原样返回,而是组合品牌信息、最新快照和用户关系:
- 基础信息:品牌 code、名称、Logo、备注;
- 行情信息:价格明细、基准价、单位、更新时间;
- 用户信息:收藏和订阅状态;
- 查询能力:按基准价排序。
收藏与订阅是两个独立关系。收藏只影响品牌在首页的位置,订阅表达用户对价格消息的关注,不能因为 UI 上都像一个“星标”就共用一张表。两张表分别以 (openid, brand_id) 建联合唯一约束,从数据库层阻止重复关系。
所有用户相关接口都从统一 JWT 中读取 openid,不接受客户端直接传用户身份。这避免了“修改请求参数即可操作他人收藏”的越权问题。
七、小程序趋势图为何不用重量级图表库
详情页的趋势图需要做的事很有限:切换 7 天、30 天和全部范围,绘制折线与面积,点击节点查看数值。项目直接把数据映射为百分比坐标:
const x = xPadding + (index / denominator) * availableWidth;
const y = 100 - ((price - domainMin) / range) * 100;然后生成 SVG path,编码为 data URI 作为背景;可点击的数据点仍用普通 view 绝对定位。这样既得到平滑、尺寸稳定的折线,也保留了小程序节点的点击能力。
纵轴范围会在最小值和最大值两侧增加 20% padding;当所有价格相同,使用固定 padding 避免除零。横向也保留边距,防止首尾圆点被裁切。这些边界处理比“能画出一条线”更决定组件是否可靠。
轻量方案的代价是可访问性、复杂坐标轴和大数据量性能有限。当前每天几十个点的行情足够适用;若要加入缩放、十字线或数千点数据,再引入成熟图表库更合理。
八、采集系统真正需要监控什么
HTTP 200 不代表价格正确。一个稳健的行情系统至少应观测:
- 每个品牌最后成功采集时间;
- 本轮得到的价格项数量;
- 与上一快照相比的变化幅度;
- 连续失败次数和解析为空次数;
- 来源更新时间与系统采集时间的差值。
例如网页改版后选择器失效,解析器可能仍返回 200,却写入空数组或 0。为每个品牌配置基本合理区间,并在写库前校验关键价格项,比只捕获异常更有效。
九、可复用的经验
异构数据聚合的稳定性来自边界清晰,而不是解析代码足够聪明:
- 一个来源一个适配器,把变化控制在最小文件内;
- 用统一快照外壳保存可查询字段,用 JSON 容纳合理差异;
- 采集失败局部化,保留上一份带时间戳的有效数据;
- API 面向用户任务生成比较视图,不泄漏采集源的原始结构;
- 对“解析成功但数据错误”建立业务校验和监控。
从官网抓到一个数字只是开始。让这个数字可比较、可追溯、可降级,才是一条能长期运行的行情数据链路。