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.
- {captchakraken-3.2.0 → captchakraken-3.2.1}/PKG-INFO +1 -1
- {captchakraken-3.2.0 → captchakraken-3.2.1}/pyproject.toml +1 -1
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/__init__.py +1 -1
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/page_solver.py +37 -19
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/selectors.py +9 -2
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/fake_dom.py +13 -0
- captchakraken-3.2.1/tests/fixtures/hcaptcha_enclave/challenge.html +13 -0
- captchakraken-3.2.1/tests/fixtures/hcaptcha_enclave/enclave.html +16 -0
- captchakraken-3.2.1/tests/fixtures/hcaptcha_enclave/host.html +13 -0
- captchakraken-3.2.1/tests/test_a_widget_inside_an_enclave_is_found.py +185 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_widget_is_waited_for.py +2 -3
- {captchakraken-3.2.0 → captchakraken-3.2.1}/.gitignore +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/AGENTS.md +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/Dockerfile +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/LICENSE +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/NOTICE +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/README.md +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/README.md +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/_harness.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/demoRecaptcha.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/watch_playwright.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/with_local_gguf.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/examples/with_playwright.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/action_types.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/cli.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/config.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/errors.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/humanize.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/image_processor.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/keyframes.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/kinds.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/models.json +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/overlay.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/pinned_model.json +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/planner.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/prompts.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/server_manager.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/solver.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/timing.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/tool_calls/board_painted.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/tool_calls/find_checkbox.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/tool_calls/find_grid.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/tool_calls/track_piece.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/updater.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/verdicts.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/src/captchakraken/watcher.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/fixtures/cursory_cross_port.json +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/fixtures/vendor_verdicts.json +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_blank_board_is_not_photographed.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_board_is_routed_and_watched_once_it_has_painted.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_board_that_never_repeats_is_animated.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_board_that_will_not_film_may_already_be_solved.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_burst_cannot_trip_its_own_deadline.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_capture_never_clips_the_page.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_checkbox_is_clicked_not_watched.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_grid_is_a_regular_lattice.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_missing_prompt_is_not_a_reason_to_wait.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_new_board_ends_the_film.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_pin_reads_its_own_entry.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_reask_is_a_different_sample.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_refusal_keeps_its_machine_readable_half.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_refused_clip_is_not_re_asked.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_repeated_answer_is_not_re_performed.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_replaced_board_has_not_failed_yet.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_round_that_did_nothing_waits_for_nothing.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_slow_camera_does_not_stretch_the_burst.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_slow_model_does_not_extend_the_burst.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_solved_board_is_not_lost_to_a_typed_action.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_still_board_is_not_filmed.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_still_burst_stops_at_the_floor.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_an_unusable_answer_is_not_a_dead_page.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_animated_solve_budget.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_api_error_is_exported.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_api_errors.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_browser_compat.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_cli_stdout_is_the_wire_between_the_ports.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_credentials_file.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_cycling_board_waits_for_its_screen.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_empty_answer_still_submits.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_endpoint_is_dialled_once.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_every_keyframe_write_names_its_stem.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_expert_reaches_the_wire.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_expert_routing.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_fetch_command.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_find_checkbox_rejects_what_is_not_a_checkbox.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_freshness_check.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_geetest_accept_is_a_signal.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_geetest_done_needs_no_bounding_box.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_geetest_submit_button.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_detection.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_detection_ci.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_dims_must_be_possible.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_flank_contrast.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_geometry_gates.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_never_pairs_tiles.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_noisy_gutter.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_off_lattice_clusters.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_overcount_falls_back.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_grid_round_pays_once.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_hcaptcha_badge_detection.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_humanizer.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_licensed_models.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_min_pixels_upscale.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_one_ask_cannot_outlive_the_solve.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_our_own_gesture_is_not_animation.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_outcome_reporting.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_overlay_is_what_the_model_reads.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_page_solver.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_pinned_model.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_pixel_parser.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_private_models.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_public_contract.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_recaptcha_chipped_board.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_recaptcha_dynamic_more_is_not_an_error.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_routing_headers.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_selected_fields.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_setup_sh_model_ids.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_slide_reads_the_piece_off_the_page.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_solver.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_stale_handle_after_submit.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_film_outlives_the_answer.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_legacy_model_warning_is_one_text.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_notice_travels_with_the_package.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_second_look_believes_its_recording.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_two_ports_share_one_mouse.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_vendor_says_whether_the_round_was_taken.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_wait_gate_metric.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_worker_protocol_survives_a_bad_request.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_thinking_is_off_on_every_runtime.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_vendor_gates_are_not_keyed_on_unknown.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_vendor_hint_decides_the_expert.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_version_matches_manifest.py +0 -0
- {captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_watcher.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
|
@@ -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&id=0so2b8l008q&host=careers.example.com&size=invisible&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&_channel=0a0j3b54q9jb&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&_channel=1vx5ycvkplus&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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_board_that_never_repeats_is_animated.py
RENAMED
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_burst_cannot_trip_its_own_deadline.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_missing_prompt_is_not_a_reason_to_wait.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_refusal_keeps_its_machine_readable_half.py
RENAMED
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_repeated_answer_is_not_re_performed.py
RENAMED
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_replaced_board_has_not_failed_yet.py
RENAMED
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_round_that_did_nothing_waits_for_nothing.py
RENAMED
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_slow_camera_does_not_stretch_the_burst.py
RENAMED
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_a_slow_model_does_not_extend_the_burst.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_an_unusable_answer_is_not_a_dead_page.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_cli_stdout_is_the_wire_between_the_ports.py
RENAMED
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_cycling_board_waits_for_its_screen.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_every_keyframe_write_names_its_stem.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_geetest_done_needs_no_bounding_box.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_recaptcha_dynamic_more_is_not_an_error.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_slide_reads_the_piece_off_the_page.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_legacy_model_warning_is_one_text.py
RENAMED
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_notice_travels_with_the_package.py
RENAMED
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_second_look_believes_its_recording.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_the_worker_protocol_survives_a_bad_request.py
RENAMED
|
File without changes
|
|
File without changes
|
{captchakraken-3.2.0 → captchakraken-3.2.1}/tests/test_vendor_gates_are_not_keyed_on_unknown.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|