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.
- adapters/__init__.py +14 -0
- adapters/base.py +34 -0
- adapters/browser_adapter.py +300 -0
- adapters/dom_scripts.py +518 -0
- adapters/envelope_builder.py +108 -0
- adapters/live_session_manager.py +326 -0
- adapters/upload_adapter.py +77 -0
- builder.py +83 -0
- cli.py +341 -0
- config/__init__.py +23 -0
- config/settings.py +339 -0
- constants/__init__.py +165 -0
- constants/contracts.py +95 -0
- constants/selectors.py +27 -0
- constants/timeouts.py +25 -0
- core/__init__.py +45 -0
- core/exceptions.py +33 -0
- core/models.py +273 -0
- engine.py +199 -0
- exceptions.py +23 -0
- mcp_server.py +347 -0
- models.py +29 -0
- orchestration/__init__.py +19 -0
- orchestration/context.py +71 -0
- orchestration/flow_orchestrator.py +358 -0
- orchestration/session_coordinator.py +88 -0
- services/__init__.py +17 -0
- services/auth_navigation_service.py +129 -0
- services/field_interaction_service.py +174 -0
- services/form_filler_service.py +106 -0
- services/inspection_formatter_service.py +68 -0
- services/inspection_service.py +40 -0
- services/reactive_interaction_service.py +174 -0
- supervisor/__init__.py +13 -0
- supervisor/client.py +171 -0
- supervisor/contracts.py +62 -0
- supervisor/master_daemon.py +303 -0
- supervisor/shell.py +194 -0
- supervisor/worker_process.py +400 -0
- validators/__init__.py +9 -0
- validators/form_validator.py +28 -0
- validators/payload_validator.py +25 -0
- webpilot_engine-2.0.0.dist-info/METADATA +299 -0
- webpilot_engine-2.0.0.dist-info/RECORD +47 -0
- webpilot_engine-2.0.0.dist-info/WHEEL +5 -0
- webpilot_engine-2.0.0.dist-info/entry_points.txt +6 -0
- 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)
|