@apifuse/connector-sdk 2.0.0-beta.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 +44 -0
- package/bin/apifuse-check.ts +408 -0
- package/bin/apifuse-dev.ts +222 -0
- package/bin/apifuse-init.ts +390 -0
- package/bin/apifuse-perf.ts +1101 -0
- package/bin/apifuse-record.ts +446 -0
- package/bin/apifuse-test.ts +688 -0
- package/bin/apifuse.ts +51 -0
- package/package.json +64 -0
- package/src/__tests__/auth.test.ts +396 -0
- package/src/__tests__/browser-auth.test.ts +180 -0
- package/src/__tests__/browser.test.ts +632 -0
- package/src/__tests__/connectors-yaml.test.ts +135 -0
- package/src/__tests__/define.test.ts +225 -0
- package/src/__tests__/errors.test.ts +69 -0
- package/src/__tests__/executor.test.ts +214 -0
- package/src/__tests__/http.test.ts +238 -0
- package/src/__tests__/insights.test.ts +210 -0
- package/src/__tests__/instrumentation.test.ts +290 -0
- package/src/__tests__/otlp.test.ts +141 -0
- package/src/__tests__/perf.test.ts +60 -0
- package/src/__tests__/proxy.test.ts +359 -0
- package/src/__tests__/recipes.test.ts +36 -0
- package/src/__tests__/serve.test.ts +233 -0
- package/src/__tests__/session.test.ts +231 -0
- package/src/__tests__/state.test.ts +100 -0
- package/src/__tests__/stealth.test.ts +57 -0
- package/src/__tests__/testing.test.ts +97 -0
- package/src/__tests__/tls.test.ts +345 -0
- package/src/__tests__/types.test.ts +142 -0
- package/src/__tests__/utils.test.ts +62 -0
- package/src/__tests__/waterfall.test.ts +270 -0
- package/src/config/connectors-yaml.ts +373 -0
- package/src/config/loader.ts +122 -0
- package/src/define.ts +137 -0
- package/src/dev.ts +38 -0
- package/src/errors.ts +68 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +100 -0
- package/src/protocol.ts +183 -0
- package/src/recipes/gov-api.ts +97 -0
- package/src/recipes/rest-api.ts +152 -0
- package/src/runtime/auth.ts +245 -0
- package/src/runtime/browser.ts +724 -0
- package/src/runtime/connector.ts +20 -0
- package/src/runtime/executor.ts +51 -0
- package/src/runtime/http.ts +248 -0
- package/src/runtime/insights.ts +456 -0
- package/src/runtime/instrumentation.ts +424 -0
- package/src/runtime/otlp.ts +171 -0
- package/src/runtime/perf.ts +73 -0
- package/src/runtime/session.ts +573 -0
- package/src/runtime/state.ts +124 -0
- package/src/runtime/tls.ts +410 -0
- package/src/runtime/trace.ts +261 -0
- package/src/runtime/waterfall.ts +245 -0
- package/src/serve.ts +665 -0
- package/src/stealth/profiles.ts +391 -0
- package/src/testing/helpers.ts +144 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/run.ts +88 -0
- package/src/types/playwright-stealth.d.ts +9 -0
- package/src/types.ts +243 -0
- package/src/utils/date.ts +163 -0
- package/src/utils/parse.ts +66 -0
- package/src/utils/text.ts +20 -0
- package/src/utils/transform.ts +62 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import type { LaunchOptions, Page } from "playwright";
|
|
3
|
+
|
|
4
|
+
import { ConnectorError } from "../errors";
|
|
5
|
+
import type {
|
|
6
|
+
BrowserClient as BrowserClientContract,
|
|
7
|
+
BrowserEngine,
|
|
8
|
+
BrowserOptions,
|
|
9
|
+
} from "../types";
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
|
|
13
|
+
const SELECTOR_POLL_INTERVAL_MS = 100;
|
|
14
|
+
|
|
15
|
+
type PlaywrightModule = typeof import("playwright");
|
|
16
|
+
type PlaywrightStealthModule = {
|
|
17
|
+
stealth(page: unknown): Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type PoolAcquireResponse = {
|
|
21
|
+
pageId: string;
|
|
22
|
+
wsEndpoint: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type JsonRpcId = number;
|
|
26
|
+
|
|
27
|
+
type JsonRpcError = {
|
|
28
|
+
code?: number;
|
|
29
|
+
message?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type JsonRpcMessage = {
|
|
33
|
+
error?: JsonRpcError;
|
|
34
|
+
id?: JsonRpcId;
|
|
35
|
+
method?: string;
|
|
36
|
+
params?: Record<string, unknown>;
|
|
37
|
+
result?: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type BrowserPageContract = {
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
content(): Promise<string>;
|
|
43
|
+
evaluate<T>(fn: string | (() => T)): Promise<T>;
|
|
44
|
+
fill?(selector: string, text: string): Promise<void>;
|
|
45
|
+
goto(url: string): Promise<void>;
|
|
46
|
+
pageId?: string;
|
|
47
|
+
screenshot(options?: { fullPage?: boolean }): Promise<Buffer>;
|
|
48
|
+
click(selector: string): Promise<void>;
|
|
49
|
+
type(selector: string, text: string): Promise<void>;
|
|
50
|
+
waitForSelector(
|
|
51
|
+
selector: string,
|
|
52
|
+
options?: { timeout?: number },
|
|
53
|
+
): Promise<void>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type BrowserClientOptions = BrowserOptions & {
|
|
57
|
+
cdpUrl?: string;
|
|
58
|
+
executablePath?: string;
|
|
59
|
+
extraArgs?: string[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type SupportedBrowserClient = BrowserClientContract & {
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
newPage(): Promise<BrowserPageContract>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function getDefaultCdpPoolUrl(env = process.env): string | undefined {
|
|
68
|
+
return env.CDP_POOL_URL ?? env.APIFUSE_CDP_POOL_URL;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function importOptionalModule<T extends object>(
|
|
72
|
+
moduleName: string,
|
|
73
|
+
): Promise<T> {
|
|
74
|
+
return (await import(moduleName)) as T;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function unwrapModuleDefault<T extends object>(module: T): T {
|
|
78
|
+
if ("default" in module) {
|
|
79
|
+
const defaultExport = module.default as unknown;
|
|
80
|
+
if (
|
|
81
|
+
(typeof defaultExport === "object" && defaultExport !== null) ||
|
|
82
|
+
typeof defaultExport === "function"
|
|
83
|
+
) {
|
|
84
|
+
return defaultExport as T;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return module;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isModuleNotFoundError(error: unknown): boolean {
|
|
92
|
+
return (
|
|
93
|
+
error instanceof Error &&
|
|
94
|
+
("code" in error
|
|
95
|
+
? (error as Error & { code?: string }).code === "MODULE_NOT_FOUND"
|
|
96
|
+
: error.message.includes("Cannot find module"))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function delay(ms: number): Promise<void> {
|
|
101
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatExpression<T>(fn: string | (() => T)): string {
|
|
105
|
+
if (typeof fn === "string") {
|
|
106
|
+
return fn;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return `(${fn.toString()})()`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toLaunchOptions(options: BrowserClientOptions): LaunchOptions {
|
|
113
|
+
return {
|
|
114
|
+
args: options.extraArgs,
|
|
115
|
+
executablePath: options.executablePath,
|
|
116
|
+
headless: options.headless ?? true,
|
|
117
|
+
proxy: options.proxy ? { server: options.proxy } : undefined,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function loadPlaywright(): Promise<PlaywrightModule> {
|
|
122
|
+
try {
|
|
123
|
+
require("playwright");
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (isModuleNotFoundError(error)) {
|
|
126
|
+
throw new ConnectorError("Playwright is not installed", {
|
|
127
|
+
cause: error instanceof Error ? error : undefined,
|
|
128
|
+
fix: "Run: bun add playwright",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
return unwrapModuleDefault(
|
|
137
|
+
await importOptionalModule<PlaywrightModule>("playwright"),
|
|
138
|
+
);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (isModuleNotFoundError(error)) {
|
|
141
|
+
throw new ConnectorError("Playwright is not installed", {
|
|
142
|
+
cause: error instanceof Error ? error : undefined,
|
|
143
|
+
fix: "Run: bun add playwright",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function loadPlaywrightStealth(): Promise<PlaywrightStealthModule> {
|
|
152
|
+
try {
|
|
153
|
+
return unwrapModuleDefault(
|
|
154
|
+
await importOptionalModule<PlaywrightStealthModule>("playwright-stealth"),
|
|
155
|
+
);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (isModuleNotFoundError(error)) {
|
|
158
|
+
throw new ConnectorError("playwright-stealth is not installed", {
|
|
159
|
+
cause: error instanceof Error ? error : undefined,
|
|
160
|
+
fix: "Run: bun add playwright-stealth",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function loadNodriver(): Promise<void> {
|
|
169
|
+
try {
|
|
170
|
+
await importOptionalModule("nodriver");
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (isModuleNotFoundError(error)) {
|
|
173
|
+
throw new ConnectorError("nodriver is not installed", {
|
|
174
|
+
cause: error instanceof Error ? error : undefined,
|
|
175
|
+
fix: "Run: pip install nodriver (Python) or bun add nodriver",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function loadSeleniumBase(): Promise<void> {
|
|
184
|
+
try {
|
|
185
|
+
await importOptionalModule("seleniumbase");
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (isModuleNotFoundError(error)) {
|
|
188
|
+
throw new ConnectorError("seleniumbase is not installed", {
|
|
189
|
+
cause: error instanceof Error ? error : undefined,
|
|
190
|
+
fix: "Run: pip install seleniumbase",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
class PlaywrightBrowserPage implements BrowserPageContract {
|
|
199
|
+
readonly pageId?: string;
|
|
200
|
+
|
|
201
|
+
constructor(private readonly page: Page) {}
|
|
202
|
+
|
|
203
|
+
async goto(url: string): Promise<void> {
|
|
204
|
+
await this.page.goto(url);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async evaluate<T>(fn: string | (() => T)): Promise<T> {
|
|
208
|
+
if (typeof fn === "string") {
|
|
209
|
+
return await this.page.evaluate(fn);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return await this.page.evaluate(fn);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async waitForSelector(
|
|
216
|
+
selector: string,
|
|
217
|
+
options?: { timeout?: number },
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
await this.page.waitForSelector(selector, options);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async click(selector: string): Promise<void> {
|
|
223
|
+
await this.page.click(selector);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async type(selector: string, text: string): Promise<void> {
|
|
227
|
+
await this.page.locator(selector).fill("");
|
|
228
|
+
await this.page.type(selector, text);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async fill(selector: string, text: string): Promise<void> {
|
|
232
|
+
await this.page.fill(selector, text);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async content(): Promise<string> {
|
|
236
|
+
return await this.page.content();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async screenshot(options?: { fullPage?: boolean }): Promise<Buffer> {
|
|
240
|
+
return await this.page.screenshot(options);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async close(): Promise<void> {
|
|
244
|
+
await this.page.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
class PlaywrightBrowserClient implements SupportedBrowserClient {
|
|
249
|
+
private browser: import("playwright").Browser | null = null;
|
|
250
|
+
readonly engine = "playwright-stealth" satisfies BrowserEngine;
|
|
251
|
+
|
|
252
|
+
constructor(private readonly options: BrowserClientOptions = {}) {}
|
|
253
|
+
|
|
254
|
+
private async ensureBrowser(): Promise<import("playwright").Browser> {
|
|
255
|
+
if (this.browser?.isConnected()) {
|
|
256
|
+
return this.browser;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const playwright = await loadPlaywright();
|
|
260
|
+
this.browser = await playwright.chromium.launch(
|
|
261
|
+
toLaunchOptions(this.options),
|
|
262
|
+
);
|
|
263
|
+
return this.browser;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async newPage(): Promise<BrowserPageContract> {
|
|
267
|
+
const browser = await this.ensureBrowser();
|
|
268
|
+
const page = await browser.newPage();
|
|
269
|
+
|
|
270
|
+
if (this.options.stealth ?? true) {
|
|
271
|
+
const { stealth } = await loadPlaywrightStealth();
|
|
272
|
+
await stealth(page);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return new PlaywrightBrowserPage(page);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async close(): Promise<void> {
|
|
279
|
+
const browser = this.browser;
|
|
280
|
+
this.browser = null;
|
|
281
|
+
|
|
282
|
+
if (!browser) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await browser.close();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
class JsonRpcWebSocketClient {
|
|
291
|
+
private nextId = 1;
|
|
292
|
+
private readonly listeners = new Map<
|
|
293
|
+
string,
|
|
294
|
+
Set<(params: unknown) => void>
|
|
295
|
+
>();
|
|
296
|
+
private readonly pending = new Map<
|
|
297
|
+
JsonRpcId,
|
|
298
|
+
{
|
|
299
|
+
reject: (reason?: unknown) => void;
|
|
300
|
+
resolve: (value: Record<string, unknown>) => void;
|
|
301
|
+
}
|
|
302
|
+
>();
|
|
303
|
+
private socket?: WebSocket;
|
|
304
|
+
private socketPromise?: Promise<WebSocket>;
|
|
305
|
+
|
|
306
|
+
constructor(private readonly endpoint: string) {}
|
|
307
|
+
|
|
308
|
+
on(method: string, listener: (params: unknown) => void): () => void {
|
|
309
|
+
const listeners = this.listeners.get(method) ?? new Set();
|
|
310
|
+
listeners.add(listener);
|
|
311
|
+
this.listeners.set(method, listeners);
|
|
312
|
+
|
|
313
|
+
return () => {
|
|
314
|
+
listeners.delete(listener);
|
|
315
|
+
if (listeners.size === 0) {
|
|
316
|
+
this.listeners.delete(method);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async send(
|
|
322
|
+
method: string,
|
|
323
|
+
params: Record<string, unknown> = {},
|
|
324
|
+
): Promise<Record<string, unknown>> {
|
|
325
|
+
const socket = await this.getSocket();
|
|
326
|
+
const id = this.nextId++;
|
|
327
|
+
|
|
328
|
+
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
329
|
+
this.pending.set(id, { resolve, reject });
|
|
330
|
+
|
|
331
|
+
socket.send(
|
|
332
|
+
JSON.stringify({
|
|
333
|
+
id,
|
|
334
|
+
jsonrpc: "2.0",
|
|
335
|
+
method,
|
|
336
|
+
params,
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async close(): Promise<void> {
|
|
343
|
+
for (const pending of this.pending.values()) {
|
|
344
|
+
pending.reject(new Error(`WebSocket closed: ${this.endpoint}`));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
this.pending.clear();
|
|
348
|
+
this.listeners.clear();
|
|
349
|
+
this.socket?.close();
|
|
350
|
+
this.socket = undefined;
|
|
351
|
+
this.socketPromise = undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async getSocket(): Promise<WebSocket> {
|
|
355
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
356
|
+
return this.socket;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (this.socketPromise) {
|
|
360
|
+
return this.socketPromise;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
this.socketPromise = new Promise<WebSocket>((resolve, reject) => {
|
|
364
|
+
const socket = new WebSocket(this.endpoint);
|
|
365
|
+
|
|
366
|
+
socket.addEventListener("open", () => {
|
|
367
|
+
this.socket = socket;
|
|
368
|
+
resolve(socket);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
socket.addEventListener("message", (event) => {
|
|
372
|
+
const rawData =
|
|
373
|
+
typeof event.data === "string"
|
|
374
|
+
? event.data
|
|
375
|
+
: Buffer.from(event.data as ArrayBufferLike).toString("utf8");
|
|
376
|
+
const payload = JSON.parse(rawData) as JsonRpcMessage;
|
|
377
|
+
|
|
378
|
+
if (typeof payload.id === "number") {
|
|
379
|
+
const pending = this.pending.get(payload.id);
|
|
380
|
+
if (!pending) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
this.pending.delete(payload.id);
|
|
385
|
+
|
|
386
|
+
if (payload.error) {
|
|
387
|
+
pending.reject(
|
|
388
|
+
new Error(payload.error.message ?? "JSON-RPC command failed"),
|
|
389
|
+
);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
pending.resolve(payload.result ?? {});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!payload.method) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
for (const listener of this.listeners.get(payload.method) ?? []) {
|
|
402
|
+
listener(payload.params);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
socket.addEventListener("close", () => {
|
|
407
|
+
for (const pending of this.pending.values()) {
|
|
408
|
+
pending.reject(new Error(`WebSocket closed: ${this.endpoint}`));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
this.pending.clear();
|
|
412
|
+
this.socket = undefined;
|
|
413
|
+
this.socketPromise = undefined;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
socket.addEventListener("error", () => {
|
|
417
|
+
reject(
|
|
418
|
+
new Error(
|
|
419
|
+
`Unable to connect to WebSocket endpoint: ${this.endpoint}`,
|
|
420
|
+
),
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return this.socketPromise;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
class CdpPoolBrowserPage implements BrowserPageContract {
|
|
430
|
+
private closed = false;
|
|
431
|
+
private initialized = false;
|
|
432
|
+
|
|
433
|
+
constructor(
|
|
434
|
+
readonly pageId: string,
|
|
435
|
+
private readonly pageClient: JsonRpcWebSocketClient,
|
|
436
|
+
private readonly release: (pageId: string) => Promise<void>,
|
|
437
|
+
) {}
|
|
438
|
+
|
|
439
|
+
async goto(url: string): Promise<void> {
|
|
440
|
+
await this.initialize();
|
|
441
|
+
const startedAt = Date.now();
|
|
442
|
+
let loadEventSeen = false;
|
|
443
|
+
const unsubscribe = this.pageClient.on("Page.loadEventFired", () => {
|
|
444
|
+
loadEventSeen = true;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
await this.pageClient.send("Page.navigate", { url });
|
|
449
|
+
await this.waitForDocumentReady(
|
|
450
|
+
startedAt + DEFAULT_WAIT_TIMEOUT_MS,
|
|
451
|
+
() => loadEventSeen,
|
|
452
|
+
);
|
|
453
|
+
} finally {
|
|
454
|
+
unsubscribe();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async evaluate<T>(fn: string | (() => T)): Promise<T> {
|
|
459
|
+
await this.initialize();
|
|
460
|
+
const result = await this.pageClient.send("Runtime.evaluate", {
|
|
461
|
+
awaitPromise: true,
|
|
462
|
+
expression: formatExpression(fn),
|
|
463
|
+
returnByValue: true,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
if (result.exceptionDetails) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
String(
|
|
469
|
+
(result.exceptionDetails as { text?: string }).text ??
|
|
470
|
+
"Browser evaluation failed",
|
|
471
|
+
),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return (result.result as { value?: T } | undefined)?.value as T;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async waitForSelector(
|
|
479
|
+
selector: string,
|
|
480
|
+
options?: { timeout?: number },
|
|
481
|
+
): Promise<void> {
|
|
482
|
+
const timeout = options?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
483
|
+
const deadline = Date.now() + timeout;
|
|
484
|
+
|
|
485
|
+
while (Date.now() < deadline) {
|
|
486
|
+
const exists = await this.evaluate<boolean>(
|
|
487
|
+
`Boolean(document.querySelector(${JSON.stringify(selector)}))`,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
if (exists) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
await delay(SELECTOR_POLL_INTERVAL_MS);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
throw new Error(`Timed out waiting for selector: ${selector}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async click(selector: string): Promise<void> {
|
|
501
|
+
await this.waitForSelector(selector);
|
|
502
|
+
await this.evaluate(
|
|
503
|
+
`(() => {
|
|
504
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
505
|
+
if (!(element instanceof HTMLElement)) {
|
|
506
|
+
throw new Error(${JSON.stringify(`Selector not found: ${selector}`)});
|
|
507
|
+
}
|
|
508
|
+
element.click();
|
|
509
|
+
})()`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async type(selector: string, text: string): Promise<void> {
|
|
514
|
+
await this.fill(selector, text);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async fill(selector: string, text: string): Promise<void> {
|
|
518
|
+
await this.waitForSelector(selector);
|
|
519
|
+
await this.evaluate(
|
|
520
|
+
`(() => {
|
|
521
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
522
|
+
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
|
|
523
|
+
throw new Error(${JSON.stringify(`Unsupported input target: ${selector}`)});
|
|
524
|
+
}
|
|
525
|
+
element.focus();
|
|
526
|
+
element.value = "";
|
|
527
|
+
})()`,
|
|
528
|
+
);
|
|
529
|
+
await this.pageClient.send("Input.insertText", { text });
|
|
530
|
+
await this.evaluate(
|
|
531
|
+
`(() => {
|
|
532
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
533
|
+
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
|
|
534
|
+
throw new Error(${JSON.stringify(`Unsupported input target: ${selector}`)});
|
|
535
|
+
}
|
|
536
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
537
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
538
|
+
})()`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async content(): Promise<string> {
|
|
543
|
+
return await this.evaluate<string>("document.documentElement.outerHTML");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async screenshot(options?: { fullPage?: boolean }): Promise<Buffer> {
|
|
547
|
+
await this.initialize();
|
|
548
|
+
const result = await this.pageClient.send("Page.captureScreenshot", {
|
|
549
|
+
captureBeyondViewport: options?.fullPage ?? false,
|
|
550
|
+
format: "png",
|
|
551
|
+
fromSurface: true,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
return Buffer.from(String(result.data ?? ""), "base64");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async close(): Promise<void> {
|
|
558
|
+
if (this.closed) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
this.closed = true;
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
await this.release(this.pageId);
|
|
566
|
+
} finally {
|
|
567
|
+
await this.pageClient.close();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private async initialize(): Promise<void> {
|
|
572
|
+
if (this.initialized) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
await this.pageClient.send("Page.enable");
|
|
577
|
+
await this.pageClient.send("Runtime.enable");
|
|
578
|
+
this.initialized = true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private async waitForDocumentReady(
|
|
582
|
+
deadline: number,
|
|
583
|
+
isLoadEventSeen: () => boolean,
|
|
584
|
+
): Promise<void> {
|
|
585
|
+
while (Date.now() < deadline) {
|
|
586
|
+
const readyState = await this.evaluate<string>("document.readyState");
|
|
587
|
+
if (readyState === "complete" || readyState === "interactive") {
|
|
588
|
+
if (isLoadEventSeen() || readyState === "complete") {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
await delay(SELECTOR_POLL_INTERVAL_MS);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
throw new Error("Timed out waiting for page load");
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
class CdpPoolBrowserClient implements SupportedBrowserClient {
|
|
601
|
+
private readonly poolClient: JsonRpcWebSocketClient;
|
|
602
|
+
readonly engine = "playwright-stealth" satisfies BrowserEngine;
|
|
603
|
+
|
|
604
|
+
constructor(options: BrowserClientOptions) {
|
|
605
|
+
if (!options.cdpUrl) {
|
|
606
|
+
throw new ConnectorError("CDP Pool URL is required", {
|
|
607
|
+
code: "BROWSER_RUNTIME_UNSUPPORTED",
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this.poolClient = new JsonRpcWebSocketClient(options.cdpUrl);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async newPage(): Promise<BrowserPageContract> {
|
|
615
|
+
const acquireResult = (await this.poolClient.send(
|
|
616
|
+
"acquire",
|
|
617
|
+
)) as PoolAcquireResponse;
|
|
618
|
+
const pageClient = new JsonRpcWebSocketClient(acquireResult.wsEndpoint);
|
|
619
|
+
const page = new CdpPoolBrowserPage(
|
|
620
|
+
acquireResult.pageId,
|
|
621
|
+
pageClient,
|
|
622
|
+
async (pageId) => {
|
|
623
|
+
await this.poolClient.send("release", { pageId });
|
|
624
|
+
},
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
await page.evaluate(
|
|
628
|
+
`window.navigator.webdriver === true ? Object.defineProperty(window.navigator, "webdriver", { configurable: true, get: () => undefined }) : undefined`,
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
return page;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async close(): Promise<void> {
|
|
635
|
+
await this.poolClient.close();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
class UnsupportedBrowserEngineClient implements SupportedBrowserClient {
|
|
640
|
+
constructor(
|
|
641
|
+
readonly engine: Extract<BrowserEngine, "nodriver" | "selenium-uc">,
|
|
642
|
+
) {}
|
|
643
|
+
|
|
644
|
+
async newPage(): Promise<never> {
|
|
645
|
+
if (this.engine === "nodriver") {
|
|
646
|
+
await loadNodriver();
|
|
647
|
+
throw new ConnectorError("nodriver engine requires Python runtime", {
|
|
648
|
+
fix: "Use connector language: python and ctx.browser in Python",
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
await loadSeleniumBase();
|
|
653
|
+
throw new ConnectorError("selenium-uc engine requires Python runtime", {
|
|
654
|
+
fix: "Use connector language: python",
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async close(): Promise<void> {}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function createPlaywrightStealthClient(
|
|
662
|
+
options: BrowserClientOptions = {},
|
|
663
|
+
): SupportedBrowserClient {
|
|
664
|
+
return new PlaywrightBrowserClient(options);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function createCdpPoolBrowserClient(
|
|
668
|
+
options: BrowserClientOptions,
|
|
669
|
+
): SupportedBrowserClient {
|
|
670
|
+
return new CdpPoolBrowserClient(options);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function createNodriverClient(): SupportedBrowserClient {
|
|
674
|
+
return new UnsupportedBrowserEngineClient("nodriver");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function createSeleniumUCClient(): SupportedBrowserClient {
|
|
678
|
+
return new UnsupportedBrowserEngineClient("selenium-uc");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export class BrowserClient implements BrowserClientContract {
|
|
682
|
+
private readonly client: SupportedBrowserClient;
|
|
683
|
+
private readonly _engine: BrowserEngine;
|
|
684
|
+
|
|
685
|
+
constructor(options: BrowserClientOptions = {}) {
|
|
686
|
+
const resolvedOptions = {
|
|
687
|
+
...options,
|
|
688
|
+
cdpUrl: options.cdpUrl ?? getDefaultCdpPoolUrl(),
|
|
689
|
+
};
|
|
690
|
+
const engine = resolvedOptions.engine ?? "playwright-stealth";
|
|
691
|
+
this._engine = engine;
|
|
692
|
+
|
|
693
|
+
switch (engine) {
|
|
694
|
+
case "nodriver":
|
|
695
|
+
this.client = createNodriverClient();
|
|
696
|
+
break;
|
|
697
|
+
case "selenium-uc":
|
|
698
|
+
this.client = createSeleniumUCClient();
|
|
699
|
+
break;
|
|
700
|
+
default:
|
|
701
|
+
this.client = resolvedOptions.cdpUrl
|
|
702
|
+
? createCdpPoolBrowserClient(resolvedOptions)
|
|
703
|
+
: createPlaywrightStealthClient(resolvedOptions);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
get engine(): BrowserEngine {
|
|
708
|
+
return this._engine;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async newPage(): Promise<unknown> {
|
|
712
|
+
return await this.client.newPage();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async close(): Promise<void> {
|
|
716
|
+
await this.client.close();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export function createBrowserClient(
|
|
721
|
+
options: BrowserClientOptions = {},
|
|
722
|
+
): BrowserClient {
|
|
723
|
+
return new BrowserClient(options);
|
|
724
|
+
}
|