diff --git a/.gitignore b/.gitignore index 4106383..7fee17b 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,7 @@ venv.bak/ /site # mypy -.mypy_cache/ \ No newline at end of file +.mypy_cache/ + +#PyCharm +.idea/ \ No newline at end of file diff --git a/epicstore_api/api.py b/epicstore_api/api.py index 2901d76..40434e6 100644 --- a/epicstore_api/api.py +++ b/epicstore_api/api.py @@ -22,12 +22,13 @@ """ import json +import re from typing import NamedTuple import cloudscraper from epicstore_api.exc import EGSException, EGSNotFound -from epicstore_api.models import EGSCategory, EGSCollectionType, EGSProductType +from epicstore_api.models import EGSCategory, EGSCollectionType, EGSProductType, ESRBRating, ESRBRatingCode from epicstore_api.queries import ( ADDONS_QUERY, CATALOG_QUERY, @@ -405,6 +406,184 @@ def fetch_store_games( withPrice=with_price, ) + def extract_esrb_rating(self, offer_data: dict) -> ESRBRating: + """Extract ESRB rating information from offer data. + + Searches through customAttributes for ESRB rating and content descriptors. + + :param offer_data: Offer data dict (typically from GraphQL queries like + fetch_store_games, fetch_catalog, get_offers_data, etc.) + :return: ESRBRating object with rating and descriptors, or None rating if not found + """ + rating = ESRBRating() + custom_attrs = offer_data.get('customAttributes', []) + if not isinstance(custom_attrs, list): + return rating + + for attr in custom_attrs: + if not isinstance(attr, dict): + continue + + self._process_rating_attribute(attr, rating) + + rating.raw_data = custom_attrs + return rating + + def _process_rating_attribute(self, attr: dict, rating: ESRBRating) -> None: + """Process a single custom attribute for ESRB rating information. + + :param attr: Custom attribute dict with 'key' and 'value' + :param rating: ESRBRating object to update with found values + """ + key = attr.get('key', '').lower() + value = attr.get('value', '') + + if 'esrb' in key or 'contentrating' in key: + rating.rating = self._parse_esrb_rating(value) + + if 'descriptor' in key or 'contentdescriptor' in key: + rating.descriptors = self._parse_content_descriptors(value) + + + def get_free_games_with_ratings( + self, + allow_countries: str | None = None + ) -> dict: + """Get free games with ESRB rating information enriched. + + This method calls get_free_games() and then fetches ESRB rating data + from the GraphQL API for each game using its product slug. + + :param allow_countries: Products in the country. Default to API's country setting. + :return: Dict with 'games' list containing games with 'esrb_rating' field added + """ + free_games_data = self.get_free_games(allow_countries=allow_countries) + + games = free_games_data.get('data', []) + if not isinstance(games, list): + return free_games_data + + enriched_games = [self._enrich_game_with_rating(game) for game in games] + + result = free_games_data.copy() + result['data'] = enriched_games + return result + + def _enrich_game_with_rating(self, game: dict) -> dict: + """Enrich a single game with ESRB rating information. + + :param game: The game data to enrich + :return: The game data with 'esrb_rating' field added + """ + enriched_game = game.copy() + product_slug = game.get('product_slug', '') + + if not product_slug: + enriched_game['esrb_rating'] = self._empty_rating() + return enriched_game + + elements = self._fetch_offer_elements(product_slug) + esrb_rating = self._get_esrb_for_elements(elements) + enriched_game['esrb_rating'] = esrb_rating + + return enriched_game + + def _fetch_offer_elements(self, product_slug: str) -> list: + """Fetch offer elements for a product slug. + + :param product_slug: Product slug to search for + :return: List of elements from store results + """ + store_result = self.fetch_store_games( + keywords=product_slug, + count=1, + with_price=False + ) + + return ( + store_result.get('data', {}) + .get('Catalog', {}) + .get('searchStore', {}) + .get('elements', []) + ) + + def _get_esrb_for_elements(self, elements: list) -> dict: + """Extract ESRB rating from offer elements or return empty rating. + + :param elements: List of offer elements + :return: Dict with rating and descriptors + """ + if not elements: + return self._empty_rating() + + offer_data = elements[0] + esrb = self.extract_esrb_rating(offer_data) + return { + 'rating': esrb.rating, + 'descriptors': esrb.descriptors, + } + + @staticmethod + def _empty_rating() -> dict: + """Create an empty ESRB rating structure. + + :return: Dict with None rating and empty descriptors + """ + return { + 'rating': None, + 'descriptors': [], + } + + + @staticmethod + def _parse_esrb_rating(value: str) -> str | None: + """Parse ESRB rating from attribute value. + + Extracts the rating code (EC, E, E10+, T, M, AO, RP) from various formats. + Uses word boundaries to avoid matching partial strings. + + :param value: Value from customAttribute + :return: ESRB rating code or None + """ + if not value: + return None + + value = value.upper().strip() + for rating in sorted(ESRBRatingCode, key=lambda r: len(r.value), reverse=True): + pattern = r'(?:^|[^A-Z0-9])' + re.escape(rating.value) + r'(?:[^A-Z0-9]|$)' + if re.search(pattern, value): + return rating.value + + return None + + @staticmethod + def _parse_content_descriptors(value: str) -> list[str]: + """Parse content descriptors from attribute value. + + Splits descriptors by common delimiters and cleans them. + + :param value: Value from customAttribute containing descriptors + :return: List of content descriptor strings + """ + if not value: + return [] + + descriptors = EpicGamesStoreAPI._split_descriptors(value) + cleaned = [d.strip() for d in descriptors if d.strip()] + return cleaned + + @staticmethod + def _split_descriptors(value: str) -> list[str]: + """Split descriptors by common delimiters. + + :param value: String containing descriptors + :return: List of split descriptors + """ + for delimiter in [',', ';', '|', '\n']: + if delimiter in value: + return value.split(delimiter) + return [value] + def _make_api_query( self, endpoint: str, @@ -436,7 +615,7 @@ def _make_graphql_query( headers = {} if not multiple_query_variables: variables.update({'locale': self.locale, 'country': self.country}) - # This variables are default and exist in all graphql queries + # These variables are default and exist in all graphql queries response = self._session.post( self._graphql_url, json={'query': query_string, 'variables': variables}, diff --git a/epicstore_api/models/__init__.py b/epicstore_api/models/__init__.py index 99633d0..f4876ef 100644 --- a/epicstore_api/models/__init__.py +++ b/epicstore_api/models/__init__.py @@ -24,3 +24,4 @@ from epicstore_api.models.categories import EGSCategory from epicstore_api.models.collection_types import EGSCollectionType from epicstore_api.models.product_types import EGSProductType +from epicstore_api.models.ratings import ESRBRating, ESRBRatingCode diff --git a/epicstore_api/models/ratings.py b/epicstore_api/models/ratings.py new file mode 100644 index 0000000..b590ac3 --- /dev/null +++ b/epicstore_api/models/ratings.py @@ -0,0 +1,56 @@ +"""MIT License. + +Copyright (c) 2020-2023 SD4RK + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +class ESRBRatingCode(Enum): + """Standard ESRB (Entertainment Software Rating Board) rating codes.""" + EC = "EC" + E = "E" + E10_PLUS = "E10+" + T = "T" + M = "M" + AO = "AO" + RP = "RP" + + +@dataclass +class ESRBRating: + """Represents the ESRB rating of a game. + + Attributes: + rating: ESRB rating code (EC, E, E10+, T, M, AO, RP) + descriptors: List of content descriptors (e.g., Blood, Violence, Nudity) + raw_data: Original data from the API as reference point + """ + rating: Optional[str] = None + descriptors: list[str] = None + raw_data: Optional[list | dict] = None + + def __post_init__(self): + if self.descriptors is None: + self.descriptors = [] + diff --git a/examples/esrb_advanced_examples.py b/examples/esrb_advanced_examples.py new file mode 100644 index 0000000..516886f --- /dev/null +++ b/examples/esrb_advanced_examples.py @@ -0,0 +1,183 @@ +from epicstore_api import EpicGamesStoreAPI, EGSCollectionType + + +def example_free_games_ratings(): + print("=" * 70) + print("EXAMPLE 1: Free Games with ESRB Ratings") + print("=" * 70) + + api = EpicGamesStoreAPI() + free_games = api.get_free_games_with_ratings() + + print(f"\nFound {len(free_games.get('data', []))} free games:\n") + for game in free_games.get('data', [])[:5]: # Show first 5 + title = game.get('title', 'Unknown') + esrb = game.get('esrb_rating', {}) + rating = esrb.get('rating') or 'Not Rated' + descriptors = esrb.get('descriptors', []) + + print(f"Title: {title}") + print(f" Rating: {rating}") + if descriptors: + print(f" Content: {', '.join(descriptors)}") + print() + + +def example_filter_by_rating(): + print("=" * 70) + print("EXAMPLE 2: Filter Games by ESRB Rating") + print("=" * 70) + + api = EpicGamesStoreAPI() + free_games = api.get_free_games_with_ratings() + + ratings_to_check = ['E', 'T', 'M'] + + for rating_code in ratings_to_check: + games = [ + game for game in free_games.get('data', []) + if game.get('esrb_rating', {}).get('rating') == rating_code + ] + print(f"\nGames rated {rating_code}: {len(games)}") + for game in games[:3]: # Show first 3 + print(f" - {game.get('title')}") + + +def example_filter_by_content(): + print("=" * 70) + print("EXAMPLE 3: Filter Games by Content Descriptors") + print("=" * 70) + + api = EpicGamesStoreAPI() + free_games = api.get_free_games_with_ratings() + content_filters = { + 'Violence': ['violence', 'intense violence', 'fantasy violence', 'cartoon violence'], + 'Blood': ['blood', 'animated blood', 'mild blood', 'blood and gore'], + 'Language': ['language', 'mild language', 'strong language'], + 'Suggestive': ['suggestive themes', 'nudity', 'partial nudity'], + } + + for content_type, keywords in content_filters.items(): + matching_games = [] + for game in free_games.get('data', []): + descriptors = game.get('esrb_rating', {}).get('descriptors', []) + if any( + any(keyword in desc.lower() for keyword in keywords) + for desc in descriptors + ): + matching_games.append(game) + + print(f"\nGames with {content_type}: {len(matching_games)}") + for game in matching_games[:3]: # Show first 3 + rating = game.get('esrb_rating', {}).get('rating', 'Not Rated') + descriptors = game.get('esrb_rating', {}).get('descriptors', []) + print(f" - {game.get('title')} ({rating})") + if descriptors: + matching_desc = [ + d for d in descriptors + if any(k in d.lower() for k in keywords) + ] + if matching_desc: + print(f" {', '.join(matching_desc)}") + + +def example_extract_from_store_search(): + print("=" * 70) + print("EXAMPLE 4: Extract from Store Search") + print("=" * 70) + + api = EpicGamesStoreAPI() + + search_terms = ['Fortnite', 'Valorant', 'Star Wars'] + + for term in search_terms: + print(f"\nSearching for '{term}':") + result = api.fetch_store_games(keywords=term, count=3) + + games = result.get('data', {}).get('Catalog', {}).get('searchStore', {}).get('elements', []) + for game in games: + esrb = api.extract_esrb_rating(game) + title = game.get('title', 'Unknown') + rating = esrb.rating or 'Not Rated' + descriptors = esrb.descriptors or [] + + print(f" {title}: {rating}") + if descriptors: + print(f" Content: {', '.join(descriptors)}") + + +def example_extract_from_collection(): + """Example 5: Extract ESRB ratings from collections.""" + print("=" * 70) + print("EXAMPLE 5: Extract from Collections") + print("=" * 70) + + api = EpicGamesStoreAPI() + print("\nFetching Featured games...") + try: + collection = api.get_collection(EGSCollectionType.FEATURED) + games = collection.get('Storefront', {}).get('collectionLayout', {}).get('collectionOffers', []) + + print(f"Found {len(games)} featured games\n") + for game in games[:5]: # Show first 5 + esrb = api.extract_esrb_rating(game) + title = game.get('title', 'Unknown') + rating = esrb.rating or 'Not Rated' + + print(f" {title}: {rating}") + except Exception as e: + print(f"Error fetching collection: {e}") + + +def example_rating_statistics(): + print("=" * 70) + print("EXAMPLE 6: Rating Statistics") + print("=" * 70) + + api = EpicGamesStoreAPI() + free_games = api.get_free_games_with_ratings() + + rating_counts = {} + descriptor_counts = {} + total_games = 0 + + for game in free_games.get('data', []): + total_games += 1 + esrb = game.get('esrb_rating', {}) + rating = esrb.get('rating') or 'Not Rated' + + rating_counts[rating] = rating_counts.get(rating, 0) + 1 + + for descriptor in esrb.get('descriptors', []): + descriptor_counts[descriptor] = descriptor_counts.get(descriptor, 0) + 1 + + print(f"\nTotal Free Games: {total_games}") + print("\nRating Distribution:") + for rating in ['E', 'E10+', 'T', 'M', 'AO', 'RP', 'Not Rated']: + count = rating_counts.get(rating, 0) + if count > 0: + percentage = (count / total_games) * 100 + print(f" {rating:10s}: {count:3d} ({percentage:5.1f}%)") + + print("\nTop 10 Content Descriptors:") + sorted_descriptors = sorted(descriptor_counts.items(), key=lambda x: x[1], reverse=True) + for descriptor, count in sorted_descriptors[:10]: + percentage = (count / total_games) * 100 + print(f" {descriptor:25s}: {count:3d} ({percentage:5.1f}%)") + + +def main(): + try: + example_free_games_ratings() + example_filter_by_rating() + example_filter_by_content() + example_extract_from_store_search() + example_extract_from_collection() + example_rating_statistics() + except Exception as e: + print(f"Error: {e}") + + +if __name__ == '__main__': + main() + diff --git a/examples/esrb_rating_example.py b/examples/esrb_rating_example.py new file mode 100644 index 0000000..983ca3e --- /dev/null +++ b/examples/esrb_rating_example.py @@ -0,0 +1,52 @@ +from epicstore_api import EpicGamesStoreAPI + + +def main() -> None: + api = EpicGamesStoreAPI() + free_games = api.get_free_games_with_ratings() + + print("Free Games with ESRB Ratings:") + print("=" * 70) + + for game in free_games.get('data', []): + title = game.get('title', 'Unknown') + esrb_rating = game.get('esrb_rating', {}) + rating = esrb_rating.get('rating', 'Not Rated') + descriptors = esrb_rating.get('descriptors', []) + + print(f"\nTitle: {title}") + print(f"ESRB Rating: {rating}") + if descriptors: + print(f"Content Descriptors: {', '.join(descriptors)}") + else: + print("Content Descriptors: None") + + print("\n" + "=" * 70) + print("\nGames rated M (Mature 17+):") + print("=" * 70) + + for game in free_games.get('data', []): + esrb_rating = game.get('esrb_rating', {}) + if esrb_rating.get('rating') == 'M': + title = game.get('title', 'Unknown') + descriptors = esrb_rating.get('descriptors', []) + print(f"\n{title}") + if descriptors: + print(f" Content: {', '.join(descriptors)}") + print("\n" + "=" * 70) + print("\nGames with Violence Content:") + print("=" * 70) + + for game in free_games.get('data', []): + esrb_rating = game.get('esrb_rating', {}) + descriptors = esrb_rating.get('descriptors', []) + has_violence = any('violence' in d.lower() for d in descriptors) + if has_violence: + title = game.get('title', 'Unknown') + rating = esrb_rating.get('rating', 'Not Rated') + print(f"{title} ({rating})") + + +if __name__ == '__main__': + main() +