直连 12306 查询直达 + 自动枚举 1~2 次中转组合,用价格滑杆/时长滑杆快速定位“比普速快、比高铁便宜”的混搭方案。
双击 run_desktop.bat,或:
pip install -r requirements.txt
python desktop.py桌面版无需浏览器、不占端口,功能与网页版一致:三卡对比(可点击定位)、价格—时间星图(悬停预告/点击定位)、 滑杆过滤、购票指引、历史搜索。查询在后台线程跑,界面不卡死。 桌面版独有:站名输入联想、日期今/明/后快捷、取消查询、复制购票清单/导出TXT、 只看经停站过滤、已覆盖中转站公示、查询条件自动记忆、出发时间排序。 一键爬取带进度条+阶段播报(新增几趟/累计几趟/新入库车次/预计剩余秒)+可取消; “查看图库”可看已爬数据(统计/按车次查经停/按车站查过路车/最近入库)。 两端查询都有真进度:阶段文案+百分比+已用秒(网页版是条纹动画进度条,桌面版是进度条+可取消)。
python -m uvicorn backend.app:app --host 127.0.0.1 --port 8000若报
WinError 10048 端口只允许使用一次:说明有旧服务还占着 8000 端口。 查杀:netstat -ano | findstr 8000找到 PID → 任务管理器结束它, 或直接用桌面版(不受影响)。
按功能分三层,不再把所有控件堆在一起:
- 查询卡(页面顶部):出发/到达/日期 + 查询按钮为第一层;换乘深度、指定中转、 搜索广度、查询模式、换乘窗口收在第二层;选「图库深挖」时才展开枚举/核验/前后天。
- 筛选栏 + 结果区(左右双栏,<1080px 自动上下叠): 左栏是价格/时长滑杆、车型、换乘次数、出发时段、精确时间(折叠)、只看有票; 右栏是排序 + 三卡对比 + 价格—时间分布图 + 方案卡片。
- 时刻表图:从主流程里拿出来,顶栏按钮开独立面板(爬取是低频维护操作, 不该和查询抢注意力)。
方案卡片改成竖向行程表:左侧发到时刻(等宽数字对齐)、中间站点连线、右侧分段票价, 换乘等待单独一条,同城异站会标「泰山 下车 → 泰安 上车 · 同城异站」。
- 车站码:
kyfw.12306.cn/otn/resources/js/framework/station_name.js - 直达余票:
.../otn/leftTicket/query[G/O/Z/A]?...purpose_codes=ADULT(自动轮询接口名 +c_url自适应) - 票价:
.../otn/leftTicket/queryTicketPrice - 经停站:
.../otn/czxx/queryByTrainNo(找顺路中转站) - 仅低频查询,不做登录/下单/抢票。
- 输入 出发/到达/日期,选换乘深度,点查询组合。
- 看顶部三卡:最快 / 推荐中间值 / 最便宜。
- 看价格—时间星图:越左下越优,点击圆点定位卡片。
- 拖左侧滑杆:最高价 / 最长耗时 / 出发时段 / 精确时间(出发HH:MM、到达HH:MM,可过夜区间)+ 车型组合(高铁+普速是灵魂)快速过滤。
- 点购票指引,去 12306 分段购买(或用 12306“中转/接续换乘”页按此组合出票)。
- 预售期 15 天内可查;历史搜索一点即查;卡片上站名、分段价(席别)、中转站、+1天均已标明。
- 票少?调“搜索广度”:快速(8站)/标准(12站)/地毯式(20站全查D/D+1,2~5分钟)。 中转100%自己组(12306只给分段余票原材料;官方中转接口需登录不可用), 候选站=直达车经停站+地理走廊排序的候选中转池(250城坐标,顺路的自动上浮, 大枢纽保底加成,名单打补丁已成过去式), 每次查询会公示“打了N次12306·试了M个中转站”。
候选中转=直达车经停站(占一半名额,剩下留给图库和枢纽)+ 本地时刻表图 + 地理走廊枢纽。图是后台礼貌爬来的:
桌面和网页都叫“一键爬取”(桌面在星图下那排按钮里:复制购票清单 / 导出全部TXT / 重置过滤 / 一键爬取),
或 python -m backend.crawler --max 1000 --from 唐山,北京。限次可调(填0=无限,一直啃到你点取消;10~5000,默认1000)。
爬来的数据落进 timetable.db(SQLite)。网页版顶栏「时刻表图」按钮打开面板,五个标签:
| 标签 | 内容 | 后端接口 |
|---|---|---|
| 爬取 | 优先站/范围/限次,实时进度,可取消 | POST /api/graph/crawl |
| 爬取记录 | 每次爬取的时间、请求数、新增车次、优先站、数据日期 | GET /api/graph/runs |
| 查车次 | 按车次号查归档里的全程经停(站序/到发时刻) | GET /api/graph/train |
| 查车站 | 按站名查归档里经过该站的所有车次 | GET /api/graph/station |
| 最近入库 | 按入库顺序倒序 | GET /api/graph/recent |
归档保证:中途取消或异常也会写一条记录(finally 里落库),前面已爬的车次照样入库
——之前只有正常跑完才写,取消一次等于没爬过。
优先站:填你常坐的站(如唐山,北京),这些站的进出先爬——和你出行相关的图最先变全。
范围:自动(干线先啃再散射地级)/仅干线/仅地级。散射=250城×最近枢纽,专抓支线车;
中断过的OD按历史产量降序重排,高产优先。图头显示覆盖率(全国图定12174列)。
原理:1 个 OD 查询发现几十趟车,1 趟车 1 个请求拿下全线车站,覆盖效率比站站对查高两个数量级。
数据存 timetable.db(SQLite),/api/graph/stats 可看(几趟车·几站·覆盖率·上次爬取)。只爬公开经停站,不碰登录抢票。
桌面“查看图库”是同一份数据的桌面视图:概览统计、每次爬取记录、按车次查经停、按车站查过路车、最近入库。
多次爬取不重复:车次号主键(重入整车替换更新)、OD 做完打标记、已知车次跳过。
实测:已啃 OD 重爬只花 1 个请求、新增 0 趟;全库 GROUP BY 查无重复行。
现有手段:UA 轮换、请求间隔+抖动、失败翻倍退避、会话刷新、搜索并发封顶4、余票/票价/经停三级缓存。 速率上限故意不做破坏性压测(把自家 IP 送进黑名单,真买票时被封就搞笑了), 改用自适应调速(AIMD):成功每次 -0.2s,失败 ×2,上限 30s、下限 1.5s 红线,速率持久化下次接着来。 每次爬完会告诉你“最快间隔X秒、退避N次”——这就是系统自己摸到的安全速率。
| 间隔 | 成功率 | 结果 |
|---|---|---|
| 1.5s | 8/8 | 全部成功 |
| 1.0s | 8/8 | 全部成功 |
| 0.6s | 4/8 | 开始返回 HTML 拦截页 |
| 0.35s | 0/8 | 全部被拦 |
安全边界在 1.0~0.6 秒之间,但代码下限仍保持 1.5 秒,原因:
- 一旦触发限流,余票接口会被封很久——实测 25 分钟以上仍未解封
- 封禁是接口级的:
init页(92KB)和车站列表(137KB)都正常, 只有余票接口返回 2042 字节的「网络可能存在问题」拦截页 - 封禁期间完全查不到票,只能干等,代价远大于提速省下的时间
另外注意:RateController.wait() 实际睡的是 cur + 0~1秒 随机抖动
(固定节奏更容易被识别),所以报告里的「1.5 秒」真实间隔约 1.5~2.5 秒。
爬虫会把「HTML 拦截页 / 空响应 / 非 JSON」与普通网络错误分开,识别为 Throttled:
- 立刻停止本次爬取(不再继续打,继续打只会拖长封禁)
- 已入库的车次保留,归档照常记录
- 前端明确提示「已被限流,建议 1 小时后再爬」
普通超时/连接错误不会误判成限流,仍按原来的连续失败计数处理。
- 实时深查(默认):在线枚举 1
2 次中转,名额按广度档位来,稳、准、慢(2070秒)。 - 图库深挖(无上限):本地 7000+ 趟时刻表图上枚举(条数可调,默认300,最大2000), 最多 4 次中转、前后天;再对头部 N 条(默认40,最大1000)打 12306 核验余票和价格(死的自动丢掉)。 图里翻太少(<20 条)且没被限流时,自动跑一轮实时搜索合并兜底。展示不截断:桌面全列,网页分页。 桌面左上角切模式,中转≤/核验条数(最多300)/前后天可调;价格滑杆放到 5000、时长放到 72 小时, 专治西藏新疆这种远路。注意:图是爬来的时刻表,车次/时刻以核验为准。 同城站(广州南/广州白云、泰安/泰山、唐山/唐山北等)出发到达换乘都按同城处理,和12306通查对齐。
12306 反爬严:并发≤4、请求间隔随机、票价只查 Top 候选、结果缓存。 关键直达查询失败自动换会话重试3次;余票/经停站/票价均有本地缓存(2分钟/1小时/15分钟), 失败时先服役旧缓存并明确标注,实在无缓存才显示演示数据+重试按钮。 遇到限流提示等30秒点「重试实时查询」即可,勿连续狂点。
双击 tests\run_tests.bat(离线:缓存/分组/图/取消/离线枚举/展示/前端逻辑/桌面冒烟全过)。
tests\ 下还有实查探针(probe.py)和轻量在线测试(test_jobflow/test_overnight/test_progress/test_crawlwire,
每个只打几个请求),改完核心逻辑跑一遍。
新增的离线用例(都是踩过的坑,改代码前先跑):
test_p0_regress.py:2 次中转枚举、建图缓存失效、datetime 契约test_display.py:同城异站换乘(泰山→泰安)标注、站名降级兜底test_frontend.py:前端日期时区、只看有票、智能排序(需要 node,没有则跳过)
| 项目 | 优化前 | 优化后 |
|---|---|---|
| 图库深挖枚举(唐山→费县,mt=2) | 16~21s | 4~6s |
| 建图(7237 趟 / 55 万条边) | 每次查询都重建 | 按库签名缓存,命中 <1ms |
| 票价查询 | 同一分段重复打网 | 分段键去重后提交 |
| 2 次中转票价 | 主线程串行,堵进度 | 线程池并行 + 可取消 |
| 并发查询 + 爬虫写库 | database is locked |
切 WAL,读写不互斥 |
枚举结果与优化前逐条一致(8 组 OD/换乘数/等待窗对比验证),只是更快。
- 内部时间轴改成「绝对分钟整数」,不再用 datetime + timedelta(原来 1150 万次
total_seconds占了 40% 时间),只在产出结果时转 datetime,对外接口不变。 - 同城展开的边按发车钟点排序,用二分只取等待窗内的车(原来每次扩展都要扫 两三百条边再逐条试 4 天)。
- 反向可达性预剪:结构上到不了终点的站不扩展(冷门长线 45 万次扩展只出 1 条的情况)。
