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.
- qoder_autopilot/__init__.py +59 -0
- qoder_autopilot/__main__.py +5 -0
- qoder_autopilot/browser/__init__.py +5 -0
- qoder_autopilot/browser/camoufox.py +62 -0
- qoder_autopilot/browser/window_tiler.py +117 -0
- qoder_autopilot/captcha/__init__.py +14 -0
- qoder_autopilot/captcha/ai_vision.py +327 -0
- qoder_autopilot/captcha/manual.py +95 -0
- qoder_autopilot/captcha/opencv_detect.py +184 -0
- qoder_autopilot/captcha/slider.py +86 -0
- qoder_autopilot/captcha/solver.py +165 -0
- qoder_autopilot/cli.py +431 -0
- qoder_autopilot/config.py +300 -0
- qoder_autopilot/credentials.py +44 -0
- qoder_autopilot/deploy.py +338 -0
- qoder_autopilot/errors.py +132 -0
- qoder_autopilot/first_run.py +107 -0
- qoder_autopilot/identity.py +66 -0
- qoder_autopilot/logger.py +119 -0
- qoder_autopilot/ninerouter.py +105 -0
- qoder_autopilot/oauth.py +149 -0
- qoder_autopilot/otp.py +67 -0
- qoder_autopilot/register.py +383 -0
- qoder_autopilot/temp_mail.py +307 -0
- qoder_autopilot/user_config.py +171 -0
- qoder_autopilot/worker_template/package.json +21 -0
- qoder_autopilot/worker_template/schema.sql +24 -0
- qoder_autopilot/worker_template/scripts/setup.sh +186 -0
- qoder_autopilot/worker_template/src/config.js +6 -0
- qoder_autopilot/worker_template/src/handlers/api.js +226 -0
- qoder_autopilot/worker_template/src/handlers/email.js +50 -0
- qoder_autopilot/worker_template/src/index.js +43 -0
- qoder_autopilot/worker_template/src/utils.js +35 -0
- qoder_autopilot/worker_template/wrangler.toml.example +19 -0
- qoder_autopilot-0.2.1.dist-info/METADATA +299 -0
- qoder_autopilot-0.2.1.dist-info/RECORD +39 -0
- qoder_autopilot-0.2.1.dist-info/WHEEL +4 -0
- qoder_autopilot-0.2.1.dist-info/entry_points.txt +2 -0
- 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,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
|