1. 凌晨三点还在点刷新:先算清楚手工监控的真实成本
去年有段时间,我的浏览习惯彻底被一个预约页面绑架了:早上睁眼先点一次刷新,通勤路上点一次,午休点一次,晚上躺床上还要再点两三次。那段时间我甚至形成了条件反射,手机一震动就以为页面变了。后来我认真算了一笔账,发现自己真正浪费掉的并不是那几十次点击的几十秒,而是被切碎之后再也拼不回来的注意力。这篇文章讲的自动提醒工具,就是在这种情况下被我一点点做出来的:它只做一件事——代替我去看那个页面,看到值得通知的变化,就用一条消息把我叫醒。
这篇内容适合三类人参考。第一类是正在被重复性页面检查消耗精力的朋友,不管是预约名额、库存补货、报名开放还是工单状态,本质都是同一套逻辑;第二类是有基础脚本能力、想把一个临时小脚本做成能连续跑几个月的小工具的人;第三类是纯粹想了解“轮询 + 差异判定 + 通知”这套范式的人。整套方案的核心关键词就三个:定时轮询、变化判定、消息推送。不需要服务器集群,不需要复杂框架,一台常年开机的小机器或者一个免费额度的云函数就能跑起来。
1.1 手工刷新最贵的不是时间,是注意力碎片
很多人评估这类事情的时候喜欢算“一次刷新三秒钟,一天三十次也就一分半”,这个算法是错的。真正的损耗在于上下文切换:你在写方案的时候点一次刷新,回来之后重新进入状态要花掉三五分钟;你在陪家人吃饭的时候点一次刷新,话题就断了。一天下来被打断十几次,损失的是整块的时间,而不是零散的那几十秒。
我在改成自动提醒之后做了个粗略对比:以前手工盯着页面的那两周,每天有效工作时间大概被削掉一个半小时;改成工具提醒之后,我一天只在自己方便的时间段集中处理一次,其余时间完全不碰那个页面。工具的价值不在于它比我点得快,而在于它把“要不要看”这个决策从随机打断变成了固定节奏。
还有一点容易被忽略:人是会疲劳的。盯着同一个页面看两百次,你会在第一百八十次的时候开始漏看——手指点下去了,眼睛没扫到列表里的新增那一行。机器不会疲劳,它每一次判断的条件完全一致,这比速度重要得多。
1.2 覆盖盲区:你睡觉的那八小时才是放号高峰
手工监控最大的漏洞不是频率不够,而是时段覆盖不全。我统计过自己查看页面的时间分布,基本集中在早九点到晚十一点,而深夜里页面上发生变化的概率一点都不低。原因不难理解:很多时间段的名额是别人取消后回流的,取消行为本身是分散的,凌晨和清晨完全可能冒出来。
这就引出了整个工具设计里最关键的一个认知:你不需要更快的监控,你需要不间断的监控。每三分钟查一次、只在白天查,一共覆盖 280 次左右;每十分钟查一次、全天不间断,也能覆盖 144 次。后者的有效发现率通常更高,因为它把最宝贵的夜间时段包了进去,而且请求量还更小,对目标页面更友好。
提示:在动手写代码之前,先花十分钟记录一下自己的作息和查看习惯,据此决定监控时段。覆盖盲区的补全,收益远大于把轮询间隔从三分钟压到一分钟。
1.3 第三方提醒服务必须先问清楚的三件事
市面上确实有现成的提醒服务,我也试过几个。用不用它们不是重点,重点是在把预约这件事交给别人之前,你得先弄清三个问题。
第一,它读的是什么。是公开页面的只读信息,还是需要你交出账号凭据?如果是后者,你等于把身份验证的全部权限交出去了,一旦对方存储不当,后果自己承担。我个人只接受纯只读、不涉及任何登录态的方案。
第二,它的刷新节奏是不是你能控制的。有些服务的提示延迟很长,因为它自己也在控制成本;有些则过于激进,反而容易触发对方的访问限制。节奏不透明就意味着你无法判断消息的可信度。
第三,它的通知能不能落到你确定会看的地方。我见过不少服务只在它自己的应用里弹通知,而我根本没有常开那个应用的习惯,等于白搭。这一点后面第 5 节会详细展开。
想清楚这三个问题之后,我最终选择了自己写。不是因为我排斥现成工具,而是因为这套逻辑本身足够简单,自己实现一遍反而更可控。
2. 工具的整体骨架:采集、判定、通知三段式
整套工具被我拆成三段,从下到上依次是采集层、判定层、通知层。这个拆分不是为了架构好看,而是为了让每一段都能单独调试、单独替换。我最初写的版本是“抓取—比较—发送”揉在一个函数里的一百多行脚本,改到第二周就改不动了:想换通知通道要动抓取代码,想调节轮询间隔要把判定逻辑也读一遍。拆开之后,三个部分各自有清晰的输入输出契约,改哪一块都不影响另外两块。
三段之间的数据流很朴素:采集层把一次抓取的结果整理成一个纯数据结构(比如一个列表,每个元素是一个名额条目,含日期、地点、类型、状态);判定层拿着一份“上一次的快照”和“这一次的快照”做比较,输出一个变化事件;通知层收到事件之后决定发不发、发什么、发到哪。整条链路里,只有采集层会碰到网络和外部页面,判定层是纯函数,通知层只负责投递。这个边界划清楚之后,测试成本会低一个量级——我可以在本地拿两份手写的快照数据跑判定逻辑,完全不需要联网。
2.1 采集层只需要做一件事:把页面翻译成可比较的数据
采集层的职责边界必须收得很紧。它不应该做任何判断,比如“这个名额是不是比上次更早”“这个类型我感不感兴趣”,这些全部交给判定层。采集层就干一件事:把这一次看到的页面,翻译成一个结构化的快照,然后原样交给下一层。好处是当页面改版时,你只需要改一个文件里的解析逻辑,其它部分完全不动。
具体实现上,我会优先找结构化数据。现在很多页面的渲染其实依赖后端返回的 JSON,浏览器里打开开发者工具,切到网络面板,筛一下请求类型,大概率能找到那个返回列表数据的接口。直接请求这个接口,比解析 HTML 省事一个数量级,字段名清晰、没有嵌套的样式干扰、结构变化时也更容易被发现。我一般会把这个接口的地址和必要的请求头抄下来,用最小的请求复现一次,确认返回结构和页面一致。
只有实在找不到结构化接口的时候,我才会退回到解析 HTML。这时候建议用稳定的选择器,比如带上 data 属性的节点,或者用“文本包含”的方式定位,尽量不依赖层级很深的div > div:nth-child(3)这种写法——那种选择器在页面加一个广告位的时候就会全盘失效。
import hashlib import json def build_snapshot(raw_items): """把抓取结果整理成统一结构的列表,供判定层使用""" snapshot = [] for item in raw_items: snapshot.append({ "date": item.get("date", "").strip(), "location": item.get("location", "").strip(), "type": item.get("type", "").strip(), "status": item.get("status", "").strip(), }) # 排序后再算指纹,避免接口返回顺序波动造成假变化 snapshot.sort(key=lambda x: (x["date"], x["location"], x["type"])) return snapshot def fingerprint(snapshot): payload = json.dumps(snapshot, ensure_ascii=False, sort_keys=True) return hashlib.sha256(payload.encode("utf-8")).hexdigest()这段代码里有两个细节值得单独说。一是排序:很多接口返回的条目顺序并不固定,同样的内容换了顺序,直接比较就会认为“变了”。排序之后再算指纹,可以滤掉这类假变化。二是字段裁剪:快照里只保留真正影响判断的字段,不要把整个响应原样塞进去,否则对方在响应里加了一个时间戳字段,你就再也收不到“没变化”这个结论了。
2.2 判定层:定义什么才算一次值得打扰你的变化
判定层是整个工具里最需要动脑子的地方,也是最容易做歪的地方。新手常犯的错误是把“快照不相等”直接等同于“有变化”,结果就是收到大量无效提醒:页面上的刷新时间变了、条数统计的文案变了、接口里某个字段带了毫秒级时间,全都会触发一次通知。三天之后你就开始无视这些消息了,工具等于失效。
我的做法是把变化分成三类,只有前两类才值得推送:
| 变化类型 | 判定条件 | 是否推送 |
|---|---|---|
| 新增 | 本次快照中存在、上次快照中不存在的条目 | 推送 |
| 消失 | 上次存在、本次不存在(说明被占用或已关闭) | 视情况,通常推送 |
| 属性变化 | 同一条目的状态字段从“可预约”变为“不可预约” | 推送 |
| 元信息变化 | 时间戳、总数文案、无关字段 | 忽略 |
具体实现上,用集合差集比逐字段比对清晰得多。把每个条目转成一个可哈希的元组(日期、地点、类型拼起来),两份快照的元组集合做差,得到的就是新增和消失。属性变化则用字典映射来比,键是元组,值是状态。
def diff_snapshots(old_snapshot, new_snapshot): def key(item): return (item["date"], item["location"], item["type"]) old_map = {key(i): i for i in old_snapshot} new_map = {key(i): i for i in new_snapshot} added = [new_map[k] for k in new_map.keys() - old_map.keys()] removed = [old_map[k] for k in old_map.keys() - new_map.keys()] changed = [ new_map[k] for k in new_map.keys() & old_map.keys() if new_map[k]["status"] != old_map[k]["status"] ] return {"added": added, "removed": removed, "changed": changed}这三行集合运算替代了原来几十行 if-else,而且逻辑一眼能看懂。我在实际调试时发现,把判定写成这样之后,加新规则变得很容易——比如“只关注某个特定地点”,在 key 生成的时候过滤掉其它地点就行,不用动比较逻辑。
2.3 通知层:决定这条消息值不值得叫醒你
通知层的设计目标不是“把消息发出去”,而是“让这条消息在正确的时间以正确的方式被你看到”。这两件事差别很大。我第一版工具是收到变化就立刻发消息,不分时段,结果第一天夜里被震醒了四次,其中三次是同一个变化被不同轮询重复捕获。
后来我加了两道闸门:时段闸门和合并闸门。时段闸门规定,非紧急的变化只在早上七点到晚上十一点之间推送,夜间发生的变化攒到早上一并发出;合并闸门规定,五分钟内的同类变化合并成一条消息,而不是发五条。这两道闸门加上去之后,消息量从一天几十条降到一天两三条,而实际上没有漏掉任何一个关键窗口。
3. 轮询节奏怎么定:从“越快越好”改成“够用就好”
绝大多数人第一次写这类脚本,第一反应是把间隔设成十秒。我试过,结果是两天之内就被目标页面拒绝了好几次,而且日志里全是超时错误,反而比三分钟一次漏得更多。轮询间隔是一个需要推导的参数,不是拍脑袋定的。
3.1 间隔的推导过程
推导的核心变量有三个:单个名额从出现到被占用的平均存活时长、你能接受的决策延迟、目标页面能承受的请求量。假设根据你观察到的历史经验,一个名额出现后平均能存活二十分钟左右才被抢走,那么理论上的最短间隔只要小于这个存活时长,你就不会因为轮询太慢而系统性错过。取存活时长的十分之一到五分之一,也就是两到四分钟,就足够稳妥了。
但如果页面本身更新有缓存,比如它自己的数据每五分钟才刷新一次,那你查得再勤也没有意义,纯属浪费请求。所以第一件事是先测量页面的实际更新粒度:在一个低速状态下连续观察半小时,记录看到变化的时刻,看看最小间隔是多少。这一步很多人跳过,导致后面的所有调参都是盲调。
我的最终配置是:白天每 180 秒一次、夜间每 600 秒一次,并在每次间隔上叠加一个 ±15% 的随机抖动。抖动这个细节很重要,固定到秒的请求节奏在日志里会形成非常机械的图案,加上抖动之后请求分布更自然,也能避免多个实例同时发起请求互相干扰。
3.2 请求构造:头部、超时、连接复用
请求本身的构造有几个实操点值得单独拎出来。首先是超时要设,而且要设成短超时,比如连接超时 5 秒、读取超时 10 秒。不设超时的后果是某个请求卡住之后整个循环被阻塞,后面所有轮询全部停摆,而你在界面上看不出任何异常——这是最隐蔽的失效方式之一。
其次是条件请求。如果目标接口支持 ETag 或 Last-Modified,一定要用上If-None-Match和If-Modified-Since。服务端返回 304 的时候你连响应体都不用解析,既省流量又省时间,对双方都友好。
import time import requests HEADERS = { "User-Agent": "Mozilla/5.0 (compatible; PersonalMonitor/1.0)", "Accept": "application/json, text/html;q=0.9", } def fetch(url, etag=None, timeout=(5, 10)): headers = dict(HEADERS) if etag: headers["If-None-Match"] = etag resp = requests.get(url, headers=headers, timeout=timeout) if resp.status_code == 304: return None, etag resp.raise_for_status() return resp.json(), resp.headers.get("ETag")注意 User-Agent 里我明确写了这是个个人监控脚本并留了版本号,这既是一种自我标识,也方便对方在需要时识别流量来源。不要伪装成浏览器去规避识别,那是另一回事,也容易把简单问题复杂化。
3.3 失败处理:状态码分类与退避策略
失败处理最忌讳“一刀切全重试”。不同的失败原因要区别对待,我的处理策略整理成了下面这张表:
| 状态码/异常 | 含义 | 处理方式 |
|---|---|---|
| 200 | 正常 | 解析并判定 |
| 304 | 内容未变 | 跳过判定,记录心跳 |
| 429 | 请求过快 | 立即把间隔翻倍,冷却至少 10 分钟 |
| 403 | 被拒绝访问 | 停止轮询,发出自身告警,人工检查 |
| 5xx | 服务端异常 | 指数退避重试,最多 3 次 |
| 连接超时 | 网络抖动 | 记录,等下一轮,不立即重试 |
| 解析失败 | 页面结构变了 | 立刻发出自身告警,这是最需要人工介入的 |
退避我采用指数加抖动的方式:第 n 次连续失败后的等待时间是base * 2^n再乘一个 0.8 到 1.2 的随机系数,base取轮询间隔本身。连续失败超过五次就暂停轮询,转入告警状态,避免在一个已经不可用的目标上死磕。
提示:429 和 403 的处理方式必须区分对待。429 是“慢一点”,403 是“别来了”。把 403 当成 429 处理并继续重试,只会让情况变得更糟。
4. 解析与去重:把“看起来变了”变成“确实变了”
这一段是很多人做完工具之后最不满意的地方。脚本跑通了,通知也能收到,但一周之后就开始嫌它吵。问题几乎都出在解析和去重这两个环节上——判定太粗、去重太弱。
4.1 优先找结构化数据,HTML 只做兜底
前面提过优先找接口,这里补充一下找不到接口时的做法。我的经验是先看页面里有没有内嵌的 JSON,比如某些页面会把初始数据塞在一个 script 标签里,用正则把它抠出来json.loads一下就能用,这比解析 DOM 可靠得多。
如果只能解析 HTML,用解析库而不是正则。我见过太多用正则匹配列表的脚本,在页面上多了一个空格或者换行之后就再也匹配不到了。用BeautifulSoup或lxml,按属性定位节点,取文本的时候统一做strip()和空白字符归一化,能挡掉大部分无意义的变化。
from bs4 import BeautifulSoup def parse_html(html): soup = BeautifulSoup(html, "html.parser") items = [] for row in soup.select("tr[data-slot-id]"): cells = [c.get_text(strip=True) for c in row.select("td")] if len(cells) < 4: continue items.append({ "date": cells[0], "location": cells[1], "type": cells[2], "status": cells[3], }) if not items: raise ValueError("解析结果为空,页面结构可能已变化") return items最后那句raise ValueError是整个函数里最重要的一行。解析结果为空是页面改版最典型的信号,如果它默默返回一个空列表,判定层会以为“所有名额都消失了”,你要么被一堆错误的消失提醒淹没,要么在无提示的状态下一直收不到消息。空结果必须当成异常处理,不能当成正常结果。
4.2 指纹与差集:判断新增、消失、改期
判定的时候我用了两层机制。第一层是整体指纹,用第 2 节那个fingerprint()函数算一次,如果和上次相同,直接跳过,不进入逐条比较。这一层挡掉了 90% 以上的轮询,绝大部分时间页面根本没变化,没必要做详细比对。
第二层是逐条差集,只在整体指纹变化时执行,产出新增、消失、状态变化三个列表。两层机制的好处是计算量小,而且日志里一眼能看出“这一轮是真变了还是假变了”——我会在每轮日志里记下指纹的前八位,事后回溯的时候非常方便。
这里有个坑要提醒:日期的格式化方式会变。有的页面在名额充足时显示“2025-06-01”,紧张时显示“2025年6月1日”,同一个日期两种写法,你的指纹就会认为变了两条完全不同的记录。解决办法是在归一化阶段就统一格式,用正则把所有日期写法统一成 ISO 格式,再做比较。
4.3 冷却窗口与合并通知
冷却窗口解决的是“同一个变化被连续捕获”的问题。假设某一轮抓到了新增名额,下一轮这个名额还在、又新增了一个,如果不做冷却,你会连着收到两条消息,其中第一条的信息已经过时了。
我的做法是:任何一次推送之后,进入 5 分钟的冷却期,冷却期内产生的新变化不立即推送,而是累积到冷却结束时合并成一条“过去 5 分钟新增 3 个名额”的消息。这个策略在名额密集回流的时候特别有用,我实测过一个晚上连续回流十几个名额的情况,合并之后只发了两条消息,信息完全没丢。
另外一个细节是消息里的时间戳必须用目标时区,而不是你本机的时区。这个在下一节的踩坑部分会专门讲,因为我自己在这上面吃过不小的亏。
5. 提醒通道怎么选:让消息出现在你一定会看的地方
通知通道的选择标准只有一个:你多久看一次它。选一个你一小时才打开一次的应用,前面所有的实时性优化都白做了。我把常见的四种通道放在一起对比过,结论是最好不要只用一种。
5.1 四种常见通道的对比
| 通道 | 到达速度 | 可靠性 | 配置成本 | 适合场景 |
|---|---|---|---|---|
| 邮件 | 慢,可能进垃圾箱 | 中 | 极低 | 每日汇总、留档 |
| IM 机器人 Webhook | 快 | 中高 | 低 | 主力通道 |
| 手机系统推送 | 快 | 取决于平台 | 中 | 夜间强提醒 |
| 短信 | 快 | 高 | 有成本 | 关键变化兜底 |
我的配置是主通道走 IM 机器人 Webhook,辅通道走邮件。Webhook 的好处是配置简单、消息能直接进到我已经常驻的应用里;邮件作为辅通道的意义在于留档和交叉验证——如果哪天 Webhook 挂了,我还能从邮件里发现漏掉的通知。这两个通道同时失效的概率很低。
实际配置 Webhook 的时候,注意几个细节。一是请求体格式,大多数机器人要求特定的 JSON 结构,字段名写错会返回错误但 HTTP 状态码可能还是 200,所以发送之后要看响应体里的状态字段,不能只看状态码。二是超时和重试,通知发送失败不要重试太多次,否则消息延迟会累积,设成最多重试两次、间隔五秒就够了。
5.2 消息正文的排版:三行之内说清楚
通知的正文比通道更重要。我在收到过大量冗长通知之后总结出一条:第一行必须是结论,后面才是细节。因为你在锁屏界面看到的就是第一行。
我的消息模板长这样:
【发现新增名额】共 2 条,最早 6 月 3 日 - 6/03 上午 某地点 A类 - 6/07 下午 某地点 B类 抓取时间 2025-06-01 07:12(目标时区)第一行给数量和最早日期,中间给明细,最后一行给抓取时间。最后一行的时间戳非常关键,它让你能判断这条消息是刚刚产生的还是延迟送达的。
还有一点经验:不要把所有字段都塞进通知里。我第一版把每个名额的所有属性都列出来,一条消息十几行,结果在手机上根本看不清。后来精简到只保留日期、时段、地点、类型四个字段,阅读效率提升非常明显。完整信息都写在日志里了,需要细节的时候去翻日志就行。
6. 让它能连续跑三个月:日志、心跳与自愈
这类工具有一个共同特点:写出来只用一天,让它长期可靠运行要花一周。绝大多数人失败在这后一步——脚本跑了两周,某天悄无声息地停了,而你完全不知道,还以为这段时间一直没有变化。这一节讲的就是怎么让工具自己告诉你“我还活着”。
6.1 日志该记什么,不该记什么
日志的第一原则是结构化,用 JSON 行格式而不是自由文本,这样事后可以用命令行工具直接筛。我每一轮轮询记录一条,字段包括:时间戳、轮询序号、HTTP 状态码、耗时、这次抓到的条目数量、整体指纹前八位、是否有推送、推送通道的返回状态。
不该记的东西同样重要:不要记录完整的响应体。一方面日志会迅速膨胀到几百兆,另一方面响应里可能包含一些不应该长期留存的内容。我的做法是只记录条目的数量和指纹,需要复查的时候临时打开调试开关再跑一轮。
日志轮转也要配好,按天切分、保留 14 天、超过 200MB 强制切。这个用一个现成的轮转工具就能解决,不用自己写。
6.2 心跳与死信告警
心跳机制解决的是“工具死了但你不知道”的问题。我的实现很简单:每一轮成功轮询之后,往一个本地文件里写当前时间;另外有一个独立的小任务,每 30 分钟检查一次这个文件,如果里面的时间已经超过 45 分钟没有更新,就通过另一条通道发一条“监控已停止”的告警。
这里的关键是告警要走不同的路径。如果监控脚本自己挂了,它不可能发出告警,所以检查任务必须独立于监控主循环;而告警通道最好也和主通道不同,比如主通道是 Webhook,那这条告警就走邮件。同一路径上的自我检测是不可靠的,这一点我在踩过两次坑之后才真正记住。
6.3 部署与开机自启
部署我建议用最朴素的方式:一个进程,配一个进程管理工具做自动重启,配一个开机自启。不要一上来就上容器编排,那会引入一堆与业务无关的复杂度。
进程管理工具要配三件事:崩溃自动重启、重启间隔限制(避免疯狂重启循环)、日志重定向。重启间隔限制特别容易被忽略,我遇到过一次解析异常导致进程每秒崩一次,没有限制的话日志文件一小时就写满了。
开机自启也要验证一遍,方法是真的重启一次机器,而不是只在命令行里模拟。这一步我吃过亏——某些自启配置依赖交互式登录会话,重启之后根本没生效,而我在之后的两天里一直以为工具在跑。
提示:部署完成之后,给自己设一个三天的提醒,三天后回去检查日志的连续性。日志里如果有超过两倍轮询间隔的空洞,说明中间发生过重启或阻塞,需要进一步查原因。
7. 七个真实踩过的坑
下面这些坑我都亲自踩过,其中三个是静默失效——工具在跑,日志也有输出,但发出来的消息是错的或者干脆没发。这类问题最伤时间,因为你不容易意识到它存在。
7.1 页面改版导致选择器静默失效
最典型的一次:目标页面调整了表格结构,把原来的td改成了嵌套的 div,我的选择器返回空列表。由于当时我在解析函数里写的是“返回空列表”,判定层认为所有名额都消失了,于是连续发了六条“名额消失”的错误通知。后来才发现根本没消失,是我自己解析错了。
修复方案就是前面说的:空结果抛异常,同时加一条规则——连续两轮解析为空、且之前非空,判定为异常而不是真实变化,只发自身告警,不发业务通知。这条规则之后,类似问题再没造成误导。
7.2 时区与夏令时把时间判断搞反
这个坑害我错过了一个名额。目标页面用的是当地时区,我在判定“这个名额是否在我的可接受时间范围内”时,用的是本机时区的时间。平时差几个小时只是让我少收到几条提醒,但那次刚好碰上夏令时切换,差值多了一个小时,一个本来符合条件的名额被我判成了不符合,直接过滤掉了。
修复方案很直接:所有时间比较统一转换成绝对时间戳,只在最终生成通知文案的时候才转换回目标时区的可读格式,并且在消息里明确标注是哪个时区。所有涉及时区的地方,都在配置里写成一个显式的常量,不要依赖系统默认值。
7.3 会话过期和验证码
在只读公开信息的前提下,本来不应该涉及会话。但有些页面即便是公开信息也需要一个临时的会话标识,这个标识会在几小时后失效,失效之后返回的页面结构和正常页面完全不同,于是触发解析异常。
我的处理原则是:一旦遇到需要人工验证的页面,就停下来,不要试图绕过。工具在这种情况下应该发出一个明确的通知,告诉使用者“需要手动处理一次”,然后暂停轮询。任何试图自动化处理验证环节的做法,既不可靠也不合适。
7.4 通知通道自己挂了
有次 Webhook 的令牌过期了,接口返回 401,而我的代码只检查了网络层异常,没检查业务层返回码。结果那两天所有通知都石沉大海,日志里一切正常。
修复之后,通知发送函数会在两种情况下报警:网络异常,以及响应体里表示失败的字段。而且每天会做一次“通知通道自检”,主动发一条测试消息,确认通道可用。这个自检看起来多此一举,但它是唯一能提前发现问题的手段。
7.5 空闲时段的误判与假变化
页面在夜间会显示“暂无数据”的占位行,我早期把它当成一个正常的条目解析了进去。于是每天凌晨我都能收到一条“新增:暂无数据”的通知。解决办法是在归一化阶段过滤掉明显的占位文案,维护一个小的排除词典。
7.6 并发实例导致的重复推送
我在两个地方各跑了一份,想着双保险。结果是每个变化都被推送了两次,而且由于两份的轮询相位不同,有时候一个先推送、一个后推送,看起来像两次不同的变化。后来改成只跑一份,另外用一个独立的心跳检查来保证可用性。
7.7 依赖库升级后的行为变化
某次例行升级之后,HTTP 客户端对某些响应头的处理方式变了,导致 ETag 缓存逻辑失效,每一轮都返回 200 全量数据。这个问题的表现不是报错,而是请求量悄悄涨了三倍。教训是:依赖版本要锁定,升级时先在一份旁路日志上跑两天,比对行为是否一致,再切换主力。
8. 边界感:这个工具该做和不该做的事
写到这里,技术部分基本讲完了,但有一件事必须单独拿出来说,因为它决定了这个工具能不能长期用下去。
8.1 只读、限速、不绕过
这个工具的定位非常清楚:它是一个只读的观察者,不是参与者。它只读取公开可见的信息,不做任何提交、预约、占位之类的动作。一旦跨过这条线,工具的性质就变了,面临的不只是技术风险。
限速是另一条必须守住的线。我给自己定的规矩是:单次轮询只发一个请求,间隔不低于两分钟,遇到 429 立刻退避。这些数字不是为了性能调优,而是为了让这个工具对目标系统的影响尽可能小。一个合理的自我约束是:假设所有人都用和你一样的节奏访问,这个系统还能不能正常服务?如果答案是否定的,那就把节奏放慢。
不绕过任何访问控制同样重要。遇到需要人工验证的环节就停下来,遇到 403 就停止轮询并告警,遇到结构变化就人工确认。把这些处理成“明确的失败”而不是“想办法绕过去”,工具会更简单,也更让人放心。
8.2 个人信息与凭据的最小化
工具里不需要存任何账号密码,不需要存任何身份信息,这一点在架构设计阶段就要定死。如果你发现自己的实现里出现了“登录”“令牌”“凭据”这类东西,先停下来想想是不是走错了方向。我的配置里只有一个目标地址、一个轮询间隔、一个通知 Webhook 地址,其余什么都没有。
日志和快照文件的留存也要克制。快照只需要保留最近两份(上一份和当前一份),用来做差集就够了,更早的没有保留价值,留着反而增加泄露面。通知内容里也不要带任何可以定位到个人的信息。
说实话,这套东西从第一行代码到我觉得“可以放心不管它了”,前后改了大概六稿。前两稿都在追求“更快的发现速度”,第三稿开始才转向“更少的误报”,到第五稿才把心跳和自检补上。如果让我重新做一遍,我会把顺序反过来:先花半天时间把日志和告警搭好,再写抓取逻辑。因为一个你不信任的工具,你最终还是会回去手工刷新,那前面所有的工作就都白做了。
最后分享一个小技巧:工具上线之后,先别急着关掉手工刷新的习惯,让它并行跑上一周,每天对比一下“工具告诉我的”和“我自己看到的”,把差异记下来。这一周里你会找出绝大部分的判定规则问题,而且这些问题只有在真实数据上才会暴露出来。等连续三天没有差异,你就可以放心把那个页面从收藏夹里移走了。