Автоматизуємо покупку Ж/Д квитків Укрзалізниці

Привіт! Напевно, кожен з нас колись стикався з ситуацією, коли потрібно терміново кудись поїхати, але все Ж/Д квитки вже розкуплені. У цій статті я розповім про те, як я писав Telegram бота для відстеження та купівлі звільнилися квитків Укрзалізниці.
Як це працює
Для покупки залізничних квитків в Україні компанія Укрзалізниця запустила ресурс http://booking.uz.gov.ua/. Ресурс зручний тим, що не потрібно відвідувати каси, щоб забрати сам квиток. Досить показати провідникові QR код з посадкового талона на екрані смартфона або роздрукувавши на принтері.
Проблема полягає в тому, що на популярні рейси місця дуже швидко і закінчуються іноді купити квиток досить проблематично. Проте, багато людей не купують квиток, а бронюють його. Бронь діє лише 24 години і після цього, якщо вона не викуплена у касі квиток повертається в пул вільних. Таким чином, необхідно встигнути зловити цей момент, коли квиток доступний для покупки до того, як його знову забронюють або куплять.
Було прийнято рішення розв'язати цю задачу за допомогою скрипта, який раз у хвилину перевіряє вільні квитки на потрібний поїзд і, у разі наявності, резервує її на 15 хвилин. Після чого користувачу необхідно завершити процедуру оплати через веб-браузер.
В якості інтерфейсу був обраний Telegram так як це нова платформа для мене і я хотів з нею трохи розібратися. Як бонус відразу отримуємо повідомлення на мобільний, не замислюючись про push нотифікаціях або email ах.
В якості мови програмування був обраний Python.
Інтерфейс
І все ж, як це працює з точки зору користувача?
Бот розпізнає наступні команди:
  • /help
    — поверне список підтримуваних команд
  • /trains 2016-06-12 Kyiv Lviv
    — поверне список поїздів з Києва до Львова, відправляються 12 червня 2016 року
  • /scan Ivanov Ivan 2016-06-12 Kyiv Lviv 743K
    — запустить моніторинг квитків на поїзд 743К Київ-Львів. Повертає ID даного сканування
  • /status_1234
    — поверне стан сканування з ID 1234
  • /abort_1234
    — зупинить сканування з ID 1234
У разі успішного резервування квитка користувач отримає повідомлення, що містить Session ID. Цей ID потім необхідно вручну прописати в cookie браузера і завершити купівлю квитка.
UZ API
Для початку давайте розберемося з форматом API, використовуваним порталом. Це не становить великої праці, досить просто відкрити консоль розробника в браузері і подивитися які запити виконує скрипт на сторінці пошуку квитків.
В API використовуються тільки POST запити. Для захисту від використання сторонніми розробниками API майже у всіх дзвінки в тіло включається токен. Без сертифіката можна проводити тільки пошук станцій.
Варто також відзначити, що деякі нюанси роботи з датами. По-перше, формат дати змінюється в залежності від поточної локалі API. Наприклад, для локалі
en
формат
mm.dd.yyyy
. Тоді як для
ua
та
uk
це буде звичний нам
dd.mm.yyyy
. По-друге, для деяких запитів дата представляється у вигляді timestamp, проте він залежить від стану літнього/зимового часу. Тому я вирішив не морочитися з серіалізацією/десериализацией даних штампів, а використовувати їх в тому вигляді, в якому API повертає їх.
Отримання сертифіката
Покопавшись в підключаються сайтом скриптах, можна з легкістю виявити такий шматок:
var ajax = $v.ajax(url).header({
'GV-Ajax': 1,
'GV-Referer': encodeURI(GV.site.htcur_url + GV.site.requestUri),
'GV-Screen': screen.width + 'x' + screen.height,
'GV-Token': localStorage.getItem('gv-token') || "
});

Тут ми бачимо, що при виклики API токен зчитується з localStorage браузера. Залишилося знайти де він туди записується.
Ця частина була найцікавішою, бо як простим пошуком по html js цього знайти не вдалося. Витративши декілька годин в гуглі, я натрапив на статті, в якій автор вирішує таке ж питання з моніторингом квитків на сайті УЗ. Отже, у статті детально описано, що токен генерується обфусцированным за допомогою JJEncode кодом. За кілька хвилин знаходимо реалізацію деобфускатора на пітоні, який і буде використовуватися в подальшому.
Короткий API reference
Для виклику методів API, необхідно включати наступні заголовки:
GV-Ajax: 1
GV-Referer: http://booking.uz.gov.ua/en/
GV-Token: <token>

