diff --git a/requirements/app.txt b/requirements/app.txt index 5fc8d3a..30a0494 100755 --- a/requirements/app.txt +++ b/requirements/app.txt @@ -1,2 +1,3 @@ galaxy.plugin.api==0.64 certifi==2019.3.9 +beautifulsoup4==4.9.3 diff --git a/src/http_client.py b/src/http_client.py index 430c020..95c4fe4 100755 --- a/src/http_client.py +++ b/src/http_client.py @@ -73,11 +73,12 @@ async def request(self, method, *args, **kwargs): async def get(self, url, *args, **kwargs): silent = kwargs.pop('silent', False) + get_json = kwargs.pop('get_json', True) response = await self.request("GET", *args, url=url, **kwargs) try: raw_response = '***' if silent else await response.text() logging.debug("Response for:\n{url}\n{data}".format(url=url, data=raw_response)) - return await response.json() + return await response.json() if get_json else await response.text() except ValueError: logging.exception("Invalid response data for:\n{url}".format(url=url)) raise UnknownBackendResponse() diff --git a/src/parsers.py b/src/parsers.py new file mode 100644 index 0000000..854cc55 --- /dev/null +++ b/src/parsers.py @@ -0,0 +1,50 @@ +import json +import logging +from typing import List, Dict + +from bs4 import BeautifulSoup +from galaxy.api.errors import UnknownBackendResponse +from galaxy.api.types import SubscriptionGame + + +logger = logging.getLogger(__name__) + + +class PSNGamesParser: + + _SUBSCRIBED_GAMES_PAGINATOR_CSS_CLASS = 'ems-sdk-strand-paginator' + _SUBSCRIBED_GAMES_CSS_CLASS = 'ems-sdk-product-tile-link' + _GAME_DATA_TAG = 'data-telemetry-meta' + + def parse(self, response) -> List[SubscriptionGame]: + try: + games = self._subscription_games(response) + except NotFoundSubscriptionPaginator: + raise UnknownBackendResponse(f"HTML TAG: {self._SUBSCRIBED_GAMES_PAGINATOR_CSS_CLASS} was not found in response.") + else: + return [ + SubscriptionGame(game_id=game['titleId'], game_title=game['name']) + for game in games if game.get('name') and game.get('titleId') and not game['name'].startswith('PlayStation') + ] + + def _subscription_games(self, response: str) -> List[Dict]: + """Scrapes all PS Plus Monthly games from https://store.playstation.com/subscriptions""" + + parsed_html = BeautifulSoup(response, 'html.parser') + paginator = parsed_html.find("div", class_=self._SUBSCRIBED_GAMES_PAGINATOR_CSS_CLASS) + if not paginator: + raise NotFoundSubscriptionPaginator + logger.debug("HTML response slice of %s tag: \n%s" % (self._SUBSCRIBED_GAMES_PAGINATOR_CSS_CLASS, paginator.decode_contents())) + games = paginator.find_all("a", class_=self._SUBSCRIBED_GAMES_CSS_CLASS) + result = [] + for game in games: + try: + game_data = getattr(game, 'attrs', {}).get(self._GAME_DATA_TAG) + result.append(json.loads(game_data)) + except (json.JSONDecodeError, TypeError) as e: + logger.error(e) + return result + + +class NotFoundSubscriptionPaginator(Exception): + pass diff --git a/src/plugin.py b/src/plugin.py index 1543043..f6ef832 100755 --- a/src/plugin.py +++ b/src/plugin.py @@ -127,8 +127,7 @@ async def get_subscriptions(self) -> List[Subscription]: return [Subscription(subscription_name="PlayStation PLUS", end_time=None, owned=is_plus_active)] async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[List[SubscriptionGame], None]: - account_info = await self._psn_client.get_account_info() - yield await self._psn_client.get_subscription_games(account_info) + yield await self._psn_client.get_subscription_games() async def get_owned_games(self): async def filter_and_patch_games(titles: List[Game]) -> List[Game]: diff --git a/src/psn_client.py b/src/psn_client.py index 2894b35..2cb9c06 100755 --- a/src/psn_client.py +++ b/src/psn_client.py @@ -8,7 +8,7 @@ from galaxy.api.types import Achievement, Game, LicenseInfo, UserInfo, UserPresence, PresenceState, SubscriptionGame from galaxy.api.consts import LicenseType from http_client import paginate_url -from psn_store import PSNFreePlusStore, AccountUserInfo +from parsers import PSNGamesParser # game_id_list is limited to 5 IDs per request GAME_DETAILS_URL = "https://pl-tpy.np.community.playstation.net/trophy/v1/apps/trophyTitles" \ @@ -50,6 +50,8 @@ ACCOUNTS_URL = "https://accounts.api.playstation.com/api/v1/accounts/{user_id}" +PSN_PLUS_SUBSCRIPTIONS_URL = 'https://store.playstation.com/subscriptions' + DEFAULT_LIMIT = 100 MAX_TITLE_IDS_PER_REQUEST = 5 @@ -289,24 +291,5 @@ def friends_with_presence_parser(response): "totalResults" ) - async def get_account_info(self) -> AccountUserInfo: - def account_user_parser(data): - td = date_today() - datetime.fromisoformat(data['dateOfBirth']) - age = td.days // 365 - return AccountUserInfo(data['region'], data['legalCountry'], data['language'], age) - - return await self.fetch_data(account_user_parser, ACCOUNTS_URL.format(user_id='me'), silent=True) - - async def get_subscription_games(self, account_info: AccountUserInfo) -> List[SubscriptionGame]: - def games_parser(data): - return [ - SubscriptionGame( - game_id=item['id'].split('-')[1], - game_title=item['attributes']['name'] - ) - for item in data['included'] - if item['type'] in ['game', 'game-related'] - ] - - store = PSNFreePlusStore(self._http_client, account_info) - return await self.fetch_data(games_parser, store.games_container_url) + async def get_subscription_games(self) -> List[SubscriptionGame]: + return await self.fetch_data(PSNGamesParser().parse, PSN_PLUS_SUBSCRIPTIONS_URL, get_json=False, silent=True) diff --git a/src/psn_store.py b/src/psn_store.py deleted file mode 100644 index 840c6d2..0000000 --- a/src/psn_store.py +++ /dev/null @@ -1,49 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class AccountUserInfo: - region: str - country: str - language: str - age: int - - -class PSNFreePlusStore: - BASE_URL = 'https://store.playstation.com/' - GAMES_CONTAINTER_URL = BASE_URL + 'valkyrie-api/{language}/{country}/{age}/container/{id}' - SESSION = BASE_URL + 'kamaji/api/valkyrie_storefront/00_09_000/user/session' - SUBSCRIPTION_DETAILS = BASE_URL + 'kamaji/api/valkyrie_storefront/00_09_000/gateway/store/v1/users/me/subscription/IP9102-NPIA90006_00' - - PSPLUS_FREEGAMES_REGION_STORE = { - 'SCEA': 'STORE-MSF77008-PSPLUSFREEGAMES', - 'SCEE': 'STORE-MSF75508-PLUSINSTANTGAME', - 'SCE-ASIA': 'STORE-MSF86012-PLUS_FTT_CONTENT', - 'SCEJ': 'PN.CH.JP-PN.CH.MIXED.JP-PSPLUSFREEPLAY', - 'SCEK': 'STORE-MSF86012-PLUS_FTT_KR' - } - - def __init__(self, http_client, user: AccountUserInfo): - self._http_client = http_client - self.id = self.PSPLUS_FREEGAMES_REGION_STORE[user.region] - self.country = user.country - self.language = user.language[:2] - self.age = 21 if user.age > 21 else user.age - - @property - def games_container_url(self): - return self.GAMES_CONTAINTER_URL.format( - language=self.language, country=self.country, age=self.age, id=self.id) - - async def get_subscription_info(self): - """This provides for information about user subscription data such as 'date_end' - TODO: use it in plugin.get_subscriptions to provide extended info about subscritpion - """ - await self._refresh_session() - return await self._http_client.get(self.SUBSCRIPTION_DETAILS) - - async def _refresh_session(self): - """TODO: generate auth code from logged-in user context""" - body = 'code=' # + code - res = await self._http_client.post(self.SESSION, data=body) - return await res.json() diff --git a/src/version.py b/src/version.py index 42c29cf..b1655fd 100755 --- a/src/version.py +++ b/src/version.py @@ -1,6 +1,9 @@ __version__ = "0.31" __changelog__ = { + "unreleased": """ + - Fix showing correct PS Plus monthly games in Galaxy Subscription tab + """, "0.31": """ - Fix losing authentication as side effect of shutdown while refreshing credentials - Add cache invalidation for title-communication_ids map every 7 days; fixes not showing updated games without reconnection diff --git a/tests/test_data.py b/tests/test_data.py index 9b7fa07..7fe9f46 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -224,65 +224,143 @@ SubscriptionGame(game_title='Firewall Zero Hour™', game_id='CUSA09831_00') ] -BACKEND_STORE_FREEPSPLUS_CONTAINER = { - "data": { - "attributes": { - "attributes": {}, - "id": "STORE-MSF77008-PSPLUSFREEGAMES", - "type": "container", - "relationships": { - "children": { - "data": [ - { - "id": "UP1001-CUSA03979_00-BIOSHOCKCOLLECTN", - "type": "game" - }, - { - "id": "UP0006-CUSA09209_00-THESIMS400000000", - "type": "game" - }, - { - "id": "UP9000-CUSA09831_00-FIREWALL00000000", - "type": "game-related" - } - ] - }, - } - }, - }, - "included": [ - { - "attributes": { - "name": "BioShock: The Collection", - "platforms": ["PS4"] - }, - "id": "UP1001-CUSA03979_00-BIOSHOCKCOLLECTN", - "type": "game" - }, - { - "attributes": { - "name": "Full Game", - "platforms": [0, 10, 13], - }, - "id": "UP0006-CUSA09209_00-THESIMS400000000-U002", - "type": "legacy-skus" - }, - { - "attributes": { - "name": "The Sims™ 4", - "platforms": ["PS4"] - }, - "id": "UP0006-CUSA09209_00-THESIMS400000000", - "type": "game" - }, - { - "attributes": { - "name": "Firewall Zero Hour™", - "platforms": ["PS4"], - "ps-vr-compatibility": "required", - }, - "id": "UP9000-CUSA09831_00-FIREWALL00000000-U002", - "type": "game-related" - }, - ] -} +PSN_PLUS_MONTHLY_FREE_GAMES_HTML = """ +
+
+ +
+
+""" diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..80f9b0e --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,96 @@ +import pytest +from galaxy.api.errors import UnknownBackendResponse +from galaxy.api.types import SubscriptionGame + +from src.parsers import PSNGamesParser + + +@pytest.mark.asyncio +@pytest.mark.parametrize("http_response, expected_result", [ + pytest.param("
", [], id="'a' tag without 'ems-sdk-strand-paginator' class"), + pytest.param("
", [], id="'a' tag without 'data-telemetry-meta' attr"), + pytest.param( + '
' + '' + '
', + [], + id="empty 'data-telemetry-meta' attr" + ), + pytest.param( + '
' + "" + "" + '
', + [SubscriptionGame(game_id="CUSA09831_00", game_title="Firewall Zero Hour™")], + id="One correct game" + ), + pytest.param( + '
' + "" + "" + '
', + [], + id="Incorrect game - without name" + ), + pytest.param( + '
' + "" + "" + '
', + [], + id="Incorrect game - without titleID" + ), + pytest.param( + '
' + "" + "" + "" + "" + '
', + [SubscriptionGame(game_id="CUSA09831_00", game_title="Firewall Zero Hour™"), + SubscriptionGame(game_id="CUSA09831_01", game_title="Firewall Zero Hour 2™")], + id="Two correct games" + ), + pytest.param( + '
' + "" + "" + "" + "" + '
', + [SubscriptionGame(game_id="CUSA09831_00", game_title="Firewall Zero Hour™")], + id="Two games - one incorrect, without name" + ), + pytest.param( + '
' + "" + '
', + [SubscriptionGame(game_id="PPSA01954_00", game_title="『CONTROL』アルティメットエディション")], + id="Japanese version" + ), +]) +async def test_parse_subscription_games(http_response, expected_result): + parser = PSNGamesParser().parse(http_response) + + assert parser == expected_result + + +@pytest.mark.asyncio +@pytest.mark.parametrize("http_response", [ + pytest.param(""), + pytest.param("ems-sdk-strand-paginator-WRONG_TAG"), + pytest.param("
"), + ]) +async def test_parse_subscription_games_raise_lack_paginator_tag(http_response): + with pytest.raises(UnknownBackendResponse): + PSNGamesParser().parse(http_response) diff --git a/tests/test_subscriptions_games.py b/tests/test_subscriptions_games.py index a799860..e36ba92 100644 --- a/tests/test_subscriptions_games.py +++ b/tests/test_subscriptions_games.py @@ -1,18 +1,7 @@ import pytest -from datetime import datetime -from galaxy.api.errors import AuthenticationRequired, UnknownBackendResponse -from psn_store import AccountUserInfo -from tests.test_data import SUBSCRIPTION_GAMES, BACKEND_STORE_FREEPSPLUS_CONTAINER, USER_ACCOUNTS_DATA +from galaxy.api.errors import AuthenticationRequired - -@pytest.fixture() -def user_account_info(): - return AccountUserInfo( - region='SCEA', - language='en_US', - country='US', - age=17 - ) +from tests.test_data import SUBSCRIPTION_GAMES, PSN_PLUS_MONTHLY_FREE_GAMES_HTML @pytest.mark.asyncio @@ -28,49 +17,11 @@ async def test_not_authenticated_subscription_games(psn_plugin, psplus_name): pass -@pytest.mark.asyncio -async def test_get_user_account_info( - http_get, - user_account_info, - authenticated_psn_client, - mocker -): - http_get.return_value = USER_ACCOUNTS_DATA - with mocker.patch("psn_client.date_today", return_value=datetime(year=2020, month=2, day=25)): - assert user_account_info == await authenticated_psn_client.get_account_info() - http_get.assert_called_once() - - @pytest.mark.asyncio async def test_get_subscription_games( http_get, - user_account_info, authenticated_psn_client, - mocker ): - http_get.return_value = BACKEND_STORE_FREEPSPLUS_CONTAINER - acc_info = user_account_info - assert SUBSCRIPTION_GAMES == await authenticated_psn_client.get_subscription_games(acc_info) - http_get.assert_called_once() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("backend_response", [ - {}, - {"data": {"included": "bad data"}}, - {"data": {"included": [{"id": "EDIN_CN242242_00", "type": "game"}]}}, - {"data": {"included": [{"id": "EDIN_CN242242_00", "type": "game", "attributes": "bad data"}]}}, - {"data": {"included": [{"id": "EDIN_CN242242_00", "attributes": {"name": "BioShock Collection"}}]}}, -]) -async def test_get_subscription_games_bad_format( - http_get, - user_account_info, - authenticated_psn_client, - backend_response, -): - http_get.return_value = backend_response - acc_info = user_account_info - - with pytest.raises(UnknownBackendResponse): - await authenticated_psn_client.get_subscription_games(acc_info) + http_get.return_value = PSN_PLUS_MONTHLY_FREE_GAMES_HTML + assert SUBSCRIPTION_GAMES == await authenticated_psn_client.get_subscription_games() http_get.assert_called_once()