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.
@@ -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 visible(page, [p.selector]);
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(page, selector) {
1054
- const fields = await handles(await page.locator(selector).all().catch(() => []));
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 visible(page, [p.selector]);
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(page, s)),
1087
- visible(page, selectors_1.ACCEPTED_SELECTORS).then((found) => found.length > 0),
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 visible(page, [p.selector]);
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 visible(page, [p.selector]);
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([visible(page, s.hosts.map((h) => `iframe[src*="${h}"]`)), visible(page, s.passive ?? [])]);
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 visible(page, [p.selector]);
1195
- return at && !(await this.alreadyAccepted(page, at, p)) ? { ...p, at } : null;
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(page, at, { role, vendor }) {
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(page, response), !!checked && this.checkedIn(at, checked)]);
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.0",
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",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "captchakraken"
7
- version = "3.2.0"
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"
@@ -103,4 +103,4 @@ __all__ = [
103
103
  "Verdict",
104
104
  ]
105
105
 
106
- __version__ = "3.2.0"
106
+ __version__ = "3.2.1"
@@ -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, page: Any, selector: str) -> bool:
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
- try:
870
- fields = _handles(page.locator(selector).all())
871
- except Exception:
872
- return False
873
- return any(_input_value(f).strip() for f in fields)
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(_visible(page, (probe.selector,))), None)
900
- if at is None or self._already_accepted(page, at, probe):
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, page: Any, at: Any, probe: WidgetProbe) -> bool:
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(page, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
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
- return [vendor for vendor, s in VENDORS if s.hosts and len(_visible(page, [f'iframe[src*="{h}"]' for h in s.hosts]))
926
- > len(_visible(page, s.passive))]
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(_visible(page, (probe.selector,))), None)
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
- if any(self._has_value(page, s) for s in RESPONSE_SELECTORS) or _visible(page, ACCEPTED_SELECTORS):
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(_visible(page, (probe.selector,))), None)
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(_visible(page, (probe.selector,))), None)
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(_visible(page, (probe.selector,))), None)
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