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.
@@ -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