Daily Build

用 Python 和 systemd 自动预约练车

考驾照时,驾校通过微信小程序预约练车。每天上午 11 点开放第二天的名额,学员自己选择教练、车辆和时间段,提交后即算预约成功。

操作本身不复杂,问题是所有人都在同一时间操作。特别是下班后还能赶上的下午时段,通常很快就会约满。工作日上午 11 点又很容易碰上开会、写东西或者临时有事,只要晚打开几秒钟,合适的时间就可能已经没有了。

我不想每天专门守着手机刷新,于是写了一套很小的自动化程序,让一台一直在线的 Linux 服务器代替我完成预约。

整套程序由三个文件组成:

三者的关系是:

sniper.timer
    ↓ 到点触发
sniper.service
    ↓ 启动命令
sniper.py
    ↓ 完成一次预约后退出

这套程序陪我预约完科目二,科目三采用小班教学,练车时间直接由教练安排,不再需要自己到小程序里抢名额。因此科目二结束后,这三个文件也就完成了自己的任务。

预约流程

微信小程序里看起来只是选择一个时间段,再点击提交,背后实际需要经过几步:

获取预约项目
    ↓
获取对应车辆
    ↓
查询指定日期的时间段
    ↓
筛选仍有名额的候选项
    ↓
提交预约

脚本用到四类接口:

GET  /appoint/v2/showAppointList
GET  /carManager/showCarList
GET  /group/showGroupInfo
POST /record/v2/saveOrUpdate

前三个接口只读取信息,重复调用通常不会改变服务器状态;最后一个接口会创建预约记录,不能采用和查询接口相同的重试策略。

请求需要携带小程序正常使用时的 access_tokenRefererUser-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)

例如:

业务时区显式使用 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

候选时段必须同时满足:

一旦发现可用候选,轮询立即结束,不再继续发送查询请求。

我当时最希望约到 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.servicesniper.py 也不会继续运行。

在科目二练车的那段时间里,sniper.timer 每天准时触发,sniper.service 启动一次任务,sniper.py 完成预约后退出。科目二结束后,预约方式随之改变,这三个文件的任务也就一起结束了。

文件下载

本文涉及的脱敏版 sniper.pysniper.servicesniper.timer 可通过以下百度网盘分享链接获取:https://pan.baidu.com/s/1xxc3X6OPI3t2J91vHZ1Zhw?pwd=m6br


本文采用 CC BY-NC-SA 4.0 协议发布,可自由转载、修改,但需保留作者署名、不可用于商业用途、衍生作品需以相同协议发布。