qoder-autopilot 0.2.1__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.
Files changed (39) hide show
  1. qoder_autopilot/__init__.py +59 -0
  2. qoder_autopilot/__main__.py +5 -0
  3. qoder_autopilot/browser/__init__.py +5 -0
  4. qoder_autopilot/browser/camoufox.py +62 -0
  5. qoder_autopilot/browser/window_tiler.py +117 -0
  6. qoder_autopilot/captcha/__init__.py +14 -0
  7. qoder_autopilot/captcha/ai_vision.py +327 -0
  8. qoder_autopilot/captcha/manual.py +95 -0
  9. qoder_autopilot/captcha/opencv_detect.py +184 -0
  10. qoder_autopilot/captcha/slider.py +86 -0
  11. qoder_autopilot/captcha/solver.py +165 -0
  12. qoder_autopilot/cli.py +431 -0
  13. qoder_autopilot/config.py +300 -0
  14. qoder_autopilot/credentials.py +44 -0
  15. qoder_autopilot/deploy.py +338 -0
  16. qoder_autopilot/errors.py +132 -0
  17. qoder_autopilot/first_run.py +107 -0
  18. qoder_autopilot/identity.py +66 -0
  19. qoder_autopilot/logger.py +119 -0
  20. qoder_autopilot/ninerouter.py +105 -0
  21. qoder_autopilot/oauth.py +149 -0
  22. qoder_autopilot/otp.py +67 -0
  23. qoder_autopilot/register.py +383 -0
  24. qoder_autopilot/temp_mail.py +307 -0
  25. qoder_autopilot/user_config.py +171 -0
  26. qoder_autopilot/worker_template/package.json +21 -0
  27. qoder_autopilot/worker_template/schema.sql +24 -0
  28. qoder_autopilot/worker_template/scripts/setup.sh +186 -0
  29. qoder_autopilot/worker_template/src/config.js +6 -0
  30. qoder_autopilot/worker_template/src/handlers/api.js +226 -0
  31. qoder_autopilot/worker_template/src/handlers/email.js +50 -0
  32. qoder_autopilot/worker_template/src/index.js +43 -0
  33. qoder_autopilot/worker_template/src/utils.js +35 -0
  34. qoder_autopilot/worker_template/wrangler.toml.example +19 -0
  35. qoder_autopilot-0.2.1.dist-info/METADATA +299 -0
  36. qoder_autopilot-0.2.1.dist-info/RECORD +39 -0
  37. qoder_autopilot-0.2.1.dist-info/WHEEL +4 -0
  38. qoder_autopilot-0.2.1.dist-info/entry_points.txt +2 -0
  39. qoder_autopilot-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,59 @@
