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
|
@@ -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
|