diff --git a/doc/example_vessel.json b/doc/example_ship.json similarity index 100% rename from doc/example_vessel.json rename to doc/example_ship.json diff --git a/doc/example_ships.json b/doc/example_ships.json new file mode 100644 index 0000000..088ca46 --- /dev/null +++ b/doc/example_ships.json @@ -0,0 +1,36 @@ +[ + { + "ship_name": "Mein Schiff 1", + "ship_flag": "mt", + "ship_line_id": "32", + "ship_line_title": "TUI Cruises", + "imo": "9783564", + "mmsi": "248513000", + "lat": "46.81366", + "lon": "-71.19978", + "cog": 276, + "sog": 0, + "heading": "185", + "tst": "1663245194", + "icon": "128", + "hover": "Mein Schiff 1", + "destination": "Quebec City" + }, + { + "ship_name": "Le Bellot", + "ship_flag": "wf", + "ship_line_id": "40", + "ship_line_title": "Ponant Cruises", + "imo": "9852418", + "mmsi": "578001500", + "lat": "46.81735", + "lon": "-71.19906", + "cog": 285, + "sog": 0, + "heading": "194", + "tst": "1663245891", + "icon": "1024", + "hover": "Le Bellot", + "destination": "CAQBE" + } +] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 46567ae..687d755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pycruisemapper" -version = "0.0.1" +version = "0.9.0" authors = [ { name="Kumi Mitterer", email="pycruisemapper@kumi.email" }, ] diff --git a/src/pycruisemapper/classes/api.py b/src/pycruisemapper/classes/api.py index b62e4e6..515f818 100644 --- a/src/pycruisemapper/classes/api.py +++ b/src/pycruisemapper/classes/api.py @@ -1,5 +1,5 @@ from .http import HTTPRequest -from .vessel import Vessel, Cruise, ShipLine, Flag +from .ship import Ship from ..const import SHIPS_URL, SHIP_URL from urllib.parse import urlencode @@ -10,14 +10,14 @@ import json class CruiseMapper: - def request_vessels(self, **kwargs) -> List[Dict]: + def request_ships(self, **kwargs) -> List[Dict]: payload = { "minLat": kwargs.get("min_lat", -90), "maxLat": kwargs.get("max_lat", 90), "minLon": kwargs.get("min_lon", -180), "maxLon": kwargs.get("max_lon", 180), "filter": ",".join(kwargs.get("filter", [str(i) for i in range(100)])), - "zoom": "", + "zoom": kwargs.get("zoom", ""), "imo": kwargs.get("imo", ""), "mmsi": kwargs.get("mmsi", ""), "t": int(kwargs.get("timestamp", datetime.now().timestamp())) @@ -27,22 +27,57 @@ class CruiseMapper: return json.loads(request.open().read()) - def request_vessel(self, **kwargs) -> Dict: + def request_ship(self, **kwargs) -> Dict: payload = { "imo": kwargs.get("imo", ""), "mmsi": kwargs.get("mmsi", ""), - "zoom": "" + "zoom": kwargs.get("zoom", "") } request = HTTPRequest(f"{SHIP_URL}?{urlencode(payload)}") return json.loads(request.open().read()) - def get_vessels(self, **kwargs) -> List[Vessel]: - pass + def get_ships(self, **kwargs) -> List[Ship]: + """Get data on all ships using ships.json endpoint - def get_vessel(self, **kwargs) -> Vessel: - pass + Note that **kwargs don't seem to have any influence here, so if you + need a particular vessel by IMO or MMSI, first get all ships by simply + calling get_ships(), then use a filter on the output like this: - def fill_vessel(self, vessel: Vessel): - pass \ No newline at end of file + MeinSchiff1 = list(filter(lambda x: x.imo == 9783564))[0] + + Returns: + List[Ship]: A list of Ship objects for all ships + """ + + return [Ship.from_dict(d) for d in self.request_ships(**kwargs)] + + def get_ship(self, **kwargs) -> Ship: + """Get data on a single ship using ship.json endpoint. + + Note that this lacks some important information, so you would almost + always want to get all ships through get_ships(), then pass the returned + Ship object to fill_ship() to add the information retrieved by get_ship(). + + Returns: + Ship: Ship object with the data returned by ship.json + """ + return Ship.from_dict(self.request_ship(**kwargs)) + + def fill_ship(self, ship: Ship) -> Ship: + """Add missing data to Ship object retrieved from get_ships + + Args: + ship (Ship): "Raw" Ship object as returned from get_ships + + Returns: + Ship: Ship object with data from both get_ships and get_ship + """ + + if not (ship.imo or ship.mmsi): + raise ValueError("Ship object has no identifier, cannot process") + + details = self.get_ship(mmsi=ship.mmsi, imo=ship.imo) + ship.__dict__.update({k: v for k, v in details.__dict__.items() if v is not None}) + return ship diff --git a/src/pycruisemapper/classes/cruise.py b/src/pycruisemapper/classes/cruise.py new file mode 100644 index 0000000..6b08fb7 --- /dev/null +++ b/src/pycruisemapper/classes/cruise.py @@ -0,0 +1,33 @@ +from typing import Optional + + +class Cruise: + name: Optional[str] + url: Optional[str] + start_date: Optional[datetime] + end_date: Optional[datetime] + itinerary: Optional[List[Optional[Tuple[str, str]]]] + + @property + def days(self) -> Optional[int]: + if self.end_date and self.start_date: + return (self.end_date - self.start_date).days + + @classmethod + def from_dict(cls, indict: dict): + obj = cls() + obj.name = indict.get("name") + obj.url = indict.get("url") + obj.start_date = indict.get("start_date") + obj.end_date = indict.get("end_date") + + if "itinerary" in indict: + obj.itinerary = [] + + for item in indict["itinerary"].values(): + obj.itinerary.append((item["port"], item["date"])) + + return obj + + def __repr__(self): + return self.__dict__.__repr__() diff --git a/src/pycruisemapper/classes/flag.py b/src/pycruisemapper/classes/flag.py new file mode 100644 index 0000000..f28d418 --- /dev/null +++ b/src/pycruisemapper/classes/flag.py @@ -0,0 +1,13 @@ +from typing import Optional + + +class Flag: + code: str + name: str + + def __init__(self, code: str, name: Optional[str] = None): + self.code = code + self.name = name + + def __repr__(self): + return self.__dict__.__repr__() diff --git a/src/pycruisemapper/classes/http.py b/src/pycruisemapper/classes/http.py index acc9c68..2ceced2 100644 --- a/src/pycruisemapper/classes/http.py +++ b/src/pycruisemapper/classes/http.py @@ -9,4 +9,4 @@ class HTTPRequest(Request): self.headers.update(REQUIRED_HEADERS) def open(self): - return urlopen(self) \ No newline at end of file + return urlopen(self) diff --git a/src/pycruisemapper/classes/location.py b/src/pycruisemapper/classes/location.py new file mode 100644 index 0000000..b02e669 --- /dev/null +++ b/src/pycruisemapper/classes/location.py @@ -0,0 +1,10 @@ +class Location: + latitude: float + longitude: float + + def __init__(self, latitude: float, longitude: float): + self.latitude = latitude + self.longitude = longitude + + def __repr__(self): + return self.__dict__.__repr__() diff --git a/src/pycruisemapper/classes/ship.py b/src/pycruisemapper/classes/ship.py new file mode 100644 index 0000000..5d2c517 --- /dev/null +++ b/src/pycruisemapper/classes/ship.py @@ -0,0 +1,199 @@ +from datetime import datetime, timedelta +from typing import Optional, List, Tuple +from locale import setlocale, LC_ALL + +import re + +from .location import Location +from .cruise import Cruise +from .flag import Flag +from .shipline import ShipLine +from ..const import IMAGE_BASE_PATH, TEMP_REGEX, HTML_REGEX, DEGREES_REGEX, SPEED_REGEX, GUST_REGEX + + +class Ship: + id: Optional[int] + name: Optional[str] + url: Optional[str] + url_deckplans: Optional[str] + url_staterooms: Optional[str] + image: Optional[str] + flag: Optional[Flag] + line: Optional[ShipLine] + spec_length: Optional[int] # stored in meters + spec_passengers: Optional[int] + year_built: Optional[int] + last_report: Optional[str] + imo: Optional[int] + mmsi: Optional[int] + latitude: Optional[float] + longitude: Optional[float] + cog: Optional[int] # Course over Ground + sog: Optional[int] # Speed over Ground + heading: Optional[int] + timestamp: Optional[datetime] + icon: Optional[int] + hover: Optional[str] + cruise: Optional[Cruise] + path: Optional[List[Optional[Location]]] + ports: Optional[List[Optional[Tuple[Optional[datetime], Optional[Location]]]]] + destination: Optional[str] + eta: Optional[datetime] + current_temperature: Optional[float] # Celsius + minimum_temperature: Optional[float] # Celsius + maximum_temperature: Optional[float] # Celsius + wind_degrees: Optional[float] + wind_speed: Optional[float] # m/s + wind_gust: Optional[float] # m/s + localtime: Optional[str] + + @classmethod + def from_dict(cls, indict: dict): + obj = cls() + + obj.id = indict.get("id") + obj.name = indict.get("name", indict.get("ship_name")) + obj.url = indict.get("url") + obj.url_deckplans = indict.get("url_deckplans") + obj.url_staterooms = indict.get("url_staterooms") + + if "image" in indict: + obj.image = f"{IMAGE_BASE_PATH}{indict.get('image')}" + + if "flag" in indict: + obj.flag = Flag( + code=indict["flag"].get("code"), + name=indict["flag"].get("name") + ) + elif "ship_flag" in indict: + obj.flag = Flag( + code=indict.get("ship_flag") + ) + + if "line" in indict: + obj.line = ShipLine( + id=int(indict["line"]["id"]), + title=indict["line"].get("title"), + url=indict["line"].get("url") + ) + elif "ship_line_id" in indict: + obj.line = ShipLine( + id=indict.get("ship_line_id"), + title=indict.get("ship_line_title") + ) + + if "spec_length" in indict: + parts = indict["spec_length"].split("/") + for part in parts: + if "m" in part: + try: + obj.spec_length = float(part.strip().split()[0]) + except: + pass + + if "spec_passengers" in indict: + obj.spec_passengers = int(indict["spec_passengers"]) + + if "year_of_built" in indict: # Those field names... 🤦 + obj.year_built = int(indict["year_of_built"]) + + obj.last_report = indict.get("last_report") + + if "imo" in indict: + obj.imo = int(indict["imo"]) + + if "mmsi" in indict: + obj.mmsi = int(indict["mmsi"]) + + if "lat" in indict and "lon" in indict: + obj.location = Location(indict["lat"], indict["lon"]) + + if "cog" in indict: + obj.cog = int(indict["cog"]) + + if "sog" in indict: + obj.sog = int(indict["sog"]) + + if "heading" in indict: + obj.heading = int(indict["heading"]) + + if "ts" in indict: + obj.timestamp = datetime.fromtimestamp(indict[ts]) + + obj.icon = indict.get("icon") + obj.hover = indict.get("hover") + + if "cruise" in indict: + obj.cruise = Cruise.from_dict(indict["cruise"]) + + if "path" in indict: + if "points" in indict["path"]: + obj.path = list() + + for point in indict["path"]["points"]: + lon, lat = point + obj.path.append(Location(lat, lon)) + + if "ports" in indict["path"]: + obj.ports = list() + + for port in indict["path"]["ports"]: + if "dep_datetime" in port: + departure = datetime.strptime( + port["dep_datetime"], "%Y-%m-%d %H:%M:%S") + else: + departure = None + + if "lat" in port and "lon" in port: + location = Location(port["lat"], port["lon"]) + else: + location = None + + obj.ports.append(departure, location) + + obj.destination = indict.get("destination") + + if "eta" in indict: + try: + previous = setlocale(LC_ALL) + setlocale(LC_ALL, "C") + obj.eta = datetime.strptime(date_string, "%d %B, %H:%M") + setlocale(LC_ALL, previous) + except: + obj.eta = None + + if "weather" in indict: + temp_regex = re.compile(TEMP_REGEX) + html_regex = re.compile(HTML_REGEX) + degrees_regex = re.compile(DEGREES_REGEX) + speed_regex = re.compile(SPEED_REGEX) + gust_regex = re.compile(GUST_REGEX) + + if "temperature" in indict["weather"]: + obj.current_temperature = float(re.search(temp_regex, re.sub( + html_regex, "", indict["weather"]["temperature"])).groups()[0]) + if "wind" in indict["weather"]: + try: + obj.wind_degrees = int( + re.search(degrees_regex, indict["weather"]["wind"]).groups()[0]) + except: + pass + + try: + obj.wind_speed = float( + re.search(speed_regex, indict["weather"]["wind"]).groups()[0]) + except: + pass + + try: + obj.wind_gust = float( + re.search(speed_regex, indict["weather"]["wind"]).groups()[0]) + except: + pass + + obj.localtime = indict["weather"].get("localtime") + + return obj + + def __repr__(self): + return self.__dict__.__repr__() diff --git a/src/pycruisemapper/classes/shipline.py b/src/pycruisemapper/classes/shipline.py new file mode 100644 index 0000000..61d188b --- /dev/null +++ b/src/pycruisemapper/classes/shipline.py @@ -0,0 +1,15 @@ +from typing import Optional + + +class ShipLine: + id: int + title: str + url: Optional[str] + + def __init__(self, id: int, title: str, url: Optional[str] = None): + self.id = id + self.title = title + self.url = url + + def __repr__(self): + return self.__dict__.__repr__() \ No newline at end of file diff --git a/src/pycruisemapper/classes/vessel.py b/src/pycruisemapper/classes/vessel.py deleted file mode 100644 index 0e82ba2..0000000 --- a/src/pycruisemapper/classes/vessel.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import datetime, timedelta -from typing import Optional, List, Tuple - - -class Cruise: - name: Optional[str] - url: Optional[str] - start_date: Optional[datetime] - end_date: Optional[datetime] - itinerary: Optional[List[Optional[Tuple[str, str]]]] - - @property - def days(self) -> Optional[int]: - if self.end_date and self.start_date: - return (self.end_date - self.start_date).days - -class Flag: - code: str - name: str - -class ShipLine: - title: str - id: int - url: Optional[str] - -class Vessel: - id: Optional[int] - name: Optional[str] - url: Optional[str] - url_deckplans: Optional[str] - url_staterooms: Optional[str] - image: Optional[str] - flag: Flag - line: Optional[ShipLine] - spec_length: Optional[int] # stored in meters - spec_passengers: Optional[int] - year_built: Optional[int] - last_report: Optional[str] - imo: int - mmsi: int - latitude: float - longitude: float - cog: int # Course over Ground - sog: int # Speed over Ground - heading: int - timestamp: datetime - icon: int - hover: str - cruise: Optional[Cruise] - path: Optional[List[Optional[Tuple[float, float]]]] - ports: Optional[List[Optional[Tuple[datetime, float, float]]]] - destination: str - eta: Optional[datetime] - current_temperature: Optional[float] # Celsius - minimum_temperature: Optional[float] # Celsius - maximum_temperature: Optional[float] # Celsius - wind_degrees: Optional[float] - wind_speed: Optional[float] # m/s - wind_gust: Optional[float] # m/s - utc_offset: Optional[timedelta] \ No newline at end of file diff --git a/src/pycruisemapper/const.py b/src/pycruisemapper/const.py index 73f24d8..5df6572 100644 --- a/src/pycruisemapper/const.py +++ b/src/pycruisemapper/const.py @@ -1,5 +1,13 @@ +IMAGE_BASE_PATH = "https://www.cruisemapper.com/" + SHIPS_URL = "https://www.cruisemapper.com/map/ships.json" -SHIP_URL = "https://www.cruisemapper.com/map/ships.json" +SHIP_URL = "https://www.cruisemapper.com/map/ship.json" REQUIRED_HEADERS = {"X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (compatible: pyCruiseMapper; https://kumig.it/kumitterer/pycruisemapper)"} + +TEMP_REGEX = "(-?\d+\.?\d*) ?°C" +HTML_REGEX = "<.*?>" +DEGREES_REGEX = "(\d+) ?°" +SPEED_REGEX = "\/ ?(\d+.?\d*) ?m\/s" +GUST_REGEX = "Gust: (\d+.?\d*) ?m\/s" \ No newline at end of file