├── .gitignore ├── LICENSE ├── README.md └── trenitalia.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.pyc 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jacopo Jannone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TrenitaliaAPI 2 | 3 | Reverse engineering dell'API dell'applicazione di **Trenitalia per Android**. Il contenuto di questo repository è il frutto di una ricerca personale, non esiste alcuna affiliazione con Trenitalia o con altri organi delle Ferrovie dello Stato. Distribuito con [licenza MIT](https://github.com/jacopo-j/TrenitaliaAPI/LICENSE). 4 | 5 | ## Documenti 6 | 7 | * Per informazioni dettagliate su come ho ottenuto questo materiale leggi [**il post sul mio blog**](https://blog.jacopojannone.com/2018/09/24/trenitalia-app-reversed.html). 8 | * Per una documentazione *empirica* dell'API vedi [**la Wiki**](https://github.com/jacopo-j/TrenitaliaAPI/wiki/API-dell'app-Trenitalia). 9 | 10 | Questo modulo è da considerarsi una bozza: sono stati implementati solo i metodi principali e probabilmente esistono molti casi limite che causano eccezioni non gestite. Data la complessità dei dati non è stato possibile testare ogni singola circostanza. 11 | 12 | ## Requisiti 13 | 14 | * Python 3.7 15 | * modulo `requests` 16 | 17 | ## Utilizzo 18 | 19 | ```python 20 | from trenitalia import TrenitaliaBackend 21 | from datetime import datetime 22 | 23 | tb = TrenitaliaBackend() 24 | 25 | # Ricerca di una stazione (restituisce una lista di risultati) 26 | tb.search_station(name="milano", # Nome da cercare 27 | only_italian=False) # Cerca solo stazioni italiane (default = False) 28 | 29 | # Ricerca di una soluzione di viaggio (restituisce un generatore) 30 | # È possibile inserire data e ora di partenza OPPURE data e ora di arrivo 31 | tb.search_solution(origin="830008409", # ID della stazione di origine 32 | destination="830000219", # ID della stazione di destinazione 33 | dep_date=datetime.now(), # Data e ora di partenza 34 | arr_date=None, # Data e ora di arrivo (default = None) 35 | adults=1, # Numero di adulti (default = 1) 36 | children=0, # Numero di bambini (default = 0) 37 | train_type="All", # Può essere "All", "Frecce", "Regional" (default = "All") 38 | max_changes=99, # Massimo numero di cambi (default = 99) 39 | limit=10) # Massimo numero di soluzioni da cercare (default = 10) 40 | 41 | # Info su un treno in tempo reale (restituisce un dizionario) 42 | tb.train_info(number="9600", # Numero del treno 43 | dep_st=None, # ID della stazione di origine (opzionale) 44 | arr_st=None, # ID della stazione di destinazione (opzionale) 45 | dep_date=None) # Data di partenza (opzionale) 46 | 47 | # Tabellone arrivi/partenze (restituisce una lista di treni) 48 | tb.timetable(station_id="830008409", # ID della stazione 49 | ttype="departure") # "departure" o "arrival" 50 | 51 | 52 | ``` 53 | -------------------------------------------------------------------------------- /trenitalia.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | Author: Jacopo Jannone 6 | Repository: https://github.com/jacopo-j/TrenitaliaAPI 7 | License: MIT 8 | Copyright: (c) 2018 Jacopo Jannone 9 | 10 | Questo commento non deve MAI essere rimosso da questo file e deve 11 | essere riportato integralmente nell'intestazione di qualunque programma 12 | che faccia uso del codice di seguito o di parte di esso. 13 | 14 | This comment may NOT be removed from this file and it must be present 15 | on the header of any program using this code or any part of it. 16 | 17 | ------------------------------------------------------------------------ 18 | 19 | Permission is hereby granted, free of charge, to any person obtaining a 20 | copy of this software and associated documentation files (the 21 | "Software"), to deal in the Software without restriction, including 22 | without limitation the rights to use, copy, modify, merge, publish, 23 | distribute, sublicense, and/or sell copies of the Software, and to 24 | permit persons to whom the Software is furnished to do so, subject to 25 | the following conditions: 26 | 27 | The above copyright notice and this permission notice shall be included 28 | in all copies or substantial portions of the Software. 29 | 30 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 31 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 32 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 33 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 34 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 35 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 36 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 37 | ''' 38 | 39 | 40 | import requests 41 | import uuid 42 | import json 43 | import time 44 | import re 45 | from datetime import datetime, timedelta 46 | from decimal import Decimal 47 | 48 | 49 | class TrenitaliaBackend(): 50 | VERSION = "5.3.0.0015" # Versione dell'app 51 | VERSION_SHORT = "5.3.0" # Versione dell'app a 3 numeri 52 | HOST = "https://gw71.mplat.trenitalia.it:444/" 53 | BACKEND_PATH = "Trenitalia50/apps/services/api/Trenitalia/android/" 54 | INIT_URL = f"{HOST}{BACKEND_PATH}init" 55 | QUERY_URL = f"{HOST}{BACKEND_PATH}query" 56 | NIL = {"nil": True} 57 | 58 | class AuthenticationError(Exception): 59 | pass 60 | 61 | class InvalidServerResponse(Exception): 62 | pass 63 | 64 | class Non200StatusCode(Exception): 65 | pass 66 | 67 | class TrainNotFound(Exception): 68 | pass 69 | 70 | class MultipleTrainsFound(Exception): 71 | pass 72 | 73 | class TrainCancelled(Exception): 74 | pass 75 | 76 | class NoSolutionsFound(Exception): 77 | pass 78 | 79 | def __init__(self): 80 | self._session = requests.session() 81 | self._session.headers.update({"x-wl-app-version": self.VERSION}) 82 | # Genero un UUID univoco che identificherà questa sessione 83 | self._device_id = str(uuid.uuid4()) 84 | self._authenticate() 85 | 86 | def _cleanup(self, response): 87 | '''Ripulisce i file JSON restituiti dal server rimuovendo i tag 88 | che li racchiudono. 89 | ''' 90 | return response.replace("/*-secure-", "").replace("*/", "") 91 | 92 | def _authenticate(self, authd=None): 93 | '''Esegue l'autenticazione. Viene chiamata alla creazione 94 | dell'oggetto e ogni volta che la sessione scade. 95 | ''' 96 | if authd is None: 97 | r = self._session.post(self.INIT_URL) 98 | if (r.status_code != 401): 99 | raise self.InvalidServerResponse("Unexpected response from " 100 | "server while starting new " 101 | "session") 102 | authd = json.loads(self._cleanup(r.text)) 103 | iid = authd["challenges"]["wl_antiXSRFRealm"]["WL-Instance-Id"] 104 | token = (authd["challenges"]["wl_deviceNoProvisioningRealm"]["token"]) 105 | self._session.headers.update({"WL-Instance-Id": iid}) 106 | authh = {"wl_deviceNoProvisioningRealm": {"ID": {"app": 107 | {"id": "Trenitalia", "version": self.VERSION}, 108 | "custom": {}, 109 | "device": {"environment": "android", 110 | "id": self._device_id, 111 | "model": "unknown", 112 | "os": "7.1.0"}, 113 | "token": token}}} 114 | r = self._session.post(self.INIT_URL, 115 | headers={"Authorization": json.dumps(authh)}) 116 | r.raise_for_status() 117 | result = json.loads(self._cleanup(r.text)) 118 | if ("WL-Authentication-Success" not in result): 119 | raise self.AuthenticationError("Authentication failed") 120 | 121 | def _parse_time(self, string): 122 | '''Parsing delle durate in formato ISO 8601''' 123 | values = re.findall(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", string)[0] 124 | total_seconds = 0 125 | if values[0] != "": 126 | total_seconds += int(values[0]) * 3600 127 | if values[1] != "": 128 | total_seconds += int(values[1]) * 60 129 | if values[2] != "": 130 | total_seconds += int(values[2]) 131 | return timedelta(seconds=total_seconds) 132 | 133 | def _parse_date(self, string, timezone=True): 134 | '''Parsing delle date''' 135 | if timezone: 136 | return datetime.strptime(string, "%Y-%m-%dT%H:%M:%S%z") 137 | return datetime.strptime(string, "%Y-%m-%dT%H:%M:%S") 138 | 139 | def _build_date(self, date): 140 | '''Costruzione di una stringa che rappresenta una data, con il 141 | fuso orario corretto 142 | ''' 143 | if date is None: return None 144 | if date.tzinfo is None: 145 | tzsec = time.localtime().tm_gmtoff 146 | tz = "{:+03d}:{:02d}".format(int(tzsec / 3600), abs(tzsec) % 3600) 147 | return datetime.strftime(date, "%Y-%m-%dT%H:%M:%S") + tz 148 | tzw = datetime.strftime(date, "%z") 149 | tz = "{}:{}".format(tzw[:3], tzw[3:]) 150 | return datetime.strftime(date, "%Y-%m-%dT%H:%M:%S") + tz 151 | 152 | def _parse_stop_type(self, string): 153 | '''Parsing del tipo di fermata e conversione''' 154 | convert = {"Transit": "T", 155 | "Departure": "P", 156 | "Arrival": "A", 157 | "Stop": "F"} 158 | return convert[string] 159 | 160 | def _dict2list(self, item): 161 | '''Converte un dizionario in una lista contenente il dizionario 162 | stesso 163 | ''' 164 | if isinstance(item, list): 165 | return item 166 | if isinstance(item, dict): 167 | return [item] 168 | 169 | def search_station(self, name, only_italian=False): 170 | p = [{"AppVersion": self.VERSION_SHORT, 171 | "Credentials": None, 172 | "CredentialsAlias": None, 173 | "DeviceId": self._device_id, 174 | "Language": "IT", 175 | "PlantId": "android", 176 | "PointOfSaleId": 3, 177 | "UnitOfWork": 0}, 178 | {"GetStationsRequest": {"Body": {"Name": name}}}, 179 | {"extractOnlyItalianStations": only_italian}] 180 | for i in range(2): 181 | r = self._session.post(self.QUERY_URL, 182 | data={"adapter": "StationsAdapter", 183 | "procedure": "GetStations", 184 | "parameters": json.dumps(p)}) 185 | result = json.loads(self._cleanup(r.text)) 186 | if r.status_code == 200: 187 | break 188 | if i > 0: 189 | raise self.AuthenticationError("Authentication attempt failed " 190 | "after getting non 200 status " 191 | "code") 192 | self._authenticate(result) 193 | if (result["statusCode"] != 200): 194 | raise self.Non200StatusCode("Response statusCode {}: {}".format( 195 | result["statusCode"], 196 | result["statusReason"])) 197 | data = (result["Envelope"]["Body"]["GetStationsResponse"]["Body"] 198 | ["StationDetail"]) 199 | output = [] 200 | for station in data: 201 | output.append({"name": station["name"], 202 | "lon": Decimal(station["longitude"]) 203 | if Decimal(station["longitude"]) != 0 else None, 204 | "lat": Decimal(station["latitude"]) 205 | if Decimal(station["latitude"]) != 0 else None, 206 | "id": int(station["stationcode"][2:]), 207 | "railway": int(station["railwaycode"])}) 208 | return data 209 | 210 | def search_solution(self, origin, destination, dep_date, arr_date=None, 211 | adults=1, children=0, train_type="All", 212 | max_changes=99, limit=10): 213 | cur_index = 0 214 | if dep_date is None: 215 | depdrange = None 216 | else: 217 | depdrange = {"Start": self._build_date(dep_date), "End": None} 218 | if arr_date is None: 219 | arrdrange = None 220 | else: 221 | arrdrange = {"Start": self._build_date(arr_date), "End": None} 222 | while cur_index < limit: 223 | p = [{"AppVersion": self.VERSION_SHORT, 224 | "Credentials": None, 225 | "CredentialsAlias": None, 226 | "DeviceId": self._device_id, 227 | "Language": "IT", 228 | "PlantId": "android", 229 | "PointOfSaleId": 3, 230 | "UnitOfWork": 0}, 231 | {"SearchTravelsRequest": 232 | {"Body": {"PagingCriteria": {"StartIndex": cur_index, 233 | "EndIndex": cur_index, 234 | "SortDirection": None}, 235 | "OriginStationId": origin, 236 | "DestinationStationId": destination, 237 | "DepartureDateTimeRange": depdrange, 238 | "ArrivalDateTimeRange": arrdrange, 239 | "ReturnDateTimeRange": None, 240 | "ArrivalReturnDateTimeRange": None, 241 | "IsReturn": False, 242 | "Passengers": {"PassengerQuantity": [ 243 | {"Type": "Adult", "Quantity": adults}, 244 | {"Type": "Child", "Quantity": children}]}, 245 | "FidelityCardCode": None, 246 | "PostSaleCriteria": None, 247 | "TrainType": train_type, 248 | "MaxNumberOfChanges": str(max_changes)}}}] 249 | for i in range(2): 250 | r = self._session.post(self.QUERY_URL, 251 | data={"adapter": "SearchAndBuyAdapter", 252 | "procedure": "SearchTravels", 253 | "parameters": json.dumps(p)}) 254 | result = json.loads(self._cleanup(r.text)) 255 | if r.status_code == 200: 256 | break 257 | if i > 0: 258 | raise self.AuthenticationError("Authentication attempt " 259 | "failed after getting non " 260 | "200 status code") 261 | self._authenticate(result) 262 | if result["statusCode"] == 500: 263 | if result["statusReason"].startswith("Nessuna soluzione"): 264 | raise self.NoSolutionsFound() 265 | if result["statusReason"] == ("Errore restituito dal sistema " 266 | "centrale"): 267 | return 268 | if (result["statusCode"] != 200): 269 | raise self.Non200StatusCode("Response statusCode {}: {}".format( 270 | result["statusCode"], 271 | result["statusReason"])) 272 | solution = (result["Envelope"]["Body"]["SearchTravelsResponse"] 273 | ["Body"]["PageResult"]["TravelSolution"]) 274 | output = {"changes": int(solution["Changes"]), 275 | "destination": 276 | {"name": solution["DestinationStation"]["Name"], 277 | "id": solution["DestinationStation"]["Id"]}, 278 | "origin": 279 | {"name": solution["OriginStation"]["Name"], 280 | "id": solution["OriginStation"]["Id"]}, 281 | "duration": self._parse_time( 282 | solution["TotalJourneyTime"]), 283 | "arr_date": self._parse_date( 284 | solution["ArrivalDateTime"]), 285 | "dep_date": self._parse_date( 286 | solution["DepartureDateTime"]), 287 | "saleable": solution["IsSaleable"], 288 | "solution_id": solution["SolutionId"], 289 | "vehicles": [], 290 | "min_points": Decimal(solution["MinLoyaltyPoints"]) 291 | if "MinLoyaltyPoints" in solution and 292 | solution["MinLoyaltyPoints"] != self.NIL 293 | else None, 294 | "min_price": Decimal(solution["MinPrice"]) 295 | if solution["MinPrice"] != self.NIL else None} 296 | for v in self._dict2list(solution["Nodes"]["SolutionNode"]): 297 | vh_data = {"dep_date": self._parse_date( 298 | v["DepartureDateTime"]), 299 | "arr_date": self._parse_date( 300 | v["ArrivalDateTime"]), 301 | "category": (v["Train"]["CategoryCode"], 302 | v["Train"]["CategoryName"]), 303 | "number": v["Train"]["Number"], 304 | "arr_station": 305 | {"name": v["ArrivalStation"]["Name"], 306 | "id": v["ArrivalStation"]["Id"]}, 307 | "dep_station": 308 | {"name":v["DepartureStation"]["Name"], 309 | "id": v["DepartureStation"]["Id"]}, 310 | "id": v["Id"], 311 | "duration": self._parse_time( 312 | v["JourneyDuration"])} 313 | output["vehicles"].append(vh_data) 314 | yield output 315 | cur_index += 1 316 | 317 | def train_info(self, number, dep_st=None, arr_st=None, dep_date=None): 318 | p = [{"AppVersion": self.VERSION_SHORT, 319 | "Credentials": None, 320 | "CredentialsAlias": None, 321 | "DeviceId": self._device_id, 322 | "Language": "IT", 323 | "PlantId": "android", 324 | "PointOfSaleId": 3, 325 | "UnitOfWork": 0}, 326 | {"TrainRealtimeInfoRequest": 327 | {"Body": {"ArrivalStationId": arr_st, 328 | "DepartureDate": self._build_date(dep_date), 329 | "DepartureStationId": dep_st, 330 | "Train": {"CategoryCode": None, 331 | "CategoryName": None, 332 | "Notifiable": None, 333 | "Number": number}}}}] 334 | for i in range(2): 335 | r = self._session.post(self.QUERY_URL, 336 | data={"adapter": "TrainRealtimeInfoAdapter", 337 | "procedure": "TrainRealtimeInfo", 338 | "parameters": json.dumps(p)}) 339 | result = json.loads(self._cleanup(r.text)) 340 | if r.status_code == 200: 341 | break 342 | if i > 0: 343 | raise self.AuthenticationError("Authentication attempt failed " 344 | "after getting non 200 status " 345 | "code") 346 | self._authenticate(result) 347 | if result["statusCode"] == 500: 348 | if result["statusReason"] == "Treno non valido": 349 | raise self.TrainNotFound() 350 | if result["statusReason"] == "Il treno e' cancellato": 351 | raise self.TrainCancelled() 352 | if (result["statusCode"] != 200): 353 | raise self.Non200StatusCode("Response statusCode {}: {}".format( 354 | result["statusCode"], 355 | result["statusReason"])) 356 | data = (result["Envelope"]["Body"]["TrainRealtimeInfoResponse"]["Body"] 357 | ["RealtimeTrainInfoWithStops"]) 358 | if isinstance(data, list): 359 | raise self.MultipleTrainsFound() 360 | chkpdate = self._parse_date(data["LastCheckPointTime"], timezone=False) 361 | if (chkpdate == datetime(1, 1, 1, 0, 0)): 362 | chkpdate = None 363 | if (data["LastReachedCheckPoint"] != "--"): 364 | chkloc = data["LastReachedCheckPoint"] 365 | else: 366 | chkloc = None 367 | output = {"category": (data["Train"]["CategoryCode"], 368 | data["Train"]["CategoryName"]), 369 | "number": data["Train"]["Number"], 370 | "duration": self._parse_time(data["ScheduledDuration"]), 371 | "delay": self._parse_time(data["Delay"]), 372 | "viaggiatreno": data["IsViaggiaTreno"], 373 | "checkpoint_date": chkpdate, 374 | "checkpoint_locality": chkloc, 375 | "stops": []} 376 | for stop in data["Stops"]["RealtimeTrainStop"]: 377 | if stop["ScheduledInfo"]["Departure"] != self.NIL: 378 | sch_dep = self._parse_date(stop["ScheduledInfo"]["Departure"]) 379 | else: 380 | sch_dep = None 381 | if stop["ScheduledInfo"]["Arrival"] != self.NIL: 382 | sch_arr = self._parse_date(stop["ScheduledInfo"]["Arrival"]) 383 | else: 384 | sch_arr = None 385 | if stop["ActualInfo"]["Departure"] != self.NIL: 386 | act_dep = self._parse_date(stop["ActualInfo"]["Departure"]) 387 | else: 388 | act_dep = None 389 | if stop["ActualInfo"]["Arrival"] != self.NIL: 390 | act_arr = self._parse_date(stop["ActualInfo"]["Arrival"]) 391 | else: 392 | act_arr = None 393 | if stop["ActualInfo"]["Track"] != "": 394 | sch_plat = stop["ActualInfo"]["Track"] 395 | else: 396 | sch_plat = None 397 | if stop["ActualInfo"]["Track"] != "": 398 | act_plat = stop["ActualInfo"]["Track"] 399 | else: 400 | act_plat = None 401 | stopdata = {"reached": stop["Reached"], 402 | "type": self._parse_stop_type(stop["StopType"]), 403 | "station": {"id": stop["Station"]["Id"], 404 | "lat": stop["Station"]["Latitude"], 405 | "lon": stop["Station"]["Longitude"], 406 | "name": stop["Station"]["Name"]}, 407 | "scheduled_dep": sch_dep, 408 | "actual_dep": act_dep, 409 | "scheduled_arr": sch_arr, 410 | "actual_arr": act_arr, 411 | "scheduled_plat": sch_plat, 412 | "actual_plat": act_plat} 413 | output["stops"].append(stopdata) 414 | return output 415 | 416 | def timetable(self, station_id, ttype): 417 | p = [{"AppVersion": self.VERSION_SHORT, 418 | "Credentials": None, 419 | "CredentialsAlias": None, 420 | "DeviceId": self._device_id, 421 | "Language": "IT", 422 | "PlantId": "android", 423 | "PointOfSaleId": 3, 424 | "UnitOfWork": 0, 425 | "StationId": station_id, 426 | "Type": ttype.upper()}, 427 | None] 428 | for i in range(2): 429 | r = self._session.post(self.QUERY_URL, 430 | data={"adapter": "GetStationTimetables", 431 | "procedure": "getStationTables", 432 | "parameters": json.dumps(p)}) 433 | result = json.loads(self._cleanup(r.text)) 434 | if r.status_code == 200: 435 | break 436 | if i > 0: 437 | raise self.AuthenticationError("Authentication attempt failed " 438 | "after getting non 200 status " 439 | "code") 440 | self._authenticate(result) 441 | output = [] 442 | for train in result["trains"]: 443 | try: 444 | chkpdate = self._parse_date(train["LastReachedCheckPointBase"]) 445 | except TypeError: 446 | chkpdate = None 447 | output.append({"category": (train["category"]["code"], 448 | train["category"]["name"]), 449 | "number": train["number"], 450 | "delay": self._parse_time(train["delay"]), 451 | "checkpoint_date": chkpdate, 452 | "origin": {"id": train["originId"], 453 | "name": train["originName"]}, 454 | "destination": {"id": train["destinationId"], 455 | "name": train["destinationName"]}, 456 | "dep_time": train["departureTime"], 457 | "arr_time": train["arrivalTime"], 458 | "scheduled_plat": train["scheduledTrack"] 459 | if train["scheduledTrack"] != "" else None, 460 | "actual_plat": train["actualTrack"] 461 | if train["actualTrack"] != "" else None}) 462 | return output 463 | --------------------------------------------------------------------------------