用 Python 和 systemd 自动预约练车
考驾照时,驾校通过微信小程序预约练车。每天上午 11 点开放第二天的名额,学员自己选择教练、车辆和时间段,提交后即算预约成功。
操作本身不复杂,问题是所有人都在同一时间操作。特别是下班后还能赶上的下午时段,通常很快就会约满。工作日上午 11 点又很容易碰上开会、写东西或者临时有事,只要晚打开几秒钟,合适的时间就可能已经没有了。
我不想每天专门守着手机刷新,于是写了一套很小的自动化程序,让一台一直在线的 Linux 服务器代替我完成预约。
整套程序由三个文件组成:
sniper.py:查询预约信息、等待放号、选择时间并提交预约sniper.service:把 Python 脚本定义成一次性的 systemd 服务sniper.timer:每天在放号前启动sniper.service
三者的关系是:
sniper.timer
↓ 到点触发
sniper.service
↓ 启动命令
sniper.py
↓ 完成一次预约后退出
这套程序陪我预约完科目二,科目三采用小班教学,练车时间直接由教练安排,不再需要自己到小程序里抢名额。因此科目二结束后,这三个文件也就完成了自己的任务。
预约流程
微信小程序里看起来只是选择一个时间段,再点击提交,背后实际需要经过几步:
获取预约项目
↓
获取对应车辆
↓
查询指定日期的时间段
↓
筛选仍有名额的候选项
↓
提交预约
脚本用到四类接口:
GET /appoint/v2/showAppointList
GET /carManager/showCarList
GET /group/showGroupInfo
POST /record/v2/saveOrUpdate
前三个接口只读取信息,重复调用通常不会改变服务器状态;最后一个接口会创建预约记录,不能采用和查询接口相同的重试策略。
请求需要携带小程序正常使用时的 access_token、Referer 和 User-Agent 等信息。这些内容来自我自己账号的一次正常请求,脚本只用于替代本人的手动操作。
为了避免把抓包时看到的内部 ID 直接写死,程序启动后会先按名称查找目标预约项目:
appoint_id = next(
appoint["id"]
for appoint in resp.json().get("content", {}).get("appointList", [])
if TARGET_SCHOOL in appoint.get("appointName", "")
)
拿到 appoint_id 后,再查询该项目下的车辆 ID。
这样后台即使重新创建了预约项目,只要显示名称没有变化,脚本仍然能够找到正确对象。这里多做一次查询,比长期依赖一个不透明的数字稳妥得多。
确定预约日期
每天 11 点开放的是第二天的练车时段。
程序不仅会被 timer 在上午自动启动,我偶尔也会在下午手动运行做测试。如果直接使用「当前日期加一天」,下午运行时就会等待第二天的放号时间,却仍然查询明天的练车日期。
因此脚本先计算下一次放号时间,再由放号日期推导对应的练车日期:
now = datetime.now(ZoneInfo("Asia/Shanghai"))
hour, minute, second = map(int, release_time.split(":"))
release_dt = now.replace(
hour=hour,
minute=minute,
second=second,
microsecond=0,
)
if now >= release_dt:
release_dt += timedelta(days=1)
practice_date = release_dt.date() + timedelta(days=1)
例如:
- 7 月 12 日上午运行:等待 7 月 12 日 11 点,预约 7 月 13 日
- 7 月 12 日下午运行:等待 7 月 13 日 11 点,预约 7 月 14 日
业务时区显式使用 Asia/Shanghai,不依赖服务器的系统时区。服务器使用 UTC 或其他时区,都不会影响练车日期的计算。
对准服务器的放号时间
第一版程序直接按照本机时间等待 11 点。
普通定时任务对几百毫秒的误差并不敏感,但练车名额通常在很短时间内就会被约走。服务器时间和本机时间存在偏差时,程序可能提前读到尚未更新的数据,也可能比其他人晚开始查询。
脚本没有另外实现 NTP 客户端,而是利用 HTTP 响应头中的 Date 作为服务器时间参考。
放号前十秒,程序先请求一次预约项目列表,并记录请求发出和收到响应的本机时间:
request_start = time.time()
resp = client.get("/appoint/v2/showAppointList")
request_end = time.time()
随后在响应头提供的服务器时间上,加上半个请求往返时间作为粗略补偿,再计算本机与服务器之间的偏差:
server_ts = (
parsedate_to_datetime(server_date).timestamp()
+ (request_end - request_start) / 2.0
)
clock_offset = server_ts - time.time()
后续等待时,不再只看 time.time(),而是使用 time.time() + clock_offset 估算服务器侧的当前时间。
这不是高精度授时。HTTP Date 通常只有秒级精度,网络上下行延迟也不可能完全相同。不过这里的目标只是避免服务器和本机存在明显时间差,并不需要精确到微秒。
等待函数会根据距离目标时间的远近调整休眠间隔:
while (diff := target_ts - (time.time() + clock_offset)) > 0:
if diff > 2.0:
time.sleep(1.0)
elif diff > 0.1:
time.sleep(0.05)
else:
time.sleep(0.001)
距离放号还有较长时间时,每秒检查一次即可;进入最后两秒后,再逐步缩短间隔。这样做只是为了避免一次长时间 sleep 在最后阶段额外晚几百毫秒,不是要让普通 Linux 主机达到实时系统的精度。
查询并选择可用时段
临近放号时刻,程序开始查询第二天的时段列表,并在放号五秒后停止:
PRE_POLL_SEC = 0.05
POLL_WINDOW_SEC = 5.0
POLL_INTERVAL_SEC = 0.05
程序在放号前 50 ms 进入查询循环,让第一条请求尽量贴近服务端更新状态的时间。这个数值只是一个很小的提前量,并不代表程序能够精确控制到 50 ms。
接口返回多个时间段后,程序逐项检查:
if item.get("status") != "open":
continue
max_person = int(item.get("maxPerson") or 0)
booked_count = len(item.get("recordList", []))
if booked_count >= max_person:
continue
候选时段必须同时满足:
- 状态已经变为
open - 当前预约人数小于最大人数
一旦发现可用候选,轮询立即结束,不再继续发送查询请求。
我当时最希望约到 17:00,其次是 08:30,所以把时间偏好单独写在配置中:
PREFERRED_TIMES = ["17:00", "08:30"]
程序先建立可用时间到 item_id 的映射,再按偏好顺序整理候选项:
preferred_ids = [
available_slots[t]
for t in PREFERRED_TIMES
if t in available_slots
]
fallback_ids = [
item_id
for start_time, item_id in available_slots.items()
if start_time not in PREFERRED_TIMES
]
return preferred_ids or fallback_ids
这里的策略是:
- 只要首选时段中有一个仍然可用,就只尝试首选时段
- 所有首选时段都没有时,才退回其他开放时段
也可以返回 preferred_ids + fallback_ids,让程序在首选失败后继续尝试任意时段。这样预约成功率会更高,但也可能约到自己根本无法参加的时间。
「约到一个时段」和「约到一个能去的时段」不是一回事,所以脚本选择了更保守的策略。
未知比失败更危险
拿到候选 item_id 后,程序调用真正创建预约记录的接口。
一次提交可能出现三种结果:
SubmitResult = Literal["success", "known_fail", "unknown"]
明确成功
服务器正常返回,并且 success 为真:
if resp.get("success"):
logging.info(
f">>> SUCCESS! Order ID: "
f"{resp.get('content', {}).get('id')} <<<"
)
return "success"
此时预约已经完成,程序立即退出。
明确失败
请求正常返回,但服务器明确表示本次预约没有成功。例如时间段在查询完成后、提交请求到达前已经被其他人约满。
这种情况下,程序能够确认没有产生预约记录,可以继续尝试下一个候选项。
状态未知
更麻烦的是提交请求发生超时或其他网络异常。此时可能发生了几种情况:
- 请求根本没有到达服务器
- 请求已经到达,但尚未处理
- 服务器已经创建预约,只是响应没有返回
- 服务器返回了内容,但客户端没有正常解析
客户端无法从一次网络异常中判断究竟是哪一种,因此程序不会把异常简单视为失败:
except Exception:
return "unknown"
主流程收到 unknown 后立即停止,不再尝试其他时段:
if result == "unknown":
logging.warning(
"Submit state unknown. "
"Stopping to avoid duplicate booking."
)
return
如果服务器已经预约成功,而客户端因为没有收到响应再次提交,就可能产生重复预约,或者触发接口的其他限制。
因此这里采用的原则是:
只有明确知道本次失败,才继续提交下一个候选项。
GET 查询失败后可以再次读取,因为它不会改变服务器状态;会创建记录的 POST 请求则不能盲目重试。对这个程序来说,未知状态比明确失败更危险。
为什么顺序提交
候选时段按照偏好顺序逐个提交:
for item_id in candidate_ids:
result = submit_once(...)
程序没有使用线程池或 asyncio 同时提交多个预约请求。
并发提交看起来更快,但多个请求也可能同时成功。即使服务端限制同一天只能预约一次,也会产生额外的冲突请求,并让客户端更难判断最终状态。
顺序提交的行为很清楚:
第一个成功
→ 立即退出
第一个明确失败
→ 尝试下一个
任何一次状态未知
→ 停止提交
整个关键窗口内只有一个短暂的查询循环,以及极少量的预约请求。同步的 httpx.Client 已经够用,没有必要为了并发增加状态管理的复杂度。
一次运行中的所有请求都复用同一个客户端:
with httpx.Client(
http2=True,
base_url=BASE_URL,
headers=HEADERS,
timeout=3.0,
) as client:
...
连接池可以避免轮询时每次重新建立连接。超时设置为三秒,是因为查询窗口本身只有五秒;一次请求如果长时间挂起,后续查询已经没有意义。
脚本顶部还使用 PEP 723 声明 Python 版本和依赖:
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "httpx[http2]>=0.28.1",
# ]
# ///
因此可以直接通过 uv 运行,不需要单独维护虚拟环境和 requirements.txt。
sniper.service:完成一次预约
sniper.py 只负责一次执行。它启动后计算日期、等待放号、尝试预约,然后退出。
如何启动它、从哪个目录运行、使用哪个 Python 版本,由 sniper.service 负责:
[Unit]
Description=Sniper Service
After=network.target
[Service]
Type=oneshot
WorkingDirectory=/root/scripts
ExecStart=/root/.local/bin/uv run --managed-python --python 3.14 sniper.py
After=network.target
脚本需要访问预约接口,因此 service 安排在基础网络服务之后启动。
这里没有使用更严格的 network-online.target。timer 每天在系统已经稳定运行时触发,并不是开机后立即执行,因此没有必要再增加额外依赖。
Type=oneshot
oneshot 表示这是一个执行一次就结束的任务。
systemd 启动脚本,等待进程退出,然后记录本次 service 的最终状态。Python 不需要常驻后台,也不需要自己实现守护进程、PID 文件或者下一次调度。
WorkingDirectory
脚本位于 /root/scripts。设置工作目录后,ExecStart 中可以直接使用相对路径 sniper.py。
ExecStart
实际运行命令是:
/root/.local/bin/uv run --managed-python --python 3.14 sniper.py
脚本声明的最低版本是 Python 3.13,服务器实际通过 uv 使用 Python 3.14。--managed-python 表示由 uv 管理对应的 Python 运行时,不依赖系统自带版本。
手动测试 service:
sudo systemctl start sniper.service
查看状态和日志:
systemctl status sniper.service
journalctl -u sniper.service
脚本中的 logging 输出会直接进入 systemd journal,不需要额外配置日志文件和轮转。
sniper.timer:每天准时唤醒
sniper.service 定义「怎么运行」,sniper.timer 定义「什么时候运行」:
[Unit]
Description=Run Sniper 15 seconds before 11:00 AM Beijing Time (03:00 UTC)
[Timer]
OnCalendar=*-*-* 02:59:45
AccuracySec=100ms
Unit=sniper.service
[Install]
WantedBy=timers.target
这台服务器的系统时区是 UTC,北京时间上午 11 点对应 UTC 03:00,因此 timer 在 02:59:45 触发,也就是放号前十五秒。
提前启动是为了给准备过程留下余量:
10:59:45 sniper.timer 触发
↓
10:59:45 sniper.service 启动 Python
↓
10:59:50 获取项目和车辆,校准服务器时间
↓
10:59:59 进入放号前的查询准备
↓
11:00:00 查询开放时段并提交预约
AccuracySec=100ms 把 timer 的调度精度窗口缩小到 100 ms。
systemd 默认可能为了合并唤醒和节省资源,把 timer 安排在目标时间附近执行。对备份、清理日志这类任务来说,晚几十秒没有关系;这里希望脚本在放号前留出相对稳定的准备时间,因此把精度窗口调小。
Unit=sniper.service 指定 timer 到点后启动哪个 service。
安装两个单元文件后,重新加载 systemd,并启用 timer:
sudo systemctl daemon-reload
sudo systemctl enable --now sniper.timer
查看下一次触发时间:
systemctl list-timers sniper.timer
也可以分别查看 timer 和 service 的状态:
systemctl status sniper.timer
systemctl status sniper.service
timer 本身只负责调度,不运行 Python;service 本身只负责执行,不计算下一次时间。两者拆开后,每一部分的职责都很清楚。
一次完整运行
一次正常执行的日志类似:
Engine spinning up... Booking for: 2026-07-13
Sync complete. Offset: -0.327s. Awaiting firing window...
Polling...
Locked 1 candidates. Executing safe sequential submit...
Submitting order once for slot ID: ...
>>> SUCCESS! Order ID: ... <<<
如果五秒内没有读到可用名额:
No slots found within the polling window.
如果候选时段在提交前被其他人约走,程序会尝试下一个候选项。全部明确失败后输出:
Mission failed. No confirmed booking.
如果 POST 请求的结果无法确认,则停止提交,由我打开小程序检查最终状态。
程序没有实现邮件、微信或其他通知。每天只运行一次,时间固定在上午 11 点,我通常很快就会查看小程序。为了省掉这一次人工确认,再增加一套通知服务,对这个脚本没有多少实际收益。
刚好够用
写这类脚本时,很容易继续增加功能:把每个参数都做成配置,支持多个教练和车辆,细分所有异常,增加通知、状态持久化、自动恢复,再接一个接口确认预约结果。
这些功能都能实现,但这个程序从一开始就有明确边界:
- 只给我自己使用
- 只匹配一个目标教练群
- 当前项目的车辆选择固定
- 每天只运行一次
- 总共只使用几个星期
- 考试结束后需求永久消失
在这个前提下,继续抽象并不会自动提高代码质量。更多配置需要更多测试,更多分支也会增加新的故障点。
这个程序真正需要保证的是:
- 到点能够启动
- 不会算错练车日期
- 尽量贴近服务器的放号时间
- 优先选择我能参加的时段
- 不会因为盲目重试产生重复预约
- 无法确认提交状态时停止运行
这些目标满足以后,程序已经完成了它的任务。
短生命周期脚本不等于可以随便写。日期边界、时间偏差和提交状态这些真正影响结果的问题仍然需要认真处理;但没有必要因为某项做法属于「最佳实践」,就把它机械地加入一个只运行几周的程序。
这里的取舍不是没有想到,而是在效率、可维护性和代码整洁之间,选择了刚好够用的实现。
退役
科目二考试结束后,科目三开始采用小班教学。教练会直接根据每个人的进度安排练车时间,不再需要学员每天到小程序里预约。
因此这个脚本也没有继续修改和复用的必要。禁用 timer:
sudo systemctl disable --now sniper.timer
sniper.timer 不再触发定时任务,sniper.service 和 sniper.py 也不会继续运行。
在科目二练车的那段时间里,sniper.timer 每天准时触发,sniper.service 启动一次任务,sniper.py 完成预约后退出。科目二结束后,预约方式随之改变,这三个文件的任务也就一起结束了。
文件下载
本文涉及的脱敏版 sniper.py、sniper.service 和 sniper.timer 可通过以下百度网盘分享链接获取:https://pan.baidu.com/s/1xxc3X6OPI3t2J91vHZ1Zhw?pwd=m6br
本文采用 CC BY-NC-SA 4.0 协议发布,可自由转载、修改,但需保留作者署名、不可用于商业用途、衍生作品需以相同协议发布。