sideloader 2.0.0__tar.gz → 2.2.0__tar.gz

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.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sideloader
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Download large files via PyPI packages
5
- Author: Sygmei
6
- Author-email: Sygmei <3835355+Sygmei@users.noreply.github.com>
5
+ Author: Null Void
6
+ Author-email: Null Void <nullvoid@nullvoid.com>
7
7
  Requires-Dist: build>=1.3.0
8
8
  Requires-Dist: twine>=6.2.0
9
9
  Requires-Dist: wheel>=0.45.1
@@ -1,11 +1,9 @@
1
1
  [project]
2
2
  name = "sideloader"
3
- version = "2.0.0"
3
+ version = "2.2.0"
4
4
  description = "Download large files via PyPI packages"
5
5
  readme = "README.md"
6
- authors = [
7
- { name = "Sygmei", email = "3835355+Sygmei@users.noreply.github.com" },
8
- ]
6
+ authors = [{ name = "Null Void", email = "nullvoid@nullvoid.com" }]
9
7
  requires-python = ">=3.12"
10
8
  dependencies = [
11
9
  "build>=1.3.0",
@@ -56,7 +56,7 @@ class SideloadClient:
56
56
  def __exit__(self, exc_type, exc_val, exc_tb):
57
57
  self.connector.close()
58
58
 
59
- def notify_hookdeck(self, bin_id: str):
59
+ def notify_hookdeck(self, bin_id: str, event_type: str = "start"):
60
60
  """Send an event to Hookdeck via the Publish API to trigger server-side processing"""
61
61
  if not self.hookdeck_source_id or not self.hookdeck_api_key:
62
62
  return
@@ -67,14 +67,16 @@ class SideloadClient:
67
67
  "https://hkdk.events/v1/publish",
68
68
  headers={"x-hookdeck-source-id": self.hookdeck_source_id},
69
69
  auth=(self.hookdeck_api_key, ""),
70
- json={"request_id": bin_id},
70
+ json={"request_id": bin_id, "event_type": event_type},
71
71
  )
72
72
  response.raise_for_status()
73
73
  console.print(
74
- f"📡 Hookdeck event sent for request [bold cyan]{bin_id}[/bold cyan]"
74
+ f"📡 Hookdeck {event_type} event sent for request [bold cyan]{bin_id}[/bold cyan]"
75
75
  )
76
76
  except Exception as e:
77
- console.print(f"⚠️ Failed to send Hookdeck event: {e}", style="yellow")
77
+ console.print(
78
+ f"⚠️ Failed to send Hookdeck {event_type} event: {e}", style="yellow"
79
+ )
78
80
 
79
81
  def create_request(self, url: str) -> str:
80
82
  """Create a new sideload request and return the bin ID"""
@@ -907,6 +909,7 @@ Examples:
907
909
  console.print(
908
910
  f"📊 File size: [cyan]{output_file.stat().st_size:,} bytes[/cyan]"
909
911
  )
912
+ client.notify_hookdeck(bin_id, "clean")
910
913
  else:
911
914
  # Use temporary directory for downloads
912
915
  with tempfile.TemporaryDirectory() as temp_dir:
@@ -944,6 +947,7 @@ Examples:
944
947
  console.print(
945
948
  f"📊 File size: [cyan]{output_file.stat().st_size:,} bytes[/cyan]"
946
949
  )
950
+ client.notify_hookdeck(bin_id, "clean")
947
951
 
948
952
  except KeyboardInterrupt:
949
953
  console.print("\n⚠️ Download interrupted by user", style="yellow")
