Skip to content

把八家金店放进一张榜单:异构金价采集与行情展示 ​

做一个金价页面不难,难的是持续从结构各异、随时会变化的品牌官网取到数据,再把不同字段口径整理成用户能够横向比较的行情。

一、行情产品的核心是数据链路 ​

「金价排行榜」覆盖周大福、六福珠宝、周六福、周生生、老庙黄金、老凤祥、周大生和潮宏基。数据来源既有 JSON API,也有普通 HTML 页面,字段名称、价格分类和更新时间格式均不相同。

它后来成为「零碎百宝箱」里最早稳定运行的行情工具,也为其他数据型功能验证了整条采集链路。

完整链路可以拆为四层:

mermaid
flowchart LR
    Sources[品牌官网/API] --> Adapters[品牌采集适配器]
    Adapters --> Normalize[统一价格数组]
    Normalize --> History[(BrandPrice 历史快照)]
    History --> API[排行榜与详情 API]
    API --> MiniProgram[小程序品牌卡/趋势图]

网页选择器变化、单个品牌超时、某类价格暂时缺失,都不应该让整张榜单不可用。因此采集端的首要目标不是“写一个万能解析器”,而是隔离变化。

二、每个数据源一个适配器 ​

总调度器只维护品牌代码到采集函数的映射:

python
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 明细:

text
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 表达系统最近一次实际写入的时间。二者分开很重要:来源可能数小时不更新,但采集任务仍在正常运行。排障时可以据此区分“采集器停了”和“上游没有新行情”。

如果数据规模继续增长,还可以沿着现有模型增加两项约束:

  1. 在应用层按日 upsert 的基础上,为 (brand_id, update_date) 增加数据库唯一约束,防止并发采集插入重复日记录;
  2. 为排行榜查询建立 (brand_id, update_time) 组合索引;如果未来改为小时级留档,再对旧数据做日级归档。

五、调度失败要局部化 ​

服务启动时初始化品牌元数据,随后 APScheduler 在每小时指定分钟执行全量采集。每个品牌拥有独立的 try/except,所以某一来源失败只会留下旧快照,不会中断其他来源。

mermaid
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 天和全部范围,绘制折线与面积,点击节点查看数值。项目直接把数据映射为百分比坐标:

js
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。为每个品牌配置基本合理区间,并在写库前校验关键价格项,比只捕获异常更有效。

九、可复用的经验 ​

异构数据聚合的稳定性来自边界清晰,而不是解析代码足够聪明:

  1. 一个来源一个适配器,把变化控制在最小文件内;
  2. 用统一快照外壳保存可查询字段,用 JSON 容纳合理差异;
  3. 采集失败局部化,保留上一份带时间戳的有效数据;
  4. API 面向用户任务生成比较视图,不泄漏采集源的原始结构;
  5. 对“解析成功但数据错误”建立业务校验和监控。

从官网抓到一个数字只是开始。让这个数字可比较、可追溯、可降级,才是一条能长期运行的行情数据链路。

Released under the MIT License.