mobile-store-scraper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mobile_store_scraper/__init__.py +31 -0
- mobile_store_scraper/__main__.py +63 -0
- mobile_store_scraper/appstore.py +263 -0
- mobile_store_scraper/countries.py +46 -0
- mobile_store_scraper/data/devices.properties +990 -0
- mobile_store_scraper/dates.py +84 -0
- mobile_store_scraper/errors.py +45 -0
- mobile_store_scraper/http.py +193 -0
- mobile_store_scraper/models.py +186 -0
- mobile_store_scraper/play/__init__.py +8 -0
- mobile_store_scraper/play/api.py +417 -0
- mobile_store_scraper/play/device.py +171 -0
- mobile_store_scraper/play/proto.py +189 -0
- mobile_store_scraper/play/scraper.py +335 -0
- mobile_store_scraper/play/web.py +195 -0
- mobile_store_scraper/scrape.py +188 -0
- mobile_store_scraper-0.1.0.dist-info/METADATA +157 -0
- mobile_store_scraper-0.1.0.dist-info/RECORD +21 -0
- mobile_store_scraper-0.1.0.dist-info/WHEEL +4 -0
- mobile_store_scraper-0.1.0.dist-info/entry_points.txt +2 -0
- mobile_store_scraper-0.1.0.dist-info/licenses/LICENSE +26 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""mobile-store-scraper - async Google Play + Apple App Store scraper with reliable versions.
|
|
2
|
+
|
|
3
|
+
>>> import mobile_store_scraper as mss
|
|
4
|
+
>>> app = mss.scrape(store="gp", app="com.naturalmotion.customstreetracer2", country="DE")
|
|
5
|
+
>>> app.version, app.last_updated, app.min_os_ver, app.downloads, app.released, app.publisher
|
|
6
|
+
('6.8.0', '2026-10-14T07:10:38Z', 'Android 7.0', 82008679, '2016-06-29T15:01:05Z', 'Zynga')
|
|
7
|
+
"""
|
|
8
|
+
from .appstore import AppStore
|
|
9
|
+
from .countries import ALL_COUNTRIES, APP_STORE_COUNTRIES, PLAY_COUNTRIES
|
|
10
|
+
from .dates import to_iso
|
|
11
|
+
from .errors import (AppNotFound, InvalidCountry, IpBlocked, ParseError, PlayApiError, PlayAuthError, RateLimited,
|
|
12
|
+
StoreScraperError)
|
|
13
|
+
from .http import HttpClient
|
|
14
|
+
from .models import AppData, AppStoreApp, DeviceVersion, GooglePlayApp, Store, VersionSource
|
|
15
|
+
from .play import (DEFAULT_DEVICE, REPRESENTATIVE_DEVICES, GooglePlay, PlayApiClient, PlaySession,
|
|
16
|
+
available_devices)
|
|
17
|
+
from .scrape import (Scraper, resolve_store, scrape, scrape_async, scrape_countries, scrape_countries_async,
|
|
18
|
+
scrape_many, scrape_many_async)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"scrape", "scrape_async", "scrape_countries", "scrape_countries_async", "scrape_many", "scrape_many_async",
|
|
24
|
+
"Scraper", "resolve_store",
|
|
25
|
+
"GooglePlay", "AppStore", "PlayApiClient", "PlaySession", "HttpClient",
|
|
26
|
+
"AppData", "GooglePlayApp", "AppStoreApp", "DeviceVersion", "Store", "VersionSource",
|
|
27
|
+
"PLAY_COUNTRIES", "APP_STORE_COUNTRIES", "ALL_COUNTRIES",
|
|
28
|
+
"DEFAULT_DEVICE", "REPRESENTATIVE_DEVICES", "available_devices", "to_iso",
|
|
29
|
+
"StoreScraperError", "AppNotFound", "IpBlocked", "InvalidCountry", "ParseError", "RateLimited",
|
|
30
|
+
"PlayAuthError", "PlayApiError", "__version__",
|
|
31
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Command line.
|
|
2
|
+
|
|
3
|
+
python -m mobile_store_scraper gp com.naturalmotion.customstreetracer2 -c DE
|
|
4
|
+
python -m mobile_store_scraper as 310633997 -c all --concurrency 20 --rate-limit 10
|
|
5
|
+
python -m mobile_store_scraper gp com.google.android.youtube -m all_devices --json
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .errors import StoreScraperError
|
|
15
|
+
from .scrape import Scraper
|
|
16
|
+
|
|
17
|
+
SHOW = ("version", "last_updated", "min_os_ver", "downloads", "released", "publisher")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _row(app) -> str:
|
|
21
|
+
return (f"{app.country:3} {str(app.version):22} {str(app.last_updated):21} {str(app.min_os_ver):13} "
|
|
22
|
+
f"{str(getattr(app, 'downloads', '-')):>13} {str(app.released):21} {app.publisher or ''}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _main(argv: list[str]) -> int:
|
|
26
|
+
ap = argparse.ArgumentParser(prog="mobile-store-scraper")
|
|
27
|
+
ap.add_argument("store", help="gp / google_play / play or as / apple / app_store / ios")
|
|
28
|
+
ap.add_argument("app", help="package name (Play) or track id / bundle id (App Store)")
|
|
29
|
+
ap.add_argument("-c", "--country", default="US", help="ISO code, comma list, or 'all'")
|
|
30
|
+
ap.add_argument("-l", "--lang", default=None)
|
|
31
|
+
ap.add_argument("-m", "--mode", default="auto", choices=["auto", "web", "api", "all_devices"],
|
|
32
|
+
help="Google Play version mode")
|
|
33
|
+
ap.add_argument("--concurrency", type=int, default=None, help="max simultaneous requests (default: unlimited)")
|
|
34
|
+
ap.add_argument("--rate-limit", type=float, default=None, help="requests/second per host (default: unlimited)")
|
|
35
|
+
ap.add_argument("--json", action="store_true", help="print every attribute as JSON")
|
|
36
|
+
a = ap.parse_args(argv)
|
|
37
|
+
|
|
38
|
+
async with Scraper(concurrency=a.concurrency, rate_limit=a.rate_limit, version_mode=a.mode, lang=a.lang) as s:
|
|
39
|
+
if a.country.lower() != "all" and "," not in a.country:
|
|
40
|
+
try:
|
|
41
|
+
res = {a.country.upper(): await s.scrape(a.store, a.app, a.country)}
|
|
42
|
+
except StoreScraperError as exc:
|
|
43
|
+
res = {a.country.upper(): exc}
|
|
44
|
+
else:
|
|
45
|
+
res = await s.scrape_countries(a.store, a.app, a.country)
|
|
46
|
+
if a.json:
|
|
47
|
+
print(json.dumps({k: (v.to_dict() if not isinstance(v, Exception) else {"error": f"{type(v).__name__}: {v}"})
|
|
48
|
+
for k, v in res.items()}, indent=2, ensure_ascii=False, default=str))
|
|
49
|
+
else:
|
|
50
|
+
print(f"{'cc':3} {'version':22} {'last_updated':21} {'min_os_ver':13} {'downloads':>13} {'released':21} publisher")
|
|
51
|
+
for k, v in res.items():
|
|
52
|
+
print(_row(v) if not isinstance(v, Exception) else f"{k:3} {type(v).__name__}: {v}")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> None:
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
59
|
+
sys.exit(asyncio.run(_main(sys.argv[1:])))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Async Apple App Store scraper.
|
|
2
|
+
|
|
3
|
+
Primary source is the iTunes lookup API (fast, JSON, up to 150 ids per call). The public
|
|
4
|
+
``apps.apple.com`` listing is used as a fallback when lookup has no result, and optionally
|
|
5
|
+
as a cross-check (``verify_with_web=True``) because lookup's CDN cache sometimes lags a
|
|
6
|
+
fresh release by a few hours.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Iterable, Optional, Union
|
|
14
|
+
|
|
15
|
+
from .countries import APP_STORE_COUNTRIES, normalize_country
|
|
16
|
+
from .dates import from_iso, from_js_date, to_iso
|
|
17
|
+
from .errors import AppNotFound, InvalidCountry, ParseError, StoreScraperError
|
|
18
|
+
from .http import HttpClient
|
|
19
|
+
from .models import AppStoreApp, VersionSource
|
|
20
|
+
|
|
21
|
+
LOOKUP_URL = "https://itunes.apple.com/lookup"
|
|
22
|
+
WEB_URL = "https://apps.apple.com/{cc}/app/id{id}"
|
|
23
|
+
_SSD_RE = re.compile(r'<script[^>]*id="serialized-server-data"[^>]*>(.*?)</script>', re.S)
|
|
24
|
+
_VER_RE = re.compile(r"^\d+(\.\d+)*[\w.\-]*$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version_key(v: Optional[str]) -> tuple:
|
|
28
|
+
if not v:
|
|
29
|
+
return ()
|
|
30
|
+
return tuple(int(p) if p.isdigit() else 0 for p in re.split(r"[.\-]", v))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_numeric_id(app_id: Union[str, int]) -> bool:
|
|
34
|
+
return _clean_id(app_id).isdigit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _clean_id(app_id: Union[str, int]) -> str:
|
|
38
|
+
s = str(app_id).strip()
|
|
39
|
+
return s[2:] if s.startswith("id") and s[2:].isdigit() else s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_lookup_result(r: dict[str, Any], country: str, lang: Optional[str] = None) -> AppStoreApp:
|
|
43
|
+
price = r.get("price")
|
|
44
|
+
min_os = r.get("minimumOsVersion")
|
|
45
|
+
return AppStoreApp(
|
|
46
|
+
app_id=str(r.get("trackId")),
|
|
47
|
+
country=country,
|
|
48
|
+
language=lang,
|
|
49
|
+
url=r.get("trackViewUrl"),
|
|
50
|
+
title=r.get("trackName"),
|
|
51
|
+
version=r.get("version"),
|
|
52
|
+
version_source=VersionSource.ITUNES_LOOKUP.value if r.get("version") else None,
|
|
53
|
+
last_updated=to_iso(from_iso(r.get("currentVersionReleaseDate"))),
|
|
54
|
+
released=to_iso(from_iso(r.get("releaseDate"))),
|
|
55
|
+
min_os_ver=f"iOS {min_os}" if min_os else None,
|
|
56
|
+
publisher=r.get("artistName") or r.get("sellerName"),
|
|
57
|
+
developer_id=str(r["artistId"]) if r.get("artistId") else None,
|
|
58
|
+
developer_website=r.get("sellerUrl"),
|
|
59
|
+
description=r.get("description"),
|
|
60
|
+
release_notes=r.get("releaseNotes"),
|
|
61
|
+
genre=r.get("primaryGenreName"),
|
|
62
|
+
genre_id=str(r["primaryGenreId"]) if r.get("primaryGenreId") else None,
|
|
63
|
+
icon=r.get("artworkUrl512") or r.get("artworkUrl100"),
|
|
64
|
+
screenshots=r.get("screenshotUrls") or [],
|
|
65
|
+
score=r.get("averageUserRating"),
|
|
66
|
+
ratings=r.get("userRatingCount"),
|
|
67
|
+
price=price,
|
|
68
|
+
currency=r.get("currency"),
|
|
69
|
+
free=(price == 0) if price is not None else None,
|
|
70
|
+
content_rating=r.get("trackContentRating") or r.get("contentAdvisoryRating"),
|
|
71
|
+
track_id=r.get("trackId"),
|
|
72
|
+
bundle_id=r.get("bundleId"),
|
|
73
|
+
seller=r.get("sellerName"),
|
|
74
|
+
seller_url=r.get("sellerUrl"),
|
|
75
|
+
developer_url=r.get("artistViewUrl"),
|
|
76
|
+
file_size_bytes=int(r["fileSizeBytes"]) if str(r.get("fileSizeBytes", "")).isdigit() else None,
|
|
77
|
+
supported_devices=r.get("supportedDevices") or [],
|
|
78
|
+
ipad_screenshots=r.get("ipadScreenshotUrls") or [],
|
|
79
|
+
appletv_screenshots=r.get("appletvScreenshotUrls") or [],
|
|
80
|
+
languages=r.get("languageCodesISO2A") or [],
|
|
81
|
+
genres=r.get("genres") or [],
|
|
82
|
+
genre_ids=r.get("genreIds") or [],
|
|
83
|
+
advisories=r.get("advisories") or [],
|
|
84
|
+
features=r.get("features") or [],
|
|
85
|
+
content_advisory_rating=r.get("contentAdvisoryRating"),
|
|
86
|
+
score_current_version=r.get("averageUserRatingForCurrentVersion"),
|
|
87
|
+
ratings_current_version=r.get("userRatingCountForCurrentVersion"),
|
|
88
|
+
formatted_price=r.get("formattedPrice"),
|
|
89
|
+
is_game_center_enabled=r.get("isGameCenterEnabled"),
|
|
90
|
+
kind=r.get("kind"),
|
|
91
|
+
raw={"lookup": r},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _walk(o: Any):
|
|
96
|
+
if isinstance(o, dict):
|
|
97
|
+
yield o
|
|
98
|
+
for v in o.values():
|
|
99
|
+
yield from _walk(v)
|
|
100
|
+
elif isinstance(o, list):
|
|
101
|
+
for v in o:
|
|
102
|
+
yield from _walk(v)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_web_html(html: str) -> dict[str, Any]:
|
|
106
|
+
"""Extract newest version + its timestamp (and title) from an apps.apple.com page."""
|
|
107
|
+
m = _SSD_RE.search(html)
|
|
108
|
+
if not m:
|
|
109
|
+
raise ParseError("apps.apple.com page has no serialized-server-data block")
|
|
110
|
+
data = json.loads(m.group(1))
|
|
111
|
+
version = date = None
|
|
112
|
+
for node in _walk(data):
|
|
113
|
+
mrv = node.get("mostRecentVersion")
|
|
114
|
+
if mrv is None:
|
|
115
|
+
continue
|
|
116
|
+
for sub in _walk(mrv):
|
|
117
|
+
ps, ss = sub.get("primarySubtitle"), sub.get("secondarySubtitle")
|
|
118
|
+
if isinstance(ps, str) and _VER_RE.match(ps) and isinstance(ss, str):
|
|
119
|
+
version, date = ps, from_js_date(ss)
|
|
120
|
+
break
|
|
121
|
+
if version:
|
|
122
|
+
break
|
|
123
|
+
if not version:
|
|
124
|
+
for sub in _walk(data):
|
|
125
|
+
ps, ss = sub.get("primarySubtitle"), sub.get("secondarySubtitle")
|
|
126
|
+
if isinstance(ps, str) and _VER_RE.match(ps) and isinstance(ss, str) and from_js_date(ss):
|
|
127
|
+
version, date = ps, from_js_date(ss)
|
|
128
|
+
break
|
|
129
|
+
tm = (re.search(r'<meta[^>]*property="og:title"[^>]*content="([^"]+)"', html)
|
|
130
|
+
or re.search(r'<meta[^>]*content="([^"]+)"[^>]*property="og:title"', html))
|
|
131
|
+
return {"version": version, "updated": date, "title": tm.group(1) if tm else None}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class AppStore:
|
|
135
|
+
"""Async App Store scraper.
|
|
136
|
+
|
|
137
|
+
``app_id`` may be the numeric track id (``310633997`` / ``"id310633997"``) or a bundle id
|
|
138
|
+
(``"net.whatsapp.WhatsApp"``). Pace: unlimited by default; set ``concurrency`` and/or
|
|
139
|
+
``rate_limit`` (requests/second) to slow down.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
verify_with_web: bool = False,
|
|
146
|
+
web_fallback: bool = True,
|
|
147
|
+
concurrency: Optional[int] = None,
|
|
148
|
+
rate_limit: Optional[float] = None,
|
|
149
|
+
http: Optional[HttpClient] = None,
|
|
150
|
+
proxy: Optional[str] = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
self.verify_with_web = verify_with_web
|
|
153
|
+
self.web_fallback = web_fallback
|
|
154
|
+
self._own_http = http is None
|
|
155
|
+
self.http = http or HttpClient(concurrency=concurrency, rate_limit=rate_limit, proxy=proxy)
|
|
156
|
+
|
|
157
|
+
async def aclose(self) -> None:
|
|
158
|
+
if self._own_http:
|
|
159
|
+
await self.http.aclose()
|
|
160
|
+
|
|
161
|
+
async def __aenter__(self) -> "AppStore":
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
165
|
+
await self.aclose()
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------ raw calls
|
|
168
|
+
async def lookup(self, ids: Iterable[Union[str, int]], country: str = "US",
|
|
169
|
+
lang: Optional[str] = None) -> list[dict[str, Any]]:
|
|
170
|
+
"""Raw iTunes lookup for numeric ids and/or bundle ids (batched, concurrent)."""
|
|
171
|
+
country = normalize_country(country, APP_STORE_COUNTRIES, "App Store")
|
|
172
|
+
ids = [_clean_id(i) for i in ids]
|
|
173
|
+
numeric = [i for i in ids if i.isdigit()]
|
|
174
|
+
bundles = [i for i in ids if not i.isdigit()]
|
|
175
|
+
params_list = [{"id": ",".join(numeric[i:i + 150])} for i in range(0, len(numeric), 150)]
|
|
176
|
+
params_list += [{"bundleId": ",".join(bundles[i:i + 150])} for i in range(0, len(bundles), 150)]
|
|
177
|
+
|
|
178
|
+
async def one(params: dict) -> list[dict[str, Any]]:
|
|
179
|
+
params.update({"country": country.lower(), "entity": "software"})
|
|
180
|
+
if lang:
|
|
181
|
+
params["lang"] = lang
|
|
182
|
+
resp = await self.http.get(LOOKUP_URL, params=params)
|
|
183
|
+
if resp.status_code == 400 and "country" in resp.text:
|
|
184
|
+
raise InvalidCountry(f"{country} is not an App Store storefront")
|
|
185
|
+
if resp.status_code != 200:
|
|
186
|
+
raise StoreScraperError(f"iTunes lookup -> HTTP {resp.status_code}")
|
|
187
|
+
try:
|
|
188
|
+
data = json.loads(resp.text)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise ParseError(f"iTunes lookup sent non-JSON: {resp.text[:120]!r}") from exc
|
|
191
|
+
return [r for r in data.get("results", []) if r.get("wrapperType") == "software" or "trackId" in r]
|
|
192
|
+
|
|
193
|
+
out: list[dict[str, Any]] = []
|
|
194
|
+
for chunk in await asyncio.gather(*(one(p) for p in params_list)):
|
|
195
|
+
out += chunk
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
async def web(self, track_id: Union[str, int], country: str = "US") -> dict[str, Any]:
|
|
199
|
+
country = normalize_country(country, APP_STORE_COUNTRIES, "App Store")
|
|
200
|
+
resp = await self.http.get(WEB_URL.format(cc=country.lower(), id=_clean_id(track_id)))
|
|
201
|
+
if resp.status_code == 404:
|
|
202
|
+
raise AppNotFound(str(track_id), country, "app_store")
|
|
203
|
+
if resp.status_code != 200:
|
|
204
|
+
raise StoreScraperError(f"apps.apple.com {track_id}/{country} -> HTTP {resp.status_code}")
|
|
205
|
+
return parse_web_html(resp.text)
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------ public
|
|
208
|
+
async def app(self, app_id: Union[str, int], country: str = "US", *, lang: Optional[str] = None,
|
|
209
|
+
verify_with_web: Optional[bool] = None) -> AppStoreApp:
|
|
210
|
+
country = normalize_country(country, APP_STORE_COUNTRIES, "App Store")
|
|
211
|
+
results = await self.lookup([app_id], country, lang)
|
|
212
|
+
verify = self.verify_with_web if verify_with_web is None else verify_with_web
|
|
213
|
+
if results:
|
|
214
|
+
info = parse_lookup_result(results[0], country, lang)
|
|
215
|
+
if verify:
|
|
216
|
+
try:
|
|
217
|
+
await self._merge_web(info)
|
|
218
|
+
except StoreScraperError as exc:
|
|
219
|
+
info.raw["web_error"] = str(exc)
|
|
220
|
+
return info
|
|
221
|
+
if self.web_fallback and _is_numeric_id(app_id):
|
|
222
|
+
w = await self.web(app_id, country) # raises AppNotFound on 404
|
|
223
|
+
if w["version"]:
|
|
224
|
+
return AppStoreApp(
|
|
225
|
+
app_id=_clean_id(app_id), country=country, language=lang, title=w["title"],
|
|
226
|
+
version=w["version"], version_source=VersionSource.APP_STORE_WEB.value,
|
|
227
|
+
last_updated=to_iso(w["updated"]), track_id=int(_clean_id(app_id)),
|
|
228
|
+
url=WEB_URL.format(cc=country.lower(), id=_clean_id(app_id)), raw={"web": w},
|
|
229
|
+
)
|
|
230
|
+
raise AppNotFound(str(app_id), country, "app_store")
|
|
231
|
+
|
|
232
|
+
async def _merge_web(self, info: AppStoreApp) -> None:
|
|
233
|
+
w = await self.web(info.app_id, info.country)
|
|
234
|
+
info.raw["web"] = w
|
|
235
|
+
if w["version"] and _version_key(w["version"]) > _version_key(info.version):
|
|
236
|
+
info.version = w["version"]
|
|
237
|
+
info.version_source = VersionSource.APP_STORE_WEB.value
|
|
238
|
+
if w["updated"]:
|
|
239
|
+
info.last_updated = to_iso(w["updated"])
|
|
240
|
+
|
|
241
|
+
async def apps(self, app_ids: Iterable[Union[str, int]], country: str = "US", *,
|
|
242
|
+
lang: Optional[str] = None) -> dict[str, Union[AppStoreApp, Exception]]:
|
|
243
|
+
"""Many apps, one country, batched into as few lookup calls as possible."""
|
|
244
|
+
ids = [_clean_id(i) for i in app_ids]
|
|
245
|
+
country = normalize_country(country, APP_STORE_COUNTRIES, "App Store")
|
|
246
|
+
results = await self.lookup(ids, country, lang)
|
|
247
|
+
by_key: dict[str, dict] = {}
|
|
248
|
+
for r in results:
|
|
249
|
+
by_key[str(r.get("trackId"))] = r
|
|
250
|
+
if r.get("bundleId"):
|
|
251
|
+
by_key[r["bundleId"]] = r
|
|
252
|
+
return {i: (parse_lookup_result(by_key[i], country, lang) if i in by_key
|
|
253
|
+
else AppNotFound(i, country, "app_store")) for i in ids}
|
|
254
|
+
|
|
255
|
+
async def app_all_countries(
|
|
256
|
+
self, app_id: Union[str, int], countries: Optional[Iterable[str]] = None, *,
|
|
257
|
+
lang: Optional[str] = None, verify_with_web: Optional[bool] = None,
|
|
258
|
+
) -> dict[str, Union[AppStoreApp, Exception]]:
|
|
259
|
+
"""``{country: AppStoreApp | AppNotFound | error}`` for every App Store storefront."""
|
|
260
|
+
codes = [normalize_country(c, APP_STORE_COUNTRIES, "App Store") for c in (countries or APP_STORE_COUNTRIES)]
|
|
261
|
+
res = await asyncio.gather(*(self.app(app_id, c, lang=lang, verify_with_web=verify_with_web) for c in codes),
|
|
262
|
+
return_exceptions=True)
|
|
263
|
+
return dict(zip(codes, res))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Storefront country lists.
|
|
2
|
+
|
|
3
|
+
``PLAY_COUNTRIES``: every location on Google's "Supported locations for distribution to
|
|
4
|
+
Google Play users" table (version 2026/01, published 2026-09-28), 225 codes.
|
|
5
|
+
|
|
6
|
+
``APP_STORE_COUNTRIES``: every ISO 3166-1 code accepted by the iTunes lookup API, found by
|
|
7
|
+
probing all 249 ISO codes on 2026-10-24 (invalid storefronts answer HTTP 400), 184 codes.
|
|
8
|
+
|
|
9
|
+
Re-validate them any time with ``python bench/bench.py --validate-countries``.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .errors import InvalidCountry
|
|
14
|
+
|
|
15
|
+
PLAY_COUNTRIES: tuple[str, ...] = tuple("""
|
|
16
|
+
AD AE AF AG AI AL AM AO AQ AR AT AU AW AZ BA BB BD BE BF BG BH BI BJ BM BN BO BQ BR BS BT BV BW
|
|
17
|
+
BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES
|
|
18
|
+
ET FI FJ FK FM FR GA GB GD GE GG GH GI GM GN GQ GR GS GT GW GY HK HM HN HR HT HU ID IE IL IM IN IO
|
|
19
|
+
IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
|
|
20
|
+
MG MK ML MM MN MO MR MS MT MU MV MW MX MY MZ NA NE NF NG NI NL NO NP NR NU NZ OM PA PE PG PH PK PL
|
|
21
|
+
PN PS PT PY QA RO RS RU RW SA SB SC SD SE SG SH SI SK SL SM SN SO SR SS ST SV SZ TC TD TF TG TH TJ
|
|
22
|
+
TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VN VU WS XK YE ZA ZM ZW
|
|
23
|
+
""".split())
|
|
24
|
+
|
|
25
|
+
APP_STORE_COUNTRIES: tuple[str, ...] = tuple("""
|
|
26
|
+
AD AE AF AG AI AL AM AO AR AT AU AZ BA BB BD BE BF BG BH BJ BM BN BO BR BS BT BW BY BZ CA CD CF CG
|
|
27
|
+
CH CI CL CM CN CO CR CV CY CZ DE DK DM DO DZ EC EE EG ES ET FI FJ FM FR GA GB GD GE GH GM GN GR GT
|
|
28
|
+
GW GY HK HN HR HU ID IE IL IN IQ IS IT JM JO JP KE KG KH KN KR KW KY KZ LA LB LC LI LK LR LT LU LV
|
|
29
|
+
LY MA MC MD ME MG MK ML MM MN MO MR MS MT MU MV MW MX MY MZ NA NE NG NI NL NO NP NR NZ OM PA PE PG
|
|
30
|
+
PH PK PL PS PT PW PY QA RO RS RU RW SA SB SC SE SG SI SK SL SN SR ST SV SZ TC TD TH TJ TM TN TO TR
|
|
31
|
+
TT TW TZ UA UG US UY UZ VC VE VG VN VU WS XK YE ZA ZM ZW
|
|
32
|
+
""".split())
|
|
33
|
+
|
|
34
|
+
ALL_COUNTRIES: tuple[str, ...] = tuple(sorted(set(PLAY_COUNTRIES) | set(APP_STORE_COUNTRIES)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_country(code: str, allowed: tuple[str, ...] | None = None, store: str = "") -> str:
|
|
38
|
+
"""Upper-case and validate a two-letter country code (``"uk"`` is accepted for ``GB``)."""
|
|
39
|
+
if not isinstance(code, str) or len(code.strip()) != 2:
|
|
40
|
+
raise InvalidCountry(f"{code!r} is not a two-letter country code")
|
|
41
|
+
c = code.strip().upper()
|
|
42
|
+
if c == "UK":
|
|
43
|
+
c = "GB"
|
|
44
|
+
if allowed is not None and c not in allowed:
|
|
45
|
+
raise InvalidCountry(f"{c} is not a {store or 'store'} storefront")
|
|
46
|
+
return c
|