@@ -0,0 +1,208 @@
1
+ """
2
+ PyPI package cleanup via browser automation with Playwright.
3
+ Reusable functions for deleting sideload packages from PyPI.
4
+ """
5
+
6
+ import asyncio
7
+ import os
8
+ import random
9
+
10
+ import pyotp
11
+ from playwright.async_api import async_playwright, Page
12
+
13
+
14
+ PYPI_USER = os.environ.get("PYPI_USER", "")
15
+ PYPI_PASSWORD = os.environ.get("PYPI_PASSWORD", "")
16
+ PYPI_TOTP = os.environ.get("PYPI_TOTP", "")
17
+
18
+
19
+ async def _human_like_mouse_movement(page: Page):
20
+ """Simulate human-like mouse movements across the page"""
21
+ viewport_size = page.viewport_size
22
+ width = viewport_size.get("width", 1280) if viewport_size else 1280
23
+ height = viewport_size.get("height", 720) if viewport_size else 720
24
+
25
+ for _ in range(random.randint(3, 6)):
26
+ x = random.randint(50, width - 50)
27
+ y = random.randint(50, height - 50)
28
+ await page.mouse.move(x, y)
29
+ await asyncio.sleep(random.uniform(0.15, 0.4))
30
+
31
+
32
+ async def pypi_login(page: Page):
33
+ """Log in to PyPI. Handles TOTP if PYPI_TOTP is set."""
34
+ print("🔐 Logging in to PyPI...")
35
+ await page.goto("https://pypi.org/account/login/")
36
+
37
+ await page.fill("#username", PYPI_USER)
38
+ await asyncio.sleep(random.uniform(0.2, 0.4))
39
+ await page.fill("#password", PYPI_PASSWORD)
40
+ await asyncio.sleep(random.uniform(0.3, 0.6))
41
+
42
+ await page.click('input[type="submit"]')
43
+ await asyncio.sleep(2)
44
+
45
+ current_url = page.url
46
+ if "/account/two-factor/" in current_url and PYPI_TOTP:
47
+ print(" 🔐 Generating TOTP code...")
48
+ await asyncio.sleep(random.uniform(1.5, 2.5))
49
+ await _human_like_mouse_movement(page)
50
+
51
+ totp = pyotp.TOTP(PYPI_TOTP)
52
+ code = totp.now()
53
+
54
+ totp_input = await page.query_selector(
55
+ 'input[name="totp_value"]'
56
+ ) or await page.query_selector('input[type="text"]')
57
+
58
+ if totp_input:
59
+ await totp_input.click()
60
+ await asyncio.sleep(random.uniform(0.4, 0.8))
61
+ for i, char in enumerate(code):
62
+ await totp_input.type(char, delay=random.randint(100, 250))
63
+ await asyncio.sleep(random.uniform(0.1, 0.25))
64
+ if i % 2 == 0:
65
+ await page.mouse.move(
66
+ random.randint(200, 600), random.randint(200, 500)
67
+ )
68
+
69
+ await asyncio.sleep(random.uniform(0.8, 1.5))
70
+ await _human_like_mouse_movement(page)
71
+
72
+ submit_button = await page.query_selector(
73
+ 'button[type="submit"]'
74
+ ) or await page.query_selector('input[type="submit"]')
75
+ if submit_button:
76
+ await submit_button.click()
77
+ await asyncio.sleep(4)
78
+
79
+ print(" ✅ Login completed!")
80
+
81
+
82
+ async def delete_pypi_project(page: Page, package_name: str) -> bool:
83
+ """Delete a single project from PyPI using browser automation."""
84
+ try:
85
+ print(f" 🗑️ Deleting {package_name}...")
86
+ settings_url = f"https://pypi.org/manage/project/{package_name}/settings/"
87
+ await page.goto(settings_url)
88
+
89
+ title = await page.title()
90
+ if "404" in title or "Not Found" in title:
91
+ print(f" ⚠️ Project {package_name} not found on PyPI")
92
+ return False
93
+
94
+ await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
95
+ await asyncio.sleep(0.5)
96
+
97
+ # Extract exact project name from the page
98
+ exact_project_name = await page.evaluate(
99
+ """() => {
100
+ const labels = Array.from(document.querySelectorAll('label'));
101
+ for (const label of labels) {
102
+ const text = label.textContent;
103
+ if (text && text.includes('confirm by typing the project name')) {
104
+ const match = text.match(/\\(([^)]+)\\)/);
105
+ if (match) return match[1];
106
+ }
107
+ }
108
+ const path = window.location.pathname;
109
+ const urlMatch = path.match(/\\/manage\\/project\\/([^\\/]+)/);
110
+ return urlMatch ? decodeURIComponent(urlMatch[1]) : null;
111
+ }"""
112
+ )
113
+
114
+ if not exact_project_name:
115
+ exact_project_name = package_name
116
+
117
+ # Check all confirmation checkboxes
118
+ checkboxes = await page.query_selector_all('input[type="checkbox"]')
119
+ for checkbox in checkboxes:
120
+ await checkbox.check()
121
+
122
+ # Type project name in confirmation field
123
+ confirm_input = await page.query_selector('input[name="confirm_project_name"]')
124
+ if not confirm_input:
125
+ print(f" ⚠️ Could not find confirmation input for {package_name}")
126
+ return False
127
+
128
+ await confirm_input.clear()
129
+ await asyncio.sleep(random.uniform(0.2, 0.5))
130
+ await _human_like_mouse_movement(page)
131
+
132
+ for char in exact_project_name:
133
+ await confirm_input.type(char, delay=random.randint(80, 200))
134
+ await asyncio.sleep(random.uniform(0.05, 0.15))
135
+
136
+ await asyncio.sleep(random.uniform(1.0, 2.0))
137
+ await _human_like_mouse_movement(page)
138
+
139
+ # Click delete button
140
+ delete_link = await page.query_selector('[data-delete-confirm-target="button"]')
141
+ if not delete_link:
142
+ print(f" ⚠️ Could not find delete button")
143
+ return False
144
+
145
+ is_disabled = await delete_link.evaluate(
146
+ '(el) => el.classList.contains("button--disabled") || el.hasAttribute("disabled")'
147
+ )
148
+ if is_disabled:
149
+ print(f" ⚠️ Delete button is still disabled")
150
+ return False
151
+
152
+ box = await delete_link.bounding_box()
153
+ if box:
154
+ target_x = box["x"] + random.uniform(10, box["width"] - 10)
155
+ target_y = box["y"] + random.uniform(10, box["height"] - 10)
156
+ await page.mouse.move(target_x, target_y)
157
+ await asyncio.sleep(random.uniform(0.2, 0.5))
158
+
159
+ await delete_link.click()
160
+ await asyncio.sleep(random.uniform(0.5, 1.0))
161
+ await page.wait_for_load_state("networkidle")
162
+
163
+ print(f" ✅ Deleted {package_name} from PyPI")
164
+ return True
165
+
166
+ except Exception as e:
167
+ print(f" ❌ Error deleting {package_name}: {e}")
168
+ return False
169
+
170
+
171
+ async def delete_pypi_packages(package_names: list[str]) -> tuple[int, int]:
172
+ """
173
+ Delete a list of packages from PyPI.
174
+
175
+ Returns:
176
+ Tuple of (deleted_count, error_count)
177
+ """
178
+ if not PYPI_USER or not PYPI_PASSWORD:
179
+ print(" ⚠️ PYPI_USER / PYPI_PASSWORD not set, skipping PyPI cleanup")
180
+ return 0, 0
181
+
182
+ deleted = 0
183
+ errors = 0
184
+
185
+ async with async_playwright() as p:
186
+ browser = await p.chromium.launch(headless=True)
187
+ context = await browser.new_context()
188
+ page = await context.new_page()
189
+
190
+ try:
191
+ await pypi_login(page)
192
+
193
+ for package_name in package_names:
194
+ if await delete_pypi_project(page, package_name):
195
+ deleted += 1
196
+ else:
197
+ errors += 1
198
+ # Small delay between deletions
199
+ await asyncio.sleep(random.uniform(1.0, 3.0))
200
+ finally:
201
+ await browser.close()
202
+
203
+ return deleted, errors
204
+
205
+
206
+ def delete_pypi_packages_sync(package_names: list[str]) -> tuple[int, int]:
207
+ """Synchronous wrapper for delete_pypi_packages."""
208
+ return asyncio.run(delete_pypi_packages(package_names))
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Admin script to delete all sideload-* packages from PyPI using browser automation.
4
+ Uses the shared pypi_cleanup module for login and project deletion.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import asyncio
10
+ import random
11
+
12
+ from playwright.async_api import async_playwright, Page
13
+
14
+ from sideloader.pypi_cleanup import (
15
+ pypi_login,
16
+ delete_pypi_project,
17
+ PYPI_USER,
18
+ PYPI_PASSWORD,
19
+ PYPI_TOTP,
20
+ )
21
+
22
+ if not PYPI_USER or not PYPI_PASSWORD:
23
+ print("❌ PYPI_USER and PYPI_PASSWORD environment variables must be set")
24
+ sys.exit(1)
25
+
26
+
27
+ async def get_user_projects(page: Page) -> list[str]:
28
+ """Get all projects owned by the user that start with sideload-"""
29
+ try:
30
+ print("🔍 Fetching your PyPI projects...")
31
+
32
+ await page.goto("https://pypi.org/manage/projects/")
33
+ await page.wait_for_load_state("networkidle")
34
+
35
+ projects = await page.evaluate(
36
+ """() => {
37
+ const links = Array.from(document.querySelectorAll('a[href*="/manage/project/"]'));
38
+ return links.map(link => {
39
+ const match = link.href.match(/\\/manage\\/project\\/([^\\/]+)\\//);
40
+ return match ? match[1] : null;
41
+ }).filter(name => name && name.startsWith('sideload-'));
42
+ }"""
43
+ )
44
+
45
+ return projects
46
+
47
+ except Exception as e:
48
+ print(f"❌ Error fetching projects: {e}")
49
+ return []
50
+
51
+
52
+ async def wait_for_login(page: Page):
53
+ """Wait for user to manually complete login (captcha / manual 2FA)."""
54
+ current_url = page.url
55
+ if "/account/two-factor/" in current_url or "/account/login/" in current_url:
56
+ print("\n" + "=" * 60)
57
+ print(" 👤 Please complete login in the browser window")
58
+ print(" ⏳ Waiting for you to finish...")
59
+ print("=" * 60 + "\n")
60
+
61
+ while True:
62
+ await asyncio.sleep(2)
63
+ current_url = page.url
64
+ if (
65
+ "/account/two-factor/" not in current_url
66
+ and "/account/login/" not in current_url
67
+ ):
68
+ break
69
+ print(" ⏳ Still waiting...", end="\r")
70
+ print("\n ✓ Login completed!")
71
+
72
+
73
+ async def main():
74
+ print("🧹 PyPI Sideload Package Cleanup Tool")
75
+ print("=" * 50)
76
+ if PYPI_TOTP:
77
+ print("🔐 TOTP auto-generation enabled")
78
+ else:
79
+ print("⚠️ TOTP auto-generation disabled (set PYPI_TOTP to enable)")
80
+ print()
81
+
82
+ async with async_playwright() as p:
83
+ browser = await p.chromium.launch(headless=False)
84
+ context = await browser.new_context()
85
+ page = await context.new_page()
86
+
87
+ try:
88
+ # Login using shared module (handles credentials + TOTP)
89
+ await pypi_login(page)
90
+
91
+ # If we're still on a login/2FA page (e.g. captcha), wait for manual completion
92
+ await wait_for_login(page)
93
+
94
+ # Get all sideload projects
95
+ projects = await get_user_projects(page)
96
+
97
+ if not projects:
98
+ print("❌ No sideload-* projects found")
99
+ return
100
+
101
+ print(f"\n📋 Found {len(projects)} sideload projects:")
102
+ for pkg in projects:
103
+ print(f" - {pkg}")
104
+
105
+ # Delete each project using shared module
106
+ print("\n🗑️ Deleting projects...\n")
107
+ deleted = 0
108
+ for pkg in projects:
109
+ if await delete_pypi_project(page, pkg):
110
+ deleted += 1
111
+ await asyncio.sleep(random.uniform(1.0, 3.0))
112
+
113
+ print(f"\n✅ Deleted {deleted}/{len(projects)} projects")
114
+
115
+ finally:
116
+ await browser.close()
117
+
118
+
119
+ if __name__ == "__main__":
120
+ asyncio.run(main())
@@ -343,6 +343,36 @@ def cleanup_all_collections():
343
343
  return total_deleted, total_errors
