Skip to content

Commit 28d21a2

Browse files
TheQmaksclaude
andcommitted
Initial release v0.1.0
Multi-source APK downloader with automatic fallback across 6 sources: APK20, F-Droid, APKPure, APKMirror, Uptodown, APKCombo. Features: download, search, info, XAPK-to-APK conversion, CLI + Python API. CI: ruff + mypy lint. PyPI publish on version tags via trusted publisher. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0 parents  commit 28d21a2

21 files changed

Lines changed: 1975 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
9+
jobs:
10+
lint:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: astral-sh/setup-uv@v5
15+
- run: uv sync --group dev
16+
- run: uv run ruff check justapk/
17+
- run: uv run mypy justapk/

.github/workflows/publish.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
jobs:
8+
publish:
9+
runs-on: ubuntu-latest
10+
environment: pypi
11+
permissions:
12+
id-token: write
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: astral-sh/setup-uv@v5
16+
- run: uv build
17+
- uses: pypa/gh-action-pypi-publish@release/v1

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
__pycache__/
2+
*.pyc
3+
*.pyo
4+
*.egg-info/
5+
*.egg
6+
dist/
7+
build/
8+
.eggs/
9+
*.apk
10+
*.xapk
11+
*.apks
12+
.venv/
13+
.env
14+
.idea/
15+
.vscode/
16+
.mypy_cache/
17+
.ruff_cache/
18+
research/
19+
uv.lock

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 TheQmaks
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.

