diff --git a/Makefile b/Makefile index e3b4633..f1a9257 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ venv: dist: rm dist/* || true - python3 -m pip install pypandoc twine wheel setuptools + python3 -m pip install pypandoc==1.5 twine wheel setuptools python3 setup.py sdist upload: dist diff --git a/README.md b/README.md index abe281c..0c97001 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Command line tool for alerting price drops in the Sony PlayStation Network (PSN) ## Description +**Since the PSN upgrade that came with the release of PlayStation 5, some functionality of the PSN interface is broken. Currently only searching by a name query is working** + The Sony Entertainment Network (SEN) uses CIDs to identify items in its catalogue. In order to alert you on the desired price of an SEN you need the CID. Use your Browser (cid GET parameter in URL) or this script (`--query`) to retrieve the CID. In order to check the price of an item. You need a store identifier. These store identifiers are known to work: diff --git a/gameprices/shops/psn.py b/gameprices/shops/psn.py index 67fe7fe..cf23682 100644 --- a/gameprices/shops/psn.py +++ b/gameprices/shops/psn.py @@ -54,37 +54,16 @@ def _get_rewards(item): return rewards -def _get_all_prices(item): +def _get_all_prices(item) -> List[float]: # Returns all prices, regardless of their semantics prices = [] # highest to lowest - has_free_offer = False - for sku in item["skus"]: - p = float(sku["price"]) / 100 - if p != 0 and p != 100: # 0 is likely demo, 100 is likely PS Now offering - prices.append(p) - elif p == 0: - has_free_offer = True - - for reward in _get_rewards(item): - p = float(reward.get("price")) / 100 - if p != 0 and p != 100: # 0 is likely demo, 100 is likely PS Now offering - prices.append(p) - elif p == 0: - has_free_offer = True - - if "bonus_price" in reward: - p = float(reward.get("bonus_price")) / 100 - prices.append(p) + for price in item.prices: + prices.append(price.value) prices.sort(key=lambda x: x, reverse=True) - if len(prices) == 0 and has_free_offer: - # If there were no other prices found and there was found - # a 0 price before, expect this to be a free item and no demo - prices.append(0.0) - return prices @@ -182,8 +161,10 @@ def _search_for_items_by_name(name: str, store: str) -> List[GameOffer]: def _get_item_for_cid(cid: str, store: str) -> GameOffer: # TODO This does not return a parsable object, it is lacking price + raise NotImplementedError("Searching by CID is not implemented yet for the new PSN API") url = Psn._build_api_url_for_product_page(country=store, cid=cid) - return _get_game_offers_from_product_page(url, store) + offers = _get_game_offers_from_product_page(url, store) + return offers def _get_game_offers(url, store: str) -> List[GameOffer]: data = _get_next_data_respose(url) @@ -278,17 +259,6 @@ def _determine_store(cid: str) -> str: return store -def _get_items_by_container(container, store, filters_dict): - url = api_root + "/viewfinder/" + store + "/" + api_version + "/" + container + "?size=" + fetch_size - - for i in filters_dict: - url = url + "&" + quote(i) + "=" + quote(filters_dict[i]) - - data = utils.get_json_response(url) - links = data["links"] - - return links - def _get_price_value_from_price_string(price: str) -> float: try: @@ -310,13 +280,13 @@ def _build_api_url_for_product_page(country: str, cid: str): cleaned_country = country.replace("/","-").lower() return "%s/%s/product/%s" % (api_root, cleaned_country, cid) - def _item_to_game_offer(self, game): - if not game: + def _item_to_game_offer(self, item): + if not item: raise Exception("Item is empty") - normal_price = _get_normal_price(game) - plus_price = _get_playstation_plus_price_reduction(game) - non_plus_price = _get_non_playstation_plus_price_reduction(game) + normal_price = _get_normal_price(item) + plus_price = _get_playstation_plus_price_reduction(item) + non_plus_price = _get_non_playstation_plus_price_reduction(item) prices = [] @@ -341,18 +311,7 @@ def _item_to_game_offer(self, game): # Make lowest price first in list prices.sort(key=lambda x: x.value) - return GameOffer( - id=game["id"], - cid=game["id"], - url=game["url"], - type=game["gameContentTypesList"][0]["key"] - if "gameContentTypesList" in game - else None, - name=game["name"], - prices=prices, - platforms=game["playable_platform"] if "playable_platform" in game else "", - picture_url=_get_image(game), - ) + return game def search(self, name): game_offers = _search_for_items_by_name(name=name, store=self.country) @@ -365,4 +324,5 @@ def search(self, name): def get_item_by(self, item_id) -> GameOffer: item = _get_item_for_cid(item_id, self.country) - return self._item_to_game_offer(item) + game_offer = self._item_to_game_offer(item) + return game_offer diff --git a/gameprices/test/test_cli.py b/gameprices/test/test_cli.py index b6dc246..5326f55 100644 --- a/gameprices/test/test_cli.py +++ b/gameprices/test/test_cli.py @@ -129,7 +129,7 @@ def test_cli_no_match(): sys.argv = [ "psncli", "--query", - "sdfjsdkfsdkfjskdfj YOU WONT FIND ME NEVER EVER. HOPEFULLY", + "sdfjsdkfsdkfjskdfj", # this may hopefully never yield results ] with pytest.raises(SystemExit) as pytest_wrapped_e: diff --git a/gameprices/test/test_dealmailalert.py b/gameprices/test/test_dealmailalert.py index c482bb0..894ed71 100644 --- a/gameprices/test/test_dealmailalert.py +++ b/gameprices/test/test_dealmailalert.py @@ -1,5 +1,8 @@ +import pytest + from gameprices.cli.mailalert import main as psnmailalert_main from gameprices.test.commons import mailalert +from gameprices.test.test_psn import NO_SEARCH_FOR_CID_REASON def test_mailfunc_not_existing(): @@ -7,6 +10,7 @@ def test_mailfunc_not_existing(): mailalert(wrong_line, psnmailalert_main, should_remain_in_file=wrong_line) +@pytest.mark.skip(reason=NO_SEARCH_FOR_CID_REASON) def test_mailfunc_existing_and_not_existing(): unmatchable_price = "EP9000-CUSA07123_00-NIOHEU0000000000,0.00,DE/de" matchable_and_unmatchable_price = ( @@ -19,5 +23,7 @@ def test_mailfunc_existing_and_not_existing(): ) + +@pytest.mark.skip(reason=NO_SEARCH_FOR_CID_REASON) def test_support_lines_without_store(): mailalert("EP0177-CUSA07010_00-SONICMANIA000000,100.00", psnmailalert_main) diff --git a/gameprices/test/test_psn.py b/gameprices/test/test_psn.py index 5686f3a..80ea960 100644 --- a/gameprices/test/test_psn.py +++ b/gameprices/test/test_psn.py @@ -2,6 +2,9 @@ # -*- coding: utf-8 -*- import unittest + +import pytest + from gameprices.shops import psn from gameprices.shops.psn import Psn from gameprices.cli import * @@ -10,6 +13,7 @@ from gameprices.test.commons import mailalert from gameprices.utils.utils import format_items_as_json +NO_SEARCH_FOR_CID_REASON = "The search for IDs with the new PSN API as of 2020 is not yet implemented" class PsnTest(unittest.TestCase): # CID for item that is free for Plus members but not for normal members @@ -27,6 +31,7 @@ def test_search_for_cid_by_title_in_us_store(self): assert len(cids) > 0 + @pytest.mark.skip(reason=NO_SEARCH_FOR_CID_REASON) def test_get_item_for_cid(self): store = "DE/de" cids = psn._get_cid_for_name("Tearaway", store) @@ -34,6 +39,7 @@ def test_get_item_for_cid(self): assert item["name"] is not None + @pytest.mark.skip(reason=NO_SEARCH_FOR_CID_REASON) def test_get_item_for_cid2(self): store = "DE/de" cids = psn._get_cid_for_name("Child of Light", store) @@ -41,14 +47,7 @@ def test_get_item_for_cid2(self): assert item["name"] is not None - def test_get_item_by_container(self): - store = "DE/de" - items = psn._get_items_by_container( - "STORE-MSF75508-PLUSINSTANTGAME", store, {"platform": "ps4"} - ) - - assert len(items) > 0 - + @pytest.mark.skip(reason=NO_SEARCH_FOR_CID_REASON) def test_get_playstation_plus_price(self): store = "DE/de" item = psn._get_item_for_cid(self.freeForPlusCid, store) @@ -70,94 +69,12 @@ def test_get_playstation_plus_price(self): assert isinstance(normal_price, float) assert normal_price == 0 + @pytest.mark.skip(reason=NO_SEARCH_FOR_CID_REASON) def test_get_rewards_from_api(self): store = "DE/de" item = psn._get_item_for_cid("EP0006-CUSA02532_00-UNRAVELUNRAVEL09", store) assert len(psn._get_rewards(item)) > -1 - def test_get_rewards_from_string(self): - item = { - 'skus': [{'amortizeFlag': False, 'bundleExclusiveFlag': False, 'chargeImmediatelyFlag': False, - 'charge_type_id': 0, 'credit_card_required_flag': 0, 'defaultSku': True, - 'display_price': '€49,99', 'eligibilities': [], 'entitlements': [ - {'description': None, 'drms': [], 'duration': 0, 'durationOverrideTypeId': None, - 'exp_after_first_use': 0, 'feature_type_id': 3, 'id': 'PILLARS-CID', - 'license_type': 0, 'metadata': {'voiceLanguageCode': ['en'], - 'subtitleLanguageCode': ['de', 'ru', 'en', 'it', 'fr', 'pl', - 'es']}, - 'name': 'Pillars of Eternity: Complete Edition', 'packageType': 'PS4GD', - 'packages': [{'platformId': 13, 'platformName': 'ps4', 'size': 151248}], - 'preorder_placeholder_flag': False, 'size': 0, 'subType': 0, - 'subtitle_language_codes': ['de', 'ru', 'en', 'it', 'fr', 'pl', 'es'], 'type': 5, 'use_count': 0, - 'voice_language_codes': ['en']}], 'id': 'PILLARS-CID', - 'is_original': False, 'name': 'Vollversion', 'platforms': [0, 18, 10, 13], 'price': 4999, - 'rewards': [{'id': 'ID_CAMPAIGN_1', 'discount': 70, 'price': 1499, 'reward_type': 2, - 'display_price': '€14,99', 'isPlus': False, 'campaigns': [ - {'id': 'ID_CAMPAIGN_2', 'start_date': '2022-02-02T00:00:00Z', - 'end_date': '2022-02-16T23:59:00Z'}], 'bonus_discount': 80, - 'bonus_entitlement_id': 'PLUS_ENTITLEMENT_ID', 'bonus_price': 999, - 'reward_source_type_id': 2, 'start_date': '2022-02-02T00:00:00Z', - 'end_date': '2022-02-16T23:59:00Z', 'bonus_display_price': '€9,99'}], - 'seasonPassExclusiveFlag': False, 'skuAvailabilityOverrideFlag': False, 'sku_type': 0, - 'type': 'standard'}] - } - assert psn._get_all_prices(item) == [49.99, 14.99, 9.99] - assert psn._get_normal_price(item) == 49.99 - assert psn._get_playstation_plus_price_reduction(item) == 9.99 - assert psn._get_non_playstation_plus_price_reduction(item) == 14.99 - - def test_get_rewards_from_string_with_demo_and_psnow(self): - item = { - 'skus': [{'amortizeFlag': True, 'bundleExclusiveFlag': False, 'chargeImmediatelyFlag': False, - 'charge_type_id': 0, 'credit_card_required_flag': 0, 'defaultSku': True, - 'display_price': '€100,00', 'eligibilities': [], 'entitlements': [ - {'description': None, 'drms': [], 'duration': 1800, 'durationOverrideTypeId': None, - 'exp_after_first_use': 0, 'feature_type_id': 3, 'id': 'TEARAWAY_CID', - 'license_type': 0, 'metadata': { - 'voiceLanguageCode': ['de', 'no', 'fi', 'ru', 'sv', 'pt', 'en', 'it', 'fr', 'es', 'pl', 'da', - 'nl'], - 'subtitleLanguageCode': ['de', 'no', 'fi', 'sv', 'ru', 'pt', 'en', 'it', 'fr', 'es', 'pl', 'da', - 'nl']}, 'name': 'Tearaway™ Unfolded', 'packageType': 'PS4GD', - 'packages': [{'platformId': 13, 'platformName': 'ps4', 'size': 77712}], - 'preorder_placeholder_flag': False, 'size': 0, 'subType': 0, - 'subtitle_language_codes': ['de', 'no', 'fi', 'sv', 'ru', 'pt', 'en', 'it', 'fr', 'es', 'pl', 'da', - 'nl'], 'type': 5, 'use_count': 0, - 'voice_language_codes': ['de', 'no', 'fi', 'ru', 'sv', 'pt', 'en', 'it', 'fr', 'es', 'pl', 'da', - 'nl']}], 'id': 'TEARAWAY_CID', - 'is_original': False, 'name': 'PS Now Download Game', 'platforms': [0, 18, 10, 13], - 'price': 10000, 'rewards': [ - {'id': 'ID2', 'entitlement_id': 'IP9102-NPIA90011_01-RWD-104513', - 'service_provider_id': 'ID3', 'discount': 100, 'price': 0, 'reward_type': 2, - 'display_price': 'Kostenlos', 'name': 'PS Now -- Discount 100% Off', 'isPlus': False, - 'rewardSourceId': 3, 'reward_source_type_id': 1, 'start_date': '2000-01-01T00:00:00Z'}], - 'seasonPassExclusiveFlag': False, 'skuAvailabilityOverrideFlag': False, 'sku_type': 0, - 'type': 'standard'}, - {'amortizeFlag': False, 'bundleExclusiveFlag': False, 'chargeImmediatelyFlag': False, - 'charge_type_id': 0, 'credit_card_required_flag': 0, 'display_price': '€19,99', - 'eligibilities': [], 'entitlements': [ - {'description': None, 'drms': [], 'duration': 0, 'durationOverrideTypeId': None, - 'exp_after_first_use': 0, 'feature_type_id': 3, 'id': 'TEARAWAY_CID', - 'license_type': 0, 'metadata': { - 'voiceLanguageCode': ['de', 'no', 'fi', 'ru', 'sv', 'pt', 'en', 'it', 'fr', 'es', 'pl', - 'da', 'nl'], - 'subtitleLanguageCode': ['de', 'no', 'fi', 'sv', 'ru', 'pt', 'en', 'it', 'fr', 'es', 'pl', - 'da', 'nl']}, 'name': 'Tearaway™ Unfolded', - 'packageType': 'PS4GD', - 'packages': [{'platformId': 13, 'platformName': 'ps4', 'size': 70937}], - 'preorder_placeholder_flag': False, 'size': 0, 'subType': 0, - 'subtitle_language_codes': ['de', 'no', 'fi', 'sv', 'ru', 'pt', 'en', 'it', 'fr', 'es', 'pl', - 'da', 'nl'], 'type': 5, 'use_count': 0, - 'voice_language_codes': ['de', 'no', 'fi', 'ru', 'sv', 'pt', 'en', 'it', 'fr', 'es', 'pl', - 'da', 'nl']}], 'id': 'TEARAWAY_CID', - 'is_original': False, 'name': 'Vollversion', 'platforms': [0, 18, 10, 13], 'price': 1999, - 'rewards': [], 'seasonPassExclusiveFlag': False, 'skuAvailabilityOverrideFlag': False, - 'sku_type': 0, 'type': 'standard'}] - } - assert psn._get_all_prices(item) == [19.99] - assert psn._get_normal_price(item) == 19.99 - assert psn._get_playstation_plus_price_reduction(item) == None - assert psn._get_non_playstation_plus_price_reduction(item) == None - @unittest.skip("Skip temporary price reduction") def test_check_currently_reduced_item_all_prices(self): store = "DE/de" @@ -193,15 +110,15 @@ def test_format_items_to_json(self): json_string = format_items_as_json(game_offers) assert "Tearaway" in json_string - def test_get_item_for_id(self): + def test_get_item_by_search(self): game_offers = self.psn.search("Tearaway™ Unfolded") game_offer = game_offers[0] assert game_offer.name == "Tearaway™ Unfolded" - assert game_offer.prices[0].offer_type == "NORMAL" # Normal price should be first + assert game_offer.prices[0].offer_type == "discountedPrice" # Discounted price should be first assert game_offer.prices[0].value != 0 # Demo should not be first returned assert game_offer.prices[0].value != 100 # Price should not be 100 which is the PS Now dummy price - def test_get_item_for_id_that_misses_price(self): + def test_get_item_by_search_that_misses_price(self): game_offers = self.psn.search("Dreams") game_offer = game_offers[0] assert game_offer.prices[0].value >= 0 @@ -217,6 +134,7 @@ def test_search_test_without_playable_platforms(self): print("\n".join(str(e) for e in game_offers)) assert len(game_offers) > 1 + @pytest.mark.skip(reason=NO_SEARCH_FOR_CID_REASON) def test_get_item_by_id(self): id = "EP9000-CUSA00562_00-TEARAWAYUNFOLDED" name = "Tearaway™ Unfolded" @@ -225,8 +143,10 @@ def test_get_item_by_id(self): assert game_offer.name == name assert game_offer.id == id - def test_game_has_picture(self): + @pytest.mark.skip(reason=NO_SEARCH_FOR_CID_REASON) + def test_get_item_by_id_has_picture(self): assert "http" in self.get_game().picture_url + @pytest.mark.skip(reason=NO_SEARCH_FOR_CID_REASON) def test_mailfunc(self): mailalert("EP0177-CUSA07010_00-SONICMANIA000000,100.00", psnmailalert_main) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e87585d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +lxml +cssselect