Skip to content

Commit b981c09

Browse files
authored
Fix search pagination (#5)
1 parent cabf7de commit b981c09

4 files changed

Lines changed: 319 additions & 200 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ An automated Telegram bot designed to help gamers find the lowest prices for Pla
1212

1313
### Commands
1414

15-
| Command | Description |
16-
|---------------|-------------------------------------------|
17-
| `/start` | Welcome message |
18-
| `/add_region` | Search and add a PS Store region to track |
19-
| `/my_regions` | Manage your tracked regions |
15+
| Command | Description |
16+
|---------------|------------------------------------------------------------------|
17+
| `/start` | Welcome message |
18+
| `/add_region` | Search and add a PS Store region to track |
19+
| `/my_regions` | Manage your tracked regions |
20+
| `/search` | Search for a game and compare prices across your tracked regions |

‎bot/handlers/regions.py‎

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,45 @@
2222
router = Router()
2323

2424

25-
@router.message(Command("add_region"))
26-
async def cmd_add_region(message: Message, state: FSMContext) -> None:
27-
await state.set_state(RegionForm.waiting_for_search)
25+
async def _do_region_search(message: Message, session: AsyncSession, query: str) -> None:
26+
user = await get_or_create_user(
27+
session, message.from_user.id, message.from_user.username
28+
)
29+
await session.commit()
30+
31+
user_regions = await get_user_regions(session, user.id)
32+
tracked_locales = {r.code for r in user_regions}
33+
34+
all_countries = await get_ps_regions()
35+
matches = [c for c in all_countries if query.lower() in c["name"].lower()]
36+
37+
if not matches:
38+
await message.answer(
39+
"No results found. Try a different name:",
40+
reply_markup=cancel_keyboard(),
41+
)
42+
return
43+
44+
keyboard = ps_regions_keyboard(matches, tracked_locales=tracked_locales)
2845
await message.answer(
29-
"Type a country name to search:",
30-
reply_markup=cancel_keyboard(),
46+
f"Found {len(matches)} region(s). Choose one:",
47+
reply_markup=keyboard,
3148
)
3249

3350

51+
@router.message(Command("add_region"))
52+
async def cmd_add_region(message: Message, state: FSMContext, session: AsyncSession) -> None:
53+
query = message.text.partition(" ")[2].strip()
54+
if query:
55+
await _do_region_search(message, session, query)
56+
else:
57+
await state.set_state(RegionForm.waiting_for_search)
58+
await message.answer(
59+
"Type a country name to search:",
60+
reply_markup=cancel_keyboard(),
61+
)
62+
63+
3464
@router.message(Command("my_regions"))
3565
async def cmd_my_regions(message: Message, session: AsyncSession) -> None:
3666
user = await get_or_create_user(
@@ -57,33 +87,11 @@ async def cmd_my_regions(message: Message, session: AsyncSession) -> None:
5787
async def on_region_search(
5888
message: Message, state: FSMContext, session: AsyncSession
5989
) -> None:
60-
query = message.text.strip().lower()
90+
query = message.text.strip()
6191
if not query:
6292
return
63-
64-
user = await get_or_create_user(
65-
session, message.from_user.id, message.from_user.username
66-
)
67-
await session.commit()
68-
69-
user_regions = await get_user_regions(session, user.id)
70-
tracked_locales = {r.code for r in user_regions}
71-
72-
all_countries = await get_ps_regions()
73-
matches = [c for c in all_countries if query in c["name"].lower()]
74-
75-
if not matches:
76-
await message.answer(
77-
"No results found. Try a different name:",
78-
reply_markup=cancel_keyboard(),
79-
)
80-
return
81-
82-
keyboard = ps_regions_keyboard(matches, tracked_locales=tracked_locales)
83-
await message.answer(
84-
f"Found {len(matches)} region(s). Choose one:",
85-
reply_markup=keyboard,
86-
)
93+
await state.clear()
94+
await _do_region_search(message, session, query)
8795

8896

8997
@router.callback_query(F.data == "noop")

‎services/ps_store.py‎

Lines changed: 111 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,22 @@
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

2727
GAME_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
3741
class 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+
107123
def _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+
115146
def _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+
120161
def _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

158189
async 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

258278
async def get_game_price(ps_id: str, region: str = "en-us") -> float | None:

0 commit comments

Comments
 (0)