📜 ⬆️ ⬇️

We automate the purchase of railway tickets Ukrzalіznitsі

Hello! Probably, each of us once came across a situation where you need to urgently go somewhere, but all the F / D tickets are already sold out. In this article I will talk about how I wrote Telegram bot to track and purchase vacated tickets Ukrzalіznitsі.


How it works


For the purchase of train tickets in Ukraine, the company Ukrzaliznitsya launched a resource http://booking.uz.gov.ua/ . The resource is convenient because you do not need to visit the ticket office to pick up the ticket itself. It is enough to show the conductor the QR code from the boarding pass on the smartphone screen or printed out on the printer.


The problem is that on popular flights the places end very quickly and sometimes buying a ticket is quite problematic. However, many people do not buy a ticket, but book it. The reservation is valid only for 24 hours and after that, if it is not purchased at the box office, the ticket will be returned to the pool of free. Thus, it is necessary to have time to catch this moment when the ticket is available for purchase before it is again booked or purchased.


It was decided to solve this problem with the help of a script, which once a minute checks free tickets for the train of interest and, if available, reserves it for 15 minutes. After that, the user must complete the procedure of payment through a web browser.


Telegram was chosen as the interface as it is a new platform for me and I wanted to deal with it a bit. As a bonus, we immediately receive notifications to the mobile, without thinking about push notifications or emails.
Python was chosen as the programming language.


Interface


And yet, how does this work from the user's point of view?
The bot recognizes the following commands:



In case of successful reservation of the ticket, the user will receive a message containing the Session ID. This ID is then required to manually register in the browser’s cookie and complete the ticket purchase.


UZ API


First, let's deal with the API format used by the portal. This is not a big deal, just open the developer console in the browser and see what requests the script performs on the ticket search page.


The API uses only POST requests. To protect against third-party API use, a token is included in the body in almost all calls. Without a token, you can only search for stations.


It is also worth noting some of the nuances of working with dates. First, the date format changes depending on the current locale of the API. For example, for the en locale, the format would be mm.dd.yyyy . Whereas for ua and ru it will be familiar to us dd.mm.yyyy . Secondly, for some requests, the date is represented as a timestamp, but it depends on the state of summer / winter time. So I decided not to bother with serializing / deserializing these stamps, but to use them in the form in which the API returns them.


Getting a token


Having rummaged in scripts connected by a site, it is possible to find out such piece with ease:


 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') || '' }); 

Here we see that when calling the API, the token is read from the browser localStorage. It remains to find where it is recorded there.


This part was the most interesting, because it could not be found by a simple search on html and js. After spending several hours in Google, I came across an article in which the author resolves the same issue with monitoring tickets on the UZ website. So, the article describes in detail that the token is generated by obfuscated code using JJEncode . In a few minutes we find the implementation of deobfuscator on python , which will be used in the future.


Short API reference


To call API methods, you must include the following headers:


 GV-Ajax: 1 GV-Referer: http://booking.uz.gov.ua/en/ GV-Token: <token> 

Search stations


For example, to generate hints for autocompletion of stations, a request is made with an empty body at http://booking.uz.gov.ua/en/purchase/station/ky/ , where ky is what the user enters in the text box of the station selection.


In response, the server sends approximately the following 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 } 

Search for trains


To search for trains, you must perform a request at http://booking.uz.gov.ua/en/purchase/search/ with the following body:


 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= 

In response, we will receive a list of trains following the specified route. Also, the answer will include information on the number of empty seats in the cars of each type (Suite, Coupe, Platzkart, etc.):


 { "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 } 

View wagons


You can view the list of cars and the number of empty seats by completing a request at http://booking.uz.gov.ua/en/purchase/coaches/ with the following body:


 station_id_from=2200001 station_id_till=2218000 date_dep=1462976400 train=743 #   model=3 #   coach_type=2 #   (, ,  . .) round_trip=0 another_ec=0 

In response, we will receive a list of cars of this type with the number of empty seats and the price:


 { "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 } 

View available places


To view the available seats in the selected car, you must fulfill the request at http://booking.uz.gov.ua/en/purchase/coach/ with the body:


 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 

In response, we get a list of available places:


 { "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 } 

Work with a basket


In order to put a ticket in the basket, thus having reserved it for 15 minutes for payment, you must fulfill the request at http://booking.uz.gov.ua/en/cart/add/ with the body:


 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 

Monitoring


So, here we are at the most interesting part, the monitoring of free tickets. To solve this problem, the UZScanner class has been implemented, which has several methods:



The monitoring class is implemented in such a way that you can easily connect any user interfaces to it, for example, any other non-Telegram, bot, or web site.


Monitoring is an asynchronous process and is performed as quorutine. In case of successful reservation of the ticket, the callback monitoring is performed, informing the user about the result. For this, a callback function is passed to the class constructor.


 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 

In order for the calling code to distinguish for which particular user the callback occurred, in addition to the data on the train itself, callback ID is also transmitted:


 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 

The main monitoring function is a cycle in which the function of checking seats is launched for each train.


 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) 

The monitoring function itself works according to the following algorithm:



 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() 

Conclusion


We dealt with the API used by the http://booking.uz.gov.ua portal and implemented the ticket reservation script. The code is available on github . Docker image is available on DockerHub . Also available Telegram bot @uz_ticket_bot


UPD UZ blocked IP bot. Bot is temporarily disabled.


')

Source: https://habr.com/ru/post/303150/


All Articles