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.
- {sideloader-2.0.0 → sideloader-2.2.0}/PKG-INFO +3 -3
- {sideloader-2.0.0 → sideloader-2.2.0}/pyproject.toml +2 -4
- {sideloader-2.0.0 → sideloader-2.2.0}/src/sideloader/cli.py +8 -4
- sideloader-2.2.0/src/sideloader/pypi_cleanup.py +208 -0
- sideloader-2.2.0/src/sideloader/scripts/cleanup_pypi.py +120 -0
- {sideloader-2.0.0 → sideloader-2.2.0}/src/sideloader/server.py +43 -0
- sideloader-2.0.0/src/sideloader/scripts/cleanup_pypi.py +0 -352
- {sideloader-2.0.0 → sideloader-2.2.0}/README.md +0 -0
- {sideloader-2.0.0 → sideloader-2.2.0}/src/sideloader/__init__.py +0 -0
- {sideloader-2.0.0 → sideloader-2.2.0}/src/sideloader/jsonbin_connector.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: sideloader
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Download large files via PyPI packages
|
|
5
|
-
Author:
|
|
6
|
-
Author-email:
|
|
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.
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|