值得买(SMZDM)好价推送机器人,运行在 Val Town 的 Deno 运行时上。 按关键词规则监控「好价」列表,命中后以富文本卡片推送到 Telegram。
这是对 yuxiaoy/smzdm 的重构版本:存储从单个 blob JSON 换成 SQLite,抓取从固定
5 页 换成增量游标翻页,推送从纯文本 标题: 链接
换成带价格/商城/热度/爆料原文/按钮的卡片。
| 文件 | 类型 | 作用 |
|---|---|---|
shared.ts | script | 环境变量、可调参数、时间与价格解析等纯函数 |
db.ts | script | SQLite 表结构、设置存取、推送历史 |
smzdm.ts | script | 好价接口客户端与增量游标翻页 |
matcher.ts | script | 规则解析与匹配 |
notify.ts | script | Telegram 卡片渲染、限流、重试 |
spider.ts | interval | 定时任务入口 |
webhook.ts | http | Telegram 命令入口(无第三方 bot 框架) |
-
在 Val Town 新建一个 val,把上面 7 个文件贴进去,注意把
spider.ts的类型设为 Interval、webhook.ts设为 HTTP,其余保持 Script。 -
配置环境变量(Settings → Environment Variables):
变量 必填 说明 BOT_TOKEN是 @BotFather 给的 bot token CHAT_ID是 接收推送的 chat id OWNER_IDS建议 允许发命令的 Telegram 用户 id,逗号分隔。留空时机器人会拒绝所有命令 WEBHOOK_SECRET否 webhook 校验密钥,不填则由 token 派生 SHA-256 -
用浏览器打开
webhook.ts的 HTTP 地址一次。GET 请求会注册 webhook 和命令菜单, 并返回注册结果。之后每次改动 URL 都要再访问一次。 -
把
spider.ts的 interval 设为 10 分钟。增量翻页在闲时只发 1 个请求, 忙时自动加深,所以跑得勤反而更省也更及时。 -
在 Telegram 里
/add_brands 你的关键词开始用。
OWNER_IDS 如果不填,任何人找到这个 bot 都会被拒绝命令——这是故意的安全默认值。
你的用户 id 可以问 @userinfobot。
一条规则是不含空格的单个 token,内部用 - 分隔多个条件:
| 写法 | 含义 |
|---|---|
apple | 含 apple |
apple-watch | 同时含 apple 和 watch |
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_date 为 00: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_TOKEN 和 CHAT_ID
存在, OWNER_IDS=42 是 webhook_test.ts 里那个「机主」的 id。
| 文件 | 覆盖内容 |
|---|---|
pure_test.ts | 价格与日期解析、静音时段、分块、规则 DSL 全部语法 |
notify_test.ts | 卡片字段、HTML 转义、实体截断、429 重试、400 降级、摘要分块 |
sql_test.ts | 从 db.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 次(首次 + 降价),防止价格反复波动时刷屏。