244 lines
8.9 KiB
Python
244 lines
8.9 KiB
Python
import csv
|
|
import json
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from pprint import pprint
|
|
from typing import Any
|
|
|
|
import requests
|
|
|
|
BASE_URL = "https://www.nightjet.com"
|
|
|
|
|
|
def dprint(txt) -> None:
|
|
return
|
|
print(txt)
|
|
|
|
|
|
def request_init_token(endpoint: str = "/nj-booking-ocp/init/start") -> str:
|
|
DEBUG_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTU1NDMyMjMsInB1YmxpY0lkIjoiYmU2N2ZlNDNjY2Y3NDI3Yjk0MjY3NmI0MjJmZmIzOWYifQ.Hvo7Ljm9iFof_w7RrQkVVACOX8wgY2qAzTKAYDm5QC4"
|
|
token = DEBUG_TOKEN
|
|
|
|
# headers = {
|
|
# "Referer": "https://www.nightjet.com",
|
|
# "Content-Type": "application/json"
|
|
# }
|
|
# body = {
|
|
# "lang": "en"
|
|
# }
|
|
# resp_json = requests.post(f"{BASE_URL}{endpoint}", data=json.dumps(body), headers=headers).json()
|
|
# token = resp_json["token"]
|
|
|
|
dprint(f"Received init token: {token}")
|
|
return token
|
|
|
|
|
|
START_STATION = "8096003" # BerlinHBF
|
|
END_STATION = "8796001" # Paris Est
|
|
TRAVEL_DATE = "2025-10-14"
|
|
|
|
|
|
def request_connections(
|
|
token: str, endpoint: str = "/nj-booking-ocp/connection"
|
|
) -> list[Any]:
|
|
DEBUG_CONNECTIONS = json.loads(
|
|
'{ "connections": [ { "from": { "name": "Berlin Hbf", "number": "8011160" }, "to": { "name": "Paris Est", "number": "8700011" }, "trains": [ { "train": "NJ 40424", "departure": { "utc": 1760461680000, "local": "2025-10-14T19:08:00" }, "arrival": { "utc": 1760513880000, "local": "2025-10-15T09:38:00" }, "trainType": "regular", "seatAsIC": false } ] }, { "from": { "name": "Berlin Hbf", "number": "8011160" }, "to": { "name": "Paris Est", "number": "8700011" }, "trains": [ { "train": "NJ 40424", "departure": { "utc": 1760634480000, "local": "2025-10-16T19:08:00" }, "arrival": { "utc": 1760686680000, "local": "2025-10-17T09:38:00" }, "trainType": "regular", "seatAsIC": false } ] }, { "from": { "name": "Berlin Hbf", "number": "8011160" }, "to": { "name": "Paris Est", "number": "8700011" }, "trains": [ { "train": "NJ 40424", "departure": { "utc": 1760893680000, "local": "2025-10-19T19:08:00" }, "arrival": { "utc": 1760945880000, "local": "2025-10-20T09:38:00" }, "trainType": "regular", "seatAsIC": false } ] } ] }'
|
|
)
|
|
resp_json = DEBUG_CONNECTIONS
|
|
|
|
# uri = f"{BASE_URL}{endpoint}/{START_STATION}/{END_STATION}/{TRAVEL_DATE}"
|
|
# headers = {
|
|
# "Accept": "application/json",
|
|
# "Accept-Language": "en-US,en;q=0.5",
|
|
# "Referer": "https://www.nightjet.com/en/ticket-buchen/",
|
|
# "x-token": token,
|
|
# "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0",
|
|
# }
|
|
#
|
|
# resp_json = requests.get(
|
|
# uri,
|
|
# headers=headers,
|
|
# ).json()
|
|
return resp_json["connections"]
|
|
|
|
|
|
TRAVELLER_BIRTHDATE = "2000-07-15" # TODO: randomize a little
|
|
|
|
|
|
def connection_data_to_booking_requests(connections) -> list[dict[str, Any]]:
|
|
b_requests = []
|
|
for c in connections:
|
|
train = c["trains"][0]
|
|
dep = train["departure"]["utc"]
|
|
req = {
|
|
"njFrom": c["from"]["number"], # from station,
|
|
"njTo": c["to"]["number"], # to station
|
|
"njDep": dep, # departure time,
|
|
"maxChanges": 0,
|
|
"connections": 1,
|
|
"filter": {
|
|
"njTrain": train["train"], # train number
|
|
"njDeparture": dep, # departure time again
|
|
},
|
|
"objects": [ # traveller
|
|
{"type": "person", "birthDate": TRAVELLER_BIRTHDATE, "cards": []}
|
|
],
|
|
"relations": [],
|
|
"lang": "en",
|
|
}
|
|
b_requests.append(req)
|
|
dprint(
|
|
f"Crafted booking request {c['from']['name']} -> {c['to']['name']}: {train['departure']['local']}-{train['arrival']['local']}."
|
|
)
|
|
return b_requests
|
|
|
|
|
|
def request_bookings(
|
|
token: str, booking_req: dict[str, Any], endpoint: str = "/nj-booking-ocp/offer/get"
|
|
) -> dict[Any, Any]:
|
|
with open("bookings.json") as f:
|
|
DEBUG_BOOKINGS = json.load(f)[0]
|
|
resp_json = DEBUG_BOOKINGS
|
|
|
|
# headers = {
|
|
# "Accept": "application/json",
|
|
# "Accept-Language": "en-US,en;q=0.5",
|
|
# "Content-Type": "application/json",
|
|
# "Referer": "https://www.nightjet.com/en/ticket-buchen/",
|
|
# "Origin": "https://www.nightjet.com",
|
|
# "x-token": token,
|
|
# "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0",
|
|
# }
|
|
# resp_json = requests.post(
|
|
# f"{BASE_URL}{endpoint}", headers=headers, data=json.dumps(booking_req)
|
|
# ).json()
|
|
# dprint(f"Requested prices ({booking_req["njFrom"]} -> {booking_req["njTo"]} at {booking_req["njDep"]}).")
|
|
return resp_json
|
|
|
|
|
|
def json_extract(obj, key):
|
|
"""Recursively fetch values from nested JSON."""
|
|
arr = []
|
|
|
|
def extract(obj, arr, key):
|
|
"""Recursively search for values of key in JSON tree."""
|
|
if isinstance(obj, dict):
|
|
for k, v in obj.items():
|
|
if isinstance(v, (dict, list)):
|
|
extract(v, arr, key)
|
|
elif k == key:
|
|
arr.append(v)
|
|
elif isinstance(obj, list):
|
|
for item in obj:
|
|
extract(item, arr, key)
|
|
return arr
|
|
|
|
values = extract(obj, arr, key)
|
|
return values
|
|
|
|
|
|
@dataclass
|
|
class Price:
|
|
id: str
|
|
name: str
|
|
price: float
|
|
|
|
|
|
def extract_prices(bookings_dict: list[dict[Any, Any]]) -> list[Price]:
|
|
prices = []
|
|
# .result[].connections[].offers[].reservation.reservationSegments[].compartments[].objects
|
|
for booking in bookings_dict:
|
|
for reservation in booking["result"]:
|
|
for connection in reservation["connections"]:
|
|
for offer in connection["offers"]:
|
|
for reservation in offer["reservation"]["reservationSegments"]:
|
|
for compartment in reservation["compartments"]:
|
|
id = compartment["externalIdentifier"]
|
|
name = compartment["name"]["en"]
|
|
# filter undesired compartments
|
|
if id in ["sideCorridorCoach_2"]:
|
|
continue
|
|
# print all compartment identifiers w/ full name
|
|
# dprint(f"{id}: {name}")
|
|
|
|
# only keep those with a price (i.e. bookable?)
|
|
if "objects" not in compartment:
|
|
continue
|
|
price = compartment["objects"][0]["price"]
|
|
prices.append(Price(id, name, price))
|
|
return prices
|
|
|
|
|
|
def get_lowest_price(prices: list[Price]) -> Price:
|
|
lowest = Price("", "", 10000000.0)
|
|
for p in prices:
|
|
if p.price < lowest.price:
|
|
lowest = p
|
|
return lowest
|
|
|
|
|
|
CSV_LOWEST_FILE = "lowest.csv"
|
|
CSV_ALL_PRICES_SUFFIX = "_all_prices.csv"
|
|
|
|
|
|
def dump_all_prices_to_csv(prices: list[Price]) -> None:
|
|
with open(f"{int(datetime.now().timestamp())}{CSV_ALL_PRICES_SUFFIX}", "w") as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow(["id", "price", "name"])
|
|
writer.writerows([[price.id, price.price, price.name] for price in prices])
|
|
|
|
|
|
def add_to_csv(price: Price) -> None:
|
|
if not Path(CSV_LOWEST_FILE).is_file():
|
|
with open(CSV_LOWEST_FILE, "w") as f:
|
|
csv.writer(f).writerow(["id", "price", "name"])
|
|
|
|
with open(CSV_LOWEST_FILE, "a") as f:
|
|
csv.writer(f).writerow([price.id, price.price, price.name])
|
|
|
|
|
|
def get_last_price_from_csv() -> Price | None:
|
|
if not Path(CSV_LOWEST_FILE).is_file():
|
|
return
|
|
|
|
with open(CSV_LOWEST_FILE) as f:
|
|
last = next(reversed(list(csv.reader(f))))
|
|
return Price(last[0], last[2], float(last[1]))
|
|
|
|
|
|
def notify_user(previous: Price, new: Price, channel: str) -> None:
|
|
requests.post(
|
|
f"https://ntfy.sh/{channel}",
|
|
data=f"from {previous.price} -> {new.price} ({new.name})",
|
|
headers={
|
|
"Title": f"Nightjet train price went {'down' if new.price < previous.price else 'up'}",
|
|
"Priority": "urgent" if new.price < previous.price else "default",
|
|
"Tags": "green_circle" if new.price < previous.price else "orange_circle",
|
|
},
|
|
)
|
|
|
|
|
|
def main():
|
|
token = request_init_token()
|
|
connections = request_connections(token)
|
|
booking_requests = connection_data_to_booking_requests(connections)
|
|
bookings = [request_bookings(token, req) for req in booking_requests]
|
|
prices = extract_prices(bookings)
|
|
|
|
# create a snapshot of all current prices
|
|
dump_all_prices_to_csv(prices)
|
|
|
|
# extract the lowest and the last lowest price
|
|
new = get_lowest_price(prices)
|
|
previous = get_last_price_from_csv()
|
|
|
|
# if the price changed, add it to lowest prices
|
|
if not previous or new.price != previous.price:
|
|
print(f"PRICE CHANGE. {previous} -> {new}")
|
|
notify_user(previous or Price("", "", 0.0), new, "alerta-alerta-pichi-133")
|
|
add_to_csv(new)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|