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,358 @@
|
|
|
1
|
+
"""Flow Orchestrator: coordinates multi-step inspection and form application workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from builder import FormPayloadBuilder
|
|
10
|
+
from common.logging import log_event
|
|
11
|
+
from config.settings import get_settings, load_device_profile
|
|
12
|
+
from constants.timeouts import DEFAULT_SUBMIT_WAIT_MS
|
|
13
|
+
from core.models import ApplyExecutionResult, InspectionResult
|
|
14
|
+
from orchestration.context import (
|
|
15
|
+
ApplyRequestContext,
|
|
16
|
+
InspectionRequestContext,
|
|
17
|
+
SessionContext,
|
|
18
|
+
)
|
|
19
|
+
from orchestration.session_coordinator import SessionCoordinator
|
|
20
|
+
from services.auth_navigation_service import AuthNavigationService
|
|
21
|
+
from services.field_interaction_service import FieldInteractionService
|
|
22
|
+
from services.inspection_formatter_service import InspectionFormatterService
|
|
23
|
+
from services.inspection_service import InspectionService
|
|
24
|
+
from services.reactive_interaction_service import ReactiveInteractionService
|
|
25
|
+
from adapters.live_session_manager import LiveSessionManager
|
|
26
|
+
from adapters.upload_adapter import UploadAdapter
|
|
27
|
+
from validators.form_validator import FormValidator
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FlowOrchestrator:
|
|
31
|
+
"""Enterprise flow orchestrator managing multi-step web automation pipelines."""
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def execute_inspection_flow(req: InspectionRequestContext) -> Dict[str, Any]:
|
|
35
|
+
"""Executes full multi-step inspection flow."""
|
|
36
|
+
if req.close_session:
|
|
37
|
+
terminated = LiveSessionManager.terminate_session()
|
|
38
|
+
if terminated:
|
|
39
|
+
print("[✓] Successfully terminated live browser session and killed process.")
|
|
40
|
+
else:
|
|
41
|
+
print("[*] No active live browser session was found to terminate.")
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
settings = get_settings(
|
|
45
|
+
env_file=req.env_file,
|
|
46
|
+
settings_json_path=req.config_path,
|
|
47
|
+
)
|
|
48
|
+
device_profile = load_device_profile(
|
|
49
|
+
profile_path=req.device_profile_path,
|
|
50
|
+
env_file=req.env_file,
|
|
51
|
+
)
|
|
52
|
+
headless = not req.headed if req.headed else settings.headless
|
|
53
|
+
effective_cookies = req.cookies_path or settings.cookies_path
|
|
54
|
+
|
|
55
|
+
session_ctx = SessionContext(
|
|
56
|
+
settings=settings,
|
|
57
|
+
device_profile=device_profile,
|
|
58
|
+
headless=headless,
|
|
59
|
+
cookies_path=effective_cookies,
|
|
60
|
+
keep_alive=req.keep_alive or req.list_tabs,
|
|
61
|
+
close_session=req.close_session,
|
|
62
|
+
timeout_minutes=req.timeout_minutes or 30,
|
|
63
|
+
tab_index=req.tab_index,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
with SessionCoordinator(session_ctx) as coordinator:
|
|
67
|
+
if req.list_tabs:
|
|
68
|
+
tabs = coordinator.list_open_tabs()
|
|
69
|
+
print("\n" + "═" * 70)
|
|
70
|
+
print(" 📑 OPEN BROWSER TABS")
|
|
71
|
+
print("═" * 70)
|
|
72
|
+
if not tabs:
|
|
73
|
+
print(" [!] No open tabs detected in current session.")
|
|
74
|
+
else:
|
|
75
|
+
for t in tabs:
|
|
76
|
+
active_mark = " [*ACTIVE*]" if t.get("is_active") else ""
|
|
77
|
+
print(f" • Tab #{t['index']}{active_mark}: '{t['title']}'\n URL: {t['url']}")
|
|
78
|
+
print("═" * 70 + "\n")
|
|
79
|
+
return {"tabs": tabs}
|
|
80
|
+
|
|
81
|
+
page = coordinator.page
|
|
82
|
+
ctx = coordinator.context
|
|
83
|
+
assert page is not None and ctx is not None
|
|
84
|
+
|
|
85
|
+
if req.tab_index is not None:
|
|
86
|
+
page = coordinator.switch_to_tab(req.tab_index)
|
|
87
|
+
|
|
88
|
+
if req.url:
|
|
89
|
+
print(f"[*] Starting engine and navigating to: {req.url}")
|
|
90
|
+
coordinator.adapter.navigate(req.url)
|
|
91
|
+
else:
|
|
92
|
+
if page.url and page.url != "about:blank":
|
|
93
|
+
print(f"[*] Working on active page in existing browser session: {page.url}")
|
|
94
|
+
else:
|
|
95
|
+
raise ValueError("Target URL (--url) is required when starting a new session without an active page.")
|
|
96
|
+
|
|
97
|
+
auth_svc = AuthNavigationService(page, ctx)
|
|
98
|
+
|
|
99
|
+
# 1. Landing portal bypass if applicable
|
|
100
|
+
if "jobs." in page.url or page.locator("button.dropdown-toggle, .dialogApplyBtn").count() > 0:
|
|
101
|
+
print("[*] Transitioning past landing portal...")
|
|
102
|
+
auth_svc.bypass_job_landing_portal()
|
|
103
|
+
page = coordinator.switch_to_latest_page()
|
|
104
|
+
|
|
105
|
+
# 2. Form readiness & section expansion
|
|
106
|
+
auth_svc.wait_for_form_ready(settings.form_ready_timeout_ms)
|
|
107
|
+
auth_svc.expand_all_sections(settings.expand_section_wait_ms)
|
|
108
|
+
|
|
109
|
+
# 3. Inspect page schema
|
|
110
|
+
print("[*] Inspecting page components and form schema...")
|
|
111
|
+
inspection_svc = InspectionService(page)
|
|
112
|
+
inspection_data = inspection_svc.inspect(unpack_options=req.unpack_options)
|
|
113
|
+
|
|
114
|
+
# 4. Format & display summary
|
|
115
|
+
summary = InspectionFormatterService.format_summary(inspection_data)
|
|
116
|
+
print("\n" + summary)
|
|
117
|
+
|
|
118
|
+
# 5. Persist schema & screenshot
|
|
119
|
+
if req.output_path:
|
|
120
|
+
with open(req.output_path, "w", encoding="utf-8") as f:
|
|
121
|
+
json.dump(inspection_data, f, indent=2, ensure_ascii=False)
|
|
122
|
+
print(f"[✓] Full schema saved to: {req.output_path}")
|
|
123
|
+
|
|
124
|
+
if req.screenshot_path:
|
|
125
|
+
coordinator.adapter.capture_screenshot(req.screenshot_path, full_page=True)
|
|
126
|
+
print(f"[✓] Screenshot saved to: {req.screenshot_path}")
|
|
127
|
+
|
|
128
|
+
return inspection_data
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def execute_apply_flow(req: ApplyRequestContext) -> ApplyExecutionResult:
|
|
132
|
+
"""Executes full multi-step form filling, file uploading, and submission flow."""
|
|
133
|
+
if req.close_session:
|
|
134
|
+
terminated = LiveSessionManager.terminate_session()
|
|
135
|
+
if terminated:
|
|
136
|
+
print("[✓] Successfully terminated live browser session and killed process.")
|
|
137
|
+
else:
|
|
138
|
+
print("[*] No active live browser session was found to terminate.")
|
|
139
|
+
return ApplyExecutionResult(
|
|
140
|
+
confirmed_fields=[],
|
|
141
|
+
unconfirmed_fields=[],
|
|
142
|
+
failed_fields=[],
|
|
143
|
+
final_url="",
|
|
144
|
+
validation_errors=[],
|
|
145
|
+
submitted=False,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
settings = get_settings(
|
|
149
|
+
env_file=req.env_file,
|
|
150
|
+
settings_json_path=req.config_path,
|
|
151
|
+
)
|
|
152
|
+
device_profile = load_device_profile(
|
|
153
|
+
profile_path=req.device_profile_path,
|
|
154
|
+
env_file=req.env_file,
|
|
155
|
+
)
|
|
156
|
+
headless = not req.headed if req.headed else settings.headless
|
|
157
|
+
effective_cookies = req.cookies_path or settings.cookies_path
|
|
158
|
+
|
|
159
|
+
session_ctx = SessionContext(
|
|
160
|
+
settings=settings,
|
|
161
|
+
device_profile=device_profile,
|
|
162
|
+
headless=headless,
|
|
163
|
+
cookies_path=effective_cookies,
|
|
164
|
+
keep_alive=req.keep_alive or req.list_tabs,
|
|
165
|
+
close_session=req.close_session,
|
|
166
|
+
timeout_minutes=req.timeout_minutes or 30,
|
|
167
|
+
tab_index=req.tab_index,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
with SessionCoordinator(session_ctx) as coordinator:
|
|
171
|
+
if req.list_tabs:
|
|
172
|
+
tabs = coordinator.list_open_tabs()
|
|
173
|
+
print("\n" + "═" * 70)
|
|
174
|
+
print(" 📑 OPEN BROWSER TABS")
|
|
175
|
+
print("═" * 70)
|
|
176
|
+
if not tabs:
|
|
177
|
+
print(" [!] No open tabs detected in current session.")
|
|
178
|
+
else:
|
|
179
|
+
for t in tabs:
|
|
180
|
+
active_mark = " [*ACTIVE*]" if t.get("is_active") else ""
|
|
181
|
+
print(f" • Tab #{t['index']}{active_mark}: '{t['title']}'\n URL: {t['url']}")
|
|
182
|
+
print("═" * 70 + "\n")
|
|
183
|
+
return ApplyExecutionResult(
|
|
184
|
+
confirmed_fields=[],
|
|
185
|
+
unconfirmed_fields=[],
|
|
186
|
+
failed_fields=[],
|
|
187
|
+
final_url=coordinator.page.url if coordinator.page else "",
|
|
188
|
+
validation_errors=[],
|
|
189
|
+
submitted=False,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
page = coordinator.page
|
|
193
|
+
ctx = coordinator.context
|
|
194
|
+
assert page is not None and ctx is not None
|
|
195
|
+
|
|
196
|
+
if req.tab_index is not None:
|
|
197
|
+
page = coordinator.switch_to_tab(req.tab_index)
|
|
198
|
+
|
|
199
|
+
if req.url:
|
|
200
|
+
print(f"[*] Navigating to target URL: {req.url}")
|
|
201
|
+
coordinator.adapter.navigate(req.url)
|
|
202
|
+
else:
|
|
203
|
+
if page.url and page.url != "about:blank":
|
|
204
|
+
print(f"[*] Working on active page in existing browser session: {page.url}")
|
|
205
|
+
else:
|
|
206
|
+
raise ValueError("Target URL (--url) is required when starting a new session without an active page.")
|
|
207
|
+
|
|
208
|
+
auth_svc = AuthNavigationService(page, ctx)
|
|
209
|
+
|
|
210
|
+
# 1. Landing portal bypass
|
|
211
|
+
if req.url and ("jobs." in page.url or page.locator("button.dropdown-toggle, .dialogApplyBtn").count() > 0):
|
|
212
|
+
print("[*] Bypassing landing page to active application...")
|
|
213
|
+
auth_svc.bypass_job_landing_portal()
|
|
214
|
+
page = coordinator.switch_to_latest_page()
|
|
215
|
+
|
|
216
|
+
print("[*] Current active URL:", page.url)
|
|
217
|
+
|
|
218
|
+
# 2. Wait for form readiness & expand accordions
|
|
219
|
+
auth_svc.wait_for_form_ready(settings.form_ready_timeout_ms)
|
|
220
|
+
print("[*] Ensuring all form sections are expanded...")
|
|
221
|
+
try:
|
|
222
|
+
auth_svc.expand_all_sections(settings.expand_section_wait_ms)
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
# 3. Assemble fields to set
|
|
227
|
+
fields_to_set = []
|
|
228
|
+
if req.data_path:
|
|
229
|
+
if os.path.isfile(req.data_path):
|
|
230
|
+
print(f"[*] Loading form payload from: {req.data_path}")
|
|
231
|
+
fields_to_set.extend(FormPayloadBuilder.load_from_file(req.data_path))
|
|
232
|
+
else:
|
|
233
|
+
print(f"[!] Warning: Specified data file not found: {req.data_path}")
|
|
234
|
+
|
|
235
|
+
if req.fill_arguments:
|
|
236
|
+
fields_to_set.extend(FormPayloadBuilder.parse_cli_fill_arguments(req.fill_arguments))
|
|
237
|
+
|
|
238
|
+
field_svc = FieldInteractionService(page)
|
|
239
|
+
confirmed = []
|
|
240
|
+
failed = []
|
|
241
|
+
|
|
242
|
+
# 4. Populate fields dynamically
|
|
243
|
+
if fields_to_set:
|
|
244
|
+
print(f"\n[*] Populating {len(fields_to_set)} field(s)...")
|
|
245
|
+
for key, val in fields_to_set:
|
|
246
|
+
success = field_svc.set_field(key, val)
|
|
247
|
+
if success:
|
|
248
|
+
confirmed.append((key, val))
|
|
249
|
+
print(f" [✓] Set '{key}' -> '{val}'")
|
|
250
|
+
else:
|
|
251
|
+
failed.append((key, val))
|
|
252
|
+
print(f" [~] Unmatched/Failed '{key}' -> '{val}'")
|
|
253
|
+
print(f"[*] Fields summary: {len(confirmed)} set | {len(failed)} skipped.")
|
|
254
|
+
|
|
255
|
+
# 5. Handle document uploads
|
|
256
|
+
upload_adapter = UploadAdapter(page)
|
|
257
|
+
if req.upload_files:
|
|
258
|
+
for up in req.upload_files:
|
|
259
|
+
if "=" in up:
|
|
260
|
+
kw, path = up.split("=", 1)
|
|
261
|
+
kw, path = kw.strip(), path.strip()
|
|
262
|
+
else:
|
|
263
|
+
kw, path = "", up.strip()
|
|
264
|
+
|
|
265
|
+
if os.path.isfile(path):
|
|
266
|
+
print(f"[*] Uploading file '{path}' (Keyword: '{kw}')...")
|
|
267
|
+
upload_adapter.upload_document(path, kw)
|
|
268
|
+
else:
|
|
269
|
+
print(f"[!] Warning: Upload file not found: {path}")
|
|
270
|
+
|
|
271
|
+
# 6. Handle custom button presses with reactive observation
|
|
272
|
+
inspection_svc = InspectionService(page)
|
|
273
|
+
validator = FormValidator(page)
|
|
274
|
+
reactive_svc = ReactiveInteractionService(
|
|
275
|
+
page=page,
|
|
276
|
+
context=ctx,
|
|
277
|
+
inspection_service=inspection_svc,
|
|
278
|
+
form_validator=validator,
|
|
279
|
+
on_active_page_change=lambda p: coordinator.switch_to_latest_page(),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if req.press_buttons:
|
|
283
|
+
for btn in req.press_buttons:
|
|
284
|
+
print(f"\n[*] Pressing requested button: '{btn}'...")
|
|
285
|
+
res = reactive_svc.click_button(btn)
|
|
286
|
+
if not res.get("success"):
|
|
287
|
+
print(f" [!] Failed to find or click button: '{btn}'")
|
|
288
|
+
continue
|
|
289
|
+
print(f" [✓] Successfully clicked '{btn}'.")
|
|
290
|
+
|
|
291
|
+
page = coordinator.switch_to_latest_page()
|
|
292
|
+
coordinator.adapter.print_page_telemetry()
|
|
293
|
+
|
|
294
|
+
if res.get("new_window"):
|
|
295
|
+
print(f"\n[🌐 New Window / Tab Opened]: {res['new_url']}")
|
|
296
|
+
if req.auto_inspect and res.get("schema"):
|
|
297
|
+
print(InspectionFormatterService.format_summary(res["schema"]))
|
|
298
|
+
elif res.get("redirected"):
|
|
299
|
+
print(f"\n[🔀 Redirect / Navigation Detected]: {res['new_url']}")
|
|
300
|
+
if req.auto_inspect:
|
|
301
|
+
schema = res.get("schema") or inspection_svc.inspect(unpack_options=False)
|
|
302
|
+
print(InspectionFormatterService.format_summary(schema))
|
|
303
|
+
elif res.get("modal_opened"):
|
|
304
|
+
print(f"\n[🪟 New Modal / Dialog Appeared]:\n{res['modal_text']}")
|
|
305
|
+
if req.auto_inspect and res.get("schema"):
|
|
306
|
+
print(InspectionFormatterService.format_summary(res["schema"]))
|
|
307
|
+
elif res.get("new_inputs_count", 0) > 0:
|
|
308
|
+
print(f"\n[📋 Dynamic Form Expansion (+{res['new_inputs_count']} new inputs detected)]")
|
|
309
|
+
if req.auto_inspect and res.get("schema"):
|
|
310
|
+
print(InspectionFormatterService.format_summary(res["schema"]))
|
|
311
|
+
else:
|
|
312
|
+
if req.auto_inspect:
|
|
313
|
+
print(f"\n[*] Auto-inspecting post-interaction state for: '{page.title()}' ({page.url})...")
|
|
314
|
+
post_inspection = inspection_svc.inspect(unpack_options=False)
|
|
315
|
+
print(InspectionFormatterService.format_summary(post_inspection))
|
|
316
|
+
|
|
317
|
+
if res.get("alerts"):
|
|
318
|
+
print("\n[📢 Page Notices / Alerts]:", res["alerts"])
|
|
319
|
+
|
|
320
|
+
# 7. Screenshot state
|
|
321
|
+
if req.screenshot_path:
|
|
322
|
+
coordinator.adapter.capture_screenshot(req.screenshot_path, full_page=True)
|
|
323
|
+
print(f"[✓] Form filled state captured: {req.screenshot_path}")
|
|
324
|
+
|
|
325
|
+
# 8. Validation checks
|
|
326
|
+
validation_errors = validator.get_validation_errors()
|
|
327
|
+
if validation_errors:
|
|
328
|
+
print("\n[!] Form Validation Notices:", validation_errors)
|
|
329
|
+
else:
|
|
330
|
+
print("\n[✓] Zero validation errors detected!")
|
|
331
|
+
|
|
332
|
+
# 9. Final submission
|
|
333
|
+
confirmation_screenshot = None
|
|
334
|
+
if req.submit:
|
|
335
|
+
print("\n[*] Executing final submission...")
|
|
336
|
+
reactive_svc.click_button("apply")
|
|
337
|
+
page.wait_for_timeout(settings.submission_wait_ms)
|
|
338
|
+
|
|
339
|
+
post_errors = validator.get_validation_errors()
|
|
340
|
+
if post_errors:
|
|
341
|
+
print("[!] Post-Submit Notices:", post_errors)
|
|
342
|
+
else:
|
|
343
|
+
print("[✓] Application submitted successfully!")
|
|
344
|
+
|
|
345
|
+
if req.screenshot_path:
|
|
346
|
+
confirmation_screenshot = "submission_final_confirmation.png"
|
|
347
|
+
coordinator.adapter.capture_screenshot(confirmation_screenshot, full_page=True)
|
|
348
|
+
print(f"[✓] Confirmation screenshot saved: {confirmation_screenshot}")
|
|
349
|
+
|
|
350
|
+
return ApplyExecutionResult(
|
|
351
|
+
confirmed_fields=confirmed,
|
|
352
|
+
unconfirmed_fields=[],
|
|
353
|
+
failed_fields=failed,
|
|
354
|
+
final_url=page.url,
|
|
355
|
+
validation_errors=validation_errors,
|
|
356
|
+
submitted=req.submit,
|
|
357
|
+
confirmation_screenshot=confirmation_screenshot,
|
|
358
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Session Coordinator: manages browser session lifecycle, cookie propagation, and window state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
from playwright.sync_api import BrowserContext, Page
|
|
7
|
+
|
|
8
|
+
from adapters.browser_adapter import BrowserAdapter
|
|
9
|
+
from common.cookies import save_cookies
|
|
10
|
+
from common.logging import log_event
|
|
11
|
+
from orchestration.context import SessionContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SessionCoordinator:
|
|
15
|
+
"""Coordinates Playwright browser lifecycle, context persistence, and page state."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, session_ctx: SessionContext):
|
|
18
|
+
self.session_ctx = session_ctx
|
|
19
|
+
self.adapter = BrowserAdapter(
|
|
20
|
+
headless=session_ctx.headless,
|
|
21
|
+
settings=session_ctx.settings,
|
|
22
|
+
device_profile=session_ctx.device_profile,
|
|
23
|
+
keep_alive=session_ctx.keep_alive,
|
|
24
|
+
close_session=session_ctx.close_session,
|
|
25
|
+
timeout_minutes=session_ctx.timeout_minutes,
|
|
26
|
+
tab_index=session_ctx.tab_index,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def page(self) -> Optional[Page]:
|
|
31
|
+
"""Active Page handle."""
|
|
32
|
+
return self.adapter.page
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def context(self) -> Optional[BrowserContext]:
|
|
36
|
+
"""Active BrowserContext handle."""
|
|
37
|
+
return self.adapter.context
|
|
38
|
+
|
|
39
|
+
def __enter__(self) -> "SessionCoordinator":
|
|
40
|
+
self.start()
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
44
|
+
self.close()
|
|
45
|
+
|
|
46
|
+
def start(self) -> None:
|
|
47
|
+
"""Starts browser context or connects to live persistent session."""
|
|
48
|
+
log_event("info", "Starting browser session", {
|
|
49
|
+
"headless": self.session_ctx.headless,
|
|
50
|
+
"device": self.session_ctx.device_profile.device_name,
|
|
51
|
+
"keep_alive": self.session_ctx.keep_alive,
|
|
52
|
+
})
|
|
53
|
+
self.adapter.start(cookies_path=self.session_ctx.cookies_path)
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Saves refreshed cookies and terminates or detaches browser session."""
|
|
57
|
+
if self.context and self.session_ctx.cookies_path:
|
|
58
|
+
try:
|
|
59
|
+
cookies = self.context.cookies()
|
|
60
|
+
save_cookies(self.session_ctx.cookies_path, cookies)
|
|
61
|
+
log_event("info", "Persisted session cookies", {
|
|
62
|
+
"path": self.session_ctx.cookies_path,
|
|
63
|
+
"count": len(cookies),
|
|
64
|
+
})
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
log_event("warn", "Failed saving session cookies", {"error": str(exc)})
|
|
67
|
+
|
|
68
|
+
self.adapter.stop()
|
|
69
|
+
log_event("info", "Ended browser session turn", {})
|
|
70
|
+
|
|
71
|
+
def list_open_tabs(self) -> List[Dict[str, Any]]:
|
|
72
|
+
"""Returns metadata for all open tabs."""
|
|
73
|
+
return self.adapter.list_open_tabs()
|
|
74
|
+
|
|
75
|
+
def switch_to_tab(self, index: int) -> Page:
|
|
76
|
+
"""Switches active page to specified tab index."""
|
|
77
|
+
return self.adapter.switch_to_tab(index)
|
|
78
|
+
|
|
79
|
+
def switch_to_latest_page(self) -> Page:
|
|
80
|
+
"""Synchronizes and returns the latest opened browser tab/window."""
|
|
81
|
+
ctx = self.context
|
|
82
|
+
assert ctx is not None, "Browser context is not initialized."
|
|
83
|
+
if len(ctx.pages) > 0:
|
|
84
|
+
latest = ctx.pages[-1]
|
|
85
|
+
self.adapter.page = latest
|
|
86
|
+
return latest
|
|
87
|
+
assert self.page is not None
|
|
88
|
+
return self.page
|
services/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Services package for universal web applier."""
|
|
2
|
+
|
|
3
|
+
from services.auth_navigation_service import AuthNavigationService
|
|
4
|
+
from services.inspection_service import InspectionService
|
|
5
|
+
from services.inspection_formatter_service import InspectionFormatterService
|
|
6
|
+
from services.field_interaction_service import FieldInteractionService
|
|
7
|
+
from services.form_filler_service import FormFillerService
|
|
8
|
+
from services.reactive_interaction_service import ReactiveInteractionService
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AuthNavigationService",
|
|
12
|
+
"InspectionService",
|
|
13
|
+
"InspectionFormatterService",
|
|
14
|
+
"FieldInteractionService",
|
|
15
|
+
"FormFillerService",
|
|
16
|
+
"ReactiveInteractionService",
|
|
17
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Authentication and portal navigation service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from playwright.sync_api import BrowserContext, Page
|
|
8
|
+
|
|
9
|
+
from adapters.dom_scripts import (
|
|
10
|
+
DETECT_LOGIN_PAGE_SCRIPT,
|
|
11
|
+
INJECT_LOGIN_CREDENTIALS_SCRIPT,
|
|
12
|
+
PORTAL_DROPDOWN_CLICK_SCRIPT,
|
|
13
|
+
PORTAL_APPLY_CLICK_SCRIPT,
|
|
14
|
+
CHECK_FORM_LOADING_SCRIPT,
|
|
15
|
+
EXPAND_ALL_SECTIONS_SCRIPT,
|
|
16
|
+
)
|
|
17
|
+
from common.cookies import save_cookies
|
|
18
|
+
from constants.timeouts import (
|
|
19
|
+
PAUSE_PORTAL_DROPDOWN_CLICK_MS,
|
|
20
|
+
PAUSE_PORTAL_APPLY_CLICK_MS,
|
|
21
|
+
PAUSE_LOGIN_STABILIZATION_MS,
|
|
22
|
+
DEFAULT_FORM_READY_TIMEOUT_MS,
|
|
23
|
+
DEFAULT_EXPAND_SECTION_WAIT_MS,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthNavigationService:
|
|
28
|
+
"""Manages login detection, portal transitions, and form readiness state."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, page: Page, context: BrowserContext):
|
|
31
|
+
self.page = page
|
|
32
|
+
self.context = context
|
|
33
|
+
|
|
34
|
+
def handle_login_if_needed(
|
|
35
|
+
self,
|
|
36
|
+
email: str,
|
|
37
|
+
password: str,
|
|
38
|
+
cookies_save_path: Optional[str] = None,
|
|
39
|
+
) -> bool:
|
|
40
|
+
"""Detects if page is a login/sign-in page and completes authentication."""
|
|
41
|
+
try:
|
|
42
|
+
self.page.wait_for_load_state("domcontentloaded", timeout=5000)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
is_login = self.page.evaluate(DETECT_LOGIN_PAGE_SCRIPT)
|
|
48
|
+
except Exception:
|
|
49
|
+
self.page.wait_for_timeout(2000)
|
|
50
|
+
try:
|
|
51
|
+
is_login = self.page.evaluate(DETECT_LOGIN_PAGE_SCRIPT)
|
|
52
|
+
except Exception:
|
|
53
|
+
is_login = False
|
|
54
|
+
|
|
55
|
+
if is_login:
|
|
56
|
+
print("[*] Login page detected. Authenticating automatically...")
|
|
57
|
+
self.page.evaluate(INJECT_LOGIN_CREDENTIALS_SCRIPT, [email, password])
|
|
58
|
+
self.page.wait_for_timeout(1000)
|
|
59
|
+
|
|
60
|
+
# Click Sign In / Submit button via native text locator
|
|
61
|
+
for selector in ["button:has-text('sign in')", "[role='button']:has-text('sign in')", "input[type='submit']"]:
|
|
62
|
+
loc = self.page.locator(selector)
|
|
63
|
+
if loc.count() > 0 and loc.first.is_visible():
|
|
64
|
+
loc.first.click()
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
self.page.wait_for_timeout(PAUSE_LOGIN_STABILIZATION_MS)
|
|
68
|
+
print(f"[✓] Post-login URL: {self.page.url}")
|
|
69
|
+
|
|
70
|
+
if cookies_save_path:
|
|
71
|
+
try:
|
|
72
|
+
cookies = self.context.cookies()
|
|
73
|
+
save_cookies(cookies_save_path, cookies)
|
|
74
|
+
print(f"[✓] Fresh cookies saved to: {cookies_save_path}")
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
print(f"[!] Error saving cookies: {exc}")
|
|
77
|
+
return True
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def bypass_job_landing_portal(self) -> bool:
|
|
81
|
+
"""Clicks portal landing 'Apply Now' dropdowns/buttons to enter active ATS application."""
|
|
82
|
+
dropdown_clicked = self.page.evaluate(PORTAL_DROPDOWN_CLICK_SCRIPT)
|
|
83
|
+
if dropdown_clicked:
|
|
84
|
+
self.page.wait_for_timeout(PAUSE_PORTAL_DROPDOWN_CLICK_MS)
|
|
85
|
+
|
|
86
|
+
clicked_apply = self.page.evaluate(PORTAL_APPLY_CLICK_SCRIPT)
|
|
87
|
+
if clicked_apply:
|
|
88
|
+
self.page.wait_for_timeout(PAUSE_PORTAL_APPLY_CLICK_MS)
|
|
89
|
+
|
|
90
|
+
# Check if an interim email getter prompt is shown (e.g. SuccessFactors social apply)
|
|
91
|
+
try:
|
|
92
|
+
email_inp = self.page.locator("input[name='email']:visible, input[type='email']:visible")
|
|
93
|
+
if email_inp.count() > 0:
|
|
94
|
+
start_btn = self.page.locator("button.start:visible, button:has-text('Start'):visible")
|
|
95
|
+
if start_btn.count() > 0:
|
|
96
|
+
start_btn.first.click()
|
|
97
|
+
self.page.wait_for_timeout(PAUSE_PORTAL_APPLY_CLICK_MS)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
if len(self.context.pages) > 1:
|
|
102
|
+
self.page = self.context.pages[-1]
|
|
103
|
+
print(f"[*] Switched to active application window: {self.page.url}")
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def wait_for_form_ready(self, timeout_ms: int = DEFAULT_FORM_READY_TIMEOUT_MS) -> None:
|
|
108
|
+
"""Waits for form elements to be rendered in DOM, dismissing 'Loading...' state."""
|
|
109
|
+
print("[*] Waiting for form components to render...")
|
|
110
|
+
try:
|
|
111
|
+
self.page.wait_for_load_state("domcontentloaded", timeout=min(5000, timeout_ms))
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
for _ in range(6):
|
|
117
|
+
is_loading = self.page.evaluate(CHECK_FORM_LOADING_SCRIPT)
|
|
118
|
+
if not is_loading:
|
|
119
|
+
break
|
|
120
|
+
self.page.wait_for_timeout(500)
|
|
121
|
+
self.page.wait_for_timeout(1000)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
print(f"[!] Warning waiting for form: {exc}")
|
|
124
|
+
|
|
125
|
+
def expand_all_sections(self, wait_ms: int = DEFAULT_EXPAND_SECTION_WAIT_MS) -> int:
|
|
126
|
+
"""Safely expands any collapsed form sections without toggling open ones."""
|
|
127
|
+
opened = self.page.evaluate(EXPAND_ALL_SECTIONS_SCRIPT)
|
|
128
|
+
self.page.wait_for_timeout(wait_ms)
|
|
129
|
+
return opened
|