webpilot-engine 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. adapters/__init__.py +14 -0
  2. adapters/base.py +34 -0
  3. adapters/browser_adapter.py +300 -0
  4. adapters/dom_scripts.py +518 -0
  5. adapters/envelope_builder.py +108 -0
  6. adapters/live_session_manager.py +326 -0
  7. adapters/upload_adapter.py +77 -0
  8. builder.py +83 -0
  9. cli.py +341 -0
  10. config/__init__.py +23 -0
  11. config/settings.py +339 -0
  12. constants/__init__.py +165 -0
  13. constants/contracts.py +95 -0
  14. constants/selectors.py +27 -0
  15. constants/timeouts.py +25 -0
  16. core/__init__.py +45 -0
  17. core/exceptions.py +33 -0
  18. core/models.py +273 -0
  19. engine.py +199 -0
  20. exceptions.py +23 -0
  21. mcp_server.py +347 -0
  22. models.py +29 -0
  23. orchestration/__init__.py +19 -0
  24. orchestration/context.py +71 -0
  25. orchestration/flow_orchestrator.py +358 -0
  26. orchestration/session_coordinator.py +88 -0
  27. services/__init__.py +17 -0
  28. services/auth_navigation_service.py +129 -0
  29. services/field_interaction_service.py +174 -0
  30. services/form_filler_service.py +106 -0
  31. services/inspection_formatter_service.py +68 -0
  32. services/inspection_service.py +40 -0
  33. services/reactive_interaction_service.py +174 -0
  34. supervisor/__init__.py +13 -0
  35. supervisor/client.py +171 -0
  36. supervisor/contracts.py +62 -0
  37. supervisor/master_daemon.py +303 -0
  38. supervisor/shell.py +194 -0
  39. supervisor/worker_process.py +400 -0
  40. validators/__init__.py +9 -0
  41. validators/form_validator.py +28 -0
  42. validators/payload_validator.py +25 -0
  43. webpilot_engine-2.0.0.dist-info/METADATA +299 -0
  44. webpilot_engine-2.0.0.dist-info/RECORD +47 -0
  45. webpilot_engine-2.0.0.dist-info/WHEEL +5 -0
  46. webpilot_engine-2.0.0.dist-info/entry_points.txt +6 -0
  47. webpilot_engine-2.0.0.dist-info/top_level.txt +14 -0
