Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

SMZDM Telegram Bot

值得买(SMZDM)好价推送机器人,运行在 Val Town 的 Deno 运行时上。 按关键词规则监控「好价」列表,命中后以富文本卡片推送到 Telegram。

这是对 yuxiaoy/smzdm 的重构版本:存储从单个 blob JSON 换成 SQLite,抓取从固定 5 页 换成增量游标翻页,推送从纯文本 标题: 链接 换成带价格/商城/热度/爆料原文/按钮的卡片。

文件结构

文件类型作用
shared.tsscript环境变量、可调参数、时间与价格解析等纯函数
db.tsscriptSQLite 表结构、设置存取、推送历史
smzdm.tsscript好价接口客户端与增量游标翻页
matcher.tsscript规则解析与匹配
notify.tsscriptTelegram 卡片渲染、限流、重试
spider.tsinterval定时任务入口
webhook.tshttpTelegram 命令入口(无第三方 bot 框架)

部署

  1. 在 Val Town 新建一个 val,把上面 7 个文件贴进去,注意把 spider.ts 的类型设为 Intervalwebhook.ts 设为 HTTP,其余保持 Script。

  2. 配置环境变量(Settings → Environment Variables):

    变量必填说明
    BOT_TOKEN@BotFather 给的 bot token
    CHAT_ID接收推送的 chat id
    OWNER_IDS建议允许发命令的 Telegram 用户 id,逗号分隔。留空时机器人会拒绝所有命令
    WEBHOOK_SECRETwebhook 校验密钥,不填则由 token 派生 SHA-256
  3. 用浏览器打开 webhook.ts 的 HTTP 地址一次。GET 请求会注册 webhook 和命令菜单, 并返回注册结果。之后每次改动 URL 都要再访问一次。

  4. spider.ts 的 interval 设为 10 分钟。增量翻页在闲时只发 1 个请求, 忙时自动加深,所以跑得勤反而更省也更及时。

  5. 在 Telegram 里 /add_brands 你的关键词 开始用。

OWNER_IDS 如果不填,任何人找到这个 bot 都会被拒绝命令——这是故意的安全默认值。 你的用户 id 可以问 @userinfobot

规则语法

一条规则是不含空格的单个 token,内部用 - 分隔多个条件:

写法含义
appleapple
apple-watch同时含 applewatch
sony|索尼-耳机sony索尼)且 耳机
apple-watch-!表带-!钢化膜排除配件噪音
nike-<300价格不超过 300
茅台->1000价格不低于 1000
/rtx\s?40[6-9]0/正则,忽略大小写
!/翻新|拆机/负向正则

