@web-auto/camo 0.1.2 → 0.1.3
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/package.json +6 -3
- package/src/cli.mjs +7 -1
- package/src/commands/container.mjs +178 -0
- package/src/container/change-notifier.mjs +170 -0
- package/src/container/element-filter.mjs +97 -0
- package/src/container/index.mjs +3 -0
- package/src/lib/client.mjs +197 -0
- package/src/utils/help.mjs +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@web-auto/camo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.0003",
|
|
4
4
|
"description": "Camoufox Browser CLI - Cross-platform browser automation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "node scripts/build.mjs",
|
|
18
|
-
"test": "node --test tests
|
|
18
|
+
"test": "node --test 'tests/**/*.test.mjs'",
|
|
19
|
+
"test:coverage": "c8 --reporter=text --reporter=lcov node --test 'tests/**/*.test.mjs'",
|
|
19
20
|
"version:bump": "node scripts/bump-version.mjs",
|
|
20
21
|
"install:global": "npm run build && npm install -g .",
|
|
21
22
|
"uninstall:global": "npm uninstall -g @web-auto/camo",
|
|
@@ -32,8 +33,10 @@
|
|
|
32
33
|
"engines": {
|
|
33
34
|
"node": ">=20.0.0"
|
|
34
35
|
},
|
|
35
|
-
"dependencies": {},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"camoufox": "^0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"c8": "^10.1.3"
|
|
38
41
|
}
|
|
39
42
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { handleCookiesCommand } from './commands/cookies.mjs';
|
|
|
11
11
|
import { handleWindowCommand } from './commands/window.mjs';
|
|
12
12
|
import { handleMouseCommand } from './commands/mouse.mjs';
|
|
13
13
|
import { handleSystemCommand } from './commands/system.mjs';
|
|
14
|
+
import { handleContainerCommand } from './commands/container.mjs';
|
|
14
15
|
import {
|
|
15
16
|
handleStartCommand, handleStopCommand, handleStatusCommand,
|
|
16
17
|
handleGotoCommand, handleBackCommand, handleScreenshotCommand,
|
|
@@ -112,11 +113,16 @@ async function main() {
|
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
if (cmd === 'container') {
|
|
117
|
+
await handleContainerCommand(args);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
const serviceCommands = new Set([
|
|
116
122
|
'start', 'stop', 'close', 'status', 'list', 'goto', 'navigate', 'back', 'screenshot',
|
|
117
123
|
'new-page', 'close-page', 'switch-page', 'list-pages', 'shutdown',
|
|
118
124
|
'scroll', 'click', 'type', 'highlight', 'clear-highlight', 'viewport',
|
|
119
|
-
'cookies', 'window', 'mouse', 'system',
|
|
125
|
+
'cookies', 'window', 'mouse', 'system', 'container',
|
|
120
126
|
]);
|
|
121
127
|
|
|
122
128
|
if (!serviceCommands.has(cmd)) {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Container commands - filter and watch elements
|
|
2
|
+
import { resolveProfileId, getPositionals } from '../utils/args.mjs';
|
|
3
|
+
import { callAPI, getSessionByProfile } from '../utils/browser-service.mjs';
|
|
4
|
+
import { getDefaultProfile } from '../utils/config.mjs';
|
|
5
|
+
import { getChangeNotifier } from '../container/change-notifier.mjs';
|
|
6
|
+
import { createElementFilter } from '../container/element-filter.mjs';
|
|
7
|
+
|
|
8
|
+
const notifier = getChangeNotifier();
|
|
9
|
+
const elementFilter = createElementFilter();
|
|
10
|
+
|
|
11
|
+
export async function handleContainerFilterCommand(args) {
|
|
12
|
+
const profileId = resolveProfileId(args);
|
|
13
|
+
const session = await getSessionByProfile(profileId);
|
|
14
|
+
if (!session) {
|
|
15
|
+
throw new Error(`No active session for profile: ${profileId || 'default'}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const selectors = [];
|
|
19
|
+
for (let i = 1; i < args.length; i++) {
|
|
20
|
+
const arg = args[i];
|
|
21
|
+
if (arg === '--profile' || arg === '-p') { i++; continue; }
|
|
22
|
+
if (arg.startsWith('--')) continue;
|
|
23
|
+
selectors.push(arg);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (selectors.length === 0) {
|
|
27
|
+
throw new Error('Usage: camo container filter <selector> [--profile <id>]');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Get DOM snapshot from browser service
|
|
31
|
+
const result = await callAPI(`/session/${session.session_id}/dom-tree`, { method: 'POST' });
|
|
32
|
+
const snapshot = result.dom_tree || result;
|
|
33
|
+
|
|
34
|
+
// Filter elements
|
|
35
|
+
const matched = [];
|
|
36
|
+
for (const selector of selectors) {
|
|
37
|
+
const elements = notifier.findElements(snapshot, { css: selector });
|
|
38
|
+
matched.push(...elements.map(e => ({
|
|
39
|
+
path: e.path,
|
|
40
|
+
tag: e.tag,
|
|
41
|
+
id: e.id,
|
|
42
|
+
classes: e.classes,
|
|
43
|
+
text: (e.textSnippet || e.text || '').slice(0, 50),
|
|
44
|
+
})));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(JSON.stringify({ ok: true, count: matched.length, elements: matched }, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function handleContainerWatchCommand(args) {
|
|
51
|
+
const profileId = resolveProfileId(args);
|
|
52
|
+
const session = await getSessionByProfile(profileId);
|
|
53
|
+
if (!session) {
|
|
54
|
+
throw new Error(`No active session for profile: ${profileId || 'default'}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const positionalArgs = getPositionals(args, ['--profile', '-p', '--selector', '-s', '--throttle', '-t']);
|
|
58
|
+
|
|
59
|
+
const selectorIdx = args.indexOf('--selector') !== -1 ? args.indexOf('--selector') : args.indexOf('-s');
|
|
60
|
+
const throttleIdx = args.indexOf('--throttle') !== -1 ? args.indexOf('--throttle') : args.indexOf('-t');
|
|
61
|
+
|
|
62
|
+
const selector = selectorIdx >= 0 ? args[selectorIdx + 1] : positionalArgs[0];
|
|
63
|
+
const throttle = throttleIdx >= 0 ? parseInt(args[throttleIdx + 1], 10) : 500;
|
|
64
|
+
|
|
65
|
+
if (!selector) {
|
|
66
|
+
throw new Error('Usage: camo container watch --selector <css> [--throttle ms] [--profile <id>]');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log(JSON.stringify({ ok: true, message: `Watching selector: ${selector}`, throttle }));
|
|
70
|
+
|
|
71
|
+
// Setup WebSocket connection for DOM updates
|
|
72
|
+
// For now, poll the browser service
|
|
73
|
+
const interval = setInterval(async () => {
|
|
74
|
+
try {
|
|
75
|
+
const result = await callAPI(`/session/${session.session_id}/dom-tree`, { method: 'POST' });
|
|
76
|
+
const snapshot = result.dom_tree || result;
|
|
77
|
+
notifier.processSnapshot(snapshot);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(JSON.stringify({ ok: false, error: err.message }));
|
|
80
|
+
}
|
|
81
|
+
}, throttle);
|
|
82
|
+
|
|
83
|
+
// Watch the selector
|
|
84
|
+
notifier.watch({ css: selector }, {
|
|
85
|
+
onAppear: (elements) => {
|
|
86
|
+
console.log(JSON.stringify({ event: 'appear', selector, count: elements.length, elements }));
|
|
87
|
+
},
|
|
88
|
+
onDisappear: (elements) => {
|
|
89
|
+
console.log(JSON.stringify({ event: 'disappear', selector, count: elements.length }));
|
|
90
|
+
},
|
|
91
|
+
onChange: ({ appeared, disappeared }) => {
|
|
92
|
+
console.log(JSON.stringify({ event: 'change', selector, appeared: appeared.length, disappeared: disappeared.length }));
|
|
93
|
+
},
|
|
94
|
+
throttle,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Keep process alive
|
|
98
|
+
process.on('SIGINT', () => {
|
|
99
|
+
clearInterval(interval);
|
|
100
|
+
notifier.destroy();
|
|
101
|
+
console.log(JSON.stringify({ ok: true, message: 'Watch stopped' }));
|
|
102
|
+
process.exit(0);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function handleContainerListCommand(args) {
|
|
107
|
+
const profileId = resolveProfileId(args);
|
|
108
|
+
const session = await getSessionByProfile(profileId);
|
|
109
|
+
if (!session) {
|
|
110
|
+
throw new Error(`No active session for profile: ${profileId || 'default'}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = await callAPI(`/session/${session.session_id}/dom-tree`, { method: 'POST' });
|
|
114
|
+
const snapshot = result.dom_tree || result;
|
|
115
|
+
|
|
116
|
+
// Get viewport info
|
|
117
|
+
const viewportResult = await callAPI(`/session/${session.session_id}/viewport`);
|
|
118
|
+
const viewport = viewportResult.viewport || { width: 1280, height: 720 };
|
|
119
|
+
|
|
120
|
+
// Collect all visible elements
|
|
121
|
+
const collectElements = (node, path = 'root') => {
|
|
122
|
+
const elements = [];
|
|
123
|
+
if (!node) return elements;
|
|
124
|
+
|
|
125
|
+
const rect = node.rect || node.bbox;
|
|
126
|
+
if (rect && viewport) {
|
|
127
|
+
const inViewport = elementFilter.isInViewport(rect, viewport);
|
|
128
|
+
const visibilityRatio = elementFilter.getVisibilityRatio(rect, viewport);
|
|
129
|
+
|
|
130
|
+
if (inViewport && visibilityRatio > 0.1) {
|
|
131
|
+
elements.push({
|
|
132
|
+
path,
|
|
133
|
+
tag: node.tag,
|
|
134
|
+
id: node.id,
|
|
135
|
+
classes: node.classes?.slice(0, 3),
|
|
136
|
+
visibilityRatio: Math.round(visibilityRatio * 100) / 100,
|
|
137
|
+
rect: { x: rect.left || rect.x, y: rect.top || rect.y, w: rect.width, h: rect.height },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (node.children) {
|
|
143
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
144
|
+
elements.push(...collectElements(node.children[i], `${path}/${i}`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return elements;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const elements = collectElements(snapshot);
|
|
152
|
+
console.log(JSON.stringify({ ok: true, viewport, count: elements.length, elements: elements.slice(0, 50) }, null, 2));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function handleContainerCommand(args) {
|
|
156
|
+
const sub = args[1];
|
|
157
|
+
|
|
158
|
+
switch (sub) {
|
|
159
|
+
case 'filter':
|
|
160
|
+
return handleContainerFilterCommand(args.slice(1));
|
|
161
|
+
case 'watch':
|
|
162
|
+
return handleContainerWatchCommand(args.slice(1));
|
|
163
|
+
case 'list':
|
|
164
|
+
return handleContainerListCommand(args.slice(1));
|
|
165
|
+
default:
|
|
166
|
+
console.log(`Usage: camo container <filter|watch|list> [options]
|
|
167
|
+
|
|
168
|
+
Commands:
|
|
169
|
+
filter <selector> - Filter DOM elements by CSS selector
|
|
170
|
+
watch --selector <css> - Watch for element changes (outputs JSON events)
|
|
171
|
+
list - List all visible elements in viewport
|
|
172
|
+
|
|
173
|
+
Options:
|
|
174
|
+
--profile, -p <id> - Profile to use
|
|
175
|
+
--throttle, -t <ms> - Throttle interval for watch (default: 500)
|
|
176
|
+
`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Change Notifier - Subscribe to DOM changes and element events
|
|
2
|
+
|
|
3
|
+
export class ChangeNotifier {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.subscriptions = new Map(); // topic -> Set<callback>
|
|
6
|
+
this.elementWatchers = new Map(); // selector -> { lastState, callbacks }
|
|
7
|
+
this.lastSnapshot = null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Subscribe to a topic
|
|
11
|
+
subscribe(topic, callback) {
|
|
12
|
+
if (!this.subscriptions.has(topic)) {
|
|
13
|
+
this.subscriptions.set(topic, new Set());
|
|
14
|
+
}
|
|
15
|
+
this.subscriptions.get(topic).add(callback);
|
|
16
|
+
return () => {
|
|
17
|
+
this.subscriptions.get(topic)?.delete(callback);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Watch specific elements by selector
|
|
22
|
+
watch(selector, options = {}) {
|
|
23
|
+
const { onAppear, onDisappear, onChange, throttle = 200 } = options;
|
|
24
|
+
|
|
25
|
+
if (!this.elementWatchers.has(selector)) {
|
|
26
|
+
this.elementWatchers.set(selector, {
|
|
27
|
+
lastState: null,
|
|
28
|
+
lastNotifyTime: 0,
|
|
29
|
+
callbacks: { onAppear, onDisappear, onChange },
|
|
30
|
+
});
|
|
31
|
+
} else {
|
|
32
|
+
const watcher = this.elementWatchers.get(selector);
|
|
33
|
+
if (onAppear) watcher.callbacks.onAppear = onAppear;
|
|
34
|
+
if (onDisappear) watcher.callbacks.onDisappear = onDisappear;
|
|
35
|
+
if (onChange) watcher.callbacks.onChange = onChange;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
this.elementWatchers.delete(selector);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Notify all subscribers of a topic
|
|
44
|
+
notify(topic, data) {
|
|
45
|
+
const callbacks = this.subscriptions.get(topic);
|
|
46
|
+
if (!callbacks) return;
|
|
47
|
+
for (const callback of callbacks) {
|
|
48
|
+
try {
|
|
49
|
+
callback(data);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`[ChangeNotifier] callback error for ${topic}:`, err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Process new DOM snapshot and trigger notifications
|
|
57
|
+
processSnapshot(snapshot) {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const prevSnapshot = this.lastSnapshot;
|
|
60
|
+
this.lastSnapshot = snapshot;
|
|
61
|
+
|
|
62
|
+
// Notify general DOM change
|
|
63
|
+
this.notify('dom:changed', { snapshot, prevSnapshot });
|
|
64
|
+
|
|
65
|
+
// Process element watchers
|
|
66
|
+
for (const [selector, watcher] of this.elementWatchers) {
|
|
67
|
+
const { lastState, callbacks, lastNotifyTime, throttle } = watcher;
|
|
68
|
+
|
|
69
|
+
// Throttle notifications
|
|
70
|
+
if (now - lastNotifyTime < throttle) continue;
|
|
71
|
+
|
|
72
|
+
const currentElements = this.findElements(snapshot, selector);
|
|
73
|
+
const currentState = currentElements.map(e => e.path).sort().join(',');
|
|
74
|
+
|
|
75
|
+
if (lastState !== null && currentState !== lastState) {
|
|
76
|
+
// Something changed
|
|
77
|
+
const prevElements = watcher.prevElements || [];
|
|
78
|
+
const appeared = currentElements.filter(e => !prevElements.find(p => p.path === e.path));
|
|
79
|
+
const disappeared = prevElements.filter(e => !currentElements.find(c => c.path === e.path));
|
|
80
|
+
|
|
81
|
+
if (appeared.length > 0 && callbacks.onAppear) {
|
|
82
|
+
callbacks.onAppear(appeared);
|
|
83
|
+
watcher.lastNotifyTime = now;
|
|
84
|
+
}
|
|
85
|
+
if (disappeared.length > 0 && callbacks.onDisappear) {
|
|
86
|
+
callbacks.onDisappear(disappeared);
|
|
87
|
+
watcher.lastNotifyTime = now;
|
|
88
|
+
}
|
|
89
|
+
if (callbacks.onChange) {
|
|
90
|
+
callbacks.onChange({ current: currentElements, previous: prevElements, appeared, disappeared });
|
|
91
|
+
watcher.lastNotifyTime = now;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
watcher.lastState = currentState;
|
|
96
|
+
watcher.prevElements = currentElements;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Find elements matching selector in DOM tree
|
|
101
|
+
findElements(node, selector, path = 'root') {
|
|
102
|
+
const results = [];
|
|
103
|
+
if (!node) return results;
|
|
104
|
+
|
|
105
|
+
// Check if current node matches
|
|
106
|
+
if (this.nodeMatchesSelector(node, selector)) {
|
|
107
|
+
results.push({ ...node, path });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Recurse into children
|
|
111
|
+
if (node.children) {
|
|
112
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
113
|
+
const childResults = this.findElements(node.children[i], selector, `${path}/${i}`);
|
|
114
|
+
results.push(...childResults);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check if node matches selector
|
|
122
|
+
nodeMatchesSelector(node, selector) {
|
|
123
|
+
if (!node) return false;
|
|
124
|
+
|
|
125
|
+
// CSS selector match
|
|
126
|
+
if (selector.css && node.selector === selector.css) return true;
|
|
127
|
+
if (selector.css && node.classes) {
|
|
128
|
+
const selClasses = selector.css.match(/\.[\w-]+/g);
|
|
129
|
+
if (selClasses) {
|
|
130
|
+
const nodeClasses = new Set(node.classes || []);
|
|
131
|
+
if (selClasses.map(s => s.slice(1)).every(c => nodeClasses.has(c))) return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ID match
|
|
136
|
+
if (selector.id && node.id === selector.id) return true;
|
|
137
|
+
|
|
138
|
+
// Class match
|
|
139
|
+
if (selector.classes) {
|
|
140
|
+
const nodeClasses = new Set(node.classes || []);
|
|
141
|
+
if (selector.classes.every(c => nodeClasses.has(c))) return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Cleanup
|
|
148
|
+
destroy() {
|
|
149
|
+
this.subscriptions.clear();
|
|
150
|
+
this.elementWatchers.clear();
|
|
151
|
+
this.lastSnapshot = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Global instance
|
|
156
|
+
let globalNotifier = null;
|
|
157
|
+
|
|
158
|
+
export function getChangeNotifier() {
|
|
159
|
+
if (!globalNotifier) {
|
|
160
|
+
globalNotifier = new ChangeNotifier();
|
|
161
|
+
}
|
|
162
|
+
return globalNotifier;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function destroyChangeNotifier() {
|
|
166
|
+
if (globalNotifier) {
|
|
167
|
+
globalNotifier.destroy();
|
|
168
|
+
globalNotifier = null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Container Element Filter - Filter DOM elements by visibility, container definitions
|
|
2
|
+
|
|
3
|
+
export class ElementFilter {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.viewportMargin = options.viewportMargin || 0;
|
|
6
|
+
this.minVisibleRatio = options.minVisibleRatio || 0.5;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Check if element is in viewport
|
|
10
|
+
isInViewport(rect, viewport) {
|
|
11
|
+
return (
|
|
12
|
+
rect.left < viewport.width + this.viewportMargin &&
|
|
13
|
+
rect.right > -this.viewportMargin &&
|
|
14
|
+
rect.top < viewport.height + this.viewportMargin &&
|
|
15
|
+
rect.bottom > -this.viewportMargin
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Calculate visibility ratio
|
|
20
|
+
getVisibilityRatio(rect, viewport) {
|
|
21
|
+
const visibleLeft = Math.max(0, rect.left);
|
|
22
|
+
const visibleTop = Math.max(0, rect.top);
|
|
23
|
+
const visibleRight = Math.min(viewport.width, rect.right);
|
|
24
|
+
const visibleBottom = Math.min(viewport.height, rect.bottom);
|
|
25
|
+
|
|
26
|
+
const visibleArea = Math.max(0, visibleRight - visibleLeft) * Math.max(0, visibleBottom - visibleTop);
|
|
27
|
+
const totalArea = rect.width * rect.height;
|
|
28
|
+
|
|
29
|
+
return totalArea > 0 ? visibleArea / totalArea : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Filter elements by container definition
|
|
33
|
+
filterByContainer(elements, containerDef) {
|
|
34
|
+
const selectors = containerDef.selectors || [];
|
|
35
|
+
const results = [];
|
|
36
|
+
|
|
37
|
+
for (const element of elements) {
|
|
38
|
+
for (const selector of selectors) {
|
|
39
|
+
if (this.matchesSelector(element, selector)) {
|
|
40
|
+
results.push({
|
|
41
|
+
element,
|
|
42
|
+
container: containerDef,
|
|
43
|
+
matchedSelector: selector,
|
|
44
|
+
});
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if element matches selector definition
|
|
54
|
+
matchesSelector(element, selector) {
|
|
55
|
+
if (selector.css && element.selector === selector.css) return true;
|
|
56
|
+
if (selector.id && element.id === selector.id) return true;
|
|
57
|
+
if (selector.classes) {
|
|
58
|
+
const elementClasses = new Set(element.classes || []);
|
|
59
|
+
if (selector.classes.every(c => elementClasses.has(c))) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Main filter method
|
|
65
|
+
filter(elements, options = {}) {
|
|
66
|
+
const {
|
|
67
|
+
container,
|
|
68
|
+
viewport,
|
|
69
|
+
requireVisible = true,
|
|
70
|
+
minVisibleRatio = this.minVisibleRatio,
|
|
71
|
+
} = options;
|
|
72
|
+
|
|
73
|
+
let results = [...elements];
|
|
74
|
+
|
|
75
|
+
// Filter by container definition
|
|
76
|
+
if (container) {
|
|
77
|
+
results = this.filterByContainer(results, container);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Filter by viewport visibility
|
|
81
|
+
if (requireVisible && viewport) {
|
|
82
|
+
results = results.filter(item => {
|
|
83
|
+
const rect = item.element?.rect || item.rect;
|
|
84
|
+
if (!rect) return false;
|
|
85
|
+
const ratio = this.getVisibilityRatio(rect, viewport);
|
|
86
|
+
item.visibilityRatio = ratio;
|
|
87
|
+
return ratio >= minVisibleRatio;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createElementFilter(options) {
|
|
96
|
+
return new ElementFilter(options);
|
|
97
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Camo Container Client - High-level API for container subscription
|
|
2
|
+
|
|
3
|
+
import { callAPI, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
|
|
4
|
+
import { getDefaultProfile } from '../utils/config.mjs';
|
|
5
|
+
import { getChangeNotifier } from '../container/change-notifier.mjs';
|
|
6
|
+
import { createElementFilter } from '../container/element-filter.mjs';
|
|
7
|
+
|
|
8
|
+
export class CamoContainerClient {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.profileId = options.profileId || getDefaultProfile();
|
|
11
|
+
this.serviceUrl = options.serviceUrl || 'http://127.0.0.1:7704';
|
|
12
|
+
this.notifier = getChangeNotifier();
|
|
13
|
+
this.filter = createElementFilter(options.filterOptions || {});
|
|
14
|
+
this.session = null;
|
|
15
|
+
this.pollInterval = null;
|
|
16
|
+
this.subscriptions = new Map(); // containerId -> callback
|
|
17
|
+
this.lastSnapshot = null;
|
|
18
|
+
this.viewport = { width: 1280, height: 720 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async ensureSession() {
|
|
22
|
+
if (this.session) return this.session;
|
|
23
|
+
|
|
24
|
+
if (!await checkBrowserService()) {
|
|
25
|
+
throw new Error('Browser service not running. Run: camo init');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.session = await getSessionByProfile(this.profileId);
|
|
29
|
+
if (!this.session) {
|
|
30
|
+
throw new Error(`No active session for profile: ${this.profileId}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return this.session;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getSnapshot() {
|
|
37
|
+
await this.ensureSession();
|
|
38
|
+
|
|
39
|
+
const result = await callAPI(`/session/${this.session.session_id}/dom-tree`, { method: 'POST' });
|
|
40
|
+
this.lastSnapshot = result.dom_tree || result;
|
|
41
|
+
return this.lastSnapshot;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getViewport() {
|
|
45
|
+
await this.ensureSession();
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const result = await callAPI(`/session/${this.session.session_id}/viewport`);
|
|
49
|
+
this.viewport = result.viewport || this.viewport;
|
|
50
|
+
} catch {}
|
|
51
|
+
|
|
52
|
+
return this.viewport;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Subscribe to container changes
|
|
56
|
+
async subscribe(containers, options = {}) {
|
|
57
|
+
const { throttle = 500 } = options;
|
|
58
|
+
await this.ensureSession();
|
|
59
|
+
|
|
60
|
+
for (const container of containers) {
|
|
61
|
+
const { containerId, selector, onAppear, onDisappear, onChange } = container;
|
|
62
|
+
|
|
63
|
+
this.notifier.watch(
|
|
64
|
+
typeof selector === 'string' ? { css: selector } : selector,
|
|
65
|
+
{
|
|
66
|
+
onAppear: (elements) => {
|
|
67
|
+
this.subscriptions.get(containerId)?.onAppear?.(elements);
|
|
68
|
+
},
|
|
69
|
+
onDisappear: (elements) => {
|
|
70
|
+
this.subscriptions.get(containerId)?.onDisappear?.(elements);
|
|
71
|
+
},
|
|
72
|
+
onChange: (data) => {
|
|
73
|
+
this.subscriptions.get(containerId)?.onChange?.(data);
|
|
74
|
+
},
|
|
75
|
+
throttle,
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
this.subscriptions.set(containerId, {
|
|
80
|
+
selector,
|
|
81
|
+
onAppear,
|
|
82
|
+
onDisappear,
|
|
83
|
+
onChange,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Start polling
|
|
88
|
+
if (!this.pollInterval) {
|
|
89
|
+
this.pollInterval = setInterval(async () => {
|
|
90
|
+
try {
|
|
91
|
+
const snapshot = await this.getSnapshot();
|
|
92
|
+
this.notifier.processSnapshot(snapshot);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Ignore errors during polling
|
|
95
|
+
}
|
|
96
|
+
}, throttle);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
unsubscribe: () => {
|
|
101
|
+
for (const container of containers) {
|
|
102
|
+
this.subscriptions.delete(container.containerId);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Checkpoint detection helper
|
|
109
|
+
async detectCheckpoint(checkpointRules) {
|
|
110
|
+
const snapshot = await this.getSnapshot();
|
|
111
|
+
const viewport = await this.getViewport();
|
|
112
|
+
|
|
113
|
+
const matched = new Set();
|
|
114
|
+
|
|
115
|
+
for (const [checkpointId, rule] of Object.entries(checkpointRules)) {
|
|
116
|
+
const { selectors, requireAll = false } = rule;
|
|
117
|
+
let matchCount = 0;
|
|
118
|
+
|
|
119
|
+
for (const selector of selectors) {
|
|
120
|
+
const elements = this.notifier.findElements(snapshot, { css: selector });
|
|
121
|
+
const visible = elements.filter(e => {
|
|
122
|
+
const rect = e.rect || e.bbox;
|
|
123
|
+
if (!rect) return false;
|
|
124
|
+
return this.filter.isInViewport(rect, viewport);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (visible.length > 0) {
|
|
128
|
+
matchCount++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (requireAll) {
|
|
133
|
+
if (matchCount === selectors.length) {
|
|
134
|
+
matched.add(checkpointId);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
if (matchCount > 0) {
|
|
138
|
+
matched.add(checkpointId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Array.from(matched);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// List visible elements
|
|
147
|
+
async listVisibleElements(options = {}) {
|
|
148
|
+
const { minVisibility = 0.1, maxResults = 50 } = options;
|
|
149
|
+
const snapshot = await this.getSnapshot();
|
|
150
|
+
const viewport = await this.getViewport();
|
|
151
|
+
|
|
152
|
+
const collect = (node, path = 'root') => {
|
|
153
|
+
const elements = [];
|
|
154
|
+
if (!node) return elements;
|
|
155
|
+
|
|
156
|
+
const rect = node.rect || node.bbox;
|
|
157
|
+
if (rect) {
|
|
158
|
+
const ratio = this.filter.getVisibilityRatio(rect, viewport);
|
|
159
|
+
if (ratio >= minVisibility) {
|
|
160
|
+
elements.push({
|
|
161
|
+
path,
|
|
162
|
+
tag: node.tag,
|
|
163
|
+
id: node.id,
|
|
164
|
+
classes: node.classes?.slice(0, 3),
|
|
165
|
+
visibilityRatio: Math.round(ratio * 100) / 100,
|
|
166
|
+
rect: { x: rect.left || rect.x, y: rect.top || rect.y, w: rect.width, h: rect.height },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (node.children) {
|
|
172
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
173
|
+
elements.push(...collect(node.children[i], `${path}/${i}`));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return elements;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const all = collect(snapshot);
|
|
181
|
+
return all.slice(0, maxResults);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Cleanup
|
|
185
|
+
destroy() {
|
|
186
|
+
if (this.pollInterval) {
|
|
187
|
+
clearInterval(this.pollInterval);
|
|
188
|
+
this.pollInterval = null;
|
|
189
|
+
}
|
|
190
|
+
this.subscriptions.clear();
|
|
191
|
+
this.notifier.destroy();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function createCamoClient(options) {
|
|
196
|
+
return new CamoContainerClient(options);
|
|
197
|
+
}
|
package/src/utils/help.mjs
CHANGED
|
@@ -114,6 +114,11 @@ EXAMPLES:
|
|
|
114
114
|
camo unlock myprofile
|
|
115
115
|
camo stop
|
|
116
116
|
|
|
117
|
+
CONTAINER FILTER & SUBSCRIPTION:
|
|
118
|
+
container filter <selector> [--profile <id>] Filter DOM elements by CSS selector
|
|
119
|
+
container watch --selector <css> Watch for element changes
|
|
120
|
+
container list List visible elements in viewport
|
|
121
|
+
|
|
117
122
|
ENV:
|
|
118
123
|
WEBAUTO_BROWSER_URL Default: http://127.0.0.1:7704
|
|
119
124
|
WEBAUTO_REPO_ROOT Optional explicit webauto repo root
|