dragg2 commited on
Commit
d272ff0
·
verified ·
1 Parent(s): d2d6db4

Upload 32 files

Browse files
src/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (188 Bytes). View file
 
src/__pycache__/main.cpython-311.pyc ADDED
Binary file (5.87 kB). View file
 
src/api/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (184 Bytes). View file
 
src/api/__pycache__/routes.cpython-311.pyc ADDED
Binary file (6.48 kB). View file
 
src/core/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (198 Bytes). View file
 
src/core/__pycache__/config.cpython-311.pyc ADDED
Binary file (5.23 kB). View file
 
src/main.py CHANGED
@@ -77,11 +77,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
77
  task_manager.register_solver(task_type, v2_solver)
78
  log.info("Registered reCAPTCHA v2 solver for types: %s", _RECAPTCHA_V2_TYPES)
79
 
80
- hcaptcha_solver = HCaptchaSolver(config)
81
- await hcaptcha_solver.start()
82
- for task_type in _HCAPTCHA_TYPES:
83
- task_manager.register_solver(task_type, hcaptcha_solver)
84
- log.info("Registered hCaptcha solver for types: %s", _HCAPTCHA_TYPES)
 
 
85
 
86
  turnstile_solver = TurnstileSolver(config)
87
  await turnstile_solver.start()
@@ -94,10 +96,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
94
  task_manager.register_solver(task_type, recognizer)
95
  log.info("Registered image captcha recognizer for types: %s", _IMAGE_TEXT_TYPES)
96
 
97
- classifier = ClassificationSolver(config)
98
- for task_type in _CLASSIFICATION_TYPES:
99
- task_manager.register_solver(task_type, classifier)
100
- log.info("Registered classification solver for types: %s", _CLASSIFICATION_TYPES)
101
 
102
  yield
103
  # ── shutdown ──
 
77
  task_manager.register_solver(task_type, v2_solver)
78
  log.info("Registered reCAPTCHA v2 solver for types: %s", _RECAPTCHA_V2_TYPES)
79
 
80
+ classifier = ClassificationSolver(config)
81
+
82
+ hcaptcha_solver = HCaptchaSolver(config, classifier=classifier)
83
+ await hcaptcha_solver.start()
84
+ for task_type in _HCAPTCHA_TYPES:
85
+ task_manager.register_solver(task_type, hcaptcha_solver)
86
+ log.info("Registered hCaptcha solver for types: %s", _HCAPTCHA_TYPES)
87
 
88
  turnstile_solver = TurnstileSolver(config)
89
  await turnstile_solver.start()
 
96
  task_manager.register_solver(task_type, recognizer)
97
  log.info("Registered image captcha recognizer for types: %s", _IMAGE_TEXT_TYPES)
98
 
99
+ for task_type in _CLASSIFICATION_TYPES:
100
+ task_manager.register_solver(task_type, classifier)
101
+ log.info("Registered classification solver for types: %s", _CLASSIFICATION_TYPES)
 
102
 
103
  yield
104
  # ── shutdown ──
src/models/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (195 Bytes). View file
 
src/models/__pycache__/task.cpython-311.pyc ADDED
Binary file (3.86 kB). View file
 
src/models/task.py CHANGED
@@ -7,20 +7,21 @@ from pydantic import BaseModel, Field
7
 
8
  # ── createTask ──────────────────────────────────────────────
9
 
10
- class TaskObject(BaseModel):
11
- type: str
12
- websiteURL: str | None = None
13
- websiteKey: str | None = None
14
  pageAction: str | None = None
15
  minScore: float | None = None
16
  isInvisible: bool | None = None
17
  # Image captcha / classification fields
18
  body: str | None = None
19
- image: str | None = None
20
- images: list[str] | None = None
21
- question: str | None = None
22
- queries: list[str] | str | None = None
23
- project_name: str | None = None
 
24
 
25
 
26
  class CreateTaskRequest(BaseModel):
 
7
 
8
  # ── createTask ──────────────────────────────────────────────
9
 
10
+ class TaskObject(BaseModel):
11
+ type: str
12
+ websiteURL: str | None = None
13
+ websiteKey: str | None = None
14
  pageAction: str | None = None
15
  minScore: float | None = None
16
  isInvisible: bool | None = None
17
  # Image captcha / classification fields
18
  body: str | None = None
19
+ image: str | None = None
20
+ images: list[str] | None = None
21
+ examples: list[str] | None = None
22
+ question: str | None = None
23
+ queries: list[str] | str | None = None
24
+ project_name: str | None = None
25
 
26
 
27
  class CreateTaskRequest(BaseModel):
src/services/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (199 Bytes). View file
 
src/services/__pycache__/classification.cpython-311.pyc ADDED
Binary file (12 kB). View file
 
src/services/__pycache__/hcaptcha.cpython-311.pyc ADDED
Binary file (25.1 kB). View file
 
