bwb-browser 2.0.4 → 3.1.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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * bwb-browser — Page Diagnostics
3
+ *
4
+ * Comprehensive page health check: performance metrics, console errors,
5
+ * broken resources, meta tags, and network failures.
6
+ * Combines multiple CDP + JS checks into a single report.
7
+ */
8
+
9
+ /**
10
+ * Run a full diagnostic on the current page.
11
+ * Returns performance metrics, console errors, broken images, meta tags.
12
+ */
13
+ export async function diagnosePage(protocol) {
14
+ const { Runtime, Network, Page } = protocol;
15
+
16
+ // Collect console errors during diagnostic
17
+ const consoleErrors = [];
18
+ let unsubConsole, unsubException;
19
+
20
+ try {
21
+ await Runtime.enable();
22
+ unsubConsole = Runtime.consoleAPICalled((params) => {
23
+ if (params.type === "error" || params.type === "warning") {
24
+ consoleErrors.push({
25
+ level: params.type,
26
+ text: (params.args || []).map(a => a.value !== undefined ? String(a.value) : a.description || "").join(" "),
27
+ });
28
+ }
29
+ });
30
+ unsubException = Runtime.exceptionThrown((params) => {
31
+ const d = params.exceptionDetails;
32
+ consoleErrors.push({
33
+ level: "exception",
34
+ text: d?.exception?.description || d?.text || "Unknown exception",
35
+ });
36
+ });
37
+ } catch {}
38
+
39
+ // Allow page to settle
40
+ await new Promise(r => setTimeout(r, 300));
41
+
42
+ // Page load performance
43
+ let performance = {};
44
+ try {
45
+ const { result } = await Runtime.evaluate({
46
+ expression: `JSON.stringify({
47
+ loadTime: performance.timing ? (performance.timing.loadEventEnd - performance.timing.navigationStart) : null,
48
+ domContentLoaded: performance.timing ? (performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart) : null,
49
+ domInteractive: performance.timing ? (performance.timing.domInteractive - performance.timing.navigationStart) : null,
50
+ })`,
51
+ returnByValue: true,
52
+ });
53
+ performance = JSON.parse(result?.value || "{}");
54
+ } catch {}
55
+
56
+ // Meta tags
57
+ let meta = {};
58
+ try {
59
+ const { result } = await Runtime.evaluate({
60
+ expression: `JSON.stringify({
61
+ title: document.title,
62
+ description: (document.querySelector('meta[name=description]') || {}).content || '',
63
+ viewport: (document.querySelector('meta[name=viewport]') || {}).content || '',
64
+ charset: document.characterSet || '',
65
+ lang: document.documentElement?.lang || '',
66
+ })`,
67
+ returnByValue: true,
68
+ });
69
+ meta = JSON.parse(result?.value || "{}");
70
+ } catch {}
71
+
72
+ // Broken images
73
+ let brokenImages = [];
74
+ try {
75
+ const { result } = await Runtime.evaluate({
76
+ expression: `JSON.stringify(
77
+ Array.from(document.querySelectorAll('img')).filter(i => i.naturalWidth === 0 && i.src).map(i => ({
78
+ src: i.src.slice(0, 200),
79
+ alt: i.alt || '(missing)',
80
+ width: i.width,
81
+ height: i.height,
82
+ }))
83
+ )`,
84
+ returnByValue: true,
85
+ });
86
+ brokenImages = JSON.parse(result?.value || "[]");
87
+ } catch {}
88
+
89
+ // Links check
90
+ let linkCount = 0;
91
+ try {
92
+ const { result } = await Runtime.evaluate({
93
+ expression: "document.querySelectorAll('a[href]').length",
94
+ });
95
+ linkCount = result?.value || 0;
96
+ } catch {}
97
+
98
+ // Input count
99
+ let inputCount = 0;
100
+ try {
101
+ const { result } = await Runtime.evaluate({
102
+ expression: "document.querySelectorAll('input:not([type=hidden]), textarea, select, button, [role=button]').length",
103
+ });
104
+ inputCount = result?.value || 0;
105
+ } catch {}
106
+
107
+ // Current URL
108
+ let currentUrl = "";
109
+ try {
110
+ const { result } = await Runtime.evaluate({ expression: "location.href" });
111
+ currentUrl = result?.value || "";
112
+ } catch {}
113
+
114
+ // Cleanup
115
+ try { if (unsubConsole) unsubConsole(); } catch {}
116
+ try { if (unsubException) unsubException(); } catch {}
117
+
118
+ return {
119
+ url: currentUrl,
120
+ timestamp: new Date().toISOString(),
121
+ performance,
122
+ meta,
123
+ interactions: {
124
+ links: linkCount,
125
+ inputs: inputCount,
126
+ },
127
+ issues: {
128
+ consoleErrors: consoleErrors.slice(0, 20),
129
+ brokenImages: brokenImages.slice(0, 20),
130
+ hasBrokenImages: brokenImages.length > 0,
131
+ hasConsoleErrors: consoleErrors.length > 0,
132
+ },
133
+ score: calculateHealthScore(performance, consoleErrors, brokenImages),
134
+ };
135
+ }
136
+
137
+ function calculateHealthScore(perf, errors, brokenImgs) {
138
+ let score = 100;
139
+
140
+ // Penalize slow loads
141
+ if (perf.loadTime > 5000) score -= 20;
142
+ else if (perf.loadTime > 2000) score -= 10;
143
+
144
+ // Penalize errors
145
+ score -= errors.length * 5;
146
+ score -= brokenImgs.length * 10;
147
+
148
+ return Math.max(0, Math.min(100, score));
149
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * bwb-browser — Realistic Browser Fingerprint
3
+ *
4
+ * Applies standard browser fingerprint countermeasures to reduce
5
+ * false-positive automation detection in CI/testing environments.
6
+ *
7
+ * These techniques are the same ones used by Playwright's Stealth plugin
8
+ * and Puppeteer Extra — common tools for testing your own site's
9
+ * bot-detection systems in a realistic browser environment.
10
+ *
11
+ * How it works:
12
+ * - Override navigator.webdriver → false
13
+ * - Set realistic navigator.plugins
14
+ * - Configure sensible language preferences
15
+ * - Override chrome.runtime references
16
+ * - Set a standard desktop User-Agent
17
+ *
18
+ * NOTE: Scripts are injected via Page.addScriptToEvaluateOnNewDocument,
19
+ * which means they only affect pages loaded AFTER applyRealisticProfile
20
+ * is called. Call before browser_goto for best results.
21
+ */
22
+
23
+ /**
24
+ * Apply a realistic browser fingerprint to reduce automation detection.
25
+ * Injects anti-fingerprinting scripts before page JS executes.
26
+ */
27
+ export async function applyRealisticProfile(protocol) {
28
+ const { Page, Network } = protocol;
29
+
30
+ // Inject fingerprint-normalizing script for ALL new documents
31
+ await Page.addScriptToEvaluateOnNewDocument({
32
+ source: `
33
+ // Normalize webdriver flag (standard automation test practice)
34
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
35
+
36
+ // Set realistic plugin list
37
+ Object.defineProperty(navigator, 'plugins', {
38
+ get: () => [
39
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
40
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
41
+ { name: 'Native Client', filename: 'internal-nacl-plugin' },
42
+ ],
43
+ configurable: true,
44
+ });
45
+
46
+ // Realistic language preferences
47
+ Object.defineProperty(navigator, 'languages', {
48
+ get: () => ['en-US', 'en', 'es'],
49
+ configurable: true,
50
+ });
51
+
52
+ // Remove automation-specific chrome.runtime
53
+ if (window.chrome) {
54
+ if (window.chrome.runtime) {
55
+ delete window.chrome.runtime;
56
+ }
57
+ if (!window.chrome.loadTimes) {
58
+ window.chrome.loadTimes = function() { return {}; };
59
+ }
60
+ }
61
+
62
+ // Normalize permissions query
63
+ if (navigator.permissions && navigator.permissions.query) {
64
+ const origQuery = navigator.permissions.query;
65
+ navigator.permissions.query = function(params) {
66
+ if (params && params.name === 'notifications') {
67
+ return Promise.resolve({ state: 'prompt', onchange: null });
68
+ }
69
+ return origQuery.call(this, params);
70
+ };
71
+ }
72
+
73
+ // Realistic connection type
74
+ if (navigator.connection) {
75
+ Object.defineProperty(navigator.connection, 'rtt', { get: () => 100 });
76
+ }
77
+ `,
78
+ });
79
+
80
+ // Set a standard desktop user-agent
81
+ try {
82
+ await Network.setUserAgentOverride({
83
+ userAgent:
84
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
85
+ });
86
+ } catch {}
87
+
88
+ return {
89
+ status: "profile applied",
90
+ note: "Only affects pages loaded after this call. Navigate to a new page for the profile to take effect.",
91
+ techniques: [
92
+ "navigator.webdriver → false",
93
+ "navigator.plugins — realistic list",
94
+ "navigator.languages — configured",
95
+ "chrome.runtime — normalized",
96
+ "Permissions query — overridden",
97
+ "User-Agent — standard desktop",
98
+ ],
99
+ };
100
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * bwb-browser — Shared interaction helpers
3
+ *
4
+ * Functions for navigating, clicking, filling, and waiting for page elements
5
+ * using raw CDP protocol.
6
+ */
7
+
8
+ // ─── Navigation Helper ────────────────────────────────────────────────────────
9
+
10
+ export async function gotoUrl(page, runtime, url, timeoutMs) {
11
+ await page.enable();
12
+
13
+ const loadPromise = page.loadEventFired().then(() => true);
14
+ const domPromise = page.domContentEventFired().then(() => true);
15
+
16
+ await page.navigate({ url });
17
+
18
+ await Promise.race([
19
+ Promise.all([loadPromise, domPromise]),
20
+ new Promise(r => setTimeout(() => r(false), timeoutMs)),
21
+ ]);
22
+
23
+ // Small grace for JS framework rendering
24
+ await new Promise(r => setTimeout(r, 500));
25
+
26
+ const { result } = await runtime.evaluate({ expression: "document.title" });
27
+ return { title: result?.value || "", url };
28
+ }
29
+
30
+ // ─── Click Helper (uses CDP Input.dispatchMouseEvent) ─────────────────────────
31
+
32
+ export async function clickElement(page, runtime, input, selector) {
33
+ const { result } = await runtime.evaluate({
34
+ expression: `(() => {
35
+ const el = document.querySelector(${JSON.stringify(selector)});
36
+ if (!el) return JSON.stringify({ error: 'NOT_FOUND' });
37
+ const rect = el.getBoundingClientRect();
38
+ return JSON.stringify({
39
+ x: rect.x + rect.width / 2,
40
+ y: rect.y + rect.height / 2,
41
+ width: rect.width,
42
+ height: rect.height,
43
+ tag: el.tagName,
44
+ text: (el.textContent || '').trim().slice(0, 50),
45
+ });
46
+ })()`,
47
+ });
48
+
49
+ let info;
50
+ try { info = JSON.parse(result.value); } catch {
51
+ throw new Error(`Element not found: ${selector}`);
52
+ }
53
+
54
+ if (info.error === "NOT_FOUND") {
55
+ throw new Error(`Element not found: ${selector}`);
56
+ }
57
+
58
+ // Dispatch real mouse events via CDP Input domain (ONLY — no native JS click)
59
+ const x = Math.round(info.x);
60
+ const y = Math.round(info.y);
61
+ await input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
62
+ await input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
63
+
64
+ return info;
65
+ }
66
+
67
+ // ─── Fill Helper (uses CDP Input.insertText) ──────────────────────────────────
68
+
69
+ export async function fillElement(page, runtime, input, selector, text) {
70
+ const { result } = await runtime.evaluate({
71
+ expression: `(() => {
72
+ const el = document.querySelector(${JSON.stringify(selector)});
73
+ if (!el) return 'NOT_FOUND';
74
+ el.focus();
75
+ el.value = '';
76
+ return 'FOCUSED';
77
+ })()`,
78
+ returnByValue: true,
79
+ });
80
+
81
+ if (result.value === "NOT_FOUND") {
82
+ throw new Error(`Element not found: ${selector}`);
83
+ }
84
+
85
+ // Clear existing text via CDP Input domain
86
+ await input.dispatchKeyEvent({ type: "keyDown", key: "Control" });
87
+ await input.dispatchKeyEvent({ type: "keyDown", key: "a" });
88
+ await input.dispatchKeyEvent({ type: "keyUp", key: "a" });
89
+ await input.dispatchKeyEvent({ type: "keyUp", key: "Control" });
90
+ await input.dispatchKeyEvent({ type: "keyDown", key: "Delete" });
91
+ await input.dispatchKeyEvent({ type: "keyUp", key: "Delete" });
92
+
93
+ // Insert text via CDP Input domain
94
+ await input.insertText({ text });
95
+ }
96
+
97
+ // ─── waitForSelector Helper ──────────────────────────────────────────────────
98
+
99
+ export async function waitForSelector(runtime, selector, opts = {}) {
100
+ const timeout = opts.timeout || 10000;
101
+ const disappear = opts.disappear || false;
102
+ const start = Date.now();
103
+
104
+ while (Date.now() - start < timeout) {
105
+ const { result } = await runtime.evaluate({
106
+ expression: `(() => {
107
+ const el = document.querySelector(${JSON.stringify(selector)});
108
+ if (!el) return JSON.stringify({ status: "NOT_FOUND" });
109
+ const rect = el.getBoundingClientRect();
110
+ const hidden = rect.width === 0 || rect.height === 0;
111
+ const text = (el.textContent || "").trim().slice(0, 200);
112
+ return JSON.stringify({ status: "FOUND", tag: el.tagName, text, hidden });
113
+ })()`,
114
+ });
115
+
116
+ const info = JSON.parse(result?.value || "{}");
117
+
118
+ if (disappear && info.status === "NOT_FOUND") return true;
119
+ if (!disappear && info.status === "FOUND" && !info.hidden) return true;
120
+ if (!disappear && info.status === "FOUND" && !opts.visible) return true;
121
+
122
+ await new Promise((r) => setTimeout(r, 200));
123
+ }
124
+
125
+ throw new Error(`browser_waitForSelector: "${selector}" not ${disappear ? "disappeared" : "found"} within ${timeout}ms`);
126
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * bwb-browser — Persistent Browser Sessions
3
+ *
4
+ * Save and load browser cookies to/from disk, enabling authenticated sessions
5
+ * to persist across agent restarts. "Login once, agent works for days."
6
+ *
7
+ * Sessions are stored as JSON files in ~/.bwb/sessions/
8
+ */
9
+
10
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from "fs";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+
14
+ const SESSION_DIR = join(homedir(), ".bwb", "sessions");
15
+
16
+ function ensureDir() {
17
+ mkdirSync(SESSION_DIR, { recursive: true });
18
+ }
19
+
20
+ /**
21
+ * Save all cookies from the current browser session to a named session file.
22
+ */
23
+ export async function saveSession(name, protocol) {
24
+ if (!name || typeof name !== "string") {
25
+ throw new Error("Session name is required");
26
+ }
27
+ // Sanitize name to avoid path traversal
28
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
29
+ ensureDir();
30
+
31
+ const { cookies } = await protocol.Network.getAllCookies();
32
+ const path = join(SESSION_DIR, `${safeName}.json`);
33
+ writeFileSync(path, JSON.stringify({
34
+ name: safeName,
35
+ cookieCount: cookies.length,
36
+ savedAt: Date.now(),
37
+ cookies,
38
+ }, null, 2));
39
+
40
+ return { savedTo: path, cookieCount: cookies.length, name: safeName };
41
+ }
42
+
43
+ /**
44
+ * Load cookies from a named session file into the browser.
45
+ */
46
+ export async function loadSession(name, protocol) {
47
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
48
+ const path = join(SESSION_DIR, `${safeName}.json`);
49
+
50
+ if (!existsSync(path)) {
51
+ throw new Error(`Session not found: "${name}". Available sessions: ${listSessions().map(s => s.name).join(", ") || "none"}`);
52
+ }
53
+
54
+ const data = JSON.parse(readFileSync(path, "utf8"));
55
+
56
+ if (!data.cookies || !Array.isArray(data.cookies)) {
57
+ throw new Error(`Invalid session file: ${path}`);
58
+ }
59
+
60
+ // Set all cookies in the browser
61
+ await protocol.Network.setCookies({ cookies: data.cookies });
62
+
63
+ return {
64
+ loaded: safeName,
65
+ cookieCount: data.cookies.length,
66
+ savedAt: new Date(data.savedAt).toISOString(),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * List all saved sessions.
72
+ */
73
+ export function listSessions() {
74
+ ensureDir();
75
+ const files = readdirSync(SESSION_DIR).filter(f => f.endsWith(".json"));
76
+ return files.map(f => {
77
+ try {
78
+ const data = JSON.parse(readFileSync(join(SESSION_DIR, f), "utf8"));
79
+ return {
80
+ name: data.name || f.replace(".json", ""),
81
+ cookieCount: data.cookies?.length || 0,
82
+ savedAt: data.savedAt ? new Date(data.savedAt).toISOString() : "unknown",
83
+ };
84
+ } catch {
85
+ return { name: f.replace(".json", ""), cookieCount: 0, savedAt: "unknown" };
86
+ }
87
+ });
88
+ }