hyper-agent-browser 0.1.0 → 0.3.1
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 +334 -92
- package/package.json +2 -2
- package/src/browser/manager.ts +61 -3
- package/src/cli.ts +151 -1
- package/src/commands/extract.ts +76 -0
- package/src/commands/network.ts +111 -0
- package/src/commands/wait.ts +226 -0
- package/src/daemon/browser-pool.ts +90 -17
- package/src/daemon/server.ts +66 -0
- package/src/extractors/form-extractor.ts +153 -0
- package/src/extractors/list-extractor.ts +213 -0
- package/src/extractors/meta-extractor.ts +139 -0
- package/src/extractors/table-extractor.ts +215 -0
- package/src/snapshot/dom-extractor.ts +28 -15
- package/src/utils/network-listener.ts +247 -0
package/src/cli.ts
CHANGED
|
@@ -183,6 +183,47 @@ program
|
|
|
183
183
|
console.log(result);
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
program
|
|
187
|
+
.command("wait-idle")
|
|
188
|
+
.description("Wait for page to be idle (network + DOM stable)")
|
|
189
|
+
.option("--timeout <ms>", "Timeout in milliseconds", "30000")
|
|
190
|
+
.option("--strategy <strategy>", "Strategy: network, dom, or both", "both")
|
|
191
|
+
.option("--network-idle-time <ms>", "Network idle time", "500")
|
|
192
|
+
.option("--dom-stable-time <ms>", "DOM stable time", "500")
|
|
193
|
+
.option("--ignore <selectors>", "Ignore DOM changes in selectors (comma-separated)")
|
|
194
|
+
.action(async (options, command) => {
|
|
195
|
+
const result = await executeViaDaemon(
|
|
196
|
+
"wait-idle",
|
|
197
|
+
{
|
|
198
|
+
timeout: Number.parseInt(options.timeout),
|
|
199
|
+
strategy: options.strategy,
|
|
200
|
+
networkIdleTime: Number.parseInt(options.networkIdleTime),
|
|
201
|
+
domStableTime: Number.parseInt(options.domStableTime),
|
|
202
|
+
ignoreSelectors: options.ignore ? options.ignore.split(",") : undefined,
|
|
203
|
+
},
|
|
204
|
+
command.parent,
|
|
205
|
+
);
|
|
206
|
+
console.log(result);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
program
|
|
210
|
+
.command("wait-element <selector>")
|
|
211
|
+
.description("Wait for element state (attached/detached/visible/hidden)")
|
|
212
|
+
.option("--state <state>", "Element state", "visible")
|
|
213
|
+
.option("--timeout <ms>", "Timeout in milliseconds", "10000")
|
|
214
|
+
.action(async (selector, options, command) => {
|
|
215
|
+
const result = await executeViaDaemon(
|
|
216
|
+
"wait-element",
|
|
217
|
+
{
|
|
218
|
+
selector,
|
|
219
|
+
state: options.state,
|
|
220
|
+
timeout: Number.parseInt(options.timeout),
|
|
221
|
+
},
|
|
222
|
+
command.parent,
|
|
223
|
+
);
|
|
224
|
+
console.log(result);
|
|
225
|
+
});
|
|
226
|
+
|
|
186
227
|
// 第一批新增 Action 命令
|
|
187
228
|
program
|
|
188
229
|
.command("check <selector>")
|
|
@@ -506,6 +547,115 @@ program
|
|
|
506
547
|
console.log(result);
|
|
507
548
|
});
|
|
508
549
|
|
|
550
|
+
// Extract commands
|
|
551
|
+
program
|
|
552
|
+
.command("extract-table")
|
|
553
|
+
.description("Extract table data from page")
|
|
554
|
+
.option("--selector <selector>", "Table selector")
|
|
555
|
+
.option("--no-headers", "Exclude headers")
|
|
556
|
+
.option("--max-rows <number>", "Maximum number of rows", "1000")
|
|
557
|
+
.action(async (options, command) => {
|
|
558
|
+
const result = await executeViaDaemon(
|
|
559
|
+
"extract-table",
|
|
560
|
+
{
|
|
561
|
+
selector: options.selector,
|
|
562
|
+
includeHeaders: options.headers,
|
|
563
|
+
maxRows: Number.parseInt(options.maxRows),
|
|
564
|
+
},
|
|
565
|
+
command.parent,
|
|
566
|
+
);
|
|
567
|
+
console.log(result);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
program
|
|
571
|
+
.command("extract-list")
|
|
572
|
+
.description("Extract list data from page")
|
|
573
|
+
.option("--selector <selector>", "List container selector")
|
|
574
|
+
.option("--pattern <pattern>", "Item pattern (auto by default)", "auto")
|
|
575
|
+
.option("--max-items <number>", "Maximum number of items", "1000")
|
|
576
|
+
.action(async (options, command) => {
|
|
577
|
+
const result = await executeViaDaemon(
|
|
578
|
+
"extract-list",
|
|
579
|
+
{
|
|
580
|
+
selector: options.selector,
|
|
581
|
+
pattern: options.pattern,
|
|
582
|
+
maxItems: Number.parseInt(options.maxItems),
|
|
583
|
+
},
|
|
584
|
+
command.parent,
|
|
585
|
+
);
|
|
586
|
+
console.log(result);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
program
|
|
590
|
+
.command("extract-form")
|
|
591
|
+
.description("Extract form field data from page")
|
|
592
|
+
.option("--selector <selector>", "Form selector")
|
|
593
|
+
.option("--include-disabled", "Include disabled fields")
|
|
594
|
+
.action(async (options, command) => {
|
|
595
|
+
const result = await executeViaDaemon(
|
|
596
|
+
"extract-form",
|
|
597
|
+
{
|
|
598
|
+
selector: options.selector,
|
|
599
|
+
includeDisabled: options.includeDisabled,
|
|
600
|
+
},
|
|
601
|
+
command.parent,
|
|
602
|
+
);
|
|
603
|
+
console.log(result);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
program
|
|
607
|
+
.command("extract-meta")
|
|
608
|
+
.description("Extract page metadata (SEO, OpenGraph, Schema.org)")
|
|
609
|
+
.option(
|
|
610
|
+
"--include <types>",
|
|
611
|
+
"Metadata types (seo,og,twitter,schema,other)",
|
|
612
|
+
"seo,og,twitter,schema,other",
|
|
613
|
+
)
|
|
614
|
+
.action(async (options, command) => {
|
|
615
|
+
const result = await executeViaDaemon(
|
|
616
|
+
"extract-meta",
|
|
617
|
+
{
|
|
618
|
+
include: options.include.split(","),
|
|
619
|
+
},
|
|
620
|
+
command.parent,
|
|
621
|
+
);
|
|
622
|
+
console.log(result);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// Network commands
|
|
626
|
+
program
|
|
627
|
+
.command("network-start")
|
|
628
|
+
.description("Start network request monitoring")
|
|
629
|
+
.option("--filter <types>", "Resource types (xhr,fetch,document,script,image,font)", "xhr,fetch")
|
|
630
|
+
.option("--url-pattern <pattern>", "URL pattern to match (glob)")
|
|
631
|
+
.option("--methods <methods>", "HTTP methods (GET,POST,PUT,DELETE)")
|
|
632
|
+
.action(async (options, command) => {
|
|
633
|
+
const result = await executeViaDaemon(
|
|
634
|
+
"network-start",
|
|
635
|
+
{
|
|
636
|
+
filter: options.filter.split(","),
|
|
637
|
+
urlPattern: options.urlPattern,
|
|
638
|
+
methods: options.methods ? options.methods.split(",") : undefined,
|
|
639
|
+
},
|
|
640
|
+
command.parent,
|
|
641
|
+
);
|
|
642
|
+
console.log(result);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
program
|
|
646
|
+
.command("network-stop <listener-id>")
|
|
647
|
+
.description("Stop network monitoring and get captured requests")
|
|
648
|
+
.action(async (listenerId, _options, command) => {
|
|
649
|
+
const result = await executeViaDaemon(
|
|
650
|
+
"network-stop",
|
|
651
|
+
{
|
|
652
|
+
listenerId,
|
|
653
|
+
},
|
|
654
|
+
command.parent,
|
|
655
|
+
);
|
|
656
|
+
console.log(result);
|
|
657
|
+
});
|
|
658
|
+
|
|
509
659
|
// Session commands
|
|
510
660
|
program
|
|
511
661
|
.command("sessions")
|
|
@@ -597,7 +747,7 @@ program
|
|
|
597
747
|
.command("version")
|
|
598
748
|
.description("Show version information")
|
|
599
749
|
.action(() => {
|
|
600
|
-
console.log("hyper-agent-browser v0.
|
|
750
|
+
console.log("hyper-agent-browser v0.3.1 (with daemon architecture)");
|
|
601
751
|
console.log(`Bun v${Bun.version}`);
|
|
602
752
|
console.log("Patchright v1.55.1");
|
|
603
753
|
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Page } from "patchright";
|
|
2
|
+
import { FormExtractor } from "../extractors/form-extractor";
|
|
3
|
+
import { ListExtractor } from "../extractors/list-extractor";
|
|
4
|
+
import { MetaExtractor } from "../extractors/meta-extractor";
|
|
5
|
+
import { TableExtractor } from "../extractors/table-extractor";
|
|
6
|
+
|
|
7
|
+
export interface ExtractTableOptions {
|
|
8
|
+
selector?: string;
|
|
9
|
+
includeHeaders?: boolean;
|
|
10
|
+
maxRows?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ExtractListOptions {
|
|
14
|
+
selector?: string;
|
|
15
|
+
pattern?: string;
|
|
16
|
+
maxItems?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExtractFormOptions {
|
|
20
|
+
selector?: string;
|
|
21
|
+
includeDisabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExtractMetaOptions {
|
|
25
|
+
include?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 提取表格数据
|
|
30
|
+
*/
|
|
31
|
+
export async function extractTable(page: Page, options: ExtractTableOptions = {}): Promise<string> {
|
|
32
|
+
const extractor = new TableExtractor();
|
|
33
|
+
const result = await extractor.extract(page, options.selector, {
|
|
34
|
+
includeHeaders: options.includeHeaders,
|
|
35
|
+
maxRows: options.maxRows,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return JSON.stringify(result, null, 2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 提取列表数据
|
|
43
|
+
*/
|
|
44
|
+
export async function extractList(page: Page, options: ExtractListOptions = {}): Promise<string> {
|
|
45
|
+
const extractor = new ListExtractor();
|
|
46
|
+
const result = await extractor.extract(page, options.selector, {
|
|
47
|
+
pattern: options.pattern,
|
|
48
|
+
maxItems: options.maxItems,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return JSON.stringify(result, null, 2);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 提取表单数据
|
|
56
|
+
*/
|
|
57
|
+
export async function extractForm(page: Page, options: ExtractFormOptions = {}): Promise<string> {
|
|
58
|
+
const extractor = new FormExtractor();
|
|
59
|
+
const result = await extractor.extract(page, options.selector, {
|
|
60
|
+
includeDisabled: options.includeDisabled,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return JSON.stringify(result, null, 2);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 提取元数据
|
|
68
|
+
*/
|
|
69
|
+
export async function extractMeta(page: Page, options: ExtractMetaOptions = {}): Promise<string> {
|
|
70
|
+
const extractor = new MetaExtractor();
|
|
71
|
+
const result = await extractor.extract(page, {
|
|
72
|
+
include: options.include,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return JSON.stringify(result, null, 2);
|
|
76
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Page } from "patchright";
|
|
2
|
+
import type { Session } from "../session/store";
|
|
3
|
+
import {
|
|
4
|
+
NetworkListener,
|
|
5
|
+
type NetworkListenerConfig,
|
|
6
|
+
generateListenerId,
|
|
7
|
+
} from "../utils/network-listener";
|
|
8
|
+
|
|
9
|
+
// 存储活动的监听器
|
|
10
|
+
const activeListeners = new Map<string, NetworkListener>();
|
|
11
|
+
|
|
12
|
+
export interface NetworkStartOptions {
|
|
13
|
+
filter?: string[];
|
|
14
|
+
urlPattern?: string;
|
|
15
|
+
methods?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NetworkStopResult {
|
|
19
|
+
listenerId: string;
|
|
20
|
+
startTime: number;
|
|
21
|
+
endTime: number;
|
|
22
|
+
duration: number;
|
|
23
|
+
totalRequests: number;
|
|
24
|
+
requests: any[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 开始网络监听
|
|
29
|
+
*/
|
|
30
|
+
export async function networkStart(
|
|
31
|
+
page: Page,
|
|
32
|
+
session: Session,
|
|
33
|
+
options: NetworkStartOptions = {},
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
const listenerId = generateListenerId();
|
|
36
|
+
|
|
37
|
+
const config: NetworkListenerConfig = {
|
|
38
|
+
id: listenerId,
|
|
39
|
+
sessionDir: session.userDataDir.replace(/\/userdata$/, ""),
|
|
40
|
+
filter: {
|
|
41
|
+
types: options.filter ?? ["xhr", "fetch"],
|
|
42
|
+
urlPattern: options.urlPattern,
|
|
43
|
+
methods: options.methods,
|
|
44
|
+
},
|
|
45
|
+
startTime: Date.now(),
|
|
46
|
+
status: "active",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const listener = new NetworkListener(config);
|
|
50
|
+
await listener.start(page);
|
|
51
|
+
|
|
52
|
+
activeListeners.set(listenerId, listener);
|
|
53
|
+
|
|
54
|
+
const info = listener.getInfo();
|
|
55
|
+
return JSON.stringify(info, null, 2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 停止网络监听并返回数据
|
|
60
|
+
*/
|
|
61
|
+
export async function networkStop(listenerId: string): Promise<string> {
|
|
62
|
+
const listener = activeListeners.get(listenerId);
|
|
63
|
+
|
|
64
|
+
if (!listener) {
|
|
65
|
+
throw new Error(`Network listener not found: ${listenerId}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await listener.stop();
|
|
69
|
+
|
|
70
|
+
const info = listener.getInfo();
|
|
71
|
+
const requests = await listener.getRequests();
|
|
72
|
+
|
|
73
|
+
const result: NetworkStopResult = {
|
|
74
|
+
listenerId,
|
|
75
|
+
startTime: info.startTime,
|
|
76
|
+
endTime: Date.now(),
|
|
77
|
+
duration: Date.now() - info.startTime,
|
|
78
|
+
totalRequests: requests.length,
|
|
79
|
+
requests,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
activeListeners.delete(listenerId);
|
|
83
|
+
|
|
84
|
+
return JSON.stringify(result, null, 2);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 获取所有活动监听器
|
|
89
|
+
*/
|
|
90
|
+
export function getActiveListeners(): string[] {
|
|
91
|
+
return Array.from(activeListeners.keys());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 清理会话的所有监听器
|
|
96
|
+
*/
|
|
97
|
+
export async function cleanupSessionListeners(sessionName: string): Promise<void> {
|
|
98
|
+
const toDelete: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const [id, listener] of activeListeners.entries()) {
|
|
101
|
+
const info = listener.getInfo();
|
|
102
|
+
if (info.listenerId.includes(sessionName)) {
|
|
103
|
+
await listener.stop();
|
|
104
|
+
toDelete.push(id);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const id of toDelete) {
|
|
109
|
+
activeListeners.delete(id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { Page } from "patchright";
|
|
2
|
+
|
|
3
|
+
export interface WaitIdleOptions {
|
|
4
|
+
timeout?: number;
|
|
5
|
+
strategy?: "network" | "dom" | "both";
|
|
6
|
+
networkIdleTime?: number;
|
|
7
|
+
domStableTime?: number;
|
|
8
|
+
ignoreSelectors?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WaitElementOptions {
|
|
12
|
+
state?: "attached" | "detached" | "visible" | "hidden";
|
|
13
|
+
timeout?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 等待页面空闲(网络请求完成 + DOM 稳定)
|
|
18
|
+
*/
|
|
19
|
+
export async function waitIdle(page: Page, options: WaitIdleOptions = {}): Promise<void> {
|
|
20
|
+
const timeout = options.timeout ?? 30000;
|
|
21
|
+
const strategy = options.strategy ?? "both";
|
|
22
|
+
const networkIdleTime = options.networkIdleTime ?? 500;
|
|
23
|
+
const domStableTime = options.domStableTime ?? 500;
|
|
24
|
+
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (strategy === "network" || strategy === "both") {
|
|
29
|
+
await waitNetworkIdle(page, networkIdleTime, timeout);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (strategy === "dom" || strategy === "both") {
|
|
33
|
+
const remainingTimeout = timeout - (Date.now() - startTime);
|
|
34
|
+
if (remainingTimeout <= 0) {
|
|
35
|
+
throw new Error("Timeout before DOM stability check");
|
|
36
|
+
}
|
|
37
|
+
await waitDomStable(page, domStableTime, remainingTimeout, options.ignoreSelectors);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof Error && error.message.includes("Timeout")) {
|
|
41
|
+
// 获取当前状态用于调试
|
|
42
|
+
const state = await getPageState(page);
|
|
43
|
+
throw new WaitTimeoutError(timeout, state);
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 等待网络空闲
|
|
51
|
+
*/
|
|
52
|
+
async function waitNetworkIdle(page: Page, _idleTime: number, timeout: number): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
// 使用 Playwright 的 networkidle 策略
|
|
55
|
+
await page.waitForLoadState("networkidle", { timeout });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new Error(`Network idle timeout after ${timeout}ms`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 等待 DOM 稳定
|
|
63
|
+
*/
|
|
64
|
+
async function waitDomStable(
|
|
65
|
+
page: Page,
|
|
66
|
+
stableTime: number,
|
|
67
|
+
timeout: number,
|
|
68
|
+
ignoreSelectors?: string[],
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
try {
|
|
71
|
+
await page.evaluate(
|
|
72
|
+
({ stableTime, timeout, ignoreSelectors }) => {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const startTime = Date.now();
|
|
75
|
+
let timer: NodeJS.Timeout;
|
|
76
|
+
|
|
77
|
+
const observer = new MutationObserver((mutations) => {
|
|
78
|
+
// 过滤掉被忽略的区域的变化
|
|
79
|
+
const relevantMutations = ignoreSelectors
|
|
80
|
+
? mutations.filter((mutation) => {
|
|
81
|
+
const target = mutation.target as Element;
|
|
82
|
+
return !ignoreSelectors.some((selector) => {
|
|
83
|
+
try {
|
|
84
|
+
return target.matches?.(selector) || target.closest?.(selector);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
})
|
|
90
|
+
: mutations;
|
|
91
|
+
|
|
92
|
+
if (relevantMutations.length === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 重置计时器
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
|
|
99
|
+
// 检查是否超时
|
|
100
|
+
if (Date.now() - startTime > timeout) {
|
|
101
|
+
observer.disconnect();
|
|
102
|
+
reject(new Error("DOM stability timeout"));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 设置新的稳定计时器
|
|
107
|
+
timer = setTimeout(() => {
|
|
108
|
+
observer.disconnect();
|
|
109
|
+
resolve(undefined);
|
|
110
|
+
}, stableTime);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// 开始观察
|
|
114
|
+
observer.observe(document.body, {
|
|
115
|
+
childList: true,
|
|
116
|
+
subtree: true,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 初始计时器(如果 DOM 已经稳定)
|
|
120
|
+
timer = setTimeout(() => {
|
|
121
|
+
observer.disconnect();
|
|
122
|
+
resolve(undefined);
|
|
123
|
+
}, stableTime);
|
|
124
|
+
|
|
125
|
+
// 超时保护
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
observer.disconnect();
|
|
129
|
+
reject(new Error("DOM stability timeout"));
|
|
130
|
+
}, timeout);
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
{ stableTime, timeout, ignoreSelectors },
|
|
134
|
+
);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new Error(`DOM stable timeout after ${timeout}ms`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 等待元素出现/消失/可见/隐藏
|
|
142
|
+
*/
|
|
143
|
+
export async function waitElement(
|
|
144
|
+
page: Page,
|
|
145
|
+
selector: string,
|
|
146
|
+
options: WaitElementOptions = {},
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
const state = options.state ?? "visible";
|
|
149
|
+
const timeout = options.timeout ?? 10000;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const { parseSelector } = await import("../utils/selector");
|
|
153
|
+
const parsed = parseSelector(selector);
|
|
154
|
+
|
|
155
|
+
let locator;
|
|
156
|
+
if (parsed.type === "css") {
|
|
157
|
+
locator = page.locator(parsed.value);
|
|
158
|
+
} else if (parsed.type === "text") {
|
|
159
|
+
locator = page.getByText(parsed.value);
|
|
160
|
+
} else if (parsed.type === "xpath") {
|
|
161
|
+
locator = page.locator(`xpath=${parsed.value}`);
|
|
162
|
+
} else {
|
|
163
|
+
throw new Error(`Unsupported selector type: ${parsed.type}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await locator.waitFor({ state, timeout });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof Error && error.message.includes("Timeout")) {
|
|
169
|
+
throw new WaitElementTimeoutError(selector, state, timeout);
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 获取页面当前状态(用于调试)
|
|
177
|
+
*/
|
|
178
|
+
async function getPageState(page: Page): Promise<any> {
|
|
179
|
+
const state: any = {
|
|
180
|
+
network: {
|
|
181
|
+
idle: false,
|
|
182
|
+
pendingRequests: 0,
|
|
183
|
+
requests: [],
|
|
184
|
+
},
|
|
185
|
+
dom: {
|
|
186
|
+
stable: false,
|
|
187
|
+
mutations: 0,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// 简单检测:如果能成功等待 networkidle,说明网络是空闲的
|
|
192
|
+
try {
|
|
193
|
+
await page.waitForLoadState("networkidle", { timeout: 100 });
|
|
194
|
+
state.network.idle = true;
|
|
195
|
+
} catch {
|
|
196
|
+
state.network.idle = false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return state;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 等待超时错误
|
|
204
|
+
*/
|
|
205
|
+
class WaitTimeoutError extends Error {
|
|
206
|
+
constructor(timeout: number, state: any) {
|
|
207
|
+
const message = `wait-idle timeout after ${timeout}ms`;
|
|
208
|
+
super(message);
|
|
209
|
+
this.name = "WaitTimeoutError";
|
|
210
|
+
|
|
211
|
+
// 在错误消息中包含状态信息
|
|
212
|
+
const stateJson = JSON.stringify(state, null, 2);
|
|
213
|
+
this.message = `${message}\n\nCurrent state:\n${stateJson}\n\nSuggestions:\n- Check pendingRequests and consider adding --exclude-url for slow requests\n- Check recentMutations and consider adding --ignore for animated regions\n- Increase timeout or idle times`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 等待元素超时错误
|
|
219
|
+
*/
|
|
220
|
+
class WaitElementTimeoutError extends Error {
|
|
221
|
+
constructor(selector: string, state: string, timeout: number) {
|
|
222
|
+
const message = `Element not ${state} after ${timeout}ms: ${selector}`;
|
|
223
|
+
super(message);
|
|
224
|
+
this.name = "WaitElementTimeoutError";
|
|
225
|
+
}
|
|
226
|
+
}
|