diff --git a/README.md b/README.md index f9a6ab9..312db2b 100644 --- a/README.md +++ b/README.md @@ -39,18 +39,31 @@ pip install pyventim ### Quick start -#### Public API +To find attractions we can use the exploration endpoint: ```python -# Import the module -from pyventim.public import EventimExploration - -# Returns attractions found by the explorer api given the search term. -explorer: EventimExploration = EventimExploration() -result = explorer.explore_attractions( - search_term="Stage Theater im Hafen Hamburg", - sort="DateAsc", -) +import pyventim + +# Create the Eventim class +eventim = pyventim.Eventim() + +# Returns an iterator that fetches all pages off the search endpoint. +attractions = eventim.explore_attractions(search_term="Landmvrks") + +# We can loop over each attraction. The module handles fetching pages automatically. +for attraction in attractions: + print(attraction["attractionId"], attraction["name"]) +``` + +Next we use the product group endpoint to fetch events for our attraction and get the events of the html endpoint. + +```python + +# We loop over each product group and fetch the events automatically. +for product_group in eventim.explore_product_groups(search_term="Landmvrks"): + product_group_id = product_group["productGroupId"] + for event in eventim.get_product_group_events_from_calendar(product_group_id): + print(event["title"], event["eventDate"], event["price"], event["ticketAvailable"], sep=" | ") ``` For a more detailed information please refer to the [documentation](https://kggx.github.io/pyventim/pyventim.html). diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..71a166f --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,3 @@ +
+ +# Examples diff --git a/docs/getting_started.md b/docs/getting_started.md deleted file mode 100644 index 81d5592..0000000 --- a/docs/getting_started.md +++ /dev/null @@ -1,9 +0,0 @@ -
- -# Getting started - -## Module structure - -``` -|- pyventim.Eventim # G -``` diff --git a/docs/seatmap_endpoint.md b/docs/seatmap_endpoint.md new file mode 100644 index 0000000..bc3c578 --- /dev/null +++ b/docs/seatmap_endpoint.md @@ -0,0 +1,105 @@ +## Seatmap Endpoint + +This endpoint can be used to get seatmap data of specific attraction. + +Functionality can break without notice because this is parsed via HTML. + +### get_event_seatmap_information + +This function can return data about the seatmap if found in the HTML of the event page. If no seat map is found then None will be returned. + +```python +# This will fetch the html for the given event. Note the url must match the structure in the example! +result = eventim.get_event_seatmap_information("/event/disneys-der-koenig-der-loewen-stage-theater-im-hafen-hamburg-18500464/") +``` + +A sample for the seatmap information can be found here (Note this is truncated.): + +```json +{ + "fansale": { <- ... -> }, + "seatmapOptions": { <- ... -> }, + "seatmapIsDefault": false, + "packageData": { <- ... -> }, + "price": { <- ... -> }, + "maxAmountForEvent": 10, + "minAmountForEvent": 1, + "maxAmountForPromo": 0, + "preselectedPcId": 0, + "showBackLink": false, + "backLink": "/event/disneys-der-koenig-der-loewen-stage-theater-im-hafen-hamburg-16828914/", + "eventName": "Disneys DER K\u00d6NIG DER L\u00d6WEN", + "restrictions": { <- ... -> }, + "cartData": { <- ... -> }, + "panoramaOptions": { <- ... -> }, + "imageviewerOptions": { <- ... -> }, + "ccEnabled": true, + "initialRequestPath": "/api/shoppingCart/?affiliate=EVE&token=518ACD577869FB764ED195DF569D347C", + "captchaActive": false, + "captchaAPIPath": "//www.google.com/recaptcha/api.js?hl=de", + "captchaSiteKey": "", + "pcFlyoutExpanded": true, + "pcUpgradeActiveClass": "js-pc-upgrade-active", + "pricePlaceholder": "#PRICE#", + "hasPriceDisclaimerHint": false, + "hasCorporateBenefits": false, + "hasCombinedTicketTypes": false, + "hasAllCategoriesToggleText": true, + "reducedAvailabilityWidgetLink": "", + "hasOptionsFilter": false, + "ticketTypeFilterMap": { <- ... -> }, + "api": "/api/corporate/benefit/limits/?affiliate=EVE", + "useParis24Styles": false, + "messages": { <- ... -> } +} +``` + +### get_event_seatmap + +This function returns seatmap data with avialible seats and their pricing data. This could be used to generate a seatmap image or make yield analytics. + +```python +# This will fetch the html for the given event. Note the url must match the structure in the example! +result = eventim.get_event_seatmap_information("/event/disneys-der-koenig-der-loewen-stage-theater-im-hafen-hamburg-18500464/") +seatmap = eventim.get_event_seatmap(result["seatmapOptions"]) +``` + +A sample for the seatmap information can be found here (Note this is fictional data): + +```json +{ + "seatmap_key": "web_1_16828914_0_EVE_0", + "seatmap_timestamp": 1716717879990, + "seatmap_individual_seats": 2051, + "seatmap_dimension_x": 8192, + "seatmap_dimension_y": 8192, + "seatmap_seat_size": 59, + "blocks": [ + { + "block_id": "b1", + "block_name": "Parkett links", + "block_description": "Parkett links", + "block_rows": [ + { + "row_code": "r4", + "row_seats": [ + { + "seat_code": "s515", + "seat_price_category_index": 1, + "seat_coordinate_x": 3846, + "seat_coordinate_y": 1699 + } + ] + } + ] + } + ], + "price_categories": [ + { + "price_category_id": "p32919041", + "price_category_name": "Kat. 1 Premium", + "price_category_color": "#f1075e" + } + ] +} +``` diff --git a/requirements-dev.txt b/requirements-dev.txt index 5bd5c19..9585cb5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,3 +11,4 @@ black >= 24.4.0 pdoc >= 14.4.0 twine >= 5.1.0 build >= 1.2.1 +Pillow \ No newline at end of file diff --git a/shell.nix b/shell.nix index 835c876..ccfb4f6 100644 --- a/shell.nix +++ b/shell.nix @@ -27,6 +27,7 @@ let pdoc twine build + pillow ] ); diff --git a/src/pyventim/__init__.py b/src/pyventim/__init__.py index 62e35d5..01c9025 100644 --- a/src/pyventim/__init__.py +++ b/src/pyventim/__init__.py @@ -1,8 +1,9 @@ """ .. include:: ../../README.md -.. include:: ../../docs/getting_started.md +.. include:: ../../docs/examples.md .. include:: ../../docs/exploration_endpoint.md .. include:: ../../docs/component_endpoint.md +.. include:: ../../docs/seatmap_endpoint.md """ from .eventim import Eventim diff --git a/src/pyventim/adapters.py b/src/pyventim/adapters.py index 6e534b5..658f1fa 100644 --- a/src/pyventim/adapters.py +++ b/src/pyventim/adapters.py @@ -2,27 +2,30 @@ from json import JSONDecodeError from typing import Dict, Any - +import logging import requests -from .exceptions import ExplorationException, ComponentException +from .exceptions import RestException, HtmlException from .models import RestResult, HtmlResult -class ExplorationAdapter: - """Adapter for the exploration endpoint""" +class RestAdapter: + """Adapter for all resta based requests""" - def __init__(self, session: requests.Session | None = None) -> None: + def __init__( + self, + hostname: str, + session: requests.Session | None = None, + logger: logging.Logger | None = None, + ) -> None: self.session: requests.Session = session or requests.Session() self.session.headers.update( { "user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0" # pylint: disable=C0301 } ) - - self.hostname = ( - "https://public-api.eventim.com/websearch/search/api/exploration" - ) + self._logger = logger or logging.getLogger(__name__) + self.hostname = hostname def _do( self, @@ -32,19 +35,31 @@ def _do( json_data: Dict | None = None, ) -> RestResult: try: - response = self.session.request( + response: requests.Response = self.session.request( method=method, url=f"{self.hostname}/{endpoint}", params=params, json=json_data, ) + except requests.exceptions.RequestException as e: - raise ExplorationException("Request failed") from e + self._logger.critical(f"Request failed at {self.hostname}/{endpoint}") + preview = self.session.prepare_request( + requests.Request( + method=method, + url=f"{self.hostname}/{endpoint}", + params=params, + json=json_data, + ) + ) + self._logger.debug(preview.url) + + raise RestException("Request failed") from e try: data_out: Dict[str, Any] = response.json() except (ValueError, JSONDecodeError) as e: - raise ExplorationException("Bad JSON in response") from e + raise RestException("Bad JSON in response") from e if 299 >= response.status_code >= 200: return RestResult( @@ -53,10 +68,10 @@ def _do( json_data=data_out, ) - raise ExplorationException(f"{response.status_code}: {response.reason}") + raise RestException(f"{response.status_code}: {response.reason}") def get(self, endpoint: str, params: Dict | None = None) -> RestResult: - """Get a choosen endpoint on the public-api.eventim.com. + """Get a choosen endpoint on a restful API. Args: endpoint (str): Endpoint to query. @@ -68,37 +83,58 @@ def get(self, endpoint: str, params: Dict | None = None) -> RestResult: return self._do(method="GET", endpoint=endpoint, params=params) -class ComponentAdapter: - def __init__(self, session: requests.Session | None = None) -> None: +class HtmlAdapter: + """Adapter for all html based requests.""" + + def __init__( + self, + hostname: str = "https://www.eventim.de/", + session: requests.Session | None = None, + logger: logging.Logger | None = None, + ) -> None: self.session: requests.Session = session or requests.Session() self.session.headers.update( { "user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0" # pylint: disable=C0301 } ) - - self.hostname = "https://www.eventim.de/component" + self._logger = logger or logging.getLogger(__name__) + self.hostname = hostname def _do( self, method: str, + endpoint: str, params: Dict | None = None, json_data: Dict | None = None, ) -> HtmlResult: try: response = self.session.request( method=method, - url=f"{self.hostname}", + url=f"{self.hostname}/{endpoint}", params=params, json=json_data, ) + except requests.exceptions.RequestException as e: - raise ComponentException("Request failed") from e + self._logger.critical(f"Request failed at {self.hostname}/{endpoint}") + preview = self.session.prepare_request( + requests.Request( + method=method, + url=f"{self.hostname}/{endpoint}", + params=params, + json=json_data, + ) + ) + self._logger.debug(preview.url) + raise HtmlException("Request failed") from e + + self._logger.debug(response.request.url) try: data_out: str = response.content.decode("utf-8") except (ValueError, JSONDecodeError) as e: - raise ComponentException("Bad HTML in response") from e + raise HtmlException("Bad HTML in response") from e if 299 >= response.status_code >= 200: return HtmlResult( @@ -107,10 +143,10 @@ def _do( html_data=data_out, ) - raise ComponentException(f"{response.status_code}: {response.reason}") + raise HtmlException(f"{response.status_code}: {response.reason}") - def get(self, params: Dict | None = None) -> HtmlResult: - """Get a choosen endpoint on the public-api.eventim.com. + def get(self, endpoint: str, params: Dict | None = None) -> HtmlResult: + """Get a choosen endpoint on the html page. Args: endpoint (str): Endpoint to query. @@ -119,63 +155,4 @@ def get(self, params: Dict | None = None) -> HtmlResult: Returns: RestResult: RestResult with status_code, message and json_data """ - return self._do(method="GET", params=params) - - -# class EventimCompenent: -# """Class that handles access to the public Eventim API for components.""" - -# def __init__(self, session: requests.Session = None) -> None: -# # If a valid session is not provided by the user create a new one. -# if not isinstance(session, requests.Session): -# self.session: requests.Session = requests.Session() -# else: -# self.session: requests.Session = session - -# # Requires a desktop browser user-agent -# self.session.headers.update( -# { -# "user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0" -# } -# ) -# self.endpoint = "https://www.eventim.de/component/" - - -# def get_attraction_events(self, attraction_id: int) -> dict: -# r = self.session.get( -# f"{self.endpoint}", -# headers={}, -# params={ -# "doc": "component", -# "esid": f"{attraction_id}", -# "fun": "eventselectionbox", -# # "startdate": None, -# # "enddate": None, -# # "ptype": None, -# # "cityname": None, -# # "filterused": True, -# # "pnum": 1, -# }, -# ) -# r.raise_for_status() -# return r.content.decode("utf-8") -# # Actually returning json in scheme: https://schema.org/MusicEvent -# # https://www.eventim.de/component/?affiliate=EVE&cityname=Berlin&doc=component&esid=3642502&filterused=true&fun=eventselectionbox&tab=1&startdate=2024-05-22 -# # https://www.eventim.de/component/?doc=component&enddate=2024-09-30&esid=473431&fun=eventlisting&pnum=2&ptype=&startdate=2024-05-18 -# # Parameters -# # esid via public exploration API aka artist id -# # pnum = page number -# # filterused = true -# # startdate -# # enddate -# # ptype=tickets | vip_packages (TODO: Look for types) -# # fun=eventselectionbox -# # doc=component -# # cityname=Hamburg - -# # raise NotImplementedError() - -# # König der Löwen: -# # https://www.eventim.de/component/?affiliate=EVE&cityname=Hamburg&doc=component&esid=473431&filterused=true&fun=eventselectionbox&startdate=2024-05-12&tab=2&enddate=2024-05-12 - -# # Sleep Token https://www.eventim.de/component/ + return self._do(method="GET", endpoint=endpoint, params=params) diff --git a/src/pyventim/eventim.py b/src/pyventim/eventim.py index f80e7db..92fbc4e 100644 --- a/src/pyventim/eventim.py +++ b/src/pyventim/eventim.py @@ -4,11 +4,15 @@ from typing import Literal, Iterator, Dict, List from .models import ExplorationParameters, ComponentParameters -from .adapters import ExplorationAdapter, ComponentAdapter # pylint: disable=E0401 +from .adapters import RestAdapter, HtmlAdapter # pylint: disable=E0401 from .utils import ( parse_has_next_page_from_component_html, parse_list_from_component_html, parse_calendar_from_component_html, + parse_has_seatmap_from_event_html, + parse_seatmap_configuration_from_event_html, + parse_seatmap_url_params_from_seatmap_information, + parse_seathamp_data_from_api, ) @@ -18,8 +22,13 @@ class Eventim: def __init__( self, ) -> None: - self.explorer_api: ExplorationAdapter = ExplorationAdapter() - self.component_adapter: ComponentAdapter = ComponentAdapter() + self.rest_adapter: RestAdapter = RestAdapter( + hostname="https://public-api.eventim.com/websearch/search/api/exploration" + ) + self.private_rest_adapter: RestAdapter = RestAdapter( + hostname="https://api.eventim.com" + ) + self.html_adapter: HtmlAdapter = HtmlAdapter() def explore_attractions( self, @@ -46,7 +55,7 @@ def explore_attractions( while True: - rest_result = self.explorer_api.get( + rest_result = self.rest_adapter.get( endpoint="v1/attractions", params=params.model_dump(exclude_none=True) ) @@ -85,7 +94,7 @@ def explore_locations( ) while True: - rest_result = self.explorer_api.get( + rest_result = self.rest_adapter.get( endpoint="v1/locations", params=params.model_dump(exclude_none=True) ) @@ -143,7 +152,7 @@ def explore_product_groups( ) while True: - rest_result = self.explorer_api.get( + rest_result = self.rest_adapter.get( endpoint="v2/productGroups", params=params.model_dump(exclude_none=True) ) @@ -158,21 +167,21 @@ def explore_product_groups( params.page = params.page + 1 - def get_attraction_events( + def get_product_group_events( self, - attraction_id: int, + product_group_id: int, date_from: date | None = None, date_to: date | None = None, ticket_type: Literal["tickets", "vip_packages", "extras"] | None = None, city_name: str | None = None, ) -> Iterator[Dict]: # pylint: disable=line-too-long - """This returns the attraction events. The attraction events follow this schema: https://schema.org/MusicEvent. + """This returns the product_group_id events. The product_group_id events follow this schema: https://schema.org/MusicEvent. The API only returns a maximum of 90 events. This should be plenty but some event types like continous musicals have more than 90 events - **If you try to fetching many events (>90) at a time then get_attraction_events_from_calendar() should be used.** + **If you try to fetching many events (>90) at a time then get_product_group_events_from_calendar() should be used.** Args: - attraction_id (int): Attraction ID to query + product_group_id (int): product_group_id to query date_from (date | None, optional): Event date later than. Defaults to None. date_to (date | None, optional): Event date earlier than. Defaults to None. ticket_type (Literal["tickets", "vip_packages", "extras"] | None, optional): Include only events with tickets avialible in type. Defaults to None. @@ -183,7 +192,7 @@ def get_attraction_events( """ params = ComponentParameters( - esid=attraction_id, + esid=product_group_id, startdate=date_from, enddate=date_to, ptype=ticket_type, @@ -191,32 +200,32 @@ def get_attraction_events( ) while params.pnum <= 10: - comp_result = self.component_adapter.get( - params=params.model_dump(exclude_none=True) + comp_result = self.html_adapter.get( + endpoint="component", params=params.model_dump(exclude_none=True) ) - attraction_events = parse_list_from_component_html(comp_result.html_data) + product_group_events = parse_list_from_component_html(comp_result.html_data) - for attraction_event in attraction_events: - yield attraction_event + for product_group_event in product_group_events: + yield product_group_event if parse_has_next_page_from_component_html(comp_result.html_data) is False: break params.pnum = params.pnum + 1 - def get_attraction_events_from_calendar( + def get_product_group_events_from_calendar( self, - attraction_id: int, + product_group_id: int, date_from: date | None = None, date_to: date | None = None, ticket_type: Literal["tickets", "vip_packages", "extras"] | None = None, city_name: str | None = None, ) -> Iterator[Dict]: # pylint: disable=line-too-long - """This returns the attraction events. The attraction events follow a custom calendar schema. + """This returns the product_group events. The product_group events follow a custom calendar schema. Args: - attraction_id (int): Attraction ID to query + product_group_id (int): product_group_id to query date_from (date | None, optional): Event date later than. Defaults to None. date_to (date | None, optional): Event date earlier than. Defaults to None. ticket_type (Literal["tickets", "vip_packages", "extras"] | None, optional): Include only events with tickets avialible in type. Defaults to None. @@ -226,15 +235,15 @@ def get_attraction_events_from_calendar( Iterator[Dict]: The events in a calendar schema. """ params = ComponentParameters( - esid=attraction_id, + esid=product_group_id, startdate=date_from, enddate=date_to, ptype=ticket_type, cityname=city_name, ) - comp_result = self.component_adapter.get( - params=params.model_dump(exclude_none=True) + comp_result = self.html_adapter.get( + endpoint="component", params=params.model_dump(exclude_none=True) ) calendar_configuration = parse_calendar_from_component_html( @@ -243,5 +252,53 @@ def get_attraction_events_from_calendar( calendar_content = calendar_configuration["calendar_content"] - for attraction_event in calendar_content["result"]: - yield attraction_event + for product_group_event in calendar_content["result"]: + yield product_group_event + + def get_event_seatmap_information(self, event_url: str) -> Dict | None: + """Given a event url like "/event/disneys-der-koenig-der-loewen-stage-theater-im-hafen-hamburg-18500464/" the function will return the seatmap information if present. + + Args: + event_url (str): Event url to be checked + + Returns: + Dict | None: Returns the seatmap option or None if nothing was found in the html.s + """ + # pylint: disable=line-too-long + event_key = event_url.split("/")[2] + html_result = self.html_adapter.get(endpoint=f"event/{event_key}", params=None) + + if not parse_has_seatmap_from_event_html(html_result.html_data): + return None + + return parse_seatmap_configuration_from_event_html(html_result.html_data) + + def get_event_seatmap(self, seatmap_options: dict, parse: bool = True) -> Dict: + """This function gets a seatmap from the private eventim api using embeded signed links. + + Args: + seatmap_options (dict): Seatmap options taken from the get_event_seatmap_information function. + parse (bool, optional): Whether to return a parsed or raw result. Defaults to True. + + Returns: + Dict: Result of the seatmap call. Only returns avialible seats in event. Note: Standing seats are also not included! + """ + # pylint: disable=line-too-long + + # Get the url via seatmap options + params = parse_seatmap_url_params_from_seatmap_information( + options=seatmap_options + ) + + # Fetch data + seatmap = self.private_rest_adapter.get( + endpoint="seatmap/api/SeatMapHandler", + params=params, + ) + + # Export raw if desired + if not parse: + return seatmap.json_data + + # Return the parsed + return parse_seathamp_data_from_api(seatmap.json_data) diff --git a/src/pyventim/exceptions.py b/src/pyventim/exceptions.py index 0b3f8be..5d82828 100644 --- a/src/pyventim/exceptions.py +++ b/src/pyventim/exceptions.py @@ -1,9 +1,9 @@ """Custom Exception Classes for the wrapper""" -class ExplorationException(Exception): - """Raised if the Exploration Adatper returns an error.""" +class RestException(Exception): + """Raised if the Rest Adatper returns an error.""" -class ComponentException(Exception): +class HtmlException(Exception): """Raised if the Component Adatper returns an error.""" diff --git a/src/pyventim/models.py b/src/pyventim/models.py index bd96889..b54c0bf 100644 --- a/src/pyventim/models.py +++ b/src/pyventim/models.py @@ -32,7 +32,7 @@ class ComponentParameters(BaseModel): doc: Literal["component"] = "component" fun: Literal["eventselectionbox"] = "eventselectionbox" - esid: int + esid: int # product_group_id pnum: int = 1 # This is optional but recommended to start at 1 startdate: Optional[date] = None diff --git a/src/pyventim/utils.py b/src/pyventim/utils.py index cc247d7..dc07728 100644 --- a/src/pyventim/utils.py +++ b/src/pyventim/utils.py @@ -86,3 +86,124 @@ def parse_has_next_page_from_component_html(html: str) -> bool: return current < total return False + + +def parse_has_seatmap_from_event_html(html: str) -> bool: + """This function checks if the html has a seatmap data compoenent + + Args: + html (str): HTML to check + + Returns: + bool: True if the html has a seatmapOptions string + """ + return ( + len( + lxml.html.fromstring(html).xpath( + ".//script[@type='application/configuration' and contains(text(),'seatmapOptions')]" + ) + ) + > 0 + ) + + +def parse_seatmap_configuration_from_event_html(html: str) -> Dict: + """This function parses the seatmap configuration from an event page. + + Args: + html (str): HTML to parse + + Returns: + Dict: Returns the extracted json data. + """ + return json.loads( + lxml.html.fromstring(html) + .xpath( + ".//script[@type='application/configuration' and contains(text(),'seatmapOptions')]" + )[0] + .text + ) + + +def parse_seathamp_data_from_api(seatmap_data: Dict) -> Dict: + # pylint: disable=line-too-long + """This function parses the eventim seatmap data in a more readable format + + Args: + seatmap_data (Dict): Raw seatmap data returned by a eventim event page. + + Returns: + Dict: Seat map data in a readable format. Seatmap meta and seats in the format "Block -> Row -> Seat" + """ + blocks = [] + for block in seatmap_data["blocks"]: + rows = [] + for row in block["rows"]: + seats = [] + for seat in row[1]: + seats.append( + dict( + seat_code=seat[0], + seat_price_category_index=seat[1], + seat_coordinate_x=seat[2], + seat_coordinate_y=seat[3], + ) + ) + # End of seat + + rows.append(dict(row_code=row[0], row_seats=seats)) + # End of row + + blocks.append( + dict( + block_id=block["blockId"], + block_name=block["name"], + block_description=block["blockDescription"], + block_rows=rows, + ) + ) + # End of block + + # Parse pricing categories + price_categories = [ + dict( + price_category_id=x[0], price_category_name=x[1], price_category_color=x[2] + ) + for x in seatmap_data["pcs"] + ] + + # Return the final seatmap + seatmap = dict( + seatmap_key=seatmap_data["key"], + seatmap_timestamp=seatmap_data["availabilityTimestamp"], + seatmap_individual_seats=seatmap_data["individualSeats"], + seatmap_dimension_x=seatmap_data["dimension"][0], + seatmap_dimension_y=seatmap_data["dimension"][1], + seatmap_seat_size=seatmap_data["seatSize"], + blocks=blocks, + price_categories=price_categories, + ) + + return seatmap + + +def parse_seatmap_url_params_from_seatmap_information(options: dict) -> Dict: + """This function builds a private signed api url from the obtained seatmap information. + + Args: + options (dict): Options to be processed + + Returns: + Dict: Dictionary containing key, value pairs for the request + """ + params = { + param.split("=")[0]: param.split("=")[1] + for param in options["additionalRequestParams"][1:].split("&") + } + # Add additional parmeter to the params + params["cType"] = options["cType"] + params["evId"] = options["evId"] + params["cId"] = options["cId"] + params["fun"] = "json" + + return params diff --git a/tests/.resources/sample_component.html b/tests/.resources/sample_component.html deleted file mode 100644 index c2c4c08..0000000 --- a/tests/.resources/sample_component.html +++ /dev/null @@ -1,4787 +0,0 @@ -
-
-
- -
-
- -
-
- - Datumseingabe löschen - - -
-
- - -
- -
-
-
-
-
-
-
-
-
- - -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- -
-
- -
- -
-
-
- - -
-
-
-
- -
- -
-
- - diff --git a/tests/adapters/test_exploration_adapter.py b/tests/adapters/test_exploration_adapter.py index 931a481..87e1f8d 100644 --- a/tests/adapters/test_exploration_adapter.py +++ b/tests/adapters/test_exploration_adapter.py @@ -5,8 +5,7 @@ from pyventim import exceptions # pylint: disable=E0401 -exp = adapters.ExplorationAdapter() -exp.hostname = "https://httpstat.us/" +exp = adapters.RestAdapter(hostname="https://httpstat.us/") exp.session.headers.update({"accept": "application/json"}) @@ -22,7 +21,7 @@ def test_response_success(): def test_response_failure(): """Test failure with a 404""" with pytest.raises( - exceptions.ExplorationException, + exceptions.RestException, match="404: Not Found", ): exp.get("404") @@ -32,7 +31,7 @@ def test_respone_invalid_json(): """Test json decode failure with a html response.""" exp.session.headers.update({"accept": "application/html"}) with pytest.raises( - exceptions.ExplorationException, + exceptions.RestException, match="Bad JSON in response", ): exp.get("200") diff --git a/tests/eventim/test_component.py b/tests/eventim/test_component.py index a9a43e7..7b9ecb8 100644 --- a/tests/eventim/test_component.py +++ b/tests/eventim/test_component.py @@ -11,20 +11,20 @@ # We are testing against a long running german musical prone NOT to change with a known theater LOCATION = "Stage Theater im Hafen Hamburg" ATTRACTION = "Disneys DER KÖNIG DER LÖWEN" -ATTRACTION_ID = 473431 +PRODUCT_GROUP_ID = 473431 SORT = "DateAsc" -def test_get_attraction_events_success(): - """Tests the attractions on a fixed attraction""" +def test_get_product_group_events(): + """Tests on a fixed product_group""" # Check the overall result to be a iterator - attractions = EVENTIM.get_attraction_events(ATTRACTION_ID) + product_group_events = EVENTIM.get_product_group_events(PRODUCT_GROUP_ID) - assert isinstance(attractions, Iterator) + assert isinstance(product_group_events, Iterator) # Item check - first_item = next(attractions) + first_item = next(product_group_events) assert isinstance(first_item, Dict) @@ -54,15 +54,17 @@ def test_get_attraction_events_success(): assert first_item["location"]["name"] == "Stage Theater im Hafen Hamburg" -def test_get_attraction_events_from_calendar(): - """Tests the attractions on a fixed attraction""" +def test_get_product_group_events_from_calendar(): + """Tests on a fixed product_group""" # Check the overall result to be a iterator - attractions = EVENTIM.get_attraction_events_from_calendar(ATTRACTION_ID) + product_group_events = EVENTIM.get_product_group_events_from_calendar( + PRODUCT_GROUP_ID + ) - assert isinstance(attractions, Iterator) + assert isinstance(product_group_events, Iterator) # Item check - first_item = next(attractions) + first_item = next(product_group_events) assert isinstance(first_item, Dict) diff --git a/tests/eventim/test_seatmap.py b/tests/eventim/test_seatmap.py new file mode 100644 index 0000000..78eae3a --- /dev/null +++ b/tests/eventim/test_seatmap.py @@ -0,0 +1,115 @@ +from typing import Dict + +import pytest +import pydantic +from pyventim import Eventim # pylint: disable=E0401 + + +EVENTIM = Eventim() +EVENT_URL = ( + "/event/disneys-der-koenig-der-loewen-stage-theater-im-hafen-hamburg-18500464/" +) + + +def test_get_event_seatmap_information(): + result = EVENTIM.get_event_seatmap_information(EVENT_URL) + assert isinstance(result, Dict) + assert sorted(list(result.keys())) == sorted( + [ + "fansale", + "seatmapOptions", + "seatmapIsDefault", + "packageData", + "price", + "maxAmountForEvent", + "minAmountForEvent", + "maxAmountForPromo", + "preselectedPcId", + "showBackLink", + "backLink", + "eventName", + "restrictions", + "cartData", + "panoramaOptions", + "imageviewerOptions", + "ccEnabled", + "initialRequestPath", + "captchaActive", + "captchaAPIPath", + "captchaSiteKey", + "pcFlyoutExpanded", + "pcUpgradeActiveClass", + "pricePlaceholder", + "hasPriceDisclaimerHint", + "hasCorporateBenefits", + "hasCombinedTicketTypes", + "hasAllCategoriesToggleText", + "reducedAvailabilityWidgetLink", + "hasOptionsFilter", + "ticketTypeFilterMap", + "api", + "useParis24Styles", + "messages", + ] + ) + + # Value check on backLink & name + assert ( + result["backLink"] + == "/event/disneys-der-koenig-der-loewen-stage-theater-im-hafen-hamburg-18500464/" + ) + assert result["eventName"] == "Disneys DER K\u00d6NIG DER L\u00d6WEN" + + +def test_get_event_seatmap(): + # Get the event + result = EVENTIM.get_event_seatmap_information(EVENT_URL) + assert isinstance(result, Dict) + + seatmap = EVENTIM.get_event_seatmap(result["seatmapOptions"]) + assert isinstance(seatmap, Dict) + assert sorted(list(seatmap.keys())) == sorted( + [ + "seatmap_key", + "seatmap_timestamp", + "seatmap_individual_seats", + "seatmap_dimension_x", + "seatmap_dimension_y", + "seatmap_seat_size", + "blocks", + "price_categories", + ] + ) + + # Check block + first_block = seatmap["blocks"][0] + assert isinstance(first_block, Dict) + assert sorted(list(first_block.keys())) == sorted( + [ + "block_id", + "block_name", + "block_description", + "block_rows", + ] + ) + # find first block with fileld row and check row + for block in seatmap["blocks"]: + for row in block["block_rows"]: + if len(row["row_seats"]) > 0: + filled_row = row + break + + assert isinstance(filled_row, Dict) + assert sorted(list(filled_row.keys())) == sorted(["row_code", "row_seats"]) + + # Check seat + row_seats = filled_row["row_seats"][0] + assert isinstance(row_seats, Dict) + assert sorted(list(row_seats.keys())) == sorted( + [ + "seat_code", + "seat_price_category_index", + "seat_coordinate_x", + "seat_coordinate_y", + ] + ) diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index c1c3ff3..bcdc655 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -17,14 +17,15 @@ def test_parse_city_id_from_link(): def test_parse_list_from_component_html(): - adapter = pyventim.adapters.ComponentAdapter() + adapter = pyventim.adapters.HtmlAdapter() result = adapter.get( + endpoint="component", params={ "doc": "component", "esid": 473431, "fun": "eventselectionbox", "pnum": 1, - } + }, ) attractions = pyventim.utils.parse_list_from_component_html(result.html_data) assert isinstance(attractions, List) @@ -33,14 +34,15 @@ def test_parse_list_from_component_html(): def test_parse_calendar_from_component_html(): - adapter = pyventim.adapters.ComponentAdapter() + adapter = pyventim.adapters.HtmlAdapter() result = adapter.get( + endpoint="component", params={ "doc": "component", "esid": 473431, "fun": "eventselectionbox", "pnum": 1, - } + }, ) calendar_config = pyventim.utils.parse_calendar_from_component_html( result.html_data @@ -58,17 +60,197 @@ def test_parse_calendar_from_component_html(): def test_parse_has_next_page_from_component_html(): - adapter = pyventim.adapters.ComponentAdapter() + adapter = pyventim.adapters.HtmlAdapter() result = adapter.get( + endpoint="component", params={ "doc": "component", "esid": 473431, "fun": "eventselectionbox", "pnum": 1, - } + }, ) assert isinstance( pyventim.utils.parse_has_next_page_from_component_html(result.html_data), bool ) pass + + +def test_parse_seatmap_url_params_from_seatmap_information(): + options = { + "cType": "web", + "cId": 1, + "evId": 16828914, + "additionalRequestParams": "&a_systemId=1&a_promotionId=0&a_sessionId=EVE_NO_SESSION×tamp=28611964&expiryTime=28611974&chash=L_itK5sj-4&signature=yGTajJUzWiNtRSGzif1ajavSfxKMBfIKiSDyQfjXaGg", + "holds": True, + "drawMarker": True, + "useCommonBackground": True, + "onlyOnePcBookable": False, + "server": "https://api.eventim.com", + "seatThreshold": 2500, + "skipHulls": True, + "stage": {"mode": "drag", "iconUrl": "../images/green_arrow.png"}, + "shopLogoPath": "/obj/media/DE-eventim/specialLogos/checkoutApp/logo_blue_01.svg", + } + + params = pyventim.utils.parse_seatmap_url_params_from_seatmap_information(options) + assert isinstance(params, Dict) + + required_keys = [ + "cType", + "evId", + "cId", + "fun", + "a_sessionId", + "timestamp", + "expiryTime", + "chash", + "signature", + ] + + for key in required_keys: + assert key in list(params.keys()) + + +def test_parse_has_seatmap_from_event_html(): + html_adapter = pyventim.adapters.HtmlAdapter() + html_result = html_adapter.get( + endpoint=f"event/disneys-der-koenig-der-loewen-stage-theater-im-hafen-hamburg-18500464/", + params=None, + ) + assert ( + pyventim.utils.parse_has_seatmap_from_event_html(html_result.html_data) == True + ) + + html_adapter = pyventim.adapters.HtmlAdapter() + html_result = html_adapter.get( + endpoint=f"event/beartooth-sporthalle-hamburg-17585252/", + params=None, + ) + assert ( + pyventim.utils.parse_has_seatmap_from_event_html(html_result.html_data) == False + ) + + +def test_parse_seatmap_configuration_from_event_html(): + html_adapter = pyventim.adapters.HtmlAdapter() + html_result = html_adapter.get( + endpoint=f"event/disneys-der-koenig-der-loewen-stage-theater-im-hafen-hamburg-18500464/", + params=None, + ) + + result = pyventim.utils.parse_seatmap_configuration_from_event_html( + html_result.html_data + ) + assert isinstance(result, Dict) + assert sorted(list(result.keys())) == sorted( + [ + "fansale", + "seatmapOptions", + "seatmapIsDefault", + "packageData", + "price", + "maxAmountForEvent", + "minAmountForEvent", + "maxAmountForPromo", + "preselectedPcId", + "showBackLink", + "backLink", + "eventName", + "restrictions", + "cartData", + "panoramaOptions", + "imageviewerOptions", + "ccEnabled", + "initialRequestPath", + "captchaActive", + "captchaAPIPath", + "captchaSiteKey", + "pcFlyoutExpanded", + "pcUpgradeActiveClass", + "pricePlaceholder", + "hasPriceDisclaimerHint", + "hasCorporateBenefits", + "hasCombinedTicketTypes", + "hasAllCategoriesToggleText", + "reducedAvailabilityWidgetLink", + "hasOptionsFilter", + "ticketTypeFilterMap", + "api", + "useParis24Styles", + "messages", + ] + ) + + # Value check on backLink & name + assert ( + result["backLink"] + == "/event/disneys-der-koenig-der-loewen-stage-theater-im-hafen-hamburg-18500464/" + ) + assert result["eventName"] == "Disneys DER K\u00d6NIG DER L\u00d6WEN" + + +def test_parse_seathamp_data_from_api(): + dummy_block = { + "blockId": "b1", + "name": "Parkett links", + "blockDescription": "Parkett links", + "rows": [["r2", [["s260", 0, 3783, 1471]]]], + } + + seatmap_data = { + "key": "web_1_16825147_0_EVE_0", + "availabilityTimestamp": 1716215061170, + "individualSeats": 2051, + "dimension": [4096, 4096], + "seatSize": 59, + "blocks": [dummy_block], + "pcs": [["p32914323", "Kat. 1 Premium", "#f1075e", "#ffffff"]], + } + result = pyventim.utils.parse_seathamp_data_from_api(seatmap_data) + + assert isinstance(result, Dict) + assert sorted(list(result.keys())) == sorted( + [ + "seatmap_key", + "seatmap_timestamp", + "seatmap_individual_seats", + "seatmap_dimension_x", + "seatmap_dimension_y", + "seatmap_seat_size", + "blocks", + "price_categories", + ] + ) + + # Value checks + assert result["seatmap_key"] == "web_1_16825147_0_EVE_0" + assert result["seatmap_timestamp"] == 1716215061170 + assert result["seatmap_individual_seats"] == 2051 + + assert result["seatmap_dimension_x"] == 4096 + assert result["seatmap_dimension_y"] == 4096 + assert result["seatmap_seat_size"] == 59 + + # Block + block = result["blocks"][0] + assert block["block_id"] == "b1" + assert block["block_name"] == "Parkett links" + assert block["block_description"] == "Parkett links" + + # Row + row = block["block_rows"][0] + assert row["row_code"] == "r2" + + seat = row["row_seats"][0] + assert seat["seat_code"] == "s260" + assert seat["seat_price_category_index"] == 0 + assert seat["seat_coordinate_x"] == 3783 + assert seat["seat_coordinate_y"] == 1471 + + # Price Categories + price_category = result["price_categories"][0] + assert price_category["price_category_id"] == "p32914323" + assert price_category["price_category_name"] == "Kat. 1 Premium" + assert price_category["price_category_color"] == "#f1075e"