aetherius 0.2.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.
- aetherius/__init__.py +42 -0
- aetherius/__main__.py +12 -0
- aetherius/_contracts/__init__.py +0 -0
- aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius/acts/__init__.py +1 -0
- aetherius/acts/_shared.py +61 -0
- aetherius/acts/continuum/__init__.py +7 -0
- aetherius/acts/continuum/actions.py +149 -0
- aetherius/acts/continuum/bridge.py +159 -0
- aetherius/acts/continuum/browser.py +164 -0
- aetherius/acts/continuum/debug_overlay.py +65 -0
- aetherius/acts/continuum/driver.py +127 -0
- aetherius/acts/continuum/human_actions.py +74 -0
- aetherius/acts/oracle/__init__.py +1 -0
- aetherius/acts/oracle/driver.py +1 -0
- aetherius/acts/oracle/locator.py +1 -0
- aetherius/acts/oracle/model.py +1 -0
- aetherius/acts/oracle/perception.py +1 -0
- aetherius/acts/phantom/__init__.py +1 -0
- aetherius/acts/phantom/driver.py +1 -0
- aetherius/acts/phantom/loop.py +1 -0
- aetherius/acts/phantom/memory.py +1 -0
- aetherius/acts/phantom/perception.py +1 -0
- aetherius/acts/phantom/planner.py +1 -0
- aetherius/acts/vector/__init__.py +1 -0
- aetherius/acts/vector/auth.py +108 -0
- aetherius/acts/vector/client.py +129 -0
- aetherius/acts/vector/driver.py +128 -0
- aetherius/builder/__init__.py +40 -0
- aetherius/builder/catalog.py +93 -0
- aetherius/builder/factory.py +243 -0
- aetherius/builder/templates.py +150 -0
- aetherius/builder/validation.py +120 -0
- aetherius/cli.py +236 -0
- aetherius/config/__init__.py +1 -0
- aetherius/config/secrets.py +71 -0
- aetherius/config/settings.py +44 -0
- aetherius/console/__init__.py +1 -0
- aetherius/console/app.py +66 -0
- aetherius/console/console.tcss +97 -0
- aetherius/console/daemon_control.py +83 -0
- aetherius/console/run_bridge.py +43 -0
- aetherius/console/screens/__init__.py +1 -0
- aetherius/console/screens/_pending.py +38 -0
- aetherius/console/screens/builder/__init__.py +1 -0
- aetherius/console/screens/builder/act_picker.py +75 -0
- aetherius/console/screens/builder/io_editor.py +167 -0
- aetherius/console/screens/builder/options_editor.py +132 -0
- aetherius/console/screens/builder/preview.py +57 -0
- aetherius/console/screens/builder/screen.py +235 -0
- aetherius/console/screens/builder/step_editor.py +249 -0
- aetherius/console/screens/catalog.py +52 -0
- aetherius/console/screens/home.py +90 -0
- aetherius/console/screens/library.py +124 -0
- aetherius/console/screens/library_scan.py +99 -0
- aetherius/console/screens/recorder.py +193 -0
- aetherius/console/screens/runs.py +138 -0
- aetherius/console/screens/sessions.py +14 -0
- aetherius/console/screens/settings.py +132 -0
- aetherius/console/screenshots.py +173 -0
- aetherius/console/theme.py +114 -0
- aetherius/console/widgets/__init__.py +1 -0
- aetherius/console/widgets/event_log.py +44 -0
- aetherius/console/widgets/form.py +101 -0
- aetherius/console/widgets/json_preview.py +18 -0
- aetherius/console/widgets/run_summary.py +82 -0
- aetherius/core/__init__.py +1 -0
- aetherius/core/actions/__init__.py +1 -0
- aetherius/core/actions/base.py +99 -0
- aetherius/core/actions/data.py +126 -0
- aetherius/core/actions/flow.py +76 -0
- aetherius/core/actions/interaction.py +109 -0
- aetherius/core/actions/navigation.py +32 -0
- aetherius/core/actions/registry.py +70 -0
- aetherius/core/actions/spec.py +42 -0
- aetherius/core/blueprint/__init__.py +1 -0
- aetherius/core/blueprint/loader.py +82 -0
- aetherius/core/blueprint/models.py +81 -0
- aetherius/core/blueprint/template.py +101 -0
- aetherius/core/blueprint/validator.py +45 -0
- aetherius/core/driver.py +36 -0
- aetherius/core/errors.py +134 -0
- aetherius/core/events/__init__.py +1 -0
- aetherius/core/events/bus.py +28 -0
- aetherius/core/events/models.py +29 -0
- aetherius/core/events/sinks.py +55 -0
- aetherius/core/extraction/__init__.py +1 -0
- aetherius/core/extraction/html_extractor.py +52 -0
- aetherius/core/extraction/json_extractor.py +125 -0
- aetherius/core/runtime/__init__.py +1 -0
- aetherius/core/runtime/context.py +61 -0
- aetherius/core/runtime/engine.py +188 -0
- aetherius/core/runtime/result.py +39 -0
- aetherius/core/runtime/selector.py +1 -0
- aetherius/models/__init__.py +1 -0
- aetherius/models/registry.py +1 -0
- aetherius/models/store/.gitkeep +0 -0
- aetherius/recorder/__init__.py +12 -0
- aetherius/recorder/_capture_js.py +80 -0
- aetherius/recorder/_gesture_js.py +40 -0
- aetherius/recorder/_names.py +20 -0
- aetherius/recorder/_overlay_js.py +262 -0
- aetherius/recorder/_playwright.py +49 -0
- aetherius/recorder/_selector_js.py +148 -0
- aetherius/recorder/_transform.py +214 -0
- aetherius/recorder/_vector_js.py +174 -0
- aetherius/recorder/base.py +92 -0
- aetherius/recorder/blueprint_recorder.py +72 -0
- aetherius/recorder/capture.py +59 -0
- aetherius/recorder/continuum_backend.py +119 -0
- aetherius/recorder/gesture_recorder.py +193 -0
- aetherius/recorder/selector_synth.py +83 -0
- aetherius/recorder/session.py +101 -0
- aetherius/recorder/vector_backend.py +158 -0
- aetherius/server/__init__.py +12 -0
- aetherius/server/app.py +37 -0
- aetherius/server/config.py +25 -0
- aetherius/server/deps.py +50 -0
- aetherius/server/jobs.py +155 -0
- aetherius/server/routes/__init__.py +1 -0
- aetherius/server/routes/blueprints.py +45 -0
- aetherius/server/routes/recorder.py +25 -0
- aetherius/server/routes/runs.py +49 -0
- aetherius/server/routes/stream.py +53 -0
- aetherius/server/schemas.py +78 -0
- aetherius/stealth/__init__.py +1 -0
- aetherius/stealth/fingerprint/__init__.py +1 -0
- aetherius/stealth/fingerprint/patch.py +38 -0
- aetherius/stealth/fingerprint/profile.py +95 -0
- aetherius/stealth/gestures/__init__.py +1 -0
- aetherius/stealth/gestures/data/human_library.json +7107 -0
- aetherius/stealth/gestures/library.py +126 -0
- aetherius/stealth/gestures/seed.py +97 -0
- aetherius/stealth/humanizer/__init__.py +1 -0
- aetherius/stealth/humanizer/input.py +89 -0
- aetherius/stealth/humanizer/keyboard.py +75 -0
- aetherius/stealth/humanizer/mouse.py +153 -0
- aetherius/stealth/humanizer/scroll.py +55 -0
- aetherius/stealth/humanizer/timing.py +54 -0
- aetherius/stealth/ml/__init__.py +1 -0
- aetherius/stealth/ml/fingerprint_model.py +1 -0
- aetherius/stealth/ml/motion_model.py +1 -0
- aetherius/stealth/policy.py +133 -0
- aetherius/stealth/session/__init__.py +1 -0
- aetherius/stealth/session/store.py +35 -0
- aetherius/stealth/session/warmup.py +52 -0
- aetherius/version.py +3 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius-0.2.0.dist-info/METADATA +606 -0
- aetherius-0.2.0.dist-info/RECORD +154 -0
- aetherius-0.2.0.dist-info/WHEEL +4 -0
- aetherius-0.2.0.dist-info/entry_points.txt +2 -0
- aetherius-0.2.0.dist-info/licenses/LICENSE +11 -0
aetherius/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Aetherius: a fixed, robust web-bot engine driven by declarative instruction files (Blueprints).
|
|
2
|
+
|
|
3
|
+
Public surface. Heavy machinery lives in subpackages and is imported lazily so that
|
|
4
|
+
``import aetherius`` stays cheap and dependency-light. Blueprints are executed in-process
|
|
5
|
+
through the :class:`Aetherius` facade, or from any language through the local daemon.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
11
|
+
|
|
12
|
+
from .version import __version__
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .core.runtime.result import Result
|
|
16
|
+
|
|
17
|
+
__all__ = ["Aetherius", "__version__"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Aetherius:
|
|
21
|
+
"""In-process entry point: load a Blueprint and run it.
|
|
22
|
+
|
|
23
|
+
The facade is deliberately thin. It defers to ``aetherius.core.runtime`` for execution
|
|
24
|
+
so that importing the package never pulls in Act-specific dependencies (Playwright, ONNX, …).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def run(
|
|
28
|
+
self,
|
|
29
|
+
blueprint: str,
|
|
30
|
+
*,
|
|
31
|
+
inputs: Mapping[str, Any] | None = None,
|
|
32
|
+
secrets: Mapping[str, str] | None = None,
|
|
33
|
+
) -> "Result":
|
|
34
|
+
"""Execute a Blueprint file and return its :class:`~aetherius.core.runtime.result.Result`.
|
|
35
|
+
|
|
36
|
+
``blueprint`` is a path to a JSON or YAML instruction file; ``inputs`` fills its declared
|
|
37
|
+
parameters and ``secrets`` provides runtime-only credentials that are never persisted.
|
|
38
|
+
"""
|
|
39
|
+
from .core.blueprint.loader import load_blueprint
|
|
40
|
+
from .core.runtime.engine import RunEngine
|
|
41
|
+
|
|
42
|
+
return RunEngine().run(load_blueprint(blueprint), inputs=inputs, secrets=secrets)
|
aetherius/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Enable ``python -m aetherius`` as an alias for the ``aetherius`` console script.
|
|
2
|
+
|
|
3
|
+
Used by the Console and the TypeScript SDK to spawn the daemon with the current interpreter, without
|
|
4
|
+
depending on the ``aetherius`` entry point being on PATH.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .cli import main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://aetherius.dev/contracts/blueprint.schema.json",
|
|
4
|
+
"title": "Aetherius Blueprint",
|
|
5
|
+
"description": "The declarative instruction file executed by Aetherius. Language-agnostic source of truth shared by the Python core and every SDK.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["aetherius", "name", "act"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"aetherius": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "Blueprint format version.",
|
|
13
|
+
"pattern": "^[0-9]+\\.[0-9]+$"
|
|
14
|
+
},
|
|
15
|
+
"name": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Dotted identifier, e.g. domain.task.",
|
|
18
|
+
"minLength": 1
|
|
19
|
+
},
|
|
20
|
+
"description": { "type": "string" },
|
|
21
|
+
"act": {
|
|
22
|
+
"description": "Execution engine.",
|
|
23
|
+
"enum": ["vector", "continuum", "oracle", "phantom"]
|
|
24
|
+
},
|
|
25
|
+
"inputs": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"description": "Declared, typed parameters that make the Blueprint reusable.",
|
|
28
|
+
"additionalProperties": { "$ref": "#/$defs/inputSpec" }
|
|
29
|
+
},
|
|
30
|
+
"secrets": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"description": "Names of runtime-only secrets. Values are injected at execution and never stored in the file.",
|
|
33
|
+
"items": { "type": "string" },
|
|
34
|
+
"uniqueItems": true
|
|
35
|
+
},
|
|
36
|
+
"vars": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"description": "Local constants (domains, paths) available for interpolation."
|
|
39
|
+
},
|
|
40
|
+
"options": { "$ref": "#/$defs/options" },
|
|
41
|
+
"vision": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"description": "Vision configuration for Oracle/Phantom.",
|
|
44
|
+
"properties": { "model": { "type": "string" } },
|
|
45
|
+
"additionalProperties": true
|
|
46
|
+
},
|
|
47
|
+
"goal": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "High-level objective for Phantom (Act IV) when the run is not fully scripted."
|
|
50
|
+
},
|
|
51
|
+
"constraints": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"description": "Guardrails for Phantom.",
|
|
54
|
+
"items": { "type": "string" }
|
|
55
|
+
},
|
|
56
|
+
"steps": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"description": "The ordered action sequence (the action dictionary).",
|
|
59
|
+
"items": { "$ref": "#/$defs/step" }
|
|
60
|
+
},
|
|
61
|
+
"outputs": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"description": "Shape of the returned data, via interpolation of step results."
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"anyOf": [
|
|
67
|
+
{ "required": ["steps"] },
|
|
68
|
+
{ "required": ["goal"] }
|
|
69
|
+
],
|
|
70
|
+
"$defs": {
|
|
71
|
+
"inputSpec": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"additionalProperties": false,
|
|
74
|
+
"required": ["type"],
|
|
75
|
+
"properties": {
|
|
76
|
+
"type": { "enum": ["string", "number", "integer", "boolean", "date", "path", "object", "array"] },
|
|
77
|
+
"required": { "type": "boolean", "default": false },
|
|
78
|
+
"format": { "type": "string" },
|
|
79
|
+
"default": {},
|
|
80
|
+
"description": { "type": "string" }
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"options": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"additionalProperties": false,
|
|
86
|
+
"properties": {
|
|
87
|
+
"debug": {
|
|
88
|
+
"type": "boolean",
|
|
89
|
+
"default": false,
|
|
90
|
+
"description": "Visible browser + cursor overlay + slow-mo for browser Acts; structured terminal logs otherwise."
|
|
91
|
+
},
|
|
92
|
+
"stealth": { "$ref": "#/$defs/stealth" },
|
|
93
|
+
"session": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"additionalProperties": false,
|
|
96
|
+
"properties": {
|
|
97
|
+
"profile": { "type": "string" },
|
|
98
|
+
"persist": { "type": "boolean", "default": false }
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"timeout_ms": { "type": "integer", "minimum": 0 },
|
|
102
|
+
"retries": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"additionalProperties": false,
|
|
105
|
+
"properties": {
|
|
106
|
+
"max": { "type": "integer", "minimum": 0 },
|
|
107
|
+
"backoff": { "enum": ["none", "linear", "exponential"] }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"stealth": {
|
|
113
|
+
"description": "Modular discretion. Off by default; a preset name; or an inline configuration.",
|
|
114
|
+
"oneOf": [
|
|
115
|
+
{ "type": "string", "description": "\"off\" or a named preset." },
|
|
116
|
+
{
|
|
117
|
+
"type": "object",
|
|
118
|
+
"additionalProperties": false,
|
|
119
|
+
"properties": {
|
|
120
|
+
"mouse": { "enum": ["off", "gestures"] },
|
|
121
|
+
"keyboard": { "enum": ["off", "human"] },
|
|
122
|
+
"scroll": { "enum": ["off", "eased"] },
|
|
123
|
+
"timing": {
|
|
124
|
+
"type": "object",
|
|
125
|
+
"additionalProperties": false,
|
|
126
|
+
"properties": { "distraction": { "type": "number", "minimum": 0, "maximum": 1 } }
|
|
127
|
+
},
|
|
128
|
+
"fingerprint": { "type": "string" }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
"step": {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"description": "A single action. Parameters depend on the action; extra properties are allowed and validated per-action by the engine.",
|
|
136
|
+
"properties": {
|
|
137
|
+
"id": { "type": "string" },
|
|
138
|
+
"action": { "type": "string" }
|
|
139
|
+
},
|
|
140
|
+
"required": ["action"],
|
|
141
|
+
"additionalProperties": true
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The four Acts: interchangeable execution engines behind the ActDriver protocol."""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Act-agnostic action handlers shared by every driver.
|
|
2
|
+
|
|
3
|
+
``emit``, ``wait``, ``set`` and ``assert`` carry no Act-specific behaviour: they manipulate the run
|
|
4
|
+
context and the event bus, never a transport or a browser. They live here so each driver (Vector,
|
|
5
|
+
Continuum, ...) inherits one implementation instead of duplicating it. Drivers dispatch to these
|
|
6
|
+
from their own ``run_step`` ``match`` statement.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from ..core.blueprint.models import StepModel
|
|
15
|
+
from ..core.errors import StatusAssertionError
|
|
16
|
+
from ..core.events.bus import EventBus
|
|
17
|
+
from ..core.events.models import EventType, RunEvent
|
|
18
|
+
from ..core.runtime.context import RunContext
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SharedActionsMixin:
|
|
22
|
+
"""Provides the Act-agnostic handlers (``emit``/``wait``/``set``/``assert``)."""
|
|
23
|
+
|
|
24
|
+
def _set(self, step: StepModel, renderer: Callable[[Any], Any]) -> dict[str, Any]:
|
|
25
|
+
value = renderer(step.extra_fields.get("value"))
|
|
26
|
+
return {"value": value}
|
|
27
|
+
|
|
28
|
+
def _assert(self, step: StepModel, renderer: Callable[[Any], Any]) -> dict[str, Any]:
|
|
29
|
+
p = step.extra_fields
|
|
30
|
+
condition: str = renderer(p.get("condition", ""))
|
|
31
|
+
if str(condition).strip().lower() not in {"true", "1", "yes"}:
|
|
32
|
+
message = renderer(p.get("message", f"Assertion failed: {p.get('condition')}"))
|
|
33
|
+
raise StatusAssertionError(expected=1, actual=0, url="<assert>", body_preview=message)
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
def _emit(
|
|
37
|
+
self,
|
|
38
|
+
step: StepModel,
|
|
39
|
+
ctx: RunContext,
|
|
40
|
+
bus: EventBus,
|
|
41
|
+
renderer: Callable[[Any], Any],
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
p = step.extra_fields
|
|
44
|
+
message: str = renderer(p.get("event", p.get("message", "")))
|
|
45
|
+
bus.emit(
|
|
46
|
+
RunEvent(
|
|
47
|
+
run_id=ctx.run_id,
|
|
48
|
+
type=EventType.PROGRESS,
|
|
49
|
+
step_id=step.id,
|
|
50
|
+
message=message,
|
|
51
|
+
level="info",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
def _wait(self, step: StepModel, renderer: Callable[[Any], Any]) -> dict[str, Any]:
|
|
57
|
+
p = step.extra_fields
|
|
58
|
+
ms: float = float(renderer(p.get("ms", 0)))
|
|
59
|
+
if ms > 0:
|
|
60
|
+
time.sleep(ms / 1000)
|
|
61
|
+
return {}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Mapping of core actions to concrete Playwright page operations.
|
|
2
|
+
|
|
3
|
+
Pure functions with a uniform signature ``(page, params, render) -> dict``: they translate a
|
|
4
|
+
Blueprint step into one browser operation and return that step's outputs. Kept free of the event
|
|
5
|
+
bus and filesystem on purpose, so they are trivially testable against a fake page. Artifact-producing
|
|
6
|
+
actions (screenshot) and DOM reads (extract/wait_for/evaluate) live in the driver and bridge, which
|
|
7
|
+
own the run context.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, Mapping
|
|
13
|
+
|
|
14
|
+
from ...core.errors import ActionError
|
|
15
|
+
|
|
16
|
+
Renderer = Callable[[Any], Any]
|
|
17
|
+
PageAction = Callable[[Any, Mapping[str, Any], Renderer], dict[str, Any]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _locator(page: Any, params: Mapping[str, Any], render: Renderer) -> Any:
|
|
21
|
+
"""Resolve a step's target into a Playwright locator.
|
|
22
|
+
|
|
23
|
+
``selector`` is CSS by default; ``selector_type`` switches to ``xpath`` or ``text``.
|
|
24
|
+
"""
|
|
25
|
+
selector = render(params.get("selector", ""))
|
|
26
|
+
if not selector:
|
|
27
|
+
raise ActionError("This action requires a 'selector'.")
|
|
28
|
+
selector_type = str(render(params.get("selector_type", "css")) or "css").lower()
|
|
29
|
+
if selector_type == "css":
|
|
30
|
+
return page.locator(selector)
|
|
31
|
+
if selector_type == "xpath":
|
|
32
|
+
return page.locator(selector if selector.startswith("xpath=") else f"xpath={selector}")
|
|
33
|
+
if selector_type == "text":
|
|
34
|
+
return page.get_by_text(selector)
|
|
35
|
+
raise ActionError(f"Unknown selector_type {selector_type!r} (expected css, xpath or text).")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def navigate(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
39
|
+
url = render(params.get("url", ""))
|
|
40
|
+
if not url:
|
|
41
|
+
raise ActionError("navigate requires a 'url'.")
|
|
42
|
+
wait_until = render(params.get("wait_until", "load"))
|
|
43
|
+
response = page.goto(url, wait_until=wait_until)
|
|
44
|
+
return {"url": page.url, "status": response.status if response is not None else None}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def back(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
48
|
+
page.go_back()
|
|
49
|
+
return {"url": page.url}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def forward(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
53
|
+
page.go_forward()
|
|
54
|
+
return {"url": page.url}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def reload(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
58
|
+
page.reload()
|
|
59
|
+
return {"url": page.url}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def click(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
63
|
+
_locator(page, params, render).click()
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fill(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
68
|
+
_locator(page, params, render).fill(render(params.get("value", "")))
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def type_text(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
73
|
+
text = render(params.get("text", params.get("value", "")))
|
|
74
|
+
delay = params.get("delay_ms")
|
|
75
|
+
locator = _locator(page, params, render)
|
|
76
|
+
if delay is not None:
|
|
77
|
+
locator.press_sequentially(text, delay=float(render(delay)))
|
|
78
|
+
else:
|
|
79
|
+
locator.press_sequentially(text)
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def press(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
84
|
+
key = render(params.get("key", ""))
|
|
85
|
+
if not key:
|
|
86
|
+
raise ActionError("press requires a 'key'.")
|
|
87
|
+
if params.get("selector"):
|
|
88
|
+
_locator(page, params, render).press(key)
|
|
89
|
+
else:
|
|
90
|
+
page.keyboard.press(key)
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def select(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
95
|
+
values = render(params.get("values", params.get("value")))
|
|
96
|
+
if values is None:
|
|
97
|
+
raise ActionError("select requires a 'value' or 'values'.")
|
|
98
|
+
_locator(page, params, render).select_option(values)
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def hover(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
103
|
+
_locator(page, params, render).hover()
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def scroll(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
108
|
+
if params.get("selector"):
|
|
109
|
+
_locator(page, params, render).scroll_into_view_if_needed()
|
|
110
|
+
else:
|
|
111
|
+
dx = float(render(params.get("dx", 0)) or 0)
|
|
112
|
+
dy = float(render(params.get("dy", 0)) or 0)
|
|
113
|
+
page.mouse.wheel(dx, dy)
|
|
114
|
+
return {}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def upload(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
118
|
+
file = render(params.get("file", params.get("files")))
|
|
119
|
+
if not file:
|
|
120
|
+
raise ActionError("upload requires a 'file'.")
|
|
121
|
+
_locator(page, params, render).set_input_files(file)
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def drag(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
126
|
+
source = render(params.get("source", params.get("from", "")))
|
|
127
|
+
target = render(params.get("target", params.get("to", "")))
|
|
128
|
+
if not source or not target:
|
|
129
|
+
raise ActionError("drag requires 'source'/'from' and 'target'/'to' selectors.")
|
|
130
|
+
page.drag_and_drop(source, target)
|
|
131
|
+
return {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Simple, side-effect-free page operations dispatched directly by the driver.
|
|
135
|
+
PAGE_ACTIONS: dict[str, PageAction] = {
|
|
136
|
+
"navigate": navigate,
|
|
137
|
+
"back": back,
|
|
138
|
+
"forward": forward,
|
|
139
|
+
"reload": reload,
|
|
140
|
+
"click": click,
|
|
141
|
+
"fill": fill,
|
|
142
|
+
"type": type_text,
|
|
143
|
+
"press": press,
|
|
144
|
+
"select": select,
|
|
145
|
+
"hover": hover,
|
|
146
|
+
"scroll": scroll,
|
|
147
|
+
"upload": upload,
|
|
148
|
+
"drag": drag,
|
|
149
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Injected-JavaScript bridge and DOM extraction helpers for scraping.
|
|
2
|
+
|
|
3
|
+
Reads from the live page: ``extract`` turns a map of ``name -> {selector, as}`` into typed outputs,
|
|
4
|
+
``wait_for`` blocks until a selector appears (honouring a Blueprint failure code on timeout), and
|
|
5
|
+
``evaluate`` runs injected JavaScript. Like actions.py, these are pure ``(page, params, render)``
|
|
6
|
+
functions so they test against a fake page.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, Callable, Mapping
|
|
13
|
+
|
|
14
|
+
from ...core.errors import ActionError, StepTimeoutError
|
|
15
|
+
|
|
16
|
+
Renderer = Callable[[Any], Any]
|
|
17
|
+
|
|
18
|
+
_NUMBER_RE = re.compile(r"-?\d+(?:[.,]\d+)?")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_timeout(exc: Exception) -> bool:
|
|
22
|
+
"""True when *exc* is a Playwright (or builtin) timeout, matched by class name.
|
|
23
|
+
|
|
24
|
+
Avoids importing Playwright here so the module stays light and unit-testable.
|
|
25
|
+
"""
|
|
26
|
+
return type(exc).__name__ == "TimeoutError"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _failure_code(on_timeout: Any) -> str | None:
|
|
30
|
+
"""Parse ``"fail:CODE"`` into ``CODE``; anything else yields no code."""
|
|
31
|
+
if isinstance(on_timeout, str) and on_timeout.startswith("fail:"):
|
|
32
|
+
return on_timeout.split(":", 1)[1] or None
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve(page: Any, selector: str, selector_type: str) -> Any:
|
|
37
|
+
if selector_type == "xpath":
|
|
38
|
+
return page.locator(selector if selector.startswith("xpath=") else f"xpath={selector}")
|
|
39
|
+
return page.locator(selector)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _coerce_number(text: str | None) -> float | int | None:
|
|
43
|
+
"""Extract the first number from *text*, returning int when integral, else float."""
|
|
44
|
+
if not text:
|
|
45
|
+
return None
|
|
46
|
+
match = _NUMBER_RE.search(text)
|
|
47
|
+
if match is None:
|
|
48
|
+
return None
|
|
49
|
+
raw = match.group(0).replace(",", ".")
|
|
50
|
+
value = float(raw)
|
|
51
|
+
return int(value) if value.is_integer() else value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_value(target: Any, as_: str, spec: Mapping[str, Any]) -> Any:
|
|
55
|
+
"""Read a single (already-resolved) locator as *as_*: text, number, html or attr."""
|
|
56
|
+
if as_ == "text":
|
|
57
|
+
return (target.inner_text() or "").strip()
|
|
58
|
+
if as_ == "number":
|
|
59
|
+
return _coerce_number(target.inner_text())
|
|
60
|
+
if as_ == "html":
|
|
61
|
+
return target.inner_html()
|
|
62
|
+
if as_ == "attr":
|
|
63
|
+
attr = spec.get("attr")
|
|
64
|
+
if not attr:
|
|
65
|
+
raise ActionError("extract 'as: attr' requires an 'attr' name.")
|
|
66
|
+
return target.get_attribute(attr)
|
|
67
|
+
raise ActionError(f"Unknown extract type {as_!r} (text, number, html, attr, list or count).")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_one(page: Any, selector: str, as_: str, spec: Mapping[str, Any]) -> Any:
|
|
71
|
+
selector_type = str(spec.get("selector_type", "css")).lower()
|
|
72
|
+
locator = _resolve(page, selector, selector_type)
|
|
73
|
+
if as_ == "count":
|
|
74
|
+
return locator.count()
|
|
75
|
+
return _read_value(locator.first, as_, spec)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_list(page: Any, selector: str, spec: Mapping[str, Any]) -> list[Any]:
|
|
79
|
+
"""Read every match of *selector* as a list of values (item type via ``item``, default text)."""
|
|
80
|
+
selector_type = str(spec.get("selector_type", "css")).lower()
|
|
81
|
+
item_as = str(spec.get("item", "text")).lower()
|
|
82
|
+
locator = _resolve(page, selector, selector_type)
|
|
83
|
+
return [_read_value(target, item_as, spec) for target in locator.all()]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read_records(page: Any, spec: Mapping[str, Any], render: Renderer) -> list[dict[str, Any]]:
|
|
87
|
+
"""Read a repeating container into a list of records, one field per ``fields`` entry.
|
|
88
|
+
|
|
89
|
+
Each field's selector is resolved *within* its container, so a row's ``.title``/``.price`` are
|
|
90
|
+
read relative to that row rather than globally — the shape a table/list demonstration produces.
|
|
91
|
+
"""
|
|
92
|
+
each = render(spec.get("each", ""))
|
|
93
|
+
if not each:
|
|
94
|
+
raise ActionError("extract records require an 'each' container selector.")
|
|
95
|
+
fields: Mapping[str, Any] = spec.get("fields") or {}
|
|
96
|
+
if not fields:
|
|
97
|
+
raise ActionError("extract records require a non-empty 'fields' map.")
|
|
98
|
+
|
|
99
|
+
records: list[dict[str, Any]] = []
|
|
100
|
+
for container in page.locator(each).all():
|
|
101
|
+
record: dict[str, Any] = {}
|
|
102
|
+
for field_name, field_spec in fields.items():
|
|
103
|
+
field_selector = render(field_spec.get("selector", ""))
|
|
104
|
+
if not field_selector:
|
|
105
|
+
raise ActionError(f"extract record field {field_name!r} requires a 'selector'.")
|
|
106
|
+
field_as = str(render(field_spec.get("as", "text"))).lower()
|
|
107
|
+
record[field_name] = _read_value(
|
|
108
|
+
container.locator(field_selector).first, field_as, field_spec
|
|
109
|
+
)
|
|
110
|
+
records.append(record)
|
|
111
|
+
return records
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
115
|
+
specs: Mapping[str, Any] = params.get("outputs") or {}
|
|
116
|
+
result: dict[str, Any] = {}
|
|
117
|
+
for name, spec in specs.items():
|
|
118
|
+
if "each" in spec:
|
|
119
|
+
result[name] = _read_records(page, spec, render)
|
|
120
|
+
continue
|
|
121
|
+
selector = render(spec.get("selector", ""))
|
|
122
|
+
if not selector:
|
|
123
|
+
raise ActionError(f"extract output {name!r} requires a 'selector'.")
|
|
124
|
+
as_ = str(render(spec.get("as", "text"))).lower()
|
|
125
|
+
if as_ == "list":
|
|
126
|
+
result[name] = _read_list(page, selector, spec)
|
|
127
|
+
else:
|
|
128
|
+
result[name] = _read_one(page, selector, as_, spec)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def wait_for(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
133
|
+
selector = render(params.get("selector", ""))
|
|
134
|
+
if not selector:
|
|
135
|
+
raise ActionError("wait_for requires a 'selector'.")
|
|
136
|
+
kwargs: dict[str, Any] = {"state": render(params.get("state", "visible"))}
|
|
137
|
+
if params.get("timeout_ms") is not None:
|
|
138
|
+
kwargs["timeout"] = float(render(params.get("timeout_ms")))
|
|
139
|
+
try:
|
|
140
|
+
# `.first`: waiting is about presence, so a selector matching several elements is normal
|
|
141
|
+
# and must not trip Playwright's strict-mode (which is reserved for acting on one element).
|
|
142
|
+
page.locator(selector).first.wait_for(**kwargs)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
if _is_timeout(exc):
|
|
145
|
+
code = _failure_code(render(params.get("on_timeout")))
|
|
146
|
+
raise StepTimeoutError(
|
|
147
|
+
f"wait_for timed out for selector {selector!r}", code=code
|
|
148
|
+
) from exc
|
|
149
|
+
raise
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def evaluate(page: Any, params: Mapping[str, Any], render: Renderer) -> dict[str, Any]:
|
|
154
|
+
script = render(params.get("script", params.get("expression", "")))
|
|
155
|
+
if not script:
|
|
156
|
+
raise ActionError("evaluate requires a 'script' or 'expression'.")
|
|
157
|
+
if "arg" in params:
|
|
158
|
+
return {"result": page.evaluate(script, render(params.get("arg")))}
|
|
159
|
+
return {"result": page.evaluate(script)}
|