intuned-runtime 1.3.4__tar.gz → 1.3.6__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.

Potentially problematic release.


This version of intuned-runtime might be problematic. Click here for more details.

Files changed (118) hide show
  1. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/PKG-INFO +2 -1
  2. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/controller/save.py +2 -2
  3. intuned_runtime-1.3.6/intuned_runtime/captcha/__init__.py +7 -0
  4. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/pyproject.toml +2 -1
  5. intuned_runtime-1.3.6/runtime/browser/extensions/__init__.py +7 -0
  6. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/browser/extensions/intuned_extension.py +10 -8
  7. intuned_runtime-1.3.6/runtime/browser/extensions/intuned_extension_server.py +207 -0
  8. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/browser/launch_chromium.py +9 -1
  9. intuned_runtime-1.3.6/runtime/helpers/extensions.py +371 -0
  10. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/types/settings_types.py +11 -0
  11. intuned_runtime-1.3.4/runtime/browser/extensions/__init__.py +0 -3
  12. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/LICENSE +0 -0
  13. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/README.md +0 -0
  14. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/__init__.py +0 -0
  15. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/__init__.py +0 -0
  16. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/attempt_api_command.py +0 -0
  17. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/attempt_authsession_check_command.py +0 -0
  18. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/attempt_authsession_command.py +0 -0
  19. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/attempt_authsession_create_command.py +0 -0
  20. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/attempt_command.py +0 -0
  21. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/authsession_command.py +0 -0
  22. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/authsession_record_command.py +0 -0
  23. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/command.py +0 -0
  24. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/deploy_command.py +0 -0
  25. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/init_command.py +0 -0
  26. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/run_api_command.py +0 -0
  27. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/run_authsession_command.py +0 -0
  28. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/run_authsession_create_command.py +0 -0
  29. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/run_authsession_update_command.py +0 -0
  30. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/run_authsession_validate_command.py +0 -0
  31. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/run_command.py +0 -0
  32. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/commands/save_command.py +0 -0
  33. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/controller/__test__/__init__.py +0 -0
  34. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/controller/__test__/test_api.py +0 -0
  35. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/controller/__test__/test_authsession.py +0 -0
  36. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/controller/api.py +0 -0
  37. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/controller/authsession.py +0 -0
  38. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/controller/deploy.py +0 -0
  39. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/types.py +0 -0
  40. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/__test__/test_browser.py +0 -0
  41. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/__test__/test_traces.py +0 -0
  42. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/api_helpers.py +0 -0
  43. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/auth_session_helpers.py +0 -0
  44. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/backend.py +0 -0
  45. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/browser.py +0 -0
  46. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/confirmation.py +0 -0
  47. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/console.py +0 -0
  48. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/error.py +0 -0
  49. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/exclusions.py +0 -0
  50. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/get_auth_parameters.py +0 -0
  51. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/help.py +0 -0
  52. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/import_function.py +0 -0
  53. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/timeout.py +0 -0
  54. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/traces.py +0 -0
  55. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_cli/utils/wrapper.py +0 -0
  56. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/__init__.py +0 -0
  57. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/__init__.py +0 -0
  58. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/browser/__init__.py +0 -0
  59. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/browser/save_state.py +0 -0
  60. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/project/__init__.py +0 -0
  61. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/project/auth_session/__init__.py +0 -0
  62. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/project/auth_session/check.py +0 -0
  63. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/project/auth_session/create.py +0 -0
  64. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/project/auth_session/load.py +0 -0
  65. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/project/project.py +0 -0
  66. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/project/run.py +0 -0
  67. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/project/run_interface.py +0 -0
  68. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/project/type_check.py +0 -0
  69. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/commands/root.py +0 -0
  70. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/logger.py +0 -0
  71. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/utils/ai_source_project.py +0 -0
  72. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/utils/code_tree.py +0 -0
  73. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/utils/run_apis.py +0 -0
  74. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/utils/setup_ide_functions_token.py +0 -0
  75. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/utils/unix_socket.py +0 -0
  76. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_internal_cli/utils/wrapper.py +0 -0
  77. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/intuned_runtime/__init__.py +0 -0
  78. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/__init__.py +0 -0
  79. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/backend_functions/__init__.py +0 -0
  80. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/backend_functions/_call_backend_function.py +0 -0
  81. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/backend_functions/get_auth_session_parameters.py +0 -0
  82. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/browser/__init__.py +0 -0
  83. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/browser/extensions/helpers.py +0 -0
  84. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/browser/helpers.py +0 -0
  85. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/browser/launch_browser.py +0 -0
  86. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/browser/launch_camoufox.py +0 -0
  87. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/browser/storage_state.py +0 -0
  88. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/constants.py +0 -0
  89. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/context/__init__.py +0 -0
  90. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/context/context.py +0 -0
  91. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/env.py +0 -0
  92. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/errors/__init__.py +0 -0
  93. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/errors/auth_session_errors.py +0 -0
  94. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/errors/run_api_errors.py +0 -0
  95. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/errors/trace_errors.py +0 -0
  96. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/helpers/__init__.py +0 -0
  97. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/helpers/attempt_store.py +0 -0
  98. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/helpers/extend_payload.py +0 -0
  99. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/helpers/extend_timeout.py +0 -0
  100. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/helpers/get_auth_session_parameters.py +0 -0
  101. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/py.typed +0 -0
  102. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/run/__init__.py +0 -0
  103. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/run/intuned_settings.py +0 -0
  104. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/run/playwright_context.py +0 -0
  105. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/run/playwright_tracing.py +0 -0
  106. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/run/pydantic_encoder.py +0 -0
  107. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/run/run_api.py +0 -0
  108. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/run/setup_context_hook.py +0 -0
  109. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/run/traces.py +0 -0
  110. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/run/types.py +0 -0
  111. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/types/__init__.py +0 -0
  112. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/types/payload.py +0 -0
  113. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/types/run_types.py +0 -0
  114. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/utils/__init__.py +0 -0
  115. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/utils/anyio.py +0 -0
  116. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime/utils/config_loader.py +0 -0
  117. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime_helpers/__init__.py +0 -0
  118. {intuned_runtime-1.3.4 → intuned_runtime-1.3.6}/runtime_helpers/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: intuned-runtime
