Привет! Наверное, каждый из нас когда-то сталкивался с ситуацией, когда нужно срочно куда-то уехать, но все Ж/Д билеты уже раскуплены. В этой статье я расскажу о том, как я писал 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
и ru
это будет привычный нам 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",
"from": {
"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",
"from": {
"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
Автор: ketom_z80