Пошук станцій
Наприклад, для формування підказок автодоповнення станцій виконується запит з порожнім тілом за адресою
http://booking.uz.gov.ua/en/purchase/station/ky/
,
ky
— це те, що користувач вводить у текстове поле вибору станції.
У відповідь сервер відправляє приблизно такий JSON:
{
"value": [
{
"title": "Kyiv",
"station_id": "2200001"
},
{
"title": "Kyivska Rusanivka",
"station_id": "2201180"
},
{
"title": "Kyj",
"station_id": "2031278"
},
{
"title": "Kykshor",
"station_id": "2011189"
}
],
"error": null,
"data": {
"req_text": [
"ky",
"лн"
]
},
"captcha": null
}

Пошук поїздів
Для пошуку поїздів необхідно виконати запит на
http://booking.uz.gov.ua/en/purchase/search/
з таким тілом:
station_id_from=2200001 # ID станції відправлення
station_id_till=2218000 # ID станції призначення
date_dep=06.12.2016 # дата відправлення в форматі mm.dd.yyyy
time_dep=00:00 
time_dep_till= 
another_ec=0 
search=

У відповідь ми отримаємо список поїздів, наступних за вказаним маршрутом. Так само, у відповідь буде включена інформація про кількість вільних місць у вагонах кожного типу (Люкс, Купе, Плацкарт, і т. д.):
{
"value": [
{
"num": "743Л",
"model": 1,
"category": 1,
"travel_time": "5:01",
"з": {
"station_id": 2200001,
"station": "Darnytsya",
"date": 1465741200,
"src_date": "2016-06-12 17:20:00"
},
"till": {
"station_id": 2218000,
"station": "Lviv",
"date": 1465759260,
"src_date": "2016-06-12 22:21:00"
},
"types": [
{
"title": "Seating first class",
"letter": "1",
"places": 117
},
{
"title": "Seating second class",
"letter": "С2",
"places": 176
}
],
"reserve_error": "reserve_24h"
},
{
"num": "091К",
"model": 0,
"category": 0,
"travel_time": "7:25",
"з": {
"station_id": 2200001,
"station": "Kyiv-Pasazhyrsky",
"date": 1465760460,
"src_date": "2016-06-12 22:41:00"
},
"till": {
"station_id": 2218000,
"station": "Lviv",
"date": 1465787160,
"src_date": "2016-06-13 06:06:00"
},
"types": [
{
"title": "Suite / first-class sleeper",
"letter": "Л",
"places": 11
},
{
"title": "Coupe / coach with compartments",
"letter": "До",
"places": 50
}
],
"reserve_error": "reserve_24h"
}
],
"error": null,
"data": null,
"captcha": null
}

Перегляд вагонів
Переглянути список вагонів і кількість вільних місць можна виконавши запит на
http://booking.uz.gov.ua/en/purchase/coaches/
з таким тілом:
station_id_from=2200001
station_id_till=2218000
date_dep=1462976400
train=743К # номер поїзда
model=3 # модель поїзда
coach_type=С2 # тип вагона (люкс, купе, і т. д.)
round_trip=0
another_ec=0

У відповідь ми отримаємо список вагонів даного типу з кількістю вільних місць і ціною:
{
"coach_type_id": 10,
"coaches": [
{
"num": 1,
"type": "З",
"allow_bonus": false,
"places_cnt": 21,
"has_bedding": false,
"reserve_price": 1700,
"services": [],
"prices": {
"А": 35831
},
"coach_type_id": 10,
"coach_class": "2"
},
{
"num": 3,
"type": "З",
"allow_bonus": false,
"places_cnt": 21,
"has_bedding": false,
"reserve_price": 1700,
"services": [],
"prices": {
"А": 35831
},
"coach_type_id": 9,
"coach_class": "2"
}
],
"places_allowed": 8,
"places_max": 8
}

Перегляд вільних місць
Для перегляду вільних місць в обраному вагоні необхідно виконати запит на
http://booking.uz.gov.ua/en/purchase/coach/
з тілом:
station_id_from=2200001
station_id_till=2218000
train=743К
coach_num=1
coach_class=2
coach_type_id=19
date_dep=1462976400
change_scheme=1

У відповідь отримуємо список вільних місць:
{
"value": {
"places": {
"А": [
"8",
"12",
"16",
"18",
"22",
"27",
"28",
"32",
"33",
"34",
"36",
"37",
"38",
"39",
"42",
"43",
"47",
"48",
"49",
"55",
"56"
]
}
},
"error": null,
"data": null,
"captcha": null
}

