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

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

Для начала давайте разберёмся с форматом 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: 


Поиск станций

Например, для формирования подсказок автодополнения станций выполняется запрос с пустым телом по адресу 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

© Habrahabr.ru