src/services/__pycache__/recaptcha_v2.cpython-311.pyc ADDED
Binary file (15 kB). View file
 
src/services/__pycache__/recaptcha_v3.cpython-311.pyc ADDED
Binary file (8.14 kB). View file
 
src/services/__pycache__/recognition.cpython-311.pyc ADDED
Binary file (8.36 kB). View file
 
src/services/__pycache__/task_manager.cpython-311.pyc ADDED
Binary file (6.18 kB). View file
 
src/services/__pycache__/turnstile.cpython-311.pyc ADDED
Binary file (7.79 kB). View file
 
src/services/classification.py CHANGED
@@ -23,23 +23,29 @@ from ..core.config import Config
23
 
24
  log = logging.getLogger(__name__)
25
 
26
- HCAPTCHA_SYSTEM_PROMPT = """\
27
- You are an image classification assistant for HCaptcha challenges.
28
- Given a question and one or more base64-encoded images, determine which images match the question.
29
-
30
- Return STRICT JSON only. No markdown, no extra text.
31
-
32
- For single-image questions (is this image X?):
33
- {"answer": true} or {"answer": false}
34
-
35
- For multi-image grid questions (select all images containing X):
36
- {"answer": [0, 2, 5]}
37
- where numbers are 0-indexed positions of matching images.
38
-
39
- Rules:
40
- - Return ONLY the JSON object, nothing else.
41
- - Be precise with your classification.
42
- """
 
 
 
 
 
 
43
 
44
  RECAPTCHA_V2_SYSTEM_PROMPT = """\
45
  You are an image classification assistant for reCAPTCHA v2 challenges.
@@ -85,7 +91,7 @@ Rules:
85
  """
86
 
87
 
88
- class ClassificationSolver:
89
  """Solves image classification captchas using a vision model."""
90
 
91
  def __init__(self, config: Config) -> None:
@@ -95,18 +101,28 @@ class ClassificationSolver:
95
  api_key=config.local_api_key,
96
  )
97
 
98
- async def solve(self, params: dict[str, Any]) -> dict[str, Any]:
99
- task_type = params.get("type", "")
100
- system_prompt = self._get_system_prompt(task_type)
101
- question = params.get("question", "") or params.get("queries", "")
102
-
103
- # Handle different image field names across task types
104
- images = self._extract_images(params)
105
- if not images:
106
- raise ValueError("No image data provided")
107
-
108
- result = await self._classify(system_prompt, question, images)
109
- return result
 
 
 
 
 
 
 
 
 
 
110
 
111
  @staticmethod
112
  def _get_system_prompt(task_type: str) -> str:
@@ -119,7 +135,7 @@ class ClassificationSolver:
119
  return prompts.get(task_type, RECAPTCHA_V2_SYSTEM_PROMPT)
120
 
121
  @staticmethod
122
- def _extract_images(params: dict[str, Any]) -> list[str]:
123
  """Extract base64 image(s) from various param formats."""
124
  images: list[str] = []
125
 
@@ -137,10 +153,19 @@ class ClassificationSolver:
137
  images.append(params["body"])
138
 
139
  # HCaptcha queries format: list of base64 strings
140
- if "queries" in params and isinstance(params["queries"], list):
141
- images.extend(params["queries"])
142
-
143
- return images
 
 
 
 
 
 
 
 
 
144
 
145
  @staticmethod
146
  def _prepare_image(b64_data: str) -> str:
@@ -156,17 +181,55 @@ class ClassificationSolver:
156
  except Exception:
157
  return f"data:image/png;base64,{b64_data}"
158
 
