Skip to content

Commit

Permalink
Merge pull request #15 from snipem/fix-psn-api
Browse files Browse the repository at this point in the history
Fix new PSN Api
  • Loading branch information
snipem authored Mar 22, 2023
2 parents 966243a + ce23edb commit fb5be2d
Show file tree
Hide file tree
Showing 7 changed files with 41 additions and 151 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
68 changes: 14 additions & 54 deletions gameprices/shops/psn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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 = []

Expand All @@ -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)
Expand All @@ -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
2 changes: 1 addition & 1 deletion gameprices/test/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions gameprices/test/test_dealmailalert.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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():
wrong_line = "EP9000-CUSA07123_00-000000000000000,10.00,DE/de"
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 = (
Expand All @@ -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)
110 changes: 15 additions & 95 deletions gameprices/test/test_psn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand All @@ -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
Expand All @@ -27,28 +31,23 @@ 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)
item = psn._get_item_for_cid(cids[0], store)

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)
item = psn._get_item_for_cid(cids[0], store)

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)
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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)
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
lxml
cssselect

0 comments on commit fb5be2d

Please sign in to comment.