README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<div align="center">
2+
3+
<img src="https://em-content.zobj.net/source/apple/391/package_1f4e6.png" width="80" />
4+
5+
# justapk
6+
7+
**Download any APK by package name. 6 sources, automatic fallback, zero config.**
8+
9+
[![PyPI](https://img.shields.io/pypi/v/justapk?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/justapk/)
10+
[![Python](https://img.shields.io/pypi/pyversions/justapk?logo=python&logoColor=white)](https://pypi.org/project/justapk/)
11+
[![License](https://img.shields.io/github/license/TheQmaks/justapk)](LICENSE)
12+
[![CI](https://img.shields.io/github/actions/workflow/status/TheQmaks/justapk/ci.yml?label=CI&logo=github)](https://github.com/TheQmaks/justapk/actions/workflows/ci.yml)
13+
14+
<br>
15+
16+
```
17+
justapk download org.telegram.messenger
18+
```
19+
20+
*One command. Six sources. Always gets the APK.*
21+
22+
</div>
23+
24+
---
25+
26+
## Why?
27+
28+
Existing APK downloaders break constantly — sites add Cloudflare, change APIs, go offline. **justapk** doesn't care. It cycles through 6 sources automatically until one works. Under the hood it uses reverse-engineered mobile APIs and Cloudflare bypass via TLS fingerprint impersonation.
29+
30+
## Install
31+
32+
```bash
33+
pip install justapk
34+
```
35+
36+
> Python 3.11+
37+
38+
## Usage
39+
40+
### `download` — grab an APK
41+
42+
```bash
43+
justapk download <package> # auto-select best source
44+
justapk download <package> -s apkpure # from a specific source
45+
justapk download <package> -v 11.6.2 # specific version
46+
justapk download <package> -o ./apks/ # custom output directory
47+
justapk download <package> --no-convert # keep XAPK as-is (no merge)
48+
```
49+
50+
### `search` — find apps
51+
52+
```bash
53+
justapk search telegram
54+
justapk search telegram -s fdroid
55+
```
56+
57+
### `info` — app metadata
58+
59+
```bash
60+
justapk info org.telegram.messenger
61+
justapk info org.telegram.messenger -s apkpure
62+
```
63+
64+
### `convert` — XAPK/split APK to single APK
65+
66+
```bash
67+
justapk convert app.xapk
68+
justapk convert app.xapk -o output/
69+
```
70+
71+
Merges split APKs (base + native libs + assets) and signs with a debug key.
72+
73+
### `sources` — list available sources
74+
75+
```bash
76+
justapk sources
77+
```
78+
79+
> All commands output JSON to stdout. Progress goes to stderr — pipe-friendly by design.
80+
81+
## Sources
82+
83+
Tried in this order. If one fails, the next one picks up automatically.
84+
85+
| | Source | How it works | Notes |
86+
|:---:|--------|-------------|-------|
87+
| 1 | **APK20** | REST API + HTML parsing | No Cloudflare |
88+
| 2 | **F-Droid** | JSON API | FOSS apps only (~4K packages) |
89+
| 3 | **APKPure** | Reverse-engineered mobile API | Largest catalog |
90+
| 4 | **APKMirror** | HTML scraping + `curl_cffi` | Cloudflare bypass |
91+
| 5 | **Uptodown** | Reverse-engineered mobile API | No Cloudflare |
92+
| 6 | **APKCombo** | HTML scraping + `curl_cffi` | Cloudflare bypass |
93+
94+
## Python API
95+
96+
```python
97+
from pathlib import Path
98+
from justapk import APKDownloader
99+
100+
dl = APKDownloader()
101+
102+
# Download with auto-fallback
103+
result = dl.download("org.telegram.messenger", output_dir=Path("./apks/"))
104+
print(result.path, result.size, result.sha256)
105+
106+
# Search across all sources
107+
apps = dl.search("telegram")
108+
for app in apps:
109+
print(app.package, app.name, app.source)
110+
111+
# Get app info
112+
info = dl.info("org.telegram.messenger")
113+
if info:
114+
print(info.name, info.version)
115+
116+
# Pin a specific source
117+
result = dl.download("org.telegram.messenger", source="apkpure")
118+
```
119+
120+
## License
121+
122+
[MIT](LICENSE)

justapk/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""justapk — Multi-source APK downloader."""
2+
3+
__version__ = "0.1.1"
4+
5+
from justapk.downloader import APKDownloader
6+
from justapk.models import AppInfo, DownloadResult
7+
8+
__all__ = ["APKDownloader", "AppInfo", "DownloadResult", "__version__"]

justapk/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Allow running as `python -m justapk`."""
2+
3+
from justapk.cli import main
4+
5+
raise SystemExit(main())

justapk/cli.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
import sys
6+
from pathlib import Path
7+
8+
from justapk import __version__
9+
from justapk.downloader import APKDownloader
10+
from justapk.sources import SOURCE_PRIORITY
11+
12+
13+
def main(argv: list[str] | None = None) -> int:
14+
parser = argparse.ArgumentParser(
15+
prog="justapk",
16+
description="Multi-source APK downloader",
17+
)
18+
parser.add_argument("-V", "--version", action="version", version=f"justapk {__version__}")
19+
sub = parser.add_subparsers(dest="command")
20+
21+
# download
22+
dl = sub.add_parser("download", help="Download APK by package name")
23+
dl.add_argument("package", help="Android package name (e.g. org.telegram.messenger)")
24+
dl.add_argument("-s", "--source", choices=SOURCE_PRIORITY, help="Use specific source")
25+
dl.add_argument("-o", "--output", type=Path, default=Path("."), help="Output directory")
26+
dl.add_argument("-v", "--app-version", dest="app_version", help="Specific version to download")
27+
dl.add_argument(
28+
"--no-convert", action="store_true",
29+
help="Do not auto-convert XAPK/split APK to APK",
30+
)
31+
32+
# search
33+
sr = sub.add_parser("search", help="Search for apps")
34+
sr.add_argument("query", help="Search query")
35+
sr.add_argument("-s", "--source", choices=SOURCE_PRIORITY, help="Search in specific source")
36+
37+
# info
38+
inf = sub.add_parser("info", help="Get app metadata")
39+
inf.add_argument("package", help="Android package name")
40+
inf.add_argument("-s", "--source", choices=SOURCE_PRIORITY, help="Use specific source")
41+
42+
# convert
43+
cv = sub.add_parser("convert", help="Convert XAPK/split APK to single APK")
44+
cv.add_argument("file", type=Path, help="Path to .xapk or .apks file")
45+
cv.add_argument("-o", "--output", type=Path, help="Output directory (default: same as input)")
46+
47+
# sources
48+
sub.add_parser("sources", help="List available sources")
49+
50+
args = parser.parse_args(argv)
51+
52+
if not args.command:
53+
parser.print_help()
54+
return 1
55+
56+
try:
57+
if args.command == "sources":
58+
return _cmd_sources()
59+
elif args.command == "download":
60+
return _cmd_download(args)
61+
elif args.command == "search":
62+
return _cmd_search(args)
63+
elif args.command == "info":
64+
return _cmd_info(args)
65+
elif args.command == "convert":
66+
return _cmd_convert(args)
67+
except KeyboardInterrupt:
68+
sys.stderr.write("\nInterrupted.\n")
69+
return 130
70+
except Exception as e:
71+
sys.stderr.write(f"Error: {e}\n")
72+
73+
return 1
74+
75+
76+
def _cmd_sources() -> int:
77+
data = [{"name": name, "priority": i + 1} for i, name in enumerate(SOURCE_PRIORITY)]
78+
print(json.dumps(data, indent=2))
79+
return 0
80+
81+
82+
def _cmd_download(args) -> int:
83+
dl = APKDownloader(auto_convert_xapk=not args.no_convert)
84+
result = dl.download(
85+
package=args.package,
86+
output_dir=args.output,
87+
source=args.source,
88+
version=args.app_version,
89+
)
90+
print(json.dumps(result.to_dict(), indent=2))
91+
return 0
92+
93+
94+
def _cmd_search(args) -> int:
95+
dl = APKDownloader()
96+
results = dl.search(args.query, source=args.source)
97+
data = [r.to_dict() for r in results]
98+
print(json.dumps(data, indent=2))
99+
return 0
100+
101+
102+
def _cmd_convert(args) -> int:
103+
from justapk.utils import sha256_file
104+
from justapk.xapk import convert_xapk_to_apk
105+
106+
xapk = args.file
107+
if not xapk.exists():
108+
sys.stderr.write(f"File not found: {xapk}\n")
109+
return 1
110+
if xapk.suffix.lower() not in (".xapk", ".apks"):
111+
sys.stderr.write(f"Not an XAPK/APKS file: {xapk}\n")
112+
return 1
113+
114+
out_dir = args.output or xapk.parent
115+
apk_path = convert_xapk_to_apk(xapk, out_dir)
116+
print(json.dumps({
117+
"path": str(apk_path),
118+
"size": apk_path.stat().st_size,
119+
"sha256": sha256_file(apk_path),
120+
}, indent=2))
121+
return 0
122+
123+
124+
def _cmd_info(args) -> int:
125+
dl = APKDownloader()
126+
result = dl.info(args.package, source=args.source)
127+
if result:
128+
print(json.dumps(result.to_dict(), indent=2))
129+
return 0
130+
else:
131+
sys.stderr.write(f"No info found for: {args.package}\n")
132+
return 1

0 commit comments

Comments
 (0)