Робота з кошиком
Для того, щоб покласти квиток у кошик, тим самим зарезервувавши його на 15 хвилин для оплати, необхідно виконати запит на
http://booking.uz.gov.ua/en/cart/add/
з тілом:
code_station_from:2200007
code_station_to:2218000
train:743К
date:1463580000
round_trip:0
places[0][ord]:0
places[0][coach_num]:5
places[0][coach_class]:2
places[0][coach_type_id]:22
places[0][place_num]:37
places[0][firstname]:Name
places[0][lastname]:Surname
places[0][bedding]:0
places[0][child]:
places[0][stud]:
places[0][transp]:0
places[0][reserve]:0

Моніторинг
Отже, ось ми і дісталися до самої цікавої частини, до моніторингу вільних квитків. Для вирішення цієї задачі був реалізований клас
UZScanner
, який має кілька методів:
  • додати поїзд для моніторингу
  • видалити поїзд з моніторингу
  • запуск моніторингу
  • зупинка моніторингу
Клас моніторингу реалізований таким чином, щоб до нього з легкістю можна було підключати будь-які користувацькі інтерфейси, наприклад, будь-який інший, відмінний від Telegram, бот або веб сайт.
Моніторинг є асинхронним процесом і виконується як корутина. У разі успішного резервування квитка, моніторинг виконує callback, інформуючи користувача про результат. Для цього у конструктор класу передається callback-функції.
class UZScanner(object):

def __init__(self, success_cb, delay=60):
self.success_cb = success_cb

self.loop = asyncio.get_event_loop()
self.delay = delay
self.session = aiohttp.ClientSession()
self.client = UZClient(self.session)
self.__state = dict()
self.__running = False

Для того, щоб викликає код розрізняв для якого саме користувача стався callback, крім даних про самому поїзді також передається callback ID:
def add_item(self, success_cb_id, firstname, lastname, date,
source, destination, train_num, ct_letter=None):
scan_id = uuid4().hex
self.__state[scan_id] = dict(
success_cb_id=success_cb_id,
firstname=firstname,
lastname=lastname,
date=date,
source=source,
destination=destination,
train_num=train_num,
ct_letter=ct_letter,
lock=asyncio.Lock(),
attempts=0,
error=None)
return scan_id

Основна функція моніторингу є циклом, в якому для кожного поїзда запускається функція перевірки наявності місць.
async def run(self):
self.__running = True
while self.__running:
for scan_id, data in self.__state.items():
asyncio.ensure_future(self.scan(scan_id, data))
await reliable_async_sleep(self.delay)

Сама ж функція моніторингу працює за таким алгоритмом:
  • Отримати список поїздів на задану дату по заданому маршруту
  • Перевірити, чи є потрібний поїзд
  • Для всіх вагонів (або тільки для зазначеного типу) перевірити наявність місць
  • Спробувати зарезервувати перше знайдене вільне місце
  • У разі успіху, виконати callback, видалити поїзд з моніторингу
async def scan(self, scan_id, data):
if data['lock'].locked():
return

async with data['lock']:
data['attempts'] += 1

train = await self.client.fetch_train(
data['date'], data['source'], data['destination'], data['train_num'])
if train is None:
return self.handle_error(
scan_id, data, 'Train {} not found'.format(data['train_num']))

if data['ct_letter']:
coach_type = self.find_coach_type(train, data['ct_letter'])
if coach_type is None:
return self.handle_error(
scan_id, data, 'Coach type {} not found'.format(data['ct_letter']))
coach_types = [coach_type]
else:
coach_types = train.coach_types

session_id = await self.book(train, coach_types, data['firstname'], data['lastname'])
if session_id is None:
return self.handle_error(scan_id, data, 'No available seats')

await self.success_cb(data['success_cb_id'], session_id)
self.abort(scan_id)

@staticmethod
async def book(train, coach_types, firstname, lastname):
with UZClient() as client:
for coach_type in coach_types:
for coach in await client.list_coaches(train, coach_type):
try:
seats = await client.list_seats(train, coach)
except ResponseError:
continue
for seat in seats:
try:
await client.book_seat(train, coach, seat, firstname, lastname)
except ResponseError:
continue
return client.get_session_id()

Висновок
Ми розібралися з API, використовуваним порталом http://booking.uz.gov.ua і реалізували скрипт резервування квитка. Код доступний на GitHub. Docker image доступний на DockerHub. Також доступний Telegram бот @uz_ticket_bot
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.