captchakraken 3.2.0 → 3.2.1
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.
- package/dist/selectors.d.ts +3 -0
- package/dist/selectors.js +7 -3
- package/dist/solver.d.ts +1 -1
- package/dist/solver.js +26 -14
- package/package.json +1 -1
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +1 -1
- package/python/src/captchakraken/page_solver.py +37 -19
- package/python/src/captchakraken/selectors.py +9 -2
package/dist/selectors.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface VendorSelectors {
|
|
|
10
10
|
readonly challenge?: readonly string[];
|
|
11
11
|
/** On the host page: the checkbox. Detected while `response` is empty and `checked` is not showing. */
|
|
12
12
|
readonly checkbox?: readonly string[];
|
|
13
|
+
/** On the host page: a frame the vendor draws its other frames inside. Every host-page selector also reads the inside of a visible one. */
|
|
14
|
+
readonly enclave?: readonly string[];
|
|
13
15
|
/** On the host page: inline widget shapes, detected last. Iframe selectors go first; a class that lives inside the frame document (.CheckboxCaptcha, .mtcap) only ever matches an inline embed. */
|
|
14
16
|
readonly widget?: readonly string[];
|
|
15
17
|
/** On the host page: the field the vendor fills on success. Read first and unconditionally, because hCaptcha's overlay hides the anchor. */
|
|
@@ -48,6 +50,7 @@ export interface WidgetProbe {
|
|
|
48
50
|
}
|
|
49
51
|
/** Every host-page selector with what it names, in detection order: open challenges, then checkboxes, then inline widgets. */
|
|
50
52
|
export declare const WIDGET_PROBES: ReadonlyArray<WidgetProbe>;
|
|
53
|
+
export declare const ENCLAVE_SELECTORS: readonly string[];
|
|
51
54
|
export declare const RESPONSE_SELECTORS: readonly string[];
|
|
52
55
|
export declare const ACCEPTED_SELECTORS: readonly string[];
|
|
53
56
|
/** `.//` keeps the xpath relative so a host-page widget cannot reach the form's own submit. */
|
package/dist/selectors.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PIECE_SELECTORS = exports.SLIDER_HANDLE_SELECTORS = exports.TEXT_INPUT_SELECTORS = exports.TEXT_INPUT_VENDOR_SELECTORS = exports.SUBMIT_SELECTORS = exports.submitByText = exports.ACCEPTED_SELECTORS = exports.RESPONSE_SELECTORS = exports.WIDGET_PROBES = exports.VENDORS = exports.SELECTORS = void 0;
|
|
3
|
+
exports.PIECE_SELECTORS = exports.SLIDER_HANDLE_SELECTORS = exports.TEXT_INPUT_SELECTORS = exports.TEXT_INPUT_VENDOR_SELECTORS = exports.SUBMIT_SELECTORS = exports.submitByText = exports.ACCEPTED_SELECTORS = exports.RESPONSE_SELECTORS = exports.ENCLAVE_SELECTORS = exports.WIDGET_PROBES = exports.VENDORS = exports.SELECTORS = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Every selector the driver knows, keyed by vendor. Mirrored in python/src/captchakraken/selectors.py; keep both in the
|
|
6
6
|
* same order, because detection takes the first visible match in table order.
|
|
@@ -24,17 +24,20 @@ exports.SELECTORS = {
|
|
|
24
24
|
blocked: ['.rc-doscaptcha-header-text'],
|
|
25
25
|
passive: ['iframe[src*="size=invisible"]'],
|
|
26
26
|
},
|
|
27
|
-
// Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com.
|
|
27
|
+
// Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com. secure-api.js
|
|
28
|
+
// draws every frame inside a full-viewport enclave, one per render, and only a visible one holds an open challenge.
|
|
28
29
|
[kinds_1.Vendor.HCAPTCHA]: {
|
|
29
30
|
hosts: ['hcaptcha.com'],
|
|
30
31
|
challenge: ['iframe[src*="hcaptcha"][src*="frame=challenge"]'],
|
|
31
|
-
checkbox: ['iframe[src*="hcaptcha"][src*="frame=checkbox"]'],
|
|
32
|
+
checkbox: ['iframe[src*="hcaptcha"][src*="frame=checkbox"]:not([src*="frame=checkbox-invisible"])'],
|
|
33
|
+
enclave: ['iframe[src*="hcaptcha"][src*="frame=enclave"]'],
|
|
32
34
|
response: '[name="h-captcha-response"]',
|
|
33
35
|
checked: '#checkbox[aria-checked="true"]',
|
|
34
36
|
box: ['#checkbox'],
|
|
35
37
|
fresh: '.prompt-text',
|
|
36
38
|
images: ['.task-image .image', '.task .image', '.challenge-example img', '.image-wrapper img'],
|
|
37
39
|
submit: ['.button-submit'],
|
|
40
|
+
passive: ['iframe[src*="hcaptcha"][src*="frame=enclave"]', 'iframe[src*="hcaptcha"][src*="frame=checkbox-invisible"]'],
|
|
38
41
|
},
|
|
39
42
|
[kinds_1.Vendor.TURNSTILE]: {
|
|
40
43
|
hosts: ['challenges.cloudflare.com'],
|
|
@@ -96,6 +99,7 @@ const ofEveryVendor = (key) => exports.VENDORS.flatMap(([, s]) => s[key] ?? []);
|
|
|
96
99
|
exports.WIDGET_PROBES = [
|
|
97
100
|
[kinds_1.FrameRole.CHALLENGE, 'challenge'], [kinds_1.FrameRole.CHECKBOX, 'checkbox'], [kinds_1.FrameRole.UNKNOWN, 'widget'],
|
|
98
101
|
].flatMap(([role, key]) => exports.VENDORS.flatMap(([vendor, s]) => (s[key] ?? []).map((selector) => ({ vendor, role, selector }))));
|
|
102
|
+
exports.ENCLAVE_SELECTORS = ofEveryVendor('enclave');
|
|
99
103
|
exports.RESPONSE_SELECTORS = exports.VENDORS.flatMap(([, s]) => s.response ?? []);
|
|
100
104
|
exports.ACCEPTED_SELECTORS = exports.VENDORS.flatMap(([, s]) => s.accepted ?? []);
|
|
101
105
|
const lower = "translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')";
|
package/dist/solver.d.ts
CHANGED
|
@@ -177,7 +177,7 @@ export declare class CaptchaKrakenSolver {
|
|
|
177
177
|
private waitForBoardImages;
|
|
178
178
|
/** Which vendors' code the page loaded, from resource timing and linked URLs. A tripwire, not a detector. */
|
|
179
179
|
vendorsOnTheWire(page: Page): Promise<Vendor[]>;
|
|
180
|
-
/** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge) is not a widget, so what is left is markup the table no longer matches. */
|
|
180
|
+
/** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge, an enclave) is not a widget, so what is left is markup the table no longer matches. */
|
|
181
181
|
private unmatchedVendorFrames;
|
|
182
182
|
private noWidgetMessage;
|
|
183
183
|
/**
|
package/dist/solver.js
CHANGED
|
@@ -133,6 +133,12 @@ const visible = async (scope, selectors) => (await Promise.all(selectors.map((s)
|
|
|
133
133
|
const handleOf = (at) => at.elementHandle({ timeout: HANDLE_TIMEOUT_MS }).catch(() => null);
|
|
134
134
|
const handles = async (ats) => (await Promise.all(ats.map(handleOf))).filter((h) => h !== null);
|
|
135
135
|
const frameOf = async (at) => (await (await handleOf(at))?.contentFrame().catch(() => null)) ?? null;
|
|
136
|
+
/**
|
|
137
|
+
* Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave is
|
|
138
|
+
* skipped because Playwright judges an element visible within its own document, whatever its frame's visibility.
|
|
139
|
+
*/
|
|
140
|
+
const hostScopes = async (page) => [page, ...(await Promise.all((await visible(page, selectors_1.ENCLAVE_SELECTORS)).map(frameOf))).filter((f) => f !== null)];
|
|
141
|
+
const visibleIn = async (scopes, selectors) => (await Promise.all(scopes.map((scope) => visible(scope, selectors)))).flat();
|
|
136
142
|
const hasText = async (el) => !!((await el.textContent().catch(() => null)) ?? '').trim();
|
|
137
143
|
const SLIDE_TOLERANCE_PX = 2;
|
|
138
144
|
const SLIDE_MAX_CORRECTIONS = 3;
|
|
@@ -633,8 +639,9 @@ class CaptchaKrakenSolver {
|
|
|
633
639
|
async isBlocked(page) {
|
|
634
640
|
try {
|
|
635
641
|
const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].blocked);
|
|
642
|
+
const scopes = await hostScopes(page);
|
|
636
643
|
const shown = await Promise.all(probes.map(async (p) => {
|
|
637
|
-
const [at] = await
|
|
644
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
638
645
|
const frame = at && await frameOf(at);
|
|
639
646
|
return !!frame && (await Promise.all((selectors_1.SELECTORS[p.vendor].blocked ?? []).map((sel) => this.visibleWithText(frame, sel)))).some(Boolean);
|
|
640
647
|
}));
|
|
@@ -1050,8 +1057,8 @@ class CaptchaKrakenSolver {
|
|
|
1050
1057
|
return this.findControl(scope, selectors_1.SUBMIT_SELECTORS);
|
|
1051
1058
|
}
|
|
1052
1059
|
/** Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter. */
|
|
1053
|
-
async hasValue(
|
|
1054
|
-
const fields = await handles(await
|
|
1060
|
+
async hasValue(scopes, selector) {
|
|
1061
|
+
const fields = await handles((await Promise.all(scopes.map((scope) => scope.locator(selector).all().catch(() => [])))).flat());
|
|
1055
1062
|
return (await Promise.all(fields.map((f) => f.inputValue().catch(() => '')))).some((v) => v.trim().length > 0);
|
|
1056
1063
|
}
|
|
1057
1064
|
/** A visible match carrying text; an empty banner or prompt is a placeholder, not a signal. */
|
|
@@ -1068,8 +1075,9 @@ class CaptchaKrakenSolver {
|
|
|
1068
1075
|
/** Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error. */
|
|
1069
1076
|
async bannerKind(page) {
|
|
1070
1077
|
const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].banners);
|
|
1078
|
+
const scopes = await hostScopes(page);
|
|
1071
1079
|
const kinds = await Promise.all(probes.map(async (p) => {
|
|
1072
|
-
const [at] = await
|
|
1080
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
1073
1081
|
const frame = at && await frameOf(at);
|
|
1074
1082
|
if (!frame)
|
|
1075
1083
|
return null;
|
|
@@ -1082,11 +1090,12 @@ class CaptchaKrakenSolver {
|
|
|
1082
1090
|
async isCaptchaSolved(page) {
|
|
1083
1091
|
try {
|
|
1084
1092
|
const checkboxes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHECKBOX && selectors_1.SELECTORS[p.vendor].checked);
|
|
1093
|
+
const scopes = await hostScopes(page);
|
|
1085
1094
|
const signals = await Promise.all([
|
|
1086
|
-
...selectors_1.RESPONSE_SELECTORS.map((s) => this.hasValue(
|
|
1087
|
-
|
|
1095
|
+
...selectors_1.RESPONSE_SELECTORS.map((s) => this.hasValue(scopes, s)),
|
|
1096
|
+
visibleIn(scopes, selectors_1.ACCEPTED_SELECTORS).then((found) => found.length > 0),
|
|
1088
1097
|
...checkboxes.map(async (p) => {
|
|
1089
|
-
const [at] = await
|
|
1098
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
1090
1099
|
return !!at && this.checkedIn(at, selectors_1.SELECTORS[p.vendor].checked);
|
|
1091
1100
|
}),
|
|
1092
1101
|
]);
|
|
@@ -1100,8 +1109,9 @@ class CaptchaKrakenSolver {
|
|
|
1100
1109
|
async isChallengeFreshlyRendered(page) {
|
|
1101
1110
|
try {
|
|
1102
1111
|
const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].fresh);
|
|
1112
|
+
const scopes = await hostScopes(page);
|
|
1103
1113
|
const fresh = await Promise.all(probes.map(async (p) => {
|
|
1104
|
-
const [at] = await
|
|
1114
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
1105
1115
|
const el = at && await handleOf(at);
|
|
1106
1116
|
if (!el || (this.lastSubmitFrameHash && (await this.elementFrameHash(el)) === this.lastSubmitFrameHash))
|
|
1107
1117
|
return false;
|
|
@@ -1159,12 +1169,13 @@ class CaptchaKrakenSolver {
|
|
|
1159
1169
|
const blob = names.join(' ');
|
|
1160
1170
|
return selectors_1.VENDORS.filter(([, s]) => s.hosts.some((h) => blob.includes(h))).map(([vendor]) => vendor);
|
|
1161
1171
|
}
|
|
1162
|
-
/** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge) is not a widget, so what is left is markup the table no longer matches. */
|
|
1172
|
+
/** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge, an enclave) is not a widget, so what is left is markup the table no longer matches. */
|
|
1163
1173
|
async unmatchedVendorFrames(page) {
|
|
1174
|
+
const scopes = await hostScopes(page);
|
|
1164
1175
|
const counts = await Promise.all(selectors_1.VENDORS.map(async ([vendor, s]) => {
|
|
1165
1176
|
if (!s.hosts.length)
|
|
1166
1177
|
return null;
|
|
1167
|
-
const [frames, passive] = await Promise.all([
|
|
1178
|
+
const [frames, passive] = await Promise.all([visibleIn(scopes, s.hosts.map((h) => `iframe[src*="${h}"]`)), visibleIn(scopes, s.passive ?? [])]);
|
|
1168
1179
|
return frames.length > passive.length ? vendor : null;
|
|
1169
1180
|
}));
|
|
1170
1181
|
return counts.filter((v) => v !== null);
|
|
@@ -1190,20 +1201,21 @@ class CaptchaKrakenSolver {
|
|
|
1190
1201
|
}
|
|
1191
1202
|
/** Open challenges first, then unsolved checkboxes, then the inline vendors; every probe is queried at once. */
|
|
1192
1203
|
async detectCaptcha(page) {
|
|
1204
|
+
const scopes = await hostScopes(page);
|
|
1193
1205
|
const found = await Promise.all(selectors_1.WIDGET_PROBES.map(async (p) => {
|
|
1194
|
-
const [at] = await
|
|
1195
|
-
return at && !(await this.alreadyAccepted(
|
|
1206
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
1207
|
+
return at && !(await this.alreadyAccepted(scopes, at, p)) ? { ...p, at } : null;
|
|
1196
1208
|
}));
|
|
1197
1209
|
const hit = found.find((h) => h !== null);
|
|
1198
1210
|
const el = hit && await handleOf(hit.at);
|
|
1199
1211
|
return hit && el ? { el, at: hit.at, vendor: hit.vendor, role: hit.role } : null;
|
|
1200
1212
|
}
|
|
1201
1213
|
/** A checkbox the vendor has already accepted is not a captcha to solve. */
|
|
1202
|
-
async alreadyAccepted(
|
|
1214
|
+
async alreadyAccepted(scopes, at, { role, vendor }) {
|
|
1203
1215
|
const { response, checked } = selectors_1.SELECTORS[vendor];
|
|
1204
1216
|
if (role !== kinds_1.FrameRole.CHECKBOX)
|
|
1205
1217
|
return false;
|
|
1206
|
-
const [token, box] = await Promise.all([!!response && this.hasValue(
|
|
1218
|
+
const [token, box] = await Promise.all([!!response && this.hasValue(scopes, response), !!checked && this.checkedIn(at, checked)]);
|
|
1207
1219
|
return token || box;
|
|
1208
1220
|
}
|
|
1209
1221
|
loraName(cliRoot) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "captchakraken",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"description": "Captcha solver for Playwright and Puppeteer. Finds the captcha, reads the puzzle with a fine-tuned Qwen3.5-9B vision model, and clicks, drags, slides or types through to a token \u2014 reCAPTCHA, GeeTest, NetEase Yidun, Tencent, Lemin, Prosopo, Turnstile and distorted text. Hosted API or self-hosted.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/python/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "captchakraken"
|
|
7
|
-
version = "3.2.
|
|
7
|
+
version = "3.2.1"
|
|
8
8
|
description = "Captcha-solving engine and CLI: OpenCV grid detection plus a fine-tuned Qwen3.5-9B vision model, for image grids, click and drag puzzles, sliders, distorted text and animated challenges. Hosted API or self-hosted on vLLM."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -25,7 +25,7 @@ from .action_types import CaptchaAction
|
|
|
25
25
|
from .humanize import Humanizer, resolve as resolve_humanizer
|
|
26
26
|
from .kinds import (ActionKind, FrameRole, HumanizationMode, KeyframeMode, PauseKind, Phase, PromptFamily,
|
|
27
27
|
RecaptchaBanner, RetryMode, SettleVerdict, Vendor, Verdict)
|
|
28
|
-
from .selectors import (ACCEPTED_SELECTORS, PIECE_SELECTORS, RESPONSE_SELECTORS, SELECTORS, SLIDER_HANDLE_SELECTORS,
|
|
28
|
+
from .selectors import (ACCEPTED_SELECTORS, ENCLAVE_SELECTORS, PIECE_SELECTORS, RESPONSE_SELECTORS, SELECTORS, SLIDER_HANDLE_SELECTORS,
|
|
29
29
|
SUBMIT_SELECTORS, TEXT_INPUT_SELECTORS, TEXT_INPUT_VENDOR_SELECTORS, VENDORS, WIDGET_PROBES,
|
|
30
30
|
VendorSelectors, WidgetProbe)
|
|
31
31
|
from .solver import CaptchaSolver, UnsupportedCaptchaError
|
|
@@ -366,6 +366,16 @@ def _frame_of(at: Any) -> Optional[Any]:
|
|
|
366
366
|
return None
|
|
367
367
|
|
|
368
368
|
|
|
369
|
+
def _host_scopes(page: Any) -> List[Any]:
|
|
370
|
+
"""Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave
|
|
371
|
+
is skipped because Playwright judges an element visible within its own document, whatever its frame's visibility."""
|
|
372
|
+
return [page, *(f for f in map(_frame_of, _visible(page, ENCLAVE_SELECTORS)) if f is not None)]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _visible_in(scopes: Sequence[Any], selectors: Sequence[str]) -> List[Any]:
|
|
376
|
+
return [at for scope in scopes for at in _visible(scope, selectors)]
|
|
377
|
+
|
|
378
|
+
|
|
369
379
|
def _input_value(handle: Any) -> str:
|
|
370
380
|
try:
|
|
371
381
|
return str(handle.input_value() or "")
|
|
@@ -864,13 +874,15 @@ class PageSolver:
|
|
|
864
874
|
|
|
865
875
|
# ── detection ────────────────────────────────────────────────────────
|
|
866
876
|
|
|
867
|
-
def _has_value(self,
|
|
877
|
+
def _has_value(self, scopes: Sequence[Any], selector: str) -> bool:
|
|
868
878
|
"""Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter."""
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
879
|
+
def fields(scope: Any) -> List[Any]:
|
|
880
|
+
try:
|
|
881
|
+
return _handles(scope.locator(selector).all())
|
|
882
|
+
except Exception:
|
|
883
|
+
return []
|
|
884
|
+
|
|
885
|
+
return any(_input_value(f).strip() for scope in scopes for f in fields(scope))
|
|
874
886
|
|
|
875
887
|
def _visible_with_text(self, scope: Any, selector: str) -> bool:
|
|
876
888
|
"""A visible match carrying text; an empty banner or prompt is a placeholder, not a signal."""
|
|
@@ -895,20 +907,21 @@ class PageSolver:
|
|
|
895
907
|
|
|
896
908
|
def detect_captcha(self, page: Any) -> Optional[Widget]:
|
|
897
909
|
"""Open challenges first, then unsolved checkboxes, then the inline vendors."""
|
|
910
|
+
scopes = _host_scopes(page)
|
|
898
911
|
for probe in WIDGET_PROBES:
|
|
899
|
-
at = next(iter(
|
|
900
|
-
if at is None or self._already_accepted(
|
|
912
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
913
|
+
if at is None or self._already_accepted(scopes, at, probe):
|
|
901
914
|
continue
|
|
902
915
|
element = _handle_of(at)
|
|
903
916
|
return Widget(element, at, probe.vendor, probe.role) if element is not None else None
|
|
904
917
|
return None
|
|
905
918
|
|
|
906
|
-
def _already_accepted(self,
|
|
919
|
+
def _already_accepted(self, scopes: Sequence[Any], at: Any, probe: WidgetProbe) -> bool:
|
|
907
920
|
"""A checkbox the vendor has already accepted is not a captcha to solve."""
|
|
908
921
|
s = SELECTORS[probe.vendor]
|
|
909
922
|
if probe.role != FrameRole.CHECKBOX:
|
|
910
923
|
return False
|
|
911
|
-
return bool(s.response and self._has_value(
|
|
924
|
+
return bool(s.response and self._has_value(scopes, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
|
|
912
925
|
|
|
913
926
|
def vendors_on_the_wire(self, page: Any) -> List[Vendor]:
|
|
914
927
|
"""Which vendors' code the page loaded. Resource timing, because a request listener would have to have been attached before navigation."""
|
|
@@ -921,9 +934,10 @@ class PageSolver:
|
|
|
921
934
|
|
|
922
935
|
def _unmatched_vendor_frames(self, page: Any) -> List[Vendor]:
|
|
923
936
|
"""Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame
|
|
924
|
-
(an invisible badge) is not a widget, so what is left is markup the table no longer matches."""
|
|
925
|
-
|
|
926
|
-
|
|
937
|
+
(an invisible badge, an enclave) is not a widget, so what is left is markup the table no longer matches."""
|
|
938
|
+
scopes = _host_scopes(page)
|
|
939
|
+
return [vendor for vendor, s in VENDORS if s.hosts and len(_visible_in(scopes, [f'iframe[src*="{h}"]' for h in s.hosts]))
|
|
940
|
+
> len(_visible_in(scopes, s.passive))]
|
|
927
941
|
|
|
928
942
|
def _no_widget_message(self, page: Any) -> str:
|
|
929
943
|
base = f"no interactive captcha widget detected within {self.config.detection_timeout_ms}ms"
|
|
@@ -954,11 +968,12 @@ class PageSolver:
|
|
|
954
968
|
|
|
955
969
|
def _is_blocked(self, page: Any) -> bool:
|
|
956
970
|
"""The vendor showing its refusal screen: rate-limited or flagged, with no board to answer."""
|
|
971
|
+
scopes = _host_scopes(page)
|
|
957
972
|
for probe in WIDGET_PROBES:
|
|
958
973
|
blocked = SELECTORS[probe.vendor].blocked
|
|
959
974
|
if probe.role != FrameRole.CHALLENGE or not blocked:
|
|
960
975
|
continue
|
|
961
|
-
at = next(iter(
|
|
976
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
962
977
|
frame = _frame_of(at) if at is not None else None
|
|
963
978
|
if frame and any(self._visible_with_text(frame, selector) for selector in blocked):
|
|
964
979
|
return True
|
|
@@ -972,13 +987,14 @@ class PageSolver:
|
|
|
972
987
|
def is_captcha_solved(self, page: Any) -> bool:
|
|
973
988
|
"""The vendor's own done signal: a response token, a painted success state, or a checked box."""
|
|
974
989
|
try:
|
|
975
|
-
|
|
990
|
+
scopes = _host_scopes(page)
|
|
991
|
+
if any(self._has_value(scopes, s) for s in RESPONSE_SELECTORS) or _visible_in(scopes, ACCEPTED_SELECTORS):
|
|
976
992
|
return True
|
|
977
993
|
for probe in WIDGET_PROBES:
|
|
978
994
|
checked = SELECTORS[probe.vendor].checked
|
|
979
995
|
if probe.role != FrameRole.CHECKBOX or not checked:
|
|
980
996
|
continue
|
|
981
|
-
at = next(iter(
|
|
997
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
982
998
|
if at is not None and self._checked_in(at, checked):
|
|
983
999
|
return True
|
|
984
1000
|
except Exception:
|
|
@@ -988,11 +1004,12 @@ class PageSolver:
|
|
|
988
1004
|
def _is_challenge_freshly_rendered(self, page: Any) -> bool:
|
|
989
1005
|
"""A next round has painted, as opposed to the answered frame animating closed."""
|
|
990
1006
|
try:
|
|
1007
|
+
scopes = _host_scopes(page)
|
|
991
1008
|
for probe in WIDGET_PROBES:
|
|
992
1009
|
fresh = SELECTORS[probe.vendor].fresh
|
|
993
1010
|
if probe.role != FrameRole.CHALLENGE or not fresh:
|
|
994
1011
|
continue
|
|
995
|
-
at = next(iter(
|
|
1012
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
996
1013
|
element = _handle_of(at) if at is not None else None
|
|
997
1014
|
if element is None:
|
|
998
1015
|
continue
|
|
@@ -1012,11 +1029,12 @@ class PageSolver:
|
|
|
1012
1029
|
def _banner_kind(self, page: Any) -> Optional[RecaptchaBanner]:
|
|
1013
1030
|
"""Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error."""
|
|
1014
1031
|
try:
|
|
1032
|
+
scopes = _host_scopes(page)
|
|
1015
1033
|
for probe in WIDGET_PROBES:
|
|
1016
1034
|
banners = SELECTORS[probe.vendor].banners
|
|
1017
1035
|
if probe.role != FrameRole.CHALLENGE or not banners:
|
|
1018
1036
|
continue
|
|
1019
|
-
at = next(iter(
|
|
1037
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
1020
1038
|
frame = _frame_of(at) if at is not None else None
|
|
1021
1039
|
if not frame:
|
|
1022
1040
|
continue
|
|
@@ -18,6 +18,9 @@ class VendorSelectors:
|
|
|
18
18
|
challenge: Sequence[str] = ()
|
|
19
19
|
# On the host page: the checkbox. Detected while `response` is empty and `checked` is not showing.
|
|
20
20
|
checkbox: Sequence[str] = ()
|
|
21
|
+
# On the host page: a frame the vendor draws its other frames inside. Every host-page selector also reads the
|
|
22
|
+
# inside of a visible one.
|
|
23
|
+
enclave: Sequence[str] = ()
|
|
21
24
|
# On the host page: inline widget shapes, detected last. Iframe selectors go first; a class that lives inside the
|
|
22
25
|
# frame document (.CheckboxCaptcha, .mtcap) only ever matches an inline embed.
|
|
23
26
|
widget: Sequence[str] = ()
|
|
@@ -68,17 +71,20 @@ SELECTORS: Mapping[Vendor, VendorSelectors] = {
|
|
|
68
71
|
blocked=(".rc-doscaptcha-header-text",),
|
|
69
72
|
passive=('iframe[src*="size=invisible"]',),
|
|
70
73
|
),
|
|
71
|
-
# Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com.
|
|
74
|
+
# Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com. secure-api.js
|
|
75
|
+
# draws every frame inside a full-viewport enclave, one per render, and only a visible one holds an open challenge.
|
|
72
76
|
Vendor.HCAPTCHA: VendorSelectors(
|
|
73
77
|
hosts=("hcaptcha.com",),
|
|
74
78
|
challenge=('iframe[src*="hcaptcha"][src*="frame=challenge"]',),
|
|
75
|
-
checkbox=('iframe[src*="hcaptcha"][src*="frame=checkbox"]',),
|
|
79
|
+
checkbox=('iframe[src*="hcaptcha"][src*="frame=checkbox"]:not([src*="frame=checkbox-invisible"])',),
|
|
80
|
+
enclave=('iframe[src*="hcaptcha"][src*="frame=enclave"]',),
|
|
76
81
|
response='[name="h-captcha-response"]',
|
|
77
82
|
checked='#checkbox[aria-checked="true"]',
|
|
78
83
|
box=("#checkbox",),
|
|
79
84
|
fresh=".prompt-text",
|
|
80
85
|
images=(".task-image .image", ".task .image", ".challenge-example img", ".image-wrapper img"),
|
|
81
86
|
submit=(".button-submit",),
|
|
87
|
+
passive=('iframe[src*="hcaptcha"][src*="frame=enclave"]', 'iframe[src*="hcaptcha"][src*="frame=checkbox-invisible"]'),
|
|
82
88
|
),
|
|
83
89
|
Vendor.TURNSTILE: VendorSelectors(
|
|
84
90
|
hosts=("challenges.cloudflare.com",),
|
|
@@ -161,6 +167,7 @@ WIDGET_PROBES: Sequence[WidgetProbe] = tuple(
|
|
|
161
167
|
for vendor, s in VENDORS
|
|
162
168
|
for selector in getattr(s, key))
|
|
163
169
|
|
|
170
|
+
ENCLAVE_SELECTORS: Sequence[str] = _of_every_vendor("enclave")
|
|
164
171
|
RESPONSE_SELECTORS: Sequence[str] = tuple(s.response for _, s in VENDORS if s.response)
|
|
165
172
|
ACCEPTED_SELECTORS: Sequence[str] = tuple(s.accepted for _, s in VENDORS if s.accepted)
|
|
166
173
|
|