Привет! Наверное, каждый из нас когда-то сталкивался с ситуацией, когда нужно срочно куда-то уехать, но все Ж/Д билеты уже раскуплены. В этой статье я расскажу о том, как я писал 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
UPD УЗ заблокировала IP бота. Бот временно отключён.
