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