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
supervisor/shell.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Interactive REPL Shell Mode (Thin Client).
|
|
2
|
+
|
|
3
|
+
Provides an interactive console prompt for step-by-step browser automation,
|
|
4
|
+
communicating directly with the Master Supervisor (Layer 1).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
if PROJECT_ROOT not in sys.path:
|
|
15
|
+
sys.path.insert(0, PROJECT_ROOT)
|
|
16
|
+
|
|
17
|
+
from services.inspection_formatter_service import InspectionFormatterService
|
|
18
|
+
from supervisor.client import SupervisorClient
|
|
19
|
+
from supervisor.contracts import SupervisorActionRequest, SupervisorActionResponse
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_interactive_shell(host: str = "127.0.0.1", port: int = 9333):
|
|
23
|
+
"""Runs the interactive REPL session."""
|
|
24
|
+
client = SupervisorClient(base_url=f"http://{host}:{port}")
|
|
25
|
+
try:
|
|
26
|
+
client.ensure_running()
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
print(f"[!] Failed to connect to Master Supervisor: {exc}")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
print("\n" + "═" * 70)
|
|
32
|
+
print(" 🚀 WEBPILOT — INTERACTIVE AUTONOMOUS REPL SHELL")
|
|
33
|
+
print("═" * 70)
|
|
34
|
+
print(" Commands:")
|
|
35
|
+
print(" • open <url> : Navigate to target URL")
|
|
36
|
+
print(" • fill <k>=<v> [<k>=<v>] : Populate one or more fields")
|
|
37
|
+
print(" • press <button_text> : Click button with reactive mutation tracking")
|
|
38
|
+
print(" • upload <key>=<path> : Upload document/CV")
|
|
39
|
+
print(" • inspect : Inspect current page form schema")
|
|
40
|
+
print(" • tabs : List all open tabs")
|
|
41
|
+
print(" • tab <index> : Switch to specific tab index (0-based)")
|
|
42
|
+
print(" • cookies <path> : Save refreshed session cookies")
|
|
43
|
+
print(" • screenshot <path> : Capture visual screenshot")
|
|
44
|
+
print(" • status : View runtime PIDs and tier health")
|
|
45
|
+
print(" • restart : Cascading kill of Layer 2 & 3 with instant restart")
|
|
46
|
+
print(" • exit / quit : Exit shell")
|
|
47
|
+
print("═" * 70 + "\n")
|
|
48
|
+
|
|
49
|
+
current_url = ""
|
|
50
|
+
|
|
51
|
+
while True:
|
|
52
|
+
try:
|
|
53
|
+
line = input("webpilot> ").strip()
|
|
54
|
+
except (KeyboardInterrupt, EOFError):
|
|
55
|
+
print("\nExiting interactive shell.")
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
if not line:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
parts = line.split(maxsplit=1)
|
|
62
|
+
cmd = parts[0].lower()
|
|
63
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
64
|
+
|
|
65
|
+
if cmd in ("exit", "quit", "q"):
|
|
66
|
+
print("Exiting interactive shell. Background supervisor remains active.")
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
elif cmd == "status":
|
|
70
|
+
st = client.get_status()
|
|
71
|
+
print("\n[📊 Supervisor & Worker Telemetry]:")
|
|
72
|
+
for k, v in st.items():
|
|
73
|
+
print(f" • {k:<25}: {v}")
|
|
74
|
+
print()
|
|
75
|
+
|
|
76
|
+
elif cmd == "restart":
|
|
77
|
+
print("[*] Initiating cascading kill of Layer 2 & 3...")
|
|
78
|
+
res = client.restart_worker()
|
|
79
|
+
print(f"[✓] {res.message}\n")
|
|
80
|
+
|
|
81
|
+
elif cmd in ("open", "goto"):
|
|
82
|
+
if not arg:
|
|
83
|
+
print("[!] Error: URL required, e.g. open https://example.com")
|
|
84
|
+
continue
|
|
85
|
+
current_url = arg.strip()
|
|
86
|
+
print(f"[*] Navigating to: {current_url}...")
|
|
87
|
+
req = SupervisorActionRequest(action="apply", url=current_url)
|
|
88
|
+
res = client.execute(req)
|
|
89
|
+
_print_apply_response(res)
|
|
90
|
+
|
|
91
|
+
elif cmd == "fill":
|
|
92
|
+
if not arg:
|
|
93
|
+
print("[!] Error: field mapping required, e.g. fill username=user@mail.com")
|
|
94
|
+
continue
|
|
95
|
+
fill_args = [a.strip() for a in arg.split(" ") if "=" in a]
|
|
96
|
+
if not fill_args:
|
|
97
|
+
fill_args = [arg.strip()]
|
|
98
|
+
req = SupervisorActionRequest(action="apply", fill_arguments=fill_args)
|
|
99
|
+
res = client.execute(req)
|
|
100
|
+
_print_apply_response(res)
|
|
101
|
+
|
|
102
|
+
elif cmd == "press":
|
|
103
|
+
if not arg:
|
|
104
|
+
print("[!] Error: button label required, e.g. press 'Sign In'")
|
|
105
|
+
continue
|
|
106
|
+
btn = arg.strip().strip("'\"")
|
|
107
|
+
print(f"[*] Pressing button: '{btn}'...")
|
|
108
|
+
req = SupervisorActionRequest(action="apply", press_buttons=[btn])
|
|
109
|
+
res = client.execute(req)
|
|
110
|
+
_print_apply_response(res)
|
|
111
|
+
|
|
112
|
+
elif cmd == "upload":
|
|
113
|
+
if not arg:
|
|
114
|
+
print("[!] Error: upload path required, e.g. upload resume=C:/cv.pdf")
|
|
115
|
+
continue
|
|
116
|
+
req = SupervisorActionRequest(action="apply", upload_files=[arg.strip()])
|
|
117
|
+
res = client.execute(req)
|
|
118
|
+
_print_apply_response(res)
|
|
119
|
+
|
|
120
|
+
elif cmd == "inspect":
|
|
121
|
+
print("[*] Inspecting active page schema...")
|
|
122
|
+
req = SupervisorActionRequest(action="inspect")
|
|
123
|
+
res = client.execute(req)
|
|
124
|
+
if res.schema:
|
|
125
|
+
print("\n" + InspectionFormatterService.format_summary(res.schema))
|
|
126
|
+
else:
|
|
127
|
+
print(f"[!] Inspection failed: {res.message}")
|
|
128
|
+
|
|
129
|
+
elif cmd == "tabs":
|
|
130
|
+
req = SupervisorActionRequest(action="list_tabs")
|
|
131
|
+
res = client.execute(req)
|
|
132
|
+
print("\n" + "═" * 70)
|
|
133
|
+
print(" 📑 OPEN BROWSER TABS")
|
|
134
|
+
print("═" * 70)
|
|
135
|
+
if not res.tabs:
|
|
136
|
+
print(" [!] No open tabs detected.")
|
|
137
|
+
else:
|
|
138
|
+
for t in res.tabs:
|
|
139
|
+
act = " [*ACTIVE*]" if t.get("is_active") else ""
|
|
140
|
+
print(f" • Tab #{t['index']}{act}: '{t['title']}'\n URL: {t['url']}")
|
|
141
|
+
print("═" * 70 + "\n")
|
|
142
|
+
|
|
143
|
+
elif cmd == "tab":
|
|
144
|
+
if not arg or not arg.isdigit():
|
|
145
|
+
print("[!] Error: Tab index required, e.g. tab 0")
|
|
146
|
+
continue
|
|
147
|
+
idx = int(arg)
|
|
148
|
+
req = SupervisorActionRequest(action="switch_tab", tab_index=idx)
|
|
149
|
+
res = client.execute(req)
|
|
150
|
+
print(f"[✓] {res.message}\n")
|
|
151
|
+
|
|
152
|
+
elif cmd == "cookies":
|
|
153
|
+
save_path = arg.strip() or "session_cookies.json"
|
|
154
|
+
req = SupervisorActionRequest(action="apply", cookies_path=save_path)
|
|
155
|
+
res = client.execute(req)
|
|
156
|
+
print(f"[✓] Cookies requested for save: {save_path}\n")
|
|
157
|
+
|
|
158
|
+
elif cmd == "screenshot":
|
|
159
|
+
save_path = arg.strip() or "screenshot.png"
|
|
160
|
+
req = SupervisorActionRequest(action="apply", screenshot_path=save_path)
|
|
161
|
+
res = client.execute(req)
|
|
162
|
+
print(f"[✓] Screenshot saved: {save_path}\n")
|
|
163
|
+
|
|
164
|
+
else:
|
|
165
|
+
print(f"[!] Unknown command '{cmd}'. Type 'open', 'fill', 'press', 'inspect', 'tabs', 'restart', or 'exit'.")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _print_apply_response(res: SupervisorActionResponse):
|
|
169
|
+
"""Formats output lines and reactive events from worker."""
|
|
170
|
+
if not res.success:
|
|
171
|
+
print(f"[!] Action failed: {res.message}")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
print(" 📄 ACTIVE PAGE TELEMETRY")
|
|
175
|
+
print("─" * 70)
|
|
176
|
+
print(f" • Tab #{res.active_tab_index} (Total: {res.total_tabs}) | Title: '{res.active_title}'")
|
|
177
|
+
print(f" • URL: {res.active_url}")
|
|
178
|
+
print("═" * 70)
|
|
179
|
+
|
|
180
|
+
for line in res.output_lines:
|
|
181
|
+
print(f" {line}")
|
|
182
|
+
|
|
183
|
+
if res.reactive_event:
|
|
184
|
+
rev = res.reactive_event
|
|
185
|
+
if rev.get("redirected"):
|
|
186
|
+
print(f"\n[🔀 Redirect / Navigation]: {rev.get('new_url')}")
|
|
187
|
+
if rev.get("new_window"):
|
|
188
|
+
print(f"\n[🌐 New Window Opened]: {rev.get('new_url')}")
|
|
189
|
+
if rev.get("modal_opened"):
|
|
190
|
+
print(f"\n[🪟 Modal Appeared]: {rev.get('modal_text')}")
|
|
191
|
+
if res.schema:
|
|
192
|
+
print(InspectionFormatterService.format_summary(res.schema))
|
|
193
|
+
|
|
194
|
+
print()
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Layer 2: Operational Worker Process.
|
|
2
|
+
|
|
3
|
+
Runs as a supervised subprocess of Master Supervisor (Layer 1).
|
|
4
|
+
Holds Playwright, Chromium browser, contexts, pages, and active session state.
|
|
5
|
+
Communicates via stdin/stdout JSON-RPC.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
# Ensure project root is on sys.path
|
|
17
|
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
18
|
+
if PROJECT_ROOT not in sys.path:
|
|
19
|
+
sys.path.insert(0, PROJECT_ROOT)
|
|
20
|
+
|
|
21
|
+
from adapters.browser_adapter import BrowserAdapter
|
|
22
|
+
from adapters.upload_adapter import UploadAdapter
|
|
23
|
+
from common.cookies import save_cookies
|
|
24
|
+
from builder import FormPayloadBuilder
|
|
25
|
+
from config.settings import get_settings, load_device_profile
|
|
26
|
+
from core.models import DeviceProfile
|
|
27
|
+
from orchestration.context import SessionContext
|
|
28
|
+
from orchestration.session_coordinator import SessionCoordinator
|
|
29
|
+
from services.auth_navigation_service import AuthNavigationService
|
|
30
|
+
from services.field_interaction_service import FieldInteractionService
|
|
31
|
+
from services.inspection_formatter_service import InspectionFormatterService
|
|
32
|
+
from services.inspection_service import InspectionService
|
|
33
|
+
from services.reactive_interaction_service import ReactiveInteractionService
|
|
34
|
+
from supervisor.contracts import SupervisorActionRequest, SupervisorActionResponse
|
|
35
|
+
from validators.form_validator import FormValidator
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OperationalWorker:
|
|
39
|
+
"""Manages active Playwright runtime and executes form/browser automation tasks."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, headed: bool = False):
|
|
42
|
+
self.settings = get_settings()
|
|
43
|
+
self.headed = headed
|
|
44
|
+
self.coordinator: Optional[SessionCoordinator] = None
|
|
45
|
+
|
|
46
|
+
def _ensure_session(self, req: SupervisorActionRequest) -> SessionCoordinator:
|
|
47
|
+
"""Initializes or returns active SessionCoordinator."""
|
|
48
|
+
if (
|
|
49
|
+
self.coordinator is None
|
|
50
|
+
or self.coordinator.page is None
|
|
51
|
+
or self.coordinator.page.is_closed()
|
|
52
|
+
):
|
|
53
|
+
effective_cookies = req.cookies_path or self.settings.cookies_path
|
|
54
|
+
session_ctx = SessionContext(
|
|
55
|
+
settings=self.settings,
|
|
56
|
+
device_profile=load_device_profile(),
|
|
57
|
+
headless=not req.headed if req.headed else not self.headed,
|
|
58
|
+
cookies_path=effective_cookies,
|
|
59
|
+
keep_alive=False,
|
|
60
|
+
close_session=False,
|
|
61
|
+
timeout_minutes=req.timeout_minutes,
|
|
62
|
+
tab_index=req.tab_index,
|
|
63
|
+
)
|
|
64
|
+
self.coordinator = SessionCoordinator(session_ctx)
|
|
65
|
+
self.coordinator.start()
|
|
66
|
+
return self.coordinator
|
|
67
|
+
|
|
68
|
+
def handle_request(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
|
|
69
|
+
"""Dispatches action to internal services and returns structured response."""
|
|
70
|
+
action = req.action.lower().strip()
|
|
71
|
+
lines: List[str] = []
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if action in ("ping", "status"):
|
|
75
|
+
return self._handle_status(req)
|
|
76
|
+
|
|
77
|
+
elif action == "list_tabs":
|
|
78
|
+
return self._handle_list_tabs(req)
|
|
79
|
+
|
|
80
|
+
elif action == "switch_tab":
|
|
81
|
+
return self._handle_switch_tab(req)
|
|
82
|
+
|
|
83
|
+
elif action == "inspect":
|
|
84
|
+
return self._handle_inspect(req)
|
|
85
|
+
|
|
86
|
+
elif action == "apply":
|
|
87
|
+
return self._handle_apply(req)
|
|
88
|
+
|
|
89
|
+
elif action in ("stop", "shutdown"):
|
|
90
|
+
self.shutdown()
|
|
91
|
+
return SupervisorActionResponse(
|
|
92
|
+
success=True,
|
|
93
|
+
action=action,
|
|
94
|
+
message="Operational Worker shut down cleanly.",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
else:
|
|
98
|
+
return SupervisorActionResponse(
|
|
99
|
+
success=False,
|
|
100
|
+
action=action,
|
|
101
|
+
message=f"Unknown action: '{action}'",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
return SupervisorActionResponse(
|
|
106
|
+
success=False,
|
|
107
|
+
action=action,
|
|
108
|
+
message=f"Error executing {action}: {exc}",
|
|
109
|
+
output_lines=[f"[!] Worker exception: {exc}"],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _handle_status(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
|
|
113
|
+
coord = self.coordinator
|
|
114
|
+
if not coord or not coord.page:
|
|
115
|
+
return SupervisorActionResponse(
|
|
116
|
+
success=True,
|
|
117
|
+
action="status",
|
|
118
|
+
message="Worker is idle (no active browser yet).",
|
|
119
|
+
worker_pid=os.getpid(),
|
|
120
|
+
total_tabs=0,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
adapter = coord.adapter
|
|
124
|
+
return SupervisorActionResponse(
|
|
125
|
+
success=True,
|
|
126
|
+
action="status",
|
|
127
|
+
message="Worker active with running browser.",
|
|
128
|
+
browser_pid=adapter.browser_pid,
|
|
129
|
+
worker_pid=os.getpid(),
|
|
130
|
+
active_tab_index=adapter.get_active_tab_index(),
|
|
131
|
+
active_url=coord.page.url if coord.page else "",
|
|
132
|
+
active_title=coord.page.title() if coord.page else "",
|
|
133
|
+
total_tabs=len(coord.context.pages) if coord.context else 0,
|
|
134
|
+
tabs=adapter.list_open_tabs(),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _handle_list_tabs(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
|
|
138
|
+
coord = self._ensure_session(req)
|
|
139
|
+
adapter = coord.adapter
|
|
140
|
+
tabs = adapter.list_open_tabs()
|
|
141
|
+
return SupervisorActionResponse(
|
|
142
|
+
success=True,
|
|
143
|
+
action="list_tabs",
|
|
144
|
+
message=f"Retrieved {len(tabs)} tabs.",
|
|
145
|
+
browser_pid=adapter.browser_pid,
|
|
146
|
+
worker_pid=os.getpid(),
|
|
147
|
+
total_tabs=len(tabs),
|
|
148
|
+
tabs=tabs,
|
|
149
|
+
active_tab_index=adapter.get_active_tab_index(),
|
|
150
|
+
active_url=coord.page.url if coord.page else "",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _handle_switch_tab(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
|
|
154
|
+
coord = self._ensure_session(req)
|
|
155
|
+
if req.tab_index is None:
|
|
156
|
+
return SupervisorActionResponse(
|
|
157
|
+
success=False,
|
|
158
|
+
action="switch_tab",
|
|
159
|
+
message="tab_index is required for switch_tab action.",
|
|
160
|
+
)
|
|
161
|
+
page = coord.switch_to_tab(req.tab_index)
|
|
162
|
+
return SupervisorActionResponse(
|
|
163
|
+
success=True,
|
|
164
|
+
action="switch_tab",
|
|
165
|
+
message=f"Switched to Tab #{req.tab_index}: '{page.title()}'",
|
|
166
|
+
active_tab_index=req.tab_index,
|
|
167
|
+
active_url=page.url,
|
|
168
|
+
active_title=page.title(),
|
|
169
|
+
total_tabs=len(coord.context.pages) if coord.context else 0,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _handle_inspect(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
|
|
173
|
+
coord = self._ensure_session(req)
|
|
174
|
+
page = coord.page
|
|
175
|
+
assert page is not None
|
|
176
|
+
|
|
177
|
+
if req.url:
|
|
178
|
+
coord.adapter.navigate(req.url)
|
|
179
|
+
page = coord.page
|
|
180
|
+
|
|
181
|
+
# Landing portal transition if needed
|
|
182
|
+
auth_svc = AuthNavigationService(page, coord.context)
|
|
183
|
+
if req.url and ("jobs." in page.url or page.locator("button.dropdown-toggle, .dialogApplyBtn").count() > 0):
|
|
184
|
+
auth_svc.bypass_job_landing_portal()
|
|
185
|
+
page = coord.switch_to_latest_page()
|
|
186
|
+
|
|
187
|
+
inspection_svc = InspectionService(page)
|
|
188
|
+
schema = inspection_svc.inspect(unpack_options=req.unpack_options)
|
|
189
|
+
|
|
190
|
+
if req.screenshot_path:
|
|
191
|
+
coord.adapter.capture_screenshot(req.screenshot_path, full_page=True)
|
|
192
|
+
|
|
193
|
+
return SupervisorActionResponse(
|
|
194
|
+
success=True,
|
|
195
|
+
action="inspect",
|
|
196
|
+
message=f"Inspected page: '{page.title()}'",
|
|
197
|
+
browser_pid=coord.adapter.browser_pid,
|
|
198
|
+
worker_pid=os.getpid(),
|
|
199
|
+
active_tab_index=coord.adapter.get_active_tab_index(),
|
|
200
|
+
active_url=page.url,
|
|
201
|
+
active_title=page.title(),
|
|
202
|
+
total_tabs=len(coord.context.pages) if coord.context else 0,
|
|
203
|
+
schema=schema,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _handle_apply(self, req: SupervisorActionRequest) -> SupervisorActionResponse:
|
|
207
|
+
coord = self._ensure_session(req)
|
|
208
|
+
page = coord.page
|
|
209
|
+
ctx = coord.context
|
|
210
|
+
assert page is not None and ctx is not None
|
|
211
|
+
|
|
212
|
+
lines: List[str] = []
|
|
213
|
+
|
|
214
|
+
if req.tab_index is not None:
|
|
215
|
+
page = coord.switch_to_tab(req.tab_index)
|
|
216
|
+
|
|
217
|
+
if req.url:
|
|
218
|
+
lines.append(f"[*] Navigating to target URL: {req.url}")
|
|
219
|
+
coord.adapter.navigate(req.url)
|
|
220
|
+
page = coord.page
|
|
221
|
+
else:
|
|
222
|
+
if page.url and page.url != "about:blank":
|
|
223
|
+
lines.append(f"[*] Working on active page in existing session: {page.url}")
|
|
224
|
+
else:
|
|
225
|
+
return SupervisorActionResponse(
|
|
226
|
+
success=False,
|
|
227
|
+
action="apply",
|
|
228
|
+
message="Target URL (--url) is required when starting a new session without an active page.",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
auth_svc = AuthNavigationService(page, ctx)
|
|
232
|
+
|
|
233
|
+
# 1. Landing portal bypass
|
|
234
|
+
if req.url and ("jobs." in page.url or page.locator("button.dropdown-toggle, .dialogApplyBtn").count() > 0):
|
|
235
|
+
lines.append("[*] Bypassing landing page to active application...")
|
|
236
|
+
auth_svc.bypass_job_landing_portal()
|
|
237
|
+
page = coord.switch_to_latest_page()
|
|
238
|
+
|
|
239
|
+
# 2. Wait for form readiness & expand accordions
|
|
240
|
+
auth_svc.wait_for_form_ready(self.settings.form_ready_timeout_ms)
|
|
241
|
+
lines.append("[*] Ensuring all form sections are expanded...")
|
|
242
|
+
try:
|
|
243
|
+
auth_svc.expand_all_sections(self.settings.expand_section_wait_ms)
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
# 3. Assemble fields to set
|
|
248
|
+
fields_to_set = []
|
|
249
|
+
if req.data_path and os.path.isfile(req.data_path):
|
|
250
|
+
fields_to_set.extend(FormPayloadBuilder.load_from_file(req.data_path))
|
|
251
|
+
if req.fill_arguments:
|
|
252
|
+
fields_to_set.extend(FormPayloadBuilder.parse_cli_fill_arguments(req.fill_arguments))
|
|
253
|
+
|
|
254
|
+
field_svc = FieldInteractionService(page)
|
|
255
|
+
confirmed = []
|
|
256
|
+
failed = []
|
|
257
|
+
|
|
258
|
+
if fields_to_set:
|
|
259
|
+
lines.append(f"[*] Populating {len(fields_to_set)} field(s)...")
|
|
260
|
+
for key, val in fields_to_set:
|
|
261
|
+
success = field_svc.set_field(key, val)
|
|
262
|
+
if success:
|
|
263
|
+
confirmed.append((key, val))
|
|
264
|
+
lines.append(f" [✓] Set '{key}' -> '{val}'")
|
|
265
|
+
else:
|
|
266
|
+
failed.append((key, val))
|
|
267
|
+
lines.append(f" [~] Unmatched/Failed '{key}' -> '{val}'")
|
|
268
|
+
lines.append(f"[*] Fields summary: {len(confirmed)} set | {len(failed)} skipped.")
|
|
269
|
+
|
|
270
|
+
# 4. Handle uploads
|
|
271
|
+
if req.upload_files:
|
|
272
|
+
upload_adapter = UploadAdapter(page)
|
|
273
|
+
for up in req.upload_files:
|
|
274
|
+
if "=" in up:
|
|
275
|
+
kw, path = up.split("=", 1)
|
|
276
|
+
kw, path = kw.strip(), path.strip()
|
|
277
|
+
else:
|
|
278
|
+
kw, path = "", up.strip()
|
|
279
|
+
if os.path.isfile(path):
|
|
280
|
+
lines.append(f"[*] Uploading '{path}' (Keyword: '{kw}')...")
|
|
281
|
+
upload_adapter.upload_document(path, kw)
|
|
282
|
+
|
|
283
|
+
# 5. Handle buttons with reactive observation
|
|
284
|
+
inspection_svc = InspectionService(page)
|
|
285
|
+
validator = FormValidator(page)
|
|
286
|
+
reactive_svc = ReactiveInteractionService(
|
|
287
|
+
page=page,
|
|
288
|
+
context=ctx,
|
|
289
|
+
inspection_service=inspection_svc,
|
|
290
|
+
form_validator=validator,
|
|
291
|
+
on_active_page_change=lambda p: coord.switch_to_latest_page(),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
last_reactive_event: Optional[Dict[str, Any]] = None
|
|
295
|
+
if req.press_buttons:
|
|
296
|
+
for btn in req.press_buttons:
|
|
297
|
+
lines.append(f"[*] Pressing button: '{btn}'...")
|
|
298
|
+
res = reactive_svc.click_button(btn)
|
|
299
|
+
if not res.get("success"):
|
|
300
|
+
lines.append(f" [!] Failed to find or click button: '{btn}'")
|
|
301
|
+
continue
|
|
302
|
+
lines.append(f" [✓] Successfully clicked '{btn}'.")
|
|
303
|
+
page = coord.switch_to_latest_page()
|
|
304
|
+
last_reactive_event = res
|
|
305
|
+
|
|
306
|
+
if res.get("new_window"):
|
|
307
|
+
lines.append(f"\n[🌐 New Window / Tab Opened]: {res['new_url']}")
|
|
308
|
+
elif res.get("redirected"):
|
|
309
|
+
lines.append(f"\n[🔀 Redirect / Navigation Detected]: {res['new_url']}")
|
|
310
|
+
elif res.get("modal_opened"):
|
|
311
|
+
lines.append(f"\n[🪟 New Modal / Dialog Appeared]:\n{res['modal_text']}")
|
|
312
|
+
elif res.get("new_inputs_count", 0) > 0:
|
|
313
|
+
lines.append(f"\n[📋 Dynamic Form Expansion (+{res['new_inputs_count']} new inputs)]")
|
|
314
|
+
|
|
315
|
+
# 6. Capture screenshot if requested
|
|
316
|
+
if req.screenshot_path:
|
|
317
|
+
coord.adapter.capture_screenshot(req.screenshot_path, full_page=True)
|
|
318
|
+
lines.append(f"[✓] Form state captured: {req.screenshot_path}")
|
|
319
|
+
|
|
320
|
+
# 7. Save refreshed cookies if requested
|
|
321
|
+
if req.cookies_path and ctx:
|
|
322
|
+
try:
|
|
323
|
+
cookies = ctx.cookies()
|
|
324
|
+
save_cookies(req.cookies_path, cookies)
|
|
325
|
+
lines.append(f"[✓] Persisted {len(cookies)} cookies to: {req.cookies_path}")
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
lines.append(f"[!] Warning saving cookies: {exc}")
|
|
328
|
+
|
|
329
|
+
# 8. Validation checks
|
|
330
|
+
validation_errors = validator.get_validation_errors()
|
|
331
|
+
if validation_errors:
|
|
332
|
+
lines.append(f"[!] Validation Notices: {validation_errors}")
|
|
333
|
+
else:
|
|
334
|
+
lines.append("[✓] Zero validation errors detected!")
|
|
335
|
+
|
|
336
|
+
schema = None
|
|
337
|
+
if req.auto_inspect:
|
|
338
|
+
schema = inspection_svc.inspect(unpack_options=False)
|
|
339
|
+
|
|
340
|
+
return SupervisorActionResponse(
|
|
341
|
+
success=True,
|
|
342
|
+
action="apply",
|
|
343
|
+
message="Form action executed successfully.",
|
|
344
|
+
browser_pid=coord.adapter.browser_pid,
|
|
345
|
+
worker_pid=os.getpid(),
|
|
346
|
+
active_tab_index=coord.adapter.get_active_tab_index(),
|
|
347
|
+
active_url=page.url,
|
|
348
|
+
active_title=page.title(),
|
|
349
|
+
total_tabs=len(ctx.pages) if ctx else 0,
|
|
350
|
+
tabs=coord.adapter.list_open_tabs(),
|
|
351
|
+
confirmed_fields=confirmed,
|
|
352
|
+
failed_fields=failed,
|
|
353
|
+
validation_errors=validation_errors,
|
|
354
|
+
schema=schema,
|
|
355
|
+
reactive_event=last_reactive_event,
|
|
356
|
+
output_lines=lines,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def shutdown(self) -> None:
|
|
360
|
+
"""Terminates Playwright coordinator and all child browser tabs."""
|
|
361
|
+
if self.coordinator:
|
|
362
|
+
try:
|
|
363
|
+
self.coordinator.close()
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
self.coordinator = None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def run_worker_loop():
|
|
370
|
+
"""Reads JSON requests from stdin, executes, writes JSON response to stdout."""
|
|
371
|
+
rpc_out = sys.stdout
|
|
372
|
+
# Redirect all print/logging to stderr to keep JSON-RPC channel strictly pure
|
|
373
|
+
sys.stdout = sys.stderr
|
|
374
|
+
|
|
375
|
+
worker = OperationalWorker()
|
|
376
|
+
for raw_line in sys.stdin:
|
|
377
|
+
line = raw_line.strip()
|
|
378
|
+
if not line:
|
|
379
|
+
continue
|
|
380
|
+
try:
|
|
381
|
+
data = json.loads(line)
|
|
382
|
+
req = SupervisorActionRequest.from_dict(data)
|
|
383
|
+
res = worker.handle_request(req)
|
|
384
|
+
out = json.dumps(res.to_dict(), ensure_ascii=False)
|
|
385
|
+
rpc_out.write(out + "\n")
|
|
386
|
+
rpc_out.flush()
|
|
387
|
+
if req.action in ("stop", "shutdown"):
|
|
388
|
+
break
|
|
389
|
+
except Exception as exc:
|
|
390
|
+
err_res = SupervisorActionResponse(
|
|
391
|
+
success=False,
|
|
392
|
+
action="unknown",
|
|
393
|
+
message=f"Fatal worker parse error: {exc}",
|
|
394
|
+
)
|
|
395
|
+
rpc_out.write(json.dumps(err_res.to_dict(), ensure_ascii=False) + "\n")
|
|
396
|
+
rpc_out.flush()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
if __name__ == "__main__":
|
|
400
|
+
run_worker_loop()
|
validators/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Form validation and error banner inspection."""
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
from playwright.sync_api import Page
|
|
5
|
+
from constants.selectors import SELECTOR_VALIDATION_ERRORS
|
|
6
|
+
|
|
7
|
+
class FormValidator:
|
|
8
|
+
"""Inspects the active DOM for validation notices, errors, and alerts."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, page: Page):
|
|
11
|
+
self.page = page
|
|
12
|
+
|
|
13
|
+
def get_validation_errors(self) -> List[str]:
|
|
14
|
+
"""Collects error alerts, validation messages, or red warning texts currently shown."""
|
|
15
|
+
if self.page is None:
|
|
16
|
+
return []
|
|
17
|
+
|
|
18
|
+
errors = self.page.evaluate("""(selector) => {
|
|
19
|
+
const errorNodes = Array.from(document.querySelectorAll(selector));
|
|
20
|
+
const texts = errorNodes.map(e => e.innerText.trim()).filter(t => t.length > 0);
|
|
21
|
+
|
|
22
|
+
// Also inspect lines starting with error glyphs
|
|
23
|
+
const bodyLines = document.body.innerText.split('\\n').map(l => l.trim());
|
|
24
|
+
const errLines = bodyLines.filter(l => l.includes('is required') || l.includes('Please correct the errors'));
|
|
25
|
+
return Array.from(new Set([...texts, ...errLines]));
|
|
26
|
+
}""", SELECTOR_VALIDATION_ERRORS)
|
|
27
|
+
|
|
28
|
+
return list(errors)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Validation logic for form payloads and CLI inputs."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Tuple, Union
|
|
4
|
+
from core.exceptions import ValidationError
|
|
5
|
+
|
|
6
|
+
class PayloadValidator:
|
|
7
|
+
"""Validates candidate payloads and CLI key-value arguments."""
|
|
8
|
+
|
|
9
|
+
@staticmethod
|
|
10
|
+
def validate_key_value_pair(key: Any, val: Any) -> Tuple[str, Any]:
|
|
11
|
+
"""Validates that a field key is a non-empty string."""
|
|
12
|
+
clean_key = str(key).strip()
|
|
13
|
+
if not clean_key:
|
|
14
|
+
raise ValidationError("Field key cannot be empty or whitespace")
|
|
15
|
+
return clean_key, val
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def validate_payload_list(items: List[Tuple[str, Any]]) -> List[Tuple[str, Any]]:
|
|
19
|
+
"""Ensures all items in payload are valid (key, value) tuples."""
|
|
20
|
+
validated = []
|
|
21
|
+
for item in items:
|
|
22
|
+
if not isinstance(item, (tuple, list)) or len(item) != 2:
|
|
23
|
+
raise ValidationError(f"Invalid payload item structure: {item}")
|
|
24
|
+
validated.append(PayloadValidator.validate_key_value_pair(item[0], item[1]))
|
|
25
|
+
return validated
|