captchakraken 3.2.0__tar.gz → 3.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. {captchakraken-3.2.0 → captchakraken-3.2.1}/PKG-INFO +1 -1
  2. {captchakraken-3.2.0 → captchakraken-3.2.1}/pyproject.toml +1 -1
  3. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/__init__.py +1 -1
  4. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/page_solver.py +37 -19
  5. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/selectors.py +9 -2
  6. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/fake_dom.py +13 -0
  7. captchakraken-3.2.1/tests/fixtures/hcaptcha_enclave/challenge.html +13 -0
  8. captchakraken-3.2.1/tests/fixtures/hcaptcha_enclave/enclave.html +16 -0
  9. captchakraken-3.2.1/tests/fixtures/hcaptcha_enclave/host.html +13 -0
  10. captchakraken-3.2.1/tests/test_a_widget_inside_an_enclave_is_found.py +185 -0
  11. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_widget_is_waited_for.py +2 -3
  12. {captchakraken-3.2.0 → captchakraken-3.2.1}/.gitignore +0 -0
  13. {captchakraken-3.2.0 → captchakraken-3.2.1}/AGENTS.md +0 -0
  14. {captchakraken-3.2.0 → captchakraken-3.2.1}/Dockerfile +0 -0
  15. {captchakraken-3.2.0 → captchakraken-3.2.1}/LICENSE +0 -0
  16. {captchakraken-3.2.0 → captchakraken-3.2.1}/NOTICE +0 -0
  17. {captchakraken-3.2.0 → captchakraken-3.2.1}/README.md +0 -0
  18. {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/README.md +0 -0
  19. {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/_harness.py +0 -0
  20. {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/demoRecaptcha.py +0 -0
  21. {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/watch_playwright.py +0 -0
  22. {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/with_local_gguf.py +0 -0
  23. {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/with_playwright.py +0 -0
  24. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/action_types.py +0 -0
  25. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/cli.py +0 -0
  26. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/config.py +0 -0
  27. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/errors.py +0 -0
  28. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/humanize.py +0 -0
  29. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/image_processor.py +0 -0
  30. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/keyframes.py +0 -0
  31. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/kinds.py +0 -0
  32. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/models.json +0 -0
  33. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/overlay.py +0 -0
  34. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/pinned_model.json +0 -0
  35. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/planner.py +0 -0
  36. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/prompts.py +0 -0
  37. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/server_manager.py +0 -0
  38. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/solver.py +0 -0
  39. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/timing.py +0 -0
  40. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/tool_calls/board_painted.py +0 -0
  41. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/tool_calls/find_checkbox.py +0 -0
  42. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/tool_calls/find_grid.py +0 -0
  43. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/tool_calls/track_piece.py +0 -0
  44. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/updater.py +0 -0
  45. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/verdicts.py +0 -0
  46. {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/watcher.py +0 -0
  47. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/fixtures/cursory_cross_port.json +0 -0
  48. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/fixtures/vendor_verdicts.json +0 -0
  49. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_blank_board_is_not_photographed.py +0 -0
  50. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_board_is_routed_and_watched_once_it_has_painted.py +0 -0
  51. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_board_that_never_repeats_is_animated.py +0 -0
  52. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_board_that_will_not_film_may_already_be_solved.py +0 -0
  53. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_burst_cannot_trip_its_own_deadline.py +0 -0
  54. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_capture_never_clips_the_page.py +0 -0
  55. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_checkbox_is_clicked_not_watched.py +0 -0
  56. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_grid_is_a_regular_lattice.py +0 -0
  57. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_missing_prompt_is_not_a_reason_to_wait.py +0 -0
  58. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_new_board_ends_the_film.py +0 -0
  59. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_pin_reads_its_own_entry.py +0 -0
  60. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_reask_is_a_different_sample.py +0 -0
  61. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_refusal_keeps_its_machine_readable_half.py +0 -0
  62. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_refused_clip_is_not_re_asked.py +0 -0
  63. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_repeated_answer_is_not_re_performed.py +0 -0
  64. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_replaced_board_has_not_failed_yet.py +0 -0
  65. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_round_that_did_nothing_waits_for_nothing.py +0 -0
  66. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_slow_camera_does_not_stretch_the_burst.py +0 -0
  67. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_slow_model_does_not_extend_the_burst.py +0 -0
  68. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_solved_board_is_not_lost_to_a_typed_action.py +0 -0
  69. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_still_board_is_not_filmed.py +0 -0
  70. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_still_burst_stops_at_the_floor.py +0 -0
  71. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_an_unusable_answer_is_not_a_dead_page.py +0 -0
  72. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_animated_solve_budget.py +0 -0
  73. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_api_error_is_exported.py +0 -0
  74. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_api_errors.py +0 -0
  75. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_browser_compat.py +0 -0
  76. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_cli_stdout_is_the_wire_between_the_ports.py +0 -0
  77. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_credentials_file.py +0 -0
  78. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_cycling_board_waits_for_its_screen.py +0 -0
  79. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_empty_answer_still_submits.py +0 -0
  80. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_endpoint_is_dialled_once.py +0 -0
  81. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_every_keyframe_write_names_its_stem.py +0 -0
  82. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_expert_reaches_the_wire.py +0 -0
  83. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_expert_routing.py +0 -0
  84. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_fetch_command.py +0 -0
  85. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_find_checkbox_rejects_what_is_not_a_checkbox.py +0 -0
  86. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_freshness_check.py +0 -0
  87. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_geetest_accept_is_a_signal.py +0 -0
  88. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_geetest_done_needs_no_bounding_box.py +0 -0
  89. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_geetest_submit_button.py +0 -0
  90. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_detection.py +0 -0
  91. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_detection_ci.py +0 -0
  92. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_dims_must_be_possible.py +0 -0
  93. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_flank_contrast.py +0 -0
  94. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_geometry_gates.py +0 -0
  95. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_never_pairs_tiles.py +0 -0
  96. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_noisy_gutter.py +0 -0
  97. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_off_lattice_clusters.py +0 -0
  98. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_overcount_falls_back.py +0 -0
  99. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_round_pays_once.py +0 -0
  100. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_hcaptcha_badge_detection.py +0 -0
  101. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_humanizer.py +0 -0
  102. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_licensed_models.py +0 -0
  103. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_min_pixels_upscale.py +0 -0
  104. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_one_ask_cannot_outlive_the_solve.py +0 -0
  105. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_our_own_gesture_is_not_animation.py +0 -0
  106. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_outcome_reporting.py +0 -0
  107. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_overlay_is_what_the_model_reads.py +0 -0
  108. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_page_solver.py +0 -0
  109. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_pinned_model.py +0 -0
  110. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_pixel_parser.py +0 -0
  111. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_private_models.py +0 -0
  112. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_public_contract.py +0 -0
  113. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_recaptcha_chipped_board.py +0 -0
  114. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_recaptcha_dynamic_more_is_not_an_error.py +0 -0
  115. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_routing_headers.py +0 -0
  116. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_selected_fields.py +0 -0
  117. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_setup_sh_model_ids.py +0 -0
  118. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_slide_reads_the_piece_off_the_page.py +0 -0
  119. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_solver.py +0 -0
  120. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_stale_handle_after_submit.py +0 -0
  121. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_film_outlives_the_answer.py +0 -0
  122. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_legacy_model_warning_is_one_text.py +0 -0
  123. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_notice_travels_with_the_package.py +0 -0
  124. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_second_look_believes_its_recording.py +0 -0
  125. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_two_ports_share_one_mouse.py +0 -0
  126. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_vendor_says_whether_the_round_was_taken.py +0 -0
  127. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_wait_gate_metric.py +0 -0
  128. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_worker_protocol_survives_a_bad_request.py +0 -0
  129. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_thinking_is_off_on_every_runtime.py +0 -0
  130. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_vendor_gates_are_not_keyed_on_unknown.py +0 -0
  131. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_vendor_hint_decides_the_expert.py +0 -0
  132. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_version_matches_manifest.py +0 -0
  133. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_watcher.py +0 -0
  134. {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/virtual_clock.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: captchakraken
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: 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.
5
5
  Project-URL: Homepage, https://captchakraken.com
6
6
  Project-URL: Documentation, https://github.com/CaptchaKraken/CaptchaKraken#readme
@@ -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
 
@@ -3,6 +3,7 @@ The locator half is the Playwright surface the driver duck-types (`locator/filte
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import re
6
7
  from dataclasses import dataclass, field
7
8
  from typing import Any, Callable, Dict, List, Optional, Sequence
8
9
 
@@ -100,3 +101,15 @@ def fake_scope(nodes: Optional[Sequence[FakeNode]], unparsable: Sequence[str] =
100
101
  return [FakeHandle(n) for n in (nodes or ()) if _hit(selector, n.matches)]
101
102
 
102
103
  return FakeScope(lookup)
104
+
105
+
106
+ def iframe_matches(src: str, selectors: Sequence[str]) -> List[str]:
107
+ """The iframe selectors an element with this `src` answers to, the way a CSS engine would read them."""
108
+ def wants(sel: str) -> List[str]:
109
+ return re.findall(r'\[src\*="([^"]+)"\]', sel)
110
+
111
+ def hit(sel: str) -> bool:
112
+ positive, _, negative = sel.partition(":not(")
113
+ return all(s in src for s in wants(positive)) and not any(s in src for s in wants(negative))
114
+
115
+ return [sel for sel in selectors if sel.startswith("iframe[src") and hit(sel)]
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <!-- hCaptcha's challenge frame (image_label_binary, 3x3) as a live job-board posting served it inside secure-api.js's
3
+ enclave on 2026-10-15, reduced to the markup the driver reads: the picture URLs are flat gradients. -->
4
+ <html lang="en"><head><meta charset="utf-8"><title>hCaptcha</title></head>
5
+ <body class="no-selection" style="width: 400px; height: 600px;"><div class="interface-wrapper" style="width: 380px; height: 580px; margin: 10px; position: relative;"><div class="challenge-container" style="width: 380px; height: 500px; position: relative; z-index: 0; margin-bottom: 10px; right: auto;"><div class="challenge" style="width: 380px; height: 500px;"><div class="challenge-view" style="width: 380px; height: 500px;"><div class="challenge-header" style="position: relative; display: block; width: 380px; height: 110px; margin-bottom: 10px;"><div class="challenge-prompt" style="display: table; width: 380px; height: 110px; text-align: left; position: absolute; top: 0px; z-index: 0;"><div class="prompt-padding" style="width: 360px; height: 90px; top: 10px; left: 10px; position: absolute;"><h2 class="prompt-text" id="prompt-question" dir="ltr" style="font-size: 18px; line-height: normal; font-weight: normal; margin: 0px; width: 360px; color: rgb(255, 255, 255); vertical-align: top; display: table-cell; position: absolute; z-index: 5; transition: opacity 0.3s cubic-bezier(0.65, 0, 0.35, 1); direction: ltr; text-align: left;"><span style="color: inherit; font-weight: 500; font-size: inherit; line-height: inherit;">Click on all materials that absorb liquid</span></h2><p class="skip" style="width: 360px; color: rgb(255, 255, 255); font-size: 14px; vertical-align: bottom; display: table-cell; position: absolute; margin: 0px; bottom: 0px; z-index: 5; transition: opacity 0.3s cubic-bezier(0.65, 0, 0.35, 1);"></p></div><div class="prompt-bg" style="opacity: 1; position: absolute; top: 0px; left: 0px; z-index: -1; width: 378px; height: 106px; background-color: rgb(0, 131, 143); border-radius: 4px; border-width: 1px; border-color: rgb(0, 131, 143); border-style: solid;"></div></div></div><div class="task-grid" role="group" aria-labelledby="prompt-question" tabindex="0" style="width: 380px; height: 380px; position: relative; display: inline-block;"><div class="task" tabindex="0" role="button" aria-label="Challenge Image 1" aria-pressed="false" style="width: 120px; height: 120px; cursor: pointer; box-sizing: border-box; outline: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; position: absolute; left: 0px; top: 0px;"><div class="task-image" style="transition: none; width: 120px; height: 120px; position: absolute;"><div class="wrapper" style="width: 120px; height: 120px; overflow: hidden; border-radius: 2px; transition: none; position: relative; top: 50%; left: 50%; margin-top: -60px; margin-left: -60px; background-color: rgb(245, 245, 245);"><div class="image" style="position: absolute; top: 50%; left: 50%; z-index: 5; transition: none; background: linear-gradient(135deg, #7b8a8b, #ffffff) 50% 50% / 120px 120px no-repeat; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px;"></div></div></div></div><div class="task" tabindex="0" role="button" aria-label="Challenge Image 2" aria-pressed="false" style="width: 120px; height: 120px; cursor: pointer; box-sizing: border-box; outline: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; position: absolute; left: 130px; top: 0px;"><div class="task-image" style="transition: none; width: 120px; height: 120px; position: absolute;"><div class="wrapper" style="width: 120px; height: 120px; overflow: hidden; border-radius: 2px; transition: none; position: relative; top: 50%; left: 50%; margin-top: -60px; margin-left: -60px; background-color: rgb(245, 245, 245);"><div class="image" style="position: absolute; top: 50%; left: 50%; z-index: 5; transition: none; background: linear-gradient(135deg, #c9c3e6, #ffffff) 50% 50% / 120px 120px no-repeat; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px;"></div></div></div></div><div class="task" tabindex="0" role="button" aria-label="Challenge Image 3" aria-pressed="false" style="width: 120px; height: 120px; cursor: pointer; box-sizing: border-box; outline: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; position: absolute; left: 260px; top: 0px;"><div class="task-image" style="transition: none; width: 120px; height: 120px; position: absolute;"><div class="wrapper" style="width: 120px; height: 120px; overflow: hidden; border-radius: 2px; transition: none; position: relative; top: 50%; left: 50%; margin-top: -60px; margin-left: -60px; background-color: rgb(245, 245, 245);"><div class="image" style="position: absolute; top: 50%; left: 50%; z-index: 5; transition: none; background: linear-gradient(135deg, #8f9aa0, #ffffff) 50% 50% / 120px 120px no-repeat; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px;"></div></div></div></div><div class="task" tabindex="0" role="button" aria-label="Challenge Image 4" aria-pressed="false" style="width: 120px; height: 120px; cursor: pointer; box-sizing: border-box; outline: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; position: absolute; left: 0px; top: 130px;"><div class="task-image" style="transition: none; width: 120px; height: 120px; position: absolute;"><div class="wrapper" style="width: 120px; height: 120px; overflow: hidden; border-radius: 2px; transition: none; position: relative; top: 50%; left: 50%; margin-top: -60px; margin-left: -60px; background-color: rgb(245, 245, 245);"><div class="image" style="position: absolute; top: 50%; left: 50%; z-index: 5; transition: none; background: linear-gradient(135deg, #d8cdb8, #ffffff) 50% 50% / 120px 120px no-repeat; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px;"></div></div></div></div><div class="task" tabindex="0" role="button" aria-label="Challenge Image 5" aria-pressed="false" style="width: 120px; height: 120px; cursor: pointer; box-sizing: border-box; outline: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; position: absolute; left: 130px; top: 130px;"><div class="task-image" style="transition: none; width: 120px; height: 120px; position: absolute;"><div class="wrapper" style="width: 120px; height: 120px; overflow: hidden; border-radius: 2px; transition: none; position: relative; top: 50%; left: 50%; margin-top: -60px; margin-left: -60px; background-color: rgb(245, 245, 245);"><div class="image" style="position: absolute; top: 50%; left: 50%; z-index: 5; transition: none; background: linear-gradient(135deg, #2b2b2b, #ffffff) 50% 50% / 120px 120px no-repeat; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px;"></div></div></div></div><div class="task" tabindex="0" role="button" aria-label="Challenge Image 6" aria-pressed="false" style="width: 120px; height: 120px; cursor: pointer; box-sizing: border-box; outline: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; position: absolute; left: 260px; top: 130px;"><div class="task-image" style="transition: none; width: 120px; height: 120px; position: absolute;"><div class="wrapper" style="width: 120px; height: 120px; overflow: hidden; border-radius: 2px; transition: none; position: relative; top: 50%; left: 50%; margin-top: -60px; margin-left: -60px; background-color: rgb(245, 245, 245);"><div class="image" style="position: absolute; top: 50%; left: 50%; z-index: 5; transition: none; background: linear-gradient(135deg, #e3dde9, #ffffff) 50% 50% / 120px 120px no-repeat; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px;"></div></div></div></div><div class="task" tabindex="0" role="button" aria-label="Challenge Image 7" aria-pressed="false" style="width: 120px; height: 120px; cursor: pointer; box-sizing: border-box; outline: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; position: absolute; left: 0px; top: 260px;"><div class="task-image" style="transition: none; width: 120px; height: 120px; position: absolute;"><div class="wrapper" style="width: 120px; height: 120px; overflow: hidden; border-radius: 2px; transition: none; position: relative; top: 50%; left: 50%; margin-top: -60px; margin-left: -60px; background-color: rgb(245, 245, 245);"><div class="image" style="position: absolute; top: 50%; left: 50%; z-index: 5; transition: none; background: linear-gradient(135deg, #e8e2b0, #ffffff) 50% 50% / 120px 120px no-repeat; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px;"></div></div></div></div><div class="task" tabindex="0" role="button" aria-label="Challenge Image 8" aria-pressed="false" style="width: 120px; height: 120px; cursor: pointer; box-sizing: border-box; outline: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; position: absolute; left: 130px; top: 260px;"><div class="task-image" style="transition: none; width: 120px; height: 120px; position: absolute;"><div class="wrapper" style="width: 120px; height: 120px; overflow: hidden; border-radius: 2px; transition: none; position: relative; top: 50%; left: 50%; margin-top: -60px; margin-left: -60px; background-color: rgb(245, 245, 245);"><div class="image" style="position: absolute; top: 50%; left: 50%; z-index: 5; transition: none; background: linear-gradient(135deg, #6f6458, #ffffff) 50% 50% / 120px 120px no-repeat; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px;"></div></div></div></div><div class="task" tabindex="0" role="button" aria-label="Challenge Image 9" aria-pressed="false" style="width: 120px; height: 120px; cursor: pointer; box-sizing: border-box; outline: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; position: absolute; left: 260px; top: 260px;"><div class="task-image" style="transition: none; width: 120px; height: 120px; position: absolute;"><div class="wrapper" style="width: 120px; height: 120px; overflow: hidden; border-radius: 2px; transition: none; position: relative; top: 50%; left: 50%; margin-top: -60px; margin-left: -60px; background-color: rgb(245, 245, 245);"><div class="image" style="position: absolute; top: 50%; left: 50%; z-index: 5; transition: none; background: linear-gradient(135deg, #5b4a6e, #ffffff) 50% 50% / 120px 120px no-repeat; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px;"></div></div></div></div></div></div></div></div><div class="interface-challenge" dir="ltr" style="direction: ltr; text-align: left; width: 380px; height: 70px; position: absolute; left: 0px; bottom: 0px;"><div class="button-submit button" tabindex="0" role="button" aria-live="polite" title="Skip Challenge" aria-label="Skip Challenge" aria-disabled="false" style="cursor: pointer; background-color: rgb(85, 85, 85); height: 35px; min-width: 70px; padding: 0px 5px; border-radius: 4px; border: 2px solid rgb(255, 255, 255); position: absolute; bottom: 0px; z-index: 100; right: 0px; left: initial;"><div class="text" style="color: rgb(255, 255, 255); width: 100%; height: 100%; text-align: center; position: relative; pointer-events: none; line-height: 35px; font-size: 14px; font-weight: 600; z-index: 5; display: block;">Skip</div></div></div></div><script>
6
+ // Stands in for hCaptcha's own script: a tile toggles when pressed, and the submit hands a token to the enclave.
7
+ document.querySelectorAll('.task').forEach((task) => task.addEventListener('click', () =>
8
+ task.setAttribute('aria-pressed', String(task.getAttribute('aria-pressed') !== 'true'))));
9
+ document.querySelector('.button-submit').addEventListener('click', () =>
10
+ parent.postMessage({ hcaptchaToken: 'P1_' + new URLSearchParams(location.hash.slice(1)).get('id') }, '*'));
11
+ </script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <!-- hCaptcha's secure-api.js enclave frame, recorded on a live job-board posting on 2026-10-15 with its challenge open.
3
+ The script stands in for hCaptcha's: the challenge's token lands in this frame's response field, not the host page's. -->
4
+ <html lang="en"><head><meta charset="utf-8"><title>hCaptcha</title></head>
5
+ <body class="no-selection" style="overflow-y: auto;">
6
+ <div style="background-color: rgb(255, 255, 255); border: 1px solid rgb(215, 215, 215); box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 4px; border-radius: 4px; left: 440px; top: 150px; z-index: 2147483647; position: absolute; pointer-events: auto; opacity: 1; visibility: visible; display: block;"><div style="position: relative; z-index: 1; width: 400px; height: 600px;"><iframe src="hcaptcha.html#frame=challenge&amp;id=0so2b8l008q&amp;host=careers.example.com&amp;size=invisible&amp;theme=light" frameborder="0" scrolling="no" allow="private-state-token-redemption" title="hCaptcha challenge" style="border: 0px; z-index: 2000000000; position: relative; width: 400px; height: 600px;"></iframe></div><div style="width: 100%; height: 100%; position: fixed; pointer-events: auto; top: 0px; left: 0px; z-index: 0; background-color: rgb(255, 255, 255); opacity: 0.05; cursor: pointer;"></div></div>
7
+ <iframe aria-hidden="true" data-hcaptcha-widget-id="0so2b8l008q" data-hcaptcha-response="" src="hcaptcha.html#frame=checkbox-invisible" style="display: none;"></iframe>
8
+ <textarea id="g-recaptcha-response-0so2b8l008q" name="g-recaptcha-response" style="display: none;"></textarea>
9
+ <textarea id="h-captcha-response-0so2b8l008q" name="h-captcha-response" style="display: none;"></textarea>
10
+ <script>
11
+ addEventListener('message', (e) => {
12
+ if (e.data && e.data.hcaptchaToken) document.querySelector('[name="h-captcha-response"]').value = e.data.hcaptchaToken;
13
+ });
14
+ </script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <!-- A job-board apply page after hcaptcha.execute(), as recorded on 2026-10-15: no hCaptcha frame of its own, only
3
+ secure-api.js's two full-viewport enclaves, one per render. Both are served the recorded open enclave, so only the
4
+ enclave's own visibility tells them apart. FRAMES_ORIGIN is replaced with a second origin when served, because the
5
+ vendor's frames are cross-origin. -->
6
+ <html lang="en"><head><meta charset="utf-8"><title>Apply</title></head>
7
+ <body style="margin: 0; font: 16px sans-serif; background: #f5f5f5;">
8
+ <h1 style="margin: 40px 260px;">Apply</h1>
9
+ <form style="margin: 0 260px;"><label>Full name <input name="name"></label></form>
10
+ <iframe sandbox="allow-scripts allow-same-origin" title="Widget containing checkbox for hCaptcha security challenge" frameborder="0" scrolling="no" src="FRAMES_ORIGIN/idle/hcaptcha-enclave.html#frame=enclave&amp;_channel=0a0j3b54q9jb&amp;host=careers.example.com" style="position: fixed; top: 0px; left: 0px; width: 100%; height: 100%; z-index: 2147483647; visibility: hidden;"></iframe>
11
+ <iframe sandbox="allow-scripts allow-same-origin" title="Widget containing checkbox for hCaptcha security challenge" frameborder="0" scrolling="no" src="FRAMES_ORIGIN/live/hcaptcha-enclave.html#frame=enclave&amp;_channel=1vx5ycvkplus&amp;host=careers.example.com" style="position: fixed; top: 0px; left: 0px; width: 100%; height: 100%; z-index: 2147483647; visibility: visible;"></iframe>
12
+ </body>
13
+ </html>
@@ -0,0 +1,185 @@
1
+ """hCaptcha's secure-api.js draws no frame on the host page. Every frame it owns sits inside a full-viewport enclave
2
+ iframe, one enclave per render, and only the visible one holds the open challenge.
3
+
4
+ Measured on a live job-board posting on 2026-10-15: after hcaptcha.execute() the host page held two enclaves and nothing else, so
5
+ detection found no widget, and the tripwire blamed the selector table for a challenge that was on screen and still
6
+ matched every selector inside its frame. The JS test of the same name drives the same cases.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+ import sys
13
+ import threading
14
+ from contextlib import contextmanager
15
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+ from typing import Any, Iterator, List, Optional
18
+
19
+ import pytest
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
22
+
23
+ from fake_dom import FakeHandle, FakeNode, fake_scope, iframe_matches # noqa: E402
24
+
25
+ from captchakraken.action_types import ClickAction # noqa: E402
26
+ from captchakraken.kinds import FrameRole, Vendor # noqa: E402
27
+ from captchakraken.page_solver import PageSolver, PageSolverConfig # noqa: E402
28
+ from captchakraken.selectors import ENCLAVE_SELECTORS, VENDORS, WIDGET_PROBES # noqa: E402
29
+
30
+ ASSETS = "https://newassets.hcaptcha.com/captcha/v1/06f8dac28bb865a058d94cbd615a79d63217dd86/static"
31
+ ENCLAVE = f"{ASSETS}/hcaptcha-enclave.html#frame=enclave&_channel=1vx5ycvkplus&host=careers.example.com"
32
+ INVISIBLE = f"{ASSETS}/hcaptcha.html#frame=checkbox-invisible"
33
+
34
+
35
+ def _challenge_src(widget_id: str) -> str:
36
+ return f"{ASSETS}/hcaptcha.html#frame=challenge&id={widget_id}&host=careers.example.com&size=invisible"
37
+
38
+
39
+ # Every iframe selector the driver reads on a host page, so a fake frame answers to exactly what a CSS engine would.
40
+ HOST_IFRAME_SELECTORS = tuple(dict.fromkeys([
41
+ *(p.selector for p in WIDGET_PROBES), *ENCLAVE_SELECTORS,
42
+ *(sel for _, s in VENDORS for sel in (*s.passive, *(f'iframe[src*="{h}"]' for h in s.hosts))),
43
+ ]))
44
+
45
+
46
+ def _iframe(src: str, **node: Any) -> FakeNode:
47
+ return FakeNode(matches=iframe_matches(src, HOST_IFRAME_SELECTORS), **node)
48
+
49
+
50
+ def _enclave(visible: bool, frame: List[FakeNode]) -> FakeNode:
51
+ return _iframe(ENCLAVE, visible=visible, frame=frame)
52
+
53
+
54
+ def _response(value: str = "") -> FakeNode:
55
+ return FakeNode(matches=['[name="h-captcha-response"]'], visible=False, value=value)
56
+
57
+
58
+ def test_the_open_challenge_is_found_inside_the_visible_enclave_not_the_hidden_one():
59
+ # The hidden enclave's challenge is not hidden inside its own document, so the enclave's visibility is what decides.
60
+ idle = _iframe(_challenge_src("0ewuzu8hdfwo"))
61
+ live = _iframe(_challenge_src("0so2b8l008q"))
62
+ page = fake_scope([
63
+ _enclave(False, [idle, _iframe(INVISIBLE, visible=False), _response()]),
64
+ _enclave(True, [live, _iframe(INVISIBLE, visible=False), _response()]),
65
+ ])
66
+ widget = PageSolver().detect_captcha(page)
67
+ assert widget is not None, "nothing detected through the enclave"
68
+ assert (widget.vendor, widget.role) == (Vendor.HCAPTCHA, FrameRole.CHALLENGE)
69
+ assert isinstance(widget.element, FakeHandle) and widget.element.node is live, "detected the hidden enclave's challenge"
70
+
71
+
72
+ def test_an_enclave_nobody_has_raised_holds_no_widget():
73
+ page = fake_scope([_enclave(False, [_iframe(_challenge_src("a"))]), _enclave(False, [_iframe(_challenge_src("b"))])])
74
+ assert PageSolver().detect_captcha(page) is None
75
+
76
+
77
+ def test_the_invisible_checkbox_is_never_a_checkbox_to_click():
78
+ assert iframe_matches(INVISIBLE, [p.selector for p in WIDGET_PROBES]) == [], "a probe names the invisible checkbox frame"
79
+ assert PageSolver().detect_captcha(fake_scope([_enclave(True, [_iframe(INVISIBLE)])])) is None
80
+
81
+
82
+ def test_a_token_written_inside_the_enclave_is_a_solve():
83
+ solver = PageSolver()
84
+ assert solver.is_captcha_solved(fake_scope([_enclave(True, [_iframe(_challenge_src("a")), _response()])])) is False
85
+ assert solver.is_captcha_solved(fake_scope([_enclave(True, [_iframe(_challenge_src("a")), _response("P1_eyJ0")])]))
86
+
87
+
88
+ def test_an_enclave_is_not_a_frame_the_selector_table_missed():
89
+ solver = PageSolver()
90
+ quiet = fake_scope([_enclave(True, [_iframe(INVISIBLE, visible=False)]), _enclave(False, [])])
91
+ assert solver._unmatched_vendor_frames(quiet) == [], "blamed the table for a frame that is never interactive"
92
+ # A frame inside the enclave that no selector names IS the table going stale, and must still say so.
93
+ renamed = fake_scope([_enclave(True, [_iframe(f"{ASSETS}/hcaptcha.html#frame=puzzle&id=a")])])
94
+ assert solver._unmatched_vendor_frames(renamed) == [Vendor.HCAPTCHA]
95
+
96
+
97
+ # The same page through a real browser: two origins, two levels of frames, the layout that page served.
98
+ FIXTURES = Path(__file__).resolve().parent / "fixtures" / "hcaptcha_enclave"
99
+ # Where the live enclave puts its challenge: the wrapper's offset plus its 1px border, at the frame's own size.
100
+ CHALLENGE_BOX = {"x": 441, "y": 151, "width": 400, "height": 600}
101
+
102
+
103
+ def _tile(n: int) -> List[float]:
104
+ """Tile `n` of the recorded 3x3, as the model answers: fractions of the challenge frame."""
105
+ col, row = (n - 1) % 3, (n - 1) // 3
106
+ x, y = 10 + col * 130, 130 + row * 130
107
+ return [x / 400, y / 600, (x + 120) / 400, (y + 120) / 600]
108
+
109
+
110
+ class _FixtureHandler(SimpleHTTPRequestHandler):
111
+ NAMES = {"host.html": "host.html", "hcaptcha-enclave.html": "enclave.html", "hcaptcha.html": "challenge.html"}
112
+
113
+ def do_GET(self) -> None: # noqa: N802 - the stdlib's name
114
+ name = self.NAMES.get(self.path.split("?")[0].rsplit("/", 1)[-1])
115
+ if name is None:
116
+ self.send_error(404)
117
+ return
118
+ body = (FIXTURES / name).read_text().replace("FRAMES_ORIGIN", f"http://localhost:{self.server.server_port}")
119
+ self.send_response(200)
120
+ self.send_header("content-type", "text/html")
121
+ self.end_headers()
122
+ self.wfile.write(body.encode())
123
+
124
+ def log_message(self, *_: Any) -> None:
125
+ pass
126
+
127
+
128
+ @contextmanager
129
+ def _served_fixtures() -> Iterator[str]:
130
+ """host.html on one origin; the enclaves and the challenge on another, as hCaptcha serves them."""
131
+ server = ThreadingHTTPServer(("127.0.0.1", 0), _FixtureHandler)
132
+ threading.Thread(target=server.serve_forever, daemon=True).start()
133
+ try:
134
+ yield f"http://127.0.0.1:{server.server_port}/host.html"
135
+ finally:
136
+ server.shutdown()
137
+ server.server_close()
138
+
139
+
140
+ @pytest.fixture
141
+ def recorded_page() -> Iterator[Any]:
142
+ sync_playwright = pytest.importorskip("playwright.sync_api", reason="playwright not installed").sync_playwright
143
+ with _served_fixtures() as url, sync_playwright() as p:
144
+ browser = p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"])
145
+ try:
146
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
147
+ page.goto(url)
148
+ page.frame_locator('iframe[src*="/live/"]').frame_locator('iframe[src*="frame=challenge"]') \
149
+ .locator(".button-submit").wait_for()
150
+ yield page
151
+ finally:
152
+ browser.close()
153
+
154
+
155
+ def _live_challenge(page: Any) -> Any:
156
+ return next(f for f in page.frames if "/live/hcaptcha.html#frame=challenge" in f.url)
157
+
158
+
159
+ def test_through_real_frames_the_challenge_is_found_where_it_is_on_screen(recorded_page):
160
+ widget = PageSolver().detect_captcha(recorded_page)
161
+ assert widget is not None, "nothing detected through the enclave"
162
+ assert widget.role == FrameRole.CHALLENGE
163
+ assert widget.element.content_frame() == _live_challenge(recorded_page), "detected the hidden enclave's challenge"
164
+ # Clicks and crops are placed in page coordinates, so the box must be measured from the top frame.
165
+ assert widget.element.bounding_box() == CHALLENGE_BOX
166
+
167
+
168
+ def test_through_real_frames_a_solve_clicks_the_tiles_it_was_told_to_and_reads_the_token(recorded_page):
169
+ solver = PageSolver(config=PageSolverConfig(humanization="none", video_solve_enabled=False, max_solve_loops=1,
170
+ detection_timeout_ms=2_000, post_solve_outcome_timeout_ms=2_000))
171
+ shown: List[Optional[tuple]] = []
172
+
173
+ def answer(image_path: str, *_: Any, **__: Any):
174
+ shown.append(struct.unpack(">II", Path(image_path).read_bytes()[16:24]))
175
+ return [ClickAction(action="click", target_bounding_boxes=[_tile(2), _tile(6)])], []
176
+
177
+ solver._get_solution = answer
178
+ solver._solver.planner.report_outcome = lambda *_: False
179
+
180
+ result = solver.solve(recorded_page)
181
+ pressed = _live_challenge(recorded_page).locator('.task[aria-pressed="true"]').evaluate_all(
182
+ "tasks => tasks.map((t) => t.getAttribute('aria-label'))")
183
+ assert pressed == ["Challenge Image 2", "Challenge Image 6"], "the clicks landed on other tiles"
184
+ assert shown == [(CHALLENGE_BOX["width"], CHALLENGE_BOX["height"])], "the model was shown something other than the challenge"
185
+ assert result.is_solved, "the token the enclave received was not read as a solve"
@@ -116,10 +116,9 @@ class _Scope:
116
116
  self.srcs = srcs
117
117
 
118
118
  def locator(self, selector: str) -> Any:
119
- from fake_dom import FakeLocator
119
+ from fake_dom import FakeLocator, iframe_matches
120
120
 
121
- needle = selector.split('src*="', 1)[1].split('"', 1)[0] if 'src*="' in selector else None
122
- return FakeLocator(lambda: [_Frame(s) for s in self.srcs if needle and needle in s])
121
+ return FakeLocator(lambda: [_Frame(s) for s in self.srcs if iframe_matches(s, [selector])])
123
122
 
124
123
 
125
124
  def test_an_invisible_badge_is_not_an_unmatched_frame():
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes