Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,7 @@ venv.bak/
/site

# mypy
.mypy_cache/
.mypy_cache/

#PyCharm
.idea/
183 changes: 181 additions & 2 deletions epicstore_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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},
Expand Down
1 change: 1 addition & 0 deletions epicstore_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
56 changes: 56 additions & 0 deletions epicstore_api/models/ratings.py
Original file line number Diff line number Diff line change
@@ -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 = []

Loading