159
- async def _classify(
160
- self, system_prompt: str, question: str, images: list[str]
161
- ) -> dict[str, Any]:
162
- content: list[dict[str, Any]] = []
163
-
164
- for img_b64 in images:
165
- data_url = self._prepare_image(img_b64)
166
- content.append({
167
- "type": "image_url",
168
- "image_url": {"url": data_url, "detail": "high"},
169
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
  user_text = question if question else "Classify this captcha image."
172
  content.append({"type": "text", "text": user_text})
@@ -174,17 +237,18 @@ class ClassificationSolver:
174
  last_error: Exception | None = None
175
  for attempt in range(self._config.captcha_retries):
176
  try:
177
- response = await self._client.chat.completions.create(
178
- model=self._config.captcha_multimodal_model,
179
- temperature=0.05,
180
- max_tokens=512,
181
  messages=[
182
  {"role": "system", "content": system_prompt},
183
  {"role": "user", "content": content},
184
  ],
185
- )
186
- raw = response.choices[0].message.content or ""
187
- return self._parse_json(raw)
 
188
  except Exception as exc:
189
  last_error = exc
190
  log.warning("Classification attempt %d failed: %s", attempt + 1, exc)
 
23
 
24
  log = logging.getLogger(__name__)
25
 
26
+ HCAPTCHA_SYSTEM_PROMPT = """\
27
+ You are an image classification assistant for HCaptcha challenges.
28
+
29
+ You may receive:
30
+ 1. optional sample/example images that show the target object, and
31
+ 2. one or more candidate captcha images that must be classified.
32
+
33
+ Determine which candidate images match the question or the sample images.
34
+
35
+ Return STRICT JSON only. No markdown, no extra text.
36
+
37
+ For single-image questions (is this image X?):
38
+ {"answer": true} or {"answer": false}
39
+
40
+ For multi-image selection questions:
41
+ {"answer": [0, 2, 5]}
42
+ where numbers are 0-indexed positions of matching candidate images.
43
+
44
+ Rules:
45
+ - Return ONLY the JSON object, nothing else.
46
+ - Use example images only as references; do not include them in the returned indices.
47
+ - Be precise with your classification.
48
+ """
49
 
50
  RECAPTCHA_V2_SYSTEM_PROMPT = """\
51
  You are an image classification assistant for reCAPTCHA v2 challenges.
 
91
  """
92
 
93
 
94
+ class ClassificationSolver:
95
  """Solves image classification captchas using a vision model."""
96
 
97
  def __init__(self, config: Config) -> None:
 
101
  api_key=config.local_api_key,
102
  )
103
 
104
+ async def solve(self, params: dict[str, Any]) -> dict[str, Any]:
105
+ task_type = params.get("type", "")
106
+ system_prompt = self._get_system_prompt(task_type)
107
+ question = params.get("question", "") or params.get("queries", "")
108
+
109
+ # Handle different image field names across task types
110
+ images = self._extract_images(params)
111
+ if not images:
112
+ raise ValueError("No image data provided")
113
+
114
+ examples = self._extract_examples(params)
115
+ log.info(
116
+ "Classification request: task_type=%s model=%s images=%d examples=%d question=%r",
117
+ task_type or "unknown",
118
+ self._config.captcha_multimodal_model,
119
+ len(images),
120
+ len(examples),
121
+ question[:120] if isinstance(question, str) else question,
122
+ )
123
+ result = await self._classify(system_prompt, question, images, examples=examples)
124
+ log.info("Classification parsed result: %s", result)
125
+ return result
126
 
127
  @staticmethod
128
  def _get_system_prompt(task_type: str) -> str:
 
135
  return prompts.get(task_type, RECAPTCHA_V2_SYSTEM_PROMPT)
136
 
137
  @staticmethod
138
+ def _extract_images(params: dict[str, Any]) -> list[str]:
139
  """Extract base64 image(s) from various param formats."""
140
  images: list[str] = []
141
 
 
153
  images.append(params["body"])
154
 
155
  # HCaptcha queries format: list of base64 strings
156
+ if "queries" in params and isinstance(params["queries"], list):
157
+ images.extend(params["queries"])
158
+
159
+ return images
160
+
161
+ @staticmethod
162
+ def _extract_examples(params: dict[str, Any]) -> list[str]:
163
+ examples = params.get("examples")
164
+ if isinstance(examples, list):
165
+ return [item for item in examples if isinstance(item, str)]
166
+ if isinstance(examples, str):
167
+ return [examples]
168
+ return []
169
 
170
  @staticmethod
171
  def _prepare_image(b64_data: str) -> str:
 
181
  except Exception:
182
  return f"data:image/png;base64,{b64_data}"
183
 
184
+ async def _classify(
185
+ self,
186
+ system_prompt: str,
187
+ question: str,
188
+ images: list[str],
189
+ *,
190
+ examples: list[str] | None = None,
191
+ ) -> dict[str, Any]:
192
+ content: list[dict[str, Any]] = []
193
+
194
+ prepared_examples = examples or []
195
+ if prepared_examples:
196
+ content.append(
197
+ {
198
+ "type": "text",
199
+ "text": (
200
+ "Sample images showing the target object. "
201
+ "Do not classify these; use them only as references."
202
+ ),
203
+ }
204
+ )
205
+ for example_b64 in prepared_examples:
206
+ data_url = self._prepare_image(example_b64)
207
+ content.append(
208
+ {
209
+ "type": "image_url",
210
+ "image_url": {"url": data_url, "detail": "high"},
211
+ }
212
+ )
213
+
214
+ if len(images) > 1:
215
+ content.append(
216
+ {
217
+ "type": "text",
218
+ "text": (
219
+ "Candidate images to classify. "
220
+ "Indices are 0-based in display order."
221
+ ),
222
+ }
223
+ )
224
+
225
+ for img_b64 in images:
226
+ data_url = self._prepare_image(img_b64)
227
+ content.append(
228
+ {
229
+ "type": "image_url",
230
+ "image_url": {"url": data_url, "detail": "high"},
231
+ }
232
+ )
233
 
234
  user_text = question if question else "Classify this captcha image."
235
  content.append({"type": "text", "text": user_text})
 
237
  last_error: Exception | None = None
238
  for attempt in range(self._config.captcha_retries):
239
  try:
240
+ response = await self._client.chat.completions.create(
241
+ model=self._config.captcha_multimodal_model,
242
+ temperature=0.05,
243
+ max_tokens=512,
244
  messages=[
245
  {"role": "system", "content": system_prompt},
246
  {"role": "user", "content": content},
247
  ],
248
+ )
249
+ raw = response.choices[0].message.content or ""
250
+ log.info("Classification raw response: %s", raw[:300])
251
+ return self._parse_json(raw)
252
  except Exception as exc:
253
  last_error = exc
254
  log.warning("Classification attempt %d failed: %s", attempt + 1, exc)
src/services/hcaptcha.py CHANGED
@@ -1,142 +1,477 @@
1
- """HCaptcha solver using Playwright browser automation.
2
-
3
- Supports HCaptchaTaskProxyless task type.
4
- Visits the target page, interacts with the hCaptcha widget, and extracts the response token.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import asyncio
10
- import logging
11
- from typing import Any
12
-
13
- from playwright.async_api import Browser, Playwright, async_playwright
14
-
15
- from ..core.config import Config
16
-
17
- log = logging.getLogger(__name__)
18
-
19
- _STEALTH_JS = """
20
- Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
21
- Object.defineProperty(navigator, 'languages', {get: () => ['en-US', 'en']});
22
- Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
23
- window.chrome = {runtime: {}, loadTimes: () => {}, csi: () => {}};
24
- """
25
-
26
- _EXTRACT_HCAPTCHA_TOKEN_JS = """
27
- () => {
28
- const textarea = document.querySelector('[name="h-captcha-response"]')
29
- || document.querySelector('[name="g-recaptcha-response"]');
30
- if (textarea && textarea.value && textarea.value.length > 20) {
31
- return textarea.value;
32
- }
33
- if (window.hcaptcha && typeof window.hcaptcha.getResponse === 'function') {
34
- const resp = window.hcaptcha.getResponse();
35
- if (resp && resp.length > 20) return resp;
36
- }
37
- return null;
38
- }
39
- """
40
-
41
-
42
- class HCaptchaSolver:
43
- """Solves HCaptchaTaskProxyless tasks via headless Chromium."""
44
-
45
- def __init__(self, config: Config, browser: Browser | None = None) -> None:
46
- self._config = config
47
- self._playwright: Playwright | None = None
48
- self._browser: Browser | None = browser
49
- self._owns_browser = browser is None
50
-
51
- async def start(self) -> None:
52
- if self._browser is not None:
53
- return
54
- self._playwright = await async_playwright().start()
55
- self._browser = await self._playwright.chromium.launch(
56
- headless=self._config.browser_headless,
57
- args=[
58
- "--disable-blink-features=AutomationControlled",
59
- "--no-sandbox",
60
- "--disable-dev-shm-usage",
61
- "--disable-gpu",
62
- ],
63
- )
64
- log.info("HCaptchaSolver browser started")
65
-
66
- async def stop(self) -> None:
67
- if self._owns_browser:
68
- if self._browser:
69
- await self._browser.close()
70
- if self._playwright:
71
- await self._playwright.stop()
72
- log.info("HCaptchaSolver stopped")
73
-
74
- async def solve(self, params: dict[str, Any]) -> dict[str, Any]:
75
- website_url = params["websiteURL"]
76
- website_key = params["websiteKey"]
77
-
78
- last_error: Exception | None = None
79
- for attempt in range(self._config.captcha_retries):
80
- try:
81
- token = await self._solve_once(website_url, website_key)
82
- return {"gRecaptchaResponse": token}
83
- except Exception as exc:
84
- last_error = exc
85
- log.warning(
86
- "HCaptcha attempt %d/%d failed: %s",
87
- attempt + 1,
88
- self._config.captcha_retries,
89
- exc,
90
- )
91
- if attempt < self._config.captcha_retries - 1:
92
- await asyncio.sleep(2)
93
-
94
- raise RuntimeError(
95
- f"HCaptcha failed after {self._config.captcha_retries} attempts: {last_error}"
96
- )
97
-
98
- async def _solve_once(self, website_url: str, website_key: str) -> str:
99
- assert self._browser is not None
100
-
101
- context = await self._browser.new_context(
102
- user_agent=(
103
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
104
- "AppleWebKit/537.36 (KHTML, like Gecko) "
105
- "Chrome/131.0.0.0 Safari/537.36"
106
- ),
107
- viewport={"width": 1920, "height": 1080},
108
- locale="en-US",
109
- )
110
- page = await context.new_page()
111
- await page.add_init_script(_STEALTH_JS)
112
-
113
- try:
114
- timeout_ms = self._config.browser_timeout * 1000
115
- await page.goto(website_url, wait_until="networkidle", timeout=timeout_ms)
116
-
117
- await page.mouse.move(400, 300)
118
- await asyncio.sleep(1)
119
-
120
- # Click only the checkbox iframe — match by specific title to avoid the challenge iframe
121
- iframe_element = page.frame_locator(
122
- 'iframe[title="Widget containing checkbox for hCaptcha security challenge"]'
123
- )
124
- checkbox = iframe_element.locator("#checkbox")
125
- await checkbox.click(timeout=10_000)
126
-
127
- # Wait for token may require challenge completion; poll up to 30s
128
- for _ in range(6):
129
- await asyncio.sleep(5)
130
- token = await page.evaluate(_EXTRACT_HCAPTCHA_TOKEN_JS)
131
- if isinstance(token, str) and len(token) > 20:
132
- break
133
- else:
134
- token = None
135
-
136
- if not isinstance(token, str) or len(token) < 20:
137
- raise RuntimeError(f"Invalid hCaptcha token: {token!r}")
138
-
139
- log.info("Got hCaptcha token (len=%d)", len(token))
140
- return token
141
- finally:
142
- await context.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HCaptcha solver using Playwright browser automation.
2
+
3
+ Supports ``HCaptchaTaskProxyless`` task type.
4
+
5
+ Strategy:
6
+ 1. Visit the target page with a realistic browser context.
7
+ 2. Click the hCaptcha checkbox.
8
+ 3. If a token is issued immediately, return it.
9
+ 4. If an image-selection challenge appears, extract the prompt + tile images,
10
+ call ``ClassificationSolver`` for ``HCaptchaClassification``-style
11
+ reasoning, click the matching tiles, submit the challenge, and continue
12
+ polling for the token.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import base64
19
+ import logging
20
+ from typing import Any
21
+ from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
22
+
23
+ from playwright.async_api import Browser, ElementHandle, Frame, Page, Playwright, async_playwright
24
+
25
+ from ..core.config import Config
26
+ from .classification import ClassificationSolver
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ _STEALTH_JS = """
31
+ Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
32
+ Object.defineProperty(navigator, 'languages', {get: () => ['en-US', 'en']});
33
+ Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
34
+ window.chrome = {runtime: {}, loadTimes: () => {}, csi: () => {}};
35
+ """
36
+
37
+ _EXTRACT_HCAPTCHA_TOKEN_JS = """
38
+ () => {
39
+ const textarea = document.querySelector('[name="h-captcha-response"]')
40
+ || document.querySelector('[name="g-recaptcha-response"]');
41
+ if (textarea && textarea.value && textarea.value.length > 20) {
42
+ return textarea.value;
43
+ }
44
+ if (window.hcaptcha && typeof window.hcaptcha.getResponse === 'function') {
45
+ const resp = window.hcaptcha.getResponse();
46
+ if (resp && resp.length > 20) return resp;
47
+ }
48
+ return null;
49
+ }
50
+ """
51
+
52
+ _QUESTION_JS = """
53
+ () => {
54
+ const prompt = document.querySelector('.prompt-text')
55
+ || document.querySelector('h2.prompt-text')
56
+ || document.querySelector('.challenge-prompt')
57
+ || document.querySelector('[class*="prompt"]');
58
+ return prompt?.textContent?.trim() || null;
59
+ }
60
+ """
61
+
62
+ _CHALLENGE_TILE_SELECTORS = (
63
+ ".task-grid .task-image",
64
+ ".task-grid .task",
65
+ ".task-grid .image",
66
+ ".challenge-container .task-image",
67
+ ".challenge-view .task-image",
68
+ ".task-image",
69
+ ".task",
70
+ )
71
+
72
+ _EXAMPLE_IMAGE_SELECTORS = (
73
+ ".challenge-example .image",
74
+ ".challenge-example",
75
+ ".example-wrapper .image",
76
+ )
77
+
78
+ _VERIFY_BUTTON_SELECTORS = (
79
+ ".button-submit",
80
+ 'button[type="submit"]',
81
+ 'button[aria-label*="Verify"]',
82
+ )
83
+
84
+
85
+ class HCaptchaSolver:
86
+ """Solves ``HCaptchaTaskProxyless`` tasks via Playwright."""
87
+
88
+ def __init__(
89
+ self,
90
+ config: Config,
91
+ browser: Browser | None = None,
92
+ classifier: ClassificationSolver | None = None,
93
+ ) -> None:
94
+ self._config = config
95
+ self._playwright: Playwright | None = None
96
+ self._browser: Browser | None = browser
97
+ self._owns_browser = browser is None
98
+ self._classifier = classifier
99
+
100
+ async def start(self) -> None:
101
+ if self._browser is not None:
102
+ return
103
+ self._playwright = await async_playwright().start()
104
+ self._browser = await self._playwright.chromium.launch(
105
+ headless=self._config.browser_headless,
106
+ args=[
107
+ "--disable-blink-features=AutomationControlled",
108
+ "--no-sandbox",
109
+ "--disable-dev-shm-usage",
110
+ "--disable-gpu",
111
+ ],
112
+ )
113
+ log.info("HCaptchaSolver browser started")
114
+
115
+ async def stop(self) -> None:
116
+ if self._owns_browser:
117
+ if self._browser:
118
+ await self._browser.close()
119
+ if self._playwright:
120
+ await self._playwright.stop()
121
+ log.info("HCaptchaSolver stopped")
122
+
123
+ async def solve(self, params: dict[str, Any]) -> dict[str, Any]:
124
+ website_url = params["websiteURL"]
125
+ website_key = params["websiteKey"]
126
+
127
+ last_error: Exception | None = None
128
+ for attempt in range(self._config.captcha_retries):
129
+ try:
130
+ token = await self._solve_once(website_url, website_key)
131
+ return {"gRecaptchaResponse": token}
132
+ except Exception as exc:
133
+ last_error = exc
134
+ log.warning(
135
+ "HCaptcha attempt %d/%d failed: %s",
136
+ attempt + 1,
137
+ self._config.captcha_retries,
138
+ exc,
139
+ )
140
+ if attempt < self._config.captcha_retries - 1:
141
+ await asyncio.sleep(2)
142
+
143
+ raise RuntimeError(
144
+ f"HCaptcha failed after {self._config.captcha_retries} attempts: {last_error}"
145
+ )
146
+
147
+ async def _solve_once(self, website_url: str, website_key: str) -> str:
148
+ assert self._browser is not None
149
+ target_url = self._prepare_target_url(website_url, website_key)
150
+ if target_url != website_url:
151
+ log.info("Normalized hCaptcha target URL to honor requested sitekey: %s", target_url)
152
+
153
+ context = await self._browser.new_context(
154
+ user_agent=(
155
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
156
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
157
+ "Chrome/131.0.0.0 Safari/537.36"
158
+ ),
159
+ viewport={"width": 1920, "height": 1080},
160
+ locale="en-US",
161
+ )
162
+ page = await context.new_page()
163
+ await page.add_init_script(_STEALTH_JS)
164
+
165
+ try:
166
+ timeout_ms = self._config.browser_timeout * 1000
167
+ await page.goto(target_url, wait_until="networkidle", timeout=timeout_ms)
168
+ await page.mouse.move(400, 300)
169
+ await asyncio.sleep(1)
170
+
171
+ await self._click_checkbox(page)
172
+
173
+ # 先给低风险会话一个直接出 token 的机会。
174
+ token = await self._wait_for_token(page, seconds=4)
175
+ if token:
176
+ log.info("Got hCaptcha token directly after checkbox click (len=%d)", len(token))
177
+ return token
178
+
179
+ # 无头环境常见路径:进入图片 challenge,然后走 classification fallback。
180
+ log.info(
181
+ "No direct hCaptcha token after checkbox click, entering classification fallback"
182
+ )
183
+ fallback_handled = await self._solve_image_selection_challenge(page)
184
+ if fallback_handled:
185
+ token = await self._wait_for_token(page)
186
+
187
+ if not isinstance(token, str) or len(token) < 20:
188
+ raise RuntimeError(f"Invalid hCaptcha token: {token!r}")
189
+
190
+ log.info("Got hCaptcha token (len=%d)", len(token))
191
+ return token
192
+ finally:
193
+ await context.close()
194
+
195
+ async def _click_checkbox(self, page: Page) -> None:
196
+ frame = await self._find_frame(page, "checkbox", wait_seconds=10)
197
+ if frame is None:
198
+ raise RuntimeError("Could not find hCaptcha checkbox frame")
199
+
200
+ checkbox = await frame.query_selector("#checkbox")
201
+ if checkbox is None:
202
+ raise RuntimeError("Could not find hCaptcha checkbox element")
203
+
204
+ await checkbox.click(timeout=10_000)
205
+ log.info("Clicked hCaptcha checkbox")
206
+
207
+ async def _wait_for_token(self, page: Page, *, seconds: int | None = None) -> str | None:
208
+ remaining = max(1, seconds or self._config.captcha_timeout)
209
+ for _ in range(remaining):
210
+ token = await page.evaluate(_EXTRACT_HCAPTCHA_TOKEN_JS)
211
+ if isinstance(token, str) and len(token) > 20:
212
+ return token
213
+ await asyncio.sleep(1)
214
+ return None
215
+
216
+ async def _find_frame(
217
+ self, page: Page, frame_role: str, *, wait_seconds: int = 5
218
+ ) -> Frame | None:
219
+ attempts = max(1, wait_seconds * 2)
220
+ for _ in range(attempts):
221
+ for frame in page.frames:
222
+ url = frame.url or ""
223
+ if "hcaptcha" in url and f"frame={frame_role}" in url:
224
+ return frame
225
+ await asyncio.sleep(0.5)
226
+ return None
227
+
228
+ @staticmethod
229
+ def _prepare_target_url(website_url: str, website_key: str) -> str:
230
+ """为官方 demo 自动补齐/对齐 sitekey,确保按请求参数测试真实行为。"""
231
+ if not website_key:
232
+ return website_url
233
+
234
+ parsed = urlsplit(website_url)
235
+ host = parsed.netloc.lower()
236
+ path = parsed.path.rstrip("/")
237
+ is_official_demo = host in {"accounts.hcaptcha.com", "demo.hcaptcha.com"} and path == "/demo"
238
+ if not is_official_demo:
239
+ return website_url
240
+
241
+ query = parse_qs(parsed.query, keep_blank_values=True)
242
+ changed = False
243
+
244
+ current_sitekey = query.get("sitekey", [None])[0]
245
+ if current_sitekey != website_key:
246
+ query["sitekey"] = [website_key]
247
+ changed = True
248
+
249
+ if "hl" not in query:
250
+ query["hl"] = ["en"]
251
+ changed = True
252
+
253
+ if not changed:
254
+ return website_url
255
+
256
+ return urlunsplit(
257
+ (
258
+ parsed.scheme,
259
+ parsed.netloc,
260
+ parsed.path,
261
+ urlencode(query, doseq=True),
262
+ parsed.fragment,
263
+ )
264
+ )
265
+
266
+ async def _solve_image_selection_challenge(self, page: Page) -> bool:
267
+ if self._classifier is None:
268
+ raise RuntimeError(
269
+ "Classification fallback is unavailable because no ClassificationSolver was injected"
270
+ )
271
+
272
+ rounds = max(1, self._config.captcha_retries)
273
+ for round_index in range(rounds):
274
+ token = await self._wait_for_token(page, seconds=1)
275
+ if token:
276
+ return True
277
+
278
+ challenge = await self._collect_selection_challenge(page)
279
+ if challenge is None:
280
+ unsupported_reason = await self._describe_unsupported_challenge(page)
281
+ log.warning(
282
+ "Could not collect hCaptcha image-selection challenge in round %d: %s",
283
+ round_index + 1,
284
+ unsupported_reason,
285
+ )
286
+ if round_index == 0:
287
+ raise RuntimeError(unsupported_reason)
288
+ return False
289
+
290
+ log.info(
291
+ "Collected hCaptcha image-selection challenge in round %d: question=%r tiles=%d examples=%d",
292
+ round_index + 1,
293
+ challenge["question"],
294
+ len(challenge["tiles"]),
295
+ len(challenge["examples"]),
296
+ )
297
+ payload = self._build_classification_payload(
298
+ question=challenge["question"],
299
+ tile_images=challenge["tile_images"],
300
+ examples=challenge["examples"],
301
+ )
302
+ result = await self._classifier.solve(payload)
303
+ log.info("Classification solver returned raw result: %s", result)
304
+ indices = self._extract_selection_indices(
305
+ result=result,
306
+ tile_count=len(challenge["tiles"]),
307
+ )
308
+
309
+ await self._click_selected_tiles(challenge["tiles"], indices)
310
+ await self._click_verify_button(challenge["frame"])
311
+
312
+ token = await self._wait_for_token(page, seconds=6)
313
+ if token:
314
+ return True
315
+
316
+ log.info(
317
+ "hCaptcha challenge round %d submitted without immediate token, retrying",
318
+ round_index + 1,
319
+ )
320
+
321
+ return False
322
+
323
+ async def _collect_selection_challenge(self, page: Page) -> dict[str, Any] | None:
324
+ frame = await self._find_frame(page, "challenge", wait_seconds=10)
325
+ if frame is None:
326
+ return None
327
+
328
+ await asyncio.sleep(1)
329
+ question = await frame.evaluate(_QUESTION_JS)
330
+ if not isinstance(question, str) or not question.strip():
331
+ return None
332
+
333
+ tiles = await self._find_clickable_tiles(frame)
334
+ if not tiles:
335
+ return None
336
+
337
+ tile_entries: list[tuple[ElementHandle[Any], str]] = []
338
+ for tile in tiles:
339
+ encoded = await self._capture_element_base64(tile)
340
+ if encoded:
341
+ tile_entries.append((tile, encoded))
342
+
343
+ if not tile_entries:
344
+ return None
345
+
346
+ return {
347
+ "frame": frame,
348
+ "question": question.strip(),
349
+ "tiles": [tile for tile, _ in tile_entries],
350
+ "tile_images": [encoded for _, encoded in tile_entries],
351
+ "examples": await self._extract_example_images(frame),
352
+ }
353
+
354
+ async def _find_clickable_tiles(self, frame: Frame) -> list[ElementHandle[Any]]:
355
+ for selector in _CHALLENGE_TILE_SELECTORS:
356
+ elements = await frame.query_selector_all(selector)
357
+ if elements:
358
+ return elements
359
+ return []
360
+
361
+ async def _extract_example_images(self, frame: Frame) -> list[str]:
362
+ examples: list[str] = []
363
+ for selector in _EXAMPLE_IMAGE_SELECTORS:
364
+ elements = await frame.query_selector_all(selector)
365
+ if not elements:
366
+ continue
367
+ for element in elements:
368
+ encoded = await self._capture_element_base64(element)
369
+ if encoded:
370
+ examples.append(encoded)
371
+ if examples:
372
+ break
373
+ return examples
374
+
375
+ async def _describe_unsupported_challenge(self, page: Page) -> str:
376
+ """给出更贴近真实 challenge 类型的错误信息,避免把 canvas/puzzle 误报成网格 DOM 问题。"""
377
+ frame = await self._find_frame(page, "challenge", wait_seconds=2)
378
+ if frame is None:
379
+ return (
380
+ "hCaptcha challenge iframe disappeared before the built-in fallback "
381
+ "could inspect it"
382
+ )
383
+
384
+ prompt = await frame.evaluate(_QUESTION_JS)
385
+ prompt_text = prompt.strip().lower() if isinstance(prompt, str) else ""
386
+ has_canvas = await frame.locator("canvas").count() > 0
387
+ submit_text = (
388
+ await frame.locator(".button-submit").first.inner_text()
389
+ if await frame.locator(".button-submit").count() > 0
390
+ else ""
391
+ )
392
+
393
+ if "puzzle piece" in prompt_text or (has_canvas and "skip" in submit_text.lower()):
394
+ log.warning(
395
+ "Detected unsupported hCaptcha canvas/puzzle challenge: prompt=%r submit=%r has_canvas=%s",
396
+ prompt,
397
+ submit_text,
398
+ has_canvas,
399
+ )
400
+ return (
401
+ "hCaptcha presented a canvas/puzzle challenge, which is not supported "
402
+ "by the built-in HCaptchaClassification fallback"
403
+ )
404
+
405
+ log.warning(
406
+ "Detected unsupported hCaptcha challenge layout: prompt=%r submit=%r has_canvas=%s",
407
+ prompt,
408
+ submit_text,
409
+ has_canvas,
410
+ )
411
+ return (
412
+ "hCaptcha image challenge detected, but the current DOM layout is not "
413
+ "supported by the built-in classification fallback"
414
+ )
415
+
416
+ async def _capture_element_base64(self, element: ElementHandle[Any]) -> str | None:
417
+ try:
418
+ image_bytes = await element.screenshot(type="png")
419
+ except Exception:
420
+ return None
421
+ return base64.b64encode(image_bytes).decode("ascii")
422
+
423
+ @staticmethod
424
+ def _build_classification_payload(
425
+ *, question: str, tile_images: list[str], examples: list[str]
426
+ ) -> dict[str, Any]:
427
+ payload: dict[str, Any] = {
428
+ "type": "HCaptchaClassification",
429
+ "question": question,
430
+ "images": tile_images,
431
+ }
432
+ if examples:
433
+ payload["examples"] = examples
434
+ return payload
435
+
436
+ @staticmethod
437
+ def _extract_selection_indices(
438
+ *, result: dict[str, Any], tile_count: int
439
+ ) -> list[int]:
440
+ raw_answer = result.get("answer")
441
+ if isinstance(raw_answer, bool):
442
+ indices = [0] if raw_answer and tile_count == 1 else []
443
+ elif isinstance(raw_answer, list):
444
+ indices = [int(idx) for idx in raw_answer if isinstance(idx, int | float)]
445
+ else:
446
+ raw_objects = result.get("objects")
447
+ if isinstance(raw_objects, list):
448
+ indices = [int(idx) for idx in raw_objects if isinstance(idx, int | float)]
449
+ else:
450
+ indices = []
451
+
452
+ deduped: list[int] = []
453
+ for idx in indices:
454
+ if 0 <= idx < tile_count and idx not in deduped:
455
+ deduped.append(idx)
456
+ return deduped
457
+
458
+ async def _click_selected_tiles(
459
+ self,
460
+ tiles: list[ElementHandle[Any]],
461
+ indices: list[int],
462
+ ) -> None:
463
+ for idx in indices:
464
+ await tiles[idx].click(timeout=10_000)
465
+ await asyncio.sleep(0.2)
466
+ log.info("Clicked %d hCaptcha tile(s): %s", len(indices), indices)
467
+
468
+ async def _click_verify_button(self, frame: Frame) -> None:
469
+ for selector in _VERIFY_BUTTON_SELECTORS:
470
+ button = await frame.query_selector(selector)
471
+ if button is None:
472
+ continue
473
+ await button.click(timeout=10_000)
474
+ await asyncio.sleep(1)
475
+ log.info("Submitted hCaptcha challenge with selector %s", selector)
476
+ return
477
+ raise RuntimeError("Could not find hCaptcha verify/submit button")