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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ apkscraper = apkscraper.__main__:main
@@ -0,0 +1 @@
1
+ apkscraper