apkscraper 1.0.0__py3-none-any.whl
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.
apkscraper/__init__.py
ADDED
|
File without changes
|
apkscraper/__main__.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from requests.adapters import HTTPAdapter
|
|
3
|
+
from urllib3.util.retry import Retry
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import argparse
|
|
7
|
+
from bs4 import BeautifulSoup
|
|
8
|
+
from urllib.parse import quote_plus
|
|
9
|
+
import sys
|
|
10
|
+
sys.dont_write_bytecode = True
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import subprocess
|
|
15
|
+
import concurrent.futures
|
|
16
|
+
from typing import List, Dict, Optional
|
|
17
|
+
|
|
18
|
+
# ANSI Colors
|
|
19
|
+
C_CYAN = '\033[96m'
|
|
20
|
+
C_GREEN = '\033[92m'
|
|
21
|
+
C_YELLOW = '\033[93m'
|
|
22
|
+
C_RED = '\033[91m'
|
|
23
|
+
C_GRAY = '\033[90m'
|
|
24
|
+
C_RESET = '\033[0m'
|
|
25
|
+
|
|
26
|
+
# Thread lock to prevent progress bars from scrambling in the terminal
|
|
27
|
+
print_lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from tqdm import tqdm
|
|
31
|
+
HAS_TQDM = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
HAS_TQDM = False
|
|
34
|
+
|
|
35
|
+
class BaseScraper:
|
|
36
|
+
def __init__(self, name: str):
|
|
37
|
+
self.name = name
|
|
38
|
+
self.session = requests.Session()
|
|
39
|
+
|
|
40
|
+
retries = Retry(
|
|
41
|
+
total=3,
|
|
42
|
+
backoff_factor=1,
|
|
43
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
44
|
+
allowed_methods=["HEAD", "GET", "OPTIONS", "POST"]
|
|
45
|
+
)
|
|
46
|
+
adapter = HTTPAdapter(max_retries=retries, pool_connections=15, pool_maxsize=15)
|
|
47
|
+
self.session.mount('http://', adapter)
|
|
48
|
+
self.session.mount('https://', adapter)
|
|
49
|
+
|
|
50
|
+
def downloadFile(self, url: str, filename: str) -> bool:
|
|
51
|
+
"""Download file efficiently with resume support and progress."""
|
|
52
|
+
if os.path.exists(filename):
|
|
53
|
+
with print_lock:
|
|
54
|
+
print(f"{C_YELLOW}[i] {self.name}: Skipping {os.path.basename(filename)} (already exists){C_RESET}")
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
part_filename = f"{filename}.part"
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
response = self.session.get(url, stream=True, allow_redirects=True, timeout=20)
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
except requests.RequestException as e:
|
|
63
|
+
with print_lock:
|
|
64
|
+
print(f"\n{C_RED}[!] {self.name}: Failed to download {os.path.basename(filename)}: {e}{C_RESET}")
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
total_size = int(response.headers.get('content-length', 0))
|
|
68
|
+
block_size = 1024 * 1024 # 1MB chunks
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with open(part_filename, 'wb') as f:
|
|
72
|
+
if HAS_TQDM:
|
|
73
|
+
with tqdm(total=total_size, unit='iB', unit_scale=True, desc=f"{C_CYAN}[{self.name}] {os.path.basename(filename)}{C_RESET}", leave=False) as pbar:
|
|
74
|
+
for data in response.iter_content(block_size):
|
|
75
|
+
f.write(data)
|
|
76
|
+
pbar.update(len(data))
|
|
77
|
+
else:
|
|
78
|
+
downloaded = 0
|
|
79
|
+
last_percent = -1
|
|
80
|
+
short_name = os.path.basename(filename)
|
|
81
|
+
if len(short_name) > 20:
|
|
82
|
+
short_name = short_name[:17] + "..."
|
|
83
|
+
|
|
84
|
+
for data in response.iter_content(block_size):
|
|
85
|
+
f.write(data)
|
|
86
|
+
downloaded += len(data)
|
|
87
|
+
if total_size > 0:
|
|
88
|
+
percent = int(100 * downloaded / total_size)
|
|
89
|
+
if percent > last_percent:
|
|
90
|
+
filled = int(40 * downloaded / total_size)
|
|
91
|
+
bar = f"{C_GREEN}โ{C_RESET}" * filled + f"{C_GRAY}-{C_RESET}" * (40 - filled)
|
|
92
|
+
with print_lock:
|
|
93
|
+
sys.stdout.write(f"\r{C_CYAN}[{self.name}]{C_RESET} {short_name:<20} [{bar}] {C_YELLOW}{percent:3d}%{C_RESET}")
|
|
94
|
+
sys.stdout.flush()
|
|
95
|
+
last_percent = percent
|
|
96
|
+
with print_lock:
|
|
97
|
+
print(f"\n{C_GREEN}[+] {self.name}: Completed {os.path.basename(filename)}{C_RESET}")
|
|
98
|
+
|
|
99
|
+
# Atomic rename once completed
|
|
100
|
+
os.rename(part_filename, filename)
|
|
101
|
+
return True
|
|
102
|
+
except IOError as e:
|
|
103
|
+
with print_lock:
|
|
104
|
+
print(f"\n{C_RED}[!] {self.name}: Disk I/O error while saving {filename}: {e}{C_RESET}")
|
|
105
|
+
if os.path.exists(part_filename):
|
|
106
|
+
os.remove(part_filename)
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
class APKPureScraper(BaseScraper):
|
|
110
|
+
def __init__(self):
|
|
111
|
+
super().__init__("APKPure")
|
|
112
|
+
self.session.headers.update({
|
|
113
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
|
|
114
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
115
|
+
'Referer': 'https://apkpure.net/'
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
def search(self, query: str) -> List[Dict]:
|
|
119
|
+
url = f"https://apkpure.net/api/v1/search_suggestion_new?key={quote_plus(query)}&limit=5&type=net"
|
|
120
|
+
headers = {'Accept': 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest'}
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
response = self.session.get(url, headers=headers, timeout=10)
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
results = response.json()
|
|
126
|
+
return [{'name': r.get('title'), 'id': r.get('packageName'), 'url': r.get('url')} for r in results]
|
|
127
|
+
except Exception:
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
def getVersions(self, app_id: str) -> List[Dict]:
|
|
131
|
+
url = f"https://apkpure.net/app/{app_id}/versions"
|
|
132
|
+
try:
|
|
133
|
+
response = self.session.get(url, timeout=15)
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
except Exception:
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
text = response.content.decode('utf-8', errors='ignore')
|
|
139
|
+
soup = BeautifulSoup(text, 'html.parser')
|
|
140
|
+
versions = []
|
|
141
|
+
|
|
142
|
+
version_links = soup.find_all('a', href=re.compile(f"/{re.escape(app_id)}/download/"))
|
|
143
|
+
for link in version_links:
|
|
144
|
+
href = link.get('href')
|
|
145
|
+
if not href: continue
|
|
146
|
+
|
|
147
|
+
version_str = href.split('/')[-1]
|
|
148
|
+
if not any(v['version'] == version_str for v in versions):
|
|
149
|
+
versions.append({
|
|
150
|
+
'version': version_str,
|
|
151
|
+
'url': f"https://apkpure.net{href}",
|
|
152
|
+
'source': self
|
|
153
|
+
})
|
|
154
|
+
return versions
|
|
155
|
+
|
|
156
|
+
def getDownloadLink(self, download_page_url: str) -> Optional[str]:
|
|
157
|
+
try:
|
|
158
|
+
response = self.session.get(download_page_url, timeout=15)
|
|
159
|
+
response.raise_for_status()
|
|
160
|
+
text = response.content.decode('utf-8', errors='ignore')
|
|
161
|
+
match = re.search(r'href="(https://d\.apkpure\.net/b/(?:APK|XAPK)/[^"]+)"', text)
|
|
162
|
+
return match.group(1) if match else None
|
|
163
|
+
except Exception:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
class UptodownScraper(BaseScraper):
|
|
167
|
+
def __init__(self):
|
|
168
|
+
super().__init__("Uptodown")
|
|
169
|
+
self.session.headers.update({
|
|
170
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
|
|
171
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
172
|
+
'Referer': 'https://www.uptodown.com/'
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
def search(self, query: str) -> List[Dict]:
|
|
176
|
+
url = "https://www.uptodown.com/android/en/s"
|
|
177
|
+
try:
|
|
178
|
+
response = self.session.post(url, data={"queryString": query}, timeout=10)
|
|
179
|
+
response.raise_for_status()
|
|
180
|
+
data = response.json()
|
|
181
|
+
if data.get("success") == 1 and "data" in data and "apps" in data["data"]:
|
|
182
|
+
results = []
|
|
183
|
+
for r in data["data"]["apps"]:
|
|
184
|
+
clean_name = re.sub(r'<[^>]+>', '', r.get('name', ''))
|
|
185
|
+
results.append({'name': clean_name, 'id': r.get('url'), 'url': r.get('url')})
|
|
186
|
+
return results
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
def getVersions(self, app_url: str) -> List[Dict]:
|
|
192
|
+
if not app_url.startswith('http'):
|
|
193
|
+
app_url = f"https://{app_url}"
|
|
194
|
+
|
|
195
|
+
versions_url = f"{app_url}/versions"
|
|
196
|
+
try:
|
|
197
|
+
response = self.session.get(versions_url, timeout=15)
|
|
198
|
+
response.raise_for_status()
|
|
199
|
+
except Exception:
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
text = response.content.decode('utf-8', errors='ignore')
|
|
203
|
+
soup = BeautifulSoup(text, 'html.parser')
|
|
204
|
+
versions = []
|
|
205
|
+
|
|
206
|
+
version_divs = soup.find_all('div', attrs={'data-version-id': True, 'data-url': True})
|
|
207
|
+
for div in version_divs:
|
|
208
|
+
version_id = div.get('data-version-id')
|
|
209
|
+
base_url = div.get('data-url')
|
|
210
|
+
extra_url = div.get('data-extra-url', 'descargar')
|
|
211
|
+
|
|
212
|
+
version_span = div.find('span', class_='version')
|
|
213
|
+
version_str = version_span.text.strip() if version_span else version_id
|
|
214
|
+
|
|
215
|
+
versions.append({
|
|
216
|
+
'version': version_str,
|
|
217
|
+
'url': f"{base_url}/{extra_url}/{version_id}",
|
|
218
|
+
'source': self
|
|
219
|
+
})
|
|
220
|
+
return versions
|
|
221
|
+
|
|
222
|
+
def getDownloadLink(self, download_page_url: str) -> Optional[str]:
|
|
223
|
+
try:
|
|
224
|
+
response = self.session.get(download_page_url, timeout=15)
|
|
225
|
+
response.raise_for_status()
|
|
226
|
+
text = response.content.decode('utf-8', errors='ignore')
|
|
227
|
+
soup = BeautifulSoup(text, 'html.parser')
|
|
228
|
+
dl_button = soup.find('button', id='detail-download-button')
|
|
229
|
+
|
|
230
|
+
if dl_button and dl_button.get('data-url'):
|
|
231
|
+
token = dl_button.get('data-url')
|
|
232
|
+
dw_url = f"https://dw.uptodown.com/dwn/{token}"
|
|
233
|
+
|
|
234
|
+
with self.session.get(dw_url, stream=True, timeout=15) as redir_resp:
|
|
235
|
+
redir_resp.raise_for_status()
|
|
236
|
+
content_type = redir_resp.headers.get('content-type', '').lower()
|
|
237
|
+
|
|
238
|
+
if 'text/html' in content_type:
|
|
239
|
+
# Some small HTML page with meta refresh
|
|
240
|
+
html_text = redir_resp.content.decode('utf-8', errors='ignore')
|
|
241
|
+
match = re.search(r'url=\'(https://dw\.uptodown\.net/dwn/[^\']+)\'', html_text)
|
|
242
|
+
return match.group(1) if match else dw_url
|
|
243
|
+
else:
|
|
244
|
+
# It's directly serving the file (e.g. application/octet-stream)
|
|
245
|
+
return redir_resp.url
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
def processVersion(v: Dict, args: argparse.Namespace, title: str) -> None:
|
|
251
|
+
"""Worker function for parallel downloads."""
|
|
252
|
+
scraper = v['source']
|
|
253
|
+
ver_str = v['version']
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
dl_url = scraper.getDownloadLink(v['url'])
|
|
257
|
+
|
|
258
|
+
if not dl_url:
|
|
259
|
+
with print_lock:
|
|
260
|
+
print(f"{C_RED}[!] {scraper.name}: Could not resolve URL for version {ver_str}{C_RESET}")
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
ext = 'xapk' if ('/XAPK/' in dl_url or 'xapk' in dl_url) else 'apk'
|
|
264
|
+
filename = os.path.join(args.dir, f"{title}_{ver_str}_{scraper.name}.{ext}")
|
|
265
|
+
scraper.downloadFile(dl_url, filename)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
with print_lock:
|
|
268
|
+
print(f"{C_RED}[!] {scraper.name}: Unexpected worker error on version {ver_str} - {e}{C_RESET}")
|
|
269
|
+
|
|
270
|
+
def main():
|
|
271
|
+
parser = argparse.ArgumentParser(description="Universal Historical APK Scraper")
|
|
272
|
+
parser.add_argument('query', help="App name to search for")
|
|
273
|
+
parser.add_argument('-a', '--all', action='store_true', help="Download all versions available")
|
|
274
|
+
parser.add_argument('-v', '--version', help="Download specific version")
|
|
275
|
+
parser.add_argument('-d', '--dir', default='.', help="Directory to save downloads")
|
|
276
|
+
parser.add_argument('-s', '--source', choices=['all', 'apkpure', 'uptodown'], default='all', help="Sources to scrape from")
|
|
277
|
+
parser.add_argument('-w', '--workers', type=int, default=4, help="Number of concurrent downloads")
|
|
278
|
+
|
|
279
|
+
args = parser.parse_args()
|
|
280
|
+
|
|
281
|
+
# Bug Bounty Quality of Life: Auto-strip extensions if copy-pasted from scope
|
|
282
|
+
if args.query.lower().endswith('.apk'):
|
|
283
|
+
args.query = args.query[:-4]
|
|
284
|
+
elif args.query.lower().endswith('.xapk'):
|
|
285
|
+
args.query = args.query[:-5]
|
|
286
|
+
|
|
287
|
+
if not os.path.exists(args.dir):
|
|
288
|
+
os.makedirs(args.dir)
|
|
289
|
+
|
|
290
|
+
scrapers = []
|
|
291
|
+
if args.source in ['all', 'apkpure']: scrapers.append(APKPureScraper())
|
|
292
|
+
if args.source in ['all', 'uptodown']: scrapers.append(UptodownScraper())
|
|
293
|
+
|
|
294
|
+
print(f"{C_CYAN}[*] Searching for '{args.query}' across {len(scrapers)} source(s)...{C_RESET}")
|
|
295
|
+
|
|
296
|
+
all_versions = []
|
|
297
|
+
title = args.query.replace(' ', '_')
|
|
298
|
+
package_name = None
|
|
299
|
+
|
|
300
|
+
for scraper in scrapers:
|
|
301
|
+
results = scraper.search(args.query)
|
|
302
|
+
if not results:
|
|
303
|
+
print(f"{C_RED}[-] {scraper.name}: No search results.{C_RESET}")
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
# Exact package name match logic
|
|
307
|
+
app = results[0]
|
|
308
|
+
for r in results:
|
|
309
|
+
if r.get('id') == args.query or r.get('id', '').endswith(f"/{args.query}"):
|
|
310
|
+
app = r
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
app_name = app.get('name') or args.query
|
|
314
|
+
print(f"{C_GREEN}[+] {scraper.name}: Found '{app_name}'{C_RESET}")
|
|
315
|
+
title = app_name.replace(' ', '_')
|
|
316
|
+
|
|
317
|
+
app_id = app.get('id') or args.query
|
|
318
|
+
if scraper.name == "APKPure":
|
|
319
|
+
package_name = app_id
|
|
320
|
+
|
|
321
|
+
versions = scraper.getVersions(app_id)
|
|
322
|
+
|
|
323
|
+
if versions:
|
|
324
|
+
print(f"{C_GRAY} -> Found {len(versions)} historical versions.{C_RESET}")
|
|
325
|
+
all_versions.extend(versions)
|
|
326
|
+
else:
|
|
327
|
+
print(f"{C_GRAY} -> No historical versions found.{C_RESET}")
|
|
328
|
+
|
|
329
|
+
if not all_versions:
|
|
330
|
+
print(f"\n{C_YELLOW}[!] Could not find any versions across selected sources.{C_RESET}")
|
|
331
|
+
if package_name:
|
|
332
|
+
print(f"{C_CYAN}[*] Fallback: Engaging apkeep for package '{package_name}'...{C_RESET}")
|
|
333
|
+
try:
|
|
334
|
+
subprocess.run(['apkeep', '-a', package_name, args.dir], check=True)
|
|
335
|
+
print(f"{C_GREEN}[+] Fallback completed successfully.{C_RESET}")
|
|
336
|
+
except FileNotFoundError:
|
|
337
|
+
print(f"{C_RED}[!] Fallback failed: 'apkeep' command not found on the system.{C_RESET}")
|
|
338
|
+
print(f"{C_GRAY} Hint: Install it via 'cargo install apkeep' or from https://github.com/EFForg/apkeep{C_RESET}")
|
|
339
|
+
except subprocess.CalledProcessError as e:
|
|
340
|
+
print(f"{C_RED}[!] Fallback failed: apkeep returned an error ({e}).{C_RESET}")
|
|
341
|
+
else:
|
|
342
|
+
print(f"{C_RED}[!] Cannot use fallback: could not determine Android package ID.{C_RESET}")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
versions_to_download = []
|
|
346
|
+
if args.all:
|
|
347
|
+
versions_to_download = all_versions
|
|
348
|
+
elif args.version:
|
|
349
|
+
versions_to_download = [v for v in all_versions if v['version'] == args.version]
|
|
350
|
+
if not versions_to_download:
|
|
351
|
+
print(f"\n{C_RED}[!] Version {args.version} not found across any sources.{C_RESET}")
|
|
352
|
+
return
|
|
353
|
+
else:
|
|
354
|
+
versions_to_download = [all_versions[0]]
|
|
355
|
+
print(f"\n{C_CYAN}[*] Defaulting to latest version found: {versions_to_download[0]['version']} (from {versions_to_download[0]['source'].name}){C_RESET}")
|
|
356
|
+
|
|
357
|
+
seen_versions = set()
|
|
358
|
+
deduped = []
|
|
359
|
+
for v in versions_to_download:
|
|
360
|
+
if v['version'] not in seen_versions:
|
|
361
|
+
seen_versions.add(v['version'])
|
|
362
|
+
deduped.append(v)
|
|
363
|
+
|
|
364
|
+
print(f"\n{C_CYAN}[*] Preparing to download {len(deduped)} unique version(s) utilizing {args.workers} workers...{C_RESET}\n")
|
|
365
|
+
|
|
366
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
|
|
367
|
+
futures = [executor.submit(processVersion, v, args, title) for v in deduped]
|
|
368
|
+
concurrent.futures.wait(futures)
|
|
369
|
+
|
|
370
|
+
print(f"\n{C_GREEN}[+] All tasks completed!{C_RESET}")
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apkscraper
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: An enterprise-grade, multi-threaded historical APK extraction pipeline.
|
|
5
|
+
Home-page: https://github.com/sumanrox/apkscrapper
|
|
6
|
+
Author: Spectre
|
|
7
|
+
Author-email: spectre@example.com
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/sumanrox/apkscrapper/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Intended Audience :: Information Technology
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: requests>=2.25.1
|
|
24
|
+
Requires-Dist: beautifulsoup4>=4.9.3
|
|
25
|
+
Dynamic: author
|
|
26
|
+
Dynamic: author-email
|
|
27
|
+
Dynamic: classifier
|
|
28
|
+
Dynamic: description
|
|
29
|
+
Dynamic: description-content-type
|
|
30
|
+
Dynamic: home-page
|
|
31
|
+
Dynamic: project-url
|
|
32
|
+
Dynamic: requires-dist
|
|
33
|
+
Dynamic: requires-python
|
|
34
|
+
Dynamic: summary
|
|
35
|
+
|
|
36
|
+
# APKScraper
|
|
37
|
+
|
|
38
|
+
<p align="center">
|
|
39
|
+
<img src="https://raw.githubusercontent.com/sumanrox/apkscrapper/main/assets/banner.png" alt="APKScraper Banner" width="100%">
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
An enterprise-grade, multi-threaded, and fault-tolerant historical APK extraction pipeline designed for automated vulnerability research, patch diffing, and bug bounty recon.
|
|
43
|
+
|
|
44
|
+
APKScraper acts as a universal ingestion engine, aggregating historical application versions from multiple CDNs (APKPure, Uptodown) into a unified analysis pipeline.
|
|
45
|
+
|
|
46
|
+
## ๐ Features
|
|
47
|
+
|
|
48
|
+
- **Multi-Source Aggregation**: Concurrently scrapes and dedupes version histories from both APKPure and Uptodown APIs.
|
|
49
|
+
- **Zero-RAM Streaming**: Downloads massive `1GB+` `.xapk` files safely using memory-efficient block streaming and atomic `.part` temporary files, eliminating corrupted partial downloads.
|
|
50
|
+
- **Exact Package Targeting**: Employs an exact-match override engine, ensuring searches for `com.google.android.youtube` strictly download the target package without hallucinating fuzzy matches.
|
|
51
|
+
- **Scope Sanitation**: Automatically strips `.apk` and `.xapk` extensions from queries, allowing you to directly copy-paste targets from HackerOne or Bugcrowd scopes.
|
|
52
|
+
- **Apkeep Failsafe**: Integrates natively with `apkeep` as an automated fallback mechanism if regional blocks or Play Store redirection policies hide the target from historical databases.
|
|
53
|
+
- **Thread-Safe UI**: Renders beautiful, concurrent ANSI progress bars without relying on external libraries like `tqdm` that struggle in strict externally-managed Linux environments.
|
|
54
|
+
|
|
55
|
+
## ๐ฆ Requirements
|
|
56
|
+
|
|
57
|
+
- Python 3.8+
|
|
58
|
+
- `requests`
|
|
59
|
+
- `beautifulsoup4`
|
|
60
|
+
- Optional: [`apkeep`](https://github.com/EFForg/apkeep) (for fallback capabilities)
|
|
61
|
+
|
|
62
|
+
## ๐ ๏ธ Installation & Usage
|
|
63
|
+
|
|
64
|
+
Because it is built as a native Python module with a CLI entrypoint, you can install it directly to your system path:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Once installed, you can execute it natively from anywhere on your system:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
apkscraper <query> [options]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### ๐ Available Commands & Parameters
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
usage: apkscraper [-h] [-a] [-v VERSION] [-d DIR] [-s {all,apkpure,uptodown}] [-w WORKERS]
|
|
80
|
+
query
|
|
81
|
+
|
|
82
|
+
Universal Historical APK Scraper
|
|
83
|
+
|
|
84
|
+
positional arguments:
|
|
85
|
+
query App name or Exact Package ID to search for (e.g. com.google.android.youtube)
|
|
86
|
+
|
|
87
|
+
options:
|
|
88
|
+
-h, --help show this help message and exit
|
|
89
|
+
-a, --all Download all versions available
|
|
90
|
+
-v, --version VERSION Download specific version
|
|
91
|
+
-d, --dir DIR Directory to save downloads (default: current directory)
|
|
92
|
+
-s, --source {all,apkpure,uptodown}
|
|
93
|
+
Sources to scrape from (default: all)
|
|
94
|
+
-w, --workers WORKERS Number of concurrent downloads (default: 4)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Example Use Cases
|
|
98
|
+
|
|
99
|
+
**1. Download ALL Historical Versions (Default behavior)**
|
|
100
|
+
Search for an app by name or package ID and download all available historical versions across all sources.
|
|
101
|
+
```bash
|
|
102
|
+
apkscraper "youtube" --all
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**2. Target Exact Package ID from a Bug Bounty Scope**
|
|
106
|
+
Directly copy-paste a package ID from a scope. The scraper will automatically strip the `.apk` and lock onto the exact package.
|
|
107
|
+
```bash
|
|
108
|
+
apkscraper "com.paypal.android.p2pmobile.apk" --all
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**3. Download a Specific Version**
|
|
112
|
+
Only download a single, exact version of an app.
|
|
113
|
+
```bash
|
|
114
|
+
apkscraper "com.whatsapp" --version "2.24.12.78"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**4. Specify an Output Directory**
|
|
118
|
+
Route the downloaded APKs directly to your static-analysis pipeline directory.
|
|
119
|
+
```bash
|
|
120
|
+
apkscraper "youtube" --all --dir "/opt/analysis/jadx-worker/inbound"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**5. Adjust Concurrency Limits**
|
|
124
|
+
Increase or decrease the number of parallel worker threads fetching the APKs (Default is 4).
|
|
125
|
+
```bash
|
|
126
|
+
apkscraper "youtube" --all --workers 8
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**6. Isolate a Specific CDN**
|
|
130
|
+
Only poll a specific source for historical versions instead of aggregating all of them.
|
|
131
|
+
```bash
|
|
132
|
+
apkscraper "youtube" --all --source uptodown
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## ๐งช Running Tests
|
|
136
|
+
|
|
137
|
+
The package includes a comprehensive `unittest` TDD suite containing mocks for the network and file I/O operations.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
python3 -m unittest apkscraper/tests/test_scraper.py
|
|
141
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
apkscraper/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
apkscraper/__main__.py,sha256=R67Vd-2OZjA_fAB2aQvQHwpy-GFCxoJeBbhhlH70aoU,15810
|
|
3
|
+
apkscraper-1.0.0.dist-info/METADATA,sha256=RGEe5wwbKgYBVvPXWuuGCNh8QxvUMhmJ9_8CpCzqGsA,5375
|
|
4
|
+
apkscraper-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
apkscraper-1.0.0.dist-info/entry_points.txt,sha256=gy-20fWZdBQogDSpom5ACD7Pv2YxxFAJW7Ck0_jkXto,56
|
|
6
|
+
apkscraper-1.0.0.dist-info/top_level.txt,sha256=1i7Me4KsyrM2XA_1DX0yD4ZC6aSMSL2n99LtzMsgKwA,11
|
|
7
|
+
apkscraper-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apkscraper
|