2020 "AppleWebKit/537.36 (KHTML, like Gecko) "
2121 "Chrome/124.0.0.0 Safari/537.36"
2222 ),
23- "Accept" : "text/html, application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 " ,
23+ "Accept" : "application/json " ,
2424 "Accept-Language" : "en-US,en;q=0.9" ,
2525}
2626
2727GAME_TYPES = {"FULL_GAME" , "PREMIUM_EDITION" , "GAME_BUNDLE" }
2828
2929_GQL_URL = "https://web.np.playstation.com/api/graphql/v1/op"
30- # SHA-256 of the `productRetrieveForUpsellWithCtas` GQL query , embedded in PS Store JS bundles.
30+ # SHA-256 of GQL persisted queries , embedded in PS Store JS bundles.
3131# Hardcoded by Sony (Apollo Persisted Queries) — cannot be computed locally.
3232# If requests start returning 400/errors, extract the new hash from the JS bundle at store.playstation.com.
33+ _GQL_SEARCH_HASH = "6ef5e809c35a056a1150fdcf513d9c505484dd1a946b6208888435c3182f105a"
3334_GQL_UPSELL_HASH = "a110672db9e20dc4f4d655fffd2f3a09730914ec3458cfb53de70cb2b526af53"
3435
36+ _GQL_SEARCH_PAGE_SIZE = 50
37+ _GQL_SEARCH_MAX_PAGES = 3
38+
3539
3640@dataclass
3741class GameResult :
@@ -62,11 +66,6 @@ def to_dict(self) -> dict:
6266 def from_dict (cls , d : dict ) -> "GameResult" :
6367 return cls (** d )
6468
65- NEXT_DATA_RE = re .compile (
66- r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>' ,
67- re .DOTALL ,
68- )
69-
7069
7170_PRICE_RE = re .compile (r'^(?P<prefix>[^\d,.]*)(?P<number>[\d.,]+)(?P<suffix>[^\d,.]*)$' )
7271
@@ -104,6 +103,23 @@ def _parse_price(price_str: str) -> tuple[float | None, str | None]:
104103 return None , None
105104
106105
106+ def _parse_str_price_data (price_data : dict ) -> tuple [float | None , str | None , float | None , str | None ]:
107+ """Parse string-format price data from search results → (price, currency, base_price, discount_text)."""
108+ discounted_str = price_data .get ("discountedPrice" )
109+ base_str = price_data .get ("basePrice" )
110+
111+ price , currency = _parse_price (discounted_str ) if discounted_str and discounted_str != "Free" else (None , None )
112+ base_price , base_currency = _parse_price (base_str ) if base_str and base_str != "Free" else (None , None )
113+
114+ if price is None :
115+ return base_price , base_currency , None , price_data .get ("discountText" )
116+
117+ if base_price == price :
118+ base_price = None
119+
120+ return price , currency , base_price , price_data .get ("discountText" )
121+
122+
107123def _extract_cover (media : list [dict ]) -> str | None :
108124 for role in ("MASTER" , "EDITION_KEY_ART" , "FOUR_BY_THREE_BANNER" ):
109125 for item in media :
@@ -112,11 +128,36 @@ def _extract_cover(media: list[dict]) -> str | None:
112128 return None
113129
114130
131+ def _make_game_result (product : dict , price : float | None , currency : str | None ,
132+ base_price : float | None , discount_text : str | None ) -> GameResult :
133+ return GameResult (
134+ ps_id = product ["id" ],
135+ title = product .get ("name" , "" ),
136+ platforms = product .get ("platforms" ) or [],
137+ type = product .get ("storeDisplayClassification" ),
138+ price = price ,
139+ currency = currency ,
140+ base_price = base_price ,
141+ discount_text = discount_text ,
142+ cover_url = _extract_cover (product .get ("media" ) or []),
143+ )
144+
145+
115146def _locale_header (region : str ) -> str :
116147 lang , _ , country = region .partition ("-" )
117148 return f"{ lang } -{ country .upper ()} " if country else region
118149
119150
151+ def _gql_headers (region : str , referer : str ) -> dict :
152+ return {
153+ ** STORE_HEADERS ,
154+ "Origin" : "https://store.playstation.com" ,
155+ "Referer" : referer ,
156+ "apollo-require-preflight" : "true" ,
157+ "x-psn-store-locale-override" : _locale_header (region ),
158+ }
159+
160+
120161def _outright_price (webctas : list [dict ]) -> dict | None :
121162 for cta in webctas :
122163 if cta .get ("type" ) == "ADD_TO_CART" :
@@ -125,75 +166,68 @@ def _outright_price(webctas: list[dict]) -> dict | None:
125166 return None
126167
127168
128- def _resolve_product (apollo : dict , ref : str ) -> GameResult | None :
129- raw = apollo .get (ref )
130- if not raw or raw .get ("__typename" ) != "Product" :
131- return None
132-
133- media = raw .get ("media" ) or []
134- price_data = raw .get ("price" ) or {}
135-
136- discounted_str = price_data .get ("discountedPrice" )
137- base_str = price_data .get ("basePrice" )
138-
139- price , currency = _parse_price (discounted_str ) if discounted_str and discounted_str != "Free" else (None , None )
140- base_price , _ = _parse_price (base_str ) if base_str and base_str != "Free" else (None , None )
141-
142- if base_price == price :
143- base_price = None
144-
145- return GameResult (
146- ps_id = raw ["id" ],
147- title = raw ["name" ],
148- platforms = raw .get ("platforms" , []),
149- type = raw .get ("storeDisplayClassification" ),
150- price = price ,
151- currency = currency ,
152- base_price = base_price ,
153- discount_text = price_data .get ("discountText" ),
154- cover_url = _extract_cover (media ),
155- )
169+ async def _fetch_search_page (
170+ session : aiohttp .ClientSession ,
171+ base_vars : dict ,
172+ cursor : str ,
173+ offset : int ,
174+ headers : dict ,
175+ ) -> dict | None :
176+ params = urlencode ({
177+ "operationName" : "getSearchResults" ,
178+ "variables" : json .dumps ({** base_vars , "nextCursor" : cursor , "pageOffset" : offset }),
179+ "extensions" : json .dumps ({"persistedQuery" : {"version" : 1 , "sha256Hash" : _GQL_SEARCH_HASH }}),
180+ })
181+ async with session .get (f"{ _GQL_URL } ?{ params } " , headers = headers ) as resp :
182+ if resp .status != 200 :
183+ logger .warning ("search_games: HTTP %d [offset=%d]" , resp .status , offset )
184+ return None
185+ data = await resp .json (content_type = None )
186+ return (data .get ("data" ) or {}).get ("universalSearch" )
156187
157188
158189async def search_games (query : str , region : str = "en-us" ) -> list [GameResult ]:
159- url = f"https://store.playstation.com/{ region } /search/{ query } "
160-
161- async with aiohttp .ClientSession (headers = STORE_HEADERS ) as session :
162- async with session .get (url ) as resp :
163- resp .raise_for_status ()
164- html = await resp .text ()
165-
166- match = NEXT_DATA_RE .search (html )
167- if not match :
168- return []
169-
170- data = json .loads (match .group (1 ))
171- apollo : dict = data .get ("props" , {}).get ("apolloState" , {})
172- root : dict = apollo .get ("ROOT_QUERY" , {})
190+ lang , _ , country = region .partition ("-" )
191+ base_vars = {
192+ "countryCode" : country .upper () if country else region .upper (),
193+ "languageCode" : lang ,
194+ "pageSize" : _GQL_SEARCH_PAGE_SIZE ,
195+ "searchTerm" : query ,
196+ }
197+ headers = _gql_headers (region , "https://store.playstation.com/" )
198+ words = [w .lower () for w in query .split () if w ]
173199
174- search_data : dict | None = None
175- for key , val in root .items ():
176- if "universalSearch" in key and isinstance (val , dict ):
177- search_data = val
178- break
200+ results : list [GameResult ] = []
201+ cursor = ""
202+ offset = 0
203+ fetched_any = False
179204
180- if not search_data :
205+ async with aiohttp .ClientSession () as session :
206+ for _ in range (_GQL_SEARCH_MAX_PAGES ):
207+ page = await _fetch_search_page (session , base_vars , cursor , offset , headers )
208+ if not page :
209+ break
210+ fetched_any = True
211+ page_hits = 0
212+ for product in page .get ("results" , []):
213+ if product .get ("storeDisplayClassification" ) not in GAME_TYPES :
214+ continue
215+ if not all (w in product .get ("name" , "" ).lower () for w in words ):
216+ continue
217+ page_hits += 1
218+ price , currency , base_price , discount_text = _parse_str_price_data (product .get ("price" ) or {})
219+ results .append (_make_game_result (product , price , currency , base_price , discount_text ))
220+ if page ["pageInfo" ]["isLast" ] or page_hits == 0 :
221+ break
222+ cursor = page ["next" ]
223+ offset += _GQL_SEARCH_PAGE_SIZE
224+
225+ if not fetched_any :
181226 logger .warning ("search_games: no universalSearch data [query=%r region=%s]" , query , region )
182227 return []
183228
184- words = [w .lower () for w in query .split () if w ]
185-
186- results = []
187- for ref_obj in search_data .get ("results" , []):
188- ref = ref_obj .get ("__ref" , "" )
189- product = _resolve_product (apollo , ref )
190- if not product or product .type not in GAME_TYPES :
191- continue
192- title_lower = product .title .lower ()
193- if all (w in title_lower for w in words ):
194- results .append (product )
195-
196- logger .info ("search_games: %d results [query=%r region=%s]" , len (results ), query , region )
229+ logger .info ("search_games: %d results across %d page(s) [query=%r region=%s]" ,
230+ len (results ), offset // _GQL_SEARCH_PAGE_SIZE + 1 , query , region )
197231 return results
198232
199233
@@ -203,16 +237,12 @@ async def get_game_info(ps_id: str, region: str = "en-us") -> GameResult | None:
203237 "variables" : json .dumps ({"productId" : ps_id }),
204238 "extensions" : json .dumps ({"persistedQuery" : {"version" : 1 , "sha256Hash" : _GQL_UPSELL_HASH }}),
205239 })
206- headers = {
207- ** STORE_HEADERS ,
208- "Origin" : "https://store.playstation.com" ,
209- "Referer" : f"https://store.playstation.com/{ region } /product/{ ps_id } /" ,
210- "apollo-require-preflight" : "true" ,
211- "x-psn-store-locale-override" : _locale_header (region ),
212- }
213240
214241 async with aiohttp .ClientSession () as session :
215- async with session .get (f"{ _GQL_URL } ?{ params } " , headers = headers ) as resp :
242+ async with session .get (
243+ f"{ _GQL_URL } ?{ params } " ,
244+ headers = _gql_headers (region , f"https://store.playstation.com/{ region } /product/{ ps_id } /" ),
245+ ) as resp :
216246 if resp .status != 200 :
217247 logger .warning ("get_game_info: HTTP %d [ps_id=%s region=%s]" , resp .status , ps_id , region )
218248 return None
@@ -234,25 +264,15 @@ async def get_game_info(ps_id: str, region: str = "en-us") -> GameResult | None:
234264 if price_cta and not price_cta .get ("isFree" ):
235265 iso = price_cta .get ("currencyCode" )
236266 divisor = 1 if iso in _WHOLE_UNIT_CURRENCIES else 100
237- dv = price_cta .get ("discountedValue" ) or 0
238- bv = price_cta .get ("basePriceValue" ) or 0
239- price = dv / divisor if dv else None
240- base_price = bv / divisor if bv and bv != dv else None
267+ dv = price_cta .get ("discountedValue" )
268+ bv = price_cta .get ("basePriceValue" )
269+ price = ( dv if dv is not None else bv or 0 ) / divisor or None
270+ base_price = bv / divisor if bv is not None and bv != dv else None
241271 currency = PS_ISO_TO_SYMBOL .get (iso , iso )
242272 discount_text = price_cta .get ("discountText" )
243273
244274 logger .info ("get_game_info: found %r [ps_id=%s region=%s]" , product .get ("name" ), ps_id , region )
245- return GameResult (
246- ps_id = ps_id ,
247- title = product .get ("name" ),
248- platforms = product .get ("platforms" ) or [],
249- type = product .get ("storeDisplayClassification" ),
250- price = price ,
251- currency = currency ,
252- base_price = base_price ,
253- discount_text = discount_text ,
254- cover_url = _extract_cover (product .get ("media" ) or []),
255- )
275+ return _make_game_result (product , price , currency , base_price , discount_text )
256276
257277
258278async def get_game_price (ps_id : str , region : str = "en-us" ) -> float | None :
0 commit comments