Skip to content

Commit f771f11

Browse files
authored
Merge pull request #3778 from rommapp/posthog-code/fix-ss-invalid-json-escape
fix(ss): tolerate ScreenScraper's invalid JSON escapes
2 parents 484b1d3 + 0cf5f47 commit f771f11

2 files changed

Lines changed: 44 additions & 2 deletions

File tree

backend/adapters/services/screenscraper.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import http
33
import json
4+
import re
45
from typing import Final, cast
56

67
import aiohttp
@@ -22,6 +23,26 @@
2223

2324
LOGIN_ERROR_CHECK: Final = "Erreur de login"
2425

26+
# ScreenScraper occasionally returns malformed JSON with unescaped backslashes in
27+
# text fields (e.g. game synopses), which the strict parser rejects with
28+
# "Invalid \escape" and discards the whole response. Match any backslash that is
29+
# not part of a valid JSON escape so we can repair those before parsing.
30+
_INVALID_ESCAPE_RE: Final = re.compile(r'\\(?!["\\/bfnrt]|u[0-9a-fA-F]{4})')
31+
32+
33+
def _loads_lenient(text: str) -> dict:
34+
"""Parse a ScreenScraper JSON payload, repairing invalid escapes on failure.
35+
36+
A single unescaped backslash would otherwise sink an entire response (and thus
37+
the match), so on a decode error we double any backslash that isn't a valid
38+
JSON escape and try once more.
39+
"""
40+
try:
41+
return json.loads(text)
42+
except json.JSONDecodeError:
43+
return json.loads(_INVALID_ESCAPE_RE.sub(r"\\\\", text))
44+
45+
2546
# ScreenScraper enforces a per-account *thread* (concurrency) cap rather than a
2647
# request rate. Because a request can take several seconds, spacing out request
2748
# starts is not enough, as overlapping requests would exceed the cap and get
@@ -140,7 +161,7 @@ async def _request(self, url: str, request_timeout: int = 120) -> dict:
140161
status_code=status.HTTP_401_UNAUTHORIZED,
141162
detail="Invalid ScreenScraper credentials",
142163
)
143-
data = await res.json()
164+
data = await res.json(loads=_loads_lenient)
144165
_update_thread_allowance(data)
145166
return data
146167
except aiohttp.ServerTimeoutError:
@@ -213,7 +234,7 @@ async def _request(self, url: str, request_timeout: int = 120) -> dict:
213234
status_code=status.HTTP_401_UNAUTHORIZED,
214235
detail="Invalid ScreenScraper credentials",
215236
)
216-
data = await res.json()
237+
data = await res.json(loads=_loads_lenient)
217238
_update_thread_allowance(data)
218239
return data
219240
except (aiohttp.ClientResponseError, aiohttp.ServerTimeoutError) as err:

backend/tests/adapters/services/test_screenscraper.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from adapters.services.screenscraper import (
1212
LOGIN_ERROR_CHECK,
1313
ScreenScraperService,
14+
_loads_lenient,
1415
auth_middleware,
1516
is_daily_quota_exhausted,
1617
reset_daily_quota,
@@ -1124,3 +1125,23 @@ async def test_login_error_in_retry_attempt(self, service):
11241125
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
11251126
assert "Invalid ScreenScraper credentials" in exc_info.value.detail
11261127
assert mock_session.get.call_count == 2
1128+
1129+
1130+
class TestLoadsLenient:
1131+
"""Test tolerant parsing of ScreenScraper's occasionally malformed JSON."""
1132+
1133+
def test_parses_valid_json(self):
1134+
assert _loads_lenient('{"a": 1, "b": "x"}') == {"a": 1, "b": "x"}
1135+
1136+
def test_repairs_invalid_backslash_escape(self):
1137+
# ScreenScraper sometimes emits raw backslashes in text fields, which the
1138+
# strict parser rejects with "Invalid \escape".
1139+
raw = '{"synopsis": "path C:\\emu\\games"}'
1140+
with pytest.raises(json.JSONDecodeError):
1141+
json.loads(raw)
1142+
assert _loads_lenient(raw) == {"synopsis": "path C:\\emu\\games"}
1143+
1144+
def test_preserves_valid_escapes(self):
1145+
assert _loads_lenient('{"s": "line\\nbreak \\"quoted\\" \\u00e9"}') == {
1146+
"s": 'line\nbreak "quoted" é'
1147+
}

0 commit comments

Comments
 (0)