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,262 @@
|
|
|
1
|
+
"""The recorder's floating "mod menu": an in-page overlay to pick data and pose meta-actions.
|
|
2
|
+
|
|
3
|
+
Injected like the debug overlay (``add_init_script``, re-installed on every navigation), but isolated
|
|
4
|
+
in a Shadow DOM so the page's CSS cannot mangle it and vice versa. It hosts a toolbar (Pick data /
|
|
5
|
+
Pick table / Wait for / Make input / Finish); a hover highlight; and small panels to choose the
|
|
6
|
+
extraction format. Element descriptors and selectors come from the shared ``window.__aeSel``
|
|
7
|
+
(:mod:`._selector_js`); the ranking policy stays pure Python (:mod:`.selector_synth`).
|
|
8
|
+
|
|
9
|
+
All emit paths funnel through ``window.__aeOverlay.{emitExtract,emitRecords,emitWaitFor,emitParam}``,
|
|
10
|
+
which the UI calls and integration tests drive directly (so the payload contract is testable without
|
|
11
|
+
replaying the whole click-through). While any mode is active, ``window.__aeRecorderPicking`` tells the
|
|
12
|
+
action-capture script to stand down.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
# Self-invoking, guarded. Requires window.__aeSel (inject SELECTOR_JS first). Emits through the same
|
|
18
|
+
# __aetherius_capture binding as action capture, with pick/records/wait_for/parameterize/finish kinds.
|
|
19
|
+
OVERLAY_JS = r"""
|
|
20
|
+
(() => {
|
|
21
|
+
if (window.__aeOverlay) return;
|
|
22
|
+
const OVERLAY_ID = '__ae-recorder-overlay';
|
|
23
|
+
const ACCENT = '#c9a94a';
|
|
24
|
+
|
|
25
|
+
const send = (p) => { try { window.__aetherius_capture(JSON.stringify(p)); } catch (e) {} };
|
|
26
|
+
const S = () => window.__aeSel;
|
|
27
|
+
|
|
28
|
+
const STATUS = {
|
|
29
|
+
idle: 'Idle — pick a tool.',
|
|
30
|
+
pick: 'Pick data: click an element to extract.',
|
|
31
|
+
table: 'Pick table: click the repeating item, then each field, then Done.',
|
|
32
|
+
waitfor: 'Wait for: click the element to wait for.',
|
|
33
|
+
makeinput: 'Make input: click a field you filled to parameterize it.',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let host, root, statusEl, panelEl, highlight, doneBtn;
|
|
37
|
+
let mode = 'idle';
|
|
38
|
+
let container = null; // repeating-record container element (table mode)
|
|
39
|
+
let fields = []; // [{name, el, as, attr}]
|
|
40
|
+
|
|
41
|
+
const guessType = (el) => {
|
|
42
|
+
const tag = el.tagName.toLowerCase();
|
|
43
|
+
if (tag === 'img') return { as: 'attr', attr: 'src' };
|
|
44
|
+
if (tag === 'a') return { as: 'attr', attr: 'href' };
|
|
45
|
+
const t = (el.textContent || '').trim();
|
|
46
|
+
if (t && t.length < 24 && /\d/.test(t) && /^[^A-Za-z]*-?\d/.test(t)) return { as: 'number', attr: '' };
|
|
47
|
+
return { as: 'text', attr: '' };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const guessName = (el) => {
|
|
51
|
+
const raw = el.getAttribute('data-testid') || el.id || el.getAttribute('name') ||
|
|
52
|
+
(S().visibleText(el) || '') || el.tagName.toLowerCase();
|
|
53
|
+
return raw.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'field';
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ── Emit API (called by the UI and by integration tests) ──────────────────
|
|
57
|
+
window.__aeOverlay = {
|
|
58
|
+
emitExtract(el, cfg) {
|
|
59
|
+
// `group` (matches all siblings) is used when as is list/count; single values use the
|
|
60
|
+
// unique selector Python synthesizes from the descriptor.
|
|
61
|
+
send({ kind: 'extract', descriptor: S().descriptorFor(el), name: cfg.name, as: cfg.as,
|
|
62
|
+
attr: cfg.attr || null, item: cfg.item || null, group: S().groupSelector(el) });
|
|
63
|
+
},
|
|
64
|
+
emitRecords(cont, fieldDefs, name) {
|
|
65
|
+
send({ kind: 'extract_records', descriptor: S().descriptorFor(cont), name,
|
|
66
|
+
each: S().groupSelector(cont),
|
|
67
|
+
fields: fieldDefs.map((f) => ({
|
|
68
|
+
name: f.name, selector: S().relativeSelector(cont, f.el),
|
|
69
|
+
as: f.as || 'text', attr: f.attr || null })) });
|
|
70
|
+
},
|
|
71
|
+
emitWaitFor(el) {
|
|
72
|
+
// group selector fallback: waiting is a presence check, so a class that matches the whole
|
|
73
|
+
// group beats a brittle positional path when the element has no unique hook.
|
|
74
|
+
send({ kind: 'wait_for', descriptor: S().descriptorFor(el), group: S().groupSelector(el) });
|
|
75
|
+
},
|
|
76
|
+
emitParam(el, name) { send({ kind: 'parameterize', descriptor: S().descriptorFor(el), name }); },
|
|
77
|
+
setMode: (m) => setMode(m),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ── UI ─────────────────────────────────────────────────────────────────────
|
|
81
|
+
const el = (tag, css, text) => {
|
|
82
|
+
const n = document.createElement(tag);
|
|
83
|
+
if (css) n.style.cssText = css;
|
|
84
|
+
if (text != null) n.textContent = text;
|
|
85
|
+
return n;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const button = (label, onclick) => {
|
|
89
|
+
const b = el('button',
|
|
90
|
+
'margin:0 4px 0 0;padding:4px 8px;font:12px system-ui;cursor:pointer;border-radius:5px;' +
|
|
91
|
+
'border:1px solid ' + ACCENT + '55;background:#1c1830;color:#e7e2f3;', label);
|
|
92
|
+
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onclick(); });
|
|
93
|
+
return b;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const clearPanel = () => { if (panelEl) panelEl.innerHTML = ''; };
|
|
97
|
+
|
|
98
|
+
const setMode = (m) => {
|
|
99
|
+
mode = m;
|
|
100
|
+
window.__aeRecorderPicking = (m !== 'idle');
|
|
101
|
+
if (statusEl) statusEl.textContent = STATUS[m] || '';
|
|
102
|
+
if (highlight) highlight.style.display = (m === 'idle') ? 'none' : 'block';
|
|
103
|
+
if (doneBtn) doneBtn.style.display = (m === 'table') ? 'inline-block' : 'none';
|
|
104
|
+
if (m === 'idle') { clearPanel(); container = null; fields = []; }
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const fieldRow = (labelText, node) => {
|
|
108
|
+
const row = el('div', 'display:flex;align-items:center;gap:6px;margin:4px 0;');
|
|
109
|
+
row.appendChild(el('label', 'font:12px system-ui;color:#b9b2d0;min-width:64px;', labelText));
|
|
110
|
+
row.appendChild(node);
|
|
111
|
+
return row;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const textInput = (value) => {
|
|
115
|
+
const i = el('input', 'flex:1;padding:3px 6px;font:12px system-ui;border-radius:4px;' +
|
|
116
|
+
'border:1px solid #4a4368;background:#0f0b1e;color:#e7e2f3;');
|
|
117
|
+
i.value = value || '';
|
|
118
|
+
return i;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const typeSelect = (value) => {
|
|
122
|
+
const s = el('select', 'padding:3px;font:12px system-ui;border-radius:4px;' +
|
|
123
|
+
'border:1px solid #4a4368;background:#0f0b1e;color:#e7e2f3;');
|
|
124
|
+
for (const t of ['text', 'number', 'html', 'attr', 'count']) {
|
|
125
|
+
const o = el('option', '', t); o.value = t; if (t === value) o.selected = true; s.appendChild(o);
|
|
126
|
+
}
|
|
127
|
+
return s;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Format panel for a single element (Pick data).
|
|
131
|
+
const openFormPanel = (target) => {
|
|
132
|
+
clearPanel();
|
|
133
|
+
const g = guessType(target);
|
|
134
|
+
const name = textInput(guessName(target));
|
|
135
|
+
const type = typeSelect(g.as);
|
|
136
|
+
const attr = textInput(g.attr);
|
|
137
|
+
const attrRow = fieldRow('attr', attr);
|
|
138
|
+
const allBox = el('input'); allBox.type = 'checkbox';
|
|
139
|
+
const sync = () => { attrRow.style.display = (type.value === 'attr') ? 'flex' : 'none'; };
|
|
140
|
+
type.addEventListener('change', sync);
|
|
141
|
+
|
|
142
|
+
panelEl.appendChild(fieldRow('name', name));
|
|
143
|
+
panelEl.appendChild(fieldRow('type', type));
|
|
144
|
+
panelEl.appendChild(attrRow);
|
|
145
|
+
panelEl.appendChild(fieldRow('all matching', allBox));
|
|
146
|
+
const actions = el('div', 'margin-top:6px;');
|
|
147
|
+
actions.appendChild(button('Add', () => {
|
|
148
|
+
const asList = allBox.checked && type.value !== 'count';
|
|
149
|
+
const as = asList ? 'list' : type.value;
|
|
150
|
+
window.__aeOverlay.emitExtract(target, { name: name.value, as,
|
|
151
|
+
attr: type.value === 'attr' ? attr.value : '', item: asList ? type.value : null });
|
|
152
|
+
setMode('idle');
|
|
153
|
+
}));
|
|
154
|
+
actions.appendChild(button('Cancel', () => setMode('idle')));
|
|
155
|
+
panelEl.appendChild(actions);
|
|
156
|
+
sync();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Field panel for a record field (Pick table, after the container is chosen).
|
|
160
|
+
const openFieldPanel = (target) => {
|
|
161
|
+
clearPanel();
|
|
162
|
+
const g = guessType(target);
|
|
163
|
+
const name = textInput(guessName(target));
|
|
164
|
+
const type = typeSelect(g.as === 'count' ? 'text' : g.as);
|
|
165
|
+
panelEl.appendChild(el('div', 'font:12px system-ui;color:' + ACCENT + ';', 'Record field'));
|
|
166
|
+
panelEl.appendChild(fieldRow('name', name));
|
|
167
|
+
panelEl.appendChild(fieldRow('type', type));
|
|
168
|
+
const actions = el('div', 'margin-top:6px;');
|
|
169
|
+
actions.appendChild(button('Add field', () => {
|
|
170
|
+
fields.push({ name: name.value, el: target, as: type.value, attr: g.attr });
|
|
171
|
+
statusEl.textContent = fields.length + ' field(s) — click more, then Done.';
|
|
172
|
+
clearPanel();
|
|
173
|
+
}));
|
|
174
|
+
panelEl.appendChild(actions);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const openInputPanel = (target) => {
|
|
178
|
+
clearPanel();
|
|
179
|
+
const name = textInput(guessName(target));
|
|
180
|
+
panelEl.appendChild(fieldRow('input name', name));
|
|
181
|
+
const actions = el('div', 'margin-top:6px;');
|
|
182
|
+
actions.appendChild(button('Make input', () => {
|
|
183
|
+
window.__aeOverlay.emitParam(target, name.value); setMode('idle');
|
|
184
|
+
}));
|
|
185
|
+
actions.appendChild(button('Cancel', () => setMode('idle')));
|
|
186
|
+
panelEl.appendChild(actions);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const targetOf = (e) => {
|
|
190
|
+
const path = e.composedPath ? e.composedPath() : [e.target];
|
|
191
|
+
if (path.some((n) => n && n.id === OVERLAY_ID)) return null; // never target the overlay
|
|
192
|
+
return e.target;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const onMove = (e) => {
|
|
196
|
+
if (mode === 'idle' || !highlight) return;
|
|
197
|
+
const t = targetOf(e);
|
|
198
|
+
if (!t || !t.getBoundingClientRect) { highlight.style.display = 'none'; return; }
|
|
199
|
+
const r = t.getBoundingClientRect();
|
|
200
|
+
highlight.style.display = 'block';
|
|
201
|
+
highlight.style.left = r.left + 'px'; highlight.style.top = r.top + 'px';
|
|
202
|
+
highlight.style.width = r.width + 'px'; highlight.style.height = r.height + 'px';
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const onClick = (e) => {
|
|
206
|
+
if (mode === 'idle') return;
|
|
207
|
+
const t = targetOf(e);
|
|
208
|
+
if (!t) return; // clicks on the overlay's own controls fall through to their handlers
|
|
209
|
+
e.preventDefault(); e.stopPropagation();
|
|
210
|
+
if (mode === 'pick') { openFormPanel(t); }
|
|
211
|
+
else if (mode === 'waitfor') { window.__aeOverlay.emitWaitFor(t); setMode('idle'); }
|
|
212
|
+
else if (mode === 'makeinput') { openInputPanel(t); }
|
|
213
|
+
else if (mode === 'table') {
|
|
214
|
+
if (!container) { container = t; statusEl.textContent = 'Container set — click each field.'; }
|
|
215
|
+
else { openFieldPanel(t); }
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const install = () => {
|
|
220
|
+
const rootEl = document.documentElement;
|
|
221
|
+
if (!rootEl || document.getElementById(OVERLAY_ID)) return;
|
|
222
|
+
host = el('div'); host.id = OVERLAY_ID;
|
|
223
|
+
host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;';
|
|
224
|
+
rootEl.appendChild(host);
|
|
225
|
+
root = host.attachShadow({ mode: 'open' });
|
|
226
|
+
|
|
227
|
+
highlight = el('div', 'position:fixed;z-index:2147483646;pointer-events:none;display:none;' +
|
|
228
|
+
'border:2px solid ' + ACCENT + ';background:' + ACCENT + '22;box-sizing:border-box;');
|
|
229
|
+
root.appendChild(highlight);
|
|
230
|
+
|
|
231
|
+
const bar = el('div', 'position:fixed;top:10px;right:10px;width:270px;padding:8px 10px;' +
|
|
232
|
+
'font:12px system-ui;background:#171327ee;color:#e7e2f3;border:1px solid ' + ACCENT + '66;' +
|
|
233
|
+
'border-radius:8px;box-shadow:0 6px 24px #000a;');
|
|
234
|
+
const title = el('div', 'font-weight:600;color:' + ACCENT + ';margin-bottom:6px;', 'Aetherius recorder');
|
|
235
|
+
const tools = el('div', 'margin-bottom:6px;');
|
|
236
|
+
tools.appendChild(button('Pick data', () => setMode('pick')));
|
|
237
|
+
tools.appendChild(button('Pick table', () => setMode('table')));
|
|
238
|
+
tools.appendChild(button('Wait for', () => setMode('waitfor')));
|
|
239
|
+
tools.appendChild(button('Make input', () => setMode('makeinput')));
|
|
240
|
+
doneBtn = button('Done', () => {
|
|
241
|
+
if (container && fields.length) window.__aeOverlay.emitRecords(container, fields, 'records');
|
|
242
|
+
setMode('idle');
|
|
243
|
+
});
|
|
244
|
+
doneBtn.style.display = 'none';
|
|
245
|
+
tools.appendChild(doneBtn);
|
|
246
|
+
tools.appendChild(button('Finish', () => { setMode('idle'); send({ kind: 'finish' }); }));
|
|
247
|
+
statusEl = el('div', 'color:#b9b2d0;min-height:16px;', STATUS.idle);
|
|
248
|
+
panelEl = el('div', 'margin-top:6px;');
|
|
249
|
+
bar.appendChild(title); bar.appendChild(tools); bar.appendChild(statusEl); bar.appendChild(panelEl);
|
|
250
|
+
root.appendChild(bar);
|
|
251
|
+
|
|
252
|
+
document.addEventListener('mousemove', onMove, true);
|
|
253
|
+
document.addEventListener('click', onClick, true);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
if (document.readyState === 'loading') {
|
|
257
|
+
document.addEventListener('DOMContentLoaded', install);
|
|
258
|
+
} else {
|
|
259
|
+
install();
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
"""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Shared Playwright plumbing for the recorders: lazy import and the synchronous pump loop.
|
|
2
|
+
|
|
3
|
+
Both recorders drive a visible browser and collect callbacks that the sync Playwright API only
|
|
4
|
+
dispatches while a Playwright call is in flight. They share the same lazy import (so ``import
|
|
5
|
+
aetherius`` stays light and a missing extra is a typed error) and the same idle pump, kept here once.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..core.errors import DependencyError
|
|
14
|
+
|
|
15
|
+
# How often the pump wakes to let the sync Playwright API dispatch pending binding/event callbacks.
|
|
16
|
+
_PUMP_INTERVAL_MS = 200
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def import_playwright() -> Any:
|
|
20
|
+
"""Import the sync Playwright entry point, or raise a typed, actionable error."""
|
|
21
|
+
try:
|
|
22
|
+
from playwright.sync_api import sync_playwright
|
|
23
|
+
except ImportError as exc: # extra [browser] not installed
|
|
24
|
+
raise DependencyError(
|
|
25
|
+
"The recorder requires Playwright. Install it with:\n"
|
|
26
|
+
' pip install "aetherius[browser]" && playwright install chromium',
|
|
27
|
+
extra="browser",
|
|
28
|
+
) from exc
|
|
29
|
+
return sync_playwright
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pump(context: Any, disconnected: threading.Event, *stop_events: threading.Event | None) -> None:
|
|
33
|
+
"""Keep the sync API alive so callbacks fire, until the window closes or a stop event is set.
|
|
34
|
+
|
|
35
|
+
Several stop events are accepted (e.g. the caller's Stop and the overlay's Finish); any one ends
|
|
36
|
+
the loop. ``None`` entries are ignored so callers can pass optional events directly.
|
|
37
|
+
"""
|
|
38
|
+
while not disconnected.is_set() and not any(_is_set(e) for e in stop_events):
|
|
39
|
+
pages = context.pages
|
|
40
|
+
if not pages:
|
|
41
|
+
break
|
|
42
|
+
try:
|
|
43
|
+
pages[0].wait_for_timeout(_PUMP_INTERVAL_MS)
|
|
44
|
+
except Exception: # the page or context went away: the demonstration is over
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_set(event: threading.Event | None) -> bool:
|
|
49
|
+
return event is not None and event.is_set()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Shared in-page selector primitives, installed on ``window.__aeSel``.
|
|
2
|
+
|
|
3
|
+
The only place that can call ``CSS.escape`` and measure a selector's uniqueness against the live DOM.
|
|
4
|
+
Both the action-capture script (:mod:`._capture_js`) and the pick overlay (:mod:`._overlay_js`) build
|
|
5
|
+
their element descriptors from here, so the candidate/uniqueness logic exists once. The ranking policy
|
|
6
|
+
that turns candidates into a Blueprint selector stays pure Python (:mod:`.selector_synth`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Self-invoking, guarded. Attaches the primitives to window so later scripts can reuse them.
|
|
12
|
+
SELECTOR_JS = r"""
|
|
13
|
+
(() => {
|
|
14
|
+
if (window.__aeSel) return;
|
|
15
|
+
|
|
16
|
+
const CLICKABLE = 'a, button, [role="button"], input[type="submit"], input[type="button"], ' +
|
|
17
|
+
'input[type="reset"], input[type="checkbox"], input[type="radio"]';
|
|
18
|
+
|
|
19
|
+
const isUnique = (sel, root) => {
|
|
20
|
+
try { return (root || document).querySelectorAll(sel).length === 1; } catch (e) { return false; }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Escape a value for use inside an [attr="..."] string: only backslash and double-quote.
|
|
24
|
+
// (CSS.escape is for identifiers like #id/.class and would over-escape `/`, spaces, etc.)
|
|
25
|
+
const attrValue = (v) => String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
26
|
+
|
|
27
|
+
const testIdAttr = (el) => {
|
|
28
|
+
for (const name of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
|
|
29
|
+
if (el.hasAttribute(name)) return [name, el.getAttribute(name)];
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const cssPath = (el) => {
|
|
35
|
+
const parts = [];
|
|
36
|
+
let node = el;
|
|
37
|
+
while (node && node.nodeType === 1 && node !== document.body) {
|
|
38
|
+
let part = node.tagName.toLowerCase();
|
|
39
|
+
if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
|
|
40
|
+
const parent = node.parentElement;
|
|
41
|
+
if (parent) {
|
|
42
|
+
const sameTag = [...parent.children].filter((c) => c.tagName === node.tagName);
|
|
43
|
+
if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
|
|
44
|
+
}
|
|
45
|
+
parts.unshift(part);
|
|
46
|
+
node = node.parentElement;
|
|
47
|
+
}
|
|
48
|
+
return parts.join(' > ');
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const visibleText = (el) => {
|
|
52
|
+
const t = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
53
|
+
return t.length > 0 && t.length <= 64 ? t : null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const candidates = (el) => {
|
|
57
|
+
const tag = el.tagName.toLowerCase();
|
|
58
|
+
const out = [];
|
|
59
|
+
const push = (strategy, selector, selector_type) =>
|
|
60
|
+
out.push({ strategy, selector, selector_type, unique: isUnique(selector) });
|
|
61
|
+
|
|
62
|
+
const testId = testIdAttr(el);
|
|
63
|
+
if (testId) push('testid', '[' + testId[0] + '="' + attrValue(testId[1]) + '"]', 'css');
|
|
64
|
+
if (el.id) push('id', '#' + CSS.escape(el.id), 'css');
|
|
65
|
+
const href = tag === 'a' ? el.getAttribute('href') : null; // the natural, stable hook for a link
|
|
66
|
+
if (href) push('href', 'a[href="' + attrValue(href) + '"]', 'css');
|
|
67
|
+
const name = el.getAttribute('name');
|
|
68
|
+
if (name) push('name', tag + '[name="' + attrValue(name) + '"]', 'css');
|
|
69
|
+
const aria = el.getAttribute('aria-label');
|
|
70
|
+
if (aria) push('aria', '[aria-label="' + attrValue(aria) + '"]', 'css');
|
|
71
|
+
const role = el.getAttribute('role');
|
|
72
|
+
if (role) push('role', tag + '[role="' + attrValue(role) + '"]', 'css');
|
|
73
|
+
for (const cls of el.classList) { // a single class that is unique is a decent, readable hook
|
|
74
|
+
const s = '.' + CSS.escape(cls);
|
|
75
|
+
if (isUnique(s)) { out.push({ strategy: 'class', selector: s, selector_type: 'css', unique: true }); break; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (el.matches(CLICKABLE) || role === 'button') {
|
|
79
|
+
const text = visibleText(el);
|
|
80
|
+
if (text) {
|
|
81
|
+
// Uniqueness must match get_by_text at run time: substring, case-insensitive, whole DOM.
|
|
82
|
+
// Count the *deepest* elements containing the text (no child also contains it), which
|
|
83
|
+
// approximates get_by_text's leaf matching. A brittle "License"-style text stays non-unique.
|
|
84
|
+
const norm = (s) => (s || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
85
|
+
const needle = norm(text);
|
|
86
|
+
const hits = [...document.querySelectorAll('*')].filter((e) =>
|
|
87
|
+
norm(e.textContent).includes(needle) &&
|
|
88
|
+
![...e.children].some((c) => norm(c.textContent).includes(needle)));
|
|
89
|
+
out.push({ strategy: 'text', selector: text, selector_type: 'text', unique: hits.length === 1 });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const descriptorFor = (el) => ({
|
|
96
|
+
tag: el.tagName.toLowerCase(),
|
|
97
|
+
css_path: cssPath(el),
|
|
98
|
+
text: visibleText(el),
|
|
99
|
+
name: el.getAttribute('name'),
|
|
100
|
+
field_type: (el.getAttribute('type') || '').toLowerCase() || null,
|
|
101
|
+
autocomplete: el.getAttribute('autocomplete'),
|
|
102
|
+
candidates: candidates(el),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// A selector matching `el` AND its repeating siblings (for lists/records: the shared class that
|
|
106
|
+
// yields the most matches, else the tag). Distinct from candidates(), which seek a *unique* hook.
|
|
107
|
+
const groupSelector = (el) => {
|
|
108
|
+
let best = null, bestCount = 1;
|
|
109
|
+
for (const cls of el.classList) {
|
|
110
|
+
const s = '.' + CSS.escape(cls);
|
|
111
|
+
const n = isUnique(s) ? 1 : (() => { try { return document.querySelectorAll(s).length; } catch (e) { return 0; } })();
|
|
112
|
+
if (n >= 2 && n > bestCount) { best = s; bestCount = n; }
|
|
113
|
+
}
|
|
114
|
+
if (best) return best;
|
|
115
|
+
const tag = el.tagName.toLowerCase();
|
|
116
|
+
return tag;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// A selector for `el` scoped *within* `container` (for records: `.title` inside `.product`).
|
|
120
|
+
const relativeSelector = (container, el) => {
|
|
121
|
+
const testId = testIdAttr(el);
|
|
122
|
+
if (testId) {
|
|
123
|
+
const s = '[' + testId[0] + '="' + attrValue(testId[1]) + '"]';
|
|
124
|
+
if (isUnique(s, container)) return s;
|
|
125
|
+
}
|
|
126
|
+
for (const cls of el.classList) {
|
|
127
|
+
const s = '.' + CSS.escape(cls);
|
|
128
|
+
if (isUnique(s, container)) return s;
|
|
129
|
+
}
|
|
130
|
+
const tag = el.tagName.toLowerCase();
|
|
131
|
+
if (isUnique(tag, container)) return tag;
|
|
132
|
+
// Fallback: nth-of-type path from just under the container down to el.
|
|
133
|
+
const parts = [];
|
|
134
|
+
let node = el;
|
|
135
|
+
while (node && node !== container && node.parentElement) {
|
|
136
|
+
let part = node.tagName.toLowerCase();
|
|
137
|
+
const sameTag = [...node.parentElement.children].filter((c) => c.tagName === node.tagName);
|
|
138
|
+
if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
|
|
139
|
+
parts.unshift(part);
|
|
140
|
+
node = node.parentElement;
|
|
141
|
+
}
|
|
142
|
+
return parts.join(' > ') || tag;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
window.__aeSel = { CLICKABLE, isUnique, cssPath, visibleText, candidates, descriptorFor,
|
|
146
|
+
groupSelector, relativeSelector };
|
|
147
|
+
})();
|
|
148
|
+
"""
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Pure transformation of captured events into Blueprint steps, inputs, secrets and outputs.
|
|
2
|
+
|
|
3
|
+
No browser here: this is the testable heart of the recorder. Actions become navigate/click/fill/
|
|
4
|
+
select/press steps; overlay picks become a single coalesced ``extract`` step (single values, lists or
|
|
5
|
+
record tables) plus the Blueprint ``outputs`` map; ``wait_for`` and ``parameterize`` (turn a filled
|
|
6
|
+
value into an ``input``) round out the "beyond clicks" meta-actions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ._names import ident as _ident
|
|
14
|
+
from ._names import unique as _unique
|
|
15
|
+
from .capture import RecordedEvent
|
|
16
|
+
from .selector_synth import ElementDescriptor, SelectorChoice, synthesize
|
|
17
|
+
|
|
18
|
+
_CREDENTIAL_TOKENS = ("username", "user", "login", "email", "mail", "pass", "identifiant", "pseudo")
|
|
19
|
+
_CREDENTIAL_AUTOCOMPLETE = {"username", "email", "current-password", "new-password"}
|
|
20
|
+
# A navigation right after one of these was triggered by it, not typed by hand.
|
|
21
|
+
_NAV_TRIGGERS = frozenset({"click", "press"})
|
|
22
|
+
_ELEMENT_KINDS = frozenset({"click", "fill", "select", "press"})
|
|
23
|
+
_EXTRACT_KINDS = frozenset({"extract", "extract_records"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _selector_fields(choice: SelectorChoice) -> dict[str, str]:
|
|
27
|
+
"""Selector fields for a step: ``selector_type`` omitted when it is the ``css`` default."""
|
|
28
|
+
fields = {"selector": choice.selector}
|
|
29
|
+
if choice.selector_type != "css":
|
|
30
|
+
fields["selector_type"] = choice.selector_type
|
|
31
|
+
return fields
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_credential(descriptor: ElementDescriptor | None) -> bool:
|
|
35
|
+
"""True when a text field looks like a credential worth turning into a secret."""
|
|
36
|
+
if descriptor is None:
|
|
37
|
+
return False
|
|
38
|
+
if (descriptor.field_type or "") == "password":
|
|
39
|
+
return True
|
|
40
|
+
if (descriptor.autocomplete or "").lower() in _CREDENTIAL_AUTOCOMPLETE:
|
|
41
|
+
return True
|
|
42
|
+
haystack = f"{descriptor.name or ''} {descriptor.autocomplete or ''}".lower()
|
|
43
|
+
return any(token in haystack for token in _CREDENTIAL_TOKENS)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _choice(descriptor: ElementDescriptor | None) -> SelectorChoice:
|
|
47
|
+
return synthesize(descriptor) if descriptor is not None else SelectorChoice("")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Recording:
|
|
51
|
+
"""Accumulates the Blueprint pieces as captured events are handled, in order."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, *, credentials_as_secrets: bool) -> None:
|
|
54
|
+
self.credentials_as_secrets = credentials_as_secrets
|
|
55
|
+
self.steps: list[dict[str, Any]] = []
|
|
56
|
+
self.secrets: list[str] = []
|
|
57
|
+
self.inputs: dict[str, Any] = {}
|
|
58
|
+
self.outputs: dict[str, Any] = {}
|
|
59
|
+
self._secret_by_selector: dict[str, str] = {}
|
|
60
|
+
self._extract: dict[str, Any] | None = None # current open extract step
|
|
61
|
+
self._extract_id: str = ""
|
|
62
|
+
self._extract_seq = 0
|
|
63
|
+
self._output_names: set[str] = set()
|
|
64
|
+
self._prev_kind: str | None = None
|
|
65
|
+
|
|
66
|
+
def handle(self, event: RecordedEvent) -> None:
|
|
67
|
+
kind = event.kind
|
|
68
|
+
if kind in _EXTRACT_KINDS:
|
|
69
|
+
self._add_extraction(event)
|
|
70
|
+
else:
|
|
71
|
+
self._extract = None # any non-extraction closes the coalesced extract step
|
|
72
|
+
if kind == "navigate":
|
|
73
|
+
self._add_navigate(event)
|
|
74
|
+
elif kind == "wait_for":
|
|
75
|
+
self._add_wait_for(event)
|
|
76
|
+
elif kind == "parameterize":
|
|
77
|
+
self._parameterize(event)
|
|
78
|
+
elif kind in _ELEMENT_KINDS:
|
|
79
|
+
self._add_element(event)
|
|
80
|
+
self._prev_kind = kind
|
|
81
|
+
|
|
82
|
+
# ── actions ──────────────────────────────────────────────────────────────
|
|
83
|
+
def _add_navigate(self, event: RecordedEvent) -> None:
|
|
84
|
+
if self._prev_kind not in _NAV_TRIGGERS and event.url:
|
|
85
|
+
self.steps.append({"action": "navigate", "url": event.url})
|
|
86
|
+
|
|
87
|
+
def _add_wait_for(self, event: RecordedEvent) -> None:
|
|
88
|
+
choice = _choice(event.descriptor)
|
|
89
|
+
config = event.config or {}
|
|
90
|
+
# Presence check: a group class beats a positional path when there is no unique hook.
|
|
91
|
+
if choice.strategy == "css_path" and config.get("group"):
|
|
92
|
+
fields: dict[str, str] = {"selector": str(config["group"])}
|
|
93
|
+
else:
|
|
94
|
+
fields = _selector_fields(choice)
|
|
95
|
+
self.steps.append({"action": "wait_for", **fields})
|
|
96
|
+
|
|
97
|
+
def _add_element(self, event: RecordedEvent) -> None:
|
|
98
|
+
choice = _choice(event.descriptor)
|
|
99
|
+
selector_fields = _selector_fields(choice)
|
|
100
|
+
if event.kind == "click":
|
|
101
|
+
if event.url: # a link click: navigate to its (stable) URL rather than replay the click
|
|
102
|
+
self.steps.append({"action": "navigate", "url": event.url})
|
|
103
|
+
else:
|
|
104
|
+
self.steps.append({"action": "click", **selector_fields})
|
|
105
|
+
elif event.kind == "press":
|
|
106
|
+
self.steps.append({"action": "press", "key": event.key or "Enter", **selector_fields})
|
|
107
|
+
elif event.kind == "select":
|
|
108
|
+
self.steps.append({"action": "select", **selector_fields, "value": event.value or ""})
|
|
109
|
+
elif event.kind == "fill":
|
|
110
|
+
self._add_fill(event, choice, selector_fields)
|
|
111
|
+
|
|
112
|
+
def _add_fill(
|
|
113
|
+
self, event: RecordedEvent, choice: SelectorChoice, selector_fields: dict[str, str]
|
|
114
|
+
) -> None:
|
|
115
|
+
secret = event.redacted or (
|
|
116
|
+
self.credentials_as_secrets and _is_credential(event.descriptor)
|
|
117
|
+
)
|
|
118
|
+
if secret:
|
|
119
|
+
value = f"{{{{ secrets.{self._secret_for(event, choice)} }}}}"
|
|
120
|
+
else:
|
|
121
|
+
value = event.value or ""
|
|
122
|
+
step = {"action": "fill", **selector_fields, "value": value}
|
|
123
|
+
last = self.steps[-1] if self.steps else None
|
|
124
|
+
if (
|
|
125
|
+
last is not None
|
|
126
|
+
and last.get("action") == "fill"
|
|
127
|
+
and last.get("selector") == choice.selector
|
|
128
|
+
):
|
|
129
|
+
self.steps[-1] = step # coalesce successive edits of the same field
|
|
130
|
+
else:
|
|
131
|
+
self.steps.append(step)
|
|
132
|
+
|
|
133
|
+
def _secret_for(self, event: RecordedEvent, choice: SelectorChoice) -> str:
|
|
134
|
+
name = self._secret_by_selector.get(choice.selector)
|
|
135
|
+
if name is None:
|
|
136
|
+
base = _ident(event.descriptor.name if event.descriptor else None, fallback="secret")
|
|
137
|
+
name = _unique(base, set(self.secrets))
|
|
138
|
+
self._secret_by_selector[choice.selector] = name
|
|
139
|
+
self.secrets.append(name)
|
|
140
|
+
return name
|
|
141
|
+
|
|
142
|
+
def _parameterize(self, event: RecordedEvent) -> None:
|
|
143
|
+
"""Rewrite the most recent literal fill at this selector into an ``{{ inputs.x }}``."""
|
|
144
|
+
selector = _choice(event.descriptor).selector
|
|
145
|
+
for step in reversed(self.steps):
|
|
146
|
+
if step.get("action") == "fill" and step.get("selector") == selector:
|
|
147
|
+
if str(step.get("value", "")).startswith("{{ secrets."):
|
|
148
|
+
return # a secret is not an input; leave it alone
|
|
149
|
+
name = _unique(
|
|
150
|
+
_ident((event.config or {}).get("name"), fallback="value"), set(self.inputs)
|
|
151
|
+
)
|
|
152
|
+
step["value"] = f"{{{{ inputs.{name} }}}}"
|
|
153
|
+
self.inputs[name] = {"type": "string", "required": True}
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# ── extraction ───────────────────────────────────────────────────────────
|
|
157
|
+
def _add_extraction(self, event: RecordedEvent) -> None:
|
|
158
|
+
if self._extract is None:
|
|
159
|
+
self._extract_seq += 1
|
|
160
|
+
self._extract_id = "data" if self._extract_seq == 1 else f"data_{self._extract_seq}"
|
|
161
|
+
self._extract = {"action": "extract", "id": self._extract_id, "outputs": {}}
|
|
162
|
+
self.steps.append(self._extract)
|
|
163
|
+
config = event.config or {}
|
|
164
|
+
name = _unique(_ident(config.get("name"), fallback="value"), self._output_names)
|
|
165
|
+
self._output_names.add(name)
|
|
166
|
+
self._extract["outputs"][name] = self._spec(event, config)
|
|
167
|
+
self.outputs[name] = f"{{{{ steps.{self._extract_id}.{name} }}}}"
|
|
168
|
+
|
|
169
|
+
def _spec(self, event: RecordedEvent, config: dict[str, Any]) -> dict[str, Any]:
|
|
170
|
+
if event.kind == "extract_records":
|
|
171
|
+
return self._records_spec(event, config)
|
|
172
|
+
as_ = str(config.get("as", "text"))
|
|
173
|
+
# list/count read *all* matches, so use the sibling-group selector; single values use the
|
|
174
|
+
# unique selector our policy synthesizes from the descriptor.
|
|
175
|
+
if as_ in ("list", "count") and config.get("group"):
|
|
176
|
+
spec: dict[str, Any] = {"selector": str(config["group"])}
|
|
177
|
+
else:
|
|
178
|
+
spec = dict(_selector_fields(_choice(event.descriptor)))
|
|
179
|
+
spec["as"] = as_
|
|
180
|
+
if as_ == "list":
|
|
181
|
+
item = str(config.get("item") or "text")
|
|
182
|
+
if item != "text":
|
|
183
|
+
spec["item"] = item
|
|
184
|
+
if item == "attr" and config.get("attr"):
|
|
185
|
+
spec["attr"] = str(config["attr"])
|
|
186
|
+
elif as_ == "attr" and config.get("attr"):
|
|
187
|
+
spec["attr"] = str(config["attr"])
|
|
188
|
+
return spec
|
|
189
|
+
|
|
190
|
+
def _records_spec(self, event: RecordedEvent, config: dict[str, Any]) -> dict[str, Any]:
|
|
191
|
+
fields: dict[str, Any] = {}
|
|
192
|
+
taken: set[str] = set()
|
|
193
|
+
for raw in config.get("fields", []):
|
|
194
|
+
fname = _unique(_ident(raw.get("name"), fallback="field"), taken)
|
|
195
|
+
taken.add(fname)
|
|
196
|
+
field_spec = {
|
|
197
|
+
"selector": str(raw.get("selector", "")),
|
|
198
|
+
"as": str(raw.get("as", "text")),
|
|
199
|
+
}
|
|
200
|
+
if raw.get("as") == "attr" and raw.get("attr"):
|
|
201
|
+
field_spec["attr"] = str(raw["attr"])
|
|
202
|
+
fields[fname] = field_spec
|
|
203
|
+
each = str(config.get("each") or _choice(event.descriptor).selector)
|
|
204
|
+
return {"each": each, "fields": fields}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def events_to_steps(
|
|
208
|
+
events: list[RecordedEvent], *, credentials_as_secrets: bool = True
|
|
209
|
+
) -> tuple[list[dict[str, Any]], list[str], dict[str, Any], dict[str, Any]]:
|
|
210
|
+
"""Transform captured events into ``(steps, secrets, inputs, outputs)``."""
|
|
211
|
+
recording = _Recording(credentials_as_secrets=credentials_as_secrets)
|
|
212
|
+
for event in events:
|
|
213
|
+
recording.handle(event)
|
|
214
|
+
return recording.steps, recording.secrets, recording.inputs, recording.outputs
|