3
- Version: 1.3.4
3
+ Version: 1.3.6
4
4
  Summary: Runtime SDK that powers browser automation projects running on Intuned
5
5
  License: Elastic-2.0
6
6
  License-File: LICENSE
@@ -27,6 +27,7 @@ Requires-Dist: jsonc-parser (>=1.1.5,<2.0.0)
27
27
  Requires-Dist: more-termcolor (>=1.1.3,<2.0.0)
28
28
  Requires-Dist: pathspec (>=0.12.1,<0.13.0)
29
29
  Requires-Dist: pydantic (>=2.10.6,<3.0.0)
30
+ Requires-Dist: pyee (>=13.0.0,<14.0.0)
30
31
  Requires-Dist: pyright (>=1.1.387,<2.0.0)
31
32
  Requires-Dist: python-dotenv (==1.0.1)
32
33
  Requires-Dist: pytimeparse (>=1.1.8,<2.0.0)
@@ -60,8 +60,8 @@ async def validate_intuned_project():
60
60
 
61
61
 
62
62
  def validate_project_name(project_name: str):
63
- if len(project_name) > 50:
64
- raise CLIError("Project name must be 50 characters or less.")
63
+ if len(project_name) > 200:
64
+ raise CLIError("Project name must be 200 characters or less.")
65
65
 
66
66
  project_name_regex = r"^[a-z0-9]+(?:[-_][a-z0-9]+)*$"
67
67
  if not re.match(project_name_regex, project_name):
@@ -0,0 +1,7 @@
1
+ from runtime.helpers.extensions import on_captcha_event
2
+ from runtime.helpers.extensions import wait_for_captcha_solve
3
+
4
+ __all__ = [
5
+ "on_captcha_event",
6
+ "wait_for_captcha_solve",
7
+ ]
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "intuned-runtime"
7
- version = "1.3.4"
7
+ version = "1.3.6"
8
8
  description = "Runtime SDK that powers browser automation projects running on Intuned"
9
9
  authors = [ "Intuned Developers <engineering@intunedhq.com>",]
10
10
  readme = "README.md"
@@ -52,6 +52,7 @@ pytimeparse = "^1.1.8"
52
52
  rich = "^14.1.0"
53
53
  jsonc-parser = "^1.1.5"
54
54
  pyyaml = "^6.0.3"
55
+ pyee = "^13.0.0"
55
56
 
56
57
  [tool.poetry.scripts]
57
58
  intuned = "intuned_cli:run"
@@ -0,0 +1,7 @@
1
+ from .helpers import build_extensions_list
2
+ from .intuned_extension_server import clean_intuned_extension_server
3
+
4
+ __all__ = [
5
+ "build_extensions_list",
6
+ "clean_intuned_extension_server",
7
+ ]
@@ -5,6 +5,7 @@ from typing import Any
5
5
 
6
6
  from playwright.async_api import BrowserContext
7
7
 
8
+ from runtime.browser.extensions.intuned_extension_server import setup_intuned_extension_server
8
9
  from runtime.context.context import IntunedContext
9
10
  from runtime.env import get_functions_domain
10
11
  from runtime.env import get_project_id
