Install
openclaw skills install @openlittlebear/12306-android-adb12306-specific knowledge for booking train tickets via the Android app. Covers UC WebView virtual list behavior, proven booking flow, and common pitfalls. Us...
openclaw skills install @openlittlebear/12306-android-adb12306-specific knowledge, workflows, and pitfalls. The app (com.MobileTicket) uses Alipay Nebula UC WebView for the train list and booking pages, which has unique automation challenges. This doc reflects two successful end-to-end bookings on the current app version (2026-08-30, Realme RMX3350 1080x2400).
# venv (one-time):
python3 -m venv ~/.openclaw/workspace/.venv-12306
~/.openclaw/workspace/.venv-12306/bin/pip install uiautomator2 rapidocr-onnxruntime opencv-python onnxruntime
PY=~/.openclaw/workspace/.venv-12306/bin/python
# App must be installed + logged in. If missing, see "Install & Login".
$PY -c "import uiautomator2 as u2; d=u2.connect(); d.app_start('com.MobileTicket')"
Do NOT use the Appium bridge for 12306 on consumer phones — Realme etc. block WRITE_SECURE_SETTINGS, so Appium settings/IME fail and the session never starts. Direct uiautomator2 + OCR works (atx-agent needs no special permission). All interaction goes through the OCR loop: ocr_bridge.py in the appium-android-adb skill — screenshot → RapidOCR → click/swipe → re-OCR verify.
The train list uses a virtual list inside the UC WebView:
adb shell input tap is filtered by the WebView. d.click(x, y) (uiautomator2) reliably delivers touches.Collapsed train cards have NO 预订 button. The flow is:
二等 ¥41 9折).# after OCR shows target train card on screen:
$PY ocr_bridge.py tap-text 二等 # expand the card
$PY ocr_bridge.py tap-text 预订 --y0 <row_top> --y1 <row_bottom> # row's 预订
(Example coordinates from the real run: card row at (141, 2026) expanded; 二等 row 预订 at (939, 1987); order page followed.)
Without the filter, the list mixes departures from multiple stations and time order breaks. A train that stops at several departure stations (一车多站, e.g. G7357) appears once per stop-time, all grouped adjacently — so you see 08:10 / 08:35 / 08:52 for the same train number and can't scan by time. Hit this in production (thought "no 08:16 train exists" — there was one).
Fix: tap the departure-station filter chip at the top of the list first (chips row, y≈669-708 on 1080x2400; e.g. 上海松江 / 杭州南 / 上海虹桥 / 上海南):
chip = [x for x in t if '松江' in x[0] and x[2] < 600] # OCR: station name near top
d.click((chip[0][1]+chip[0][3])//2, (chip[0][2]+chip[0][4])//2)
After filtering: one row per train, sorted by departure time, target times are findable by simple scrolling.
⚠️ The filter resets after every re-query (查询车票) — re-apply the chip each time.
curl the direct link → adb install -r app.apkStations: tap the station field → search box opens. Chinese input via clipboard (IME switching is blocked):
$PY ocr_bridge.py type 苏州 --x 400 --y 330 # set_clipboard + long-press field
$PY ocr_bridge.py dump # find 粘贴 in OCR
$PY ocr_bridge.py tap-text 粘贴 # paste → results appear
$PY ocr_bridge.py tap-text 苏州站 # pick the main station
Date: the date picker opens with day cells — OCR the month grid and tap the day cell (e.g. tomorrow's cell right of 今天).
Tap 查询车票. The list loads with earliest departures first.
$PY ocr_bridge.py deps # HH:MM markers at x<250 = which time window is visible
$PY ocr_bridge.py swipe up --scale 0.35 # normal forward scroll (≈2 cards)
deps — you always know where you are, DOM or not.$PY ocr_bridge.py jump --n 3 (3 rapid scale-0.5 swipes breaks the stuck point), then micro-swipes back: swipe up --scale 0.25, micro down, or d.swipe(540, 1900, 540, 800, duration=0.4).^[GDCK]\d+ plus the destination name in the row text (ignore trains to other destinations, e.g. 太仓 when heading to 苏州).tap-text <name> (e.g. 熊天放).$PY ocr_bridge.py tap-text 提交订单
$PY ocr_bridge.py wait-text 订单处理中 # order processing
$PY ocr_bridge.py markers # look for 快速支付 / 立即支付
订单处理中 → wait and re-OCR (can take ~20-60s).快速支付 or 立即支付 + countdown at top = order locked, success. Report: train, date, route, seat, price, passenger, seat number, countdown.If the list data sat too long before submitting, the order page throws "车票信息已过期(M0013)". Detect it by polling markers after 提交订单:
for i in range(8):
time.sleep(4)
markers = [x[0] for x in shot() if any(k in x[0] for k in
('立即支付','快速支付','订单处理','温馨提示','取消订单','已过期','确定'))]
if any('已过期' in m or '确定' in m for m in markers): break # ERROR
if any('立即支付' in m for m in markers): break # SUCCESS
Recovery (proven):
d.press('back') ×2 → home page. (Pull-to-refresh on the list did NOT refresh the data — don't bother.)| Situation | Command |
|---|---|
| Normal advance | swipe up --scale 0.35 |
| Fine-tune position | micro up / micro down (scale 0.15) |
| Precise pixel scroll | d.swipe(540, 1900, 540, 800, duration=0.4) |
| List stuck / skips target window | jump --n 3, then micro back |
| Back up a little | swipe down --scale 0.3 |
OCR markers:
├─ 查询车票 → HOME — set stations/date → tap 查询车票
├─ 提交订单 / 选择乘车人 → ORDER PAGE — passenger → 提交订单
├─ 订单处理中 → wait, re-OCR
├─ 车票信息已过期 / 确定对话框 → EXPIRED — 确定 → back → 查询车票 → re-filter → re-book
├─ 快速支付 / 立即支付 → SUCCESS — tell user to pay
├─ deps() times + train numbers → TRAIN LIST — scroll/find → expand → 预订
├─ 粘贴 menu visible → after clipboard type, tap 粘贴
└─ unknown → dump full OCR and re-orient
~/.cache/ocr-bridge) — see appium-android-adb for cleanup.d.click.set_clipboard + long-press 粘贴.jump past the stuck point, then micro-scroll.adb shell svc power stayon true.G3314 (no spaces); DOM gives G 3 3 1 4. Regex on OCR text.