简介:济南大学图书馆座位预约系统API是一份面向学生开发者的开源学习项目,核心解决图书馆选座与签到过程中的自动化请求问题。资源共9个文件,以Python脚本和Go源码为主,辅以LICENSE与README说明,压缩包仅16KB,结构紧凑便于阅读。已有1605人学习下载,适合对爬虫、HTTP签名和RESTful接口调用感兴趣的中级开发者参考。项目中演示了HMAC认证复用和签到接口的使用思路,读者可从中了解登录态维持、请求头构造及异常处理等常见技巧;同时项目支持Python 3与Go双语言对照,既可用于辅助日常占座,也可作为课程设计或爬虫入门的练手素材。使用前建议自行替换源码中的HMAC信息,并遵守校园系统使用规范。
1. 图书馆座位预约系统 API 封装:从抢座脚本到预约闭环
期末复习周还没到,图书馆上午十点的座位就先被抢光。与其天天蹲点刷新,不如顺着小程序抓一次包,把底层 HTTP 接口整理成一套可以复用的 Python API。这套资源做的正是某高校图书馆座位预约系统的 Web 端接口封装:登录、查座位、预约、取消、签到都有现成函数。适合有 Python 基础、想写定时抢座脚本或二次开发座位查询工具的人,也适合想快速给毕设接“座位状态”模块的同学。整体以开源模块方式组织,配好账号就能跑通。下面是我拆包验证的完整过程,以及我实际踩过的坑。
2. 接口逻辑:登录、令牌与座位的状态机
开始写代码前,先理清预约系统的三个层次:前端交互、后端过程和座位状态。页面上的一个座位从绿色变灰色,看着是刷新结果,实际是后台接口返回的状态值变了。后端判断“能不能预约”不是看按钮,而是看座位状态与时段的组合。
2.1 先看预约状态机再看代码:座位状态字段与时段锁定
抓包常见结构里,座位对象通常包含 seatId、seatLabel、state 三个核心字段。state 取值各家不一样,但这个系统里 0 表示空闲,1 表示被占用,2 表示已预约未签到,3 表示使用中,4 表示暂时保留。资源包把状态定义写成了一段常量,我建议保留而不是硬编码:
SEAT_STATE = { 0: "空闲", 1: "占用", 2: "已预约未签到", 3: "使用中", 4: "暂时保留", }筛选可预约座位时,只挑 state 为 0 的座位作为候选。写代码时要注意另一个条件:时段。座位空闲不代表你要的时间段一定空闲。比如场馆开放时间 08:00 到 22:00,有的时段规则是两小时一档,有的按开始时间到结束时间自由选。资源包用一个名为 periods 的配置对象描述:
PERIODS = [ {"start": "08:00", "end": "10:00"}, {"start": "10:00", "end": "12:00"}, {"start": "12:00", "end": "14:00"}, {"start": "14:00", "end": "17:00"}, {"start": "17:00", "end": "22:00"}, ]改配置而不是改代码。遇到不同校区时,把这段配置替换即可。另一个容易忽略的点:有些座位在特定时段是“保留座位”,例如考研专座。这类座位即使 state 为 0,预约接口也会返回“该座位不可预约”。所以功能验证时,我会先取一个真实空闲座位试一笔,再开始批量封装。
2.2 登录与令牌刷新:抓包拿到鉴权方式,别踩 refresh_token 轮换坑
接口鉴权方式不少,常见的是 Bearer Token,也有些校区系统仍用 Cookie。这个项目的封装走的是 OAuth2 的密码模式加 refresh_token 轮换。第一次使用,需要抓包拿到初始 token 和 refresh_token。抓包工具不限,关键看三样东西:Authorization 头、登录返回的 JSON 结构、刷新接口的请求体。
抓包要点是登录一次后,立即把响应里的 refresh_token 保存下来,而不是只存 access_token。下面这段是资源包里的令牌管理类,我稍微改成了持久化版本:
import json import time import requests from pathlib import Path class TokenBox: def __init__(self, base_url, credential_file): self.base_url = base_url self.file = Path(credential_file) self.data = json.loads(self.file.read_text(encoding="utf-8")) def valid(self): return time.time() < self.data["expires_at"] - 60 def refresh(self): resp = requests.post( f"{self.base_url}/auth/oauth/token", data={ "grant_type": "refresh_token", "refresh_token": self.data["refresh_token"], }, timeout=5, ) body = resp.json() self.data["access_token"] = body["access_token"] self.data["refresh_token"] = body["refresh_token"] self.data["expires_at"] = time.time() + body["expires_in"] self.file.write_text(json.dumps(self.data, ensure_ascii=False), encoding="utf-8")逻辑说明:valid 里减去 60 秒余量,避免在边界时刻发请求;刷新后立刻把文件写回,因为多数系统的 refresh_token 是一次性轮换,再用旧值刷新会报 invalid_grant。参数 credential_file 建议放到本地非公开目录,不要把这个文件提交到 Git 仓库。
提示:不要把 refresh_token 直接写进源码。用环境变量或本地配置管理,脚本启动时再读取。
2.3 核心接口封装:查询空闲座位、提交预约、取消与签到
这一层是把 HTTP 请求包成业务方法。资源包里的类叫 SeatAPI,构造函数接收 base_url 和 TokenBox。核心动作是四个:查可用座位、提交预约、取消预约、签到。下面是实际可运行的样子:
class SeatAPI: def __init__(self, base_url, token_box): self.base_url = base_url self.tokens = token_box self.session = requests.Session() self.session.headers.update({"Content-Type": "application/json"}) def _request(self, method, path, **kwargs): self.tokens.valid() or self.tokens.refresh() self.session.headers.update({ "Authorization": f"Bearer {self.tokens.data['access_token']}", }) response = self.session.request( method, f"{self.base_url}{path}", timeout=10, **kwargs, ) payload = response.json() if payload.get("code") not in (0, "0"): raise RuntimeError(payload.get("message", "unknown error")) return payload.get("data") def seat_layout(self, layout_id): return self._request("GET", "/api/seat/layout", params={"layoutId": layout_id}) def create_booking(self, seat_id, date, period_start, period_end): body = { "seatId": seat_id, "date": date, "start": period_start, "end": period_end, } return self._request("POST", "/api/booking", json=body) def cancel_booking(self, booking_id): return self._request("POST", "/api/booking/cancel", json={"bookingId": booking_id}) def sign_in(self, booking_id): return self._request("POST", "/api/sign", json={"bookingId": booking_id})逻辑说明:每次请求前先检查 token,过期就刷新,避免请求发到一半被 401 踢回来。尤其注意 code 字段同时兼容了数字和字符串,不同后端返回类型不一致,这里要做防御式判断。对于“预约成功”的判断,不能只满足 code 为 0,还要看返回里有没有 bookingId。
常见接口差异:有的系统把座位编号字段叫 seatNo,有的叫 seatLabel;预约返回的标识字段有叫 reserveId、bookingId、orderId 三种可能。遇到 500 时先看返回的 body,别只盯状态码。遇到这类情况,我习惯先用手动构造的 HTTP 请求拿真实响应,再套进封装类。
3. 把封装跑起来:座位图缓存、定时抢座与结果通知
基础类串通后,下一层是“怎么用”。直接高频轮询接口是最容易翻车的方案,我会把座位图缓存到本地,把时间校准交给专门接口,把重试限制在幂等可控的范围。
3.1 楼层座位图抓取与本地映射:缓存为 JSON 文件
预约页面绘制的座位图,本质上是一棵层级结构:楼层 → 区域 → 座位。资源包提供了一个加载方法,返回的是未经处理的原始数据。总重写拉取的话,系统会按 IP 限流。我的做法是缓存到 JSON 文件,提供 force 参数控制强制刷新:
import json import time from pathlib import Path def load_layout(api, layout_id, cache_seconds=600, force=False): cache_file = Path(f"layout_{layout_id}.json") if cache_file.exists() and not force: age = time.time() - cache_file.stat().st_mtime if age < cache_seconds: return json.loads(cache_file.read_text(encoding="utf-8")) raw = api.seat_layout(layout_id) cache_file.write_text( json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8", ) return raw参数说明:cache_seconds 设 600 秒,避免轮询时反复打接口。force=True 用于每天第一次抢座前强制刷新一次,确保拿到最新布局。拿到 raw 之后,要把它转成更扁平的列表,方便按座位编号查状态:
def flat_seats(layout): seats = [] for area in layout.get("areas", []): for row in area.get("rows", []): for seat in row.get("seats", []): seats.append({ "seat_id": seat["seatId"], "label": seat.get("label", ""), "area": area.get("name", ""), "state": seat.get("state", 1), }) return seats说明:结构解析时不要假设数据一定存在。fields 名对不对,用真实返回对比最准。restructured 成 flat_seats 后,再做筛选或状态判断会简单很多。
3.2 服务器时间校准与到点抢座:不要用本机时间当准点
第一次写抢座脚本时,我直接按电脑时间提交,结果每次都提示“未到开放时间”。后来用系统接口里的服务器时间一对比,快了大半分钟。根源是开放预约的判定以服务器时间为准。所以要先增加一个时间校准函数:
def get_time_offset(api): server_time = api._request("GET", "/api/time") import time local_ts = time.time() offset = server_time["timestamp"] - local_ts return offset拿到 offset 后,预约提交时间、睡眠时间都要换算成服务器时间。比如本地比服务器快 18 秒,那必须等服务器时间到点后再提交。对接下来的抢座循环,我通常设定为:
def run_booking_at(api, seat_id, date, start, end, server_open_at_str): offset = get_time_offset(api) open_ts = str_to_ts(server_open_at_str) - offset # 提前 2 秒进入轮询,避免时间差 while time.time() < open_ts - 2: time.sleep(0.2) layout = load_layout(api, "L01", cache_seconds=30) candidates = [s for s in flat_seats(layout) if s["seat_id"] == seat_id] if not candidates or candidates[0]["state"] != 0: raise RuntimeError("seat state not ready") result = api.create_booking(seat_id, date, start, end) return result逻辑说明:先稳定校准,再抢座。其中 str_to_ts 是把“08:00:00”转时间戳的通用函数,在校园网环境下 DNS 耗时影响不大,但可以用长连接减少 TLS 握手开销。抢座不是拼毫秒,拼的是“提前排队完成状态筛选”和“按服务器时间准时提交”。
3.3 重试、幂等与结果通知:预约是写操作,必须有上限
预约写操作不像查询,不能无脑重试。第一次超时不代表失败,可能已经写进数据库。所以重试必须带幂等键。常见做法是给请求头加 X-Request-Id,后端着相同键时会返回已有结果,而不是重复下单。资源包里的实现简短有效:
import hashlib import time def build_request_id(seat_id, date, start, end): raw = f"{seat_id}|{date}|{start}|{end}" return hashlib.md5(raw.encode()).hexdigest() def safe_create(api, seat_id, date, start, end, max_retry=3): request_id = build_request_id(seat_id, date, start, end) last_err = None for attempt in range(max_retry): try: # 有幂等键之后,重试变成安全动作 api.session.headers["X-Request-Id"] = request_id return api.create_booking(seat_id, date, start, end) except Exception as exc: last_err = exc time.sleep(0.3 * (attempt + 1)) raise last_err参数说明:max_retry 设 3 次比较合理,抢座场景下超过 3 次说明窗口已经错过。加幂等键后,即使第一次网络超时,第二次请求也不会造成重复预约。结果通知可以接企业微信机器人或本地脚本,但通知服务不能阻塞主流程,我一般用独立线程发。
4. 避坑手册:登录态、时间偏差与接口幂等的五个真实教训
这一章我整理了实测时踩过的坑,每条都按“现象 → 原因 → 解决”记录,方便现场排查。
4.1 现象:Token 没过期,但接口提示未登录
早晨跑脚本时,日志里 Token 还没到过期时间,接口却连续返回 401。后来发现是昨天刷新后没有把新 refresh_token 写回配置,进程一重启,用的还是旧 refresh_token。这类系统的 refresh_token 是轮换制,每次刷新后的旧值直接失效。原因:刷新只在内存里更新,没持久化;同时多实例共用同一 token 文件时,某实例拿到了新值,另一个实例持旧值再次刷新导致失效。解决:把 token 保存提取为独立方法,任何地方刷新后统一调用 save,并且整个进程只维护权限位。
def save_token(path, data): temp_file = path.with_suffix(".tmp") temp_file.write_text(json.dumps(data), encoding="utf-8") temp_file.replace(path) # 原子替换,防止写一半崩溃4.2 现象:预约返回成功,但后台记录里没有数据
脚本打出 code=0,也打印了 result,但打开系统页面发现预约记录是空的。这个问题最诡异,花了两个小时对比。原因:预约接口要求在 Header 里携带 platform 标识,小程序端传的是 miniapp,资源包示例里漏传时后端会“假成功”。解决:把抓包得到的完整请求头照搬进 Session,我最终固定了这些关键头:
headers = { "Authorization": "Bearer ...", "Content-Type": "application/json", "platform": "miniapp", "appVersion": "1.0.0", "timestamp": str(int(time.time())), }逻辑说明:timestamp 用于后端风控,拼到 headers 里之后,每次请求必须重新生成。判断方法是看正常下单与脚本下单的请求体逐字节差异,不只是请求参数,还有每个头字段。
4.3 现象:抢座总是晚半拍,别人先抢到
脚本日志显示提交时没有报错,等返回结果,对方已经成功。后面发现,本机时间与服务器时间差了 10 秒以上。原因:服务器开放预约的判定以机房时间为准,不用客户端本地时间。解决:启动脚本时先拉系统时间接口,计算出 offset 后应用到所有时间判断;另外,在关键提交前预留一条心跳请求,避免 TLS 握手时残留连接。
4.4 现象:接口返回“该时段已存在预约”,但代码仍报错
第一次预约网络超时,按常规重试逻辑再发一次,第二次返回“已存在预约记录”。按照现有错误处理,这个提示视为异常,整个脚本退出,但实际上第一次已经成功。原因:缺少幂等键,后端无法判断两次请求是同一个操作,于是按重复预约处理了。解决:给每个写操作设置幂等头,重试时复用相同 request_id。修复后,第二次请求能返回第一次成功结果,不再抛异常。
4.5 现象:签到早于开始时间,被系统判为异常并锁定账号
我用自动签到脚本在预约开始时间前 1 分钟执行,结果系统判定为“提前占座”,账号被临时锁定。原因:馆方规则要求签到时间必须在开始前 15 分钟之后。解决:把签到时间约束抽象为窗口参数,强制检查本地时间处于区间内再调用:
def sign_with_window(api, booking_id, start_time_current): earliest = start_time_current - 15 * 60 latest = start_time_current + 60 * 60 now = time.time() if now < earliest or now > latest: raise RuntimeError("outside sign window") return api.sign_in(booking_id)参数说明:提前 15 分钟内和延后 1 小时内都是允许区间。这个窗口最好放到配置里,不同校区的执行规则不一样,硬编码必出问题。
5. 进阶验证:流水日志、交叉核对与接口正确性
接口写完了,脚本能跑通还只是第一步,我更加建议把每次请求留痕,用于回溯问题。资源包额外提供了 SQLite 日志方案,既不占内存,还好查询。
5.1 每次请求都落在 SQLite,排查时能还原现场
用一个简单的表记录请求动作、参数、返回结果和时间戳:
CREATE TABLE IF NOT EXISTS api_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT, action TEXT NOT NULL, request_body TEXT, response_body TEXT, status_code INTEGER, created_at INTEGER );在 SeatAPI._request 里,无论是成功失败,把关键字段写进表。有了流水之后,回头看“预约成功后找不到记录”这类问题就方便了,直接对比成功请求和业务记录的时间点,找出被吞掉的请求。书面记录胜过 print。
5.2 用预约记录反推接口边界,定期做取消重约测试
流水表只记录“调用发生”,还要和系统的真实预约列表做交叉核对。我一般每天检查一次,统计本地记录的预约条数和系统页面上实际存在的数量是否一致。如果不一致,说明有请求进入了后端,但被业务风控拦截。常见原因是某些座位存在黑名单时间。之后我会主动跑“取消 → 重新预约”的闭环测试,确认取消接口的幂等。
回看这段时间,我自己最大的教训是:能自动化的不只是“抢座”,还有“验证”。从那以后,每接触一个新的座位预约 API,我都会强制走一套流程:先抓包核对请求头和参数,再给 token 落盘,再给写操作加幂等键,最后做三次取消重约测试。做完之后才写到正式脚本里。这套流程帮我挡掉了大部分不预期问题。希望帮到你。
本文还有配套的精品资源,点击获取