1. 需求拆解:你要的其实不是“刷新”,是“变化”
预约这件事,本质是一场信息差博弈。名额放出来的那一刻,谁先知道,谁就有机会。于是绝大多数人的第一反应是手动刷新——F5 按到手指发酸,浏览器开了七八个标签页,手机定五个闹钟。这套操作的效率低得可怕:人不可能 24 小时盯屏幕,注意力最多维持二十分钟,而名额可能在你洗澡的那十分钟里悄悄出现又消失。所以我做的这个工具,核心目标只有一句话:把“人盯着页面”换成“程序盯着变化”,用自动提醒替代手动刷新,让该响的时候响,不该响的时候闭嘴。
先把话说清楚,这个工具不是“抢号脚本”,也不是“自动提交器”。它做的事情非常克制:周期性访问一个你已经有权访问的预约页面或接口,把返回结果和你上一次看到的结果做比对,一旦发现名额状态发生变化,通过你指定的渠道把消息推给你,剩下的操作由你自己在浏览器里完成。这个定位决定了它的技术复杂度、风险边界和长期可维护性,后面每一节的设计选择都围绕这个定位展开。
适合谁来读?如果你正在蹲一个放号不规律、取消回流频繁、名额几分钟就被清空的预约资源,并且愿意花一两个晚上把环境搭起来,那这篇内容基本可以照着抄。会一点 Python 最好,完全不会也没关系,核心逻辑不到两百行,我把每段代码的意图都写清楚了,改改参数就能跑。
1.1 预约名额的三种释放规律
很多人上来就写代码,结果跑了一周发现从来没触发过,问题往往出在没有先搞清楚名额是怎么放出来的。我观察下来,常见的有三类。
第一类是固定批次放号,比如每天某个整点或者每周某天统一释放一批。这类最容易对付,你甚至不需要高频轮询,把监测任务卡在放号前五分钟启动、放号后十分钟停止就够了。盲目全天候轮询属于纯浪费,还可能给自己招来不必要的麻烦。
第二类是取消回流,有人退掉预约,名额立刻回到池子里。这类名额出现的时间完全随机,且存活时间短,通常几分钟内就被抢走。这才是自动提醒真正有价值的场景,也是轮询频率需要认真计算的场景。
第三类是混合型,既有固定批次,又有随机回流。现实中的预约系统绝大多数属于这一类。应对方式是把监测拆成两档:放号窗口内用高频,其余时段用低频,这一点在第 3 节会给出具体的参数推导。
判断你的目标属于哪一类,最土也最有效的办法是手工观察一周:每天记录几次看到名额的时间点,画个简易的时间分布。这个动作花不了多少时间,但能帮你省掉至少一半的无效请求。
1.2 为什么我最终放弃了自动提交
一开始我也想过做全自动:检测到名额直接提交表单,省掉人工确认。动手之后发现这条路全是坑。
第一道坎是会话与校验。预约类页面普遍带有一次性令牌、图形校验、滑块验证之类的机制,这些东西的设计目的就是区分人和程序。绕过它们既违反服务条款,技术上也极其脆弱,人家改一版你就得重写一遍。
第二道坎是提交的不可逆性。自动提交意味着你把决策权交给了脚本,一旦表单里的日期、时段、就诊人信息填错,产生的后果需要你自己去处理。提醒型工具则天然安全,因为它不产生任何写入操作。
第三道坎是维护成本。全自动方案的代码量是提醒方案的十倍以上,而收益提升非常有限,因为人工确认那一下只需要三到五秒。我实测过多轮,从收到通知到点开链接、完成确认,熟练之后整个过程在十秒内,对绝大多数预约场景来说这个速度够用。
提示:把工具的能力边界画在“只读”这一侧,是长期稳定运行的前提。任何需要写入、需要绕过校验的功能,都不要往里加。
1.3 提醒型工具的真实成功率取决于什么
很多人以为这类工具的成功率取决于轮询多快,其实不是。真正决定成败的是三件事:延迟、信噪比、可达性。
延迟是名额出现到你收到消息之间的时间差,它由轮询间隔、网络往返、通知渠道推送耗时三段构成。轮询间隔占大头,但很多人忽略了通知渠道那一段——某些推送通道有几十秒的排队延迟,这一点在第 4.4 节会细说。
信噪比是指你收到的消息里,真正需要你行动的比例。一个每五分钟响一次、每次都告诉你“没有名额”的工具,三天之内就会被你静音,然后彻底失效。所以“不变化就不通知”这条规则比什么都重要。
可达性是指消息能不能真的送到你眼前。邮件可能进垃圾箱,某个即时通讯渠道可能被系统省电策略杀掉后台。我的做法是双通道冗余,一个即时推送加一个邮件兜底,两个都到才算送达。
把这三件事想明白,你就知道该在哪优化:轮询间隔压到合理下限、指纹比对做到位、通知渠道留两条。至于多开线程、分布式部署这些,对个人使用场景来说属于过度设计。
2. 技术路线选型:三条路,各有各的坑
采集一个页面的状态,技术上至少有三种做法:整页抓取做文本比对、直接调用前端接口拿 JSON、用无头浏览器渲染后提取 DOM。这三条路我全踩过一遍,结论写在下面的对比表里,但表之外还有一堆细节值得说。
选型的核心矛盾是稳定性和成本的对抗。整页抓取最省事,但页面里可能混着时间戳、广告位、推荐位,随便一个动态元素变化就会造成误触发;接口调用最干净,但需要你去浏览器开发者工具里翻请求,而且接口可能带签名参数;无头浏览器最还原,但它吃内存、启动慢,跑久了容易内存泄漏。
2.1 三种采集方式的横向对比
| 维度 | 整页抓取 + 文本 diff | 接口轮询 + JSON 比对 | 无头浏览器 + DOM 提取 |
|---|---|---|---|
| 实现难度 | 低 | 中 | 高 |
| 单次资源占用 | 中(整页 HTML) | 低(几 KB JSON) | 高(一个浏览器实例) |
| 抗页面改版能力 | 弱 | 中 | 强 |
| 误触发概率 | 高 | 低 | 中 |
| 对动态渲染的适配 | 差 | 需找对接口 | 好 |
| 长期运行稳定性 | 中 | 高 | 低 |
| 合规友好度 | 中 | 高 | 低 |
表格里最值得看的一列是“误触发概率”。整页抓取听起来简单,实际跑起来你会发现页面里有个“当前时间”的显示,每秒变一次,指纹永远在变,通知永远在响。我第一版就是这么翻车的,半夜被自己的工具吵醒三次。
无头浏览器的问题在于资源。一个 Chromium 实例常驻内存大概两三百兆,跑上几天还会有内存增长,需要定时重启。如果你只有一台 1G 内存的小机器,这条路基本走不通。
2.2 主方案:接口轮询加指纹比对
我的最终选择是第二条路。原因是它在“请求量小、解析简单、误报少”这三点上同时占优,而这三个指标恰恰决定了工具能不能长期安静地跑下去。
找接口的办法不复杂:在浏览器里打开预约页面,按 F12 切到网络面板,筛选 XHR 或 Fetch,然后手动点一下“查询”或者切换一下日期,看哪个请求返回了带名额信息的 JSON。找到之后把请求 URL、查询参数、请求头原样抄下来,先用 curl 验证一遍能不能拿到数据。这一步能不能过,决定了后面所有工作有没有意义。
需要留意的是,有些接口带了时间戳签名或者一次性 token。遇到这种情况,先判断这个 token 是不是页面加载时生成、在整个会话期间有效。如果是,你可以先请求一次页面拿到 token,再带着它去调接口,成本可以接受。如果 token 每次请求都要重新生成,那就老老实实回到无头浏览器方案去。
还有一种情况是接口返回的数据结构里,名额字段是嵌套的,比如按日期分组、组内再按时间段分。这种结构对指纹计算其实更友好,因为你可以先把数据结构化成“日期 + 时段 + 余量”的扁平列表,再做哈希,稳定性很高。
2.3 什么时候不得不上无头浏览器
有三种情况我会建议直接上无头浏览器,别在接口上死磕。
一是页面完全由前端渲染,接口返回的是加密串或者 protobuf 二进制,解析成本高于渲染成本。二是接口做了比较严格的风控,请求头里有一堆由前端 JS 动态生成的字段,逆向这些字段的时间够你把整个工具重写三遍。三是你需要模拟登录态,而登录流程里有比较复杂的交互。
用无头浏览器时有两个细节能显著提升稳定性。第一是复用浏览器上下文,不要每次轮询都新起一个实例,那样开销大到离谱。第二是抓取时只取最小的 DOM 子树,用精确的选择器定位到名额列表那个容器,别整页 innerText,否则又会掉进误触发的坑里。
注意:无头浏览器方案的内存占用会随时间增长,建议每跑 500 次轮询主动重启一次浏览器上下文,这个数字可以根据你机器的内存调整,1G 内存的机器建议降到 200 次。
3. 请求预算与频率控制:先算账再写代码
这一节是我认为最该被重视、但最容易被跳过的地方。绝大多数人写监测脚本时的思路是“越快越好”,直接把 sleep 设成 1 秒,结果要么被上游限流,要么把自己的网络环境搞得很糟糕,要么干脆触发了风控导致 IP 被临时限制。
正确的做法是先算一笔账:你的目标场景需要多快的响应,你愿意为此付出多少请求量,上游大概能容忍多少。这三个数一确定,间隔参数自然就出来了。
3.1 轮询间隔的定量推导
一天是 86400 秒。单任务在固定间隔下的日均请求量等于 86400 除以间隔秒数。把常见间隔代进去,得到下面这张表。
| 轮询间隔 | 单任务日均请求 | 理论最大响应延迟 | 适用场景 |
|---|---|---|---|
| 15 秒 | 5760 次 | 15 秒 | 高价值、名额存活极短的场景 |
| 30 秒 | 2880 次 | 30 秒 | 取消回流频繁的热门资源 |
| 60 秒 | 1440 次 | 60 秒 | 通用推荐值 |
| 120 秒 | 720 次 | 2 分钟 | 名额存活时间在十分钟以上 |
| 300 秒 | 288 次 | 5 分钟 | 固定批次放号,只需覆盖放号窗口 |
看延迟那一列要注意,它是“理论最大”,实际平均延迟约为间隔的一半。也就是说 60 秒间隔下,你平均在名额出现后 30 秒收到消息。
接下来算总量。假设你同时监控 3 个不同服务、2 个不同账号,那就是 6 个监测任务。按 60 秒间隔算,日均请求 6 × 1440 = 8640 次,平均每小时 360 次,也就是每分钟 6 次。这个量级对绝大多数站点来说是温和的,但如果上游的限流阈值是每 IP 每小时 200 次,你就已经超了。
所以第二个要算的是限流余量。假设观测到上游在每分钟超过 10 次请求时开始返回 429,那你的安全线应该设在每分钟 5 次以内,留一半余量。6 个任务在 60 秒间隔下正好是每分钟 6 次,略微超线,这时候有两条路:把间隔放宽到 90 秒,或者给每个任务加随机抖动让它们错开。我一般选后者,因为抖动还能顺带降低被识别为机器行为的概率。
poll: base_interval: 60 # 基础间隔,秒 jitter: 8 # 抖动范围 ±8 秒 night_interval: 240 # 夜间降频间隔 night_window: ["23:30", "07:00"] backoff_base: 30 # 退避基数 backoff_cap: 900 # 退避上限,15 分钟这段配置的思路是:白天按 60 秒跑并叠加随机抖动,夜间自动降到 240 秒,遇到异常时按指数退避拉开间隔。夜间降频的依据是,绝大多数人的行动能力在凌晨是零,即使收到通知也不会立刻处理,把请求省下来给白天用更划算。当然如果你的目标资源有凌晨放号的习惯,把夜间窗口去掉就行。
3.2 退避算法与抖动
真实网络里没有永远成功的请求。超时、连接重置、5xx、429,都会出现。这时候如果还按固定间隔猛冲,只会让情况更糟。我用的是标准的指数退避加随机抖动:
等待时长 = min(基数 × 2^连续失败次数, 上限) + random(0, 抖动秒数)代入具体数字感受一下:基数是 30 秒,上限 900 秒。第一次失败等 30 秒,第二次 60 秒,第三次 120 秒,第四次 240 秒,第五次 480 秒,第六次触顶 900 秒。如果连续失败六次,说明要么上游出问题了,要么你的请求方式出问题了,这时候继续高频重试没有任何意义。
抖动的价值在于打破同步。假设你同时跑 6 个任务,它们在同一秒发请求,就会形成一个小尖峰。加上 0 到 8 秒的随机偏移,这 6 个请求会散落在 8 秒窗口里,瞬时压力降到原来的几分之一。
还有一个容易被忽略的点:失败计数什么时候清零。有人写成“只要成功一次就清零”,这在网络抖动的环境下会导致反复重试。我改成“连续成功 5 次才清零”,稳定得多。
3.3 夜间降频与错峰
夜间降频的逻辑上面说了,但实现上有个细节:降频不应该在 23:30 整点突然从 60 秒跳到 240 秒,那样会形成一个明显的规律性。我通常是让间隔在半小时内线性过渡,从 60 秒逐步拉到 240 秒。这个动作对功能没有影响,但能让流量曲线更平滑。
错峰是针对多任务的。如果你的 6 个任务都在同一时刻启动,它们的请求会永远同步。解决办法很简单:启动时给每个任务加一个 0 到间隔长度的随机初始延迟,之后它们就自然地散开了。
提示:写监测脚本的时候,把自己想象成一个礼貌的访客,而不是一个来测试系统承载能力的压力工具。这个心态比任何技术手段都更能让你的工具活得更久。
4. 从抓取到通知的完整链路实现
前面讲的是思路和参数,这一节上真东西。整条链路我拆成五层:请求层、解析层、判定层、通知层、调度层。每一层职责单一,出问题的时候好定位。
代码我用 Python 写,依赖只有 requests,不引入额外的框架。理由很简单:这东西要跑几个月甚至一年,依赖越少,未来因为环境变动而挂掉的概率越低。
4.1 请求层:会话复用与请求头
import random import time import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", }) def fetch(params, retries=3): for i in range(retries): try: resp = session.get(API_URL, params=params, timeout=10) if resp.status_code == 200: return resp.json() if resp.status_code in (403, 429): time.sleep(min(30 * 2 ** i, 900) + random.uniform(0, 8)) continue except requests.RequestException: time.sleep(min(30 * 2 ** i, 900) + random.uniform(0, 8)) return None用 Session 而不是每次裸调 get,好处是连接复用和 Cookie 自动保持。一个 TCP 连接建起来要花几十毫秒,长期轮询下这个开销不能忽略。请求头尽量贴近真实浏览器,这不是为了伪装,而是很多站点会根据请求头做基础的内容协商,头不对可能直接返回一个空结构。
超时设为 10 秒是权衡的结果。太短容易误判为失败,太长则在网络卡顿时会拖慢整个循环。返回 None 表示这一轮彻底失败,交给上层决定是否记录。
4.2 解析层:只留你真正要的字段
拿到 JSON 之后不要直接拿来比较,先做结构化。这一步的价值在于把“原始响应”翻译成“业务状态”,过滤掉所有与名额无关的噪音。
def normalize(payload): slots = payload.get("data", {}).get("slots", []) result = [] for item in slots: left = int(item.get("left", 0)) if left > 0: result.append((item["date"], item.get("period", ""), left)) return sorted(result)这段代码做了三件事:只保留余量大于零的时段、把每条记录压缩成三元组、排序保证顺序稳定。排序这一步不能省,因为接口返回的顺序未必固定,不排序的话同样的状态会产生不同的指纹,直接导致误触发。
如果接口返回的结构里有嵌套,比如按日期分组、组内是时段列表,把它拍平成上面的三元组就行。拍平的好处是后面所有逻辑都只面对一种数据结构,代码量能压缩一半。
4.3 判定层:指纹、去重与状态落盘
这是整个工具的心脏。核心思路是:把结构化后的状态算成一个短哈希,只有哈希变化才触发通知。
import hashlib import json def fingerprint(normalized): raw = json.dumps(normalized, ensure_ascii=False, separators=(",", ":")) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]取前 16 位足够用了,碰撞概率在这个数据量级下可以忽略。用 JSON 而不是直接字符串拼接,是为了避免分隔符歧义——如果你用逗号拼接,("2025-07-01", "上午", 12)和("2025-07-01, 上午", "", 12)可能算出同一个串。
状态需要落盘,否则进程一重启就忘了上次看到什么,会重复通知。用一个极简的 JSON 文件就够,记录三项:上次指纹、上次通知时间、连续失败次数。
def load_state(path="state.json"): try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {"fp": "", "last_notify": 0, "fail_streak": 0} def save_state(state, path="state.json"): tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False) import os os.replace(tmp, path)先写临时文件再原子替换,这个细节很关键。我有一次机器断电,state.json 被写成了半截,下次启动直接解析失败,工具静默失效了两天。加上原子替换之后再没出现过。
主循环的逻辑就很直白了:
def main_loop(): state = load_state() while True: payload = fetch(PARAMS) if payload is None: state["fail_streak"] += 1 save_state(state) time.sleep(backoff(state["fail_streak"])) continue slots = normalize(payload) fp = fingerprint(slots) if fp != state["fp"] and slots: send_notify(slots) state["fp"] = fp state["last_notify"] = int(time.time()) state["fail_streak"] = 0 save_state(state) else: state["fail_streak"] = 0 time.sleep(next_interval())注意and slots这个条件。它的意思是“列表非空才通知”。这样做的原因是:当名额从有变成无的时候,指纹也会变,但你不希望收到一条“现在没名额了”的消息。加上这个判断,通知就只在“出现了新的可约时段”时触发。
4.4 通知层:多渠道兜底与消息模板
通知渠道的选择标准有两条:送达率高、延迟低。我一般配两条:一条即时推送(Webhook 到某个通讯工具)、一条邮件兜底。
def send_notify(slots): lines = [f"{d} {p} 余 {n}" for d, p, n in slots[:10]] body = "检测到可约时段:\n" + "\n".join(lines) body += f"\n\n查看时间:{time.strftime('%Y-%m-%d %H:%M:%S')}" body += f"\n直达链接:{BOOKING_PAGE}" try: requests.post(WEBHOOK_URL, json={"text": body}, timeout=8) except requests.RequestException: pass try: send_mail(subject="预约名额提醒", content=body) except Exception: pass两个 try 块互相独立,一个挂了不影响另一个。这一点很重要——我有过一次 Webhook 那边的服务故障,如果不是邮件兜底,那天的名额就错过了。
消息内容的设计有讲究。我见过很多人把通知写成“有新名额了,快去看看”,这种消息等于没写。合格的提醒应该让人在不打开任何页面的情况下就能判断要不要行动,所以必须包含:具体日期、具体时段、剩余数量、消息生成时间。数量限制在 10 条以内,多了在手机上要滑半天。
4.5 调度层:守护进程与开机自启
本地跑用 nohup 或者 tmux 都行,但如果机器会重启,就得配成系统服务。systemd 的配置很简单:
[Unit] Description=slot watcher After=network-online.target Wants=network-online.target [Service] Type=simple WorkingDirectory=/opt/slot-watcher ExecStart=/usr/bin/python3 /opt/slot-watcher/watcher.py Restart=always RestartSec=30 StandardOutput=append:/var/log/slot-watcher.log StandardError=append:/var/log/slot-watcher.log [Install] WantedBy=multi-user.targetRestart=always配RestartSec=30是关键。任何未捕获的异常导致进程退出,30 秒后自动拉起来。这个机制救过我很多次——某次接口返回格式变了,解析代码抛异常,进程崩了,靠自动重启撑到我发现日志。
如果你的机器资源紧张,也可以用 cron 每分钟跑一次单次执行的脚本,用文件锁防止重叠。这种方式的内存占用更低,代价是没法做精细的退避控制。
注意:日志一定要开,并且按天切割。我有一次忘了配切割,日志文件长到 4G,把磁盘写满了,导致整个服务挂掉。用 logrotate 配一条规则就能解决。
5. 通知设计:别让提醒变成噪音
工具做好之后,真正的敌人不是技术问题,是你自己的耐受度。一个天天误报的工具,一周之内必然被静音;一个从不误报但信息量太少的工具,你收到消息还得点进去看半天,最后也懒得用。通知设计这一节,讲的是怎么让提醒既有用又不烦人。
5.1 一条合格的提醒该包含什么
我把提醒的信息分成三层:判断层、行动层、溯源层。
判断层是让你一眼决定要不要动的内容,包括具体日期、时段、余量。这三样必须出现在消息的第一行,因为手机锁屏界面上通常只显示前几十个字。
行动层是让你能立刻执行的入口,包括预约页面的直达链接,或者如果你有多个账号,把对应账号的入口也附上。少一次复制粘贴,就多几秒的反应时间。
溯源层是让你排查问题的信息,包括消息生成时间、本次监测的耗时、连续失败次数。这些平时不用看,出问题的时候是救命稻草。
三层的顺序不能乱。我见过把时间戳放在最前面的写法,锁屏上一看全是“2025-07-01 03:22:11”,关键信息全被挤掉了。
5.2 三级提醒与聚合策略
名额的价值不一样,提醒的强度也该不一样。我按三档处理。
普通档:出现了非目标日期的名额,用最低优先级推送,不进声音提醒。目的是让你知道系统在工作,池子里有动静。
重点档:出现了你的目标日期范围内的名额,用正常推送加提示音。这是真正需要行动的。
紧急档:目标日期且余量大于某个阈值(比如 5 个以上),连续两次轮询都存在。这种名额相对稳定,值得全力推送,可以同时走两个渠道并加声音。
聚合策略解决的是通知风暴。假设某天放了一大批号,50 个时段同时出现,你不能发 50 条消息。我的做法是:一次轮询内的所有变化合并成一条消息,列表最多 10 条,超出的部分显示“另有 N 条”。同时加一个去重窗口,同一批状态在 10 分钟内不重复推送。
5.3 静默期与免打扰
这个功能听起来矛盾——一个提醒工具为什么要静默?因为凌晨三点的消息,除了让你睡不好之外没有任何作用,除非你真的会在半夜爬起来操作。
我的配置是:23:30 到 07:00 之间的通知只记录不推送,等到 07:00 补发一条汇总。补发的时候要标注清楚“这是夜间汇总”,否则你会误以为是刚出现的名额。
还有一种情况需要静默:已知系统维护时段。很多预约系统会定期维护,这段时间接口返回异常,如果不做处理会触发一堆失败告警。做法是在配置里维护一个维护窗口列表,窗口内直接跳过轮询。
6. 常见问题与排查实录
工具跑起来之后,遇到的问题是五花八门的,但绝大多数能归到几类。这一节我把踩过的坑整理成速查表,再挑几个有代表性的展开说。
6.1 故障速查表
| 现象 | 大概率原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 从不触发通知 | 指纹一直不变 | 打印两次的 normalized 结果对比 | 检查解析路径是否取错字段 |
| 通知疯狂触发 | 页面含动态元素 | 看两次指纹差异在哪 | 剔除时间戳类字段,或改用接口 |
| 运行几小时后停止 | 进程崩溃或内存泄漏 | 查日志尾部有无异常栈 | 配自动重启,无头浏览器定时重启 |
| 返回 403 或 429 | 触发限流或风控 | 统计近一小时请求量 | 拉长间隔,加抖动,检查请求头 |
| 数据一直是空的 | 接口参数或会话失效 | 用 curl 手动验证一次 | 更新参数、重新获取会话 |
| 通知延迟很大 | 推送通道排队 | 换个通道对比测试 | 加第二条通道做兜底 |
| 状态文件损坏 | 非原子写入 | 看 json 是否能解析 | 改原子替换,加备份 |
6.2 几个踩过的坑
第一个坑是时区。有次我怎么算都对不上,明明接口里有第二天的名额,工具就是不通知。查了半天发现接口返回的日期是按某个固定时区算的,而我的判断逻辑用的是本地时间,差了几个小时,目标日期被判定成“不在范围内”。后来所有时间处理统一转成时间戳再比较,这个坑就再没出现过。
第二个坑是编码。有些接口返回的中文时段名是 unicode 转义,有些是原始 UTF-8,做哈希的时候这两种形式算出来的结果不一样。解决办法是在 normalize 之后统一做一次规范化,确保同样的语义产生同样的字符串。
第三个坑是浮动的列表顺序。前面提过排序,但还有一种更隐蔽的情况:接口返回的列表里混了已满的时段,而这些时段的顺序是随机的。如果你的 normalize 没有过滤满额项,指纹就会因为顺序变化而抖动。过滤加排序两步都不能少。
第四个坑是通知渠道的速率限制。某个推送通道对同一接收方有每分钟消息数限制,超过之后会静默丢弃。我一开始不知道,测试的时候连发二十条,中间丢了一半。后来在通知层加了一个简单的令牌桶,每分钟最多发 5 条,超出的合并。
提示:新写好的工具不要直接放到生产环境跑,先让它空跑一天,只写日志不推送,第二天回看日志里记录的状态变化次数。正常情况下一天应该只有个位数到几十次变化,如果日志里有几百次,说明你的指纹算法有问题。
6.3 日志与自监控
日志的粒度要能支撑事后复盘,但也不能太啰嗦。我的做法是每次轮询记一行,格式固定:时间、状态码、耗时、当前指纹、是否触发。这样一天下来一千多行,出问题时用 grep 一筛就清楚了。
除了业务日志,还要加一个“心跳”机制。工具每完成 N 次轮询就往一个固定位置写一个时间戳文件,外部再挂一个极简的检查任务,如果发现时间戳超过预期太久没更新,就发一条“监测工具可能已停止”的告警。这个机制的意义是防止工具静默失效——最坏的情况不是工具报错,而是它不声不响地死了,而你以为它还在工作。
我现在的做法更省事:让工具每天固定时间给自己发一条“今日共轮询 XXX 次,触发 Y 次”的汇总消息。收到就说明活着,没收到就说明有问题,比什么监控都直观。
7. 合规边界与长期维护
这一段我想认真说,因为它决定了这个工具能不能长期存在、会不会给你带来额外麻烦。
7.1 请求礼仪与服务条款
任何公开的预约页面背后都有服务条款,条款里通常会写明禁止自动化访问、禁止高频请求之类的约束。动手之前花几分钟读一遍,不是形式主义。如果条款明确禁止,那这件事就不该做,技术上的可行性不代表行为上的正当性。
在实际操作层面,有几条我给自己定的规矩:请求间隔不低于目标站点正常用户的操作间隔;不做并发请求;遇到 429 就立刻停下来退避,不换 IP 硬冲;不做任何形式的写入操作;不把工具分享给大规模人群使用。这几条守住,你的行为边界就还在正常用户范围内。
7.2 提醒与抢占的界线
我一直认为提醒和抢占有一条清晰的界线:提醒是把信息推给你,由你做决策;抢占是用程序替你做决策并执行。前者提高的是信息效率,后者破坏的是公平性。这条界线在很多场景下其实是共识,只是很多人图省事就越过去了。
具体到实现上,有几个动作我明确不做:不自动填表提交、不绕过任何形式的校验、不搞多账号批量操作、不做名额锁定。工具只告诉你“现在有什么”,剩下的交给人。
7.3 数据卫生与隐私
工具跑起来会产生一些数据:请求日志、状态文件、通知内容。这些东西本身不含敏感信息,但如果你的脚本里硬编码了账号、手机号之类的凭据,那就要注意了。
我的做法是全部走环境变量,代码里不出现任何凭据。配置文件加个 .gitignore,别不小心传到公开仓库去。日志里凡涉及凭据的部分一律脱敏。这些是基本的工程卫生,跟工具本身无关,但漏了会很麻烦。
最后再分享一个小技巧。这套东西跑久了,你会发现真正需要优化的不是轮询频率,而是你的响应速度。我现在把通知渠道设成手机锁屏也能直接看到详情的形式,收到消息扫一眼就知道要不要动,需要动就直接从通知点进链接。从锁屏到完成确认,实测下来八秒左右。这个速度,比任何技术优化都管用。