@web-auto/camo 0.1.26 → 0.2.1
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.
- package/LICENSE +21 -21
- package/README.md +586 -586
- package/bin/browser-service.mjs +11 -11
- package/bin/camo.mjs +22 -22
- package/package.json +48 -48
- package/scripts/build.mjs +19 -19
- package/scripts/bump-version.mjs +34 -34
- package/scripts/check-file-size.mjs +80 -80
- package/scripts/file-size-policy.json +12 -2
- package/scripts/install.mjs +76 -76
- package/scripts/release.sh +54 -54
- package/src/autoscript/action-providers/index.mjs +6 -6
- package/src/autoscript/impact-engine.mjs +78 -78
- package/src/autoscript/runtime.mjs +1017 -1017
- package/src/autoscript/schema.mjs +376 -376
- package/src/cli.mjs +405 -405
- package/src/commands/attach.mjs +141 -141
- package/src/commands/autoscript.mjs +1011 -1011
- package/src/commands/browser.mjs +1255 -1257
- package/src/commands/container.mjs +401 -401
- package/src/commands/cookies.mjs +69 -69
- package/src/commands/create.mjs +98 -98
- package/src/commands/devtools.mjs +349 -349
- package/src/commands/events.mjs +152 -152
- package/src/commands/highlight-mode.mjs +24 -24
- package/src/commands/init.mjs +68 -68
- package/src/commands/lifecycle.mjs +275 -275
- package/src/commands/mouse.mjs +45 -45
- package/src/commands/profile.mjs +46 -46
- package/src/commands/record.mjs +115 -115
- package/src/commands/system.mjs +14 -14
- package/src/commands/window.mjs +123 -123
- package/src/container/change-notifier.mjs +362 -362
- package/src/container/element-filter.mjs +143 -143
- package/src/container/index.mjs +3 -3
- package/src/container/runtime-core/checkpoint.mjs +209 -209
- package/src/container/runtime-core/index.mjs +21 -21
- package/src/container/runtime-core/operations/index.mjs +774 -774
- package/src/container/runtime-core/operations/selector-scripts.mjs +277 -277
- package/src/container/runtime-core/operations/tab-pool.mjs +746 -746
- package/src/container/runtime-core/operations/viewport.mjs +189 -189
- package/src/container/runtime-core/search.mjs +190 -190
- package/src/container/runtime-core/subscription.mjs +224 -224
- package/src/container/runtime-core/utils.mjs +94 -94
- package/src/container/runtime-core/validation.mjs +127 -184
- package/src/container/runtime-core.mjs +1 -1
- package/src/container/subscription-registry.mjs +459 -459
- package/src/core/actions.mjs +561 -561
- package/src/core/browser.mjs +266 -266
- package/src/core/index.mjs +52 -52
- package/src/core/utils.mjs +91 -91
- package/src/events/daemon-entry.mjs +33 -33
- package/src/events/daemon.mjs +80 -80
- package/src/events/progress-log.mjs +109 -109
- package/src/events/ws-server.mjs +239 -239
- package/src/lib/client.mjs +200 -200
- package/src/lifecycle/cleanup.mjs +83 -83
- package/src/lifecycle/lock.mjs +126 -126
- package/src/lifecycle/session-registry.mjs +279 -279
- package/src/lifecycle/session-view.mjs +76 -76
- package/src/lifecycle/session-watchdog.mjs +281 -281
- package/src/services/browser-service/index.js +671 -674
- package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
- package/src/services/browser-service/internal/BrowserSession.js +325 -336
- package/src/services/browser-service/internal/ElementRegistry.js +60 -60
- package/src/services/browser-service/internal/ProfileLock.js +84 -84
- package/src/services/browser-service/internal/SessionManager.js +184 -184
- package/src/services/browser-service/internal/SessionManager.test.js +39 -39
- package/src/services/browser-service/internal/browser-session/cookies.js +144 -144
- package/src/services/browser-service/internal/browser-session/input-ops.js +222 -219
- package/src/services/browser-service/internal/browser-session/input-pipeline.js +144 -144
- package/src/services/browser-service/internal/browser-session/logging.js +46 -46
- package/src/services/browser-service/internal/browser-session/navigation.js +38 -38
- package/src/services/browser-service/internal/browser-session/page-hooks.js +442 -442
- package/src/services/browser-service/internal/browser-session/page-management.js +302 -336
- package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -148
- package/src/services/browser-service/internal/browser-session/recording.js +198 -198
- package/src/services/browser-service/internal/browser-session/runtime-events.js +61 -61
- package/src/services/browser-service/internal/browser-session/session-core.js +84 -84
- package/src/services/browser-service/internal/browser-session/session-state.js +38 -38
- package/src/services/browser-service/internal/browser-session/types.js +14 -14
- package/src/services/browser-service/internal/browser-session/utils.js +95 -95
- package/src/services/browser-service/internal/browser-session/viewport-manager.js +46 -46
- package/src/services/browser-service/internal/browser-session/viewport.js +215 -215
- package/src/services/browser-service/internal/container-matcher.js +851 -851
- package/src/services/browser-service/internal/container-registry.js +182 -182
- package/src/services/browser-service/internal/engine-manager.js +259 -259
- package/src/services/browser-service/internal/fingerprint.js +203 -203
- package/src/services/browser-service/internal/heartbeat.js +137 -137
- package/src/services/browser-service/internal/logging.js +46 -46
- package/src/services/browser-service/internal/page-runtime/runtime.js +1317 -1317
- package/src/services/browser-service/internal/pageRuntime.js +28 -28
- package/src/services/browser-service/internal/runtimeInjector.js +31 -31
- package/src/services/browser-service/internal/service-process-logger.js +140 -140
- package/src/services/browser-service/internal/state-bus.js +45 -45
- package/src/services/browser-service/internal/storage-paths.js +42 -42
- package/src/services/browser-service/internal/ws-server.js +1194 -1194
- package/src/services/browser-service/internal/ws-server.test.js +58 -58
- package/src/services/browser-service/server.mjs +6 -6
- package/src/services/controller/cli-bridge.js +93 -93
- package/src/services/controller/container-index.js +50 -50
- package/src/services/controller/container-storage.js +36 -36
- package/src/services/controller/controller-actions.js +207 -207
- package/src/services/controller/controller.js +1138 -1138
- package/src/services/controller/selectors.js +54 -54
- package/src/services/controller/transport.js +125 -125
- package/src/utils/args.mjs +26 -26
- package/src/utils/browser-service.mjs +544 -544
- package/src/utils/command-log.mjs +64 -64
- package/src/utils/config.mjs +214 -214
- package/src/utils/fingerprint.mjs +181 -181
- package/src/utils/help.mjs +216 -216
- package/src/utils/js-policy.mjs +13 -13
- package/src/utils/ws-client.mjs +30 -30
- package/src/container/runtime-core/operations/tab-pool.mjs.bak +0 -762
- package/src/container/runtime-core/operations/tab-pool.mjs.syntax-error +0 -762
- package/src/services/browser-service/index.js.bak +0 -671
|
@@ -1,362 +1,362 @@
|
|
|
1
|
-
// Change Notifier - Subscribe to DOM changes and element events
|
|
2
|
-
|
|
3
|
-
function normalizeSelector(selector) {
|
|
4
|
-
if (!selector) return { visible: true };
|
|
5
|
-
if (typeof selector === 'string') return { css: selector, visible: true };
|
|
6
|
-
if (typeof selector !== 'object') return { visible: true };
|
|
7
|
-
return {
|
|
8
|
-
...selector,
|
|
9
|
-
visible: selector.visible !== false,
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function selectorKey(selector) {
|
|
14
|
-
const normalized = normalizeSelector(selector);
|
|
15
|
-
const stable = {
|
|
16
|
-
css: normalized.css || null,
|
|
17
|
-
tag: normalized.tag || null,
|
|
18
|
-
id: normalized.id || null,
|
|
19
|
-
classes: Array.isArray(normalized.classes) ? [...normalized.classes].sort() : [],
|
|
20
|
-
visible: normalized.visible !== false,
|
|
21
|
-
};
|
|
22
|
-
return JSON.stringify(stable);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function parseCssSelector(css) {
|
|
26
|
-
const raw = typeof css === 'string' ? css.trim() : '';
|
|
27
|
-
if (!raw) return [];
|
|
28
|
-
const attrRegex = /\[\s*([^\s~|^$*=\]]+)\s*(\*=|\^=|\$=|=)?\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))?\s*\]/g;
|
|
29
|
-
const parseSegment = (item) => {
|
|
30
|
-
const tagMatch = item.match(/^[a-zA-Z][\w-]*/);
|
|
31
|
-
const idMatch = item.match(/#([\w-]+)/);
|
|
32
|
-
const classMatches = item.match(/\.([\w-]+)/g) || [];
|
|
33
|
-
const attrs = [];
|
|
34
|
-
let attrMatch = attrRegex.exec(item);
|
|
35
|
-
while (attrMatch) {
|
|
36
|
-
attrs.push({
|
|
37
|
-
name: String(attrMatch[1] || '').toLowerCase(),
|
|
38
|
-
op: attrMatch[2] || 'exists',
|
|
39
|
-
value: attrMatch[3] ?? attrMatch[4] ?? attrMatch[5] ?? '',
|
|
40
|
-
});
|
|
41
|
-
attrMatch = attrRegex.exec(item);
|
|
42
|
-
}
|
|
43
|
-
attrRegex.lastIndex = 0;
|
|
44
|
-
return {
|
|
45
|
-
raw: item,
|
|
46
|
-
tag: tagMatch ? tagMatch[0].toLowerCase() : null,
|
|
47
|
-
id: idMatch ? idMatch[1] : null,
|
|
48
|
-
classes: classMatches.map((token) => token.slice(1)),
|
|
49
|
-
attrs,
|
|
50
|
-
};
|
|
51
|
-
};
|
|
52
|
-
return raw
|
|
53
|
-
.split(',')
|
|
54
|
-
.map((item) => item.trim())
|
|
55
|
-
.filter(Boolean)
|
|
56
|
-
.map((item) => {
|
|
57
|
-
const segments = item
|
|
58
|
-
.split(/\s+/)
|
|
59
|
-
.map((segment) => segment.trim())
|
|
60
|
-
.filter(Boolean)
|
|
61
|
-
.map((segment) => parseSegment(segment));
|
|
62
|
-
return {
|
|
63
|
-
raw: item,
|
|
64
|
-
segments,
|
|
65
|
-
...parseSegment(item),
|
|
66
|
-
};
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function nodeAttribute(node, name, nodeId, nodeClasses) {
|
|
71
|
-
const key = String(name || '').toLowerCase();
|
|
72
|
-
if (!key) return null;
|
|
73
|
-
if (key === 'id') return nodeId || null;
|
|
74
|
-
if (key === 'class') return Array.from(nodeClasses).join(' ');
|
|
75
|
-
|
|
76
|
-
const attrs = node?.attrs && typeof node.attrs === 'object' ? node.attrs : null;
|
|
77
|
-
if (attrs && attrs[key] !== undefined && attrs[key] !== null) return String(attrs[key]);
|
|
78
|
-
|
|
79
|
-
const direct = node?.[key];
|
|
80
|
-
if (typeof direct === 'string' || typeof direct === 'number' || typeof direct === 'boolean') {
|
|
81
|
-
return String(direct);
|
|
82
|
-
}
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function matchAttribute(node, attrSpec, nodeId, nodeClasses) {
|
|
87
|
-
const value = nodeAttribute(node, attrSpec.name, nodeId, nodeClasses);
|
|
88
|
-
if (attrSpec.op === 'exists') return value !== null && value !== '';
|
|
89
|
-
if (value === null) return false;
|
|
90
|
-
const expected = String(attrSpec.value || '');
|
|
91
|
-
if (attrSpec.op === '=') return value === expected;
|
|
92
|
-
if (attrSpec.op === '*=') return value.includes(expected);
|
|
93
|
-
if (attrSpec.op === '^=') return value.startsWith(expected);
|
|
94
|
-
if (attrSpec.op === '$=') return value.endsWith(expected);
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function nodeMatchesCssSegment(node, cssSegment) {
|
|
99
|
-
const nodeTag = typeof node?.tag === 'string' ? node.tag.toLowerCase() : null;
|
|
100
|
-
const nodeId = typeof node?.id === 'string' ? node.id : null;
|
|
101
|
-
const nodeClasses = new Set(Array.isArray(node?.classes) ? node.classes : []);
|
|
102
|
-
|
|
103
|
-
const hasConstraints = Boolean(
|
|
104
|
-
cssSegment?.tag
|
|
105
|
-
|| cssSegment?.id
|
|
106
|
-
|| (cssSegment?.classes && cssSegment.classes.length > 0)
|
|
107
|
-
|| (cssSegment?.attrs && cssSegment.attrs.length > 0),
|
|
108
|
-
);
|
|
109
|
-
if (!hasConstraints) return false;
|
|
110
|
-
|
|
111
|
-
let matched = true;
|
|
112
|
-
if (cssSegment.tag && nodeTag !== cssSegment.tag) matched = false;
|
|
113
|
-
if (cssSegment.id && nodeId !== cssSegment.id) matched = false;
|
|
114
|
-
if (matched && cssSegment.classes.length > 0) {
|
|
115
|
-
matched = cssSegment.classes.every((className) => nodeClasses.has(className));
|
|
116
|
-
}
|
|
117
|
-
if (matched && cssSegment.attrs.length > 0) {
|
|
118
|
-
matched = cssSegment.attrs.every((attrSpec) => matchAttribute(node, attrSpec, nodeId, nodeClasses));
|
|
119
|
-
}
|
|
120
|
-
return matched;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function matchesAncestorChain(ancestors, segments) {
|
|
124
|
-
if (!Array.isArray(segments) || segments.length === 0) return true;
|
|
125
|
-
if (!Array.isArray(ancestors) || ancestors.length === 0) return false;
|
|
126
|
-
let ancestorIndex = ancestors.length - 1;
|
|
127
|
-
for (let segmentIndex = segments.length - 1; segmentIndex >= 0; segmentIndex -= 1) {
|
|
128
|
-
let found = false;
|
|
129
|
-
while (ancestorIndex >= 0) {
|
|
130
|
-
if (nodeMatchesCssSegment(ancestors[ancestorIndex], segments[segmentIndex])) {
|
|
131
|
-
found = true;
|
|
132
|
-
ancestorIndex -= 1;
|
|
133
|
-
break;
|
|
134
|
-
}
|
|
135
|
-
ancestorIndex -= 1;
|
|
136
|
-
}
|
|
137
|
-
if (!found) return false;
|
|
138
|
-
}
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export class ChangeNotifier {
|
|
143
|
-
constructor() {
|
|
144
|
-
this.subscriptions = new Map(); // topic -> Set<callback>
|
|
145
|
-
this.elementWatchers = new Map(); // selector -> { lastState, callbacks }
|
|
146
|
-
this.lastSnapshot = null;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
nodePassesVisibility(node, selector, viewport) {
|
|
150
|
-
const normalized = normalizeSelector(selector);
|
|
151
|
-
if (normalized.visible === false) return true;
|
|
152
|
-
if (!node || typeof node !== 'object') return false;
|
|
153
|
-
if (typeof node.visible === 'boolean') return node.visible;
|
|
154
|
-
|
|
155
|
-
const rect = node.rect || null;
|
|
156
|
-
if (!rect) return true;
|
|
157
|
-
const width = Number(rect.width || 0);
|
|
158
|
-
const height = Number(rect.height || 0);
|
|
159
|
-
if (width <= 0 || height <= 0) return false;
|
|
160
|
-
|
|
161
|
-
const vw = Number(viewport?.width || 0);
|
|
162
|
-
const vh = Number(viewport?.height || 0);
|
|
163
|
-
if (vw <= 0 || vh <= 0) return true;
|
|
164
|
-
const left = Number(rect.left ?? rect.x ?? 0);
|
|
165
|
-
const top = Number(rect.top ?? rect.y ?? 0);
|
|
166
|
-
const right = Number(rect.right ?? (left + width));
|
|
167
|
-
const bottom = Number(rect.bottom ?? (top + height));
|
|
168
|
-
return right > 0 && bottom > 0 && left < vw && top < vh;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Subscribe to a topic
|
|
172
|
-
subscribe(topic, callback) {
|
|
173
|
-
if (!this.subscriptions.has(topic)) {
|
|
174
|
-
this.subscriptions.set(topic, new Set());
|
|
175
|
-
}
|
|
176
|
-
this.subscriptions.get(topic).add(callback);
|
|
177
|
-
return () => {
|
|
178
|
-
this.subscriptions.get(topic)?.delete(callback);
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Watch specific elements by selector
|
|
183
|
-
watch(selector, options = {}) {
|
|
184
|
-
const { onAppear, onDisappear, onChange, throttle = 200 } = options;
|
|
185
|
-
const resolvedSelector = normalizeSelector(selector);
|
|
186
|
-
const key = selectorKey(resolvedSelector);
|
|
187
|
-
const resolvedThrottle = Math.max(50, Number(throttle) || 200);
|
|
188
|
-
|
|
189
|
-
if (!this.elementWatchers.has(key)) {
|
|
190
|
-
this.elementWatchers.set(key, {
|
|
191
|
-
selector: resolvedSelector,
|
|
192
|
-
lastState: null,
|
|
193
|
-
lastNotifyTime: 0,
|
|
194
|
-
throttle: resolvedThrottle,
|
|
195
|
-
callbacks: { onAppear, onDisappear, onChange },
|
|
196
|
-
});
|
|
197
|
-
} else {
|
|
198
|
-
const watcher = this.elementWatchers.get(key);
|
|
199
|
-
if (onAppear) watcher.callbacks.onAppear = onAppear;
|
|
200
|
-
if (onDisappear) watcher.callbacks.onDisappear = onDisappear;
|
|
201
|
-
if (onChange) watcher.callbacks.onChange = onChange;
|
|
202
|
-
watcher.throttle = resolvedThrottle;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return () => {
|
|
206
|
-
this.elementWatchers.delete(key);
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Notify all subscribers of a topic
|
|
211
|
-
notify(topic, data) {
|
|
212
|
-
const callbacks = this.subscriptions.get(topic);
|
|
213
|
-
if (!callbacks) return;
|
|
214
|
-
for (const callback of callbacks) {
|
|
215
|
-
try {
|
|
216
|
-
callback(data);
|
|
217
|
-
} catch (err) {
|
|
218
|
-
console.error(`[ChangeNotifier] callback error for ${topic}:`, err);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Process new DOM snapshot and trigger notifications
|
|
224
|
-
processSnapshot(snapshot) {
|
|
225
|
-
const now = Date.now();
|
|
226
|
-
const prevSnapshot = this.lastSnapshot;
|
|
227
|
-
this.lastSnapshot = snapshot;
|
|
228
|
-
|
|
229
|
-
// Notify general DOM change
|
|
230
|
-
this.notify('dom:changed', { snapshot, prevSnapshot });
|
|
231
|
-
|
|
232
|
-
// Process element watchers
|
|
233
|
-
for (const [, watcher] of this.elementWatchers) {
|
|
234
|
-
const { lastState, callbacks, lastNotifyTime, throttle } = watcher;
|
|
235
|
-
|
|
236
|
-
// Throttle notifications
|
|
237
|
-
if (now - lastNotifyTime < throttle) continue;
|
|
238
|
-
|
|
239
|
-
const currentElements = this.findElements(snapshot, watcher.selector);
|
|
240
|
-
const currentState = currentElements.map(e => e.path).sort().join(',');
|
|
241
|
-
|
|
242
|
-
if (lastState !== null && currentState !== lastState) {
|
|
243
|
-
// Something changed
|
|
244
|
-
const prevElements = watcher.prevElements || [];
|
|
245
|
-
const appeared = currentElements.filter(e => !prevElements.find(p => p.path === e.path));
|
|
246
|
-
const disappeared = prevElements.filter(e => !currentElements.find(c => c.path === e.path));
|
|
247
|
-
|
|
248
|
-
if (appeared.length > 0 && callbacks.onAppear) {
|
|
249
|
-
callbacks.onAppear(appeared);
|
|
250
|
-
watcher.lastNotifyTime = now;
|
|
251
|
-
}
|
|
252
|
-
if (disappeared.length > 0 && callbacks.onDisappear) {
|
|
253
|
-
callbacks.onDisappear(disappeared);
|
|
254
|
-
watcher.lastNotifyTime = now;
|
|
255
|
-
}
|
|
256
|
-
if (callbacks.onChange) {
|
|
257
|
-
callbacks.onChange({ current: currentElements, previous: prevElements, appeared, disappeared });
|
|
258
|
-
watcher.lastNotifyTime = now;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
watcher.lastState = currentState;
|
|
263
|
-
watcher.prevElements = currentElements;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Find elements matching selector in DOM tree
|
|
268
|
-
findElements(node, selector, path = 'root', context = null) {
|
|
269
|
-
const results = [];
|
|
270
|
-
if (!node) return results;
|
|
271
|
-
const normalized = normalizeSelector(selector);
|
|
272
|
-
const runtimeContext = context || {
|
|
273
|
-
viewport: node?.__viewport || null,
|
|
274
|
-
ancestors: [],
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
// Check if current node matches
|
|
278
|
-
if (this.nodeMatchesSelector(node, normalized, runtimeContext.ancestors) && this.nodePassesVisibility(node, normalized, runtimeContext.viewport)) {
|
|
279
|
-
results.push({ ...node, path });
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Recurse into children
|
|
283
|
-
if (node.children) {
|
|
284
|
-
const childContext = {
|
|
285
|
-
...runtimeContext,
|
|
286
|
-
ancestors: [...runtimeContext.ancestors, node],
|
|
287
|
-
};
|
|
288
|
-
for (let i = 0; i < node.children.length; i++) {
|
|
289
|
-
const childResults = this.findElements(node.children[i], normalized, `${path}/${i}`, childContext);
|
|
290
|
-
results.push(...childResults);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return results;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Check if node matches selector
|
|
298
|
-
nodeMatchesSelector(node, selector, ancestors = []) {
|
|
299
|
-
if (!node) return false;
|
|
300
|
-
const normalized = normalizeSelector(selector);
|
|
301
|
-
if (!normalized || typeof normalized !== 'object') return false;
|
|
302
|
-
|
|
303
|
-
const nodeTag = typeof node.tag === 'string' ? node.tag.toLowerCase() : null;
|
|
304
|
-
const nodeId = typeof node.id === 'string' ? node.id : null;
|
|
305
|
-
const nodeClasses = new Set(Array.isArray(node.classes) ? node.classes : []);
|
|
306
|
-
|
|
307
|
-
// Exact selector string (fast path).
|
|
308
|
-
if (normalized.css && node.selector === normalized.css) return true;
|
|
309
|
-
|
|
310
|
-
const cssVariants = parseCssSelector(normalized.css);
|
|
311
|
-
if (cssVariants.length > 0) {
|
|
312
|
-
for (const cssVariant of cssVariants) {
|
|
313
|
-
const segments = Array.isArray(cssVariant.segments) && cssVariant.segments.length > 0
|
|
314
|
-
? cssVariant.segments
|
|
315
|
-
: [cssVariant];
|
|
316
|
-
const targetSegment = segments[segments.length - 1];
|
|
317
|
-
if (!nodeMatchesCssSegment(node, targetSegment)) continue;
|
|
318
|
-
if (segments.length === 1) return true;
|
|
319
|
-
if (matchesAncestorChain(ancestors, segments.slice(0, -1))) return true;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const requiredTag = normalized.tag ? String(normalized.tag).toLowerCase() : null;
|
|
324
|
-
const requiredId = normalized.id ? String(normalized.id) : null;
|
|
325
|
-
const requiredClasses = Array.isArray(normalized.classes)
|
|
326
|
-
? normalized.classes.filter(Boolean).map((className) => String(className))
|
|
327
|
-
: [];
|
|
328
|
-
|
|
329
|
-
const hasStructuredSelector = Boolean(requiredTag || requiredId || requiredClasses.length > 0);
|
|
330
|
-
if (!hasStructuredSelector) return false;
|
|
331
|
-
if (requiredTag && nodeTag !== requiredTag) return false;
|
|
332
|
-
if (requiredId && nodeId !== requiredId) return false;
|
|
333
|
-
if (requiredClasses.length > 0 && !requiredClasses.every((className) => nodeClasses.has(className))) {
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
return true;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Cleanup
|
|
340
|
-
destroy() {
|
|
341
|
-
this.subscriptions.clear();
|
|
342
|
-
this.elementWatchers.clear();
|
|
343
|
-
this.lastSnapshot = null;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Global instance
|
|
348
|
-
let globalNotifier = null;
|
|
349
|
-
|
|
350
|
-
export function getChangeNotifier() {
|
|
351
|
-
if (!globalNotifier) {
|
|
352
|
-
globalNotifier = new ChangeNotifier();
|
|
353
|
-
}
|
|
354
|
-
return globalNotifier;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
export function destroyChangeNotifier() {
|
|
358
|
-
if (globalNotifier) {
|
|
359
|
-
globalNotifier.destroy();
|
|
360
|
-
globalNotifier = null;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
1
|
+
// Change Notifier - Subscribe to DOM changes and element events
|
|
2
|
+
|
|
3
|
+
function normalizeSelector(selector) {
|
|
4
|
+
if (!selector) return { visible: true };
|
|
5
|
+
if (typeof selector === 'string') return { css: selector, visible: true };
|
|
6
|
+
if (typeof selector !== 'object') return { visible: true };
|
|
7
|
+
return {
|
|
8
|
+
...selector,
|
|
9
|
+
visible: selector.visible !== false,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function selectorKey(selector) {
|
|
14
|
+
const normalized = normalizeSelector(selector);
|
|
15
|
+
const stable = {
|
|
16
|
+
css: normalized.css || null,
|
|
17
|
+
tag: normalized.tag || null,
|
|
18
|
+
id: normalized.id || null,
|
|
19
|
+
classes: Array.isArray(normalized.classes) ? [...normalized.classes].sort() : [],
|
|
20
|
+
visible: normalized.visible !== false,
|
|
21
|
+
};
|
|
22
|
+
return JSON.stringify(stable);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseCssSelector(css) {
|
|
26
|
+
const raw = typeof css === 'string' ? css.trim() : '';
|
|
27
|
+
if (!raw) return [];
|
|
28
|
+
const attrRegex = /\[\s*([^\s~|^$*=\]]+)\s*(\*=|\^=|\$=|=)?\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))?\s*\]/g;
|
|
29
|
+
const parseSegment = (item) => {
|
|
30
|
+
const tagMatch = item.match(/^[a-zA-Z][\w-]*/);
|
|
31
|
+
const idMatch = item.match(/#([\w-]+)/);
|
|
32
|
+
const classMatches = item.match(/\.([\w-]+)/g) || [];
|
|
33
|
+
const attrs = [];
|
|
34
|
+
let attrMatch = attrRegex.exec(item);
|
|
35
|
+
while (attrMatch) {
|
|
36
|
+
attrs.push({
|
|
37
|
+
name: String(attrMatch[1] || '').toLowerCase(),
|
|
38
|
+
op: attrMatch[2] || 'exists',
|
|
39
|
+
value: attrMatch[3] ?? attrMatch[4] ?? attrMatch[5] ?? '',
|
|
40
|
+
});
|
|
41
|
+
attrMatch = attrRegex.exec(item);
|
|
42
|
+
}
|
|
43
|
+
attrRegex.lastIndex = 0;
|
|
44
|
+
return {
|
|
45
|
+
raw: item,
|
|
46
|
+
tag: tagMatch ? tagMatch[0].toLowerCase() : null,
|
|
47
|
+
id: idMatch ? idMatch[1] : null,
|
|
48
|
+
classes: classMatches.map((token) => token.slice(1)),
|
|
49
|
+
attrs,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
return raw
|
|
53
|
+
.split(',')
|
|
54
|
+
.map((item) => item.trim())
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.map((item) => {
|
|
57
|
+
const segments = item
|
|
58
|
+
.split(/\s+/)
|
|
59
|
+
.map((segment) => segment.trim())
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map((segment) => parseSegment(segment));
|
|
62
|
+
return {
|
|
63
|
+
raw: item,
|
|
64
|
+
segments,
|
|
65
|
+
...parseSegment(item),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nodeAttribute(node, name, nodeId, nodeClasses) {
|
|
71
|
+
const key = String(name || '').toLowerCase();
|
|
72
|
+
if (!key) return null;
|
|
73
|
+
if (key === 'id') return nodeId || null;
|
|
74
|
+
if (key === 'class') return Array.from(nodeClasses).join(' ');
|
|
75
|
+
|
|
76
|
+
const attrs = node?.attrs && typeof node.attrs === 'object' ? node.attrs : null;
|
|
77
|
+
if (attrs && attrs[key] !== undefined && attrs[key] !== null) return String(attrs[key]);
|
|
78
|
+
|
|
79
|
+
const direct = node?.[key];
|
|
80
|
+
if (typeof direct === 'string' || typeof direct === 'number' || typeof direct === 'boolean') {
|
|
81
|
+
return String(direct);
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function matchAttribute(node, attrSpec, nodeId, nodeClasses) {
|
|
87
|
+
const value = nodeAttribute(node, attrSpec.name, nodeId, nodeClasses);
|
|
88
|
+
if (attrSpec.op === 'exists') return value !== null && value !== '';
|
|
89
|
+
if (value === null) return false;
|
|
90
|
+
const expected = String(attrSpec.value || '');
|
|
91
|
+
if (attrSpec.op === '=') return value === expected;
|
|
92
|
+
if (attrSpec.op === '*=') return value.includes(expected);
|
|
93
|
+
if (attrSpec.op === '^=') return value.startsWith(expected);
|
|
94
|
+
if (attrSpec.op === '$=') return value.endsWith(expected);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function nodeMatchesCssSegment(node, cssSegment) {
|
|
99
|
+
const nodeTag = typeof node?.tag === 'string' ? node.tag.toLowerCase() : null;
|
|
100
|
+
const nodeId = typeof node?.id === 'string' ? node.id : null;
|
|
101
|
+
const nodeClasses = new Set(Array.isArray(node?.classes) ? node.classes : []);
|
|
102
|
+
|
|
103
|
+
const hasConstraints = Boolean(
|
|
104
|
+
cssSegment?.tag
|
|
105
|
+
|| cssSegment?.id
|
|
106
|
+
|| (cssSegment?.classes && cssSegment.classes.length > 0)
|
|
107
|
+
|| (cssSegment?.attrs && cssSegment.attrs.length > 0),
|
|
108
|
+
);
|
|
109
|
+
if (!hasConstraints) return false;
|
|
110
|
+
|
|
111
|
+
let matched = true;
|
|
112
|
+
if (cssSegment.tag && nodeTag !== cssSegment.tag) matched = false;
|
|
113
|
+
if (cssSegment.id && nodeId !== cssSegment.id) matched = false;
|
|
114
|
+
if (matched && cssSegment.classes.length > 0) {
|
|
115
|
+
matched = cssSegment.classes.every((className) => nodeClasses.has(className));
|
|
116
|
+
}
|
|
117
|
+
if (matched && cssSegment.attrs.length > 0) {
|
|
118
|
+
matched = cssSegment.attrs.every((attrSpec) => matchAttribute(node, attrSpec, nodeId, nodeClasses));
|
|
119
|
+
}
|
|
120
|
+
return matched;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function matchesAncestorChain(ancestors, segments) {
|
|
124
|
+
if (!Array.isArray(segments) || segments.length === 0) return true;
|
|
125
|
+
if (!Array.isArray(ancestors) || ancestors.length === 0) return false;
|
|
126
|
+
let ancestorIndex = ancestors.length - 1;
|
|
127
|
+
for (let segmentIndex = segments.length - 1; segmentIndex >= 0; segmentIndex -= 1) {
|
|
128
|
+
let found = false;
|
|
129
|
+
while (ancestorIndex >= 0) {
|
|
130
|
+
if (nodeMatchesCssSegment(ancestors[ancestorIndex], segments[segmentIndex])) {
|
|
131
|
+
found = true;
|
|
132
|
+
ancestorIndex -= 1;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
ancestorIndex -= 1;
|
|
136
|
+
}
|
|
137
|
+
if (!found) return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class ChangeNotifier {
|
|
143
|
+
constructor() {
|
|
144
|
+
this.subscriptions = new Map(); // topic -> Set<callback>
|
|
145
|
+
this.elementWatchers = new Map(); // selector -> { lastState, callbacks }
|
|
146
|
+
this.lastSnapshot = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
nodePassesVisibility(node, selector, viewport) {
|
|
150
|
+
const normalized = normalizeSelector(selector);
|
|
151
|
+
if (normalized.visible === false) return true;
|
|
152
|
+
if (!node || typeof node !== 'object') return false;
|
|
153
|
+
if (typeof node.visible === 'boolean') return node.visible;
|
|
154
|
+
|
|
155
|
+
const rect = node.rect || null;
|
|
156
|
+
if (!rect) return true;
|
|
157
|
+
const width = Number(rect.width || 0);
|
|
158
|
+
const height = Number(rect.height || 0);
|
|
159
|
+
if (width <= 0 || height <= 0) return false;
|
|
160
|
+
|
|
161
|
+
const vw = Number(viewport?.width || 0);
|
|
162
|
+
const vh = Number(viewport?.height || 0);
|
|
163
|
+
if (vw <= 0 || vh <= 0) return true;
|
|
164
|
+
const left = Number(rect.left ?? rect.x ?? 0);
|
|
165
|
+
const top = Number(rect.top ?? rect.y ?? 0);
|
|
166
|
+
const right = Number(rect.right ?? (left + width));
|
|
167
|
+
const bottom = Number(rect.bottom ?? (top + height));
|
|
168
|
+
return right > 0 && bottom > 0 && left < vw && top < vh;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Subscribe to a topic
|
|
172
|
+
subscribe(topic, callback) {
|
|
173
|
+
if (!this.subscriptions.has(topic)) {
|
|
174
|
+
this.subscriptions.set(topic, new Set());
|
|
175
|
+
}
|
|
176
|
+
this.subscriptions.get(topic).add(callback);
|
|
177
|
+
return () => {
|
|
178
|
+
this.subscriptions.get(topic)?.delete(callback);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Watch specific elements by selector
|
|
183
|
+
watch(selector, options = {}) {
|
|
184
|
+
const { onAppear, onDisappear, onChange, throttle = 200 } = options;
|
|
185
|
+
const resolvedSelector = normalizeSelector(selector);
|
|
186
|
+
const key = selectorKey(resolvedSelector);
|
|
187
|
+
const resolvedThrottle = Math.max(50, Number(throttle) || 200);
|
|
188
|
+
|
|
189
|
+
if (!this.elementWatchers.has(key)) {
|
|
190
|
+
this.elementWatchers.set(key, {
|
|
191
|
+
selector: resolvedSelector,
|
|
192
|
+
lastState: null,
|
|
193
|
+
lastNotifyTime: 0,
|
|
194
|
+
throttle: resolvedThrottle,
|
|
195
|
+
callbacks: { onAppear, onDisappear, onChange },
|
|
196
|
+
});
|
|
197
|
+
} else {
|
|
198
|
+
const watcher = this.elementWatchers.get(key);
|
|
199
|
+
if (onAppear) watcher.callbacks.onAppear = onAppear;
|
|
200
|
+
if (onDisappear) watcher.callbacks.onDisappear = onDisappear;
|
|
201
|
+
if (onChange) watcher.callbacks.onChange = onChange;
|
|
202
|
+
watcher.throttle = resolvedThrottle;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return () => {
|
|
206
|
+
this.elementWatchers.delete(key);
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Notify all subscribers of a topic
|
|
211
|
+
notify(topic, data) {
|
|
212
|
+
const callbacks = this.subscriptions.get(topic);
|
|
213
|
+
if (!callbacks) return;
|
|
214
|
+
for (const callback of callbacks) {
|
|
215
|
+
try {
|
|
216
|
+
callback(data);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error(`[ChangeNotifier] callback error for ${topic}:`, err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Process new DOM snapshot and trigger notifications
|
|
224
|
+
processSnapshot(snapshot) {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
const prevSnapshot = this.lastSnapshot;
|
|
227
|
+
this.lastSnapshot = snapshot;
|
|
228
|
+
|
|
229
|
+
// Notify general DOM change
|
|
230
|
+
this.notify('dom:changed', { snapshot, prevSnapshot });
|
|
231
|
+
|
|
232
|
+
// Process element watchers
|
|
233
|
+
for (const [, watcher] of this.elementWatchers) {
|
|
234
|
+
const { lastState, callbacks, lastNotifyTime, throttle } = watcher;
|
|
235
|
+
|
|
236
|
+
// Throttle notifications
|
|
237
|
+
if (now - lastNotifyTime < throttle) continue;
|
|
238
|
+
|
|
239
|
+
const currentElements = this.findElements(snapshot, watcher.selector);
|
|
240
|
+
const currentState = currentElements.map(e => e.path).sort().join(',');
|
|
241
|
+
|
|
242
|
+
if (lastState !== null && currentState !== lastState) {
|
|
243
|
+
// Something changed
|
|
244
|
+
const prevElements = watcher.prevElements || [];
|
|
245
|
+
const appeared = currentElements.filter(e => !prevElements.find(p => p.path === e.path));
|
|
246
|
+
const disappeared = prevElements.filter(e => !currentElements.find(c => c.path === e.path));
|
|
247
|
+
|
|
248
|
+
if (appeared.length > 0 && callbacks.onAppear) {
|
|
249
|
+
callbacks.onAppear(appeared);
|
|
250
|
+
watcher.lastNotifyTime = now;
|
|
251
|
+
}
|
|
252
|
+
if (disappeared.length > 0 && callbacks.onDisappear) {
|
|
253
|
+
callbacks.onDisappear(disappeared);
|
|
254
|
+
watcher.lastNotifyTime = now;
|
|
255
|
+
}
|
|
256
|
+
if (callbacks.onChange) {
|
|
257
|
+
callbacks.onChange({ current: currentElements, previous: prevElements, appeared, disappeared });
|
|
258
|
+
watcher.lastNotifyTime = now;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
watcher.lastState = currentState;
|
|
263
|
+
watcher.prevElements = currentElements;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Find elements matching selector in DOM tree
|
|
268
|
+
findElements(node, selector, path = 'root', context = null) {
|
|
269
|
+
const results = [];
|
|
270
|
+
if (!node) return results;
|
|
271
|
+
const normalized = normalizeSelector(selector);
|
|
272
|
+
const runtimeContext = context || {
|
|
273
|
+
viewport: node?.__viewport || null,
|
|
274
|
+
ancestors: [],
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Check if current node matches
|
|
278
|
+
if (this.nodeMatchesSelector(node, normalized, runtimeContext.ancestors) && this.nodePassesVisibility(node, normalized, runtimeContext.viewport)) {
|
|
279
|
+
results.push({ ...node, path });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Recurse into children
|
|
283
|
+
if (node.children) {
|
|
284
|
+
const childContext = {
|
|
285
|
+
...runtimeContext,
|
|
286
|
+
ancestors: [...runtimeContext.ancestors, node],
|
|
287
|
+
};
|
|
288
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
289
|
+
const childResults = this.findElements(node.children[i], normalized, `${path}/${i}`, childContext);
|
|
290
|
+
results.push(...childResults);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return results;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Check if node matches selector
|
|
298
|
+
nodeMatchesSelector(node, selector, ancestors = []) {
|
|
299
|
+
if (!node) return false;
|
|
300
|
+
const normalized = normalizeSelector(selector);
|
|
301
|
+
if (!normalized || typeof normalized !== 'object') return false;
|
|
302
|
+
|
|
303
|
+
const nodeTag = typeof node.tag === 'string' ? node.tag.toLowerCase() : null;
|
|
304
|
+
const nodeId = typeof node.id === 'string' ? node.id : null;
|
|
305
|
+
const nodeClasses = new Set(Array.isArray(node.classes) ? node.classes : []);
|
|
306
|
+
|
|
307
|
+
// Exact selector string (fast path).
|
|
308
|
+
if (normalized.css && node.selector === normalized.css) return true;
|
|
309
|
+
|
|
310
|
+
const cssVariants = parseCssSelector(normalized.css);
|
|
311
|
+
if (cssVariants.length > 0) {
|
|
312
|
+
for (const cssVariant of cssVariants) {
|
|
313
|
+
const segments = Array.isArray(cssVariant.segments) && cssVariant.segments.length > 0
|
|
314
|
+
? cssVariant.segments
|
|
315
|
+
: [cssVariant];
|
|
316
|
+
const targetSegment = segments[segments.length - 1];
|
|
317
|
+
if (!nodeMatchesCssSegment(node, targetSegment)) continue;
|
|
318
|
+
if (segments.length === 1) return true;
|
|
319
|
+
if (matchesAncestorChain(ancestors, segments.slice(0, -1))) return true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const requiredTag = normalized.tag ? String(normalized.tag).toLowerCase() : null;
|
|
324
|
+
const requiredId = normalized.id ? String(normalized.id) : null;
|
|
325
|
+
const requiredClasses = Array.isArray(normalized.classes)
|
|
326
|
+
? normalized.classes.filter(Boolean).map((className) => String(className))
|
|
327
|
+
: [];
|
|
328
|
+
|
|
329
|
+
const hasStructuredSelector = Boolean(requiredTag || requiredId || requiredClasses.length > 0);
|
|
330
|
+
if (!hasStructuredSelector) return false;
|
|
331
|
+
if (requiredTag && nodeTag !== requiredTag) return false;
|
|
332
|
+
if (requiredId && nodeId !== requiredId) return false;
|
|
333
|
+
if (requiredClasses.length > 0 && !requiredClasses.every((className) => nodeClasses.has(className))) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Cleanup
|
|
340
|
+
destroy() {
|
|
341
|
+
this.subscriptions.clear();
|
|
342
|
+
this.elementWatchers.clear();
|
|
343
|
+
this.lastSnapshot = null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Global instance
|
|
348
|
+
let globalNotifier = null;
|
|
349
|
+
|
|
350
|
+
export function getChangeNotifier() {
|
|
351
|
+
if (!globalNotifier) {
|
|
352
|
+
globalNotifier = new ChangeNotifier();
|
|
353
|
+
}
|
|
354
|
+
return globalNotifier;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function destroyChangeNotifier() {
|
|
358
|
+
if (globalNotifier) {
|
|
359
|
+
globalNotifier.destroy();
|
|
360
|
+
globalNotifier = null;
|
|
361
|
+
}
|
|
362
|
+
}
|