约束:

  • - 是分隔符,所以品牌名里带连字符的要写成正则。
  • 只有排除条件、没有正向条件的规则会被 /add_brands 拒绝,否则它会命中全站好价。
  • 价格解析不出来的(包邮1元起不会被价格条件挡掉,宁可多推不漏推。
  • 中文没有词边界, 这种短词误报率很高,建议配合排除词或正则。

/test 商品标题 199元 可以看每条规则命中与否以及原因。

命令

/help                    使用说明
/brands                  查看规则
/add_brands a b          添加规则
/delete_brands a         删除规则
/delete_all_brands       清空规则
/test 标题               测试规则命中情况

/malls                   查看商城名单
/block_mall 唯品会       加入黑名单
/unblock_mall 唯品会     移出黑名单
/only_mall 京东 天猫超市 只看这些商城(留空取消)
/min_worthy 0            最低「值」票数
/max_age 24              只推多久以内发布的好价
/match_content on|off    是否同时匹配爆料原文

/photo preview|photo|off 配图方式
/quiet 23 7              静音时段(北京时间),off 关闭
/drop 5                  降价超过 5% 时复推
/pause /resume           暂停 / 恢复
/run                     立刻抓取一次

/status                  运行状态
/settings                当前配置
/results [页码]          推送记录
/delete_results          清空推送记录

推送卡片下方还有三个按钮:去购买(go.smzdm.com 直达商城)、原文、以及一键把该商城 加入黑名单。

关键实现说明

为什么用 app 接口。 www.smzdm.com 在腾讯 Chaos 反爬后面,对非浏览器请求返回 JS 挑战页,Val Town 的出口 IP 拿不到数据。api.smzdm.com/v1/youhui/list 是开放 JSON, 这个端点不需要鉴权。

为什么用 article_date 而不是 article_unix_date 排序。 两个字段实测对不上:同一条 article_date00:06:41 的商品,其 article_unix_date 换算出来差了约 12 小时;两条 相隔 7 秒的商品 unix 值差了 25 分钟。所以时间排序和游标都基于 article_date(东八区)。

为什么改成增量翻页。 实测好价流 80 条只跨约 35 分钟,且存在 7 秒一条的爆发。固定抓 100 条约等于 44 分钟的流量,白天高峰期会静默漏掉中间的商品。现在的做法是:第 0 页先拉一次, 若还没追上就并行抓后续页;某一页的 article_id 已全部见过,或没有任何比时间 游标更新的条目,就停。上限 20 页。

判断「是否继续翻页」优先看已见 id 滑动窗口(最近 600 条),时间游标只做辅助。 offset=0 会出现运营置顶的老帖(实测见到 2021 年的),用「本页最旧时间」 会在第一页就误停。翻页步进按接口返回的原始行数,不按过滤后的商品数—— 整页都是运营卡时继续往下走,而不是当成列表结束。

后面某一页失败时不推进游标、也不写入已见 id,否则中间那一页会永远漏掉。

为什么过滤 article_is_timeout / article_is_sold_out 列表里混有已失效和已售罄的 条目,offset=0 第一条就可能是几年前的置顶好价。不过滤就会推送早已过期的「好价」。

为什么先发送后入库。 只有 Telegram 确认送达才写入 smzdm_deals_v1;发送失败的条目 会把游标回退到它之前,下一轮重试。旧版本是先入库再发送且忽略发送错误,一旦被限流, 那条商品就永久丢失了。

为什么用 SQLite 而不是 blob。 article_id 做主键让去重精确且 O(1);每行带时间戳 让历史按 30 天滚动过期,而不是像旧版那样攒到 150 条就整表清空——清空后所有仍在列表里的 旧商品会被当成新的重推一遍。SQLite 也不存在两次 setJSON 互相覆盖的丢更新问题。

本地测试

测试放在 tests/,不需要 Val Town 账号,也不会碰到线上数据库或真实 Telegram: fetch 全程被桩替换,std/sqlite 通过 tests/import_map.json 指向内存里的假实现。.vtignore 会让 vt push 跳过整个 tests/ 目录。

# 全部用例(含真实接口的集成测试) BOT_TOKEN=x:y CHAT_ID=1 OWNER_IDS=42 deno test --no-prompt --allow-env \ --allow-net --allow-read --allow-import \ --import-map=tests/import_map.json tests/ # 离线时跳过联网用例 SKIP_LIVE=1 BOT_TOKEN=x:y CHAT_ID=1 OWNER_IDS=42 deno test --no-prompt \ --allow-env --allow-read --allow-import \ --import-map=tests/import_map.json tests/ deno check --allow-import *.ts deno lint .

三个环境变量都是占位符。shared.ts 在导入时会校验 BOT_TOKENCHAT_ID 存在, OWNER_IDS=42webhook_test.ts 里那个「机主」的 id。

文件覆盖内容
pure_test.ts价格与日期解析、静音时段、分块、规则 DSL 全部语法
notify_test.ts卡片字段、HTML 转义、实体截断、429 重试、400 降级、摘要分块
sql_test.tsdb.ts 抽出 SQL 丢给真实 SQLite 校验,并验证降价 upsert 语义
smzdm_test.ts翻页步进、运营卡整页、已见 id 停页、并行抓取、后页失败
spider_test.ts完整链路:过滤、去重、溢出摘要、失败重试、降价复推、失败告警
webhook_test.ts密钥校验、机主白名单、命令与参数校验、回调按钮、GET 注册
live_test.ts真实接口:字段契约、游标翻页、时间戳异常、抓取速率

写测试时踩到的一个坑:webhook 的 fetch 桩要对非 Telegram 请求透传,这样和 live_test.ts 共用 isolate 时才不会把真实接口调用吃掉。

live_test.ts 里有两个断言是「设计前提的哨兵」:如果 SMZDM 修好了 article_unix_date,或者不再往列表里塞置顶老帖,它们会失败,提示对应的实现 可以简化。

从旧版本迁移

品牌列表早已在 SQLite 的 rules 键里。当前代码不再读取旧的 blob key。

已知取舍

  • /min_worthy 会挡掉刚发布的好价(票数为 0),而刚发布的往往是最值得抢的,所以默认 0。
  • 正向关键词始终只匹配标题。/match_content on 只让排除词同时看爆料原文, 避免「凑单苹果」误中 apple。默认关。
  • 单轮最多发 8 张卡片,多出来的合并成一条摘要,既不刷屏也不丢条目。
  • 同一商品最多推 3 次(首次 + 降价),防止价格反复波动时刷屏。