344
344
 
345
345
 
346
+ def clean_request(bin_id: str):
347
+ """Clean up a completed request: delete PyPI packages and the JSONBin bin."""
348
+ try:
349
+ bin_data = jsonbin_connector.get_bin(bin_id)
350
+ status = bin_data.get("status", "UNKNOWN")
351
+ print(f" Bin {bin_id} status: {status}")
352
+
353
+ if status not in CLEANUP_STATUSES:
354
+ print(f" ⚠️ Bin {bin_id} has status {status}, skipping cleanup")
355
+ return
356
+
357
+ # Delete PyPI packages if they exist
358
+ package_names = bin_data.get("packages_names", [])
359
+ if package_names:
360
+ print(f" 🗑️ Deleting {len(package_names)} PyPI package(s)...")
361
+ from sideloader.pypi_cleanup import delete_pypi_packages_sync
362
+
363
+ deleted, errors = delete_pypi_packages_sync(package_names)
364
+ print(f" 📦 PyPI cleanup: {deleted} deleted, {errors} errors")
365
+ else:
366
+ print(" ℹ️ No packages to clean from PyPI")
367
+
368
+ # Delete the JSONBin bin
369
+ jsonbin_connector.delete_bin(bin_id)
370
+ print(f" ✅ Deleted bin {bin_id}")
371
+
372
+ except Exception as e:
373
+ print(f" ❌ Error cleaning request {bin_id}: {e}")
374
+
375
+
346
376
  def server_main():