adapters/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Adapters package for universal web applier."""
2
+
3
+ from adapters.base import BrowserAdapterProtocol, UploadAdapterProtocol
4
+ from adapters.browser_adapter import BrowserAdapter
5
+ from adapters.upload_adapter import UploadAdapter
6
+ import adapters.dom_scripts as dom_scripts
7
+
8
+ __all__ = [
9
+ "BrowserAdapterProtocol",
10
+ "UploadAdapterProtocol",
11
+ "BrowserAdapter",
12
+ "UploadAdapter",
13
+ "dom_scripts",
14
+ ]
adapters/base.py ADDED
@@ -0,0 +1,34 @@
1
+ """Adapter protocols and shared interface contracts."""
2
+
3
+ from typing import Any, Dict, List, Optional, Protocol
4
+ from playwright.sync_api import BrowserContext, Page
5
+
6
+ class BrowserAdapterProtocol(Protocol):
7
+ """Protocol for browser runtime management."""
8
+
9
+ @property
10
+ def page(self) -> Optional[Page]:
11
+ ...
12
+
13
+ @property
14
+ def context(self) -> Optional[BrowserContext]:
15
+ ...
16
+
17
+ def start(self, cookies_path: Optional[str] = None) -> None:
18
+ ...
19
+
20
+ def stop(self) -> None:
21
+ ...
22
+
23
+ def navigate(self, url: str, wait_timeout: Optional[int] = None) -> None:
24
+ ...
25
+
26
+ def capture_screenshot(self, output_path: str, full_page: bool = False) -> None:
27
+ ...
28
+
29
+
30
+ class UploadAdapterProtocol(Protocol):
31
+ """Protocol for document attachment operations."""
32
+
33
+ def upload_document(self, file_path: str, document_type_keyword: str = "") -> bool:
34
+ ...
@@ -0,0 +1,300 @@
1
+ """Playwright browser lifecycle and persistent live session adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from typing import Any, Dict, List, Optional
8
+ from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
9
+
10
+ from adapters.base import BrowserAdapterProtocol
11
+ from adapters.live_session_manager import (
12
+ LiveSessionManager,
13
+ DEFAULT_CDP_PORT,
14
+ DEFAULT_INACTIVITY_TIMEOUT_SECONDS,
15
+ )
16
+ from adapters.envelope_builder import BrowserContextEnvelopeBuilder
17
+ from common.cookies import load_and_sanitize_cookies
18
+ from common.logging import log_event
19
+ from config.settings import Settings, get_settings, load_device_profile
20
+ from constants.timeouts import PAUSE_DOM_CONTENT_LOADED_MS
21
+ from core.models import DeviceProfile
22
+
23
+
24
+ class BrowserAdapter(BrowserAdapterProtocol):
25
+ """Encapsulates Playwright Chromium browser and persistent CDP context lifecycle."""
26
+
27
+ def __init__(
28
+ self,
29
+ headless: Optional[bool] = None,
30
+ settings: Optional[Settings] = None,
31
+ device_profile: Optional[DeviceProfile] = None,
32
+ keep_alive: bool = False,
33
+ close_session: bool = False,
34
+ timeout_minutes: Optional[int] = None,
35
+ tab_index: Optional[int] = None,
36
+ ):
37
+ self.settings = settings or get_settings()
38
+ self.device_profile = device_profile or load_device_profile()
39
+ self.headless = headless if headless is not None else self.settings.headless
40
+ self.keep_alive = keep_alive
41
+ self.close_session = close_session
42
+ self.timeout_minutes = timeout_minutes if timeout_minutes is not None else 30
43
+ self.tab_index = tab_index
44
+
45
+ self._pw: Optional[Playwright] = None
46
+ self.browser: Optional[Browser] = None
47
+ self._context: Optional[BrowserContext] = None
48
+ self._page: Optional[Page] = None
49
+ self.browser_pid: Optional[int] = None
50
+ self.cdp_port: Optional[int] = None
51
+ self.is_persistent: bool = False
52
+
53
+ @property
54
+ def context(self) -> Optional[BrowserContext]:
55
+ """Active BrowserContext."""
56
+ return self._context
57
+
58
+ @property
59
+ def page(self) -> Optional[Page]:
60
+ """Active Page instance."""
61
+ return self._page
62
+
63
+ @page.setter
64
+ def page(self, p: Page) -> None:
65
+ self._page = p
66
+
67
+ def print_browser_telemetry(self) -> None:
68
+ """Prints a structured telemetry card for the browser process."""
69
+ pid_str = str(self.browser_pid) if self.browser_pid else "In-Process"
70
+ port_str = f"127.0.0.1:{self.cdp_port}" if self.cdp_port else "Local Native"
71
+ mode_str = "PERSISTENT (Live Session)" if self.is_persistent else "ISOLATED (Single-Run)"
72
+ timeout_str = f"{self.timeout_minutes} mins" if self.timeout_minutes > 0 else "Infinite"
73
+
74
+ print("\n" + "═" * 70)
75
+ print(" 🌐 BROWSER PROCESS & ENGINE TELEMETRY")
76
+ print("═" * 70)
77
+ print(f" • Process ID (PID) : {pid_str}")
78
+ print(f" • Engine / Runtime : Chromium (Headless: {self.headless})")
79
+ print(f" • Viewport / Device : {self.device_profile.viewport_width}x{self.device_profile.viewport_height} ({self.device_profile.device_name})")
80
+ print(f" • CDP Endpoint : {port_str}")
81
+ print(f" • Session Lifecycle : {mode_str} | Inactivity Timeout: {timeout_str}")
82
+ print("═" * 70)
83
+
84
+ def print_page_telemetry(self) -> None:
85
+ """Prints a structured telemetry card for the current active page."""
86
+ if not self._page or not self._context:
87
+ return
88
+
89
+ active_idx = self.get_active_tab_index()
90
+ total_tabs = len(self._context.pages)
91
+ title = self._page.title() or "Untitled"
92
+ url = self._page.url or "about:blank"
93
+
94
+ print(" 📄 ACTIVE PAGE & TAB TELEMETRY")
95
+ print("─" * 70)
96
+ print(f" • Active Tab Index : #{active_idx} (Total Open Tabs: {total_tabs})")
97
+ print(f" • Page Title : {title}")
98
+ print(f" • Active URL : {url}")
99
+ print("═" * 70 + "\n")
100
+
101
+ def get_active_tab_index(self) -> int:
102
+ """Returns the index of the currently active page in context.pages."""
103
+ if not self._context or not self._page:
104
+ return 0
105
+ try:
106
+ return self._context.pages.index(self._page)
107
+ except ValueError:
108
+ return 0
109
+
110
+ def list_open_tabs(self) -> List[Dict[str, Any]]:
111
+ """Returns a list of dictionaries describing all open tabs in the active browser."""
112
+ if not self._context:
113
+ return []
114
+
115
+ tabs_info = []
116
+ for i, p in enumerate(self._context.pages):
117
+ is_active = (p == self._page)
118
+ tabs_info.append({
119
+ "index": i,
120
+ "title": p.title() or "Untitled",
121
+ "url": p.url,
122
+ "is_active": is_active,
123
+ })
124
+ return tabs_info
125
+
126
+ def switch_to_tab(self, index: int) -> Page:
127
+ """Switches active page to the specified tab index."""
128
+ assert self._context is not None, "Browser context not initialized."
129
+ pages = self._context.pages
130
+ if 0 <= index < len(pages):
131
+ self._page = pages[index]
132
+ self._page.bring_to_front()
133
+ print(f"[*] Switched to Tab #{index}: '{self._page.title()}' ({self._page.url})")
134
+ return self._page
135
+ else:
136
+ raise IndexError(f"Tab index #{index} out of range (Total open tabs: {len(pages)})")
137
+
138
+ def start(self, cookies_path: Optional[str] = None) -> None:
139
+ """Starts browser context or connects to existing persistent live session."""
140
+ if self.close_session:
141
+ terminated = LiveSessionManager.terminate_session()
142
+ if terminated:
143
+ print("[✓] Successfully terminated live browser session and killed process.")
144
+ else:
145
+ print("[*] No active live browser session was found to terminate.")
146
+ return
147
+
148
+ self._pw = sync_playwright().start()
149
+
150
+ existing_session = LiveSessionManager.load_session_state()
151
+ should_use_persistent = self.keep_alive or (existing_session is not None)
152
+ log_event("info", "Session check", {
153
+ "existing": existing_session is not None,
154
+ "port_active": LiveSessionManager.is_cdp_port_active(DEFAULT_CDP_PORT),
155
+ })
156
+
157
+ if should_use_persistent:
158
+ self.is_persistent = True
159
+ timeout_sec = self.timeout_minutes * 60
160
+
161
+ if existing_session and LiveSessionManager.is_cdp_port_active(existing_session.get("port", DEFAULT_CDP_PORT)):
162
+ # Connect to existing running browser
163
+ self.cdp_port = existing_session.get("port", DEFAULT_CDP_PORT)
164
+ self.browser_pid = existing_session.get("pid")
165
+ LiveSessionManager.touch_session()
166
+ else:
167
+ # Launch new persistent browser instance
168
+ exec_path = self._pw.chromium.executable_path
169
+ pid, port = LiveSessionManager.launch_persistent_chrome(
170
+ executable_path=exec_path,
171
+ port=DEFAULT_CDP_PORT,
172
+ headless=self.headless,
173
+ viewport_width=self.device_profile.viewport_width,
174
+ viewport_height=self.device_profile.viewport_height,
175
+ user_agent=self.device_profile.user_agent,
176
+ timeout_seconds=timeout_sec,
177
+ )
178
+ self.browser_pid = pid
179
+ self.cdp_port = port
180
+
181
+ # Connect via Playwright CDP
182
+ self.browser = self._pw.chromium.connect_over_cdp(f"http://127.0.0.1:{self.cdp_port}")
183
+ if len(self.browser.contexts) > 0:
184
+ self._context = self.browser.contexts[0]
185
+ else:
186
+ builder = BrowserContextEnvelopeBuilder(
187
+ profile=self.device_profile,
188
+ settings=self.settings,
189
+ )
190
+ options = builder.build_context_envelope()
191
+ self._context = self.browser.new_context(**options)
192
+
193
+ # Determine active page
194
+ if len(self._context.pages) > 0:
195
+ if self.tab_index is not None and 0 <= self.tab_index < len(self._context.pages):
196
+ self._page = self._context.pages[self.tab_index]
197
+ else:
198
+ non_blank = [
199
+ p for p in self._context.pages
200
+ if p.url and not p.url.startswith("chrome://") and p.url not in ("about:blank",)
201
+ ]
202
+ if non_blank:
203
+ self._page = non_blank[-1]
204
+ else:
205
+ self._page = self._context.pages[-1]
206
+ else:
207
+ self._page = self._context.new_page()
208
+
209
+ else:
210
+ # Isolated single-run execution
211
+ self.is_persistent = False
212
+ self.browser = self._pw.chromium.launch(
213
+ headless=self.headless,
214
+ args=["--disable-blink-features=AutomationControlled"],
215
+ )
216
+ try:
217
+ import subprocess
218
+ ps_c = f'(Get-CimInstance Win32_Process | Where-Object {{ $_.ParentProcessId -eq {os.getpid()} }}).ProcessId | Select-Object -First 1'
219
+ out = subprocess.check_output(["powershell", "-NoProfile", "-Command", ps_c], text=True).strip()
220
+ if out.isdigit():
221
+ self.browser_pid = int(out)
222
+ except Exception:
223
+ pass
224
+
225
+ builder = BrowserContextEnvelopeBuilder(
226
+ profile=self.device_profile,
227
+ settings=self.settings,
228
+ )
229
+ options = builder.build_context_envelope()
230
+ self._context = self.browser.new_context(**options)
231
+ self._page = self._context.new_page()
232
+
233
+ # Load cookies if requested
234
+ effective_cookies = cookies_path or self.settings.cookies_path
235
+ if effective_cookies and os.path.isfile(effective_cookies) and self._context:
236
+ try:
237
+ sanitized = load_and_sanitize_cookies(effective_cookies)
238
+ self._context.add_cookies(sanitized)
239
+ print(f"[*] Successfully loaded and sanitized {len(sanitized)} cookies.")
240
+ except Exception as exc:
241
+ print(f"[!] Warning loading cookies: {exc}")
242
+
243
+ # Print initial telemetry
244
+ self.print_browser_telemetry()
245
+ self.print_page_telemetry()
246
+
247
+ def stop(self) -> None:
248
+ """Stops browser or cleanly detaches client while preserving live session."""
249
+ if self.is_persistent and not self.close_session:
250
+ # Cleanly detach Playwright client while preserving live session
251
+ LiveSessionManager.touch_session()
252
+ self.browser = None
253
+ if self._pw:
254
+ try:
255
+ self._pw.stop()
256
+ except Exception:
257
+ pass
258
+ self._pw = None
259
+ self._context = None
260
+ self._page = None
261
+ print(f"[INFO] Browser kept alive in background (PID: {self.browser_pid}, Port: {self.cdp_port}).")
262
+ print(f"[INFO] You can run subsequent commands on this session using --tab or --keep-alive.")
263
+ else:
264
+ # Full termination
265
+ if self.browser:
266
+ try:
267
+ self.browser.close()
268
+ except Exception:
269
+ pass
270
+ self.browser = None
271
+ if self._pw:
272
+ try:
273
+ self._pw.stop()
274
+ except Exception:
275
+ pass
276
+ self._pw = None
277
+ self._context = None
278
+ self._page = None
279
+ LiveSessionManager.terminate_session()
280
+
281
+ def navigate(self, url: str, wait_timeout: Optional[int] = None) -> None:
282
+ """Navigates to URL and waits for DOM content loaded."""
283
+ assert self._page is not None, "Browser not started. Call start() first."
284
+ if self._page.url == url:
285
+ print(f"[*] Page is already on target URL: {url}")
286
+ self.print_page_telemetry()
287
+ return
288
+ timeout = wait_timeout if wait_timeout is not None else self.settings.default_timeout_ms
289
+ try:
290
+ self._page.goto(url, timeout=timeout, wait_until="domcontentloaded")
291
+ except Exception as exc:
292
+ print(f"[!] Notice: Navigation event delayed by tracker/network: {exc}. Continuing with page state...")
293
+ self._page.wait_for_timeout(PAUSE_DOM_CONTENT_LOADED_MS)
294
+ self.print_page_telemetry()
295
+
296
+ def capture_screenshot(self, output_path: str, full_page: bool = False) -> None:
297
+ """Captures a screenshot to the specified disk path."""
298
+ assert self._page is not None, "Browser not started."
299
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
300
+ self._page.screenshot(path=output_path, full_page=full_page)