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
@@ -0,0 +1,326 @@
1
+ """Live Browser Session and Inactivity Watchdog Manager.
2
+
3
+ Manages persistent Chromium browser processes connected over CDP, allowing
4
+ sequential multi-step CLI commands to execute against the same live browser
5
+ and open tabs without closing between commands. Enforces an inactivity watchdog
6
+ timer that terminates the process after a specified duration of idle time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from typing import Any, Dict, Optional, Tuple
20
+
21
+ PROJECT_ROOT: str = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
22
+ SESSION_FILE: str = os.path.join(PROJECT_ROOT, ".live_session.json")
23
+ DEFAULT_CDP_PORT: int = 9222
24
+ DEFAULT_INACTIVITY_TIMEOUT_SECONDS: int = 1800 # 30 minutes
25
+
26
+
27
+ class LiveSessionManager:
28
+ """Manages the persistent browser daemon process and session state file."""
29
+
30
+ @staticmethod
31
+ def get_session_file_path() -> str:
32
+ """Returns the absolute path to the local session state file."""
33
+ return SESSION_FILE
34
+
35
+ @staticmethod
36
+ def is_cdp_port_active(port: int = DEFAULT_CDP_PORT) -> bool:
37
+ """Pings the CDP version endpoint to determine if a live browser is running."""
38
+ url = f"http://127.0.0.1:{port}/json/version"
39
+ try:
40
+ req = urllib.request.Request(url, headers={"User-Agent": "SessionManager"})
41
+ with urllib.request.urlopen(req, timeout=1.5) as resp:
42
+ return resp.status == 200
43
+ except Exception:
44
+ return False
45
+
46
+ @classmethod
47
+ def load_session_state(cls) -> Optional[Dict[str, Any]]:
48
+ """Reads the active session state file if present and valid."""
49
+ path = cls.get_session_file_path()
50
+ if not os.path.isfile(path):
51
+ return None
52
+
53
+ try:
54
+ with open(path, "r", encoding="utf-8") as f:
55
+ data = json.load(f)
56
+ port = data.get("port", DEFAULT_CDP_PORT)
57
+ pid = data.get("pid")
58
+
59
+ # Check if port is actually responsive
60
+ if cls.is_cdp_port_active(port):
61
+ return data
62
+ else:
63
+ # Stale session file; remove it
64
+ cls.clear_session_file()
65
+ return None
66
+ except Exception:
67
+ cls.clear_session_file()
68
+ return None
69
+
70
+ @classmethod
71
+ def touch_session(cls) -> None:
72
+ """Updates the last_active timestamp for the inactivity watchdog."""
73
+ state = cls.load_session_state()
74
+ if state:
75
+ state["last_active"] = time.time()
76
+ path = cls.get_session_file_path()
77
+ try:
78
+ with open(path, "w", encoding="utf-8") as f:
79
+ json.dump(state, f, indent=2)
80
+ except Exception:
81
+ pass
82
+
83
+ @classmethod
84
+ def save_session_state(
85
+ cls,
86
+ pid: int,
87
+ port: int,
88
+ timeout_seconds: int = DEFAULT_INACTIVITY_TIMEOUT_SECONDS,
89
+ headless: bool = True,
90
+ viewport: Optional[Dict[str, int]] = None,
91
+ ) -> None:
92
+ """Writes current session metadata to disk."""
93
+ path = cls.get_session_file_path()
94
+ data = {
95
+ "pid": pid,
96
+ "port": port,
97
+ "headless": headless,
98
+ "viewport": viewport or {"width": 1920, "height": 1080},
99
+ "created_at": time.time(),
100
+ "last_active": time.time(),
101
+ "timeout_seconds": timeout_seconds,
102
+ }
103
+ with open(path, "w", encoding="utf-8") as f:
104
+ json.dump(data, f, indent=2)
105
+
106
+ @classmethod
107
+ def clear_session_file(cls) -> None:
108
+ """Removes the session state file from disk."""
109
+ path = cls.get_session_file_path()
110
+ if os.path.isfile(path):
111
+ try:
112
+ os.remove(path)
113
+ except Exception:
114
+ pass
115
+
116
+ @classmethod
117
+ def terminate_session(cls) -> bool:
118
+ """Forcefully terminates the persistent browser process and clears state."""
119
+ state = cls.load_session_state()
120
+ cls.clear_session_file()
121
+ if not state:
122
+ return False
123
+
124
+ pid = state.get("pid")
125
+ if pid:
126
+ try:
127
+ # Terminate on Windows
128
+ if sys.platform == "win32":
129
+ subprocess.run(
130
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
131
+ stdout=subprocess.DEVNULL,
132
+ stderr=subprocess.DEVNULL,
133
+ )
134
+ else:
135
+ os.kill(pid, 9)
136
+ return True
137
+ except Exception:
138
+ pass
139
+ return False
140
+
141
+ @classmethod
142
+ def launch_persistent_chrome(
143
+ cls,
144
+ executable_path: str,
145
+ port: int = DEFAULT_CDP_PORT,
146
+ headless: bool = True,
147
+ viewport_width: int = 1920,
148
+ viewport_height: int = 1080,
149
+ user_agent: Optional[str] = None,
150
+ timeout_seconds: int = DEFAULT_INACTIVITY_TIMEOUT_SECONDS,
151
+ ) -> Tuple[int, int]:
152
+ """Launches a standalone detached Chromium instance with remote debugging port enabled.
153
+
154
+ Returns (pid, port).
155
+ """
156
+ # If already running and active, return existing
157
+ existing = cls.load_session_state()
158
+ if existing and cls.is_cdp_port_active(existing.get("port", port)):
159
+ cls.touch_session()
160
+ return existing["pid"], existing["port"]
161
+
162
+ user_data_dir = os.path.join(PROJECT_ROOT, ".live_browser_profile")
163
+ os.makedirs(user_data_dir, exist_ok=True)
164
+
165
+ args = [
166
+ executable_path,
167
+ f"--remote-debugging-port={port}",
168
+ f"--user-data-dir={user_data_dir}",
169
+ f"--window-size={viewport_width},{viewport_height}",
170
+ "--no-first-run",
171
+ "--no-default-browser-check",
172
+ "--disable-blink-features=AutomationControlled",
173
+ "--disable-gpu",
174
+ "--disable-dev-shm-usage",
175
+ "--no-sandbox",
176
+ "--keep-alive-for-test",
177
+ ]
178
+ if headless:
179
+ args.append("--headless=new")
180
+ if user_agent:
181
+ args.append(f"--user-agent={user_agent}")
182
+ args.append("about:blank")
183
+
184
+ proc_pid: Optional[int] = None
185
+ cmd_str = f'"{executable_path}" ' + " ".join(f'"{a}"' if " " in a else a for a in args[1:])
186
+
187
+ if sys.platform == "win32":
188
+ proc_pid = cls._launch_wmi_process(cmd_str)
189
+
190
+ if proc_pid is None:
191
+ flags = 0
192
+ if sys.platform == "win32":
193
+ flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
194
+ proc = subprocess.Popen(
195
+ args,
196
+ stdout=subprocess.DEVNULL,
197
+ stderr=subprocess.DEVNULL,
198
+ creationflags=flags,
199
+ )
200
+ proc_pid = proc.pid
201
+
202
+ # Wait up to 5 seconds for CDP port to become active
203
+ start_time = time.time()
204
+ active = False
205
+ while time.time() - start_time < 5.0:
206
+ if cls.is_cdp_port_active(port):
207
+ active = True
208
+ break
209
+ time.sleep(0.2)
210
+
211
+ if not active:
212
+ if sys.platform == "win32" and proc_pid:
213
+ try:
214
+ subprocess.run(
215
+ ["taskkill", "/F", "/T", "/PID", str(proc_pid)],
216
+ stdout=subprocess.DEVNULL,
217
+ stderr=subprocess.DEVNULL,
218
+ )
219
+ except Exception:
220
+ pass
221
+ raise RuntimeError(f"Chromium failed to open CDP port {port} within 5 seconds.")
222
+
223
+ cls.save_session_state(
224
+ pid=proc_pid,
225
+ port=port,
226
+ timeout_seconds=timeout_seconds,
227
+ headless=headless,
228
+ viewport={"width": viewport_width, "height": viewport_height},
229
+ )
230
+
231
+ # Launch watchdog supervisor if timeout > 0
232
+ if timeout_seconds > 0:
233
+ cls._spawn_watchdog_supervisor(proc_pid, port, timeout_seconds)
234
+
235
+ return proc_pid, port
236
+
237
+ @classmethod
238
+ def run_watchdog_loop(cls, browser_pid: int, port: int, timeout_seconds: int) -> None:
239
+ """Runs the background watchdog loop until inactivity timeout or session clear."""
240
+ session_file = cls.get_session_file_path()
241
+ while True:
242
+ time.sleep(30)
243
+ if not os.path.isfile(session_file):
244
+ break
245
+ try:
246
+ with open(session_file, "r", encoding="utf-8") as f:
247
+ data = json.load(f)
248
+ last_active = data.get("last_active", time.time())
249
+ if time.time() - last_active > timeout_seconds:
250
+ if sys.platform == "win32":
251
+ subprocess.run(
252
+ ["taskkill", "/F", "/T", "/PID", str(browser_pid)],
253
+ stdout=subprocess.DEVNULL,
254
+ stderr=subprocess.DEVNULL,
255
+ )
256
+ else:
257
+ os.kill(browser_pid, 9)
258
+ cls.clear_session_file()
259
+ break
260
+ except Exception:
261
+ break
262
+
263
+ @classmethod
264
+ def _launch_wmi_process(cls, cmd_str: str, cwd: Optional[str] = None) -> Optional[int]:
265
+ """Launches a detached process via Windows WMI outside any parent Job Object."""
266
+ working_dir = cwd or PROJECT_ROOT
267
+ ps_script = f"""
268
+ $startup = ([wmiclass]"Win32_ProcessStartup").CreateInstance()
269
+ $startup.ShowWindow = 0
270
+ $proc = ([wmiclass]"Win32_Process").Create(@'
271
+ {cmd_str}
272
+ '@, $null, $startup)
273
+ if ($proc.ReturnValue -eq 0) {{
274
+ Write-Output $proc.ProcessId
275
+ }} else {{
276
+ exit 1
277
+ }}
278
+ """
279
+ try:
280
+ encoded = base64.b64encode(ps_script.encode("utf-16le")).decode("ascii")
281
+ res = subprocess.run(
282
+ ["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
283
+ capture_output=True,
284
+ text=True,
285
+ timeout=10,
286
+ )
287
+ out = res.stdout.strip()
288
+ if res.returncode == 0 and out.isdigit():
289
+ return int(out)
290
+ else:
291
+ print(f"[!] WMI launch non-zero/non-digit: code={res.returncode}, out='{out}', err='{res.stderr.strip()}'")
292
+ except Exception as exc:
293
+ print(f"[!] WMI launch exception: {exc}")
294
+ return None
295
+
296
+ @classmethod
297
+ def _spawn_watchdog_supervisor(cls, browser_pid: int, port: int, timeout_seconds: int) -> None:
298
+ """Launches a lightweight detached background watchdog to terminate idle browser."""
299
+ watchdog_cmd = f'"{sys.executable}" -m adapters.live_session_manager {browser_pid} {port} {timeout_seconds}'
300
+
301
+ if sys.platform == "win32":
302
+ wmi_pid = cls._launch_wmi_process(watchdog_cmd)
303
+ if wmi_pid is not None:
304
+ return
305
+
306
+ flags = 0
307
+ if sys.platform == "win32":
308
+ flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
309
+
310
+ try:
311
+ subprocess.Popen(
312
+ [sys.executable, "-m", "adapters.live_session_manager", str(browser_pid), str(port), str(timeout_seconds)],
313
+ stdout=subprocess.DEVNULL,
314
+ stderr=subprocess.DEVNULL,
315
+ creationflags=flags,
316
+ )
317
+ except Exception:
318
+ pass
319
+
320
+
321
+ if __name__ == "__main__":
322
+ if len(sys.argv) >= 4:
323
+ b_pid = int(sys.argv[1])
324
+ b_port = int(sys.argv[2])
325
+ b_timeout = int(sys.argv[3])
326
+ LiveSessionManager.run_watchdog_loop(b_pid, b_port, b_timeout)
@@ -0,0 +1,77 @@
1
+ """Document and resume attachment adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from playwright.sync_api import Page
7
+ from adapters.base import UploadAdapterProtocol
8
+ from constants.timeouts import PAUSE_UPLOAD_DIRECT_MS, PAUSE_UPLOAD_CHOOSER_MS
9
+ from core.exceptions import DocumentUploadError
10
+
11
+
12
+ class UploadAdapter(UploadAdapterProtocol):
13
+ """Handles multi-heuristic document attachments across ATS platforms."""
14
+
15
+ def __init__(self, page: Page):
16
+ self.page = page
17
+
18
+ def upload_document(self, file_path: str, document_type_keyword: str = "") -> bool:
19
+ """Attaches a local document to the form using direct input or file chooser dialogs."""
20
+ if not os.path.isfile(file_path):
21
+ raise FileNotFoundError(f"Target document not found: {file_path}")
22
+
23
+ print(f"Uploading document '{file_path}' (Keyword: '{document_type_keyword}')...")
24
+
25
+ # 1. Native input[type="file"]
26
+ file_inputs = self.page.locator("input[type='file']").all()
27
+ if file_inputs:
28
+ target_fi = file_inputs[-1]
29
+ if document_type_keyword:
30
+ for fi in file_inputs:
31
+ lbl = self.page.evaluate(
32
+ "(el) => el.closest('div, tr')?.innerText || ''",
33
+ fi.element_handle()
34
+ )
35
+ if document_type_keyword.lower() in lbl.lower():
36
+ target_fi = fi
37
+ break
38
+ target_fi.set_input_files(file_path)
39
+ self.page.wait_for_timeout(PAUSE_UPLOAD_DIRECT_MS)
40
+ print("File set directly on file input.")
41
+ return True
42
+
43
+ # 2. Custom ARIA / ATS Trigger Button
44
+ custom_trigger = self.page.evaluate("""(kw) => {
45
+ const triggers = Array.from(document.querySelectorAll(
46
+ '[id*="attach"], [id*="upload"], [id*="Add"], [title*="Add"], button, a'
47
+ )).filter(el => {
48
+ const t = (el.innerText || el.title || el.id || '').toLowerCase();
49
+ if (kw && !t.includes(kw.toLowerCase())) return false;
50
+ return t.includes('add a document') || t.includes('attach') || t.includes('upload');
51
+ });
52
+ return triggers[0] ? triggers[0].id : null;
53
+ }""", document_type_keyword)
54
+
55
+ if custom_trigger:
56
+ print(f"Triggering upload via custom button ID: {custom_trigger}...")
57
+ with self.page.expect_file_chooser(timeout=10000) as fc_info:
58
+ self.page.locator(f'[id="{custom_trigger}"]').click(force=True)
59
+ file_chooser = fc_info.value
60
+ file_chooser.set_files(file_path)
61
+ self.page.wait_for_timeout(PAUSE_UPLOAD_CHOOSER_MS)
62
+ return True
63
+
64
+ # 3. SuccessFactors JUIC framework fallback
65
+ try:
66
+ with self.page.expect_file_chooser(timeout=5000) as fc_info:
67
+ self.page.evaluate(
68
+ "() => { if (window.juic && window.juic.fire) juic.fire('48:', 'action', { type: 'click' }); }"
69
+ )
70
+ file_chooser = fc_info.value
71
+ file_chooser.set_files(file_path)
72
+ self.page.wait_for_timeout(PAUSE_UPLOAD_CHOOSER_MS)
73
+ return True
74
+ except Exception:
75
+ pass
76
+
77
+ return False
builder.py ADDED
@@ -0,0 +1,83 @@
1
+ """Payload and protocol envelope builder module (Facade and Legacy Entrypoint).
2
+
3
+ Constructs browser context envelopes and form application payloads.
4
+ Enforces strict decoupling: NEVER injects hardcoded string literals or device
5
+ fallbacks. Reads strictly from passed models (DeviceProfile / Settings) or
6
+ verified values loaded from dedicated configuration files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from typing import Any, Dict, List, Optional, Tuple, Union
14
+
15
+ from adapters.envelope_builder import BrowserContextEnvelopeBuilder
16
+ from core.exceptions import ConfigurationError, ValidationError
17
+ from core.models import DeviceProfile, ProxyConfig
18
+ from config.settings import (
19
+ Settings,
20
+ get_settings,
21
+ load_device_profile,
22
+ DEFAULT_DEVICE_PROFILE_JSON,
23
+ )
24
+
25
+ __all__ = ["BrowserContextEnvelopeBuilder", "FormPayloadBuilder"]
26
+
27
+
28
+ class FormPayloadBuilder:
29
+ """Builds and validates form field application payloads.
30
+
31
+ Loads mappings from file, dictionaries, or key-value pairs without
32
+ injecting hardcoded personal identity strings or sensitive fallbacks.
33
+ """
34
+
35
+ @staticmethod
36
+ def load_from_file(filepath: str) -> List[Tuple[str, Any]]:
37
+ """Load field key-value pairs from an external JSON file."""
38
+ if not os.path.isfile(filepath):
39
+ raise ConfigurationError(f"Form payload data file not found: {filepath}")
40
+
41
+ try:
42
+ with open(filepath, "r", encoding="utf-8") as f:
43
+ raw_data = json.load(f)
44
+ except Exception as exc:
45
+ raise ValidationError(f"Invalid JSON in form payload file {filepath}: {exc}") from exc
46
+
47
+ return FormPayloadBuilder.normalize_payload(raw_data)
48
+
49
+ @staticmethod
50
+ def normalize_payload(raw_data: Union[Dict[str, Any], List[Any]]) -> List[Tuple[str, Any]]:
51
+ """Normalizes dict or list structures into a clean list of (field, value) pairs."""
52
+ fields: List[Tuple[str, Any]] = []
53
+
54
+ if isinstance(raw_data, dict):
55
+ for k, v in raw_data.items():
56
+ fields.append((str(k), v))
57
+ elif isinstance(raw_data, list):
58
+ for item in raw_data:
59
+ if isinstance(item, (list, tuple)) and len(item) == 2:
60
+ fields.append((str(item[0]), item[1]))
61
+ elif isinstance(item, dict) and "key" in item and "value" in item:
62
+ fields.append((str(item["key"]), item["value"]))
63
+ else:
64
+ raise ValidationError(f"Unrecognized payload entry structure: {item}")
65
+ else:
66
+ raise ValidationError(f"Expected dict or list for payload, got {type(raw_data).__name__}")
67
+
68
+ return fields
69
+
70
+ @staticmethod
71
+ def parse_cli_fill_arguments(fill_args: Optional[List[str]]) -> List[Tuple[str, str]]:
72
+ """Parses CLI --fill 'key=value' arguments into key-value pairs."""
73
+ if not fill_args:
74
+ return []
75
+
76
+ parsed: List[Tuple[str, str]] = []
77
+ for item in fill_args:
78
+ if "=" in item:
79
+ k, v = item.split("=", 1)
80
+ parsed.append((k.strip(), v.strip()))
81
+ else:
82
+ raise ValidationError(f"Invalid fill argument format: '{item}' (Expected 'key=value')")
83
+ return parsed