rustore-apk 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.
- rustore_apk-0.1.0/LICENSE +21 -0
- rustore_apk-0.1.0/PKG-INFO +86 -0
- rustore_apk-0.1.0/README.md +63 -0
- rustore_apk-0.1.0/pyproject.toml +57 -0
- rustore_apk-0.1.0/rustore_apk/__init__.py +3 -0
- rustore_apk-0.1.0/rustore_apk/cli.py +71 -0
- rustore_apk-0.1.0/rustore_apk/client.py +211 -0
- rustore_apk-0.1.0/rustore_apk/py.typed +0 -0
- rustore_apk-0.1.0/rustore_apk.egg-info/PKG-INFO +86 -0
- rustore_apk-0.1.0/rustore_apk.egg-info/SOURCES.txt +13 -0
- rustore_apk-0.1.0/rustore_apk.egg-info/dependency_links.txt +1 -0
- rustore_apk-0.1.0/rustore_apk.egg-info/entry_points.txt +2 -0
- rustore_apk-0.1.0/rustore_apk.egg-info/top_level.txt +1 -0
- rustore_apk-0.1.0/setup.cfg +4 -0
- rustore_apk-0.1.0/tests/test_client.py +31 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mffff4
|
|
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.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rustore-apk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Download APKs published in RuStore
|
|
5
|
+
Author: Mffff4
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Mffff4/rustore-apk
|
|
8
|
+
Project-URL: Repository, https://github.com/Mffff4/rustore-apk
|
|
9
|
+
Project-URL: Issues, https://github.com/Mffff4/rustore-apk/issues
|
|
10
|
+
Keywords: rustore,apk,downloader,android
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: System :: Software Distribution
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# rustore-apk
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/rustore-apk/)
|
|
27
|
+
[](https://pypi.org/project/rustore-apk/)
|
|
28
|
+
|
|
29
|
+
A dependency-free CLI for downloading APK artifacts published in [RuStore](https://www.rustore.ru/). It uses Python's standard-library `urllib` only.
|
|
30
|
+
|
|
31
|
+
## Install / run
|
|
32
|
+
|
|
33
|
+
With [uv](https://docs.astral.sh/uv/):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uvx rustore-apk DangerousSlope.apk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A RuStore catalog URL works too:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uvx rustore-apk \
|
|
43
|
+
'https://www.rustore.ru/catalog/app/DangerousSlope.apk' \
|
|
44
|
+
--out ./downloads
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`-o` is short for `--out`. A multi-file split APK bundle is downloaded as separate files into that directory.
|
|
48
|
+
|
|
49
|
+
Show metadata without requesting a download link:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uvx rustore-apk DangerousSlope.apk --info
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Security behavior
|
|
56
|
+
|
|
57
|
+
- Retrieves a temporary download URL through RuStore's public Android-client API flow.
|
|
58
|
+
- Accepts only HTTPS download links returned from RuStore/VK CDN domains.
|
|
59
|
+
- Checks that each downloaded artifact is a ZIP/APK container.
|
|
60
|
+
- Prints SHA-256 for every saved artifact.
|
|
61
|
+
- Does not send account credentials and does not require a RuStore account.
|
|
62
|
+
|
|
63
|
+
TLS verification is on by default. If a minimal container has no valid CA roots, pass a PEM CA bundle:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
rustore-apk DangerousSlope.apk --ca-file /path/to/ca-bundle.pem
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`--insecure` disables TLS certificate validation. It is intended solely to diagnose a broken local CA store and is **not recommended** for routine downloads.
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv sync --dev
|
|
75
|
+
uv run ruff check .
|
|
76
|
+
uv run pytest
|
|
77
|
+
uv build
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Disclaimer
|
|
81
|
+
|
|
82
|
+
This project is an unofficial client and is not affiliated with RuStore or VK. RuStore API behavior and availability may change. Download and use apps only where permitted by their publisher and RuStore's terms.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# rustore-apk
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/rustore-apk/)
|
|
4
|
+
[](https://pypi.org/project/rustore-apk/)
|
|
5
|
+
|
|
6
|
+
A dependency-free CLI for downloading APK artifacts published in [RuStore](https://www.rustore.ru/). It uses Python's standard-library `urllib` only.
|
|
7
|
+
|
|
8
|
+
## Install / run
|
|
9
|
+
|
|
10
|
+
With [uv](https://docs.astral.sh/uv/):
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uvx rustore-apk DangerousSlope.apk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A RuStore catalog URL works too:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uvx rustore-apk \
|
|
20
|
+
'https://www.rustore.ru/catalog/app/DangerousSlope.apk' \
|
|
21
|
+
--out ./downloads
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`-o` is short for `--out`. A multi-file split APK bundle is downloaded as separate files into that directory.
|
|
25
|
+
|
|
26
|
+
Show metadata without requesting a download link:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uvx rustore-apk DangerousSlope.apk --info
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Security behavior
|
|
33
|
+
|
|
34
|
+
- Retrieves a temporary download URL through RuStore's public Android-client API flow.
|
|
35
|
+
- Accepts only HTTPS download links returned from RuStore/VK CDN domains.
|
|
36
|
+
- Checks that each downloaded artifact is a ZIP/APK container.
|
|
37
|
+
- Prints SHA-256 for every saved artifact.
|
|
38
|
+
- Does not send account credentials and does not require a RuStore account.
|
|
39
|
+
|
|
40
|
+
TLS verification is on by default. If a minimal container has no valid CA roots, pass a PEM CA bundle:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
rustore-apk DangerousSlope.apk --ca-file /path/to/ca-bundle.pem
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`--insecure` disables TLS certificate validation. It is intended solely to diagnose a broken local CA store and is **not recommended** for routine downloads.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv sync --dev
|
|
52
|
+
uv run ruff check .
|
|
53
|
+
uv run pytest
|
|
54
|
+
uv build
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Disclaimer
|
|
58
|
+
|
|
59
|
+
This project is an unofficial client and is not affiliated with RuStore or VK. RuStore API behavior and availability may change. Download and use apps only where permitted by their publisher and RuStore's terms.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rustore-apk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Download APKs published in RuStore"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "Mffff4"}]
|
|
13
|
+
keywords = ["rustore", "apk", "downloader", "android"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
22
|
+
"Topic :: System :: Software Distribution",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"build>=1.2",
|
|
29
|
+
"pytest>=8.0",
|
|
30
|
+
"ruff>=0.9",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/Mffff4/rustore-apk"
|
|
35
|
+
Repository = "https://github.com/Mffff4/rustore-apk"
|
|
36
|
+
Issues = "https://github.com/Mffff4/rustore-apk/issues"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
rustore-apk = "rustore_apk.cli:main"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
include = ["rustore_apk*"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.package-data]
|
|
45
|
+
rustore_apk = ["py.typed"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py310"
|
|
50
|
+
exclude = ["build", "dist", "rustore_urllib"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "I", "UP"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ssl
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .client import Client, RuStoreError, package_from_argument, secure_context
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="rustore-apk",
|
|
14
|
+
description="Download APKs published in RuStore.",
|
|
15
|
+
epilog="Examples:\n"
|
|
16
|
+
" rustore-apk DangerousSlope.apk\n"
|
|
17
|
+
" rustore-apk https://www.rustore.ru/catalog/app/DangerousSlope.apk -o downloads\n"
|
|
18
|
+
" rustore-apk DangerousSlope.apk --info",
|
|
19
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("app", help="RuStore catalog URL or Android package name")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-o", "--out", default=".", help="Output directory (default: current directory)"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--info", action="store_true", help="Print app metadata only; do not request download links"
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--timeout", type=float, default=45.0, help="Network timeout in seconds (default: 45)"
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--ca-file",
|
|
33
|
+
metavar="PATH",
|
|
34
|
+
help="PEM CA bundle for minimal containers without system trust roots",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--insecure",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Disable TLS certificate verification (diagnostic use only; not recommended)",
|
|
40
|
+
)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> int:
|
|
45
|
+
args = _parser().parse_args(argv)
|
|
46
|
+
try:
|
|
47
|
+
context = (
|
|
48
|
+
ssl._create_unverified_context() if args.insecure else secure_context(args.ca_file)
|
|
49
|
+
)
|
|
50
|
+
client = Client(args.timeout, context)
|
|
51
|
+
app = client.app(package_from_argument(args.app))
|
|
52
|
+
version = app.version_name or "?"
|
|
53
|
+
print(f"{app.app_name} ({app.package_name}) v{version}")
|
|
54
|
+
if args.info:
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
output = Path(args.out)
|
|
58
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
urls = client.download_urls(app.app_id)
|
|
60
|
+
for index, url in enumerate(urls, start=1):
|
|
61
|
+
target = output / client.file_name_from_url(url, f"{app.package_name}-{index}.apk")
|
|
62
|
+
count, digest = client.download(url, target)
|
|
63
|
+
print(f"saved {target} ({count} bytes)\nsha256 {digest}")
|
|
64
|
+
return 0
|
|
65
|
+
except RuStoreError as exc:
|
|
66
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import http.cookiejar
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
import ssl
|
|
11
|
+
import zipfile
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.error import HTTPError, URLError
|
|
16
|
+
from urllib.parse import unquote, urlparse
|
|
17
|
+
from urllib.request import HTTPCookieProcessor, HTTPSHandler, Request, build_opener
|
|
18
|
+
|
|
19
|
+
HMAC_KEY = base64.b64decode("K+eeiCbnVFnZ71KEVal0g5siHaX6v6drh8upeLgEPoU=")
|
|
20
|
+
APK_CERT_SHA256 = base64.b64decode("Zh8ggo73gN4LebxZ8mowhkMWNV8w5Pkc+hSiB5GDmRQ=")
|
|
21
|
+
BASE_URL = "https://backapi.rustore.ru"
|
|
22
|
+
NONCE_URL = "https://api.rustore.ru/v1/secure/nonce"
|
|
23
|
+
CATALOG_RE = re.compile(r"(?:https?://)?(?:www\.)?rustore\.ru/catalog/app/([^/?#]+)")
|
|
24
|
+
PACKAGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$")
|
|
25
|
+
DEVICE_MANUFACTURER = "Google"
|
|
26
|
+
DEVICE_MODEL = "Pixel 8 Pro"
|
|
27
|
+
DEVICE_HARDWARE = "husky"
|
|
28
|
+
FIRMWARE_VER = "16"
|
|
29
|
+
ANDROID_SDK_VER = "36"
|
|
30
|
+
FIRMWARE_LANG = "ru"
|
|
31
|
+
RUSTORE_VERSION = "1105002"
|
|
32
|
+
USER_AGENT = (
|
|
33
|
+
f"RuStore/1.105.0.2 (Android {FIRMWARE_VER}; SDK {ANDROID_SDK_VER}; "
|
|
34
|
+
f"arm64-v8a; {DEVICE_MANUFACTURER} {DEVICE_MODEL}; {FIRMWARE_LANG})"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RuStoreError(RuntimeError):
|
|
39
|
+
"""Raised when RuStore does not return a usable app or download artifact."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class App:
|
|
44
|
+
app_id: int
|
|
45
|
+
package_name: str
|
|
46
|
+
app_name: str
|
|
47
|
+
version_name: str | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def package_from_argument(value: str) -> str:
|
|
51
|
+
"""Extract a package name from a catalog URL or accept a package name directly."""
|
|
52
|
+
match = CATALOG_RE.search(value.strip())
|
|
53
|
+
package = unquote(match.group(1)) if match else value.strip()
|
|
54
|
+
if not PACKAGE_RE.fullmatch(package):
|
|
55
|
+
raise RuStoreError("Pass a RuStore catalog URL or a valid Android package name")
|
|
56
|
+
return package
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def secure_context(cafile: str | None) -> ssl.SSLContext:
|
|
60
|
+
try:
|
|
61
|
+
return ssl.create_default_context(cafile=cafile)
|
|
62
|
+
except FileNotFoundError as exc:
|
|
63
|
+
raise RuStoreError(f"CA file not found: {cafile}") from exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _java_hash(value: str) -> int:
|
|
67
|
+
result = 0
|
|
68
|
+
for char in value:
|
|
69
|
+
result = (31 * result + ord(char)) & 0xFFFFFFFF
|
|
70
|
+
return result - 0x100000000 if result >= 0x80000000 else result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _device_id() -> str:
|
|
74
|
+
manufacturer, model, hardware = map(
|
|
75
|
+
_java_hash, (DEVICE_MANUFACTURER, DEVICE_MODEL, DEVICE_HARDWARE)
|
|
76
|
+
)
|
|
77
|
+
combined = hardware + ((hardware + ((model + manufacturer * 31) * 31)) * 31)
|
|
78
|
+
combined = (combined + 0x80000000) & 0xFFFFFFFF
|
|
79
|
+
if combined >= 0x80000000:
|
|
80
|
+
combined -= 0x100000000
|
|
81
|
+
return f"{secrets.token_hex(8)}-{combined}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Client:
|
|
85
|
+
def __init__(self, timeout: float, ssl_context: ssl.SSLContext) -> None:
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
self.device_id = _device_id()
|
|
88
|
+
self.signature: str | None = None
|
|
89
|
+
self.opener = build_opener(
|
|
90
|
+
HTTPCookieProcessor(http.cookiejar.CookieJar()), HTTPSHandler(context=ssl_context)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _headers(self, signed: bool = True) -> dict[str, str]:
|
|
94
|
+
headers = {
|
|
95
|
+
"deviceId": self.device_id,
|
|
96
|
+
"firmwareVer": FIRMWARE_VER,
|
|
97
|
+
"androidSdkVer": ANDROID_SDK_VER,
|
|
98
|
+
"deviceManufacturerName": DEVICE_MANUFACTURER,
|
|
99
|
+
"deviceModelName": DEVICE_MODEL,
|
|
100
|
+
"deviceModel": f"{DEVICE_MANUFACTURER} {DEVICE_MODEL}",
|
|
101
|
+
"firmwareLang": FIRMWARE_LANG,
|
|
102
|
+
"ruStoreVerCode": RUSTORE_VERSION,
|
|
103
|
+
"deviceType": "mobile",
|
|
104
|
+
"User-Agent": USER_AGENT,
|
|
105
|
+
}
|
|
106
|
+
if signed and self.signature:
|
|
107
|
+
headers["X-Client-Signature"] = self.signature
|
|
108
|
+
return headers
|
|
109
|
+
|
|
110
|
+
def _open(self, request: Request):
|
|
111
|
+
try:
|
|
112
|
+
return self.opener.open(request, timeout=self.timeout)
|
|
113
|
+
except HTTPError:
|
|
114
|
+
raise
|
|
115
|
+
except URLError as exc:
|
|
116
|
+
raise RuStoreError(f"Network error: {exc.reason}") from exc
|
|
117
|
+
|
|
118
|
+
def _sign(self, force: bool = False) -> None:
|
|
119
|
+
if self.signature and not force:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
request = Request(NONCE_URL, data=b"", method="POST", headers=self._headers(False))
|
|
123
|
+
with self._open(request) as response:
|
|
124
|
+
nonce = json.load(response).get("nonce")
|
|
125
|
+
except HTTPError as exc:
|
|
126
|
+
raise RuStoreError(f"Nonce request failed: HTTP {exc.code}") from exc
|
|
127
|
+
if not nonce:
|
|
128
|
+
raise RuStoreError("RuStore nonce response did not contain nonce")
|
|
129
|
+
digest = hmac.new(
|
|
130
|
+
HMAC_KEY, base64.b64decode(nonce) + APK_CERT_SHA256, hashlib.sha256
|
|
131
|
+
).digest()
|
|
132
|
+
self.signature = base64.b64encode(digest).decode("ascii")
|
|
133
|
+
|
|
134
|
+
def _api(self, method: str, url: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
135
|
+
self._sign()
|
|
136
|
+
raw = json.dumps(body, separators=(",", ":")).encode() if body is not None else None
|
|
137
|
+
|
|
138
|
+
def request() -> Request:
|
|
139
|
+
headers = self._headers()
|
|
140
|
+
if raw is not None:
|
|
141
|
+
headers["Content-Type"] = "application/json; charset=utf-8"
|
|
142
|
+
return Request(url, data=raw, method=method, headers=headers)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
with self._open(request()) as response:
|
|
146
|
+
return json.load(response)
|
|
147
|
+
except HTTPError as exc:
|
|
148
|
+
if exc.code != 419:
|
|
149
|
+
raise RuStoreError(f"RuStore API request failed: HTTP {exc.code}") from exc
|
|
150
|
+
|
|
151
|
+
self._sign(force=True)
|
|
152
|
+
try:
|
|
153
|
+
with self._open(request()) as response:
|
|
154
|
+
return json.load(response)
|
|
155
|
+
except HTTPError as exc:
|
|
156
|
+
raise RuStoreError(f"RuStore API retry failed: HTTP {exc.code}") from exc
|
|
157
|
+
|
|
158
|
+
def app(self, package_name: str) -> App:
|
|
159
|
+
payload = self._api("GET", f"{BASE_URL}/applicationData/overallInfo/{package_name}")
|
|
160
|
+
body = payload.get("body")
|
|
161
|
+
if payload.get("code") != "OK" or not isinstance(body, dict):
|
|
162
|
+
raise RuStoreError(f"Package not found in RuStore: {package_name}")
|
|
163
|
+
return App(
|
|
164
|
+
int(body["appId"]),
|
|
165
|
+
str(body["packageName"]),
|
|
166
|
+
str(body.get("appName") or body["packageName"]),
|
|
167
|
+
body.get("versionName"),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def download_urls(self, app_id: int) -> list[str]:
|
|
171
|
+
payload = self._api(
|
|
172
|
+
"POST",
|
|
173
|
+
f"{BASE_URL}/v3/showcase/apps/download-link",
|
|
174
|
+
{"appId": app_id, "firstInstall": True},
|
|
175
|
+
)
|
|
176
|
+
items = payload.get("downloadUrls") or payload.get("body", {}).get("downloadUrls") or []
|
|
177
|
+
urls = [str(item.get("url", "")) for item in items if isinstance(item, dict)]
|
|
178
|
+
urls = [url[:-4] + ".apk" if url.endswith(".zip") else url for url in urls]
|
|
179
|
+
if not urls:
|
|
180
|
+
raise RuStoreError("RuStore returned no APK download URLs")
|
|
181
|
+
for url in urls:
|
|
182
|
+
parsed = urlparse(url)
|
|
183
|
+
host = parsed.hostname or ""
|
|
184
|
+
if parsed.scheme != "https" or not (
|
|
185
|
+
host.endswith(".rustore.ru") or host.endswith(".vk.ru")
|
|
186
|
+
):
|
|
187
|
+
raise RuStoreError(f"Unexpected download host from RuStore API: {host}")
|
|
188
|
+
return urls
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def file_name_from_url(url: str, fallback: str) -> str:
|
|
192
|
+
name = Path(urlparse(url).path).name or fallback
|
|
193
|
+
return name if name.endswith(".apk") else f"{name}.apk"
|
|
194
|
+
|
|
195
|
+
def download(self, url: str, target: Path) -> tuple[int, str]:
|
|
196
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
digest, total = hashlib.sha256(), 0
|
|
198
|
+
try:
|
|
199
|
+
request = Request(url, headers={"User-Agent": USER_AGENT})
|
|
200
|
+
with self._open(request) as response, target.open("wb") as output:
|
|
201
|
+
while chunk := response.read(256 * 1024):
|
|
202
|
+
output.write(chunk)
|
|
203
|
+
digest.update(chunk)
|
|
204
|
+
total += len(chunk)
|
|
205
|
+
except HTTPError as exc:
|
|
206
|
+
target.unlink(missing_ok=True)
|
|
207
|
+
raise RuStoreError(f"APK download failed: HTTP {exc.code}") from exc
|
|
208
|
+
if not zipfile.is_zipfile(target):
|
|
209
|
+
target.unlink(missing_ok=True)
|
|
210
|
+
raise RuStoreError("Downloaded file is not a valid APK/ZIP container")
|
|
211
|
+
return total, digest.hexdigest()
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rustore-apk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Download APKs published in RuStore
|
|
5
|
+
Author: Mffff4
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Mffff4/rustore-apk
|
|
8
|
+
Project-URL: Repository, https://github.com/Mffff4/rustore-apk
|
|
9
|
+
Project-URL: Issues, https://github.com/Mffff4/rustore-apk/issues
|
|
10
|
+
Keywords: rustore,apk,downloader,android
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: System :: Software Distribution
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# rustore-apk
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/rustore-apk/)
|
|
27
|
+
[](https://pypi.org/project/rustore-apk/)
|
|
28
|
+
|
|
29
|
+
A dependency-free CLI for downloading APK artifacts published in [RuStore](https://www.rustore.ru/). It uses Python's standard-library `urllib` only.
|
|
30
|
+
|
|
31
|
+
## Install / run
|
|
32
|
+
|
|
33
|
+
With [uv](https://docs.astral.sh/uv/):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uvx rustore-apk DangerousSlope.apk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A RuStore catalog URL works too:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uvx rustore-apk \
|
|
43
|
+
'https://www.rustore.ru/catalog/app/DangerousSlope.apk' \
|
|
44
|
+
--out ./downloads
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`-o` is short for `--out`. A multi-file split APK bundle is downloaded as separate files into that directory.
|
|
48
|
+
|
|
49
|
+
Show metadata without requesting a download link:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uvx rustore-apk DangerousSlope.apk --info
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Security behavior
|
|
56
|
+
|
|
57
|
+
- Retrieves a temporary download URL through RuStore's public Android-client API flow.
|
|
58
|
+
- Accepts only HTTPS download links returned from RuStore/VK CDN domains.
|
|
59
|
+
- Checks that each downloaded artifact is a ZIP/APK container.
|
|
60
|
+
- Prints SHA-256 for every saved artifact.
|
|
61
|
+
- Does not send account credentials and does not require a RuStore account.
|
|
62
|
+
|
|
63
|
+
TLS verification is on by default. If a minimal container has no valid CA roots, pass a PEM CA bundle:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
rustore-apk DangerousSlope.apk --ca-file /path/to/ca-bundle.pem
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`--insecure` disables TLS certificate validation. It is intended solely to diagnose a broken local CA store and is **not recommended** for routine downloads.
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv sync --dev
|
|
75
|
+
uv run ruff check .
|
|
76
|
+
uv run pytest
|
|
77
|
+
uv build
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Disclaimer
|
|
81
|
+
|
|
82
|
+
This project is an unofficial client and is not affiliated with RuStore or VK. RuStore API behavior and availability may change. Download and use apps only where permitted by their publisher and RuStore's terms.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
rustore_apk/__init__.py
|
|
5
|
+
rustore_apk/cli.py
|
|
6
|
+
rustore_apk/client.py
|
|
7
|
+
rustore_apk/py.typed
|
|
8
|
+
rustore_apk.egg-info/PKG-INFO
|
|
9
|
+
rustore_apk.egg-info/SOURCES.txt
|
|
10
|
+
rustore_apk.egg-info/dependency_links.txt
|
|
11
|
+
rustore_apk.egg-info/entry_points.txt
|
|
12
|
+
rustore_apk.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rustore_apk
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from rustore_apk.client import Client, RuStoreError, package_from_argument
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_accepts_catalog_url() -> None:
|
|
5
|
+
assert (
|
|
6
|
+
package_from_argument("https://www.rustore.ru/catalog/app/DangerousSlope.apk")
|
|
7
|
+
== "DangerousSlope.apk"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_accepts_package_name() -> None:
|
|
12
|
+
assert package_from_argument("com.example.app") == "com.example.app"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_rejects_non_package_argument() -> None:
|
|
16
|
+
try:
|
|
17
|
+
package_from_argument("not a package")
|
|
18
|
+
except RuStoreError:
|
|
19
|
+
return
|
|
20
|
+
raise AssertionError("invalid package name was accepted")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_filename_from_url() -> None:
|
|
24
|
+
assert (
|
|
25
|
+
Client.file_name_from_url("https://static-m.rustore.ru/path/app.apk", "fallback.apk")
|
|
26
|
+
== "app.apk"
|
|
27
|
+
)
|
|
28
|
+
assert (
|
|
29
|
+
Client.file_name_from_url("https://static-m.rustore.ru/path/app", "fallback.apk")
|
|
30
|
+
== "app.apk"
|
|
31
|
+
)
|