vision-navigator 1.0.0
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/README.md +113 -0
- package/dist/cdp-driver.js +484 -0
- package/dist/cli.js +207 -0
- package/dist/injected-scripts.js +328 -0
- package/dist/navigator.js +897 -0
- package/dist/server.js +409 -0
- package/dist/storage.js +132 -0
- package/package.json +48 -0
- package/public/app.js +155 -0
- package/public/index.html +567 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Vision Navigator
|
|
2
|
+
|
|
3
|
+
A raw CDP (Chrome DevTools Protocol) browser automation tool powered by local LLMs (Ollama) or OpenRouter.
|
|
4
|
+
It natively handles navigation, simplified DOM extraction, and browser interaction via a custom CDP driver, avoiding heavy dependencies like Puppeteer or Playwright.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
- **Custom CDP Driver**: Communicates directly with Chromium over WebSockets.
|
|
8
|
+
- **Local AI Powered**: Uses `qwen2.5:3b` via Ollama by default, capable of running entirely locally.
|
|
9
|
+
- **Web UI Dashboard**: Includes a sleek dashboard to submit YAML workflows and view step-by-step results, screenshots, and logs.
|
|
10
|
+
- **AI Diagnostics**: Automatically monitors console errors, network issues, and performance metrics, and uses the AI to provide usability/performance suggestions.
|
|
11
|
+
- **CLI Interface**: Can be run as an NPM package/CLI to execute workflows headlessly and get a pass/fail report.
|
|
12
|
+
|
|
13
|
+
## 🚀 How to Run (3 Ways)
|
|
14
|
+
|
|
15
|
+
Vision Navigator is flexible and can be run in three different modes depending on your needs.
|
|
16
|
+
|
|
17
|
+
### Environment Configuration (.env)
|
|
18
|
+
|
|
19
|
+
You can configure the model, LLM provider, and storage settings using environment variables. Create a `.env` file in the root of the `vision_navigator_ts` directory (or wherever you are running the CLI from):
|
|
20
|
+
|
|
21
|
+
```env
|
|
22
|
+
# Default is qwen2.5:3b via local Ollama
|
|
23
|
+
OLLAMA_URL=http://localhost:11434
|
|
24
|
+
MODEL=qwen2.5:3b
|
|
25
|
+
|
|
26
|
+
# If using OpenRouter instead of local Ollama
|
|
27
|
+
OPENROUTER_API_KEY=your_api_key_here
|
|
28
|
+
OPENROUTER_MODEL=google/gemini-2.0-flash-exp:free
|
|
29
|
+
|
|
30
|
+
# Storage configurations (optional)
|
|
31
|
+
POCKETBASE_URL=http://127.0.0.1:8091
|
|
32
|
+
MINIO_ENDPOINT=127.0.0.1
|
|
33
|
+
MINIO_PORT=9002
|
|
34
|
+
MINIO_ACCESS_KEY=minioadmin
|
|
35
|
+
MINIO_SECRET_KEY=minioadmin
|
|
36
|
+
MINIO_BUCKET=store-runs
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 1. As a Standalone NPM CLI
|
|
40
|
+
|
|
41
|
+
If you just want to run tests locally via your terminal, you can install and use it as an NPM package.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Install dependencies and build
|
|
45
|
+
npm install
|
|
46
|
+
npm run build
|
|
47
|
+
|
|
48
|
+
# Link the package globally
|
|
49
|
+
npm link
|
|
50
|
+
|
|
51
|
+
# Run a single workflow
|
|
52
|
+
vision-navigator run ./workflows/riskely-test.yaml
|
|
53
|
+
|
|
54
|
+
# Run a directory of tests
|
|
55
|
+
vision-navigator test ./workflows
|
|
56
|
+
|
|
57
|
+
# Start the built-in web server (runs on port 8000)
|
|
58
|
+
vision-navigator serve
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Standalone Docker Container
|
|
62
|
+
|
|
63
|
+
If you want an isolated environment without installing Node.js or Chromium on your host machine, you can run the tool as a standalone Docker container.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Build the Docker image
|
|
67
|
+
docker build -t vision-navigator .
|
|
68
|
+
|
|
69
|
+
# Run a single workflow (mount your local workflows directory)
|
|
70
|
+
docker run --rm -v $(pwd)/workflows:/usr/src/app/workflows vision-navigator npm run start -- run workflows/riskely-test.yaml
|
|
71
|
+
|
|
72
|
+
# Start the Web UI only
|
|
73
|
+
docker run -p 8000:8000 vision-navigator
|
|
74
|
+
```
|
|
75
|
+
*Note: If you use a local Ollama instance on your host, make sure to pass the correct `OLLAMA_URL` environment variable (e.g. `-e OLLAMA_URL=http://host.docker.internal:11434`).*
|
|
76
|
+
|
|
77
|
+
### 3. Full Stack with Docker Compose (Recommended for Self-Hosting)
|
|
78
|
+
|
|
79
|
+
The most comprehensive way to run Vision Navigator. This spins up the full environment including the main server, a dedicated **Ollama** container (pre-configured with `qwen2.5:3b`), **PocketBase** (for test run history), and **Minio** (for storing screenshot artifacts).
|
|
80
|
+
|
|
81
|
+
1. Ensure you have Docker and Docker Compose installed.
|
|
82
|
+
2. Run the following command in the root directory:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
docker-compose up -d --build
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This will automatically:
|
|
89
|
+
- Start **Vision Navigator** Web UI at `http://localhost:8000`
|
|
90
|
+
- Start **PocketBase** at `http://localhost:8091`
|
|
91
|
+
- Start **Minio** at `http://localhost:9002`
|
|
92
|
+
- Start **Ollama** and automatically pull the `qwen2.5:3b` model.
|
|
93
|
+
|
|
94
|
+
**Running CLI tests within Docker Compose:**
|
|
95
|
+
```bash
|
|
96
|
+
# Run a single workflow
|
|
97
|
+
docker exec -it vision-navigator npm run start -- run workflows/riskely-test.yaml
|
|
98
|
+
|
|
99
|
+
# Run all workflows in a directory
|
|
100
|
+
docker exec -it vision-navigator npm run start -- test workflows
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 📝 Writing Workflows
|
|
104
|
+
Workflows are written in simple YAML format.
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
steps:
|
|
108
|
+
- instruction: "Navigate to http://quotes.toscrape.com/"
|
|
109
|
+
- instruction: "Click the 'Login' link near the top right"
|
|
110
|
+
- instruction: "Type 'admin' into the username field"
|
|
111
|
+
- instruction: "Type 'password123' into the password field"
|
|
112
|
+
- instruction: "Click the Login button"
|
|
113
|
+
```
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.CDPDriver = void 0;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const axios_1 = __importDefault(require("axios"));
|
|
9
|
+
const ws_1 = __importDefault(require("ws"));
|
|
10
|
+
const injected_scripts_1 = require("./injected-scripts");
|
|
11
|
+
const os_1 = __importDefault(require("os"));
|
|
12
|
+
class CDPDriver {
|
|
13
|
+
constructor(port = 9222) {
|
|
14
|
+
this.chromeProcess = null;
|
|
15
|
+
this.ws = null;
|
|
16
|
+
this.logs = [];
|
|
17
|
+
this.networkIssues = [];
|
|
18
|
+
this.performanceMetrics = null;
|
|
19
|
+
this.messageId = 1;
|
|
20
|
+
this.pendingRequests = new Map();
|
|
21
|
+
this.eventListeners = new Map();
|
|
22
|
+
this.port = port;
|
|
23
|
+
}
|
|
24
|
+
getChromePath() {
|
|
25
|
+
if (process.env.CHROME_BIN)
|
|
26
|
+
return process.env.CHROME_BIN;
|
|
27
|
+
const platform = os_1.default.platform();
|
|
28
|
+
if (platform === 'darwin')
|
|
29
|
+
return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
30
|
+
if (platform === 'win32')
|
|
31
|
+
return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
|
32
|
+
return '/usr/bin/google-chrome'; // Linux
|
|
33
|
+
}
|
|
34
|
+
async start() {
|
|
35
|
+
console.log("Starting custom CDP driver without Puppeteer...");
|
|
36
|
+
const chromePath = this.getChromePath();
|
|
37
|
+
this.chromeProcess = (0, child_process_1.spawn)(chromePath, [
|
|
38
|
+
`--remote-debugging-port=${this.port}`,
|
|
39
|
+
'--headless=new', // Modern headless fixes blank screenshot issues
|
|
40
|
+
'--no-sandbox',
|
|
41
|
+
'--disable-dev-shm-usage',
|
|
42
|
+
'--window-size=1280,800',
|
|
43
|
+
'--disable-web-security',
|
|
44
|
+
'--ignore-certificate-errors'
|
|
45
|
+
]);
|
|
46
|
+
// Wait for Chrome to start CDP server
|
|
47
|
+
let wsUrl = '';
|
|
48
|
+
for (let i = 0; i < 20; i++) {
|
|
49
|
+
try {
|
|
50
|
+
await new Promise(r => setTimeout(r, 500));
|
|
51
|
+
const res = await axios_1.default.get(`http://127.0.0.1:${this.port}/json`);
|
|
52
|
+
const page = res.data.find((t) => t.type === 'page');
|
|
53
|
+
if (page && page.webSocketDebuggerUrl) {
|
|
54
|
+
wsUrl = page.webSocketDebuggerUrl;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
// Ignore and retry
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!wsUrl) {
|
|
63
|
+
this.stop();
|
|
64
|
+
throw new Error("Failed to connect to Chrome CDP");
|
|
65
|
+
}
|
|
66
|
+
console.log(`Connected to native CDP WebSocket: ${wsUrl}`);
|
|
67
|
+
await this.connectWs(wsUrl);
|
|
68
|
+
// Enable domains
|
|
69
|
+
await this.send('Page.enable');
|
|
70
|
+
await this.send('Runtime.enable');
|
|
71
|
+
await this.send('Log.enable');
|
|
72
|
+
await this.send('Network.enable');
|
|
73
|
+
await this.send('Performance.enable');
|
|
74
|
+
// Listen to console events
|
|
75
|
+
this.on('Runtime.consoleAPICalled', (params) => {
|
|
76
|
+
const type = params.type.toUpperCase();
|
|
77
|
+
if (['WARNING', 'ERROR'].includes(type) || type === 'WARN') {
|
|
78
|
+
const args = params.args.map((a) => a.value || a.description).join(' ');
|
|
79
|
+
this.logs.push(`[CONSOLE ${type}] ${args}`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
this.on('Runtime.exceptionThrown', (params) => {
|
|
83
|
+
this.logs.push(`[CONSOLE EXCEPTION] ${params.exceptionDetails.text}`);
|
|
84
|
+
});
|
|
85
|
+
this.on('Network.requestFailed', (params) => {
|
|
86
|
+
const { request, errorText } = params;
|
|
87
|
+
this.networkIssues.push(`[NETWORK FAILED] ${request?.url || params.requestId} - ${errorText}`);
|
|
88
|
+
});
|
|
89
|
+
this.on('Network.responseReceived', (params) => {
|
|
90
|
+
const { response } = params;
|
|
91
|
+
if (response && response.status >= 400) {
|
|
92
|
+
this.networkIssues.push(`[NETWORK ${response.status}] ${response.url}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
connectWs(url) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
this.ws = new ws_1.default(url);
|
|
99
|
+
this.ws.on('open', resolve);
|
|
100
|
+
this.ws.on('error', reject);
|
|
101
|
+
this.ws.on('message', (data) => {
|
|
102
|
+
const msg = JSON.parse(data.toString());
|
|
103
|
+
if (msg.id) {
|
|
104
|
+
const cb = this.pendingRequests.get(msg.id);
|
|
105
|
+
if (cb) {
|
|
106
|
+
if (msg.error)
|
|
107
|
+
cb.reject(new Error(msg.error.message));
|
|
108
|
+
else
|
|
109
|
+
cb.resolve(msg.result);
|
|
110
|
+
this.pendingRequests.delete(msg.id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (msg.method) {
|
|
114
|
+
const listeners = this.eventListeners.get(msg.method) || [];
|
|
115
|
+
listeners.forEach(fn => fn(msg.params));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
send(method, params = {}) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
if (!this.ws)
|
|
123
|
+
return reject(new Error("No WS connection"));
|
|
124
|
+
const id = this.messageId++;
|
|
125
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
126
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
on(method, callback) {
|
|
130
|
+
const listeners = this.eventListeners.get(method) || [];
|
|
131
|
+
listeners.push(callback);
|
|
132
|
+
this.eventListeners.set(method, listeners);
|
|
133
|
+
}
|
|
134
|
+
once(method) {
|
|
135
|
+
return new Promise(resolve => {
|
|
136
|
+
const cb = (params) => {
|
|
137
|
+
resolve(params);
|
|
138
|
+
const listeners = this.eventListeners.get(method) || [];
|
|
139
|
+
this.eventListeners.set(method, listeners.filter(fn => fn !== cb));
|
|
140
|
+
};
|
|
141
|
+
this.on(method, cb);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async stop() {
|
|
145
|
+
if (this.ws)
|
|
146
|
+
this.ws.close();
|
|
147
|
+
if (this.chromeProcess) {
|
|
148
|
+
this.chromeProcess.kill();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
getLogs() {
|
|
152
|
+
const logs = [...this.logs];
|
|
153
|
+
this.logs = [];
|
|
154
|
+
return logs;
|
|
155
|
+
}
|
|
156
|
+
getNetworkIssues() {
|
|
157
|
+
const issues = [...this.networkIssues];
|
|
158
|
+
this.networkIssues = [];
|
|
159
|
+
return issues;
|
|
160
|
+
}
|
|
161
|
+
async getPerformanceMetrics() {
|
|
162
|
+
try {
|
|
163
|
+
const metrics = await this.send('Performance.getMetrics');
|
|
164
|
+
return metrics.metrics.reduce((acc, m) => {
|
|
165
|
+
acc[m.name] = m.value;
|
|
166
|
+
return acc;
|
|
167
|
+
}, {});
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async executeScript(script, args = []) {
|
|
174
|
+
let expression = script;
|
|
175
|
+
if (args.length > 0) {
|
|
176
|
+
expression = `(${script})(...${JSON.stringify(args)})`;
|
|
177
|
+
}
|
|
178
|
+
const res = await this.send('Runtime.evaluate', {
|
|
179
|
+
expression: expression,
|
|
180
|
+
returnByValue: true,
|
|
181
|
+
awaitPromise: true
|
|
182
|
+
});
|
|
183
|
+
if (res.exceptionDetails) {
|
|
184
|
+
throw new Error(`Script error: ${res.exceptionDetails.exception?.description || res.exceptionDetails.text}`);
|
|
185
|
+
}
|
|
186
|
+
return res.result.value;
|
|
187
|
+
}
|
|
188
|
+
async navigate(url) {
|
|
189
|
+
console.log(`Navigating to ${url}...`);
|
|
190
|
+
const navPromise = this.once('Page.loadEventFired');
|
|
191
|
+
await this.send('Page.navigate', { url });
|
|
192
|
+
await navPromise;
|
|
193
|
+
// Add a small delay after navigation to ensure initial render is complete
|
|
194
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
195
|
+
const res = await this.executeScript(`document.title`);
|
|
196
|
+
console.log(`Page loaded. Title: ${res}`);
|
|
197
|
+
}
|
|
198
|
+
async getScreenshot() {
|
|
199
|
+
await new Promise(r => setTimeout(r, 500));
|
|
200
|
+
const res = await this.send('Page.captureScreenshot', { format: 'png' });
|
|
201
|
+
return res.data;
|
|
202
|
+
}
|
|
203
|
+
async click(x, y) {
|
|
204
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
|
205
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
|
206
|
+
}
|
|
207
|
+
async typeText(text) {
|
|
208
|
+
for (const char of text) {
|
|
209
|
+
await this.send('Input.dispatchKeyEvent', { type: 'char', text: char });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async scroll(direction) {
|
|
213
|
+
const yDelta = direction === "down" ? 500 : -500;
|
|
214
|
+
await this.executeScript(`window.scrollBy(0, ${yDelta})`);
|
|
215
|
+
await new Promise(r => setTimeout(r, 500));
|
|
216
|
+
}
|
|
217
|
+
async getSimplifiedDOM() {
|
|
218
|
+
const result = await this.executeScript(injected_scripts_1.PAGE_AGENT_SCRIPT);
|
|
219
|
+
if (result && result.tree) {
|
|
220
|
+
return this.formatDOMTree(result.tree);
|
|
221
|
+
}
|
|
222
|
+
return "<body></body>";
|
|
223
|
+
}
|
|
224
|
+
formatDOMTree(node, depth = 0) {
|
|
225
|
+
if (!node)
|
|
226
|
+
return '';
|
|
227
|
+
if (node.type === 'TEXT_NODE') {
|
|
228
|
+
const text = node.text.replace(/\s+/g, ' ').trim();
|
|
229
|
+
return text ? (text.length > 50 ? text.substring(0, 50) + '...' : text) + ' ' : '';
|
|
230
|
+
}
|
|
231
|
+
const tagName = node.tagName;
|
|
232
|
+
let attributes = '';
|
|
233
|
+
if (node.isInteractive && node.highlightIndex !== null) {
|
|
234
|
+
attributes += ` id="${node.highlightIndex}"`;
|
|
235
|
+
}
|
|
236
|
+
const importantAttrs = ['placeholder', 'aria-label', 'title', 'name', 'value', 'alt'];
|
|
237
|
+
if (node.attributes) {
|
|
238
|
+
for (const [key, val] of Object.entries(node.attributes)) {
|
|
239
|
+
if (importantAttrs.includes(key) && val) {
|
|
240
|
+
const valStr = String(val);
|
|
241
|
+
const truncated = valStr.length > 30 ? valStr.substring(0, 30) + '...' : valStr;
|
|
242
|
+
attributes += ` ${key}="${truncated}"`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let childrenHTML = '';
|
|
247
|
+
if (node.children && node.children.length > 0) {
|
|
248
|
+
for (const child of node.children) {
|
|
249
|
+
childrenHTML += this.formatDOMTree(child, depth + 1);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const hasContent = childrenHTML.trim().length > 0;
|
|
253
|
+
if (!node.isInteractive && !hasContent) {
|
|
254
|
+
return '';
|
|
255
|
+
}
|
|
256
|
+
if (node.isInteractive) {
|
|
257
|
+
if (hasContent) {
|
|
258
|
+
return `<${tagName}${attributes}>${childrenHTML}</${tagName}>`;
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
return `<${tagName}${attributes}/>`;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
return childrenHTML + ' ';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async clickById(id) {
|
|
269
|
+
console.log(`Clicking element with ID: ${id}`);
|
|
270
|
+
await this.executeScript(`
|
|
271
|
+
(function() {
|
|
272
|
+
const el = document.querySelector('[data-vnav-id="${id}"]');
|
|
273
|
+
if (el) {
|
|
274
|
+
// Visual feedback
|
|
275
|
+
const rect = el.getBoundingClientRect();
|
|
276
|
+
const overlay = document.createElement('div');
|
|
277
|
+
Object.assign(overlay.style, {
|
|
278
|
+
position: 'fixed', left: rect.left + 'px', top: rect.top + 'px',
|
|
279
|
+
width: rect.width + 'px', height: rect.height + 'px',
|
|
280
|
+
backgroundColor: 'rgba(255, 0, 0, 0.3)', border: '2px solid red',
|
|
281
|
+
zIndex: '2147483647', pointerEvents: 'none'
|
|
282
|
+
});
|
|
283
|
+
document.body.appendChild(overlay);
|
|
284
|
+
setTimeout(() => overlay.remove(), 500);
|
|
285
|
+
|
|
286
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
287
|
+
el.click();
|
|
288
|
+
}
|
|
289
|
+
})();
|
|
290
|
+
`);
|
|
291
|
+
await new Promise(r => setTimeout(r, 600));
|
|
292
|
+
}
|
|
293
|
+
async typeById(id, text) {
|
|
294
|
+
console.log(`Typing "${text}" into element with ID: ${id}`);
|
|
295
|
+
await this.executeScript(`
|
|
296
|
+
(function() {
|
|
297
|
+
const el = document.querySelector('[data-vnav-id="${id}"]');
|
|
298
|
+
if (el) {
|
|
299
|
+
// Visual feedback
|
|
300
|
+
const rect = el.getBoundingClientRect();
|
|
301
|
+
const overlay = document.createElement('div');
|
|
302
|
+
Object.assign(overlay.style, {
|
|
303
|
+
position: 'fixed', left: rect.left + 'px', top: rect.top + 'px',
|
|
304
|
+
width: rect.width + 'px', height: rect.height + 'px',
|
|
305
|
+
backgroundColor: 'rgba(0, 0, 255, 0.3)', border: '2px solid blue',
|
|
306
|
+
zIndex: '2147483647', pointerEvents: 'none'
|
|
307
|
+
});
|
|
308
|
+
document.body.appendChild(overlay);
|
|
309
|
+
setTimeout(() => overlay.remove(), 500);
|
|
310
|
+
|
|
311
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
312
|
+
el.focus();
|
|
313
|
+
|
|
314
|
+
if (el.isContentEditable) {
|
|
315
|
+
if (!document.execCommand('insertText', false, ${JSON.stringify(text)})) {
|
|
316
|
+
el.innerText = ${JSON.stringify(text)};
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
// React-friendly value setter
|
|
320
|
+
const proto = el instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
|
321
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
322
|
+
if (nativeInputValueSetter) {
|
|
323
|
+
nativeInputValueSetter.call(el, ${JSON.stringify(text)});
|
|
324
|
+
} else {
|
|
325
|
+
el.value = ${JSON.stringify(text)};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
330
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
331
|
+
}
|
|
332
|
+
})();
|
|
333
|
+
`);
|
|
334
|
+
await new Promise(r => setTimeout(r, 600));
|
|
335
|
+
}
|
|
336
|
+
async dismissCookieBanners(opts) {
|
|
337
|
+
try {
|
|
338
|
+
const aggressive = Boolean(opts?.aggressive);
|
|
339
|
+
const res = await this.executeScript(`
|
|
340
|
+
(() => {
|
|
341
|
+
const aggressive = ${JSON.stringify(aggressive)};
|
|
342
|
+
const COOKIE_WORDS = [
|
|
343
|
+
'cookie', 'cookies', 'kaka', 'kakor', 'samtycke', 'samtyck', 'consent', 'gdpr', 'integritet', 'privacy'
|
|
344
|
+
];
|
|
345
|
+
const ACCEPT = [
|
|
346
|
+
'accept', 'accept all', 'agree', 'i agree', 'allow', 'allow all', 'ok', 'okay',
|
|
347
|
+
'acceptera', 'acceptera alla', 'godkänn', 'godkänn alla', 'tillåt', 'tillåt alla', 'jag godkänner', 'jag accepterar',
|
|
348
|
+
'samtyck', 'samtycker'
|
|
349
|
+
];
|
|
350
|
+
const DENY = [
|
|
351
|
+
'reject', 'decline', 'deny', 'no thanks', 'manage', 'settings', 'preferences', 'customize', 'only necessary',
|
|
352
|
+
'avvisa', 'neka', 'inställ', 'inställningar', 'anpassa', 'nödvänd', 'bara nödvändiga', 'hantera', 'preferenser'
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
const normalize = (s) => (s || '')
|
|
356
|
+
.toString()
|
|
357
|
+
.toLowerCase()
|
|
358
|
+
.normalize('NFD')
|
|
359
|
+
.replace(/[\\u0300-\\u036f]/g, '')
|
|
360
|
+
.replace(/\\s+/g, ' ')
|
|
361
|
+
.trim();
|
|
362
|
+
|
|
363
|
+
const isVisible = (el) => {
|
|
364
|
+
try {
|
|
365
|
+
const style = window.getComputedStyle(el);
|
|
366
|
+
if (!style || style.visibility === 'hidden' || style.display === 'none') return false;
|
|
367
|
+
const rect = el.getBoundingClientRect();
|
|
368
|
+
if (!rect || rect.width < 2 || rect.height < 2) return false;
|
|
369
|
+
if (rect.bottom < 0 || rect.right < 0) return false;
|
|
370
|
+
return true;
|
|
371
|
+
} catch {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const hasCookieContext = (el) => {
|
|
377
|
+
try {
|
|
378
|
+
const bodyText = normalize(document.body?.innerText || document.body?.textContent || '');
|
|
379
|
+
const globalCookie = COOKIE_WORDS.some(w => bodyText.includes(normalize(w)));
|
|
380
|
+
if (globalCookie) return true;
|
|
381
|
+
let node = el;
|
|
382
|
+
for (let i = 0; i < 6 && node; i++) {
|
|
383
|
+
const t = normalize(node.innerText || node.textContent || '');
|
|
384
|
+
if (COOKIE_WORDS.some(w => t.includes(normalize(w)))) return true;
|
|
385
|
+
node = node.parentElement;
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
} catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const scoreEl = (el) => {
|
|
394
|
+
const text = normalize(el.innerText || el.textContent || '');
|
|
395
|
+
const label = normalize(el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('value') || '');
|
|
396
|
+
const combined = (text + ' ' + label).trim();
|
|
397
|
+
if (!combined) return { score: 0, combined };
|
|
398
|
+
for (const bad of DENY) {
|
|
399
|
+
if (combined.includes(normalize(bad))) return { score: 0, combined };
|
|
400
|
+
}
|
|
401
|
+
let score = 0;
|
|
402
|
+
for (const ok of ACCEPT) {
|
|
403
|
+
const tok = normalize(ok);
|
|
404
|
+
if (!tok) continue;
|
|
405
|
+
if (combined === tok) score += 50;
|
|
406
|
+
else if (combined.includes(tok)) score += 20;
|
|
407
|
+
}
|
|
408
|
+
if (!aggressive && !hasCookieContext(el)) score = 0;
|
|
409
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
410
|
+
if (tag === 'button') score += 5;
|
|
411
|
+
const role = normalize(el.getAttribute('role'));
|
|
412
|
+
if (role === 'button') score += 2;
|
|
413
|
+
return { score, combined };
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const knownSelectors = [
|
|
417
|
+
'#onetrust-accept-btn-handler',
|
|
418
|
+
'#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
|
|
419
|
+
'#CybotCookiebotDialogBodyButtonAccept',
|
|
420
|
+
'button[id*="cookie"][id*="accept"]',
|
|
421
|
+
'button[id*="consent"][id*="accept"]',
|
|
422
|
+
'button[data-testid*="cookie"][data-testid*="accept"]'
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
const clickFirst = (el) => {
|
|
426
|
+
try {
|
|
427
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
428
|
+
el.click();
|
|
429
|
+
return true;
|
|
430
|
+
} catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
for (const sel of knownSelectors) {
|
|
436
|
+
const el = document.querySelector(sel);
|
|
437
|
+
if (el && isVisible(el)) {
|
|
438
|
+
const meta = scoreEl(el);
|
|
439
|
+
if (clickFirst(el)) {
|
|
440
|
+
return { clicked: true, label: meta.combined || sel, candidates: 1 };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const collect = (root, out) => {
|
|
446
|
+
try {
|
|
447
|
+
const nodes = root.querySelectorAll('button, a, input[type="button"], input[type="submit"], [role="button"]');
|
|
448
|
+
nodes.forEach(n => out.push(n));
|
|
449
|
+
const all = root.querySelectorAll('*');
|
|
450
|
+
all.forEach(n => {
|
|
451
|
+
if (n && n.shadowRoot) collect(n.shadowRoot, out);
|
|
452
|
+
});
|
|
453
|
+
} catch {}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const candidates = [];
|
|
457
|
+
collect(document, candidates);
|
|
458
|
+
|
|
459
|
+
const scored = candidates
|
|
460
|
+
.filter(isVisible)
|
|
461
|
+
.map(el => ({ el, ...scoreEl(el) }))
|
|
462
|
+
.filter(x => x.score > 0)
|
|
463
|
+
.sort((a, b) => b.score - a.score);
|
|
464
|
+
|
|
465
|
+
if (scored.length === 0) return { clicked: false, candidates: 0 };
|
|
466
|
+
|
|
467
|
+
const best = scored[0];
|
|
468
|
+
const ok = clickFirst(best.el);
|
|
469
|
+
return { clicked: !!ok, label: best.combined, candidates: scored.length };
|
|
470
|
+
})()
|
|
471
|
+
`);
|
|
472
|
+
return { clicked: Boolean(res?.clicked), label: res?.label, candidates: res?.candidates };
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return { clicked: false };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Keep legacy method signature if strictly needed, or reimplement
|
|
479
|
+
async getInteractiveElements() {
|
|
480
|
+
// Not strictly used by Navigator currently, but good to have
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
exports.CDPDriver = CDPDriver;
|