browser-handoff 0.1.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.
- browser_handoff/__init__.py +46 -0
- browser_handoff/config/__init__.py +10 -0
- browser_handoff/config/loader.py +102 -0
- browser_handoff/detection/__init__.py +172 -0
- browser_handoff/detection/base.py +70 -0
- browser_handoff/detection/combinators.py +193 -0
- browser_handoff/detection/content.py +142 -0
- browser_handoff/detection/element.py +248 -0
- browser_handoff/detection/llm.py +176 -0
- browser_handoff/detection/url.py +183 -0
- browser_handoff/handoff.py +376 -0
- browser_handoff/notifiers/__init__.py +44 -0
- browser_handoff/notifiers/base.py +50 -0
- browser_handoff/notifiers/discord.py +165 -0
- browser_handoff/notifiers/email.py +148 -0
- browser_handoff/notifiers/slack.py +136 -0
- browser_handoff/scenario.py +47 -0
- browser_handoff/server/__init__.py +12 -0
- browser_handoff/server/config.py +67 -0
- browser_handoff/server/session.py +44 -0
- browser_handoff/server/streaming.py +552 -0
- browser_handoff/templates/intervention.html +410 -0
- browser_handoff/templates/notification.jinja +7 -0
- browser_handoff-0.1.0.dist-info/METADATA +233 -0
- browser_handoff-0.1.0.dist-info/RECORD +27 -0
- browser_handoff-0.1.0.dist-info/WHEEL +4 -0
- browser_handoff-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""browser-handoff — Human-in-the-loop fallback for browser automation.
|
|
2
|
+
|
|
3
|
+
A standalone library that pauses your Playwright automation, hands the
|
|
4
|
+
page off to a human via CDP-based streaming, and resumes when they're
|
|
5
|
+
done — for OAuth, 2FA, payments, identity checks, or any flow that
|
|
6
|
+
requires a human.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from browser_handoff import Handoff, Scenario
|
|
10
|
+
from browser_handoff.detection import Detection
|
|
11
|
+
|
|
12
|
+
handoff = Handoff(
|
|
13
|
+
scenarios=[
|
|
14
|
+
Scenario(
|
|
15
|
+
name="login_required",
|
|
16
|
+
trigger=Detection.element(present=['input[type="email"]']),
|
|
17
|
+
complete=Detection.url(path_contains=["/dashboard"]),
|
|
18
|
+
),
|
|
19
|
+
],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
result = await handoff.run(page)
|
|
23
|
+
if result.was_blocked and not result.timed_out:
|
|
24
|
+
print(f"Human completed: {result.scenario_name}")
|
|
25
|
+
await bot_logic(page)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
29
|
+
|
|
30
|
+
from .handoff import Handoff, HandoffResult
|
|
31
|
+
from .scenario import Scenario
|
|
32
|
+
from .server import ServerConfig
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
__version__ = version("browser-handoff")
|
|
36
|
+
except PackageNotFoundError:
|
|
37
|
+
# Package is not installed (e.g. running from a checkout without `pip install -e .`).
|
|
38
|
+
__version__ = "0.0.0+unknown"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Handoff",
|
|
42
|
+
"HandoffResult",
|
|
43
|
+
"Scenario",
|
|
44
|
+
"ServerConfig",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Configuration loading with JSON/YAML support and env var interpolation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def interpolate_env_vars(config: Any) -> Any:
|
|
13
|
+
"""Recursively interpolate ${VAR} patterns with environment variables.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
config: Configuration dict, list, or value to interpolate.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
The interpolated configuration.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
config = {"url": "${BASE_URL}/api"}
|
|
23
|
+
result = interpolate_env_vars(config)
|
|
24
|
+
# If BASE_URL=https://example.com, result is {"url": "https://example.com/api"}
|
|
25
|
+
"""
|
|
26
|
+
pattern = re.compile(r"\$\{([^}]+)\}")
|
|
27
|
+
|
|
28
|
+
def replace(value: Any) -> Any:
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
return pattern.sub(
|
|
31
|
+
lambda m: os.environ.get(m.group(1), m.group(0)), value
|
|
32
|
+
)
|
|
33
|
+
elif isinstance(value, dict):
|
|
34
|
+
return {k: replace(v) for k, v in value.items()}
|
|
35
|
+
elif isinstance(value, list):
|
|
36
|
+
return [replace(v) for v in value]
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
return replace(config)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_json(content: str) -> dict[str, Any]:
|
|
43
|
+
"""Load configuration from JSON string.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
content: JSON string content.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Parsed and interpolated configuration dict.
|
|
50
|
+
"""
|
|
51
|
+
config = json.loads(content)
|
|
52
|
+
return interpolate_env_vars(config)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_yaml(content: str) -> dict[str, Any]:
|
|
56
|
+
"""Load configuration from YAML string.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
content: YAML string content.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Parsed and interpolated configuration dict.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ImportError: If PyYAML is not installed.
|
|
66
|
+
"""
|
|
67
|
+
import yaml
|
|
68
|
+
|
|
69
|
+
config = yaml.safe_load(content)
|
|
70
|
+
return interpolate_env_vars(config)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_file(path: str | Path) -> dict[str, Any]:
|
|
74
|
+
"""Load configuration from a JSON or YAML file.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
path: Path to the configuration file.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Parsed and interpolated configuration dict.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: If file extension is not .json, .yaml, or .yml.
|
|
84
|
+
FileNotFoundError: If file does not exist.
|
|
85
|
+
"""
|
|
86
|
+
path = Path(path)
|
|
87
|
+
|
|
88
|
+
if not path.exists():
|
|
89
|
+
raise FileNotFoundError(f"Configuration file not found: {path}")
|
|
90
|
+
|
|
91
|
+
content = path.read_text(encoding="utf-8")
|
|
92
|
+
suffix = path.suffix.lower()
|
|
93
|
+
|
|
94
|
+
if suffix == ".json":
|
|
95
|
+
return load_json(content)
|
|
96
|
+
elif suffix in (".yaml", ".yml"):
|
|
97
|
+
return load_yaml(content)
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Unsupported configuration file format: {suffix}. "
|
|
101
|
+
"Use .json, .yaml, or .yml"
|
|
102
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Detection types and factory for browser-handoff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import BaseDetection, DetectionResult
|
|
8
|
+
from .combinators import AllDetection, AnyDetection, NotDetection
|
|
9
|
+
from .content import ContentDetection
|
|
10
|
+
from .element import ElementDetection
|
|
11
|
+
from .url import UrlDetection
|
|
12
|
+
|
|
13
|
+
# LLM detection is optional
|
|
14
|
+
try:
|
|
15
|
+
from .llm import LLMDetection
|
|
16
|
+
|
|
17
|
+
_HAS_LLM = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
_HAS_LLM = False
|
|
20
|
+
LLMDetection = None # type: ignore
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Detection:
|
|
24
|
+
"""Factory class for creating detection instances.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
# Content detection
|
|
28
|
+
detection = Detection.content(
|
|
29
|
+
title_contains=["Sign In"],
|
|
30
|
+
body_contains=["please log in"],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# URL detection
|
|
34
|
+
detection = Detection.url(
|
|
35
|
+
host_equals=["localhost"],
|
|
36
|
+
path_matches=["/callback"],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Combinators
|
|
40
|
+
detection = Detection.all([
|
|
41
|
+
Detection.element(present=["#dashboard"]),
|
|
42
|
+
Detection.not_(Detection.element(present=[".error"])),
|
|
43
|
+
])
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def content(
|
|
48
|
+
title_contains: list[str] | None = None,
|
|
49
|
+
title_matches: list[str] | None = None,
|
|
50
|
+
body_contains: list[str] | None = None,
|
|
51
|
+
body_matches: list[str] | None = None,
|
|
52
|
+
) -> ContentDetection:
|
|
53
|
+
"""Create a content-based detection."""
|
|
54
|
+
return ContentDetection(
|
|
55
|
+
title_contains=title_contains or [],
|
|
56
|
+
title_matches=title_matches or [],
|
|
57
|
+
body_contains=body_contains or [],
|
|
58
|
+
body_matches=body_matches or [],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def url(
|
|
63
|
+
scheme_equals: str | None = None,
|
|
64
|
+
host_equals: list[str] | None = None,
|
|
65
|
+
host_not_equals: list[str] | None = None,
|
|
66
|
+
path_matches: list[str] | None = None,
|
|
67
|
+
path_contains: list[str] | None = None,
|
|
68
|
+
query_contains: list[str] | None = None,
|
|
69
|
+
) -> UrlDetection:
|
|
70
|
+
"""Create a URL-based detection."""
|
|
71
|
+
return UrlDetection(
|
|
72
|
+
scheme_equals=scheme_equals,
|
|
73
|
+
host_equals=host_equals or [],
|
|
74
|
+
host_not_equals=host_not_equals or [],
|
|
75
|
+
path_matches=path_matches or [],
|
|
76
|
+
path_contains=path_contains or [],
|
|
77
|
+
query_contains=query_contains or [],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def element(
|
|
82
|
+
present: list[str] | None = None,
|
|
83
|
+
missing: list[str] | None = None,
|
|
84
|
+
visible: list[str] | None = None,
|
|
85
|
+
hidden: list[str] | None = None,
|
|
86
|
+
) -> ElementDetection:
|
|
87
|
+
"""Create an element-based detection."""
|
|
88
|
+
return ElementDetection(
|
|
89
|
+
present=present or [],
|
|
90
|
+
missing=missing or [],
|
|
91
|
+
visible=visible or [],
|
|
92
|
+
hidden=hidden or [],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def llm(
|
|
97
|
+
model: str = "anthropic/claude-sonnet-4-5",
|
|
98
|
+
condition: str = "",
|
|
99
|
+
api_key: str | None = None,
|
|
100
|
+
) -> "LLMDetection":
|
|
101
|
+
"""Create an LLM-based detection.
|
|
102
|
+
|
|
103
|
+
Requires the 'llm' extra: pip install browser-handoff[llm]
|
|
104
|
+
|
|
105
|
+
If `api_key` is None, litellm picks up the key from the provider's
|
|
106
|
+
env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, ...).
|
|
107
|
+
"""
|
|
108
|
+
if not _HAS_LLM:
|
|
109
|
+
raise ImportError(
|
|
110
|
+
"LLM detection requires 'litellm' package. "
|
|
111
|
+
"Install with: pip install browser-handoff[llm]"
|
|
112
|
+
)
|
|
113
|
+
return LLMDetection(model=model, condition=condition, api_key=api_key)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def all(conditions: list[BaseDetection]) -> AllDetection:
|
|
117
|
+
"""Create an AND combinator (all conditions must match)."""
|
|
118
|
+
return AllDetection(conditions=conditions)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def any(conditions: list[BaseDetection]) -> AnyDetection:
|
|
122
|
+
"""Create an OR combinator (any condition must match)."""
|
|
123
|
+
return AnyDetection(conditions=conditions)
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def not_(condition: BaseDetection) -> NotDetection:
|
|
127
|
+
"""Create a NOT combinator (invert condition)."""
|
|
128
|
+
return NotDetection(condition=condition)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def from_dict(data: dict[str, Any]) -> BaseDetection:
|
|
132
|
+
"""Create a detection from dictionary representation.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
data: Dictionary with 'type' key and type-specific fields.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The appropriate detection instance.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If the detection type is unknown.
|
|
142
|
+
"""
|
|
143
|
+
detection_type = data.get("type")
|
|
144
|
+
|
|
145
|
+
if detection_type == "content":
|
|
146
|
+
return ContentDetection.from_dict(data)
|
|
147
|
+
elif detection_type == "url":
|
|
148
|
+
return UrlDetection.from_dict(data)
|
|
149
|
+
elif detection_type == "element":
|
|
150
|
+
return ElementDetection.from_dict(data)
|
|
151
|
+
elif detection_type == "llm":
|
|
152
|
+
if not _HAS_LLM:
|
|
153
|
+
raise ImportError(
|
|
154
|
+
"LLM detection requires 'litellm' package. "
|
|
155
|
+
"Install with: pip install browser-handoff[llm]"
|
|
156
|
+
)
|
|
157
|
+
return LLMDetection.from_dict(data)
|
|
158
|
+
elif detection_type == "all":
|
|
159
|
+
conditions = [Detection.from_dict(c) for c in data.get("conditions", [])]
|
|
160
|
+
return AllDetection(conditions=conditions)
|
|
161
|
+
elif detection_type == "any":
|
|
162
|
+
conditions = [Detection.from_dict(c) for c in data.get("conditions", [])]
|
|
163
|
+
return AnyDetection(conditions=conditions)
|
|
164
|
+
elif detection_type == "not":
|
|
165
|
+
condition_data = data.get("condition")
|
|
166
|
+
condition = Detection.from_dict(condition_data) if condition_data else None
|
|
167
|
+
return NotDetection(condition=condition)
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError(f"Unknown detection type: {detection_type}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = ["Detection", "DetectionResult"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Base detection classes and result types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from playwright.async_api import Page
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DetectionResult:
|
|
15
|
+
"""Result of a detection check."""
|
|
16
|
+
|
|
17
|
+
matched: bool
|
|
18
|
+
detection_type: str
|
|
19
|
+
reason: str = ""
|
|
20
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def __bool__(self) -> bool:
|
|
23
|
+
return self.matched
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaseDetection(ABC):
|
|
27
|
+
"""Abstract base class for all detection types."""
|
|
28
|
+
|
|
29
|
+
detection_type: str = "base"
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def register_listeners(
|
|
33
|
+
self,
|
|
34
|
+
page: "Page",
|
|
35
|
+
callback: Callable[["BaseDetection"], Coroutine[Any, Any, None]],
|
|
36
|
+
) -> Callable[[], None]:
|
|
37
|
+
"""Register event listeners that call callback when detection should be checked.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
page: The Playwright page to monitor.
|
|
41
|
+
callback: Async callback to invoke when detection should be checked.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A cleanup function that removes the registered listeners.
|
|
45
|
+
"""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def check(self, page: "Page") -> DetectionResult:
|
|
50
|
+
"""Check if detection condition is met.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
page: The Playwright page to check.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
DetectionResult indicating whether condition was matched.
|
|
57
|
+
"""
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, Any]:
|
|
61
|
+
"""Serialize detection to dictionary format."""
|
|
62
|
+
return {"type": self.detection_type}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: dict[str, Any]) -> "BaseDetection":
|
|
66
|
+
"""Deserialize detection from dictionary format.
|
|
67
|
+
|
|
68
|
+
This should be overridden by subclasses.
|
|
69
|
+
"""
|
|
70
|
+
raise NotImplementedError("Subclasses must implement from_dict")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Combinator detection types (all, any, not)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
|
7
|
+
|
|
8
|
+
from .base import BaseDetection, DetectionResult
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from playwright.async_api import Page
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AllDetection(BaseDetection):
|
|
16
|
+
"""AND logic - all conditions must match.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
detection = AllDetection(conditions=[
|
|
20
|
+
UrlDetection(path_matches=["/dashboard"]),
|
|
21
|
+
ElementDetection(present=[".user-avatar"]),
|
|
22
|
+
])
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
detection_type: str = field(default="all", init=False)
|
|
26
|
+
conditions: list[BaseDetection] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
def register_listeners(
|
|
29
|
+
self,
|
|
30
|
+
page: "Page",
|
|
31
|
+
callback: Callable[["BaseDetection"], Coroutine[Any, Any, None]],
|
|
32
|
+
) -> Callable[[], None]:
|
|
33
|
+
"""Register listeners for all child conditions."""
|
|
34
|
+
cleanups: list[Callable[[], None]] = []
|
|
35
|
+
|
|
36
|
+
for condition in self.conditions:
|
|
37
|
+
cleanup = condition.register_listeners(page, callback)
|
|
38
|
+
cleanups.append(cleanup)
|
|
39
|
+
|
|
40
|
+
def cleanup_all() -> None:
|
|
41
|
+
for cleanup in cleanups:
|
|
42
|
+
try:
|
|
43
|
+
cleanup()
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
return cleanup_all
|
|
48
|
+
|
|
49
|
+
async def check(self, page: "Page") -> DetectionResult:
|
|
50
|
+
"""Check if ALL conditions are met."""
|
|
51
|
+
for condition in self.conditions:
|
|
52
|
+
result = await condition.check(page)
|
|
53
|
+
if not result.matched:
|
|
54
|
+
return DetectionResult(
|
|
55
|
+
matched=False,
|
|
56
|
+
detection_type=self.detection_type,
|
|
57
|
+
reason=f"Condition '{condition.detection_type}' not met: {result.reason}",
|
|
58
|
+
details={"failed_condition": condition.to_dict()},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return DetectionResult(
|
|
62
|
+
matched=True,
|
|
63
|
+
detection_type=self.detection_type,
|
|
64
|
+
reason="All conditions met",
|
|
65
|
+
details={"conditions_count": len(self.conditions)},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
"""Serialize to dictionary."""
|
|
70
|
+
return {
|
|
71
|
+
"type": self.detection_type,
|
|
72
|
+
"conditions": [c.to_dict() for c in self.conditions],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class AnyDetection(BaseDetection):
|
|
78
|
+
"""OR logic - any condition must match.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
detection = AnyDetection(conditions=[
|
|
82
|
+
ElementDetection(present=["#success"]),
|
|
83
|
+
ContentDetection(body_contains=["Welcome"]),
|
|
84
|
+
])
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
detection_type: str = field(default="any", init=False)
|
|
88
|
+
conditions: list[BaseDetection] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
def register_listeners(
|
|
91
|
+
self,
|
|
92
|
+
page: "Page",
|
|
93
|
+
callback: Callable[["BaseDetection"], Coroutine[Any, Any, None]],
|
|
94
|
+
) -> Callable[[], None]:
|
|
95
|
+
"""Register listeners for all child conditions."""
|
|
96
|
+
cleanups: list[Callable[[], None]] = []
|
|
97
|
+
|
|
98
|
+
for condition in self.conditions:
|
|
99
|
+
cleanup = condition.register_listeners(page, callback)
|
|
100
|
+
cleanups.append(cleanup)
|
|
101
|
+
|
|
102
|
+
def cleanup_all() -> None:
|
|
103
|
+
for cleanup in cleanups:
|
|
104
|
+
try:
|
|
105
|
+
cleanup()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
return cleanup_all
|
|
110
|
+
|
|
111
|
+
async def check(self, page: "Page") -> DetectionResult:
|
|
112
|
+
"""Check if ANY condition is met."""
|
|
113
|
+
for condition in self.conditions:
|
|
114
|
+
result = await condition.check(page)
|
|
115
|
+
if result.matched:
|
|
116
|
+
return DetectionResult(
|
|
117
|
+
matched=True,
|
|
118
|
+
detection_type=self.detection_type,
|
|
119
|
+
reason=f"Condition '{condition.detection_type}' matched: {result.reason}",
|
|
120
|
+
details={"matched_condition": condition.to_dict()},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return DetectionResult(
|
|
124
|
+
matched=False,
|
|
125
|
+
detection_type=self.detection_type,
|
|
126
|
+
reason="No conditions matched",
|
|
127
|
+
details={"conditions_count": len(self.conditions)},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict[str, Any]:
|
|
131
|
+
"""Serialize to dictionary."""
|
|
132
|
+
return {
|
|
133
|
+
"type": self.detection_type,
|
|
134
|
+
"conditions": [c.to_dict() for c in self.conditions],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class NotDetection(BaseDetection):
|
|
140
|
+
"""Invert result of a condition.
|
|
141
|
+
|
|
142
|
+
Example:
|
|
143
|
+
detection = NotDetection(
|
|
144
|
+
condition=ElementDetection(present=[".error-message"])
|
|
145
|
+
)
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
detection_type: str = field(default="not", init=False)
|
|
149
|
+
condition: BaseDetection | None = None
|
|
150
|
+
|
|
151
|
+
def register_listeners(
|
|
152
|
+
self,
|
|
153
|
+
page: "Page",
|
|
154
|
+
callback: Callable[["BaseDetection"], Coroutine[Any, Any, None]],
|
|
155
|
+
) -> Callable[[], None]:
|
|
156
|
+
"""Register listeners for the wrapped condition."""
|
|
157
|
+
if self.condition is None:
|
|
158
|
+
return lambda: None
|
|
159
|
+
|
|
160
|
+
return self.condition.register_listeners(page, callback)
|
|
161
|
+
|
|
162
|
+
async def check(self, page: "Page") -> DetectionResult:
|
|
163
|
+
"""Check if condition is NOT met."""
|
|
164
|
+
if self.condition is None:
|
|
165
|
+
return DetectionResult(
|
|
166
|
+
matched=True,
|
|
167
|
+
detection_type=self.detection_type,
|
|
168
|
+
reason="No condition to negate",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
result = await self.condition.check(page)
|
|
172
|
+
|
|
173
|
+
if result.matched:
|
|
174
|
+
return DetectionResult(
|
|
175
|
+
matched=False,
|
|
176
|
+
detection_type=self.detection_type,
|
|
177
|
+
reason=f"Condition matched (should not): {result.reason}",
|
|
178
|
+
details={"negated_condition": self.condition.to_dict()},
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
return DetectionResult(
|
|
182
|
+
matched=True,
|
|
183
|
+
detection_type=self.detection_type,
|
|
184
|
+
reason=f"Condition not matched (as expected): {result.reason}",
|
|
185
|
+
details={"negated_condition": self.condition.to_dict()},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def to_dict(self) -> dict[str, Any]:
|
|
189
|
+
"""Serialize to dictionary."""
|
|
190
|
+
return {
|
|
191
|
+
"type": self.detection_type,
|
|
192
|
+
"condition": self.condition.to_dict() if self.condition else None,
|
|
193
|
+
}
|