mobile-store-scraper 0.1.0__tar.gz
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-0.1.0/.gitignore +9 -0
- mobile_store_scraper-0.1.0/LICENSE +26 -0
- mobile_store_scraper-0.1.0/PKG-INFO +157 -0
- mobile_store_scraper-0.1.0/README.md +127 -0
- mobile_store_scraper-0.1.0/bench/bench.py +286 -0
- mobile_store_scraper-0.1.0/bench/capture_fixtures.py +62 -0
- mobile_store_scraper-0.1.0/pyproject.toml +51 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/__init__.py +31 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/__main__.py +63 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/appstore.py +263 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/countries.py +46 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/data/devices.properties +990 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/dates.py +84 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/errors.py +45 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/http.py +193 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/models.py +186 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/play/__init__.py +8 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/play/api.py +417 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/play/device.py +171 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/play/proto.py +189 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/play/scraper.py +335 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/play/web.py +195 -0
- mobile_store_scraper-0.1.0/src/mobile_store_scraper/scrape.py +188 -0
- mobile_store_scraper-0.1.0/tests/fixtures/apple_lookup_de.json +40 -0
- mobile_store_scraper-0.1.0/tests/fixtures/apple_web_284882215_de.html +1 -0
- mobile_store_scraper-0.1.0/tests/fixtures/apple_web_310633997_us.html +1 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_api_details_com.android.chrome.bin +0 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_api_details_com.instagram.android.bin +0 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_api_details_com.naturalmotion.customstreetracer2.bin +0 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_api_details_com.whatsapp.bin +0 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_com.android.chrome_US_en.html +4 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_com.instagram.android_JP_ja.html +4 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_com.mojang.minecraftpe_US_en.html +4 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_com.naturalmotion.customstreetracer2_DE_en.html +4 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_com.spotify.music_BR_pt.html +4 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_com.whatsapp_DE_de.html +4 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_com.whatsapp_US_en.html +4 -0
- mobile_store_scraper-0.1.0/tests/fixtures/play_org.telegram.messenger_US_en.html +4 -0
- mobile_store_scraper-0.1.0/tests/test_offline.py +415 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nitro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---------------------------------------------------------------------------
|
|
24
|
+
src/storescraper/data/devices.properties is NOT covered by the MIT license
|
|
25
|
+
above: it is licensed GPL-3.0-or-later (see the SPDX headers in that file;
|
|
26
|
+
copyright The Calyx Institute and AuroraOSS, collected by EFF rs-google-play).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mobile-store-scraper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Google Play + Apple App Store scraper with reliable versions (incl. 'Varies with device' app bundles), unified UTC update dates and every storefront country.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Nitro4CSR/mobile-store-scraper
|
|
6
|
+
Project-URL: Repository, https://github.com/Nitro4CSR/mobile-store-scraper
|
|
7
|
+
Project-URL: Issues, https://github.com/Nitro4CSR/mobile-store-scraper/issues
|
|
8
|
+
Author: Nitro4CSR
|
|
9
|
+
License-Expression: MIT AND GPL-3.0-or-later
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: apk,app store,asyncio,google play,itunes,scraper,version
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Provides-Extra: http2
|
|
28
|
+
Requires-Dist: httpx[http2]>=0.27; extra == 'http2'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# mobile-store-scraper
|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/mobile-store-scraper/) [](https://github.com/Nitro4CSR/mobile-store-scraper/actions/workflows/ci.yml)
|
|
34
|
+
|
|
35
|
+
Async scraper for **Google Play** and the **Apple App Store** with a **reliable version for every app**, even Play apps that only say *"Varies with device"* (multi-APK / app bundles). Dates come back in one **unified UTC format**, **every storefront country** is supported, and every value is a plain **attribute**.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install mobile-store-scraper
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import mobile_store_scraper as mss # the import name uses underscores
|
|
43
|
+
|
|
44
|
+
app = mss.scrape(store="gp", app="com.naturalmotion.customstreetracer2", country="DE")
|
|
45
|
+
|
|
46
|
+
app.version # '6.8.0'
|
|
47
|
+
app.last_updated # '2026-10-14T07:10:38Z'
|
|
48
|
+
app.min_os_ver # 'Android 7.0'
|
|
49
|
+
app.downloads # 82008679 (exact when Play exposes it, else '50000000+')
|
|
50
|
+
app.released # '2016-06-29T15:01:05Z'
|
|
51
|
+
app.publisher # 'Zynga'
|
|
52
|
+
|
|
53
|
+
ios = mss.scrape(store="as", app="net.whatsapp.WhatsApp", country="JP") # bundle id or track id
|
|
54
|
+
ios.version, ios.last_updated, ios.min_os_ver, ios.publisher # ('26.41.75', '2026-10-22T15:42:36Z', 'iOS 15.1', 'WhatsApp Inc.')
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Async:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
app = await mss.scrape_async(store="gp", app="com.android.chrome", country="US")
|
|
61
|
+
|
|
62
|
+
async with mss.Scraper() as s: # reuse connections + caches
|
|
63
|
+
everywhere = await s.scrape_countries("gp", "com.whatsapp") # all 225 Play countries
|
|
64
|
+
many = await s.scrape_many("as", [310633997, 284882215], "DE") # one batched request
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`store` accepts `"gp"`, `"google_play"`, `"play"`, `"android"` or `"as"`, `"app_store"`, `"apple"`, `"ios"`.
|
|
68
|
+
|
|
69
|
+
## Pace: you choose, default is as fast as possible
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
mss.scrape(..., concurrency=None, rate_limit=None) # default: unlimited
|
|
73
|
+
async with mss.Scraper(concurrency=20, rate_limit=10) as s: ... # max 20 in flight, 10 req/s per host
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`concurrency` caps simultaneous requests and `rate_limit` caps requests per second per host. The same options exist on `GooglePlay(...)`, `AppStore(...)`, the CLI (`--concurrency`, `--rate-limit`) and the bench.
|
|
77
|
+
|
|
78
|
+
Failures are handled no matter which pace you pick:
|
|
79
|
+
* A 429 puts the host into a shared, growing cooldown that recovers by itself.
|
|
80
|
+
* A redirect to Google's "unusual traffic" page (`google.com/sorry`) pauses the host and retries, then raises `IpBlocked`.
|
|
81
|
+
* A circuit breaker stops a sweep from prolonging that block.
|
|
82
|
+
|
|
83
|
+
In testing, ~1,500 unpaced Play listing requests within ~4 minutes got an IP flagged for roughly half an hour. If you sweep that hard, set a pace or a `proxy=`.
|
|
84
|
+
|
|
85
|
+
## Result attributes
|
|
86
|
+
|
|
87
|
+
Fields a store doesn't expose are **not defined** on that store's class; for example, the App Store has no `downloads`. Fields a store can expose but didn't for this app are `None`. Dates are ISO-8601 UTC strings, also available as `datetime` via `last_updated_dt` / `released_dt`. `app.to_dict()` and `app["version"]` work too.
|
|
88
|
+
|
|
89
|
+
**Both stores** (`AppData`): `store, app_id, country, language, url, title, version, version_source, last_updated, released, min_os_ver, publisher, developer_id, developer_website, description, release_notes, genre, genre_id, icon, screenshots, score, ratings, price, currency, free, content_rating, raw`
|
|
90
|
+
|
|
91
|
+
**Google Play** (`GooglePlayApp`) adds every key google-play-scraper returns, in snake_case: `summary, description_html, installs, min_installs, real_installs, reviews, histogram, sale, sale_time, original_price, sale_text, offers_iap, in_app_product_price, developer, developer_email, developer_address, privacy_policy, categories, header_image, video, video_image, content_rating_description, ad_supported, contains_ads, comments, last_updated_on`. On top of those it has:
|
|
92
|
+
|
|
93
|
+
* `downloads`
|
|
94
|
+
* `version_code`, `varies_with_device`, `device_versions`, `version_last_updated`
|
|
95
|
+
* `min_sdk`, `target_sdk`, `download_size`, `offered_by`
|
|
96
|
+
|
|
97
|
+
**App Store** (`AppStoreApp`): `track_id, bundle_id, seller, seller_url, developer_url, file_size_bytes, supported_devices, ipad_screenshots, appletv_screenshots, languages, genres, genre_ids, advisories, features, content_advisory_rating, score_current_version, ratings_current_version, formatted_price, is_game_center_enabled, kind`
|
|
98
|
+
|
|
99
|
+
## How versions are resolved
|
|
100
|
+
|
|
101
|
+
**Google Play** (`version_mode`):
|
|
102
|
+
|
|
103
|
+
* `"auto"` *(default)*:
|
|
104
|
+
* The version comes from the public listing.
|
|
105
|
+
* When the listing has none ("Varies with device"), the **Play Android API** is asked instead. That's `android.clients.google.com/fdfe/details`, the API the Play Store app uses, queried for the build a concrete device would install (default: Pixel 9a, Android 15, arm64).
|
|
106
|
+
* The same API call fills `min_os_ver`, exact `downloads`, `target_sdk` and `download_size` when the listing lacks them.
|
|
107
|
+
* `"web"`: listing only, never logs in.
|
|
108
|
+
* `"api"`: always asks the API too, which adds `version_code` for every app.
|
|
109
|
+
* `"all_devices"`: queries several device profiles. Every build lands in `device_versions`, and `version` is the highest.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
app = mss.scrape("gp", "com.google.android.youtube", "US", version_mode="all_devices")
|
|
113
|
+
[(d.device, d.version) for d in app.device_versions]
|
|
114
|
+
# [('px_9a','21.42.243'), ('sm_s25u','21.41.526'), ('px_tablet','21.32.4'), ('google_kiwi_x86_64','21.43.523'), ...]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The API is logged into anonymously with a token from Aurora Store's community dispenser (`auroraoss.com`). The session is cached in your user cache directory, so it is only requested once. You can use your own account instead: `mss.Scraper(email="you@gmail.com", aas_token="aas_et/...")`.
|
|
118
|
+
|
|
119
|
+
**App Store**: the iTunes lookup API (up to 150 ids per request). When lookup has no result, the `apps.apple.com` page is used. `verify_with_web=True` cross-checks lookup against the page and keeps the newer version, because lookup's CDN can lag a release by a few hours.
|
|
120
|
+
|
|
121
|
+
## Dates
|
|
122
|
+
|
|
123
|
+
* `last_updated`: the store's *Updated on* / current-version release date.
|
|
124
|
+
* Play: the exact Unix timestamp from the listing, identical across languages and countries. The store's own localized text is in `last_updated_on`, e.g. `"14.10.2026"`.
|
|
125
|
+
* App Store: `currentVersionReleaseDate`.
|
|
126
|
+
* `version_last_updated` (Play, API): the upload date of exactly the build in `version`. It can be older than `last_updated`, because Play also bumps *Updated on* for staged rollouts and listing changes.
|
|
127
|
+
|
|
128
|
+
## Countries
|
|
129
|
+
|
|
130
|
+
`mss.PLAY_COUNTRIES` (225, from Google's supported-locations table) and `mss.APP_STORE_COUNTRIES` (184, every ISO code Apple's lookup accepts, probed live).
|
|
131
|
+
|
|
132
|
+
`scrape_countries()` returns `{country: result | AppNotFound | other exception}`. The Play API is called once per device, not once per country.
|
|
133
|
+
|
|
134
|
+
## CLI
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
mobile-store-scraper gp com.naturalmotion.customstreetracer2 -c DE
|
|
138
|
+
mobile-store-scraper as 310633997 -c all --concurrency 20 --rate-limit 10
|
|
139
|
+
mobile-store-scraper gp com.google.android.youtube -m all_devices --json
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Bench / tests
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
python bench/bench.py --quick # live acceptance run, few countries
|
|
146
|
+
python bench/bench.py # all countries, both stores, unlimited pace
|
|
147
|
+
python bench/bench.py --concurrency 8 --rate-limit 10
|
|
148
|
+
python bench/bench.py --validate-countries
|
|
149
|
+
python bench/capture_fixtures.py # refresh offline fixtures
|
|
150
|
+
pytest # offline tests against captured real responses
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
The code is MIT. The bundled Android device profiles (`src/mobile_store_scraper/data/devices.properties`) are GPL-3.0-or-later, as marked in that file. They come from EFF's rs-google-play and originate with the Calyx Institute and AuroraOSS.
|
|
156
|
+
|
|
157
|
+
This project is not affiliated with Google, Apple or AuroraOSS. Respect the stores' terms of service and the dispenser's fair use.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# mobile-store-scraper
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/mobile-store-scraper/) [](https://github.com/Nitro4CSR/mobile-store-scraper/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Async scraper for **Google Play** and the **Apple App Store** with a **reliable version for every app**, even Play apps that only say *"Varies with device"* (multi-APK / app bundles). Dates come back in one **unified UTC format**, **every storefront country** is supported, and every value is a plain **attribute**.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install mobile-store-scraper
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import mobile_store_scraper as mss # the import name uses underscores
|
|
13
|
+
|
|
14
|
+
app = mss.scrape(store="gp", app="com.naturalmotion.customstreetracer2", country="DE")
|
|
15
|
+
|
|
16
|
+
app.version # '6.8.0'
|
|
17
|
+
app.last_updated # '2026-10-14T07:10:38Z'
|
|
18
|
+
app.min_os_ver # 'Android 7.0'
|
|
19
|
+
app.downloads # 82008679 (exact when Play exposes it, else '50000000+')
|
|
20
|
+
app.released # '2016-06-29T15:01:05Z'
|
|
21
|
+
app.publisher # 'Zynga'
|
|
22
|
+
|
|
23
|
+
ios = mss.scrape(store="as", app="net.whatsapp.WhatsApp", country="JP") # bundle id or track id
|
|
24
|
+
ios.version, ios.last_updated, ios.min_os_ver, ios.publisher # ('26.41.75', '2026-10-22T15:42:36Z', 'iOS 15.1', 'WhatsApp Inc.')
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Async:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
app = await mss.scrape_async(store="gp", app="com.android.chrome", country="US")
|
|
31
|
+
|
|
32
|
+
async with mss.Scraper() as s: # reuse connections + caches
|
|
33
|
+
everywhere = await s.scrape_countries("gp", "com.whatsapp") # all 225 Play countries
|
|
34
|
+
many = await s.scrape_many("as", [310633997, 284882215], "DE") # one batched request
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`store` accepts `"gp"`, `"google_play"`, `"play"`, `"android"` or `"as"`, `"app_store"`, `"apple"`, `"ios"`.
|
|
38
|
+
|
|
39
|
+
## Pace: you choose, default is as fast as possible
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
mss.scrape(..., concurrency=None, rate_limit=None) # default: unlimited
|
|
43
|
+
async with mss.Scraper(concurrency=20, rate_limit=10) as s: ... # max 20 in flight, 10 req/s per host
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`concurrency` caps simultaneous requests and `rate_limit` caps requests per second per host. The same options exist on `GooglePlay(...)`, `AppStore(...)`, the CLI (`--concurrency`, `--rate-limit`) and the bench.
|
|
47
|
+
|
|
48
|
+
Failures are handled no matter which pace you pick:
|
|
49
|
+
* A 429 puts the host into a shared, growing cooldown that recovers by itself.
|
|
50
|
+
* A redirect to Google's "unusual traffic" page (`google.com/sorry`) pauses the host and retries, then raises `IpBlocked`.
|
|
51
|
+
* A circuit breaker stops a sweep from prolonging that block.
|
|
52
|
+
|
|
53
|
+
In testing, ~1,500 unpaced Play listing requests within ~4 minutes got an IP flagged for roughly half an hour. If you sweep that hard, set a pace or a `proxy=`.
|
|
54
|
+
|
|
55
|
+
## Result attributes
|
|
56
|
+
|
|
57
|
+
Fields a store doesn't expose are **not defined** on that store's class; for example, the App Store has no `downloads`. Fields a store can expose but didn't for this app are `None`. Dates are ISO-8601 UTC strings, also available as `datetime` via `last_updated_dt` / `released_dt`. `app.to_dict()` and `app["version"]` work too.
|
|
58
|
+
|
|
59
|
+
**Both stores** (`AppData`): `store, app_id, country, language, url, title, version, version_source, last_updated, released, min_os_ver, publisher, developer_id, developer_website, description, release_notes, genre, genre_id, icon, screenshots, score, ratings, price, currency, free, content_rating, raw`
|
|
60
|
+
|
|
61
|
+
**Google Play** (`GooglePlayApp`) adds every key google-play-scraper returns, in snake_case: `summary, description_html, installs, min_installs, real_installs, reviews, histogram, sale, sale_time, original_price, sale_text, offers_iap, in_app_product_price, developer, developer_email, developer_address, privacy_policy, categories, header_image, video, video_image, content_rating_description, ad_supported, contains_ads, comments, last_updated_on`. On top of those it has:
|
|
62
|
+
|
|
63
|
+
* `downloads`
|
|
64
|
+
* `version_code`, `varies_with_device`, `device_versions`, `version_last_updated`
|
|
65
|
+
* `min_sdk`, `target_sdk`, `download_size`, `offered_by`
|
|
66
|
+
|
|
67
|
+
**App Store** (`AppStoreApp`): `track_id, bundle_id, seller, seller_url, developer_url, file_size_bytes, supported_devices, ipad_screenshots, appletv_screenshots, languages, genres, genre_ids, advisories, features, content_advisory_rating, score_current_version, ratings_current_version, formatted_price, is_game_center_enabled, kind`
|
|
68
|
+
|
|
69
|
+
## How versions are resolved
|
|
70
|
+
|
|
71
|
+
**Google Play** (`version_mode`):
|
|
72
|
+
|
|
73
|
+
* `"auto"` *(default)*:
|
|
74
|
+
* The version comes from the public listing.
|
|
75
|
+
* When the listing has none ("Varies with device"), the **Play Android API** is asked instead. That's `android.clients.google.com/fdfe/details`, the API the Play Store app uses, queried for the build a concrete device would install (default: Pixel 9a, Android 15, arm64).
|
|
76
|
+
* The same API call fills `min_os_ver`, exact `downloads`, `target_sdk` and `download_size` when the listing lacks them.
|
|
77
|
+
* `"web"`: listing only, never logs in.
|
|
78
|
+
* `"api"`: always asks the API too, which adds `version_code` for every app.
|
|
79
|
+
* `"all_devices"`: queries several device profiles. Every build lands in `device_versions`, and `version` is the highest.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
app = mss.scrape("gp", "com.google.android.youtube", "US", version_mode="all_devices")
|
|
83
|
+
[(d.device, d.version) for d in app.device_versions]
|
|
84
|
+
# [('px_9a','21.42.243'), ('sm_s25u','21.41.526'), ('px_tablet','21.32.4'), ('google_kiwi_x86_64','21.43.523'), ...]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The API is logged into anonymously with a token from Aurora Store's community dispenser (`auroraoss.com`). The session is cached in your user cache directory, so it is only requested once. You can use your own account instead: `mss.Scraper(email="you@gmail.com", aas_token="aas_et/...")`.
|
|
88
|
+
|
|
89
|
+
**App Store**: the iTunes lookup API (up to 150 ids per request). When lookup has no result, the `apps.apple.com` page is used. `verify_with_web=True` cross-checks lookup against the page and keeps the newer version, because lookup's CDN can lag a release by a few hours.
|
|
90
|
+
|
|
91
|
+
## Dates
|
|
92
|
+
|
|
93
|
+
* `last_updated`: the store's *Updated on* / current-version release date.
|
|
94
|
+
* Play: the exact Unix timestamp from the listing, identical across languages and countries. The store's own localized text is in `last_updated_on`, e.g. `"14.10.2026"`.
|
|
95
|
+
* App Store: `currentVersionReleaseDate`.
|
|
96
|
+
* `version_last_updated` (Play, API): the upload date of exactly the build in `version`. It can be older than `last_updated`, because Play also bumps *Updated on* for staged rollouts and listing changes.
|
|
97
|
+
|
|
98
|
+
## Countries
|
|
99
|
+
|
|
100
|
+
`mss.PLAY_COUNTRIES` (225, from Google's supported-locations table) and `mss.APP_STORE_COUNTRIES` (184, every ISO code Apple's lookup accepts, probed live).
|
|
101
|
+
|
|
102
|
+
`scrape_countries()` returns `{country: result | AppNotFound | other exception}`. The Play API is called once per device, not once per country.
|
|
103
|
+
|
|
104
|
+
## CLI
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
mobile-store-scraper gp com.naturalmotion.customstreetracer2 -c DE
|
|
108
|
+
mobile-store-scraper as 310633997 -c all --concurrency 20 --rate-limit 10
|
|
109
|
+
mobile-store-scraper gp com.google.android.youtube -m all_devices --json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Bench / tests
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
python bench/bench.py --quick # live acceptance run, few countries
|
|
116
|
+
python bench/bench.py # all countries, both stores, unlimited pace
|
|
117
|
+
python bench/bench.py --concurrency 8 --rate-limit 10
|
|
118
|
+
python bench/bench.py --validate-countries
|
|
119
|
+
python bench/capture_fixtures.py # refresh offline fixtures
|
|
120
|
+
pytest # offline tests against captured real responses
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
The code is MIT. The bundled Android device profiles (`src/mobile_store_scraper/data/devices.properties`) are GPL-3.0-or-later, as marked in that file. They come from EFF's rs-google-play and originate with the Calyx Institute and AuroraOSS.
|
|
126
|
+
|
|
127
|
+
This project is not affiliated with Google, Apple or AuroraOSS. Respect the stores' terms of service and the dispenser's fair use.
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Live benchmark / acceptance test for mobile_store_scraper. Hits the real stores from this machine.
|
|
2
|
+
|
|
3
|
+
python bench/bench.py # full run (Play + Apple, all countries)
|
|
4
|
+
python bench/bench.py --quick # a few countries only
|
|
5
|
+
python bench/bench.py --validate-countries # re-probe which country codes each store accepts
|
|
6
|
+
python bench/bench.py --only play|apple
|
|
7
|
+
|
|
8
|
+
Writes a human summary to stdout and the full results to bench/results/<timestamp>.json.
|
|
9
|
+
Exit code is non-zero when an acceptance check fails.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
23
|
+
sys.path.insert(0, str(ROOT / "src"))
|
|
24
|
+
|
|
25
|
+
import mobile_store_scraper as mss # noqa: E402
|
|
26
|
+
from mobile_store_scraper import (APP_STORE_COUNTRIES, PLAY_COUNTRIES, AppNotFound, AppStore, # noqa: E402
|
|
27
|
+
GooglePlay, HttpClient, StoreScraperError, VersionSource)
|
|
28
|
+
|
|
29
|
+
# Apps whose web listing shows a plain version, and apps that are "Varies with device".
|
|
30
|
+
PLAY_FIXED = ["com.whatsapp", "com.mojang.minecraftpe", "com.discord", "org.mozilla.firefox", "com.duolingo"]
|
|
31
|
+
PLAY_VARIES = ["com.android.chrome", "com.google.android.youtube", "com.instagram.android", "com.facebook.katana",
|
|
32
|
+
"com.spotify.music", "com.zhiliaoapp.musically", "com.google.android.gm", "com.netflix.mediaclient",
|
|
33
|
+
"com.google.android.apps.maps", "org.telegram.messenger"]
|
|
34
|
+
APPLE_APPS = ["310633997", "284882215", "net.whatsapp.WhatsApp", "389801252", "324684580", "835599320"]
|
|
35
|
+
SESSION_DIR = ROOT / "bench" / ".sessions"
|
|
36
|
+
PACE: dict = {"concurrency": None, "rate_limit": None} # set from --concurrency / --rate-limit
|
|
37
|
+
|
|
38
|
+
failures: list[str] = []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check(cond: bool, msg: str) -> None:
|
|
42
|
+
print((" PASS " if cond else " FAIL ") + msg)
|
|
43
|
+
if not cond:
|
|
44
|
+
failures.append(msg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fmt(info) -> str:
|
|
48
|
+
code = getattr(info, "version_code", None)
|
|
49
|
+
return (f"{info.app_id:32} v={str(info.version):24} code={str(code):12} src={str(info.version_source):13} "
|
|
50
|
+
f"updated={info.last_updated} min_os={info.min_os_ver} dl={getattr(info, 'downloads', '-')}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def bench_play(quick: bool, out: dict) -> None:
|
|
54
|
+
print("\n=== Spec example: mss.scrape(store='gp', app='com.naturalmotion.customstreetracer2', country='DE') ===")
|
|
55
|
+
t = time.perf_counter()
|
|
56
|
+
app = await mss.scrape_async(store="gp", app="com.naturalmotion.customstreetracer2", country="DE",
|
|
57
|
+
session_dir=SESSION_DIR, **PACE)
|
|
58
|
+
print(f" ({time.perf_counter() - t:.2f}s) {app!r}")
|
|
59
|
+
for k in ("version", "last_updated", "min_os_ver", "downloads", "released", "publisher", "installs",
|
|
60
|
+
"version_source", "varies_with_device", "score", "ratings", "reviews", "genre", "content_rating"):
|
|
61
|
+
print(f" app.{k:20} = {getattr(app, k)!r}")
|
|
62
|
+
out["spec_example"] = app.to_dict()
|
|
63
|
+
check(bool(app.version and app.last_updated and app.min_os_ver and app.downloads and app.released
|
|
64
|
+
and app.publisher), "spec example: all requested attributes present")
|
|
65
|
+
check(app.publisher == "Zynga" and app.min_os_ver.startswith("Android"), "spec example: publisher Zynga, Android min OS")
|
|
66
|
+
|
|
67
|
+
print("\n=== Google Play ===")
|
|
68
|
+
async with GooglePlay(session_dir=SESSION_DIR, **PACE) as gp:
|
|
69
|
+
# 1) auto mode, US -----------------------------------------------------------
|
|
70
|
+
t = time.perf_counter()
|
|
71
|
+
res = await gp.apps(PLAY_FIXED + PLAY_VARIES, "US")
|
|
72
|
+
dt = time.perf_counter() - t
|
|
73
|
+
print(f"[auto/US] {len(res)} apps in {dt:.1f}s (concurrent, incl. API login when needed)")
|
|
74
|
+
out["play_auto_us"] = {}
|
|
75
|
+
for app_id, info in res.items():
|
|
76
|
+
if isinstance(info, Exception):
|
|
77
|
+
print(f" {app_id:32} ERROR {type(info).__name__}: {info}")
|
|
78
|
+
out["play_auto_us"][app_id] = {"error": str(info)}
|
|
79
|
+
continue
|
|
80
|
+
print(" " + fmt(info) + (" [varies]" if info.varies_with_device else "") +
|
|
81
|
+
(f" api_error={info.raw.get('api_error')}" if info.raw.get("api_error") else ""))
|
|
82
|
+
out["play_auto_us"][app_id] = info.to_dict()
|
|
83
|
+
ok = [i for i in res.values() if not isinstance(i, Exception)]
|
|
84
|
+
check(len(ok) == len(res), "every Play app fetched")
|
|
85
|
+
check(all(i.version for i in ok), "every Play app has a version (incl. 'Varies with device' apps)")
|
|
86
|
+
check(all(i.last_updated and i.last_updated.endswith("Z") for i in ok), "every Play app has a UTC last_updated")
|
|
87
|
+
check(all(i.min_os_ver and i.min_os_ver.startswith("Android") for i in ok), "every Play app has min_os_ver")
|
|
88
|
+
check(all(i.downloads for i in ok), "every Play app has downloads")
|
|
89
|
+
check(all(i.publisher for i in ok), "every Play app has publisher")
|
|
90
|
+
no_rel = [i.app_id for i in ok if not i.released]
|
|
91
|
+
print(f" info: released date not published by the store for: {no_rel or 'none'}")
|
|
92
|
+
varies = [i for i in ok if i.varies_with_device]
|
|
93
|
+
check(len(varies) >= 3, f"varies-with-device apps detected ({len(varies)})")
|
|
94
|
+
check(all(i.version_source == VersionSource.PLAY_API.value for i in varies), "varies apps resolved through Play API")
|
|
95
|
+
|
|
96
|
+
# 2) web vs API agreement on fixed-version apps ---------------------------------
|
|
97
|
+
print("[cross-check] web version vs Android API version for single-version apps")
|
|
98
|
+
agree = compared = 0
|
|
99
|
+
for app_id in PLAY_FIXED:
|
|
100
|
+
info = res.get(app_id)
|
|
101
|
+
if isinstance(info, Exception) or not info:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
api = await gp.api_details(app_id)
|
|
105
|
+
except StoreScraperError as exc:
|
|
106
|
+
print(f" {app_id:32} API error {exc}")
|
|
107
|
+
continue
|
|
108
|
+
web_v = info.raw["web"]["version"]
|
|
109
|
+
if web_v is None:
|
|
110
|
+
print(f" {app_id:32} web=<varies with device> api={api.version} ({api.version_code}) (skipped)")
|
|
111
|
+
continue
|
|
112
|
+
compared += 1
|
|
113
|
+
same = api.version == web_v
|
|
114
|
+
agree += same
|
|
115
|
+
print(f" {app_id:32} web={web_v:20} api={api.version} ({api.version_code}) {'==' if same else '!='}")
|
|
116
|
+
check(compared >= 3 and agree == compared, f"web and API agree on {agree}/{compared} single-version apps")
|
|
117
|
+
|
|
118
|
+
# 2b) bulkDetails: many packages in one API request --------------------------------
|
|
119
|
+
t = time.perf_counter()
|
|
120
|
+
bulk = await gp.api().bulk_details(PLAY_VARIES + ["com.does.not.exist.zzz"])
|
|
121
|
+
got = {k: v for k, v in bulk.items() if v is not None}
|
|
122
|
+
print(f"[bulkDetails] {len(bulk)} packages in one request, {time.perf_counter() - t:.2f}s -> "
|
|
123
|
+
f"{len(got)} resolved; missing={[k for k, v in bulk.items() if v is None]}")
|
|
124
|
+
for k, v in list(got.items())[:4]:
|
|
125
|
+
print(f" {k:32} {v.version} ({v.version_code})")
|
|
126
|
+
check(len(got) >= len(PLAY_VARIES) - 1 and bulk["com.does.not.exist.zzz"] is None,
|
|
127
|
+
"bulkDetails resolves real packages and maps unknown ones to None")
|
|
128
|
+
out["play_bulk"] = {k: (v.version if v else None) for k, v in bulk.items()}
|
|
129
|
+
|
|
130
|
+
# 3) all devices ------------------------------------------------------------------
|
|
131
|
+
print("[all_devices] per-device builds")
|
|
132
|
+
out["play_all_devices"] = {}
|
|
133
|
+
for app_id in PLAY_VARIES[:3]:
|
|
134
|
+
t = time.perf_counter()
|
|
135
|
+
dvs = await gp.versions_by_device(app_id)
|
|
136
|
+
print(f" ({time.perf_counter() - t:.1f}s)")
|
|
137
|
+
out["play_all_devices"][app_id] = [d.__dict__ for d in dvs]
|
|
138
|
+
print(f" {app_id}")
|
|
139
|
+
for d in dvs:
|
|
140
|
+
print(f" {d.device:20} sdk={str(d.sdk):4} {d.version or '-':28} {d.version_code or '':>12} {d.error or ''}")
|
|
141
|
+
check(sum(d.version is not None for d in dvs) >= 3, f"{app_id}: >=3 device profiles resolved")
|
|
142
|
+
|
|
143
|
+
# 4) all countries --------------------------------------------------------------
|
|
144
|
+
countries = ["US", "DE", "JP", "BR", "IN", "KR", "GB", "FR"] if quick else list(PLAY_COUNTRIES)
|
|
145
|
+
for app_id in ("com.whatsapp", "com.android.chrome"):
|
|
146
|
+
t = time.perf_counter()
|
|
147
|
+
allc = await gp.app_all_countries(app_id, countries)
|
|
148
|
+
dt = time.perf_counter() - t
|
|
149
|
+
found = {c: i for c, i in allc.items() if not isinstance(i, Exception)}
|
|
150
|
+
missing = [c for c, i in allc.items() if isinstance(i, AppNotFound)]
|
|
151
|
+
errors = {c: f"{type(i).__name__}: {i}" for c, i in allc.items()
|
|
152
|
+
if isinstance(i, Exception) and not isinstance(i, AppNotFound)}
|
|
153
|
+
versions = Counter(i.version for i in found.values())
|
|
154
|
+
updated = Counter(i.last_updated for i in found.values())
|
|
155
|
+
print(f"[all countries] {app_id}: {len(countries)} countries in {dt:.1f}s -> available={len(found)} "
|
|
156
|
+
f"not_listed={len(missing)} errors={len(errors)}")
|
|
157
|
+
print(f" versions={dict(versions)} updated={dict(updated.most_common(3))}")
|
|
158
|
+
if missing:
|
|
159
|
+
print(f" not listed in: {' '.join(sorted(missing))}")
|
|
160
|
+
for c, e in list(errors.items())[:10]:
|
|
161
|
+
print(f" error {c}: {e}")
|
|
162
|
+
out[f"play_all_countries_{app_id}"] = {
|
|
163
|
+
"seconds": dt, "available": len(found), "not_listed": missing, "errors": errors,
|
|
164
|
+
"versions": dict(versions), "updated": dict(updated),
|
|
165
|
+
}
|
|
166
|
+
check(not errors, f"{app_id}: no errors across {len(countries)} Play countries")
|
|
167
|
+
check(all(i.version for i in found.values()), f"{app_id}: version present in every country")
|
|
168
|
+
print(f"[http] web requests={gp.http.stats} api requests={gp._api_http.stats}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def bench_apple(quick: bool, out: dict) -> None:
|
|
172
|
+
print("\n=== Apple App Store ===")
|
|
173
|
+
async with AppStore(**PACE) as ap:
|
|
174
|
+
t = time.perf_counter()
|
|
175
|
+
res = await ap.apps(APPLE_APPS, "US")
|
|
176
|
+
print(f"[batch lookup/US] {len(res)} apps in {time.perf_counter() - t:.2f}s (one request)")
|
|
177
|
+
out["apple_us"] = {}
|
|
178
|
+
for k, info in res.items():
|
|
179
|
+
if isinstance(info, Exception):
|
|
180
|
+
print(f" {k:32} ERROR {info}")
|
|
181
|
+
continue
|
|
182
|
+
print(" " + fmt(info) + f" {info.title}")
|
|
183
|
+
out["apple_us"][k] = info.to_dict()
|
|
184
|
+
ok = [i for i in res.values() if not isinstance(i, Exception)]
|
|
185
|
+
check(len(ok) == len(APPLE_APPS), "every Apple app fetched (numeric ids and bundle ids)")
|
|
186
|
+
check(all(i.version and i.last_updated and i.min_os_ver and i.publisher and i.released for i in ok),
|
|
187
|
+
"every Apple app has version, last_updated, min_os_ver, publisher, released")
|
|
188
|
+
|
|
189
|
+
print("[verify_with_web] lookup vs apps.apple.com")
|
|
190
|
+
for app_id in APPLE_APPS[:3]:
|
|
191
|
+
info = await ap.app(app_id, "US", verify_with_web=True)
|
|
192
|
+
w = info.raw.get("web") or {}
|
|
193
|
+
print(f" {app_id:24} lookup={info.raw['lookup'].get('version'):12} web={w.get('version')} "
|
|
194
|
+
f"web_date={w.get('updated')} chosen={info.version} ({info.version_source})")
|
|
195
|
+
check(w.get("version") is not None, f"{app_id}: web fallback parser finds a version")
|
|
196
|
+
|
|
197
|
+
countries = ["US", "DE", "JP", "BR", "IN", "CN", "GB", "FR"] if quick else list(APP_STORE_COUNTRIES)
|
|
198
|
+
for app_id in ("310633997", "284882215"):
|
|
199
|
+
t = time.perf_counter()
|
|
200
|
+
allc = await ap.app_all_countries(app_id, countries)
|
|
201
|
+
dt = time.perf_counter() - t
|
|
202
|
+
found = {c: i for c, i in allc.items() if not isinstance(i, Exception)}
|
|
203
|
+
missing = [c for c, i in allc.items() if isinstance(i, AppNotFound)]
|
|
204
|
+
errors = {c: f"{type(i).__name__}: {i}" for c, i in allc.items()
|
|
205
|
+
if isinstance(i, Exception) and not isinstance(i, AppNotFound)}
|
|
206
|
+
versions = Counter(i.version for i in found.values())
|
|
207
|
+
print(f"[all countries] {app_id}: {len(countries)} storefronts in {dt:.1f}s -> available={len(found)} "
|
|
208
|
+
f"not_listed={len(missing)} errors={len(errors)} versions={dict(versions)}")
|
|
209
|
+
if missing:
|
|
210
|
+
print(f" not listed in: {' '.join(sorted(missing))}")
|
|
211
|
+
for c, e in list(errors.items())[:10]:
|
|
212
|
+
print(f" error {c}: {e}")
|
|
213
|
+
out[f"apple_all_countries_{app_id}"] = {"seconds": dt, "available": len(found), "not_listed": missing,
|
|
214
|
+
"errors": errors, "versions": dict(versions)}
|
|
215
|
+
check(not errors, f"{app_id}: no errors across {len(countries)} App Store storefronts")
|
|
216
|
+
print(f"[http] {ap.http.stats}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
ISO = ("AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY "
|
|
220
|
+
"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 ET FI FJ FK FM "
|
|
221
|
+
"FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT "
|
|
222
|
+
"JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN "
|
|
223
|
+
"MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT "
|
|
224
|
+
"PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL "
|
|
225
|
+
"TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS XK YE YT ZA ZM ZW").split()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def validate_countries(out: dict) -> None:
|
|
229
|
+
print("\n=== Country validation ===")
|
|
230
|
+
async with HttpClient(concurrency=8) as http:
|
|
231
|
+
async def probe(c: str):
|
|
232
|
+
r = await http.get("https://itunes.apple.com/lookup", params={"id": "310633997", "country": c.lower()})
|
|
233
|
+
return c, r.status_code
|
|
234
|
+
apple = dict(await asyncio.gather(*(probe(c) for c in ISO)))
|
|
235
|
+
valid = sorted(c for c, s in apple.items() if s == 200)
|
|
236
|
+
print(f"Apple accepts {len(valid)} codes")
|
|
237
|
+
added = sorted(set(valid) - set(APP_STORE_COUNTRIES))
|
|
238
|
+
removed = sorted(set(APP_STORE_COUNTRIES) - set(valid))
|
|
239
|
+
print(f" new vs package list: {added} dropped: {removed}")
|
|
240
|
+
check(not added and not removed, "APP_STORE_COUNTRIES matches Apple's live storefronts")
|
|
241
|
+
|
|
242
|
+
async def play(c: str):
|
|
243
|
+
r = await http.get("https://play.google.com/store/apps/details",
|
|
244
|
+
params={"id": "com.google.android.youtube", "hl": "en", "gl": c})
|
|
245
|
+
return c, r.status_code
|
|
246
|
+
p = dict(await asyncio.gather(*(play(c) for c in PLAY_COUNTRIES)))
|
|
247
|
+
bad = {c: s for c, s in p.items() if s != 200}
|
|
248
|
+
print(f"Play: YouTube listing answered 200 in {len(p) - len(bad)}/{len(p)} PLAY_COUNTRIES; non-200: {bad}")
|
|
249
|
+
out["country_validation"] = {"apple_valid": valid, "apple_added": added, "apple_removed": removed, "play_non200": bad}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def main() -> int:
|
|
253
|
+
ap = argparse.ArgumentParser()
|
|
254
|
+
ap.add_argument("--quick", action="store_true")
|
|
255
|
+
ap.add_argument("--only", choices=["play", "apple"])
|
|
256
|
+
ap.add_argument("--validate-countries", action="store_true")
|
|
257
|
+
ap.add_argument("--concurrency", type=int, default=None, help="max simultaneous requests (default unlimited)")
|
|
258
|
+
ap.add_argument("--rate-limit", type=float, default=None, help="requests/second per host (default unlimited)")
|
|
259
|
+
a = ap.parse_args()
|
|
260
|
+
PACE.update(concurrency=a.concurrency, rate_limit=a.rate_limit)
|
|
261
|
+
print(f"pace: concurrency={a.concurrency or 'unlimited'} rate_limit={a.rate_limit or 'unlimited'}")
|
|
262
|
+
out: dict = {"started": datetime.now(timezone.utc).isoformat(), "python": sys.version}
|
|
263
|
+
t0 = time.perf_counter()
|
|
264
|
+
if a.validate_countries:
|
|
265
|
+
await validate_countries(out)
|
|
266
|
+
else:
|
|
267
|
+
if a.only in (None, "play"):
|
|
268
|
+
await bench_play(a.quick, out)
|
|
269
|
+
if a.only in (None, "apple"):
|
|
270
|
+
await bench_apple(a.quick, out)
|
|
271
|
+
out["total_seconds"] = time.perf_counter() - t0
|
|
272
|
+
out["failures"] = failures
|
|
273
|
+
res_dir = ROOT / "bench" / "results"
|
|
274
|
+
res_dir.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
path = res_dir / f"{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
|
|
276
|
+
path.write_text(json.dumps(out, indent=2, default=str, ensure_ascii=False), encoding="utf-8")
|
|
277
|
+
print(f"\nTotal {out['total_seconds']:.1f}s. {len(failures)} failed checks. Results: {path}")
|
|
278
|
+
for f in failures:
|
|
279
|
+
print(" FAILED:", f)
|
|
280
|
+
return 1 if failures else 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
if sys.platform == "win32":
|
|
285
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
286
|
+
sys.exit(asyncio.run(main()))
|