mirror of
https://github.com/sotam0316/docker-py-revanced.git
synced 2026-04-24 19:38:36 +09:00
✨ Add CLI script to register new APKMirror apps [skip ci]
This commit is contained in:
Executable
+334
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""CLI to register a new APKMirror app in the repo.
|
||||
|
||||
This script automates the manual edits required when ReVanced adds support for a
|
||||
new app that's available on APKMirror. It updates:
|
||||
- src/downloader/sources.py: adds the APKMirror source mapping
|
||||
- src/patches.py: maps package name -> app key
|
||||
- README.md: appends the bullet link under the officially supported list
|
||||
|
||||
Usage examples:
|
||||
python scripts/add_apkmirror_app.py \
|
||||
--package com.facebook.katana \
|
||||
--name facebook \
|
||||
--apkmirror-path facebook-2/facebook
|
||||
|
||||
python scripts/add_apkmirror_app.py \
|
||||
--package com.facebook.katana \
|
||||
--name facebook \
|
||||
--apkmirror-url https://www.apkmirror.com/apk/facebook-2/facebook/
|
||||
|
||||
Notes
|
||||
-----
|
||||
- APKMirror only. For other sources, extend the script accordingly.
|
||||
- Idempotent: if entries already exist, it will skip updating that file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORG_APP_PARTS = 2
|
||||
APK_MIRROR_APP_EXISTS_URL = "https://www.apkmirror.com/wp-json/apkm/v1/app_exists/"
|
||||
DEFAULT_USER_AGENT = os.getenv("APKMIRROR_USER_AGENT", "nikhil")
|
||||
DEFAULT_BASIC_AUTH = os.getenv(
|
||||
"APKMIRROR_AUTH_BASIC",
|
||||
# base64("api-apkupdater:rm5rcfruUjKy04sMpyMPJXW8") as provided in the example
|
||||
"YXBpLWFwa3VwZGF0ZXI6cm01cmNmcnVVakt5MDRzTXB5TVBKWFc4",
|
||||
)
|
||||
DEFAULT_HTTP_TIMEOUT_SECS = 20
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse CLI arguments for registering an APKMirror app."""
|
||||
parser = argparse.ArgumentParser(description="Register a new APKMirror app")
|
||||
parser.add_argument("--package", required=True, help="Android package name, e.g., com.facebook.katana")
|
||||
parser.add_argument("--name", required=True, help="Short app key/name used in configs, e.g., facebook")
|
||||
|
||||
apkmirror = parser.add_mutually_exclusive_group(required=False)
|
||||
apkmirror.add_argument(
|
||||
"--apkmirror-path",
|
||||
help="APKMirror path '<org>/<app>' without leading /apk/, e.g., 'facebook-2/facebook'",
|
||||
)
|
||||
apkmirror.add_argument(
|
||||
"--apkmirror-url",
|
||||
help="Full APKMirror app URL, e.g., https://www.apkmirror.com/apk/facebook-2/facebook/",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--apkmirror-auth",
|
||||
default=DEFAULT_BASIC_AUTH,
|
||||
help="Base64 for Basic Authorization header to APKMirror API (env: APKMIRROR_AUTH_BASIC)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-agent",
|
||||
default=DEFAULT_USER_AGENT,
|
||||
help="User-Agent value for APKMirror API (env: APKMIRROR_USER_AGENT)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show planned changes without writing files",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def extract_apkmirror_path(url_or_path: str) -> tuple[str, str]:
|
||||
"""Return (org, app) from a full URL or 'org/app' path.
|
||||
|
||||
Accepted examples:
|
||||
- facebook-2/facebook
|
||||
- https://www.apkmirror.com/apk/facebook-2/facebook/
|
||||
- https://www.apkmirror.com/apk/facebook-2/facebook
|
||||
"""
|
||||
raw = url_or_path.strip()
|
||||
if raw.startswith("http"):
|
||||
# Keep only the path after '/apk/'
|
||||
m = re.search(r"/apk/([^/?#]+)/([^/?#]+)/?", raw)
|
||||
if not m:
|
||||
msg = "Unable to parse APKMirror URL. Expected .../apk/<org>/<app>/."
|
||||
raise ValueError(
|
||||
msg,
|
||||
)
|
||||
org, app = m.group(1), m.group(2)
|
||||
else:
|
||||
# org/app
|
||||
parts = raw.strip("/").split("/")
|
||||
if len(parts) != ORG_APP_PARTS:
|
||||
msg = "--apkmirror-path must be '<org>/<app>'"
|
||||
raise ValueError(msg)
|
||||
org, app = parts
|
||||
return org, app
|
||||
|
||||
|
||||
def discover_apkmirror_path_via_api(package_name: str, auth_b64: str, user_agent: str) -> tuple[str, str]:
|
||||
"""Query APKMirror app_exists API to discover the org/app path for a package.
|
||||
|
||||
Tries `app.link` first, then falls back to `release.link`.
|
||||
Returns (org, app).
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Basic {auth_b64}",
|
||||
"User-Agent": user_agent,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"pnames": [package_name]}
|
||||
resp = requests.post(
|
||||
APK_MIRROR_APP_EXISTS_URL,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=DEFAULT_HTTP_TIMEOUT_SECS,
|
||||
)
|
||||
if resp.status_code != 200: # noqa: PLR2004
|
||||
msg = f"APKMirror app_exists API error: HTTP {resp.status_code}"
|
||||
raise RuntimeError(msg)
|
||||
data = resp.json()
|
||||
items = data.get("data") or []
|
||||
if not items:
|
||||
msg = f"No data returned from APKMirror for {package_name}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
item = items[0]
|
||||
app_link = ((item.get("app") or {}).get("link")) or ((item.get("release") or {}).get("link"))
|
||||
if not app_link:
|
||||
msg = "APKMirror response missing app/release link"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# Expect a path like: /apk/<org>/<app>/...
|
||||
m = re.search(r"/apk/([^/]+)/([^/]+)/", app_link)
|
||||
if not m:
|
||||
msg = "Unable to parse org/app from APKMirror response link"
|
||||
raise RuntimeError(msg)
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
"""Read text from a file using UTF-8 encoding."""
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def write_text(path: Path, content: str) -> None:
|
||||
"""Write text to a file using UTF-8 encoding."""
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def insert_kv_into_dict( # noqa: C901, PLR0912,PLR0915
|
||||
content: str,
|
||||
dict_var_pattern: str,
|
||||
key: str,
|
||||
value_code: str,
|
||||
) -> tuple[str, bool]:
|
||||
r"""Insert a key/value into a Python dict literal for a variable.
|
||||
|
||||
- dict_var_pattern: regex to match the variable assignment line that opens the dict
|
||||
e.g., r"revanced_package_names[\s\S]*?=\s*\{"
|
||||
- key: dictionary key to insert (without quotes)
|
||||
- value_code: full code for the value expression (already quoted/f-string as needed)
|
||||
|
||||
Returns: (new_content, changed)
|
||||
"""
|
||||
# Find dict opening
|
||||
open_match = re.search(dict_var_pattern, content)
|
||||
if not open_match:
|
||||
msg = "Could not locate dictionary with given pattern"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# Find the '{' start index and walk to matching '}'
|
||||
brace_start = content.find("{", open_match.start())
|
||||
if brace_start == -1:
|
||||
msg = "Malformed dictionary start: missing '{'"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
i = brace_start
|
||||
depth = 0
|
||||
in_str: str | None = None
|
||||
esc = False
|
||||
while i < len(content):
|
||||
ch = content[i]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif ch == "\\":
|
||||
esc = True
|
||||
elif ch == in_str:
|
||||
in_str = None
|
||||
elif ch in ('"', "'"):
|
||||
in_str = ch
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
brace_end = i
|
||||
break
|
||||
i += 1
|
||||
else:
|
||||
msg = "Malformed dictionary: missing closing '}'"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# The dictionary body
|
||||
body = content[brace_start + 1 : brace_end]
|
||||
|
||||
# Check if key already exists
|
||||
key_re = re.compile(rf"^[ \t]*\"{re.escape(key)}\"\s*:\s*", re.MULTILINE)
|
||||
if key_re.search(body):
|
||||
return content, False
|
||||
|
||||
# Determine indentation: look for first item, else fallback to 4 spaces more than dict line indent
|
||||
# Get indentation of first item (if any)
|
||||
item_match = re.search(r"^(?P<indent>[ \t]+)\"[^\n]+\"\s*:\s*", body, re.MULTILINE)
|
||||
if item_match:
|
||||
indent = item_match.group("indent")
|
||||
else:
|
||||
# Compute dictionary base indent from line start to '{'
|
||||
line_start = content.rfind("\n", 0, brace_start) + 1
|
||||
base_indent = content[line_start:brace_start].split("\n")[-1]
|
||||
# Count leading spaces/tabs of the line
|
||||
m_leading = re.match(r"^[ \t]*", base_indent)
|
||||
if not m_leading:
|
||||
msg = "Could not determine indentation for dictionary body"
|
||||
raise RuntimeError(msg)
|
||||
leading = m_leading.group(0)
|
||||
indent = leading + " " * 4
|
||||
|
||||
new_entry = f'\n{indent}"{key}": {value_code},'
|
||||
|
||||
# Insert before closing brace
|
||||
new_body = body + new_entry + "\n"
|
||||
new_content = content[: brace_start + 1] + new_body + content[brace_end:]
|
||||
return new_content, True
|
||||
|
||||
|
||||
def update_sources_py(app_key: str, org: str, app: str, *, dry_run: bool) -> bool:
|
||||
"""Update `src/downloader/sources.py` with the APKMirror mapping.
|
||||
|
||||
Returns True if a change was made.
|
||||
"""
|
||||
path = REPO_ROOT / "src" / "downloader" / "sources.py"
|
||||
content = read_text(path)
|
||||
value_code = f'f"{ '{' }APK_MIRROR_BASE_APK_URL{ '}' }/{org}/{app}/"'
|
||||
pattern = r"apk_sources\s*=\s*\{"
|
||||
new_content, changed = insert_kv_into_dict(content, pattern, app_key, value_code)
|
||||
if changed and not dry_run:
|
||||
write_text(path, new_content)
|
||||
return changed
|
||||
|
||||
|
||||
def update_patches_py(package_name: str, app_key: str, *, dry_run: bool) -> bool:
|
||||
"""Update `src/patches.py` with package -> app key mapping.
|
||||
|
||||
Returns True if a change was made.
|
||||
"""
|
||||
path = REPO_ROOT / "src" / "patches.py"
|
||||
content = read_text(path)
|
||||
value_code = f'"{app_key}"'
|
||||
# Match the dict assignment, accommodating type annotations
|
||||
pattern = r"revanced_package_names[\s\S]*?=\s*\{"
|
||||
new_content, changed = insert_kv_into_dict(content, pattern, package_name, value_code)
|
||||
if changed and not dry_run:
|
||||
write_text(path, new_content)
|
||||
return changed
|
||||
|
||||
|
||||
def update_readme_md(app_key: str, org: str, app: str, *, dry_run: bool) -> bool:
|
||||
"""Insert the README bullet link for the new app.
|
||||
|
||||
Returns True if a change was made.
|
||||
"""
|
||||
path = REPO_ROOT / "README.md"
|
||||
content = read_text(path)
|
||||
bullet = f" - [{app_key}](https://www.apkmirror.com/apk/{org}/{app}/)"
|
||||
|
||||
# Check if already present (exact label match, beginning of bullet)
|
||||
exists_pattern = re.compile(r"^\s*-\s*\[" + re.escape(app_key) + r"\]\(", flags=re.MULTILINE)
|
||||
if exists_pattern.search(content):
|
||||
return False
|
||||
|
||||
# Locate the supported list block
|
||||
# Insert before the note that ends the list
|
||||
note = re.search(r"^\s*<br>`\*\*` - You can also patch any other app", content, re.MULTILINE)
|
||||
if note:
|
||||
insert_pos = note.start()
|
||||
new_content = content[:insert_pos] + bullet + "\n" + content[insert_pos:]
|
||||
else:
|
||||
# Fallback: append at end
|
||||
new_content = content.rstrip("\n") + "\n" + bullet + "\n"
|
||||
|
||||
if not dry_run:
|
||||
write_text(path, new_content)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point: parse args, perform updates, and report results."""
|
||||
args = parse_args()
|
||||
|
||||
if args.apkmirror_path or args.apkmirror_url:
|
||||
org, app = extract_apkmirror_path(args.apkmirror_path or args.apkmirror_url)
|
||||
else:
|
||||
org, app = discover_apkmirror_path_via_api(args.package, args.apkmirror_auth, args.user_agent)
|
||||
|
||||
changed_any = False
|
||||
changed_sources = update_sources_py(args.name, org, app, dry_run=args.dry_run)
|
||||
changed_any = changed_any or changed_sources
|
||||
|
||||
changed_patches = update_patches_py(args.package, args.name, dry_run=args.dry_run)
|
||||
changed_any = changed_any or changed_patches
|
||||
|
||||
changed_readme = update_readme_md(args.name, org, app, dry_run=args.dry_run)
|
||||
changed_any = changed_any or changed_readme
|
||||
|
||||
if not changed_any:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user