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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""In-page capture for the Vector recorder: observe API calls, pick a request and its JSON fields.
|
|
2
|
+
|
|
3
|
+
Symmetric with the DOM recorder, but for Act I. It patches ``fetch``/``XMLHttpRequest`` (and treats a
|
|
4
|
+
JSON *document* — navigating straight to an API URL — as a request too), lists the JSON responses in a
|
|
5
|
+
floating panel, and lets the user turn one into an ``http.request`` + JSONPath extraction. The panel
|
|
6
|
+
shows a **structured summary** of the response (a top-level array → "extract N records" with
|
|
7
|
+
auto-detected scalar fields; an object → a chip per key) rather than an infinite tree: simpler, and it
|
|
8
|
+
matches real API shapes (a list of records, or an object of fields/arrays).
|
|
9
|
+
|
|
10
|
+
Everything funnels through ``window.__aeVector.emit(request, extract)``, which the panel calls and
|
|
11
|
+
integration tests drive directly. The Python side (:mod:`.vector_backend`) turns it into a Blueprint.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
# Self-invoking, guarded. Emits through the shared __aetherius_capture binding with the http_request
|
|
17
|
+
# kind. Auth-bearing headers are handled Python-side (turned into secrets), never stored literally.
|
|
18
|
+
VECTOR_JS = r"""
|
|
19
|
+
(() => {
|
|
20
|
+
if (window.__aeVector) return;
|
|
21
|
+
const OVERLAY_ID = '__ae-recorder-overlay';
|
|
22
|
+
const ACCENT = '#c9a94a';
|
|
23
|
+
const requests = [];
|
|
24
|
+
const send = (p) => { try { window.__aetherius_capture(JSON.stringify(p)); } catch (e) {} };
|
|
25
|
+
|
|
26
|
+
const headersToObj = (h) => {
|
|
27
|
+
const o = {};
|
|
28
|
+
try {
|
|
29
|
+
if (h && typeof h.forEach === 'function') h.forEach((v, k) => { o[k] = v; });
|
|
30
|
+
else if (h && typeof h === 'object') Object.assign(o, h);
|
|
31
|
+
} catch (e) {}
|
|
32
|
+
return o;
|
|
33
|
+
};
|
|
34
|
+
const bodyToStr = (b) => (b == null ? null : (typeof b === 'string' ? b : null));
|
|
35
|
+
|
|
36
|
+
let refresh = () => {};
|
|
37
|
+
const record = (r) => {
|
|
38
|
+
if ((r.contentType || '').includes('json')) { requests.push(r); refresh(); }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const origFetch = window.fetch;
|
|
42
|
+
if (origFetch) {
|
|
43
|
+
window.fetch = function (...args) {
|
|
44
|
+
const arg0 = args[0], init = args[1] || {};
|
|
45
|
+
const url = (typeof arg0 === 'string') ? arg0 : (arg0 && arg0.url) || '';
|
|
46
|
+
const method = (init.method || (arg0 && arg0.method) || 'GET').toUpperCase();
|
|
47
|
+
const headers = headersToObj(init.headers);
|
|
48
|
+
const body = bodyToStr(init.body);
|
|
49
|
+
return origFetch.apply(this, args).then((resp) => {
|
|
50
|
+
const ct = resp.headers.get('content-type') || '';
|
|
51
|
+
resp.clone().text()
|
|
52
|
+
.then((text) => record({ method, url, headers, body, status: resp.status, contentType: ct, responseText: text }))
|
|
53
|
+
.catch(() => {});
|
|
54
|
+
return resp;
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const xOpen = XMLHttpRequest.prototype.open, xSend = XMLHttpRequest.prototype.send;
|
|
60
|
+
XMLHttpRequest.prototype.open = function (m, u) {
|
|
61
|
+
this.__ae = { method: (m || 'GET').toUpperCase(), url: u };
|
|
62
|
+
return xOpen.apply(this, arguments);
|
|
63
|
+
};
|
|
64
|
+
XMLHttpRequest.prototype.send = function (b) {
|
|
65
|
+
const xhr = this, info = xhr.__ae || {};
|
|
66
|
+
xhr.addEventListener('load', () => {
|
|
67
|
+
const ct = xhr.getResponseHeader('content-type') || '';
|
|
68
|
+
record({ method: info.method, url: info.url, headers: {}, body: bodyToStr(b),
|
|
69
|
+
status: xhr.status, contentType: ct, responseText: xhr.responseText });
|
|
70
|
+
});
|
|
71
|
+
return xSend.apply(this, arguments);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
window.__aeVector = {
|
|
75
|
+
requests,
|
|
76
|
+
emit(request, extract) { send({ kind: 'http_request', request, extract }); },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ── UI ──────────────────────────────────────────────────────────────────────
|
|
80
|
+
const el = (tag, css, text) => {
|
|
81
|
+
const n = document.createElement(tag);
|
|
82
|
+
if (css) n.style.cssText = css;
|
|
83
|
+
if (text != null) n.textContent = text;
|
|
84
|
+
return n;
|
|
85
|
+
};
|
|
86
|
+
const chip = (label, onclick) => {
|
|
87
|
+
const b = el('button', 'margin:2px;padding:3px 7px;font:12px system-ui;cursor:pointer;' +
|
|
88
|
+
'border-radius:5px;border:1px solid ' + ACCENT + '55;background:#1c1830;color:#e7e2f3;', label);
|
|
89
|
+
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onclick(); });
|
|
90
|
+
return b;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const scalarFields = (obj) => { // { key: '$.key' } for each scalar field of a sample record
|
|
94
|
+
const out = {};
|
|
95
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
96
|
+
for (const k of Object.keys(obj)) {
|
|
97
|
+
const v = obj[k];
|
|
98
|
+
if (v == null || typeof v !== 'object') out[k] = '$.' + k;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
let host, root, statusEl, listEl, detailEl;
|
|
105
|
+
const setStatus = (t) => { if (statusEl) statusEl.textContent = t; };
|
|
106
|
+
|
|
107
|
+
const pickRecords = (request, path, sample, name) => {
|
|
108
|
+
window.__aeVector.emit(request, { name, path, fields: scalarFields(sample) });
|
|
109
|
+
setStatus('Added records "' + name + '". Pick more, or Finish.');
|
|
110
|
+
};
|
|
111
|
+
const pickValue = (request, path, name) => {
|
|
112
|
+
window.__aeVector.emit(request, { name, path });
|
|
113
|
+
setStatus('Added "' + name + '". Pick more, or Finish.');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const showDetail = (request) => {
|
|
117
|
+
detailEl.innerHTML = '';
|
|
118
|
+
let data;
|
|
119
|
+
try { data = JSON.parse(request.responseText); } catch (e) { detailEl.textContent = 'Not JSON.'; return; }
|
|
120
|
+
detailEl.appendChild(el('div', 'color:' + ACCENT + ';margin:4px 0;', request.method + ' ' + request.url));
|
|
121
|
+
if (Array.isArray(data)) {
|
|
122
|
+
detailEl.appendChild(chip('Extract ' + data.length + ' records', () =>
|
|
123
|
+
pickRecords(request, '$[*]', data[0], 'records')));
|
|
124
|
+
} else if (data && typeof data === 'object') {
|
|
125
|
+
for (const k of Object.keys(data)) {
|
|
126
|
+
const v = data[k];
|
|
127
|
+
if (Array.isArray(v)) {
|
|
128
|
+
detailEl.appendChild(chip(k + ' (' + v.length + ' records)', () =>
|
|
129
|
+
pickRecords(request, '$.' + k + '[*]', v[0], k)));
|
|
130
|
+
} else if (v == null || typeof v !== 'object') {
|
|
131
|
+
detailEl.appendChild(chip(k, () => pickValue(request, '$.' + k, k)));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
detailEl.appendChild(chip('Extract value', () => pickValue(request, '$', 'value')));
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const install = () => {
|
|
140
|
+
if (!document.documentElement || document.getElementById(OVERLAY_ID)) return;
|
|
141
|
+
host = el('div'); host.id = OVERLAY_ID; host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;';
|
|
142
|
+
document.documentElement.appendChild(host);
|
|
143
|
+
root = host.attachShadow({ mode: 'open' });
|
|
144
|
+
const bar = el('div', 'position:fixed;top:10px;right:10px;width:320px;max-height:80vh;overflow:auto;' +
|
|
145
|
+
'padding:8px 10px;font:12px system-ui;background:#171327ee;color:#e7e2f3;' +
|
|
146
|
+
'border:1px solid ' + ACCENT + '66;border-radius:8px;box-shadow:0 6px 24px #000a;');
|
|
147
|
+
bar.appendChild(el('div', 'font-weight:600;color:' + ACCENT + ';', 'Aetherius recorder — API'));
|
|
148
|
+
statusEl = el('div', 'color:#b9b2d0;margin:4px 0;', 'Browse to trigger API calls, then pick one.');
|
|
149
|
+
bar.appendChild(statusEl);
|
|
150
|
+
bar.appendChild(el('div', 'color:#8f88a8;margin-top:4px;', 'Requests'));
|
|
151
|
+
listEl = el('div'); bar.appendChild(listEl);
|
|
152
|
+
detailEl = el('div', 'margin-top:6px;border-top:1px solid #ffffff22;padding-top:4px;'); bar.appendChild(detailEl);
|
|
153
|
+
bar.appendChild(chip('Finish', () => send({ kind: 'finish' })));
|
|
154
|
+
root.appendChild(bar);
|
|
155
|
+
|
|
156
|
+
refresh = () => {
|
|
157
|
+
listEl.innerHTML = '';
|
|
158
|
+
requests.slice(-12).forEach((r) => {
|
|
159
|
+
const label = r.method + ' ' + (r.url.length > 34 ? '…' + r.url.slice(-33) : r.url);
|
|
160
|
+
listEl.appendChild(chip(label, () => showDetail(r)));
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
refresh();
|
|
164
|
+
if ((document.contentType || '').includes('json')) {
|
|
165
|
+
record({ method: 'GET', url: location.href, headers: {}, body: null,
|
|
166
|
+
status: 200, contentType: 'application/json',
|
|
167
|
+
responseText: document.body ? document.body.textContent : '' });
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install);
|
|
172
|
+
else install();
|
|
173
|
+
})();
|
|
174
|
+
"""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Recorder backends behind a common interface, selected by Act — a mirror of the driver pattern.
|
|
2
|
+
|
|
3
|
+
A recorder captures a demonstration in a live browser and turns it into a Blueprint. *How* it captures
|
|
4
|
+
and *what* it emits depends on the Act: Continuum records DOM interactions, Vector records network
|
|
5
|
+
calls. Both plug into the same generic browser shell (:mod:`.session`) through this interface, and the
|
|
6
|
+
registry maps ``act -> backend`` exactly as ``ACT_CAPABILITIES`` maps ``act -> capabilities``.
|
|
7
|
+
|
|
8
|
+
Acts without a recorder yet (Oracle, Phantom) are declared here as pending holes: asking for one
|
|
9
|
+
raises a clear :class:`RecorderError` instead of failing obscurely, so the CLI/Console can say so
|
|
10
|
+
honestly — the same "honest milestone" pattern used elsewhere (PendingScreen, DependencyError).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Callable, Protocol
|
|
17
|
+
|
|
18
|
+
from ..core.errors import RecorderError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .session import RecordingSession
|
|
22
|
+
|
|
23
|
+
# Acts that are real Aetherius Acts but whose recorder is a later milestone (documented in
|
|
24
|
+
# docs/recorder.md). Kept distinct from a plainly unknown act so the message can be specific.
|
|
25
|
+
_PENDING_RECORDERS: dict[str, str] = {
|
|
26
|
+
"oracle": "visual annotation (bounding boxes on screenshots) feeding training/",
|
|
27
|
+
"phantom": "goal-based demonstration",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_BACKENDS: dict[str, Callable[..., "RecorderBackend"]] = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RecordingResult:
|
|
35
|
+
"""What a backend produces from a session: the pieces of the Blueprint (minus the envelope)."""
|
|
36
|
+
|
|
37
|
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
|
38
|
+
secrets: list[str] = field(default_factory=list)
|
|
39
|
+
inputs: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
outputs: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RecorderBackend(Protocol):
|
|
44
|
+
"""One Act's way of recording. The generic session drives it through these three hooks."""
|
|
45
|
+
|
|
46
|
+
act: str
|
|
47
|
+
|
|
48
|
+
def init_scripts(self) -> list[str]:
|
|
49
|
+
"""Scripts to inject (``add_init_script``) into every document before the page runs."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def attach(self, session: "RecordingSession") -> None:
|
|
53
|
+
"""Register bindings and page hooks on *session* (called once the context exists)."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def result(self) -> RecordingResult:
|
|
57
|
+
"""Transform everything captured during the session into a RecordingResult."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def register_backend(act: str, factory: Callable[..., "RecorderBackend"]) -> None:
|
|
62
|
+
"""Register a backend factory for *act* (called at backend-module import).
|
|
63
|
+
|
|
64
|
+
The factory accepts arbitrary keyword options and picks the ones its Act understands, so
|
|
65
|
+
``get_recorder`` can forward Act-specific knobs (e.g. ``credentials_as_secrets``) uniformly.
|
|
66
|
+
"""
|
|
67
|
+
_BACKENDS[act] = factory
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ensure_loaded() -> None:
|
|
71
|
+
"""Import the backend modules so their registrations run (idempotent, dependency-light)."""
|
|
72
|
+
from . import continuum_backend, vector_backend # noqa: F401 (import for side effects)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_recorder(act: str, **options: Any) -> "RecorderBackend":
|
|
76
|
+
"""Return a fresh backend for *act* (forwarding *options*), or raise a clear RecorderError."""
|
|
77
|
+
_ensure_loaded()
|
|
78
|
+
factory = _BACKENDS.get(act)
|
|
79
|
+
if factory is not None:
|
|
80
|
+
return factory(**options)
|
|
81
|
+
if act in _PENDING_RECORDERS:
|
|
82
|
+
raise RecorderError(
|
|
83
|
+
f"The recorder for act {act!r} is a pending milestone "
|
|
84
|
+
f"({_PENDING_RECORDERS[act]}); see docs/recorder.md."
|
|
85
|
+
)
|
|
86
|
+
raise RecorderError(f"Unknown act {act!r} (no recorder backend).")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def recorder_acts() -> set[str]:
|
|
90
|
+
"""Acts that have a working recorder today (single source of truth, like IMPLEMENTED_ACTS)."""
|
|
91
|
+
_ensure_loaded()
|
|
92
|
+
return set(_BACKENDS)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Blueprint recorder: launch a visible browser, capture a demonstration, emit a clean Blueprint.
|
|
2
|
+
|
|
3
|
+
Act-agnostic orchestrator: it picks the recorder backend for the requested Act (:mod:`.base`), drives
|
|
4
|
+
a generic :class:`~.session.RecordingSession`, assembles the backend's result into a Blueprint, and
|
|
5
|
+
re-validates the produced file through the canonical loader/validator — so the recorder can only ever
|
|
6
|
+
hand back a schema-valid, runnable Blueprint (or fail loudly). What is captured and how it maps to
|
|
7
|
+
steps is the backend's job (Continuum: DOM; Vector: network).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..builder.factory import assemble_blueprint, slugify_name
|
|
17
|
+
from ..core.blueprint.loader import load_blueprint
|
|
18
|
+
from ..core.blueprint.validator import validate_for_act
|
|
19
|
+
from ..core.errors import BlueprintValidationError
|
|
20
|
+
from .base import get_recorder
|
|
21
|
+
from .session import NotifyCallback, RecordingSession
|
|
22
|
+
|
|
23
|
+
# assemble_blueprint is re-exported from builder.factory: Blueprint construction is the builder's
|
|
24
|
+
# job, and the recorder is one of its callers. Kept importable here for existing callers and tests.
|
|
25
|
+
__all__ = ["assemble_blueprint", "record_blueprint"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def record_blueprint(
|
|
29
|
+
name: str,
|
|
30
|
+
start_url: str,
|
|
31
|
+
*,
|
|
32
|
+
act: str = "continuum",
|
|
33
|
+
out_dir: Path | str | None = None,
|
|
34
|
+
on_event: NotifyCallback | None = None,
|
|
35
|
+
stop_event: threading.Event | None = None,
|
|
36
|
+
credentials_as_secrets: bool = True,
|
|
37
|
+
) -> Path:
|
|
38
|
+
"""Record a demonstration for *act* in a live browser and write a validated Blueprint file.
|
|
39
|
+
|
|
40
|
+
Returns the path of the written ``.blueprint.json`` (under *out_dir* or ``./blueprints/``).
|
|
41
|
+
Raises :class:`~aetherius.core.errors.RecorderError` if *act* has no recorder backend, or
|
|
42
|
+
:class:`BlueprintValidationError` if the assembled Blueprint fails canonical validation.
|
|
43
|
+
"""
|
|
44
|
+
backend = get_recorder(act, credentials_as_secrets=credentials_as_secrets)
|
|
45
|
+
session = RecordingSession(start_url, backend, on_event=on_event, stop_event=stop_event)
|
|
46
|
+
result = session.record()
|
|
47
|
+
|
|
48
|
+
description = (
|
|
49
|
+
f"Genere par le recorder Aetherius (act {act}) a partir d'une demo sur {start_url}."
|
|
50
|
+
)
|
|
51
|
+
blueprint = assemble_blueprint(
|
|
52
|
+
name,
|
|
53
|
+
result.steps,
|
|
54
|
+
result.secrets,
|
|
55
|
+
act=act,
|
|
56
|
+
inputs=result.inputs,
|
|
57
|
+
outputs=result.outputs,
|
|
58
|
+
description=description,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
target_dir = Path(out_dir) if out_dir is not None else Path.cwd() / "blueprints"
|
|
62
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
path = target_dir / f"{slugify_name(name)}.blueprint.json"
|
|
64
|
+
path.write_text(json.dumps(blueprint, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
validate_for_act(load_blueprint(path))
|
|
68
|
+
except Exception as exc: # a produced-invalid Blueprint is our bug: surface it, do not hide it
|
|
69
|
+
raise BlueprintValidationError(
|
|
70
|
+
f"The recorder produced an invalid Blueprint: {exc}"
|
|
71
|
+
) from exc
|
|
72
|
+
return path
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""DOM capture data types shared by the Continuum backend and its tests.
|
|
2
|
+
|
|
3
|
+
The live-browser lifecycle now lives in the generic :mod:`.session`; this module holds the typed
|
|
4
|
+
representation of a captured DOM event and the parser that builds an element descriptor from the JSON
|
|
5
|
+
the in-page scripts report (:mod:`._capture_js`, :mod:`._overlay_js`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .selector_synth import Candidate, ElementDescriptor
|
|
15
|
+
|
|
16
|
+
# Overlay pick kinds, carrying an extraction/meta ``config`` rather than a plain action. ``finish`` is
|
|
17
|
+
# a control message handled by the backend (end the session), never turned into a recorded event.
|
|
18
|
+
_OVERLAY_KINDS = frozenset({"extract", "extract_records", "wait_for", "parameterize"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class RecordedEvent:
|
|
23
|
+
"""One captured user action, before it is transformed into a Blueprint step.
|
|
24
|
+
|
|
25
|
+
``descriptor`` is the target element (the repeating container for records) or ``None`` for a
|
|
26
|
+
navigation. A password field never carries its value: ``redacted`` is set instead. ``config``
|
|
27
|
+
carries an overlay pick's details (name / as / attr / list / fields / input name).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
kind: str # navigate | click | fill | select | press | extract | extract_records | wait_for | parameterize
|
|
31
|
+
descriptor: ElementDescriptor | None = None
|
|
32
|
+
value: str | None = None
|
|
33
|
+
key: str | None = None
|
|
34
|
+
url: str | None = None
|
|
35
|
+
redacted: bool = False
|
|
36
|
+
config: dict[str, Any] | None = None
|
|
37
|
+
ts: float = field(default_factory=time.monotonic)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _descriptor_from_raw(raw: dict[str, Any]) -> ElementDescriptor:
|
|
41
|
+
"""Build an :class:`ElementDescriptor` from the JSON an in-page script sent."""
|
|
42
|
+
candidates = tuple(
|
|
43
|
+
Candidate(
|
|
44
|
+
strategy=str(c["strategy"]),
|
|
45
|
+
selector=str(c["selector"]),
|
|
46
|
+
selector_type=str(c.get("selector_type", "css")),
|
|
47
|
+
unique=bool(c["unique"]),
|
|
48
|
+
)
|
|
49
|
+
for c in raw.get("candidates", [])
|
|
50
|
+
)
|
|
51
|
+
return ElementDescriptor(
|
|
52
|
+
tag=str(raw.get("tag", "")),
|
|
53
|
+
css_path=str(raw.get("css_path", "")),
|
|
54
|
+
candidates=candidates,
|
|
55
|
+
text=raw.get("text"),
|
|
56
|
+
name=raw.get("name"),
|
|
57
|
+
field_type=raw.get("field_type"),
|
|
58
|
+
autocomplete=raw.get("autocomplete"),
|
|
59
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Continuum recorder backend: capture DOM interactions and picks into a browser Blueprint.
|
|
2
|
+
|
|
3
|
+
Injects the shared selector primitives, the action-capture script and the pick overlay; collects the
|
|
4
|
+
DOM events they report (plus top-level navigations); and transforms them into ``continuum`` steps via
|
|
5
|
+
:mod:`._transform`. This is the Act II half of the Act-agnostic recorder (:mod:`.base`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ._capture_js import RECORDER_JS
|
|
14
|
+
from ._overlay_js import OVERLAY_JS
|
|
15
|
+
from ._selector_js import SELECTOR_JS
|
|
16
|
+
from ._transform import events_to_steps
|
|
17
|
+
from .base import RecordingResult, register_backend
|
|
18
|
+
from .capture import _OVERLAY_KINDS, RecordedEvent, _descriptor_from_raw
|
|
19
|
+
from .selector_synth import synthesize
|
|
20
|
+
from .session import RecordingSession
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def describe_event(event: RecordedEvent) -> str:
|
|
24
|
+
"""Sober one-line description of a captured DOM action, for the Console event log / CLI."""
|
|
25
|
+
if event.kind == "navigate" or (event.kind == "click" and event.url):
|
|
26
|
+
return f"navigate {event.url or ''}"
|
|
27
|
+
selector = synthesize(event.descriptor).selector if event.descriptor else "?"
|
|
28
|
+
config = event.config or {}
|
|
29
|
+
if event.kind == "extract":
|
|
30
|
+
return f"extract {config.get('name', '?')} {selector}"
|
|
31
|
+
if event.kind == "extract_records":
|
|
32
|
+
fields = ", ".join(f.get("name", "?") for f in config.get("fields", []))
|
|
33
|
+
return f"extract table {config.get('name', '?')} ({fields}) {selector}"
|
|
34
|
+
if event.kind == "wait_for":
|
|
35
|
+
return f"wait_for {selector}"
|
|
36
|
+
if event.kind == "parameterize":
|
|
37
|
+
return f"input {config.get('name', '?')} {selector}"
|
|
38
|
+
if event.kind == "press":
|
|
39
|
+
return f"press {event.key or 'Enter'} {selector}"
|
|
40
|
+
return f"{event.kind} {selector}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ContinuumRecorder:
|
|
44
|
+
"""Records DOM interactions and overlay picks into a ``continuum`` Blueprint."""
|
|
45
|
+
|
|
46
|
+
act = "continuum"
|
|
47
|
+
|
|
48
|
+
def __init__(self, *, credentials_as_secrets: bool = True) -> None:
|
|
49
|
+
self._credentials_as_secrets = credentials_as_secrets
|
|
50
|
+
self._events: list[RecordedEvent] = []
|
|
51
|
+
self._last_nav_url: str | None = None
|
|
52
|
+
self._session: RecordingSession | None = None
|
|
53
|
+
|
|
54
|
+
def init_scripts(self) -> list[str]:
|
|
55
|
+
return [SELECTOR_JS, RECORDER_JS, OVERLAY_JS]
|
|
56
|
+
|
|
57
|
+
def attach(self, session: RecordingSession) -> None:
|
|
58
|
+
self._session = session
|
|
59
|
+
session.expose("__aetherius_capture", self._on_binding)
|
|
60
|
+
session.on_new_page(self._wire_page)
|
|
61
|
+
|
|
62
|
+
def result(self) -> RecordingResult:
|
|
63
|
+
steps, secrets, inputs, outputs = events_to_steps(
|
|
64
|
+
self._events, credentials_as_secrets=self._credentials_as_secrets
|
|
65
|
+
)
|
|
66
|
+
return RecordingResult(steps=steps, secrets=secrets, inputs=inputs, outputs=outputs)
|
|
67
|
+
|
|
68
|
+
# ── capture ────────────────────────────────────────────────────────────────
|
|
69
|
+
def _wire_page(self, page: Any) -> None:
|
|
70
|
+
page.on("framenavigated", self._on_framenavigated)
|
|
71
|
+
|
|
72
|
+
def _on_framenavigated(self, frame: Any) -> None:
|
|
73
|
+
if frame.parent_frame is not None:
|
|
74
|
+
return # only the main frame is a user-visible navigation
|
|
75
|
+
url = frame.url
|
|
76
|
+
if not url or url == "about:blank" or url == self._last_nav_url:
|
|
77
|
+
return
|
|
78
|
+
self._last_nav_url = url
|
|
79
|
+
self._emit(RecordedEvent(kind="navigate", url=url))
|
|
80
|
+
|
|
81
|
+
def _on_binding(self, _source: dict[str, Any], payload: str) -> None:
|
|
82
|
+
try:
|
|
83
|
+
data = json.loads(payload)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return
|
|
86
|
+
kind = str(data.get("kind", ""))
|
|
87
|
+
if kind == "finish":
|
|
88
|
+
if self._session is not None:
|
|
89
|
+
self._session.finish() # overlay Finish: end without closing the window
|
|
90
|
+
return
|
|
91
|
+
raw = data.get("descriptor")
|
|
92
|
+
descriptor = _descriptor_from_raw(raw) if raw else None
|
|
93
|
+
if kind in _OVERLAY_KINDS:
|
|
94
|
+
config = {k: v for k, v in data.items() if k not in ("kind", "descriptor")}
|
|
95
|
+
self._emit(RecordedEvent(kind=kind, descriptor=descriptor, config=config))
|
|
96
|
+
return
|
|
97
|
+
self._emit(
|
|
98
|
+
RecordedEvent(
|
|
99
|
+
kind=kind,
|
|
100
|
+
descriptor=descriptor,
|
|
101
|
+
value=data.get("value"),
|
|
102
|
+
key=data.get("key"),
|
|
103
|
+
url=data.get("href"), # set for a link click: replayed as a navigate, not a click
|
|
104
|
+
redacted=bool(data.get("redacted", False)),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _emit(self, event: RecordedEvent) -> None:
|
|
109
|
+
self._events.append(event)
|
|
110
|
+
if self._session is not None:
|
|
111
|
+
self._session.notify(describe_event(event))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
register_backend(
|
|
115
|
+
"continuum",
|
|
116
|
+
lambda **o: ContinuumRecorder(
|
|
117
|
+
credentials_as_secrets=bool(o.get("credentials_as_secrets", True))
|
|
118
|
+
),
|
|
119
|
+
)
|