Files
playground/rg_download.py
2026-07-24 15:58:31 +02:00

566 lines
23 KiB
Python
Executable File

#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx"]
# ///
"""
RapidGator (rapidgator.net) downloader — reverse-engineered from JDownloader's RapidGatorNet plugin.
Usage:
./rg_download.py <url> [--user EMAIL] [--pass PASSWORD] [--out DIR] [--2captcha KEY]
Premium accounts use the clean v2 API path (no captcha, full speed).
Free/anonymous downloads scrape the website; reCAPTCHA v2 or Cloudflare Turnstile is
required — a 2captcha API key is strongly recommended (manual solving isn't feasible for
these captcha types).
"""
import re
import sys
import os
import time
import argparse
import getpass
from pathlib import Path
from urllib.parse import urlparse, unquote
import httpx
# ── constants ────────────────────────────────────────────────────────────────
API_BASE = "https://rapidgator.net/api/v2/"
SITE_BASE = "https://rapidgator.net"
SUPPORTED_DOMAINS = {"rapidgator.net", "rapidgator.asia", "rg.to"}
# JD plugin regex: (?i)/file/([a-z0-9]{32}|\d+)
FILE_ID_RE = re.compile(r"(?i)/file/([a-z0-9]{32}|\d+)")
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.8",
}
# ── helpers ──────────────────────────────────────────────────────────────────
def extract_file_id(url: str) -> str | None:
m = FILE_ID_RE.search(url)
return m.group(1) if m else None
def check_domain(url: str):
host = urlparse(url).hostname or ""
host = host.removeprefix("www.")
if host not in SUPPORTED_DOMAINS:
sys.exit(f"Unsupported domain '{host}'. Supported: {', '.join(sorted(SUPPORTED_DOMAINS))}")
def _api_get(session: httpx.Client, endpoint: str, params: dict) -> dict:
"""GET request to the v2 API; raises on HTTP error, returns parsed JSON."""
r = session.get(f"{API_BASE}{endpoint}", params=params, headers=HEADERS, timeout=30)
try:
data = r.json()
except Exception:
r.raise_for_status()
raise
return data
def _api_check(data: dict, context: str = "") -> dict:
"""Raise a RuntimeError if the API returned an error status."""
status = data.get("status", "")
if status != "success":
err = data.get("details") or data.get("error") or data
raise RuntimeError(f"API error{' (' + context + ')' if context else ''}: {err}")
return data.get("details", {})
# ── API calls (mirroring JD's loginAPI / requestFileInformationAPI / handlePremium_api) ──
def api_login(session: httpx.Client, email: str, password: str) -> str:
"""GET /user/login → returns session_id (used as ?token= in all other calls)."""
data = _api_get(session, "user/login", {"login": email, "password": password})
details = _api_check(data, "login")
# JD stores the field named "session_id" (PROPERTY_sessionid constant)
sid = details.get("session_id") or details.get("token")
if not sid:
raise RuntimeError(f"Login succeeded but no session_id in response: {details}")
return sid
def api_user_info(session: httpx.Client, token: str) -> dict:
"""GET /user/info?token= → account details (is_premium, premium_end_time, …)."""
data = _api_get(session, "user/info", {"token": token})
return _api_check(data, "user/info")
def api_file_info(session: httpx.Client, token: str, file_id: str) -> dict:
"""GET /file/info?token=&file_id= → file metadata (name, size, hash, …)."""
data = _api_get(session, "file/info", {"token": token, "file_id": file_id})
details = _api_check(data, "file/info")
# Nested under "file" key
return details.get("file") or details
def api_file_download(session: httpx.Client, token: str, file_id: str) -> str:
"""GET /file/download?token=&file_id= → direct download_url."""
data = _api_get(session, "file/download", {"token": token, "file_id": file_id})
details = _api_check(data, "file/download")
url = details.get("download_url")
if not url:
raise RuntimeError(f"No download_url in response: {details}")
return url
# ── 2captcha ─────────────────────────────────────────────────────────────────
class TwoCaptcha:
"""
Thin wrapper around the 2captcha API v2.
Supports reCAPTCHA v2 (RecaptchaV2TaskProxyless) and
Cloudflare Turnstile (TurnstileTaskProxyless) — the two captcha types
used by RapidGator's free download page.
"""
API = "https://api.2captcha.com"
POLL_INTERVAL = 5
POLL_TIMEOUT = 180
def __init__(self, api_key: str, client: httpx.Client):
self.api_key = api_key
self.client = client
def _call(self, endpoint: str, payload: dict) -> dict:
r = self.client.post(
f"{self.API}{endpoint}",
json={"clientKey": self.api_key, **payload},
headers={"Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
data = r.json()
err_id = data.get("errorId") or 0
if err_id and err_id != 0:
raise RuntimeError(
f"2captcha error: {data.get('errorCode') or data.get('errorDescription') or data}"
)
return data
def _poll(self, task_id: int) -> str:
"""Poll until ready, return solution token."""
print(f" 2captcha task {task_id} submitted, polling…", flush=True)
deadline = time.monotonic() + self.POLL_TIMEOUT
while time.monotonic() < deadline:
time.sleep(self.POLL_INTERVAL)
result = self._call("/getTaskResult", {"taskId": task_id})
if result.get("status") == "ready":
token = result["solution"]["token"]
print(f" 2captcha solved (token length: {len(token)})")
return token
print(" …waiting for solution")
raise RuntimeError(f"2captcha timed out after {self.POLL_TIMEOUT}s")
def solve_recaptcha_v2(self, site_key: str, page_url: str) -> str:
"""Submit a RecaptchaV2TaskProxyless and return the g-recaptcha-response token."""
task_id = self._call("/createTask", {
"task": {
"type": "RecaptchaV2TaskProxyless",
"websiteURL": page_url,
"websiteKey": site_key,
}
})["taskId"]
return self._poll(task_id)
def solve_turnstile(self, site_key: str, page_url: str) -> str:
"""Submit a TurnstileTaskProxyless and return the cf-turnstile-response token."""
task_id = self._call("/createTask", {
"task": {
"type": "TurnstileTaskProxyless",
"websiteURL": page_url,
"websiteKey": site_key,
}
})["taskId"]
return self._poll(task_id)
# ── free download (website scrape) ────────────────────────────────────────────
_RECAPTCHA_RE = re.compile(
r'class=["\']g-recaptcha["\'][^>]*data-sitekey=["\']([^"\']+)["\']'
r'|data-sitekey=["\']([^"\']+)["\'][^>]*class=["\']g-recaptcha["\']'
)
_TURNSTILE_RE = re.compile(
r'class=["\']cf-turnstile["\'][^>]*data-sitekey=["\']([^"\']+)["\']'
r'|data-sitekey=["\']([^"\']+)["\'][^>]*class=["\']cf-turnstile["\']'
)
_DL_URL_RE = re.compile(
r"'(https?://[A-Za-z0-9\-_]+\.[^/]+//\?r=download/index&session_id=[A-Za-z0-9]+)'"
r"|\"(https?://[^/]+/download/[^<>\"']+)\""
)
# X-Requested-With is required by the AJAX endpoints — they return empty without it
_XHR_HEADERS = {"X-Requested-With": "XMLHttpRequest"}
def _first_group(m: re.Match | None) -> str | None:
if not m:
return None
return next((g for g in m.groups() if g), None)
def free_download(
session: httpx.Client,
file_id: str,
file_name: str,
solver: TwoCaptcha | None,
out_dir: Path,
):
"""
Website-based free download flow (mirrors JD handleDownloadWebsite / free path).
Actual page flow discovered by inspection:
1. GET /file/<hex_id> → server sets PHPSESSID + file_id cookies;
page JS has: var fid = <numeric>; var secs = 180;
var startTimerUrl = '/download/AjaxStartTimer';
2. GET /download/AjaxStartTimer?fid=<numeric> (XHR)
→ {"state":"started", "sid":"<token>"}
3. Wait `secs` seconds (free-tier countdown)
4. GET /download/AjaxGetDownloadLink?sid=<token> (XHR)
→ poll until {"state":"done"}
5. GET /download/captcha → HTML with reCAPTCHA v2 or Turnstile widget
6. POST /download/captcha with solved token
→ redirect to the actual download URL OR
response body contains it
"""
file_url = f"{SITE_BASE}/file/{file_id}"
# ── step 1: load file page ────────────────────────────────────────────────
print(f"Loading file page: {file_url}")
r = session.get(file_url, headers=HEADERS, timeout=30)
r.raise_for_status()
page_html = r.text
# Check for premium-only direct link already embedded (logged-in premium path)
pm = re.search(r"var\s+premium_download_link\s*=\s*'(https?://[^']+)'", page_html)
if pm and pm.group(1):
stream_download(session, pm.group(1), out_dir, file_name)
return
# Extract numeric fid (set by server in page JS and cookie, distinct from hex URL ID)
fid_m = re.search(r"var\s+fid\s*=\s*(\d+)", page_html)
if not fid_m:
sys.exit("Could not find numeric fid in page JS — page layout may have changed.")
numeric_fid = fid_m.group(1)
# Extract client-side wait timer (default 180 for free users)
secs_m = re.search(r"var\s+secs\s*=\s*(\d+)", page_html)
wait_secs = int(secs_m.group(1)) if secs_m else 180
print(f" Numeric fid={numeric_fid}, wait={wait_secs}s")
if wait_secs > 600:
sys.exit(f"Server wait is {wait_secs}s — IP appears rate-limited. Try again later.")
# ── step 2: start the server-side timer ──────────────────────────────────
print("Starting download timer…")
r2 = session.get(
f"{SITE_BASE}/download/AjaxStartTimer",
params={"fid": numeric_fid},
headers={**HEADERS, **_XHR_HEADERS, "Referer": file_url},
timeout=30,
)
r2.raise_for_status()
try:
timer_data = r2.json()
except Exception:
sys.exit(f"AjaxStartTimer returned non-JSON: {r2.text[:300]!r}")
if timer_data.get("state") == "error":
sys.exit(f"AjaxStartTimer error: {timer_data.get('code') or timer_data}")
if timer_data.get("state") != "started":
sys.exit(f"Unexpected AjaxStartTimer state: {timer_data}")
sid = timer_data.get("sid")
if not sid:
sys.exit(f"AjaxStartTimer response missing sid: {timer_data}")
print(f" sid={sid}")
# ── step 3: wait for the server countdown ────────────────────────────────
print(f" Waiting {wait_secs}s…", flush=True)
for remaining in range(wait_secs, 0, -5):
time.sleep(min(5, remaining))
print(f"\r {remaining - min(5, remaining)}s remaining… ", end="", flush=True)
print()
# ── step 4: confirm server is ready ──────────────────────────────────────
print("Confirming download ready…")
for attempt in range(10):
r3 = session.get(
f"{SITE_BASE}/download/AjaxGetDownloadLink",
params={"sid": sid},
headers={**HEADERS, **_XHR_HEADERS, "Referer": file_url},
timeout=30,
)
r3.raise_for_status()
try:
link_data = r3.json()
except Exception:
sys.exit(f"AjaxGetDownloadLink returned non-JSON: {r3.text[:300]!r}")
dl_state = link_data.get("state")
if dl_state == "done":
break
if dl_state == "error":
code = link_data.get("code", "")
if "wait" in str(code).lower():
print(f" Server not ready yet, retrying in 5s… ({code})")
time.sleep(5)
continue
sys.exit(f"AjaxGetDownloadLink error: {link_data}")
print(f" Unexpected state '{dl_state}', retrying…")
time.sleep(5)
else:
sys.exit(f"AjaxGetDownloadLink never returned done. Last response: {link_data}")
# ── step 5: load captcha page ─────────────────────────────────────────────
captcha_url = f"{SITE_BASE}/download/captcha"
print("Loading captcha page…")
r4 = session.get(captcha_url, headers={**HEADERS, "Referer": file_url}, timeout=30)
r4.raise_for_status()
captcha_html = r4.text
if not captcha_html.strip():
sys.exit("Captcha page returned empty — session may have expired. Try again.")
# Detect captcha type and extract site key
recaptcha_key = _first_group(_RECAPTCHA_RE.search(captcha_html))
turnstile_key = _first_group(_TURNSTILE_RE.search(captcha_html))
# Also extract CSRF token from <meta name="__token"> if present
csrf_m = re.search(r'<meta\s+name=["\']__token["\']\s+content=["\']([^"\']+)["\']', captcha_html)
csrf_token = csrf_m.group(1) if csrf_m else None
# Extract any hidden form fields
hidden_fields: dict[str, str] = {}
for hm in re.finditer(r'<input[^>]+type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\'][^>]*value=["\']([^"\']*)["\']', captcha_html, re.I):
hidden_fields[hm.group(1)] = hm.group(2)
for hm in re.finditer(r'<input[^>]+name=["\']([^"\']+)["\'][^>]+type=["\']hidden["\'][^>]*value=["\']([^"\']*)["\']', captcha_html, re.I):
hidden_fields[hm.group(1)] = hm.group(2)
if recaptcha_key:
captcha_type = "reCAPTCHA v2"
captcha_field = "g-recaptcha-response"
site_key = recaptcha_key
elif turnstile_key:
captcha_type = "Cloudflare Turnstile"
captcha_field = "cf-turnstile-response"
site_key = turnstile_key
else:
sys.exit(
"No reCAPTCHA v2 or Turnstile found on captcha page.\n"
f"Page excerpt: {captcha_html[:500]}"
)
print(f" Detected {captcha_type} (sitekey: {site_key[:24]}…)")
if not solver:
sys.exit(
f"{captcha_type} detected — a --2captcha key is required for free downloads.\n"
"Alternatively use a premium account (--user / --pass)."
)
if recaptcha_key:
captcha_token = solver.solve_recaptcha_v2(site_key, file_url)
else:
captcha_token = solver.solve_turnstile(site_key, file_url)
# ── step 6: submit captcha form ───────────────────────────────────────────
print("Submitting captcha…")
form_data: dict = {
**hidden_fields,
captcha_field: captcha_token,
"DownloadCaptchaForm[verifyCode]": captcha_token,
}
if csrf_token:
form_data["_csrf"] = csrf_token
r5 = session.post(
captcha_url,
data=form_data,
headers={**HEADERS, "Referer": captcha_url},
timeout=30,
)
r5.raise_for_status()
# The final URL may come from a redirect the client followed, or be in the body
final_url = str(r5.url)
if "/download/" in final_url or final_url.startswith("http") and "rapidgator" not in final_url:
stream_download(session, final_url, out_dir, file_name)
return
# Search for download URL in response body
dl_m = _DL_URL_RE.search(r5.text)
if dl_m:
stream_download(session, _first_group(dl_m), out_dir, file_name)
return
# Last-resort: look for any https link pointing outside rapidgator (CDN URL)
cdn_m = re.search(r"https?://[a-z0-9\-]+\.rapidgator\.net/[^\s\"'<>]+", r5.text)
if cdn_m:
stream_download(session, cdn_m.group(0), out_dir, file_name)
return
sys.exit(
f"Could not extract download URL after captcha submission.\n"
f"Response URL: {final_url}\n"
f"Body excerpt: {r5.text[:500]}"
)
# ── download ──────────────────────────────────────────────────────────────────
def _filename_from_response(r: httpx.Response, url: str, fallback: str) -> str:
cd = r.headers.get("content-disposition", "")
if cd:
m = re.search(r"filename\*=UTF-8''([^;\r\n]+)", cd, re.I)
if m:
return unquote(m.group(1).strip().strip('"'))
m = re.search(r'filename="?([^";\r\n]+)"?', cd, re.I)
if m:
return m.group(1).strip()
path = urlparse(url).path
name = Path(path).name
return unquote(name) if name else (fallback or "download")
def stream_download(session: httpx.Client, url: str, dest: Path, fallback_name: str = ""):
# Enforce HTTPS (JD plugin does this explicitly)
if url.startswith("http://"):
url = "https://" + url[7:]
dest.mkdir(parents=True, exist_ok=True)
with session.stream("GET", url, headers=HEADERS, timeout=None, follow_redirects=True) as r:
r.raise_for_status()
filename = _filename_from_response(r, url, fallback_name)
out_path = dest / filename
print(f"Downloading → {out_path}")
total = int(r.headers.get("content-length", 0))
done = 0
with open(out_path, "wb") as f:
for chunk in r.iter_bytes(chunk_size=65536):
f.write(chunk)
done += len(chunk)
if total:
pct = done * 100 // total
print(f"\r {pct}% {done // 1024} / {total // 1024} KB", end="", flush=True)
print(f"\nDone: {out_path}")
# ── main flow ─────────────────────────────────────────────────────────────────
def download(
url: str,
username: str | None,
password: str | None,
out_dir: Path,
twocaptcha_key: str | None = None,
):
check_domain(url)
file_id = extract_file_id(url)
if not file_id:
sys.exit(f"Could not extract file ID from: {url}")
session = httpx.Client(follow_redirects=True)
token: str | None = None
solver = TwoCaptcha(twocaptcha_key, session) if twocaptcha_key else None
# ── 1. authenticate (premium path) ────────────────────────────────────────
if username and password:
print(f"Logging in as {username} …")
try:
token = api_login(session, username, password)
print("Logged in OK.")
except RuntimeError as e:
msg = str(e).lower()
if "wrong" in msg or "password" in msg or "login" in msg:
sys.exit(f"Login failed: {e}")
raise
# ── 2. check file ─────────────────────────────────────────────────────────
if token:
print(f"Checking file {file_id} via API…")
try:
info = api_file_info(session, token, file_id)
except RuntimeError as e:
sys.exit(f"File info failed: {e}")
name = info.get("name") or file_id
size = info.get("size") or 0
hash_ = info.get("hash") or info.get("md5") or ""
print(f" Name : {name}")
print(f" Size : {size // 1024 // 1024} MB" if size else " Size : unknown")
if hash_:
print(f" MD5 : {hash_}")
# ── 3. get download URL (premium API) ─────────────────────────────────
print("Requesting download URL…")
try:
direct_url = api_file_download(session, token, file_id)
except RuntimeError as e:
msg = str(e).lower()
if "daily" in msg or "limit" in msg:
sys.exit(f"Download limit reached: {e}")
sys.exit(f"Could not get download URL: {e}")
# ── 4. download ───────────────────────────────────────────────────────
stream_download(session, direct_url, out_dir, name)
else:
# ── free / anonymous path — website scrape + captcha ──────────────────
print("No credentials provided — attempting free download (captcha required).")
# Get file name from page if possible (best effort)
name = file_id
try:
r0 = session.get(f"{SITE_BASE}/file/{file_id}", headers=HEADERS, timeout=15)
title_m = re.search(r"<title>\s*Download file\s*([^<>\"]+)</title>", r0.text, re.I)
if title_m:
name = title_m.group(1).strip()
except Exception:
pass
free_download(session, file_id, name, solver, out_dir)
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="RapidGator downloader")
parser.add_argument("url", help="rapidgator.net / rg.to file URL")
parser.add_argument("--user", "-u", default=os.environ.get("RG_USER"), help="Account email")
parser.add_argument("--pass", "-p", dest="password",
default=os.environ.get("RG_PASS"), help="Account password")
parser.add_argument("--out", "-o", default=None, help="Output directory (default: current directory)")
parser.add_argument("--2captcha", dest="twocaptcha_key",
default=os.environ.get("TWOCAPTCHA_API_KEY"),
help="2captcha API key (or set TWOCAPTCHA_API_KEY env var)")
args = parser.parse_args()
password = args.password
if args.user and not password:
password = getpass.getpass(f"Password for {args.user}: ")
download(
url=args.url,
username=args.user,
password=password,
out_dir=Path(args.out) if args.out else Path.cwd(),
twocaptcha_key=args.twocaptcha_key,
)
if __name__ == "__main__":
main()