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.
- package/AGENTS.md +56 -25
- package/README.md +130 -218
- package/lib/act.mjs +326 -0
- package/lib/browser.mjs +290 -0
- package/lib/diagnose.mjs +149 -0
- package/lib/fingerprint.mjs +100 -0
- package/lib/helpers.mjs +126 -0
- package/lib/session.mjs +88 -0
- package/lib/setup.mjs +324 -0
- package/lib/tabs.mjs +165 -0
- package/package.json +22 -4
- package/server.mjs +322 -660
package/lib/diagnose.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/helpers.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/session.mjs
ADDED
|
@@ -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
|
+
}
|