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
core/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Core package for universal web applier."""
|
|
2
|
+
|
|
3
|
+
from core.models import (
|
|
4
|
+
ProxyConfig,
|
|
5
|
+
DeviceProfile,
|
|
6
|
+
InputField,
|
|
7
|
+
DropdownField,
|
|
8
|
+
ChoiceGroup,
|
|
9
|
+
FileUploadField,
|
|
10
|
+
ActionButton,
|
|
11
|
+
InspectionResult,
|
|
12
|
+
FillSummary,
|
|
13
|
+
ReactiveClickOutcome,
|
|
14
|
+
)
|
|
15
|
+
from core.exceptions import (
|
|
16
|
+
UniversalApplierError,
|
|
17
|
+
ConfigurationError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
AutomationError,
|
|
20
|
+
ElementNotFoundError,
|
|
21
|
+
InteractionTimeoutError,
|
|
22
|
+
DocumentUploadError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ProxyConfig",
|
|
28
|
+
"DeviceProfile",
|
|
29
|
+
"InputField",
|
|
30
|
+
"DropdownField",
|
|
31
|
+
"ChoiceGroup",
|
|
32
|
+
"FileUploadField",
|
|
33
|
+
"ActionButton",
|
|
34
|
+
"InspectionResult",
|
|
35
|
+
"FillSummary",
|
|
36
|
+
"ReactiveClickOutcome",
|
|
37
|
+
"UniversalApplierError",
|
|
38
|
+
"ConfigurationError",
|
|
39
|
+
"ValidationError",
|
|
40
|
+
"AutomationError",
|
|
41
|
+
"ElementNotFoundError",
|
|
42
|
+
"InteractionTimeoutError",
|
|
43
|
+
"DocumentUploadError",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
]
|
core/exceptions.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Typed exception hierarchy for Universal Web Applier."""
|
|
2
|
+
|
|
3
|
+
class UniversalApplierError(Exception):
|
|
4
|
+
"""Base exception for all universal web applier domain errors."""
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
class ConfigurationError(UniversalApplierError):
|
|
8
|
+
"""Raised when configuration values, files, or device profiles are missing or invalid."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
class ValidationError(UniversalApplierError):
|
|
12
|
+
"""Raised when input data, payloads, or schema validation fails."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
class AutomationError(UniversalApplierError):
|
|
16
|
+
"""Raised when web automation actions fail unexpectedly."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
class ElementNotFoundError(AutomationError):
|
|
20
|
+
"""Raised when an expected DOM element cannot be located."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
class InteractionTimeoutError(AutomationError):
|
|
24
|
+
"""Raised when an action or wait condition exceeds its deadline."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
class DocumentUploadError(AutomationError):
|
|
28
|
+
"""Raised when document attachment fails."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
class AuthenticationError(AutomationError):
|
|
32
|
+
"""Raised when login or credential submission fails."""
|
|
33
|
+
pass
|
core/models.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Shared domain Transfer Objects (DTOs) and data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
7
|
+
from core.exceptions import ValidationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ProxyConfig:
|
|
12
|
+
"""Network proxy configuration loaded from settings."""
|
|
13
|
+
server: str
|
|
14
|
+
username: Optional[str] = None
|
|
15
|
+
password: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
def to_playwright_dict(self) -> Dict[str, str]:
|
|
18
|
+
"""Convert to Playwright-compatible proxy dictionary."""
|
|
19
|
+
d: Dict[str, str] = {"server": self.server}
|
|
20
|
+
if self.username:
|
|
21
|
+
d["username"] = self.username
|
|
22
|
+
if self.password:
|
|
23
|
+
d["password"] = self.password
|
|
24
|
+
return d
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DeviceProfile:
|
|
29
|
+
"""Device & browser profile parameters for browser context emulation."""
|
|
30
|
+
device_name: str
|
|
31
|
+
user_agent: str
|
|
32
|
+
viewport_width: int
|
|
33
|
+
viewport_height: int
|
|
34
|
+
device_scale_factor: float = 1.0
|
|
35
|
+
is_mobile: bool = False
|
|
36
|
+
has_touch: bool = False
|
|
37
|
+
locale: str = "en-US"
|
|
38
|
+
timezone_id: str = "UTC"
|
|
39
|
+
geolocation_latitude: Optional[float] = None
|
|
40
|
+
geolocation_longitude: Optional[float] = None
|
|
41
|
+
geolocation_accuracy: Optional[float] = None
|
|
42
|
+
permissions: List[str] = field(default_factory=list)
|
|
43
|
+
platform: str = "Win32"
|
|
44
|
+
vendor: str = "Google Inc."
|
|
45
|
+
|
|
46
|
+
def viewport_dict(self) -> Dict[str, int]:
|
|
47
|
+
"""Returns the viewport dimensions dictionary."""
|
|
48
|
+
return {"width": self.viewport_width, "height": self.viewport_height}
|
|
49
|
+
|
|
50
|
+
def geolocation_dict(self) -> Optional[Dict[str, float]]:
|
|
51
|
+
"""Returns geolocation dictionary if valid coordinates are present."""
|
|
52
|
+
if self.geolocation_latitude is not None and self.geolocation_longitude is not None:
|
|
53
|
+
return {
|
|
54
|
+
"latitude": float(self.geolocation_latitude),
|
|
55
|
+
"longitude": float(self.geolocation_longitude),
|
|
56
|
+
"accuracy": float(self.geolocation_accuracy or 10.0),
|
|
57
|
+
}
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, data: Dict[str, Any]) -> "DeviceProfile":
|
|
62
|
+
"""Instantiate a DeviceProfile strictly from parsed dictionary data."""
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
raise ValidationError(f"Expected dict for DeviceProfile, got {type(data).__name__}")
|
|
65
|
+
|
|
66
|
+
viewport = data.get("viewport") or {}
|
|
67
|
+
vp_width = data.get("viewport_width") or viewport.get("width")
|
|
68
|
+
vp_height = data.get("viewport_height") or viewport.get("height")
|
|
69
|
+
|
|
70
|
+
geo = data.get("geolocation") or {}
|
|
71
|
+
geo_lat = data.get("geolocation_latitude", geo.get("latitude"))
|
|
72
|
+
geo_lon = data.get("geolocation_longitude", geo.get("longitude"))
|
|
73
|
+
geo_acc = data.get("geolocation_accuracy", geo.get("accuracy"))
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
device_name=str(data.get("device_name", "")),
|
|
77
|
+
user_agent=str(data.get("user_agent", "")),
|
|
78
|
+
viewport_width=int(vp_width) if vp_width is not None else 0,
|
|
79
|
+
viewport_height=int(vp_height) if vp_height is not None else 0,
|
|
80
|
+
device_scale_factor=float(data.get("device_scale_factor", 1.0)),
|
|
81
|
+
is_mobile=bool(data.get("is_mobile", False)),
|
|
82
|
+
has_touch=bool(data.get("has_touch", False)),
|
|
83
|
+
locale=str(data.get("locale", "en-US")),
|
|
84
|
+
timezone_id=str(data.get("timezone_id", "UTC")),
|
|
85
|
+
geolocation_latitude=float(geo_lat) if geo_lat is not None else None,
|
|
86
|
+
geolocation_longitude=float(geo_lon) if geo_lon is not None else None,
|
|
87
|
+
geolocation_accuracy=float(geo_acc) if geo_acc is not None else None,
|
|
88
|
+
permissions=list(data.get("permissions", [])),
|
|
89
|
+
platform=str(data.get("platform", "Win32")),
|
|
90
|
+
vendor=str(data.get("vendor", "Google Inc.")),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
94
|
+
"""Convert DeviceProfile to serializable dictionary."""
|
|
95
|
+
d: Dict[str, Any] = {
|
|
96
|
+
"device_name": self.device_name,
|
|
97
|
+
"user_agent": self.user_agent,
|
|
98
|
+
"viewport": self.viewport_dict(),
|
|
99
|
+
"device_scale_factor": self.device_scale_factor,
|
|
100
|
+
"is_mobile": self.is_mobile,
|
|
101
|
+
"has_touch": self.has_touch,
|
|
102
|
+
"locale": self.locale,
|
|
103
|
+
"timezone_id": self.timezone_id,
|
|
104
|
+
"permissions": self.permissions,
|
|
105
|
+
"platform": self.platform,
|
|
106
|
+
"vendor": self.vendor,
|
|
107
|
+
}
|
|
108
|
+
geo = self.geolocation_dict()
|
|
109
|
+
if geo:
|
|
110
|
+
d["geolocation"] = geo
|
|
111
|
+
return d
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class InputField:
|
|
116
|
+
"""Inspected text or numeric input element."""
|
|
117
|
+
id: str
|
|
118
|
+
label: str
|
|
119
|
+
type: str = "text"
|
|
120
|
+
required: bool = False
|
|
121
|
+
value: str = ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class DropdownField:
|
|
126
|
+
"""Inspected select dropdown or combobox picklist."""
|
|
127
|
+
id: str
|
|
128
|
+
label: str
|
|
129
|
+
type: str = "select"
|
|
130
|
+
required: bool = False
|
|
131
|
+
value: str = ""
|
|
132
|
+
trigger_id: str = ""
|
|
133
|
+
sample_options: List[str] = field(default_factory=list)
|
|
134
|
+
options_count: int = 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class ChoiceGroup:
|
|
139
|
+
"""Inspected radio or checkbox group for a question."""
|
|
140
|
+
question: str
|
|
141
|
+
type: str = "radio"
|
|
142
|
+
required: bool = False
|
|
143
|
+
options: List[str] = field(default_factory=list)
|
|
144
|
+
selected: Optional[str] = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class FileUploadField:
|
|
149
|
+
"""Inspected file upload zone or attachment input."""
|
|
150
|
+
id: str
|
|
151
|
+
label: str
|
|
152
|
+
required: bool = False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class ActionButton:
|
|
157
|
+
"""Inspected form action button."""
|
|
158
|
+
id: str
|
|
159
|
+
text: str
|
|
160
|
+
action: str = "action"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class InspectionResult:
|
|
165
|
+
"""Consolidated form inspection schema."""
|
|
166
|
+
title: str = ""
|
|
167
|
+
url: str = ""
|
|
168
|
+
sections: List[str] = field(default_factory=list)
|
|
169
|
+
inputs: List[InputField] = field(default_factory=list)
|
|
170
|
+
dropdowns: List[DropdownField] = field(default_factory=list)
|
|
171
|
+
choices: List[ChoiceGroup] = field(default_factory=list)
|
|
172
|
+
file_uploads: List[FileUploadField] = field(default_factory=list)
|
|
173
|
+
already_uploaded_files: List[str] = field(default_factory=list)
|
|
174
|
+
buttons: List[ActionButton] = field(default_factory=list)
|
|
175
|
+
|
|
176
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
177
|
+
"""Convert to JSON-serializable dictionary matching legacy schema format."""
|
|
178
|
+
return {
|
|
179
|
+
"title": self.title,
|
|
180
|
+
"url": self.url,
|
|
181
|
+
"sections": self.sections,
|
|
182
|
+
"inputs": [vars(i) for i in self.inputs],
|
|
183
|
+
"dropdowns": [vars(d) for d in self.dropdowns],
|
|
184
|
+
"choices": [vars(c) for c in self.choices],
|
|
185
|
+
"file_uploads": [vars(f) for f in self.file_uploads],
|
|
186
|
+
"already_uploaded_files": self.already_uploaded_files,
|
|
187
|
+
"buttons": [vars(b) for b in self.buttons],
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_dict(cls, data: Dict[str, Any]) -> "InspectionResult":
|
|
192
|
+
"""Instantiate DTO from raw inspection dictionary."""
|
|
193
|
+
return cls(
|
|
194
|
+
title=data.get("title", ""),
|
|
195
|
+
url=data.get("url", ""),
|
|
196
|
+
sections=list(data.get("sections", [])),
|
|
197
|
+
inputs=[InputField(**i) if isinstance(i, dict) else i for i in data.get("inputs", [])],
|
|
198
|
+
dropdowns=[DropdownField(**d) if isinstance(d, dict) else d for d in data.get("dropdowns", [])],
|
|
199
|
+
choices=[ChoiceGroup(**c) if isinstance(c, dict) else c for c in data.get("choices", [])],
|
|
200
|
+
file_uploads=[FileUploadField(**f) if isinstance(f, dict) else f for f in data.get("file_uploads", [])],
|
|
201
|
+
already_uploaded_files=list(data.get("already_uploaded_files", [])),
|
|
202
|
+
buttons=[ActionButton(**b) if isinstance(b, dict) else b for b in data.get("buttons", [])],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class FillSummary:
|
|
208
|
+
"""Summary record for batch sequential form filling."""
|
|
209
|
+
confirmed: List[Tuple[str, Any]] = field(default_factory=list)
|
|
210
|
+
unconfirmed: List[Tuple[str, Any]] = field(default_factory=list)
|
|
211
|
+
failed: List[Tuple[str, Any]] = field(default_factory=list)
|
|
212
|
+
|
|
213
|
+
def to_dict(self) -> Dict[str, List[Tuple[str, Any]]]:
|
|
214
|
+
"""Return standard dictionary format matching legacy API."""
|
|
215
|
+
return {
|
|
216
|
+
"confirmed": list(self.confirmed),
|
|
217
|
+
"unconfirmed": list(self.unconfirmed),
|
|
218
|
+
"failed": list(self.failed),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class ReactiveClickOutcome:
|
|
224
|
+
"""Outcome and reactive environment mutations triggered by a button press."""
|
|
225
|
+
success: bool
|
|
226
|
+
target: str
|
|
227
|
+
redirected: bool = False
|
|
228
|
+
new_window: bool = False
|
|
229
|
+
new_url: Optional[str] = None
|
|
230
|
+
modal_opened: bool = False
|
|
231
|
+
modal_text: str = ""
|
|
232
|
+
new_inputs_count: int = 0
|
|
233
|
+
schema: Optional[Dict[str, Any]] = None
|
|
234
|
+
alerts: List[str] = field(default_factory=list)
|
|
235
|
+
|
|
236
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
237
|
+
"""Convert to standard dictionary format matching legacy API."""
|
|
238
|
+
return {
|
|
239
|
+
"success": self.success,
|
|
240
|
+
"target": self.target,
|
|
241
|
+
"redirected": self.redirected,
|
|
242
|
+
"new_window": self.new_window,
|
|
243
|
+
"new_url": self.new_url,
|
|
244
|
+
"modal_opened": self.modal_opened,
|
|
245
|
+
"modal_text": self.modal_text,
|
|
246
|
+
"new_inputs_count": self.new_inputs_count,
|
|
247
|
+
"schema": self.schema,
|
|
248
|
+
"alerts": self.alerts,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class ApplyExecutionResult:
|
|
254
|
+
"""Structured result returned upon completion of an apply flow."""
|
|
255
|
+
confirmed_fields: List[Tuple[str, Any]] = field(default_factory=list)
|
|
256
|
+
unconfirmed_fields: List[Tuple[str, Any]] = field(default_factory=list)
|
|
257
|
+
failed_fields: List[Tuple[str, Any]] = field(default_factory=list)
|
|
258
|
+
final_url: str = ""
|
|
259
|
+
validation_errors: List[str] = field(default_factory=list)
|
|
260
|
+
submitted: bool = False
|
|
261
|
+
confirmation_screenshot: Optional[str] = None
|
|
262
|
+
|
|
263
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
264
|
+
"""Convert to dictionary matching standard serialization."""
|
|
265
|
+
return {
|
|
266
|
+
"confirmed_fields": list(self.confirmed_fields),
|
|
267
|
+
"unconfirmed_fields": list(self.unconfirmed_fields),
|
|
268
|
+
"failed_fields": list(self.failed_fields),
|
|
269
|
+
"final_url": self.final_url,
|
|
270
|
+
"validation_errors": list(self.validation_errors),
|
|
271
|
+
"submitted": self.submitted,
|
|
272
|
+
"confirmation_screenshot": self.confirmation_screenshot,
|
|
273
|
+
}
|
engine.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Universal Form Engine Facade.
|
|
2
|
+
|
|
3
|
+
Maintains 100% backwards-compatible external behavior while delegating to
|
|
4
|
+
cohesive domain services and adapters.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
from playwright.sync_api import Browser, BrowserContext, Page
|
|
11
|
+
|
|
12
|
+
from adapters.browser_adapter import BrowserAdapter
|
|
13
|
+
from adapters.upload_adapter import UploadAdapter
|
|
14
|
+
from config.settings import Settings, get_settings, load_device_profile
|
|
15
|
+
from core.models import DeviceProfile
|
|
16
|
+
from services.auth_navigation_service import AuthNavigationService
|
|
17
|
+
from services.field_interaction_service import FieldInteractionService
|
|
18
|
+
from services.form_filler_service import FormFillerService
|
|
19
|
+
from services.inspection_formatter_service import InspectionFormatterService
|
|
20
|
+
from services.inspection_service import InspectionService
|
|
21
|
+
from services.reactive_interaction_service import ReactiveInteractionService
|
|
22
|
+
from validators.form_validator import FormValidator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UniversalFormEngine:
|
|
26
|
+
"""Universal Form Engine facade coordinating browser lifecycle, inspection, and interaction services."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
headless: Optional[bool] = None,
|
|
31
|
+
settings: Optional[Settings] = None,
|
|
32
|
+
device_profile: Optional[DeviceProfile] = None,
|
|
33
|
+
viewport: Optional[dict] = None,
|
|
34
|
+
user_agent: Optional[str] = None,
|
|
35
|
+
):
|
|
36
|
+
self.settings = settings or get_settings()
|
|
37
|
+
self.device_profile = device_profile or load_device_profile()
|
|
38
|
+
|
|
39
|
+
if viewport:
|
|
40
|
+
self.device_profile.viewport_width = viewport.get("width", self.device_profile.viewport_width)
|
|
41
|
+
self.device_profile.viewport_height = viewport.get("height", self.device_profile.viewport_height)
|
|
42
|
+
if user_agent:
|
|
43
|
+
self.device_profile.user_agent = user_agent
|
|
44
|
+
|
|
45
|
+
self.headless = headless if headless is not None else self.settings.headless
|
|
46
|
+
self.viewport = self.device_profile.viewport_dict()
|
|
47
|
+
self.user_agent = self.device_profile.user_agent
|
|
48
|
+
|
|
49
|
+
self.browser_adapter = BrowserAdapter(
|
|
50
|
+
headless=self.headless,
|
|
51
|
+
settings=self.settings,
|
|
52
|
+
device_profile=self.device_profile,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Service instances initialized upon start()
|
|
56
|
+
self.auth_service: Optional[AuthNavigationService] = None
|
|
57
|
+
self.inspection_service: Optional[InspectionService] = None
|
|
58
|
+
self.field_service: Optional[FieldInteractionService] = None
|
|
59
|
+
self.filler_service: Optional[FormFillerService] = None
|
|
60
|
+
self.upload_adapter: Optional[UploadAdapter] = None
|
|
61
|
+
self.validator: Optional[FormValidator] = None
|
|
62
|
+
self.reactive_service: Optional[ReactiveInteractionService] = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def browser(self) -> Optional[Browser]:
|
|
66
|
+
"""Underlying Playwright Browser."""
|
|
67
|
+
return self.browser_adapter.browser
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def context(self) -> Optional[BrowserContext]:
|
|
71
|
+
"""Active Playwright BrowserContext."""
|
|
72
|
+
return self.browser_adapter.context
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def page(self) -> Optional[Page]:
|
|
76
|
+
"""Active Playwright Page."""
|
|
77
|
+
return self.browser_adapter.page
|
|
78
|
+
|
|
79
|
+
@page.setter
|
|
80
|
+
def page(self, p: Page) -> None:
|
|
81
|
+
self.browser_adapter.page = p
|
|
82
|
+
if self._services_ready():
|
|
83
|
+
self._bind_services(p)
|
|
84
|
+
|
|
85
|
+
def _services_ready(self) -> bool:
|
|
86
|
+
return self.auth_service is not None
|
|
87
|
+
|
|
88
|
+
def _bind_services(self, active_page: Page) -> None:
|
|
89
|
+
"""Binds active page to services and adapters."""
|
|
90
|
+
ctx = self.context
|
|
91
|
+
assert ctx is not None
|
|
92
|
+
self.auth_service = AuthNavigationService(active_page, ctx)
|
|
93
|
+
self.inspection_service = InspectionService(active_page)
|
|
94
|
+
self.field_service = FieldInteractionService(active_page)
|
|
95
|
+
self.filler_service = FormFillerService(active_page, self.field_service)
|
|
96
|
+
self.upload_adapter = UploadAdapter(active_page)
|
|
97
|
+
self.validator = FormValidator(active_page)
|
|
98
|
+
self.reactive_service = ReactiveInteractionService(
|
|
99
|
+
page=active_page,
|
|
100
|
+
context=ctx,
|
|
101
|
+
inspection_service=self.inspection_service,
|
|
102
|
+
form_validator=self.validator,
|
|
103
|
+
on_active_page_change=self._on_active_page_change,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _on_active_page_change(self, new_page: Page) -> None:
|
|
107
|
+
self.browser_adapter.page = new_page
|
|
108
|
+
self._bind_services(new_page)
|
|
109
|
+
|
|
110
|
+
def start(self, cookies_path: Optional[str] = None) -> None:
|
|
111
|
+
"""Starts Playwright browser instance, initializes context, and prepares services."""
|
|
112
|
+
self.browser_adapter.start(cookies_path=cookies_path)
|
|
113
|
+
assert self.page is not None
|
|
114
|
+
self._bind_services(self.page)
|
|
115
|
+
|
|
116
|
+
def stop(self) -> None:
|
|
117
|
+
"""Closes browser context and Playwright instance."""
|
|
118
|
+
self.browser_adapter.stop()
|
|
119
|
+
|
|
120
|
+
def navigate(self, url: str, wait_timeout: Optional[int] = None) -> None:
|
|
121
|
+
"""Navigates to URL and waits for DOM content loaded."""
|
|
122
|
+
self.browser_adapter.navigate(url=url, wait_timeout=wait_timeout)
|
|
123
|
+
|
|
124
|
+
def handle_login_if_needed(self, email: str, password: str, cookies_save_path: Optional[str] = None) -> bool:
|
|
125
|
+
"""Detects if page is a login/sign-in page and completes authentication automatically."""
|
|
126
|
+
assert self.auth_service is not None, "Engine not started."
|
|
127
|
+
return self.auth_service.handle_login_if_needed(email, password, cookies_save_path)
|
|
128
|
+
|
|
129
|
+
def bypass_job_landing_portal(self) -> bool:
|
|
130
|
+
"""Generic helper: Clicks Apply Now on portal landing pages to enter ATS application."""
|
|
131
|
+
assert self.auth_service is not None, "Engine not started."
|
|
132
|
+
res = self.auth_service.bypass_job_landing_portal()
|
|
133
|
+
if self.page != self.auth_service.page:
|
|
134
|
+
self.page = self.auth_service.page
|
|
135
|
+
return res
|
|
136
|
+
|
|
137
|
+
def wait_for_form_ready(self, timeout_ms: Optional[int] = None) -> None:
|
|
138
|
+
"""Waits for form elements to be rendered in DOM, dismissing 'Loading...' state."""
|
|
139
|
+
assert self.auth_service is not None, "Engine not started."
|
|
140
|
+
effective_timeout = timeout_ms if timeout_ms is not None else self.settings.form_ready_timeout_ms
|
|
141
|
+
self.auth_service.wait_for_form_ready(timeout_ms=effective_timeout)
|
|
142
|
+
|
|
143
|
+
def expand_all_sections(self) -> int:
|
|
144
|
+
"""Safely expands any collapsed form sections without toggling already-open ones."""
|
|
145
|
+
assert self.auth_service is not None, "Engine not started."
|
|
146
|
+
return self.auth_service.expand_all_sections(wait_ms=self.settings.expand_section_wait_ms)
|
|
147
|
+
|
|
148
|
+
def fill_form_sequential(self, field_mappings: List[Tuple[str, Any]]) -> Dict[str, List[Tuple[str, Any]]]:
|
|
149
|
+
"""Universal sequential form filler (section-by-section + global fallback)."""
|
|
150
|
+
assert self.filler_service is not None, "Engine not started."
|
|
151
|
+
return self.filler_service.fill_form_sequential(field_mappings)
|
|
152
|
+
|
|
153
|
+
def inspect(self, unpack_options: bool = True) -> Dict[str, Any]:
|
|
154
|
+
"""Deep inspection of the active page form components and schema."""
|
|
155
|
+
assert self.auth_service is not None and self.inspection_service is not None, "Engine not started."
|
|
156
|
+
self.wait_for_form_ready()
|
|
157
|
+
self.expand_all_sections()
|
|
158
|
+
return self.inspection_service.inspect(unpack_options=unpack_options)
|
|
159
|
+
|
|
160
|
+
def format_inspection_summary(self, data: Dict[str, Any]) -> str:
|
|
161
|
+
"""Returns a concise, human-readable summary of the inspected form."""
|
|
162
|
+
return InspectionFormatterService.format_summary(data)
|
|
163
|
+
|
|
164
|
+
def set_field(self, target_identifier: str, value: Any) -> bool:
|
|
165
|
+
"""Sets form field value across text, picklist, radio, checkbox widgets."""
|
|
166
|
+
assert self.field_service is not None, "Engine not started."
|
|
167
|
+
return self.field_service.set_field(target_identifier, value)
|
|
168
|
+
|
|
169
|
+
def _playwright_click_fallback(self, target: str, value: Any) -> bool:
|
|
170
|
+
"""Fallback setter using Playwright native locators (click / fill)."""
|
|
171
|
+
assert self.field_service is not None, "Engine not started."
|
|
172
|
+
return self.field_service._playwright_click_fallback(target, value)
|
|
173
|
+
|
|
174
|
+
def verify_field_value(self, target: str, expected: Any) -> bool:
|
|
175
|
+
"""Reads back current DOM value of field and checks if it matches expected."""
|
|
176
|
+
assert self.field_service is not None, "Engine not started."
|
|
177
|
+
return self.field_service.verify_field_value(target, expected)
|
|
178
|
+
|
|
179
|
+
def upload_document(self, file_path: str, document_type_keyword: str = "") -> bool:
|
|
180
|
+
"""Universally attaches a document to file inputs or custom ATS chooser triggers."""
|
|
181
|
+
assert self.upload_adapter is not None, "Engine not started."
|
|
182
|
+
return self.upload_adapter.upload_document(file_path, document_type_keyword)
|
|
183
|
+
|
|
184
|
+
def click_button(self, button_text_or_purpose: str) -> Dict[str, Any]:
|
|
185
|
+
"""Intelligently clicks a button/link and monitors reactive environmental mutations."""
|
|
186
|
+
assert self.reactive_service is not None, "Engine not started."
|
|
187
|
+
outcome = self.reactive_service.click_button(button_text_or_purpose)
|
|
188
|
+
if outcome.get("new_window") and self.page != self.reactive_service.page:
|
|
189
|
+
self.page = self.reactive_service.page
|
|
190
|
+
return outcome
|
|
191
|
+
|
|
192
|
+
def get_validation_errors(self) -> List[str]:
|
|
193
|
+
"""Collects any error alerts, validation messages, or warning texts currently shown."""
|
|
194
|
+
assert self.validator is not None, "Engine not started."
|
|
195
|
+
return self.validator.get_validation_errors()
|
|
196
|
+
|
|
197
|
+
def capture_screenshot(self, output_path: str, full_page: bool = False) -> None:
|
|
198
|
+
"""Saves a screenshot of the current page."""
|
|
199
|
+
self.browser_adapter.capture_screenshot(output_path=output_path, full_page=full_page)
|
exceptions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Backwards compatibility shim for exceptions."""
|
|
2
|
+
|
|
3
|
+
from core.exceptions import (
|
|
4
|
+
UniversalApplierError,
|
|
5
|
+
ConfigurationError,
|
|
6
|
+
ValidationError,
|
|
7
|
+
AutomationError,
|
|
8
|
+
ElementNotFoundError,
|
|
9
|
+
InteractionTimeoutError,
|
|
10
|
+
DocumentUploadError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"UniversalApplierError",
|
|
16
|
+
"ConfigurationError",
|
|
17
|
+
"ValidationError",
|
|
18
|
+
"AutomationError",
|
|
19
|
+
"ElementNotFoundError",
|
|
20
|
+
"InteractionTimeoutError",
|
|
21
|
+
"DocumentUploadError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
]
|