1
+ """
2
+ Qoder Autopilot
3
+ ================
4
+ Automated Qoder account registration with anti-detect browser (Camoufox),
5
+ captcha solving (AI + OpenCV), and 9Router OAuth device token integration.
6
+
7
+ Quick start::
8
+
9
+ from qoder_autopilot import run_one
10
+ import asyncio
11
+
12
+ result = asyncio.run(run_one(manual_captcha=True))
13
+ """
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ from .captcha import CaptchaSolver
18
+ from .cli import run_one
19
+ from .errors import (
20
+ CaptchaAIFailed,
21
+ CaptchaError,
22
+ CaptchaTimeoutError,
23
+ DeviceTokenTimeout,
24
+ FormSubmitError,
25
+ NineRouterDBNotFound,
26
+ NineRouterError,
27
+ OAuthError,
28
+ OTPTimeoutError,
29
+ QoderAutopilotError,
30
+ RegistrationError,
31
+ TempMailError,
32
+ )
33
+ from .oauth import initiate_device_flow, poll_device_token
34
+ from .register import register_and_verify
35
+ from .temp_mail import TempMail
36
+
37
+ __all__ = [
38
+ # Core functions
39
+ "register_and_verify",
40
+ "run_one",
41
+ # Services
42
+ "TempMail",
43
+ "initiate_device_flow",
44
+ "poll_device_token",
45
+ "CaptchaSolver",
46
+ # Exceptions
47
+ "QoderAutopilotError",
48
+ "TempMailError",
49
+ "CaptchaError",
50
+ "CaptchaTimeoutError",
51
+ "CaptchaAIFailed",
52
+ "RegistrationError",
53
+ "OTPTimeoutError",
54
+ "FormSubmitError",
55
+ "OAuthError",
56
+ "DeviceTokenTimeout",
57
+ "NineRouterError",
58
+ "NineRouterDBNotFound",
59
+ ]
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m qoder_autopilot"""
2
+
3
+ from .cli import main
4
+
5
+ main()
@@ -0,0 +1,5 @@
1
+ """
2
+ Qoder Autopilot — Browser Sub-package
3
+ ======================================
4
+ Camoufox browser management and window tiling utilities.
5
+ """
@@ -0,0 +1,62 @@
1
+ """
2
+ Qoder Autopilot — Camoufox Browser Launcher
3
+ ==============================================
4
+ Launch and configure Camoufox (anti-detect Firefox fork) browser instances.
5
+ Camoufox provides C++-level stealth fingerprinting to bypass bot detection.
6
+ """
7
+
8
+ import random
9
+ from collections.abc import AsyncIterator
10
+ from contextlib import asynccontextmanager
11
+
12
+ from ..logger import log
13
+
14
+
15
+ @asynccontextmanager
16
+ async def launch_browser(
17
+ headless: bool = True,
18
+ window_width: int = 900,
19
+ window_height: int = 600,
20
+ ) -> AsyncIterator:
21
+ """Launch a Camoufox browser instance with stealth settings.
22
+
23
+ Args:
24
+ headless: Run in headless mode (no visible window).
25
+ window_width: Browser window width in pixels.
26
+ window_height: Browser window height in pixels.
27
+
28
+ Yields:
29
+ Camoufox browser context manager.
30
+ """
31
+ from camoufox.async_api import AsyncCamoufox
32
+
33
+ os_choice = random.choice(["windows", "macos", "linux"])
34
+ log(f" đŸĻŠ Launching Camoufox (headless={headless}, os={os_choice})...")
35
+
36
+ async with AsyncCamoufox(
37
+ headless=headless,
38
+ os=os_choice,
39
+ window=(window_width, window_height),
40
+ ) as browser:
41
+ yield browser
42
+
43
+
44
+ async def setup_page(page) -> None:
45
+ """Apply standard page setup for stealth and stability.
46
+
47
+ - Forces 100% zoom on every page load
48
+
49
+ Note: pageerror listener intentionally omitted — Playwright's internal
50
+ handler crashes on Node.js v24+ when pageError.location is undefined.
51
+ Adding our own listener doesn't prevent the internal crash.
52
+
53
+ Args:
54
+ page: Playwright/Camoufox page object.
55
+ """
56
+
57
+ # Force 100% zoom on every page load
58
+ await page.add_init_script("""() => {
59
+ document.addEventListener('DOMContentLoaded', () => {
60
+ document.body.style.zoom = '100%';
61
+ });
62
+ }""")
@@ -0,0 +1,117 @@
1
+ """
2
+ Qoder Autopilot — Window Grid Tiling
3
+ ======================================
4
+ Tile Camoufox browser windows into a 2×2 grid layout.
5
+ Uses macOS AppleScript for window positioning.
6
+
7
+ Grid layout:
8
+ ┌──────────â”Ŧ──────────┐
9
+ │ Slot 1 │ Slot 2 │ (top row)
10
+ ├──────────â”ŧ──────────┤
11
+ │ Slot 3 │ Slot 4 │ (bottom row)
12
+ └──────────┴──────────┘
13
+
14
+ Windows cycle through slots: 5th window → slot 1, 6th → slot 2, etc.
15
+ """
16
+
17
+ import platform
18
+ import subprocess
19
+
20
+ from ..logger import log
21
+
22
+ _screen_size_cache: tuple[int, int] | None = None
23
+
24
+
25
+ def get_screen_size() -> tuple[int, int]:
26
+ """Get main screen dimensions via osascript (cached).
27
+
28
+ Returns:
29
+ Tuple of (width, height) in pixels.
30
+ """
31
+ global _screen_size_cache
32
+ if _screen_size_cache:
33
+ return _screen_size_cache
34
+
35
+ try:
36
+ result = subprocess.run(
37
+ [
38
+ "osascript",
39
+ "-e",
40
+ 'tell application "Finder" to get bounds of window of desktop',
41
+ ],
42
+ capture_output=True,
43
+ text=True,
44
+ timeout=5,
45
+ )
46
+ parts = [int(x.strip()) for x in result.stdout.strip().split(",")]
47
+ _screen_size_cache = (parts[2], parts[3])
48
+ except Exception:
49
+ _screen_size_cache = (1920, 1080)
50
+
51
+ return _screen_size_cache
52
+
53
+
54
+ def tile_all_camoufox_windows() -> None:
55
+ """Tile ALL open Camoufox windows into a 2×2 grid.
56
+
57
+ Call this after all browsers have been launched.
58
+ Only works on macOS (uses AppleScript).
59
+ """
60
+ if platform.system() != "Darwin":
61
+ return
62
+
63
+ sw, sh = get_screen_size()
64
+ half_w = sw // 2
65
+ half_h = sh // 2
66
+ menubar = 25
67
+
68
+ # Grid positions: (left, top, right, bottom)
69
+ grid = [
70
+ (0, menubar, half_w, half_h + menubar), # top-left
71
+ (half_w, menubar, sw, half_h + menubar), # top-right
72
+ (0, half_h + menubar, half_w, sh), # bottom-left
73
+ (half_w, half_h + menubar, sw, sh), # bottom-right
74
+ ]
75
+
76
+ # Build AppleScript grid list
77
+ g = grid
78
+ grid_str = (
79
+ "{"
80
+ + "{"
81
+ + f"{g[0][0]}, {g[0][1]}, {g[0][2]}, {g[0][3]}"
82
+ + "}, "
83
+ + "{"
84
+ + f"{g[1][0]}, {g[1][1]}, {g[1][2]}, {g[1][3]}"
85
+ + "}, "
86
+ + "{"
87
+ + f"{g[2][0]}, {g[2][1]}, {g[2][2]}, {g[2][3]}"
88
+ + "}, "
89
+ + "{"
90
+ + f"{g[3][0]}, {g[3][1]}, {g[3][2]}, {g[3][3]}"
91
+ + "}"
92
+ + "}"
93
+ )
94
+
95
+ script = f"""
96
+ tell application "camoufox"
97
+ set winCount to count of windows
98
+ set grid to {grid_str}
99
+ repeat with i from 1 to winCount
100
+ set posIdx to ((i - 1) mod 4) + 1
101
+ set bounds of window i to item posIdx of grid
102
+ end repeat
103
+ return winCount
104
+ end tell
105
+ """
106
+
107
+ try:
108
+ result = subprocess.run(
109
+ ["osascript", "-e", script],
110
+ capture_output=True,
111
+ text=True,
112
+ timeout=10,
113
+ )
114
+ count = result.stdout.strip()
115
+ log(f" đŸĒŸ Tiled {count} Camoufox windows into 2×2 grid")
116
+ except Exception as e:
117
+ log(f" âš ī¸ Grid tiling failed: {e}", "WARN")
@@ -0,0 +1,14 @@
1
+ """
2
+ Qoder Autopilot — Captcha Solving Sub-package
3
+ ===============================================
4
+ Multiple strategies for solving Aliyun slide CAPTCHAs:
5
+
6
+ - **AI Vision**: Use Gemini/OpenAI to identify the gap position
7
+ - **OpenCV**: 4-method computer vision approach (brightness, edge, template match)
8
+ - **Manual**: Pause and let the user solve it in the visible browser
9
+ - **Slider**: Human-like mouse movement simulation for sliding the puzzle piece
10
+ """
11
+
12
+ from .solver import CaptchaSolver
13
+
14
+ __all__ = ["CaptchaSolver"]
@@ -0,0 +1,327 @@
1
+ """
2
+ Qoder Autopilot — AI Vision Captcha Gap Detection
3
+ ===================================================
4
+ Uses AI vision models (Gemini, OpenAI, etc. via OpenAI-compatible API)
5
+ combined with OpenCV preprocessing to identify the gap position in
6
+ Aliyun slide CAPTCHAs.
7
+
8
+ Strategy:
9
+ 1. Extract puzzle piece and background images from the page
10
+ 2. Use OpenCV to create a silhouette of the puzzle piece
11
+ 3. Crop background to the puzzle strip region
12
+ 4. Apply CLAHE contrast enhancement + edge detection
13
+ 5. Send composite image to AI for gap identification
14
+ """
15
+
16
+ import base64
17
+ import json
18
+ import re
19
+ import time
20
+
21
+ from .. import config
22
+ from ..logger import log
23
+
24
+
25
+ async def gemini_detect_gap(page) -> float | None:
26
+ """Use AI Vision to find the gap position in an Aliyun slide captcha.
27
+
28
+ Extracts puzzle piece silhouette, enhances background strip, and asks
29
+ the AI model to identify the gap X coordinate.
30
+
31
+ Args:
32
+ page: Playwright/Camoufox page object with the captcha loaded.
33
+
34
+ Returns:
35
+ The X offset scaled to track width, or None on failure.
36
+ """
37
+ try:
38
+ import cv2
39
+ import numpy as np
40
+ from openai import OpenAI
41
+
42
+ api_key = config.AI_API_KEY
43
+ if not api_key:
44
+ log(" âš ī¸ AI_API_KEY not set! Check .env file")
45
+ return None
46
+
47
+ model = config.AI_MODEL
48
+ log(f" 🤖 Using model: {model}, API key: {api_key[:8]}...")
49
+ client = OpenAI(api_key=api_key, base_url=config.AI_BASE_URL)
50
+
51
+ # ─── Get image data + puzzle piece position from page ───
52
+ img_data = await page.evaluate("""() => {
53
+ const bg = document.querySelector('#aliyunCaptcha-img');
54
+ const pz = document.querySelector('#aliyunCaptcha-puzzle');
55
+ const track = document.querySelector('#aliyunCaptcha-sliding-body');
56
+ if (!bg || !pz) return null;
57
+ const bgRect = bg.getBoundingClientRect();
58
+
59
+ // Get puzzle piece ACTUAL position using multiple methods
60
+ let pzTop = 0, pzHeight = 50;
61
+
62
+ // Method 1: CSS style.top (most reliable for absolutely positioned elements)
63
+ const styleTop = pz.style.top;
64
+ if (styleTop && styleTop.includes('px')) {
65
+ pzTop = parseFloat(styleTop);
66
+ }
67
+
68
+ // Method 2: offsetTop relative to parent
69
+ if (pzTop === 0 && pz.offsetParent) {
70
+ pzTop = pz.offsetTop;
71
+ }
72
+
73
+ // Method 3: Get actual image dimensions (not container)
74
+ if (pz.tagName === 'IMG') {
75
+ pzHeight = pz.clientHeight || pz.offsetHeight || 50;
76
+ if (pzHeight > bgRect.height * 0.8 && pz.naturalHeight > 0) {
77
+ const scale = bgRect.height / pz.naturalHeight;
78
+ pzHeight = pz.naturalHeight * scale;
79
+ }
80
+ } else {
81
+ pzHeight = pz.clientHeight || 50;
82
+ }
83
+
84
+ // Fallback: if pzHeight is still wrong, estimate ~25% of bg height
85
+ if (pzHeight > bgRect.height * 0.8) {
86
+ pzHeight = Math.round(bgRect.height * 0.25);
87
+ }
88
+
89
+ return {
90
+ bgSrc: bg?.src || '',
91
+ pzSrc: pz?.src || '',
92
+ bgW: bgRect?.width || 300,
93
+ bgH: bgRect?.height || 200,
94
+ bgX: bgRect?.x || 0,
95
+ bgY: bgRect?.y || 0,
96
+ pzW: pz?.clientWidth || pz?.offsetWidth || 50,
97
+ pzH: pzHeight,
98
+ pzTop: pzTop,
99
+ trackW: track?.getBoundingClientRect()?.width || 300,
100
+ };
101
+ }""")
102
+
103
+ if not img_data or not img_data.get("bgSrc"):
104
+ log(" âš ī¸ No background image src (captcha may be loading)")
105
+ return None
106
+
107
+ def get_b64(uri: str) -> str | None:
108
+ if uri.startswith("data:"):
109
+ _, b64data = uri.split(",", 1)
110
+ return b64data
111
+ return None
112
+
113
+ bg_b64 = get_b64(img_data["bgSrc"])
114
+ pz_b64 = get_b64(img_data.get("pzSrc", ""))
115
+
116
+ # Cache puzzle piece from first attempt for reuse on retries
117
+ if pz_b64:
118
+ gemini_detect_gap._cached_pz_b64 = pz_b64
119
+ elif hasattr(gemini_detect_gap, "_cached_pz_b64"):
120
+ pz_b64 = gemini_detect_gap._cached_pz_b64
121
+ log(" 📌 Using cached puzzle piece from first attempt")
122
+
123
+ track_w = int(img_data["trackW"])
124
+
125
+ # ─── Decode background image ───
126
+ bg_img = None
127
+ if bg_b64:
128
+ bg_bytes = base64.b64decode(bg_b64)
129
+ bg_arr = np.frombuffer(bg_bytes, dtype=np.uint8)
130
+ bg_img = cv2.imdecode(bg_arr, cv2.IMREAD_COLOR)
131
+ elif img_data["bgSrc"].startswith("http"):
132
+ try:
133
+ import urllib.request
134
+
135
+ log(" 🔄 Fetching bg image from URL...")
136
+ req = urllib.request.Request(img_data["bgSrc"])
137
+ with urllib.request.urlopen(req, timeout=5) as resp:
138
+ bg_bytes_raw = resp.read()
139
+ bg_arr = np.frombuffer(bg_bytes_raw, dtype=np.uint8)
140
+ bg_img = cv2.imdecode(bg_arr, cv2.IMREAD_COLOR)
141
+ except Exception as e:
142
+ log(f" âš ī¸ Failed to fetch HTTP image: {e}")
143
+
144
+ if bg_img is None:
145
+ log(f" âš ī¸ Failed to decode bg image (src={img_data['bgSrc'][:60]})")
146
+ return None
147
+ actual_h, actual_w = bg_img.shape[:2]
148
+
149
+ # ─── Crop background to puzzle strip ───
150
+ pz_screen_y = img_data.get("pzTop", 0)
151
+ pz_screen_h = img_data["pzH"]
152
+ scale_y = actual_h / img_data["bgH"] if img_data["bgH"] > 0 else 1
153
+ pz_img_y = int(pz_screen_y * scale_y)
154
+ pz_img_h = int(pz_screen_h * scale_y)
155
+
156
+ pad = max(15, pz_img_h // 3)
157
+ crop_y1 = max(0, pz_img_y - pad)
158
+ crop_y2 = min(actual_h, pz_img_y + pz_img_h + pad)
159
+ crop_h = crop_y2 - crop_y1
160
+
161
+ log(
162
+ f" 📐 Strip: pz_y={pz_img_y}, pz_h={pz_img_h} → crop {crop_y1}-{crop_y2} ({crop_h}px)"
163
+ )
164
+
165
+ bg_strip = bg_img[crop_y1:crop_y2, :]
166
+
167
+ # ─── Process puzzle piece → shape silhouette ───
168
+ pz_silhouette_b64 = None
169
+ if pz_b64:
170
+ pz_bytes = base64.b64decode(pz_b64)
171
+ pz_arr = np.frombuffer(pz_bytes, dtype=np.uint8)
172
+ pz_img = cv2.imdecode(pz_arr, cv2.IMREAD_UNCHANGED)
173
+
174
+ if pz_img is not None:
175
+ if len(pz_img.shape) == 3 and pz_img.shape[2] == 4:
176
+ alpha = pz_img[:, :, 3]
177
+ _, mask = cv2.threshold(alpha, 10, 255, cv2.THRESH_BINARY)
178
+ else:
179
+ gray = (
180
+ cv2.cvtColor(pz_img, cv2.COLOR_BGR2GRAY)
181
+ if len(pz_img.shape) == 3
182
+ else pz_img
183
+ )
184
+ _, mask = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)
185
+
186
+ pz_h, pz_w = mask.shape[:2]
187
+ silhouette = np.ones((pz_h, pz_w), dtype=np.uint8) * 255
188
+ silhouette[mask > 0] = 0
189
+
190
+ kernel = np.ones((3, 3), np.uint8)
191
+ dilated = cv2.dilate(255 - silhouette, kernel, iterations=2)
192
+ border = cv2.subtract(dilated, 255 - silhouette)
193
+ silhouette_color = cv2.cvtColor(silhouette, cv2.COLOR_GRAY2BGR)
194
+ silhouette_color[border > 0] = [0, 0, 255]
195
+
196
+ scale = crop_h / pz_h
197
+ new_pz_w = int(pz_w * scale)
198
+ silhouette_resized = cv2.resize(silhouette_color, (new_pz_w, crop_h))
199
+
200
+ _, pz_buf = cv2.imencode(".png", silhouette_resized)
201
+ pz_silhouette_b64 = base64.b64encode(pz_buf).decode("ascii")
202
+
203
+ # ─── Process cropped strip → enhance gap ───
204
+ strip_h = bg_strip.shape[0]
205
+ strip_w = bg_strip.shape[1]
206
+
207
+ # CLAHE on strip
208
+ lab = cv2.cvtColor(bg_strip, cv2.COLOR_BGR2LAB)
209
+ l_ch, a_ch, b_ch = cv2.split(lab)
210
+ clahe = cv2.createCLAHE(clipLimit=5.0, tileGridSize=(8, 8))
211
+ l_enh = clahe.apply(l_ch)
212
+ enhanced = cv2.cvtColor(cv2.merge([l_enh, a_ch, b_ch]), cv2.COLOR_LAB2BGR)
213
+
214
+ # Edge detection on strip
215
+ gray = cv2.cvtColor(bg_strip, cv2.COLOR_BGR2GRAY)
216
+ edges = cv2.Canny(cv2.GaussianBlur(gray, (3, 3), 0), 40, 120)
217
+ edges_color = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
218
+
219
+ # Composite: original | enhanced | edges
220
+ composite = np.hstack([bg_strip, enhanced, edges_color])
221
+
222
+ # Pad if too thin (AI models struggle with very thin images)
223
+ min_height = 100
224
+ if composite.shape[0] < min_height:
225
+ pad_top = (min_height - composite.shape[0]) // 2
226
+ pad_bottom = min_height - composite.shape[0] - pad_top
227
+ composite = cv2.copyMakeBorder(
228
+ composite,
229
+ pad_top,
230
+ pad_bottom,
231
+ 0,
232
+ 0,
233
+ cv2.BORDER_CONSTANT,
234
+ value=[200, 200, 200],
235
+ )
236
+
237
+ _, comp_buf = cv2.imencode(".png", composite)
238
+ comp_b64 = base64.b64encode(comp_buf).decode("ascii")
239
+ comp_w = composite.shape[1]
240
+
241
+ log(
242
+ f" đŸ”Ŧ CV2: strip={strip_w}x{strip_h}, composite={comp_w}x{strip_h}, "
243
+ f"puzzle={'yes' if pz_silhouette_b64 else 'no'}"
244
+ )
245
+
246
+ # Save debug screenshot
247
+ try:
248
+ dbg_dir = config.SCREENSHOTS_DIR
249
+ dbg_dir.mkdir(exist_ok=True)
250
+ with open(dbg_dir / f"captcha_strip_{int(time.time())}.png", "wb") as f:
251
+ f.write(comp_buf.tobytes())
252
+ except Exception:
253
+ pass
254
+
255
+ # ─── AI Vision: Find gap in cropped strip ───
256
+ prompt = f"""You are solving a jigsaw slide puzzle CAPTCHA.
257
+
258
+ IMAGE: A horizontal strip cropped from the background at the exact height where the puzzle piece goes. Shows 3 views side by side:
259
+ - LEFT: Original photo
260
+ - MIDDLE: Contrast-enhanced (gap/shadow is darker and more visible)
261
+ - RIGHT: Edge detection (outlines highlighted)
262
+
263
+ The strip is {strip_w}px wide and {strip_h}px tall. All three views show the SAME area.
264
+
265
+ YOUR TASK: Find the GAP/CUTOUT in the background where the puzzle piece should go.
266
+ Look for a dark shadow, cutout, or missing piece in the image. It appears as a darker area with distinct edges.
267
+
268
+ Return the X coordinate (in pixels from LEFT edge) of the CENTER of the gap.
269
+ Answer MUST be between 10 and {strip_w - 10}.
270
+
271
+ Respond ONLY with JSON: {{"x": 150, "confidence": 0.95}}"""
272
+
273
+ content = [{"type": "text", "text": prompt}]
274
+ content.append(
275
+ {
276
+ "type": "text",
277
+ "text": "BACKGROUND STRIP (original | enhanced | edges):",
278
+ }
279
+ )
280
+ content.append(
281
+ {
282
+ "type": "image_url",
283
+ "image_url": {"url": f"data:image/png;base64,{comp_b64}"},
284
+ }
285
+ )
286
+
287
+ response = client.chat.completions.create(
288
+ model=model,
289
+ messages=[{"role": "user", "content": content}],
290
+ max_tokens=1000,
291
+ temperature=0,
292
+ )
293
+
294
+ if not response.choices or not response.choices[0].message:
295
+ log(f" âš ī¸ AI returned empty response object: {response}")
296
+ return None
297
+
298
+ text = (response.choices[0].message.content or "").strip()
299
+ finish = response.choices[0].finish_reason
300
+ usage = getattr(response, "usage", None)
301
+ log(f" 🤖 AI raw response: {repr(text[:200])} | finish={finish} | usage={usage}")
302
+ text = re.sub(r"^```(?:json)?\s*", "", text)
303
+ text = re.sub(r"\s*```$", "", text)
304
+
305
+ match = re.search(r'\{[^}]*"x"\s*:\s*(\d+)[^}]*\}', text)
306
+ if match:
307
+ data = json.loads(match.group())
308
+ gap_x = int(data.get("x", 0))
309
+ conf = float(data.get("confidence", 0.8))
310
+
311
+ if 0 < gap_x < actual_w:
312
+ scaled_x = (gap_x / actual_w) * track_w
313
+ log(
314
+ f" 🤖 AI match: x={gap_x}px (img {actual_w}px) "
315
+ f"→ track {scaled_x:.0f}/{track_w}px, conf={conf:.2f}"
316
+ )
317
+ return scaled_x
318
+ else:
319
+ log(f" âš ī¸ Out-of-range x={gap_x} (max {actual_w})")
320
+ else:
321
+ log(f" âš ī¸ Parse fail: {text[:120]}")
322
+
323
+ return None
324
+
325
+ except Exception as e:
326
+ log(f" âš ī¸ AI+CV2 error: {e}")
327
+ return None
@@ -0,0 +1,95 @@
1
+ """
2
+ Qoder Autopilot — Manual Captcha Solving
3
+ ==========================================
4
+ Pauses the automation and lets the user solve the captcha manually
5
+ in the visible browser window. Auto-detects when captcha disappears.
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+
11
+ from .. import config
12
+ from ..logger import log, log_err, log_ok
13
+
14
+
15
+ async def handle_captcha_manual(page) -> bool:
16
+ """Manual captcha mode: pause, let user solve it, auto-detect completion.
17
+
18
+ The browser must be visible (non-headless) for this to work.
19
+ Polls for captcha disappearance every second with a configurable timeout.
20
+
21
+ Args:
22
+ page: Playwright/Camoufox page object.
23
+
24
+ Returns:
25
+ True if captcha was solved, False on timeout.
26
+ """
27
+ await asyncio.sleep(2)
28
+
29
+ has_captcha = await page.evaluate("""() => {
30
+ const sels = ['#aliyunCaptcha-sliding', '.aliyunCaptcha', '#nc_1_wrapper',
31
+ '.nc-container', 'iframe[src*="captcha"]', '.slide-verify',
32
+ '[class*="captcha"]', '[id*="captcha"]'];
33
+ for (const s of sels) {
34
+ const el = document.querySelector(s);
35
+ if (el && el.offsetParent !== null) return true;
36
+ }
37
+ return document.querySelectorAll(
38
+ 'iframe[src*="captcha"], iframe[src*="aliyun"]'
39
+ ).length > 0;
40
+ }""")
41
+
42
+ if not has_captcha:
43
+ log(" â„šī¸ No captcha detected")
44
+ return True
45
+
46
+ log("")
47
+ log(" ╔══════════════════════════════════════════════════╗")
48
+ log(" ║ 🧑 MANUAL CAPTCHA MODE ║")
49
+ log(" ║ ║")
50
+ log(" ║ Captcha detected! Please solve it manually ║")
51
+ log(" ║ in the browser window (slide the puzzle). ║")
52
+ log(" ║ ║")
53
+ log(" ║ Script will auto-continue once captcha is gone. ║")
54
+ log(f" ║ Timeout: {config.CAPTCHA_TIMEOUT} seconds.{' ' * 22}║")
55
+ log(" ╚══════════════════════════════════════════════════╝")
56
+ log("")
57
+
58
+ # Take screenshot for reference
59
+ config.SCREENSHOTS_DIR.mkdir(exist_ok=True)
60
+ await page.screenshot(
61
+ path=str(config.SCREENSHOTS_DIR / f"manual_captcha_{int(time.time())}.png")
62
+ )
63
+
64
+ # Poll until captcha disappears or timeout
65
+ start = time.time()
66
+ last_dot = 0
67
+
68
+ while time.time() - start < config.CAPTCHA_TIMEOUT:
69
+ elapsed = int(time.time() - start)
70
+
71
+ # Print a status every 5 seconds
72
+ if elapsed // 5 > last_dot:
73
+ last_dot = elapsed // 5
74
+ log(f" âŗ Waiting for manual solve... ({elapsed}s)")
75
+
76
+ still_present = await page.evaluate("""() => {
77
+ const sels = ['#aliyunCaptcha-sliding-slider', '#aliyunCaptcha-window-float',
78
+ '#aliyunCaptcha-sliding', '.aliyunCaptcha', '#captcha-element',
79
+ '#nc_1_wrapper', '.nc-container'];
80
+ for (const s of sels) {
81
+ const el = document.querySelector(s);
82
+ if (el && el.offsetParent !== null) return true;
83
+ }
84
+ return false;
85
+ }""")
86
+
87
+ if not still_present:
88
+ log_ok(f"Manual captcha solved in {elapsed}s! 🎉")
89
+ return True
90
+
91
+ await asyncio.sleep(1)
92
+
93
+ log_err(f"Manual captcha timeout ({config.CAPTCHA_TIMEOUT}s) — user didn't solve it")
94
+ await page.screenshot(path=str(config.SCREENSHOTS_DIR / "manual_captcha_timeout.png"))
95
+ return False