hyper-agent-browser 0.3.0 → 0.3.2
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 +4 -3
- package/package.json +1 -1
- package/src/browser/manager.ts +50 -2
- package/src/cli.ts +1 -1
- package/src/commands/actions.ts +36 -1
- package/src/daemon/browser-pool.ts +67 -27
- package/src/snapshot/dom-extractor.ts +28 -15
package/README.md
CHANGED
|
@@ -204,11 +204,12 @@ hyper-agent-browser 专为 AI Agent 设计,可与 Claude Code 无缝集成。
|
|
|
204
204
|
|
|
205
205
|
```bash
|
|
206
206
|
# 方法 1:从本地仓库复制
|
|
207
|
-
mkdir -p ~/.claude/skills
|
|
208
|
-
cp skills/hyper-browser.md ~/.claude/skills/
|
|
207
|
+
mkdir -p ~/.claude/skills/hyper-agent-browser
|
|
208
|
+
cp skills/hyper-browser.md ~/.claude/skills/hyper-agent-browser/skill.md
|
|
209
209
|
|
|
210
210
|
# 方法 2:直接下载
|
|
211
|
-
|
|
211
|
+
mkdir -p ~/.claude/skills/hyper-agent-browser
|
|
212
|
+
curl -o ~/.claude/skills/hyper-agent-browser/skill.md \
|
|
212
213
|
https://raw.githubusercontent.com/hubo1989/hyper-agent-browser/main/skills/hyper-browser.md
|
|
213
214
|
```
|
|
214
215
|
|
package/package.json
CHANGED
package/src/browser/manager.ts
CHANGED
|
@@ -149,14 +149,23 @@ export class BrowserManager {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// Use launchPersistentContext for UserData persistence
|
|
152
|
-
|
|
152
|
+
// 给启动加上超时保护(15秒)
|
|
153
|
+
const launchPromise = chromium.launchPersistentContext(this.session.userDataDir, {
|
|
153
154
|
channel: this.options.channel,
|
|
154
155
|
headless: !this.options.headed,
|
|
155
156
|
args: launchArgs,
|
|
156
157
|
ignoreDefaultArgs: ignoreArgs,
|
|
157
158
|
viewport: { width: 1280, height: 720 },
|
|
159
|
+
timeout: 15000, // 15秒启动超时
|
|
158
160
|
});
|
|
159
161
|
|
|
162
|
+
this.context = await Promise.race([
|
|
163
|
+
launchPromise,
|
|
164
|
+
new Promise<never>((_, reject) =>
|
|
165
|
+
setTimeout(() => reject(new Error("Browser launch timeout (15s)")), 15000),
|
|
166
|
+
),
|
|
167
|
+
]);
|
|
168
|
+
|
|
160
169
|
// Extract browser from context
|
|
161
170
|
// @ts-ignore - context has _browser property
|
|
162
171
|
this.browser = this.context._browser;
|
|
@@ -334,6 +343,22 @@ export class BrowserManager {
|
|
|
334
343
|
// 重新连接
|
|
335
344
|
await this.connect();
|
|
336
345
|
}
|
|
346
|
+
|
|
347
|
+
// 确保返回当前活动页面(可能有多个页面时需要获取最新的)
|
|
348
|
+
if (this.context) {
|
|
349
|
+
const pages = this.context.pages();
|
|
350
|
+
if (pages.length > 0) {
|
|
351
|
+
// 优先返回非 about:blank 的页面
|
|
352
|
+
const activePage = pages.find((p) => p.url() !== "about:blank") || pages[pages.length - 1];
|
|
353
|
+
if (activePage !== this.page) {
|
|
354
|
+
this.page = activePage;
|
|
355
|
+
if (this.options.timeout) {
|
|
356
|
+
this.page.setDefaultTimeout(this.options.timeout);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
337
362
|
return this.page!;
|
|
338
363
|
}
|
|
339
364
|
|
|
@@ -353,7 +378,30 @@ export class BrowserManager {
|
|
|
353
378
|
|
|
354
379
|
async close(): Promise<void> {
|
|
355
380
|
if (this.browser) {
|
|
356
|
-
|
|
381
|
+
// 获取 PID 以便超时后强制 kill
|
|
382
|
+
const pid = this.getPid();
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
// 给 close 操作 5 秒超时
|
|
386
|
+
await Promise.race([
|
|
387
|
+
this.browser.close(),
|
|
388
|
+
new Promise((_, reject) =>
|
|
389
|
+
setTimeout(() => reject(new Error("Browser close timeout")), 5000),
|
|
390
|
+
),
|
|
391
|
+
]);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
console.log("Browser close failed or timed out, forcing cleanup...");
|
|
394
|
+
// 强制 kill 进程
|
|
395
|
+
if (pid) {
|
|
396
|
+
try {
|
|
397
|
+
process.kill(pid, "SIGKILL");
|
|
398
|
+
console.log(`Force killed browser process (PID: ${pid})`);
|
|
399
|
+
} catch {
|
|
400
|
+
// 进程可能已经退出
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
357
405
|
this.browser = null;
|
|
358
406
|
this.context = null;
|
|
359
407
|
this.page = null;
|
package/src/cli.ts
CHANGED
|
@@ -747,7 +747,7 @@ program
|
|
|
747
747
|
.command("version")
|
|
748
748
|
.description("Show version information")
|
|
749
749
|
.action(() => {
|
|
750
|
-
console.log("hyper-agent-browser v0.
|
|
750
|
+
console.log("hyper-agent-browser v0.3.1 (with daemon architecture)");
|
|
751
751
|
console.log(`Bun v${Bun.version}`);
|
|
752
752
|
console.log("Patchright v1.55.1");
|
|
753
753
|
});
|
package/src/commands/actions.ts
CHANGED
|
@@ -43,7 +43,42 @@ async function getLocator(page: Page, selector: string): Promise<Locator> {
|
|
|
43
43
|
|
|
44
44
|
export async function click(page: Page, selector: string): Promise<void> {
|
|
45
45
|
const locator = await getLocator(page, selector);
|
|
46
|
-
|
|
46
|
+
|
|
47
|
+
// 先尝试正常点击
|
|
48
|
+
try {
|
|
49
|
+
await locator.click({ timeout: 5000 });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// 如果被遮罩拦截,逐级降级
|
|
52
|
+
if (error instanceof Error && error.message.includes("intercepts pointer events")) {
|
|
53
|
+
console.log("Element intercepted, trying force click...");
|
|
54
|
+
try {
|
|
55
|
+
await locator.click({ force: true, timeout: 5000 });
|
|
56
|
+
} catch {
|
|
57
|
+
// force click 失败,使用完整鼠标事件序列(兼容 React 等框架)
|
|
58
|
+
console.log("Force click failed, using mouse event sequence...");
|
|
59
|
+
await locator.evaluate((el: HTMLElement) => {
|
|
60
|
+
const rect = el.getBoundingClientRect();
|
|
61
|
+
const x = rect.left + rect.width / 2;
|
|
62
|
+
const y = rect.top + rect.height / 2;
|
|
63
|
+
|
|
64
|
+
// 模拟完整鼠标事件序列
|
|
65
|
+
for (const type of ["mousedown", "mouseup", "click"]) {
|
|
66
|
+
el.dispatchEvent(
|
|
67
|
+
new MouseEvent(type, {
|
|
68
|
+
bubbles: true,
|
|
69
|
+
cancelable: true,
|
|
70
|
+
view: window,
|
|
71
|
+
clientX: x,
|
|
72
|
+
clientY: y,
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
47
82
|
}
|
|
48
83
|
|
|
49
84
|
export async function fill(page: Page, selector: string, value: string): Promise<void> {
|
|
@@ -2,55 +2,95 @@ import { BrowserManager } from "../browser/manager";
|
|
|
2
2
|
import type { BrowserManagerOptions } from "../browser/manager";
|
|
3
3
|
import type { Session } from "../session/store";
|
|
4
4
|
|
|
5
|
+
interface CachedBrowser {
|
|
6
|
+
manager: BrowserManager;
|
|
7
|
+
options: BrowserManagerOptions;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* BrowserPool 管理多个 Session 的浏览器实例
|
|
7
12
|
*/
|
|
8
13
|
export class BrowserPool {
|
|
9
|
-
private browsers: Map<string,
|
|
14
|
+
private browsers: Map<string, CachedBrowser> = new Map();
|
|
10
15
|
|
|
11
16
|
async get(session: Session, options: BrowserManagerOptions = {}): Promise<BrowserManager> {
|
|
12
17
|
const key = session.name;
|
|
13
18
|
|
|
14
19
|
if (this.browsers.has(key)) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
const cached = this.browsers.get(key)!;
|
|
21
|
+
const browser = cached.manager;
|
|
22
|
+
|
|
23
|
+
// 检查选项是否一致(特别是 headed 模式)
|
|
24
|
+
const optionsMismatch = this.hasOptionsMismatch(cached.options, options);
|
|
25
|
+
|
|
26
|
+
if (optionsMismatch) {
|
|
27
|
+
console.log(
|
|
28
|
+
`Options mismatch for session ${key} (headed: ${cached.options.headed} -> ${options.headed}), recreating browser`,
|
|
29
|
+
);
|
|
30
|
+
try {
|
|
31
|
+
await browser.close();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error("Error closing browser for options change:", error);
|
|
34
|
+
}
|
|
35
|
+
this.browsers.delete(key);
|
|
36
|
+
// 继续创建新的浏览器实例
|
|
37
|
+
} else if (browser.isConnected()) {
|
|
38
|
+
// 选项一致且浏览器已连接,直接返回
|
|
19
39
|
return browser;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
} else {
|
|
41
|
+
// 浏览器断开连接,尝试重连
|
|
42
|
+
try {
|
|
43
|
+
await browser.connect();
|
|
44
|
+
if (browser.isConnected()) {
|
|
45
|
+
return browser;
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error(`Failed to reconnect browser for session ${key}:`, error);
|
|
27
49
|
}
|
|
28
|
-
} catch (error) {
|
|
29
|
-
console.error(`Failed to reconnect browser for session ${key}:`, error);
|
|
30
|
-
}
|
|
31
50
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
// 重连失败,移除旧实例并创建新的
|
|
52
|
+
console.log(`Removing stale browser instance for session ${key}`);
|
|
53
|
+
this.browsers.delete(key);
|
|
54
|
+
}
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
// 创建新的浏览器实例
|
|
38
|
-
console.log(
|
|
58
|
+
console.log(
|
|
59
|
+
`Creating new browser instance for session ${key} (headed: ${options.headed ?? false})`,
|
|
60
|
+
);
|
|
39
61
|
const browser = new BrowserManager(session, options);
|
|
40
62
|
await browser.connect();
|
|
41
|
-
this.browsers.set(key, browser);
|
|
63
|
+
this.browsers.set(key, { manager: browser, options: { ...options } });
|
|
42
64
|
|
|
43
65
|
return browser;
|
|
44
66
|
}
|
|
45
67
|
|
|
68
|
+
/**
|
|
69
|
+
* 检查选项是否有重要变化需要重建浏览器
|
|
70
|
+
*/
|
|
71
|
+
private hasOptionsMismatch(
|
|
72
|
+
cached: BrowserManagerOptions,
|
|
73
|
+
requested: BrowserManagerOptions,
|
|
74
|
+
): boolean {
|
|
75
|
+
// headed 模式变化需要重建浏览器
|
|
76
|
+
if ((cached.headed ?? false) !== (requested.headed ?? false)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
// channel 变化也需要重建
|
|
80
|
+
if (cached.channel && requested.channel && cached.channel !== requested.channel) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
46
86
|
async close(sessionName: string): Promise<boolean> {
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
87
|
+
const cached = this.browsers.get(sessionName);
|
|
88
|
+
if (!cached) {
|
|
49
89
|
return false;
|
|
50
90
|
}
|
|
51
91
|
|
|
52
92
|
try {
|
|
53
|
-
await
|
|
93
|
+
await cached.manager.close();
|
|
54
94
|
} catch (error) {
|
|
55
95
|
console.error(`Error closing browser for session ${sessionName}:`, error);
|
|
56
96
|
}
|
|
@@ -59,9 +99,9 @@ export class BrowserPool {
|
|
|
59
99
|
}
|
|
60
100
|
|
|
61
101
|
async closeAll(): Promise<void> {
|
|
62
|
-
const closePromises = Array.from(this.browsers.entries()).map(async ([name,
|
|
102
|
+
const closePromises = Array.from(this.browsers.entries()).map(async ([name, cached]) => {
|
|
63
103
|
try {
|
|
64
|
-
await
|
|
104
|
+
await cached.manager.close();
|
|
65
105
|
} catch (error) {
|
|
66
106
|
console.error(`Error closing browser for session ${name}:`, error);
|
|
67
107
|
}
|
|
@@ -84,8 +124,8 @@ export class BrowserPool {
|
|
|
84
124
|
async cleanup(): Promise<void> {
|
|
85
125
|
const toRemove: string[] = [];
|
|
86
126
|
|
|
87
|
-
for (const [name,
|
|
88
|
-
if (!
|
|
127
|
+
for (const [name, cached] of this.browsers.entries()) {
|
|
128
|
+
if (!cached.manager.isConnected()) {
|
|
89
129
|
toRemove.push(name);
|
|
90
130
|
}
|
|
91
131
|
}
|
|
@@ -101,30 +101,43 @@ export class DomSnapshotExtractor {
|
|
|
101
101
|
// Try ID first
|
|
102
102
|
if (el.id) return `#${el.id}`;
|
|
103
103
|
|
|
104
|
-
//
|
|
105
|
-
|
|
104
|
+
// Build path from element to root (or an element with ID)
|
|
105
|
+
const path: string[] = [];
|
|
106
106
|
let current: Element | null = el;
|
|
107
107
|
|
|
108
|
-
while (current
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
while (current && current !== document.body && path.length < 6) {
|
|
109
|
+
const parentEl: Element | null = current.parentElement;
|
|
110
|
+
if (!parentEl) break;
|
|
111
|
+
|
|
112
|
+
// Find index among siblings of same tag type
|
|
113
|
+
const currentTag = current.tagName;
|
|
114
|
+
const siblings: Element[] = [];
|
|
115
|
+
for (let i = 0; i < parentEl.children.length; i++) {
|
|
116
|
+
const child = parentEl.children[i];
|
|
117
|
+
if (child.tagName === currentTag) {
|
|
118
|
+
siblings.push(child);
|
|
119
|
+
}
|
|
115
120
|
}
|
|
121
|
+
const index = siblings.indexOf(current) + 1;
|
|
122
|
+
|
|
123
|
+
// Use nth-of-type for uniqueness among same-tag siblings
|
|
124
|
+
const segment =
|
|
125
|
+
siblings.length > 1
|
|
126
|
+
? `${current.tagName.toLowerCase()}:nth-of-type(${index})`
|
|
127
|
+
: current.tagName.toLowerCase();
|
|
128
|
+
|
|
129
|
+
path.unshift(segment);
|
|
116
130
|
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
131
|
+
// Stop if parent has ID
|
|
132
|
+
if (parentEl.id) {
|
|
133
|
+
path.unshift(`#${parentEl.id}`);
|
|
120
134
|
break;
|
|
121
135
|
}
|
|
122
136
|
|
|
123
|
-
|
|
124
|
-
if (selector.split(">").length > 5) break;
|
|
137
|
+
current = parentEl;
|
|
125
138
|
}
|
|
126
139
|
|
|
127
|
-
return
|
|
140
|
+
return path.join(" > ");
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
// Traverse DOM
|