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.
Files changed (154) hide show
  1. aetherius/__init__.py +42 -0
  2. aetherius/__main__.py +12 -0
  3. aetherius/_contracts/__init__.py +0 -0
  4. aetherius/_contracts/blueprint.schema.json +144 -0
  5. aetherius/acts/__init__.py +1 -0
  6. aetherius/acts/_shared.py +61 -0
  7. aetherius/acts/continuum/__init__.py +7 -0
  8. aetherius/acts/continuum/actions.py +149 -0
  9. aetherius/acts/continuum/bridge.py +159 -0
  10. aetherius/acts/continuum/browser.py +164 -0
  11. aetherius/acts/continuum/debug_overlay.py +65 -0
  12. aetherius/acts/continuum/driver.py +127 -0
  13. aetherius/acts/continuum/human_actions.py +74 -0
  14. aetherius/acts/oracle/__init__.py +1 -0
  15. aetherius/acts/oracle/driver.py +1 -0
  16. aetherius/acts/oracle/locator.py +1 -0
  17. aetherius/acts/oracle/model.py +1 -0
  18. aetherius/acts/oracle/perception.py +1 -0
  19. aetherius/acts/phantom/__init__.py +1 -0
  20. aetherius/acts/phantom/driver.py +1 -0
  21. aetherius/acts/phantom/loop.py +1 -0
  22. aetherius/acts/phantom/memory.py +1 -0
  23. aetherius/acts/phantom/perception.py +1 -0
  24. aetherius/acts/phantom/planner.py +1 -0
  25. aetherius/acts/vector/__init__.py +1 -0
  26. aetherius/acts/vector/auth.py +108 -0
  27. aetherius/acts/vector/client.py +129 -0
  28. aetherius/acts/vector/driver.py +128 -0
  29. aetherius/builder/__init__.py +40 -0
  30. aetherius/builder/catalog.py +93 -0
  31. aetherius/builder/factory.py +243 -0
  32. aetherius/builder/templates.py +150 -0
  33. aetherius/builder/validation.py +120 -0
  34. aetherius/cli.py +236 -0
  35. aetherius/config/__init__.py +1 -0
  36. aetherius/config/secrets.py +71 -0
  37. aetherius/config/settings.py +44 -0
  38. aetherius/console/__init__.py +1 -0
  39. aetherius/console/app.py +66 -0
  40. aetherius/console/console.tcss +97 -0
  41. aetherius/console/daemon_control.py +83 -0
  42. aetherius/console/run_bridge.py +43 -0
  43. aetherius/console/screens/__init__.py +1 -0
  44. aetherius/console/screens/_pending.py +38 -0
  45. aetherius/console/screens/builder/__init__.py +1 -0
  46. aetherius/console/screens/builder/act_picker.py +75 -0
  47. aetherius/console/screens/builder/io_editor.py +167 -0
  48. aetherius/console/screens/builder/options_editor.py +132 -0
  49. aetherius/console/screens/builder/preview.py +57 -0
  50. aetherius/console/screens/builder/screen.py +235 -0
  51. aetherius/console/screens/builder/step_editor.py +249 -0
  52. aetherius/console/screens/catalog.py +52 -0
  53. aetherius/console/screens/home.py +90 -0
  54. aetherius/console/screens/library.py +124 -0
  55. aetherius/console/screens/library_scan.py +99 -0
  56. aetherius/console/screens/recorder.py +193 -0
  57. aetherius/console/screens/runs.py +138 -0
  58. aetherius/console/screens/sessions.py +14 -0
  59. aetherius/console/screens/settings.py +132 -0
  60. aetherius/console/screenshots.py +173 -0
  61. aetherius/console/theme.py +114 -0
  62. aetherius/console/widgets/__init__.py +1 -0
  63. aetherius/console/widgets/event_log.py +44 -0
  64. aetherius/console/widgets/form.py +101 -0
  65. aetherius/console/widgets/json_preview.py +18 -0
  66. aetherius/console/widgets/run_summary.py +82 -0
  67. aetherius/core/__init__.py +1 -0
  68. aetherius/core/actions/__init__.py +1 -0
  69. aetherius/core/actions/base.py +99 -0
  70. aetherius/core/actions/data.py +126 -0
  71. aetherius/core/actions/flow.py +76 -0
  72. aetherius/core/actions/interaction.py +109 -0
  73. aetherius/core/actions/navigation.py +32 -0
  74. aetherius/core/actions/registry.py +70 -0
  75. aetherius/core/actions/spec.py +42 -0
  76. aetherius/core/blueprint/__init__.py +1 -0
  77. aetherius/core/blueprint/loader.py +82 -0
  78. aetherius/core/blueprint/models.py +81 -0
  79. aetherius/core/blueprint/template.py +101 -0
  80. aetherius/core/blueprint/validator.py +45 -0
  81. aetherius/core/driver.py +36 -0
  82. aetherius/core/errors.py +134 -0
  83. aetherius/core/events/__init__.py +1 -0
  84. aetherius/core/events/bus.py +28 -0
  85. aetherius/core/events/models.py +29 -0
  86. aetherius/core/events/sinks.py +55 -0
  87. aetherius/core/extraction/__init__.py +1 -0
  88. aetherius/core/extraction/html_extractor.py +52 -0
  89. aetherius/core/extraction/json_extractor.py +125 -0
  90. aetherius/core/runtime/__init__.py +1 -0
  91. aetherius/core/runtime/context.py +61 -0
  92. aetherius/core/runtime/engine.py +188 -0
  93. aetherius/core/runtime/result.py +39 -0
  94. aetherius/core/runtime/selector.py +1 -0
  95. aetherius/models/__init__.py +1 -0
  96. aetherius/models/registry.py +1 -0
  97. aetherius/models/store/.gitkeep +0 -0
  98. aetherius/recorder/__init__.py +12 -0
  99. aetherius/recorder/_capture_js.py +80 -0
  100. aetherius/recorder/_gesture_js.py +40 -0
  101. aetherius/recorder/_names.py +20 -0
  102. aetherius/recorder/_overlay_js.py +262 -0
  103. aetherius/recorder/_playwright.py +49 -0
  104. aetherius/recorder/_selector_js.py +148 -0
  105. aetherius/recorder/_transform.py +214 -0
  106. aetherius/recorder/_vector_js.py +174 -0
  107. aetherius/recorder/base.py +92 -0
  108. aetherius/recorder/blueprint_recorder.py +72 -0
  109. aetherius/recorder/capture.py +59 -0
  110. aetherius/recorder/continuum_backend.py +119 -0
  111. aetherius/recorder/gesture_recorder.py +193 -0
  112. aetherius/recorder/selector_synth.py +83 -0
  113. aetherius/recorder/session.py +101 -0
  114. aetherius/recorder/vector_backend.py +158 -0
  115. aetherius/server/__init__.py +12 -0
  116. aetherius/server/app.py +37 -0
  117. aetherius/server/config.py +25 -0
  118. aetherius/server/deps.py +50 -0
  119. aetherius/server/jobs.py +155 -0
  120. aetherius/server/routes/__init__.py +1 -0
  121. aetherius/server/routes/blueprints.py +45 -0
  122. aetherius/server/routes/recorder.py +25 -0
  123. aetherius/server/routes/runs.py +49 -0
  124. aetherius/server/routes/stream.py +53 -0
  125. aetherius/server/schemas.py +78 -0
  126. aetherius/stealth/__init__.py +1 -0
  127. aetherius/stealth/fingerprint/__init__.py +1 -0
  128. aetherius/stealth/fingerprint/patch.py +38 -0
  129. aetherius/stealth/fingerprint/profile.py +95 -0
  130. aetherius/stealth/gestures/__init__.py +1 -0
  131. aetherius/stealth/gestures/data/human_library.json +7107 -0
  132. aetherius/stealth/gestures/library.py +126 -0
  133. aetherius/stealth/gestures/seed.py +97 -0
  134. aetherius/stealth/humanizer/__init__.py +1 -0
  135. aetherius/stealth/humanizer/input.py +89 -0
  136. aetherius/stealth/humanizer/keyboard.py +75 -0
  137. aetherius/stealth/humanizer/mouse.py +153 -0
  138. aetherius/stealth/humanizer/scroll.py +55 -0
  139. aetherius/stealth/humanizer/timing.py +54 -0
  140. aetherius/stealth/ml/__init__.py +1 -0
  141. aetherius/stealth/ml/fingerprint_model.py +1 -0
  142. aetherius/stealth/ml/motion_model.py +1 -0
  143. aetherius/stealth/policy.py +133 -0
  144. aetherius/stealth/session/__init__.py +1 -0
  145. aetherius/stealth/session/store.py +35 -0
  146. aetherius/stealth/session/warmup.py +52 -0
  147. aetherius/version.py +3 -0
  148. aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
  149. aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
  150. aetherius-0.2.0.dist-info/METADATA +606 -0
  151. aetherius-0.2.0.dist-info/RECORD +154 -0
  152. aetherius-0.2.0.dist-info/WHEEL +4 -0
  153. aetherius-0.2.0.dist-info/entry_points.txt +2 -0
  154. 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,7 @@
1
+ """Act II - Continuum: scripted browser automation via Playwright."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .driver import ContinuumDriver
6
+
7
+ __all__ = ["ContinuumDriver"]
@@ -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)}