347
377
  import argparse
348
378
 
@@ -353,8 +383,21 @@ def server_main():
353
383
  default=None,
354
384
  help="Process a single request by JSONBin ID and exit",
355
385
  )
386
+ parser.add_argument(
387
+ "--clean-request-id",
388
+ type=str,
389
+ default=None,
390
+ help="Clean up a completed request by JSONBin ID and exit",
391
+ )
356
392
  args = parser.parse_args()
357
393
 
394
+ if args.clean_request_id:
395
+ # Clean mode: delete the bin and exit
396
+ print(f"🧹 Cleaning request: {args.clean_request_id}")
397
+ clean_request(args.clean_request_id)
398
+ print(f"✅ Finished cleaning request: {args.clean_request_id}")
399
+ return
400
+
358
401
  if args.request_id:
359
402
  # Single-request mode: process one request and exit
360
403
  print(f"🎯 Processing single request: {args.request_id}")
@@ -1,352 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Admin script to delete all sideload-* packages from PyPI using browser automation
4
- """
5
-
6
- import os
7
- import sys
8
- import asyncio
9
- import random
10
- from playwright.async_api import async_playwright
11
- import pyotp
12
-
13
- PYPI_USER = os.environ.get("PYPI_USER")
14
- PYPI_PASSWORD = os.environ.get("PYPI_PASSWORD")
15
- PYPI_TOTP = os.environ.get("PYPI_TOTP") # Optional: TOTP secret key
16
-
17
- if not PYPI_USER or not PYPI_PASSWORD:
18
- print("❌ PYPI_USER and PYPI_PASSWORD environment variables must be set")
19
- sys.exit(1)
20
-
21
-
22
- async def human_like_mouse_movement(page):
23
- """Simulate human-like mouse movements across the page"""
24
- viewport_size = page.viewport_size
25
- width = viewport_size.get('width', 1280) if viewport_size else 1280
26
- height = viewport_size.get('height', 720) if viewport_size else 720
27
-
28
- for _ in range(random.randint(3, 6)):
29
- x = random.randint(50, width - 50)
30
- y = random.randint(50, height - 50)
31
- await page.mouse.move(x, y)
32
- await asyncio.sleep(random.uniform(0.15, 0.4))
33
-
34
-
35
- async def delete_project(page, package_name: str) -> bool:
36
- """Delete a project from PyPI using browser automation"""
37
- try:
38
- print(f"🗑️ Deleting {package_name}...")
39
-
40
- # Go to project settings
41
- settings_url = f'https://pypi.org/manage/project/{package_name}/settings/'
42
- await page.goto(settings_url)
43
-
44
- # Check if project exists (if we get 404, project doesn't exist)
45
- title = await page.title()
46
- if "404" in title or "Not Found" in title:
47
- print(f" ⚠️ Project {package_name} not found")
48
- return False
49
-
50
- # Scroll to the bottom of the page to find delete section
51
- await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
52
- await asyncio.sleep(0.5)
53
-
54
- # Extract the exact project name from the page to ensure correct case
55
- # Look for the project name in the delete section
56
- exact_project_name = await page.evaluate('''() => {
57
- // Find the label that mentions the project name
58
- const labels = Array.from(document.querySelectorAll('label'));
59
- for (const label of labels) {
60
- const text = label.textContent;
61
- if (text && text.includes('confirm by typing the project name')) {
62
- // Extract the project name from something like "confirm by typing the project name (project-name) below"
63
- const match = text.match(/\\(([^)]+)\\)/);
64
- if (match) return match[1];
65
- }
66
- }
67
- // Fallback: get from URL
68
- const path = window.location.pathname;
69
- const urlMatch = path.match(/\\/manage\\/project\\/([^\\/]+)/);
70
- return urlMatch ? decodeURIComponent(urlMatch[1]) : null;
71
- }''')
72
-
73
- if not exact_project_name:
74
- print(f" ⚠️ Could not extract exact project name from page, using: {package_name}")
75
- exact_project_name = package_name
76
- else:
77
- print(f" 📝 Extracted exact project name: {exact_project_name}")
78
-
79
- # Find and check all "I understand..." checkboxes
80
- checkboxes = await page.query_selector_all('input[type="checkbox"]')
81
- print(f" ✓ Found {len(checkboxes)} checkboxes")
82
- for checkbox in checkboxes:
83
- await checkbox.check()
84
-
85
- # Find and type project name in confirmation field
86
- confirm_input = await page.query_selector('input[name="confirm_project_name"]')
87
- if not confirm_input:
88
- print(f" ⚠️ Could not find confirmation input for {package_name}")
89
- return False
90
-
91
- # Clear and type the exact project name with human-like behavior
92
- await confirm_input.clear()
93
- await asyncio.sleep(random.uniform(0.2, 0.5))
94
-
95
- # Move mouse before typing
96
- await human_like_mouse_movement(page)
97
-
98
- # Type each character with varying delays
99
- for char in exact_project_name:
100
- await confirm_input.type(char, delay=random.randint(80, 200))
101
- await asyncio.sleep(random.uniform(0.05, 0.15))
102
-
103
- print(f" ✓ Typed project name: {exact_project_name}")
104
-
105
- # Wait a moment for the button to become enabled (human-like pause)
106
- await asyncio.sleep(random.uniform(1.0, 2.0))
107
-
108
- # Move mouse naturally
109
- await human_like_mouse_movement(page)
110
-
111
- # Find and click the delete link
112
- delete_link = await page.query_selector('[data-delete-confirm-target="button"]')
113
- if not delete_link:
114
- print(f" ⚠️ Could not find delete button")
115
- return False
116
-
117
- # Check if the button is enabled
118
- is_disabled = await delete_link.evaluate('(el) => el.classList.contains("button--disabled") || el.hasAttribute("disabled")')
119
- if is_disabled:
120
- print(f" ⚠️ Delete button is still disabled")
121
- # Take a screenshot for debugging
122
- await page.screenshot(path=f"debug_{package_name}.png")
123
- print(f" 📸 Screenshot saved as debug_{package_name}.png")
124
- return False
125
-
126
- print(f" 🖱️ Clicking delete button...")
127
-
128
- # Move mouse to the button area naturally
129
- box = await delete_link.bounding_box()
130
- if box:
131
- # Move to a random point within the button
132
- target_x = box['x'] + random.uniform(10, box['width'] - 10)
133
- target_y = box['y'] + random.uniform(10, box['height'] - 10)
134
- await page.mouse.move(target_x, target_y)
135
- await asyncio.sleep(random.uniform(0.2, 0.5))
136
-
137
- # Click and wait for navigation
138
- await delete_link.click()
139
- await asyncio.sleep(random.uniform(0.5, 1.0))
140
- await page.wait_for_load_state("networkidle")
141
-
142
- print(f" ✅ Deleted {package_name}")
143
- return True
144
-
145
- except Exception as e:
146
- print(f" ❌ Error deleting {package_name}: {e}")
147
- import traceback
148
- traceback.print_exc()
149
- # Take screenshot on error
150
- try:
151
- await page.screenshot(path=f"error_{package_name}.png")
152
- print(f" 📸 Error screenshot saved as error_{package_name}.png")
153
- except:
154
- pass
155
- return False
156
-
157
-
158
- async def get_user_projects(page) -> list[str]:
159
- """Get all projects owned by the user that start with sideload-"""
160
- try:
161
- print("🔍 Fetching your PyPI projects...")
162
-
163
- # Go to projects page
164
- await page.goto('https://pypi.org/manage/projects/')
165
- await page.wait_for_load_state("networkidle")
166
-
167
- # Extract project names
168
- projects = await page.evaluate('''() => {
169
- const links = Array.from(document.querySelectorAll('a[href*="/manage/project/"]'));
170
- return links.map(link => {
171
- const match = link.href.match(/\\/manage\\/project\\/([^\\/]+)\\//);
172
- return match ? match[1] : null;
173
- }).filter(name => name && name.startsWith('sideload-'));
174
- }''')
175
-
176
- return projects
177
-
178
- except Exception as e:
179
- print(f"❌ Error fetching projects: {e}")
180
- return []
181
-
182
-
183
- async def main():
184
- print("🧹 PyPI Sideload Package Cleanup Tool")
185
- print("=" * 50)
186
- if PYPI_TOTP:
187
- print("🔐 TOTP auto-generation enabled")
188
- else:
189
- print("⚠️ TOTP auto-generation disabled (set PYPI_TOTP to enable)")
190
- print()
191
-
192
- async with async_playwright() as p:
193
- # Launch browser
194
- browser = await p.chromium.launch(headless=False)
195
- context = await browser.new_context()
196
- page = await context.new_page()
197
-
198
- try:
199
- # Navigate to PyPI login page
200
- print("🔐 Logging in to PyPI...")
201
- print(" Opening login page...")
202
- await page.goto('https://pypi.org/account/login/')
203
-
204
- # Fill in login credentials (fast, no need to be slow here)
205
- print(" Filling credentials...")
206
- await page.fill('#username', PYPI_USER)
207
- await asyncio.sleep(random.uniform(0.2, 0.4))
208
- await page.fill('#password', PYPI_PASSWORD)
209
- await asyncio.sleep(random.uniform(0.3, 0.6))
210
-
211
- # Submit login form
212
- await page.click('input[type="submit"]')
213
- await asyncio.sleep(2)
214
-
215
- # Wait for user to complete TOTP if required
216
- current_url = page.url
217
- if '/account/two-factor/' in current_url:
218
- if PYPI_TOTP:
219
- print(" 🔐 Generating TOTP code...")
220
-
221
- # Simulate human-like delay before interacting
222
- await asyncio.sleep(random.uniform(1.5, 2.5))
223
-
224
- # Move mouse around naturally across the page
225
- print(" 🖱️ Moving mouse naturally...")
226
- await human_like_mouse_movement(page)
227
-
228
- totp = pyotp.TOTP(PYPI_TOTP)
229
- code = totp.now()
230
- print(f" ✓ Generated TOTP code: {code}")
231
-
232
- # Find TOTP input field and enter the code
233
- totp_input = await page.query_selector('input[name="totp_value"]')
234
- if not totp_input:
235
- totp_input = await page.query_selector('input[type="text"]')
236
-
237
- if totp_input:
238
- # Click on the input field naturally
239
- await totp_input.click()
240
- await asyncio.sleep(random.uniform(0.4, 0.8))
241
-
242
- # Type each character slowly with human-like delays
243
- print(" ⌨️ Typing TOTP code slowly...")
244
- for i, char in enumerate(code):
245
- await totp_input.type(char, delay=random.randint(100, 250))
246
- await asyncio.sleep(random.uniform(0.1, 0.25))
247
- # Occasionally move mouse during typing
248
- if i % 2 == 0:
249
- x = random.randint(200, 600)
250
- y = random.randint(200, 500)
251
- await page.mouse.move(x, y)
252
-
253
- print(" ✓ Entered TOTP code")
254
-
255
- # Wait a bit before submitting (like a human would)
256
- await asyncio.sleep(random.uniform(0.8, 1.5))
257
-
258
- # More mouse movements
259
- print(" 🖱️ Moving mouse before submit...")
260
- await human_like_mouse_movement(page)
261
-
262
- # Submit the form
263
- submit_button = await page.query_selector('button[type="submit"]')
264
- if not submit_button:
265
- submit_button = await page.query_selector('input[type="submit"]')
266
-
267
- if submit_button:
268
- await submit_button.click()
269
- print(" ✓ Submitted TOTP, waiting for response...")
270
-
271
- # Wait for navigation
272
- await asyncio.sleep(4)
273
-
274
- # Check if we've successfully logged in
275
- current_url = page.url
276
- if '/account/' in current_url or '/manage/' in current_url:
277
- print(" ✓ Successfully logged in!")
278
- elif '/account/two-factor/' in current_url:
279
- # Still on 2FA page - likely a captcha
280
- print("\n" + "=" * 60)
281
- print(" 🤖 CAPTCHA DETECTED!")
282
- print(" 👤 Please solve the captcha in the browser window")
283
- print(" ⏳ Waiting for you to complete it...")
284
- print("=" * 60 + "\n")
285
-
286
- # Wait for user to solve captcha - keep checking
287
- # We need to wait until we're actually logged in (on account page or similar)
288
- while True:
289
- await asyncio.sleep(2)
290
- current_url = page.url
291
- # Check if we've successfully logged in (not just left the 2FA page)
292
- if ('/account/' in current_url or '/manage/' in current_url) and '/account/two-factor/' not in current_url:
293
- break
294
- # Print status every 2 seconds to show we're still waiting
295
- print(" ⏳ Still waiting for captcha completion...", end='\r')
296
-
297
- print("\n ✓ Captcha solved and logged in! Continuing...")
298
- else:
299
- print(f" ⚠️ Unexpected page: {current_url}")
300
- print(" ⏳ Waiting for login to complete...")
301
- # Wait until we're on a known good page
302
- while True:
303
- await asyncio.sleep(2)
304
- current_url = page.url
305
- if '/account/' in current_url or '/manage/' in current_url:
306
- break
307
- print(" ⏳ Still waiting...", end='\r')
308
- print("\n ✓ Login completed!")
309
- else:
310
- print(" ⚠️ Could not find submit button")
311
- else:
312
- print(" ⚠️ Could not find TOTP input field")
313
- else:
314
- print(" ⚠️ TOTP required - please enter your 2FA code in the browser...")
315
- print(" 👤 Waiting for you to complete 2FA (and captcha if present)...")
316
- while True:
317
- await asyncio.sleep(2)
318
- current_url = page.url
319
- # Wait until we're actually logged in
320
- if '/account/two-factor/' not in current_url and ('/account/' in current_url or '/manage/' in current_url):
321
- break
322
- print(" ⏳ Still waiting for 2FA completion...", end='\r')
323
- print("\n ✓ 2FA completed!")
324
-
325
- print("\n✅ Login completed!")
326
-
327
- # Get all sideload projects
328
- projects = await get_user_projects(page)
329
-
330
- if not projects:
331
- print("❌ No sideload-* projects found")
332
- return
333
-
334
- print(f"\n📋 Found {len(projects)} sideload projects:")
335
- for pkg in projects:
336
- print(f" - {pkg}")
337
-
338
- # Delete each project
339
- print("\n🗑️ Deleting projects...\n")
340
- deleted = 0
341
- for pkg in projects:
342
- if await delete_project(page, pkg):
343
- deleted += 1
344
-
345
- print(f"\n✅ Deleted {deleted}/{len(projects)} projects")
346
-
347
- finally:
348
- await browser.close()
349
-
350
-
351
- if __name__ == "__main__":
352
- asyncio.run(main())
File without changes