@@ -50,13 +51,7 @@ async def get_intuned_worker(context: BrowserContext):
50
51
  return None
51
52
 
52
53
 
53
- async def get_intuned_extension_settings() -> dict[str, Any]:
54
- intuned_json = await load_intuned_json()
55
- captcha_settings: CaptchaSolverSettings = (
56
- intuned_json.captcha_solver
57
- if intuned_json and intuned_json.captcha_solver is not None
58
- else CaptchaSolverSettings()
59
- )
54
+ async def get_intuned_extension_settings(captcha_settings: CaptchaSolverSettings) -> dict[str, Any]:
60
55
  context = IntunedContext.current()
61
56
  return {
62
57
  **captcha_settings.model_dump(mode="json"),
@@ -70,13 +65,20 @@ async def get_intuned_extension_settings() -> dict[str, Any]:
70
65
  async def setup_intuned_extension():
71
66
  if not is_intuned_extension_enabled():
72
67
  return
68
+ intuned_json = await load_intuned_json()
69
+ captcha_settings: CaptchaSolverSettings = (
70
+ intuned_json.captcha_solver
71
+ if intuned_json and intuned_json.captcha_solver is not None
72
+ else CaptchaSolverSettings()
73
+ )
74
+ await setup_intuned_extension_server(captcha_settings)
73
75
  intuned_extension_path = get_intuned_extension_path()
74
76
  if intuned_extension_path is None:
75
77
  logger.warning("Intuned extension path not found, intuned extension might not work properly")
76
78
  return
77
79
 
78
80
  settings_path = intuned_extension_path / "intunedSettings.json"
79
- settings_data = await get_intuned_extension_settings()
81
+ settings_data = await get_intuned_extension_settings(captcha_settings)
80
82
 
81
83
  try:
82
84
  with open(settings_path, "w") as f:
@@ -0,0 +1,207 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import threading
5
+ from collections import deque
6
+ from typing import Any
7
+ from typing import Deque
8
+ from typing import Literal
9
+ from typing import Optional
10
+
11
+ from pydantic import BaseModel
12
+ from pydantic import Field
13
+ from pydantic import ValidationError
14
+ from pyee.asyncio import AsyncIOEventEmitter
15
+ from waitress.server import BaseWSGIServer
16
+ from waitress.server import create_server
17
+ from waitress.server import MultiSocketServer
18
+
19
+ from runtime.types import CaptchaSolverSettings
20
+
21
+ logging.basicConfig(level=logging.INFO)
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ CaptchaEvent = Literal[
26
+ "CAPTCHA_EXTENSION_READY", "CAPTCHA_DETECTED", "CAPTCHA_SOLVED", "HIT_LIMIT", "MAX_RETRIES_EXHAUSTED", "ERROR"
27
+ ]
28
+
29
+
30
+ class EventRequest(BaseModel):
31
+ model_config = {"populate_by_name": True}
32
+ event: CaptchaEvent
33
+ # tab_id: str = Field(None, alias="tabId")
34
+ session_id: Optional[str] = Field(None, alias="sessionId")
35
+ payload: Optional[Any] = None
36
+
37
+
38
+ class EventQueues:
39
+ queues: dict[CaptchaEvent, Deque[EventRequest]]
40
+
41
+ def __init__(self):
42
+ self.queues = {}
43
+
44
+
45
+ class TabEventQueue:
46
+ events_queue: dict[str, EventQueues]
47
+ last_detection_event: Optional[EventRequest]
48
+
49
+ def __init__(self, tab_id: str):
50
+ self.tab_id = tab_id
51
+ self.events_queue = {}
52
+ self.last_detection_event = None
53
+
54
+
55
+ class ExtensionServer:
56
+ tabs: dict[str, TabEventQueue]
57
+ is_healthy: bool = False
58
+ _server: Optional[MultiSocketServer | BaseWSGIServer] = None
59
+ _loop: Optional[asyncio.AbstractEventLoop] = None
60
+ _thread: Optional[threading.Thread] = None
61
+
62
+ def __init__(self):
63
+ self.tabs = dict()
64
+
65
+ def __call__(self, environ, start_response):
66
+ """WSGI application"""
67
+ path = environ.get("PATH_INFO", "")
68
+ method = environ["REQUEST_METHOD"]
69
+
70
+ if path == "/ingest" and method == "POST":
71
+ return self._handle_ingest(environ, start_response)
72
+
73
+ start_response("404 Not Found", [("Content-Type", "application/json")])
74
+ return [json.dumps({"error": "Not found"}).encode()]
75
+
76
+ def _handle_queue_event(self, event: EventRequest):
77
+ queueable_events: list[CaptchaEvent] = [
78
+ "CAPTCHA_DETECTED",
79
+ "CAPTCHA_SOLVED",
80
+ "HIT_LIMIT",
81
+ "MAX_RETRIES_EXHAUSTED",
82
+ "ERROR",
83
+ ]
84
+
85
+ if event.event not in queueable_events:
86
+ return
87
+ tab_id = "page-0" # We will revisit on multi-tab support
88
+ if event.session_id is None:
89
+ return
90
+ if tab_id not in self.tabs:
91
+ self.tabs[tab_id] = TabEventQueue(tab_id=tab_id)
92
+ tab_info = self.tabs[tab_id]
93
+ if event.event == "CAPTCHA_DETECTED":
94
+ tab_info.last_detection_event = event
95
+ return
96
+
97
+ if event.session_id not in tab_info.events_queue:
98
+ tab_info.events_queue[event.session_id] = EventQueues()
99
+
100
+ event_queues = tab_info.events_queue[event.session_id]
101
+ if event.event not in event_queues.queues:
102
+ event_queues.queues[event.event] = deque(maxlen=5)
103
+
104
+ event_queues.queues[event.event].append(event)
105
+
106
+ def _handle_ingest(self, environ, start_response):
107
+ try:
108
+ global event_emitter
109
+ if event_emitter is None:
110
+ event_emitter = AsyncIOEventEmitter()
111
+ content_length = int(environ.get("CONTENT_LENGTH", 0))
112
+ body = environ["wsgi.input"].read(content_length)
113
+ data = json.loads(body)
114
+ event_data = EventRequest(**data)
115
+ if event_data.event == "CAPTCHA_EXTENSION_READY":
116
+ self.is_healthy = True
117
+ self._handle_queue_event(event=event_data)
118
+ if self._loop and not self._loop.is_closed():
119
+ self._loop.call_soon_threadsafe(event_emitter.emit, event_data.event, {})
120
+ start_response("200 OK", [("Content-Type", "application/json")])
121
+ return [json.dumps({}).encode()]
122
+
123
+ except ValidationError as e:
124
+ start_response("400 Bad Request", [("Content-Type", "application/json")])
125
+ return [json.dumps({"error": e.errors()}).encode()]
126
+ except Exception as e:
127
+ logger.error(f"Error: {e}")
128
+ start_response("500 Internal Server Error", [("Content-Type", "application/json")])
129
+ return [json.dumps({"error": "Internal server error"}).encode()]
130
+
131
+ async def start(self, port: int = 3000, host: str = "0.0.0.0") -> None:
132
+ """Start server using daemon thread"""
133
+ self._loop = asyncio.get_running_loop()
134
+ self._server = create_server(self.__call__, host=host, port=port)
135
+
136
+ def _run_server():
137
+ try:
138
+ if self._server:
139
+ self._server.run()
140
+ except OSError as err:
141
+ if err.errno != 9:
142
+ raise
143
+
144
+ self._thread = threading.Thread(target=_run_server, daemon=True)
145
+ self._thread.start()
146
+
147
+ async def stop(self):
148
+ self._loop = None
149
+ if self._server:
150
+ self._server.close()
151
+
152
+ if self._thread and self._thread.is_alive():
153
+ self._thread.join(timeout=5.0)
154
+
155
+
156
+ event_emitter: Optional[AsyncIOEventEmitter] = None
157
+ extension_server: Optional[ExtensionServer] = None
158
+
159
+
160
+ async def setup_intuned_extension_server(captcha_settings: Optional[CaptchaSolverSettings] = None):
161
+ global event_emitter, extension_server
162
+ if captcha_settings is None:
163
+ captcha_settings = CaptchaSolverSettings()
164
+ extension_server = ExtensionServer()
165
+ event_emitter = AsyncIOEventEmitter()
166
+ await extension_server.start(port=captcha_settings.port)
167
+
168
+
169
+ async def clean_intuned_extension_server():
170
+ global event_emitter, extension_server
171
+ if extension_server is not None:
172
+ await extension_server.stop()
173
+ extension_server = None
174
+
175
+ if event_emitter is not None:
176
+ event_emitter.remove_all_listeners()
177
+ event_emitter = None
178
+
179
+
180
+ def get_event_from_event_queue(
181
+ event: CaptchaEvent, tab_id: str = "page-0", session_id: str = ""
182
+ ) -> Optional[EventRequest]:
183
+ if extension_server is None:
184
+ raise RuntimeError("Extension server is not initialized or healthy")
185
+
186
+ tab_info = extension_server.tabs.get(tab_id)
187
+ if tab_info is None:
188
+ return None
189
+ if event == "CAPTCHA_DETECTED":
190
+ value = tab_info.last_detection_event
191
+ tab_info.last_detection_event = None
192
+ return value
193
+
194
+ if session_id not in tab_info.events_queue:
195
+ return None
196
+
197
+ event_queues = tab_info.events_queue[session_id]
198
+ if event not in event_queues.queues or len(event_queues.queues[event]) == 0:
199
+ return None
200
+
201
+ return event_queues.queues[event].popleft()
202
+
203
+
204
+ def get_intuned_event_emitter() -> AsyncIOEventEmitter:
205
+ if event_emitter is None:
206
+ raise RuntimeError("Event emitter is not initliazed")
207
+ return event_emitter
@@ -12,6 +12,8 @@ from runtime.browser.extensions import build_extensions_list
12
12
  from runtime.browser.extensions.intuned_extension import get_intuned_worker
13
13
  from runtime.browser.extensions.intuned_extension import is_intuned_extension_enabled
14
14
  from runtime.browser.extensions.intuned_extension import setup_intuned_extension
15
+ from runtime.browser.extensions.intuned_extension_server import clean_intuned_extension_server
16
+ from runtime.browser.extensions.intuned_extension_server import setup_intuned_extension_server
15
17
 
16
18
  from .helpers import get_local_cdp_address
17
19
  from .helpers import get_proxy_env
@@ -67,6 +69,8 @@ async def launch_chromium(
67
69
  extra_args: list[str] = []
68
70
  async with async_playwright() as playwright:
69
71
  if cdp_address is not None:
72
+ if is_intuned_extension_enabled():
73
+ await setup_intuned_extension_server()
70
74
  browser: Browser = await playwright.chromium.connect_over_cdp(cdp_address)
71
75
  context = browser.contexts[0]
72
76
  user_preferences_dir = None
@@ -127,6 +131,10 @@ async def launch_chromium(
127
131
 
128
132
  context.set_default_timeout(timeout * 1000)
129
133
 
134
+ async def clean_up_after_close(*_: Any, **__: Any) -> None:
135
+ await remove_dir_after_close()
136
+ await clean_intuned_extension_server()
137
+
130
138
  async def remove_dir_after_close(*_: Any, **__: Any) -> None:
131
139
  if not dir_to_clean:
132
140
  return
@@ -142,7 +150,7 @@ async def launch_chromium(
142
150
  )
143
151
  await process.wait()
144
152
 
145
- context.once("close", remove_dir_after_close)
153
+ context.once("close", clean_up_after_close)
146
154
 
147
155
  yield context, context.pages[0]
148
156
 
@@ -0,0 +1,371 @@
1
+ import asyncio
2
+ import functools
3
+ import logging
4
+ from collections.abc import Awaitable
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+ from typing import Optional
8
+ from typing import overload
9
+ from typing import TypeVar
10
+
11
+ from playwright.async_api import Page
12
+
13
+ from runtime.browser.extensions.intuned_extension_server import CaptchaEvent
14
+ from runtime.browser.extensions.intuned_extension_server import EventRequest
15
+ from runtime.browser.extensions.intuned_extension_server import get_event_from_event_queue
16
+ from runtime.browser.extensions.intuned_extension_server import get_intuned_event_emitter
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ # Overload 1: Direct call with page only (callable pattern)
24
+ @overload
25
+ async def wait_for_captcha_solve(
26
+ page: Page,
27
+ *,
28
+ timeout: int = 10_000,
29
+ ) -> None: ...
30
+
31
+
32
+ # Overload 2: Wrapper pattern with page and func
33
+ @overload
34
+ async def wait_for_captcha_solve(
35
+ *,
36
+ page: Page,
37
+ func: Callable[[], Awaitable[Any]],
38
+ timeout: int = 10_000,
39
+ wait_for_network_settled: bool = True,
40
+ ) -> Any: ...
41
+
42
+
43
+ # Overload 3: Decorator without arguments
44
+ @overload
45
+ def wait_for_captcha_solve(
46
+ func: Callable[..., Awaitable[Any]],
47
+ ) -> Callable[..., Awaitable[Any]]: ...
48
+
49
+
50
+ # Overload 4: Decorator factory with arguments
51
+ @overload
52
+ def wait_for_captcha_solve(
53
+ *,
54
+ timeout: int = 10_000,
55
+ wait_for_network_settled: bool = True,
56
+ ) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]: ...
57
+
58
+
59
+ def wait_for_captcha_solve(
60
+ *args: Any,
61
+ **kwargs: Any,
62
+ ) -> Any:
63
+ """
64
+ Wait for CAPTCHA solve after performing an action or by itself.
65
+
66
+ Usage patterns:
67
+ 1. Callable: await wait_for_captcha_solve(page, timeout=10_000)
68
+ 2. Wrapper: await wait_for_captcha_solve(page=page, func=my_func, timeout=10_000)
69
+ 3. Decorator: @wait_for_captcha_solve or @wait_for_captcha_solve()
70
+ 4. Decorator with options: @wait_for_captcha_solve(timeout=10_000, wait_for_network_settled=True)
71
+
72
+ Args:
73
+ page: Playwright Page object
74
+ func: Optional callable to execute before waiting for captcha solve
75
+ timeout: Maximum time to wait in milliseconds (default: 10_000)
76
+ wait_for_network_settled: Whether to wait for network idle before checking captcha (default: True)
77
+ """
78
+
79
+ # Case 1a: Direct call with page only (callable pattern - positional)
80
+ # await wait_for_captcha_solve(page, timeout=10_000, wait_for_network_settled=True)
81
+ if len(args) == 1 and isinstance(args[0], Page):
82
+ page = args[0]
83
+ timeout = kwargs.get("timeout", 10_000)
84
+ wait_for_network_settled = kwargs.get("wait_for_network_settled", True)
85
+ return _wait_for_captcha_solve_core(
86
+ page=page,
87
+ func=None,
88
+ timeout=timeout,
89
+ wait_for_network_settled=wait_for_network_settled,
90
+ )
91
+
92
+ # Case 1b: Direct call with page only (callable pattern - keyword)
93
+ # await wait_for_captcha_solve(page=page, timeout=10_000, wait_for_network_settled=True)
94
+ if "page" in kwargs and "func" not in kwargs and len(args) == 0:
95
+ page = kwargs["page"]
96
+ timeout = kwargs.get("timeout", 10_000)
97
+ wait_for_network_settled = kwargs.get("wait_for_network_settled", True)
98
+
99
+ if not isinstance(page, Page):
100
+ raise ValueError(
101
+ "No Page object found in function arguments. 'page' parameter must be a Playwright Page object."
102
+ )
103
+
104
+ return _wait_for_captcha_solve_core(
105
+ page=page,
106
+ func=None,
107
+ timeout=timeout,
108
+ wait_for_network_settled=wait_for_network_settled,
109
+ )
110
+
111
+ # Case 2: Wrapper pattern with page and func as keyword arguments
112
+ # await wait_for_captcha_solve(page=page, func=func, timeout=10_000)
113
+ if "page" in kwargs and "func" in kwargs:
114
+ page = kwargs["page"]
115
+ func = kwargs["func"]
116
+ timeout = kwargs.get("timeout", 10_000)
117
+ wait_for_network_settled = kwargs.get("wait_for_network_settled", True)
118
+
119
+ if not isinstance(page, Page):
120
+ raise ValueError(
121
+ "No Page object found in function arguments. 'page' parameter must be a Playwright Page object."
122
+ )
123
+
124
+ return _wait_for_captcha_solve_core(
125
+ page=page,
126
+ func=func,
127
+ timeout=timeout,
128
+ wait_for_network_settled=wait_for_network_settled,
129
+ )
130
+
131
+ # Case 3: Decorator without arguments
132
+ # @wait_for_captcha_solve
133
+ if len(args) == 1 and callable(args[0]) and not isinstance(args[0], Page):
134
+ func = args[0]
135
+ return _create_decorated_function(func, timeout=10_000, wait_for_network_settled=True) # type: ignore
136
+
137
+ # Case 4: Decorator factory with arguments (including empty parentheses)
138
+ # @wait_for_captcha_solve() or @wait_for_captcha_solve(timeout=10_000, wait_for_network_settled=True)
139
+ if len(args) == 0 and "page" not in kwargs and "func" not in kwargs:
140
+ timeout = kwargs.get("timeout", 10_000)
141
+ wait_for_network_settled = kwargs.get("wait_for_network_settled", True)
142
+
143
+ def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
144
+ return _create_decorated_function(func, timeout=timeout, wait_for_network_settled=wait_for_network_settled)
145
+
146
+ return decorator
147
+
148
+ raise ValueError(
149
+ "Invalid usage. Valid patterns:\n"
150
+ "1. await wait_for_captcha_solve(page, timeout=10_000) or await wait_for_captcha_solve(page=page, timeout=10_000)\n"
151
+ "2. await wait_for_captcha_solve(page=page, func=func, timeout=10_000)\n"
152
+ "3. @wait_for_captcha_solve or @wait_for_captcha_solve()\n"
153
+ "4. @wait_for_captcha_solve(timeout=10_000, wait_for_network_settled=True)"
154
+ )
155
+
156
+
157
+ def _create_decorated_function(
158
+ func: Callable[..., Awaitable[Any]],
159
+ timeout: int,
160
+ wait_for_network_settled: bool,
161
+ ) -> Callable[..., Awaitable[Any]]:
162
+ """Helper to create a decorated function with captcha solve waiting."""
163
+
164
+ @functools.wraps(func)
165
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
166
+ # Find the page object in function arguments
167
+ page = next((arg for arg in args if isinstance(arg, Page)), None)
168
+ if page is None:
169
+ page = kwargs.get("page")
170
+
171
+ if not page or not isinstance(page, Page):
172
+ logger.error(
173
+ "No Page object found in function arguments. The decorated function must have a 'page' parameter or receive a Page object as an argument."
174
+ )
175
+ raise ValueError(
176
+ "No Page object found in function arguments. The decorated function must have a 'page' parameter or receive a Page object as an argument."
177
+ )
178
+
179
+ async def func_with_args():
180
+ return await func(*args, **kwargs)
181
+
182
+ return await _wait_for_captcha_solve_core(
183
+ page=page,
184
+ func=func_with_args,
185
+ timeout=timeout,
186
+ wait_for_network_settled=wait_for_network_settled,
187
+ )
188
+
189
+ return wrapper
190
+
191
+
192
+ async def _wait_for_captcha_solve_core(
193
+ *,
194
+ page: Page,
195
+ func: Optional[Callable[..., Awaitable[Any]]],
196
+ timeout: int = 10_000,
197
+ wait_for_network_settled: bool = True,
198
+ ):
199
+ """Core implementation of captcha solve waiting logic."""
200
+ if not isinstance(page, Page):
201
+ raise ValueError("No Page object found in function arguments. Page parameter must be a Playwright Page object.")
202
+
203
+ logger.debug(f"Page object: {page}")
204
+
205
+ result = None
206
+ if func is not None:
207
+ result = await func()
208
+
209
+ if wait_for_network_settled:
210
+ try:
211
+ await page.wait_for_load_state("networkidle", timeout=timeout)
212
+ except Exception as e:
213
+ logger.debug(f"Network idle wait failed: {e}")
214
+
215
+ detection_event: EventRequest
216
+ try:
217
+ detection_event = await wait_for_captcha_event("CAPTCHA_DETECTED", timeout=timeout)
218
+ except asyncio.TimeoutError:
219
+ logger.info("CAPTCHA Detection timed out")
220
+ return result
221
+ except Exception as e:
222
+ logger.error(f"Error while waiting for captcha: {e}")
223
+ raise e
224
+
225
+ logger.info("CAPTCHA Detected, awaiting result...")
226
+ if detection_event.session_id is None:
227
+ raise RuntimeError("CAPTCHA_DETECTED event missing session ID")
228
+ try:
229
+ solved_task = asyncio.create_task(
230
+ wait_for_captcha_event("CAPTCHA_SOLVED", session_id=detection_event.session_id, timeout=timeout)
231
+ )
232
+ max_retries_task = asyncio.create_task(
233
+ wait_for_captcha_event("MAX_RETRIES_EXHAUSTED", session_id=detection_event.session_id, timeout=timeout)
234
+ )
235
+ error_task = asyncio.create_task(
236
+ wait_for_captcha_event("ERROR", session_id=detection_event.session_id, timeout=timeout)
237
+ )
238
+ hit_limit_task = asyncio.create_task(
239
+ wait_for_captcha_event("HIT_LIMIT", session_id=detection_event.session_id, timeout=timeout)
240
+ )
241
+
242
+ done, pending = await asyncio.wait(
243
+ [solved_task, max_retries_task, error_task, hit_limit_task], return_when=asyncio.FIRST_COMPLETED
244
+ )
245
+
246
+ # Cancel pending tasks
247
+ for task in pending:
248
+ task.cancel()
249
+
250
+ # Get the completed task
251
+ completed = done.pop()
252
+ exception = completed.exception()
253
+ if exception:
254
+ raise exception
255
+
256
+ # Check which task completed
257
+ if completed == max_retries_task:
258
+ raise RuntimeError("Reached maximum retries on solving captcha")
259
+ elif completed == error_task:
260
+ raise RuntimeError("Captcha error")
261
+ elif completed == hit_limit_task:
262
+ raise RuntimeError("Insufficient resource credits to execute the captcha solve")
263
+
264
+ logger.info("CAPTCHA solved successfully")
265
+
266
+ except asyncio.TimeoutError as e:
267
+ logger.error("CAPTCHA Result timeout")
268
+ raise RuntimeError("CAPTCHA Solving timeout") from e
269
+ except Exception as e:
270
+ logger.error(f"CAPTCHA solve error: {e}")
271
+ raise
272
+
273
+ return result
274
+
275
+
276
+ def on_captcha_event(
277
+ event: CaptchaEvent,
278
+ f: Callable[..., Awaitable[None] | None],
279
+ *args,
280
+ **kwargs,
281
+ ):
282
+ """
283
+ Register a callback for a captcha event.
284
+
285
+ Args:
286
+ event: The captcha event to listen for
287
+ f: The callback function to execute
288
+ *args: Additional arguments to pass to the callback
289
+ **kwargs: Additional keyword arguments to pass to the callback
290
+ """
291
+ emitter = get_intuned_event_emitter()
292
+
293
+ async def wrapper(*_, **__):
294
+ result = f(*args, **kwargs)
295
+ if asyncio.iscoroutine(result):
296
+ await result
297
+
298
+ emitter.on(event, wrapper)
299
+
300
+
301
+ def once_captcha_event(
302
+ event: CaptchaEvent,
303
+ f: Callable[..., Awaitable[None] | None],
304
+ *args,
305
+ **kwargs,
306
+ ):
307
+ """
308
+ Register a one-time callback for a captcha event.
309
+
310
+ Args:
311
+ event: The captcha event to listen for
312
+ f: The callback function to execute
313
+ *args: Additional arguments to pass to the callback
314
+ **kwargs: Additional keyword arguments to pass to the callback
315
+ """
316
+ event_emitter = get_intuned_event_emitter()
317
+
318
+ async def wrapper(*_, **__):
319
+ result = f(*args, **kwargs)
320
+ if asyncio.iscoroutine(result):
321
+ await result
322
+
323
+ event_emitter.once(event, wrapper)
324
+
325
+
326
+ async def wait_for_captcha_event(event: CaptchaEvent, session_id: str = "", timeout: int = 10_000) -> EventRequest:
327
+ """
328
+ Wait for a captcha event to be emitted.
329
+
330
+ Args:
331
+ event: Event name to wait for
332
+ session_id: ID for the captcha solve session
333
+ timeout: Optional timeout in milliseconds (default: 10_000)
334
+
335
+ Returns:
336
+ The event payload
337
+
338
+ Raises:
339
+ RuntimeError: If the event emitter is not initialized
340
+ asyncio.TimeoutError: If the timeout is reached before the event fires
341
+ """
342
+
343
+ # Check if event was already fired before attaching the listener
344
+ event_from_queue = get_event_from_event_queue(
345
+ event=event, tab_id="page-0", session_id=session_id
346
+ ) # For now we stick with page-0, will revisit on multi page support
347
+ if event_from_queue is not None:
348
+ return event_from_queue
349
+
350
+ emitter = get_intuned_event_emitter()
351
+ if emitter is None:
352
+ raise RuntimeError("Intuned Extensions listener is not initialized")
353
+
354
+ loop = asyncio.get_event_loop()
355
+ future: asyncio.Future = asyncio.Future()
356
+
357
+ def handler(*_, **__):
358
+ if not future.done():
359
+ consumed_event = get_event_from_event_queue(session_id=session_id, event=event)
360
+ loop.call_soon_threadsafe(future.set_result, consumed_event)
361
+
362
+ emitter.once(event, handler)
363
+
364
+ try:
365
+ if timeout:
366
+ return await asyncio.wait_for(future, timeout=timeout / 1000)
367
+ else:
368
+ return await future
369
+ except (asyncio.TimeoutError, asyncio.CancelledError):
370
+ emitter.remove_listener(event, handler)
371
+ raise
@@ -1,3 +1,5 @@
1
+ import os
2
+ import socket
1
3
  from typing import List
2
4
  from typing import Literal
3
5
 
@@ -5,6 +7,14 @@ from pydantic import BaseModel
5
7
  from pydantic import Field
6
8
 
7
9
 
10
+ def get_intuned_captcha_extension_port():
11
+ if "INTUNED_CAPTCHA_EXTENSION_PORT" in os.environ:
12
+ return int(os.environ["INTUNED_CAPTCHA_EXTENSION_PORT"])
13
+ with socket.socket() as s:
14
+ s.bind(("", 0))
15
+ return s.getsockname()[1]
16
+
17
+
8
18
  class CaptchaSettings(BaseModel):
9
19
  enabled: bool = Field(default=False)
10
20
 
@@ -47,6 +57,7 @@ class CaptchaSolverSettings(BaseModel):
47
57
  }
48
58
 
49
59
  enabled: bool = Field(default=False)
60
+ port: int = Field(default_factory=get_intuned_captcha_extension_port)
50
61
  cloudflare: CaptchaSettings = Field(default_factory=CaptchaSettings)
51
62
  google_recaptcha_v2: CaptchaSettings = Field(alias="googleRecaptchaV2", default_factory=CaptchaSettings)
52
63
  google_recaptcha_v3: CaptchaSettings = Field(alias="googleRecaptchaV3", default_factory=CaptchaSettings)
@@ -1,3 +0,0 @@
1
- from .helpers import build_extensions_list
2
-
3
- __all__ = ["build_extensions_list"]
File without changes