From 77a7b3240ee1cf74607d9bcb7cf6e456f8663e1f Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Mon, 2 Jun 2025 22:34:37 +0500 Subject: [PATCH] feat: enhance get_egs_game_description_async to use GraphQL Signed-off-by: Boris Yumankulov --- portprotonqt/egs_api.py | 82 ++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/portprotonqt/egs_api.py b/portprotonqt/egs_api.py index 9feb1c2..7c77633 100644 --- a/portprotonqt/egs_api.py +++ b/portprotonqt/egs_api.py @@ -30,10 +30,12 @@ def get_egs_game_description_async( cache_ttl: int = 3600 ) -> None: """ - Asynchronously fetches the game description from the Epic Games Store API. + Asynchronously fetches the game description using Epic Games Store GraphQL API. + Falls back to the legacy store-content API using the productSlug from GraphQL if available. Uses per-app cache files named egs_app_{app_name}.json in ~/.cache/PortProtonQT. Checks the cache first; if the description is cached and not expired, returns it. - Prioritizes the page with type 'productHome' for the base game description. + Uses system language from get_egs_language() for the description. + Prioritizes the main game description by filtering for productSlug and excluding DLC/bundles. """ cache_dir = get_cache_dir() cache_file = cache_dir / f"egs_app_{app_name.lower().replace(':', '_').replace(' ', '_')}.json" @@ -84,39 +86,67 @@ def get_egs_game_description_async( cache_file.unlink(missing_ok=True) lang = get_egs_language() - slug = app_name.lower().replace(":", "").replace(" ", "-") - url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{slug}" + search_url = "https://graphql.epicgames.com/graphql" + headers = { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) EpicGamesLauncher" + } + search_query = { + "query": "query search($keywords: String!, $locale: String) { Catalog { searchStore(keywords: $keywords, locale: $locale) { elements { title namespace productSlug description } } } }", + "variables": { + "keywords": app_name, + "locale": lang + } + } def fetch_description(): + description = "" + product_slug = None try: - response = requests.get(url, timeout=5) + # First attempt: GraphQL search query + response = requests.post(search_url, json=search_query, headers=headers, timeout=5) response.raise_for_status() data = orjson.loads(response.content) - if not isinstance(data, dict): - logger.warning("Invalid JSON structure for %s: %s", app_name, type(data)) - callback("") - return - - description = "" - pages = data.get("pages", []) - if pages: - # Look for the page with type "productHome" for the base game - for page in pages: - if page.get("type") == "productHome": - about_data = page.get("data", {}).get("about", {}) - description = about_data.get("shortDescription", "") + if isinstance(data, dict) and "data" in data: + elements = data.get("data", {}).get("Catalog", {}).get("searchStore", {}).get("elements", []) + for element in elements: + if isinstance(element, dict) and element.get("title", "").lower() == app_name.lower() and element.get("productSlug") and not any(substring in element.get("title", "").lower() for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"]): + description = element.get("description", "") + product_slug = element.get("productSlug", "") break - else: - # Fallback to first page's description if no productHome is found - description = ( - pages[0].get("data", {}) - .get("about", {}) - .get("shortDescription", "") - ) + else: + logger.warning("Invalid JSON structure for %s in GraphQL response: %s", app_name, type(data)) + + if not description and product_slug: + logger.info("No valid description found in GraphQL for %s, falling back to legacy API with slug %s", app_name, product_slug) + # Fallback to legacy API using productSlug + legacy_url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{product_slug}" + response = requests.get(legacy_url, timeout=5) + response.raise_for_status() + data = orjson.loads(response.content) + + if not isinstance(data, dict): + logger.warning("Invalid JSON structure for %s in legacy API: %s", app_name, type(data)) + callback("") + return + + pages = data.get("pages", []) + if pages: + for page in pages: + if page.get("type") == "productHome": + about_data = page.get("data", {}).get("about", {}) + description = about_data.get("shortDescription", "") + break + else: + description = ( + pages[0].get("data", {}) + .get("about", {}) + .get("shortDescription", "") + ) if not description: - logger.warning("No valid description found for %s", app_name) + logger.warning("No valid description found for %s after both queries", app_name) logger.debug( "Fetched EGS description for %s: %s",