@epsilon-asi/actors 0.0.3 → 0.0.5
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/.ai/generators/_template.ts +37 -0
- package/.ai/generators/abstract.ts +24 -0
- package/.ai/generators/actor-task-form-filler.ts +140 -0
- package/.ai/generators/actor-task.ts +122 -0
- package/.ai/generators/auth-core.ts +126 -0
- package/.ai/generators/browser-runtime.ts +114 -0
- package/.ai/generators/cli-command.ts +96 -0
- package/.ai/generators/core-framework.ts +80 -0
- package/.ai/generators/docs.ts +92 -0
- package/.ai/generators/error-logging.ts +102 -0
- package/.ai/generators/extraction-helper.ts +96 -0
- package/.ai/generators/interaction-behavior.ts +129 -0
- package/.ai/generators/site-actor.ts +125 -0
- package/.ai/generators/site-login-flow.ts +117 -0
- package/.ai/generators/unit-test.ts +109 -0
- package/.ai/workflows/_template.ts +20 -0
- package/.ai/workflows/starter.ts +20 -0
- package/ai-gen.config.ts +67 -0
- package/package.json +4 -12
- package/src/auth/AuthStateDetector.ts +18 -0
- package/src/auth/CredentialsProvider.ts +48 -0
- package/src/auth/LoginFlow.ts +332 -0
- package/src/auth/LoginFlow.types.ts +141 -0
- package/src/auth/SessionStore.ts +21 -0
- package/src/auth/index.ts +5 -0
- package/src/browser/BrowserFactory.ts +253 -0
- package/src/browser/BrowserSession.ts +50 -0
- package/src/browser/PuppeteerLike.ts +65 -0
- package/src/browser/RuntimeConfig.ts +152 -0
- package/src/browser/index.ts +5 -0
- package/src/browser/profileValidation.ts +73 -0
- package/src/cli/run.ts +112 -0
- package/src/core/Actor.ts +167 -0
- package/src/core/ActorContext.ts +34 -0
- package/src/core/ActorRegistry.ts +26 -0
- package/src/core/ActorRunner.ts +240 -0
- package/src/core/defineActor.ts +5 -0
- package/src/core/index.ts +5 -0
- package/src/errors/AuthError.ts +7 -0
- package/src/errors/AutomationError.ts +26 -0
- package/src/errors/ConfigError.ts +7 -0
- package/src/errors/ExtractionError.ts +7 -0
- package/src/errors/NavigationError.ts +7 -0
- package/src/errors/SelectorError.ts +10 -0
- package/src/errors/index.ts +6 -0
- package/src/extraction/Extractor.ts +65 -0
- package/src/extraction/Pagination.ts +47 -0
- package/src/extraction/index.ts +2 -0
- package/src/index.ts +9 -0
- package/src/interaction/FieldClearer.ts +73 -0
- package/src/interaction/Forms.ts +27 -0
- package/src/interaction/GhostCursorAdapter.ts +79 -0
- package/src/interaction/HumanInteractor.ts +32 -0
- package/src/interaction/HumanTyping.ts +157 -0
- package/src/interaction/NativePuppeteerInteractor.ts +68 -0
- package/src/interaction/Navigation.ts +37 -0
- package/src/interaction/PageAdapter.ts +86 -0
- package/src/interaction/Waits.ts +5 -0
- package/src/interaction/index.ts +9 -0
- package/src/logging/ConsoleLogger.ts +44 -0
- package/src/logging/Logger.ts +15 -0
- package/src/logging/MemoryLogger.ts +34 -0
- package/src/logging/NullLogger.ts +8 -0
- package/src/logging/index.ts +4 -0
- package/src/sites/example/example.actor.ts +53 -0
- package/src/sites/example/example.selectors.ts +17 -0
- package/src/sites/example/example.types.ts +18 -0
- package/src/sites/example/index.ts +3 -0
- package/src/sites/index.ts +3 -0
- package/src/sites/myvistage-com/index.ts +3 -0
- package/src/sites/myvistage-com/login-action-list.json +349 -0
- package/src/sites/myvistage-com/myvistage-com.actor.ts +50 -0
- package/src/sites/myvistage-com/myvistage-com.selectors.ts +14 -0
- package/src/sites/myvistage-com/myvistage-com.types.ts +18 -0
- package/src/sites/myvistage-com/post-comment-action.json +81 -0
- package/src/sites/upwork-com/index.ts +6 -0
- package/src/sites/upwork-com/upwork-com.actor.ts +97 -0
- package/src/sites/upwork-com/upwork-com.runner.ts +17 -0
- package/src/sites/upwork-com/upwork-com.selectors.ts +10 -0
- package/src/sites/upwork-com/upwork-com.types.ts +102 -0
- package/src/sites/upwork-com/upwork-com.util.ts +41 -0
- package/src/utils/delay.ts +4 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/invariant.ts +7 -0
- package/src/utils/redact.ts +53 -0
- package/src/utils/retry.ts +31 -0
- package/src/utils/url.ts +7 -0
- package/tests/fixtures/FakeCredentialsProvider.ts +12 -0
- package/tests/fixtures/FakeCursor.ts +48 -0
- package/tests/fixtures/FakePage.ts +266 -0
- package/tests/fixtures/makeContext.ts +76 -0
- package/tests/unit/auth/AuthStateDetector.test.ts +80 -0
- package/tests/unit/auth/LoginFlow.test.ts +296 -0
- package/tests/unit/browser/BrowserFactory.test.ts +370 -0
- package/tests/unit/core/ActorRunner.test.ts +370 -0
- package/tests/unit/core/defineActor.test.ts +112 -0
- package/tests/unit/extraction/Extractor.test.ts +48 -0
- package/tests/unit/extraction/Pagination.test.ts +54 -0
- package/tests/unit/interaction/FieldClearer.test.ts +29 -0
- package/tests/unit/interaction/Forms.test.ts +35 -0
- package/tests/unit/interaction/GhostCursorAdapter.test.ts +68 -0
- package/tests/unit/interaction/HumanTyping.test.ts +54 -0
- package/tests/unit/interaction/NativePuppeteerInteractor.test.ts +22 -0
- package/tests/unit/interaction/PageAdapter.test.ts +25 -0
- package/tests/unit/logging/redact.test.ts +36 -0
- package/tests/unit/sites/myvistage-com.actor.test.ts +19 -0
- package/tests/unit/sites/myvistage-com.login.test.ts +22 -0
- package/tests/unit/sites/myvistage-com.postComment.test.ts +70 -0
- package/tests/unit/sites/upwork-com.login.test.ts +52 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ExtractionError } from '../errors/ExtractionError.js';
|
|
2
|
+
import type { PageAdapter } from '../interaction/PageAdapter.js';
|
|
3
|
+
|
|
4
|
+
export interface TableData {
|
|
5
|
+
headers: string[];
|
|
6
|
+
rows: Array<Record<string, string>>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class Extractor {
|
|
10
|
+
constructor(private readonly page: PageAdapter) {}
|
|
11
|
+
|
|
12
|
+
text(selector: string): Promise<string> {
|
|
13
|
+
return this.page.text(selector);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
textList(selector: string): Promise<string[]> {
|
|
17
|
+
return this.page.textAll(selector);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
attr(selector: string, name: string): Promise<string | null> {
|
|
21
|
+
return this.page.attr(selector, name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async hrefs(selector: string): Promise<string[]> {
|
|
25
|
+
const page = this.page.raw();
|
|
26
|
+
return page.$$eval(selector, elements =>
|
|
27
|
+
elements
|
|
28
|
+
.map(element => element.getAttribute('href'))
|
|
29
|
+
.filter((href): href is string => href !== null && href.length > 0)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async jsonLd<T = unknown>(): Promise<T[]> {
|
|
34
|
+
const page = this.page.raw();
|
|
35
|
+
return page.$$eval('script[type="application/ld+json"]', elements =>
|
|
36
|
+
elements.flatMap(element => {
|
|
37
|
+
try {
|
|
38
|
+
const text = element.textContent ?? '';
|
|
39
|
+
return text.length > 0 ? [JSON.parse(text)] : [];
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
) as Promise<T[]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async table(tableSelector: string): Promise<TableData> {
|
|
48
|
+
try {
|
|
49
|
+
return await this.page.raw().$eval(tableSelector, table => {
|
|
50
|
+
const headerCells = Array.from(table.querySelectorAll('thead th'));
|
|
51
|
+
const headers = headerCells.map(cell => (cell.textContent ?? '').replace(/\s+/g, ' ').trim());
|
|
52
|
+
const rows = Array.from(table.querySelectorAll('tbody tr')).map(row => {
|
|
53
|
+
const cells = Array.from(row.querySelectorAll('td'));
|
|
54
|
+
return Object.fromEntries(cells.map((cell, index) => [
|
|
55
|
+
headers[index] ?? `column_${index + 1}`,
|
|
56
|
+
(cell.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
57
|
+
]));
|
|
58
|
+
});
|
|
59
|
+
return { headers, rows };
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new ExtractionError(`Could not extract table from selector: ${tableSelector}`, { cause: error });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { HumanInteractor } from '../interaction/HumanInteractor.js';
|
|
2
|
+
import type { PageAdapter } from '../interaction/PageAdapter.js';
|
|
3
|
+
|
|
4
|
+
export interface CollectPagesOptions<T> {
|
|
5
|
+
nextSelector: string;
|
|
6
|
+
extractPage: () => Promise<T[]>;
|
|
7
|
+
maxPages?: number;
|
|
8
|
+
dedupeBy?: (item: T) => string;
|
|
9
|
+
waitAfterNextMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class Pagination {
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly page: PageAdapter,
|
|
15
|
+
private readonly interactor: HumanInteractor
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
async collectPages<T>(options: CollectPagesOptions<T>): Promise<T[]> {
|
|
19
|
+
const allItems: T[] = [];
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
const maxPages = options.maxPages ?? Number.POSITIVE_INFINITY;
|
|
22
|
+
|
|
23
|
+
for (let pageNumber = 1; pageNumber <= maxPages; pageNumber += 1) {
|
|
24
|
+
const pageItems = await options.extractPage();
|
|
25
|
+
for (const item of pageItems) {
|
|
26
|
+
const key = options.dedupeBy?.(item);
|
|
27
|
+
if (key !== undefined) {
|
|
28
|
+
if (seen.has(key)) continue;
|
|
29
|
+
seen.add(key);
|
|
30
|
+
}
|
|
31
|
+
allItems.push(item);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (pageNumber >= maxPages) break;
|
|
35
|
+
|
|
36
|
+
const hasNext = await this.page.exists(options.nextSelector, { timeout: 500 });
|
|
37
|
+
if (!hasNext) break;
|
|
38
|
+
|
|
39
|
+
await this.interactor.click(options.nextSelector);
|
|
40
|
+
if ((options.waitAfterNextMs ?? 0) > 0) {
|
|
41
|
+
await new Promise(resolve => setTimeout(resolve, options.waitAfterNextMs));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return allItems;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './auth/index.js';
|
|
2
|
+
export * from './browser/index.js';
|
|
3
|
+
export * from './core/index.js';
|
|
4
|
+
export * from './errors/index.js';
|
|
5
|
+
export * from './extraction/index.js';
|
|
6
|
+
export * from './interaction/index.js';
|
|
7
|
+
export * from './logging/index.js';
|
|
8
|
+
export * from './sites/index.js';
|
|
9
|
+
export * from './utils/index.js';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PageLike } from '../browser/PuppeteerLike.js';
|
|
2
|
+
|
|
3
|
+
export type ClearFieldStrategy = 'select-delete' | 'dom-value';
|
|
4
|
+
|
|
5
|
+
export interface ClearFieldOptions {
|
|
6
|
+
strategy?: ClearFieldStrategy;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function clearField(page: PageLike, selector: string, options: ClearFieldOptions = {}): Promise<void> {
|
|
10
|
+
const strategy = options.strategy ?? 'select-delete';
|
|
11
|
+
|
|
12
|
+
if (strategy === 'dom-value') {
|
|
13
|
+
await clearFieldValueInDom(page, selector);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await selectFieldContents(page, selector);
|
|
18
|
+
await page.keyboard.press('Backspace');
|
|
19
|
+
await clearFieldValueInDom(page, selector);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function selectFieldContents(page: PageLike, selector: string): Promise<void> {
|
|
23
|
+
await page.$eval(selector, element => {
|
|
24
|
+
const target = element as HTMLElement & {
|
|
25
|
+
value?: string;
|
|
26
|
+
select?: () => void;
|
|
27
|
+
setSelectionRange?: (start: number, end: number) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
target.focus();
|
|
31
|
+
|
|
32
|
+
if (typeof target.select === 'function') {
|
|
33
|
+
target.select();
|
|
34
|
+
target.dispatchEvent(new Event('select', { bubbles: true }));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof target.value === 'string' && typeof target.setSelectionRange === 'function') {
|
|
39
|
+
target.setSelectionRange(0, target.value.length);
|
|
40
|
+
target.dispatchEvent(new Event('select', { bubbles: true }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (target.isContentEditable) {
|
|
45
|
+
const range = document.createRange();
|
|
46
|
+
range.selectNodeContents(target);
|
|
47
|
+
const selection = window.getSelection();
|
|
48
|
+
selection?.removeAllRanges();
|
|
49
|
+
selection?.addRange(range);
|
|
50
|
+
target.dispatchEvent(new Event('select', { bubbles: true }));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function clearFieldValueInDom(page: PageLike, selector: string): Promise<void> {
|
|
56
|
+
await page.$eval(selector, element => {
|
|
57
|
+
const target = element as HTMLElement & { value?: string };
|
|
58
|
+
let changed = false;
|
|
59
|
+
|
|
60
|
+
if (typeof target.value === 'string' && target.value.length > 0) {
|
|
61
|
+
target.value = '';
|
|
62
|
+
changed = true;
|
|
63
|
+
} else if (target.isContentEditable && (target.textContent ?? '').length > 0) {
|
|
64
|
+
target.textContent = '';
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (changed) {
|
|
69
|
+
target.dispatchEvent(new Event('input', { bubbles: true }));
|
|
70
|
+
target.dispatchEvent(new Event('change', { bubbles: true }));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SelectorError } from '../errors/SelectorError.js';
|
|
2
|
+
import type { HumanInteractor, HumanTypeOptions } from './HumanInteractor.js';
|
|
3
|
+
import type { PageAdapter } from './PageAdapter.js';
|
|
4
|
+
|
|
5
|
+
export interface FillTextOptions extends HumanTypeOptions {
|
|
6
|
+
required?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class FormFiller {
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly page: PageAdapter,
|
|
12
|
+
private readonly interactor: HumanInteractor
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
async fillText(selector: string, value: string, options: FillTextOptions = {}): Promise<void> {
|
|
16
|
+
const exists = await this.page.exists(selector, { timeout: options.timeoutMs ?? 5_000 });
|
|
17
|
+
if (!exists && (options.required ?? true)) {
|
|
18
|
+
throw new SelectorError(`Required form field not found: ${selector}`, selector);
|
|
19
|
+
}
|
|
20
|
+
if (!exists) return;
|
|
21
|
+
await this.interactor.type(selector, value, options);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async click(selector: string): Promise<void> {
|
|
25
|
+
await this.interactor.click(selector);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { GhostCursor } from 'ghost-cursor';
|
|
2
|
+
import type { WaitForSelectorOptions } from 'puppeteer-core';
|
|
3
|
+
import type { PageLike } from '../browser/PuppeteerLike.js';
|
|
4
|
+
import { clearField } from './FieldClearer.js';
|
|
5
|
+
import type { HumanClickOptions, HumanInteractor, HumanMoveOptions, HumanTypeOptions } from './HumanInteractor.js';
|
|
6
|
+
import { HumanTyper, mergeHumanTypingOptions, type HumanTypingOptions, type RandomFunction, type SleepFunction } from './HumanTyping.js';
|
|
7
|
+
|
|
8
|
+
export interface GhostCursorLike {
|
|
9
|
+
click(selector?: string, options?: Record<string, unknown>): Promise<void>;
|
|
10
|
+
move(selector: string, options?: Record<string, unknown>): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GhostCursorAdapterOptions {
|
|
14
|
+
cursor?: GhostCursorLike;
|
|
15
|
+
cursorOptions?: Record<string, unknown>;
|
|
16
|
+
typing?: HumanTypingOptions;
|
|
17
|
+
typer?: HumanTyper;
|
|
18
|
+
sleep?: SleepFunction;
|
|
19
|
+
random?: RandomFunction;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function selectorWaitOptions(timeoutMs: number | undefined): WaitForSelectorOptions | undefined {
|
|
23
|
+
return timeoutMs === undefined ? undefined : { timeout: timeoutMs };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class GhostCursorAdapter implements HumanInteractor {
|
|
27
|
+
private readonly cursor: GhostCursorLike;
|
|
28
|
+
private readonly typer: HumanTyper;
|
|
29
|
+
private readonly typingDefaults: HumanTypingOptions | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(private readonly page: PageLike, options: GhostCursorAdapterOptions = {}) {
|
|
32
|
+
this.cursor = options.cursor ?? new GhostCursor(page as never, options.cursorOptions as never) as unknown as GhostCursorLike;
|
|
33
|
+
this.typingDefaults = options.typing;
|
|
34
|
+
this.typer = options.typer ?? new HumanTyper({
|
|
35
|
+
...(options.typing !== undefined ? { defaults: options.typing } : {}),
|
|
36
|
+
...(options.sleep !== undefined ? { sleep: options.sleep } : {}),
|
|
37
|
+
...(options.random !== undefined ? { random: options.random } : {})
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async click(selector: string, options: HumanClickOptions = {}): Promise<void> {
|
|
42
|
+
if (options.waitForSelector !== false) {
|
|
43
|
+
await this.page.waitForSelector(selector, selectorWaitOptions(options.timeoutMs));
|
|
44
|
+
}
|
|
45
|
+
await this.cursor.click(selector, options.cursorOptions);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async move(selector: string, options: HumanMoveOptions = {}): Promise<void> {
|
|
49
|
+
await this.page.waitForSelector(selector, selectorWaitOptions(options.timeoutMs));
|
|
50
|
+
await this.cursor.move(selector, options.cursorOptions);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async type(selector: string, value: string, options: HumanTypeOptions = {}): Promise<void> {
|
|
54
|
+
await this.page.waitForSelector(selector, selectorWaitOptions(options.timeoutMs));
|
|
55
|
+
|
|
56
|
+
if (options.clickBeforeTyping ?? true) {
|
|
57
|
+
await this.click(selector, options.timeoutMs === undefined ? undefined : { timeoutMs: options.timeoutMs });
|
|
58
|
+
} else {
|
|
59
|
+
await this.page.focus(selector);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (options.clear ?? true) {
|
|
63
|
+
await clearField(this.page, selector, options.clearMethod === undefined ? undefined : { strategy: options.clearMethod });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await this.typer.type(
|
|
67
|
+
this.page.keyboard,
|
|
68
|
+
value,
|
|
69
|
+
mergeHumanTypingOptions(this.typingDefaults, options.typing),
|
|
70
|
+
options.delayMs
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async scrollIntoView(selector: string): Promise<void> {
|
|
75
|
+
await this.page.$eval(selector, element => {
|
|
76
|
+
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ClearFieldStrategy } from './FieldClearer.js';
|
|
2
|
+
import type { HumanTypingOptions } from './HumanTyping.js';
|
|
3
|
+
|
|
4
|
+
export interface HumanClickOptions {
|
|
5
|
+
waitForSelector?: boolean;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
cursorOptions?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HumanMoveOptions {
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
cursorOptions?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface HumanTypeOptions {
|
|
16
|
+
/** Legacy Puppeteer keyboard delay. Used as key-hold delay for human typing, or bulk delay when human typing is disabled. */
|
|
17
|
+
delayMs?: number;
|
|
18
|
+
clear?: boolean;
|
|
19
|
+
/** Defaults to selecting the field contents and pressing Backspace before typing. */
|
|
20
|
+
clearMethod?: ClearFieldStrategy;
|
|
21
|
+
clickBeforeTyping?: boolean;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
/** Human-like key-by-key typing configuration. Enabled by default at approximately 65 WPM. */
|
|
24
|
+
typing?: HumanTypingOptions;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HumanInteractor {
|
|
28
|
+
click(selector: string, options?: HumanClickOptions): Promise<void>;
|
|
29
|
+
move(selector: string, options?: HumanMoveOptions): Promise<void>;
|
|
30
|
+
type(selector: string, value: string, options?: HumanTypeOptions): Promise<void>;
|
|
31
|
+
scrollIntoView(selector: string): Promise<void>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { KeyboardLike } from '../browser/PuppeteerLike.js';
|
|
2
|
+
import { delay as defaultDelay } from '../utils/delay.js';
|
|
3
|
+
|
|
4
|
+
export type SleepFunction = (ms: number) => Promise<void>;
|
|
5
|
+
export type RandomFunction = () => number;
|
|
6
|
+
|
|
7
|
+
export interface HumanTypingOptions {
|
|
8
|
+
/** Defaults to true. When false, the whole value is sent through keyboard.type. */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** Target words per minute. Defaults to 65. */
|
|
11
|
+
targetWordsPerMinute?: number;
|
|
12
|
+
/** Standard typing-speed word length. Defaults to 5 characters. */
|
|
13
|
+
averageWordLength?: number;
|
|
14
|
+
/** Symmetric per-keystroke jitter around the target interval. Defaults to ±18 ms. */
|
|
15
|
+
intervalJitterMs?: number;
|
|
16
|
+
/** Minimum inter-key interval after jitter is applied. Defaults to 20 ms. */
|
|
17
|
+
minimumIntervalMs?: number;
|
|
18
|
+
/** Optional keydown-to-keyup hold delay passed to Puppeteer's keyboard.type per character. */
|
|
19
|
+
keyHoldMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NormalizedHumanTypingOptions {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
targetWordsPerMinute: number;
|
|
25
|
+
averageWordLength: number;
|
|
26
|
+
intervalJitterMs: number;
|
|
27
|
+
minimumIntervalMs: number;
|
|
28
|
+
keyHoldMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HumanTyperOptions {
|
|
32
|
+
sleep?: SleepFunction;
|
|
33
|
+
random?: RandomFunction;
|
|
34
|
+
defaults?: HumanTypingOptions;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_HUMAN_TYPING_OPTIONS: NormalizedHumanTypingOptions = {
|
|
38
|
+
enabled: true,
|
|
39
|
+
targetWordsPerMinute: 65,
|
|
40
|
+
averageWordLength: 5,
|
|
41
|
+
intervalJitterMs: 18,
|
|
42
|
+
minimumIntervalMs: 20,
|
|
43
|
+
keyHoldMs: 0
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function characterIntervalMsForWordsPerMinute(
|
|
47
|
+
targetWordsPerMinute: number,
|
|
48
|
+
averageWordLength = DEFAULT_HUMAN_TYPING_OPTIONS.averageWordLength
|
|
49
|
+
): number {
|
|
50
|
+
if (!Number.isFinite(targetWordsPerMinute) || targetWordsPerMinute <= 0) {
|
|
51
|
+
throw new RangeError('targetWordsPerMinute must be greater than 0.');
|
|
52
|
+
}
|
|
53
|
+
if (!Number.isFinite(averageWordLength) || averageWordLength <= 0) {
|
|
54
|
+
throw new RangeError('averageWordLength must be greater than 0.');
|
|
55
|
+
}
|
|
56
|
+
return 60_000 / (targetWordsPerMinute * averageWordLength);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeHumanTypingOptions(options: HumanTypingOptions = {}): NormalizedHumanTypingOptions {
|
|
60
|
+
const normalized = {
|
|
61
|
+
enabled: options.enabled ?? DEFAULT_HUMAN_TYPING_OPTIONS.enabled,
|
|
62
|
+
targetWordsPerMinute: options.targetWordsPerMinute ?? DEFAULT_HUMAN_TYPING_OPTIONS.targetWordsPerMinute,
|
|
63
|
+
averageWordLength: options.averageWordLength ?? DEFAULT_HUMAN_TYPING_OPTIONS.averageWordLength,
|
|
64
|
+
intervalJitterMs: options.intervalJitterMs ?? DEFAULT_HUMAN_TYPING_OPTIONS.intervalJitterMs,
|
|
65
|
+
minimumIntervalMs: options.minimumIntervalMs ?? DEFAULT_HUMAN_TYPING_OPTIONS.minimumIntervalMs,
|
|
66
|
+
keyHoldMs: options.keyHoldMs ?? DEFAULT_HUMAN_TYPING_OPTIONS.keyHoldMs
|
|
67
|
+
} satisfies NormalizedHumanTypingOptions;
|
|
68
|
+
|
|
69
|
+
if (!Number.isFinite(normalized.targetWordsPerMinute) || normalized.targetWordsPerMinute <= 0) {
|
|
70
|
+
throw new RangeError('targetWordsPerMinute must be greater than 0.');
|
|
71
|
+
}
|
|
72
|
+
if (!Number.isFinite(normalized.averageWordLength) || normalized.averageWordLength <= 0) {
|
|
73
|
+
throw new RangeError('averageWordLength must be greater than 0.');
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isFinite(normalized.intervalJitterMs) || normalized.intervalJitterMs < 0) {
|
|
76
|
+
throw new RangeError('intervalJitterMs must be greater than or equal to 0.');
|
|
77
|
+
}
|
|
78
|
+
if (!Number.isFinite(normalized.minimumIntervalMs) || normalized.minimumIntervalMs < 0) {
|
|
79
|
+
throw new RangeError('minimumIntervalMs must be greater than or equal to 0.');
|
|
80
|
+
}
|
|
81
|
+
if (!Number.isFinite(normalized.keyHoldMs) || normalized.keyHoldMs < 0) {
|
|
82
|
+
throw new RangeError('keyHoldMs must be greater than or equal to 0.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function mergeHumanTypingOptions(
|
|
89
|
+
defaults: HumanTypingOptions | undefined,
|
|
90
|
+
overrides: HumanTypingOptions | undefined
|
|
91
|
+
): HumanTypingOptions {
|
|
92
|
+
return {
|
|
93
|
+
...(defaults ?? {}),
|
|
94
|
+
...(overrides ?? {})
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function jitteredIntervalMs(
|
|
99
|
+
baseIntervalMs: number,
|
|
100
|
+
jitterMs: number,
|
|
101
|
+
minimumIntervalMs: number,
|
|
102
|
+
random: RandomFunction
|
|
103
|
+
): number {
|
|
104
|
+
const normalizedRandom = Math.min(1, Math.max(0, random()));
|
|
105
|
+
const jitter = (normalizedRandom * 2 - 1) * jitterMs;
|
|
106
|
+
return Math.max(minimumIntervalMs, Math.round(baseIntervalMs + jitter));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function keyboardTypeOptions(delayMs: number): { delay: number } | undefined {
|
|
110
|
+
return delayMs > 0 ? { delay: delayMs } : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class HumanTyper {
|
|
114
|
+
private readonly sleep: SleepFunction;
|
|
115
|
+
private readonly random: RandomFunction;
|
|
116
|
+
private readonly defaults: HumanTypingOptions | undefined;
|
|
117
|
+
|
|
118
|
+
constructor(options: HumanTyperOptions = {}) {
|
|
119
|
+
this.sleep = options.sleep ?? defaultDelay;
|
|
120
|
+
this.random = options.random ?? Math.random;
|
|
121
|
+
this.defaults = options.defaults;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async type(
|
|
125
|
+
keyboard: KeyboardLike,
|
|
126
|
+
text: string,
|
|
127
|
+
options: HumanTypingOptions = {},
|
|
128
|
+
legacyDelayMs?: number
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
const normalized = normalizeHumanTypingOptions(mergeHumanTypingOptions(this.defaults, options));
|
|
131
|
+
const keyHoldMs = options.keyHoldMs ?? this.defaults?.keyHoldMs ?? legacyDelayMs ?? normalized.keyHoldMs;
|
|
132
|
+
|
|
133
|
+
if (!normalized.enabled) {
|
|
134
|
+
await keyboard.type(text, keyboardTypeOptions(keyHoldMs));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const baseIntervalMs = characterIntervalMsForWordsPerMinute(
|
|
139
|
+
normalized.targetWordsPerMinute,
|
|
140
|
+
normalized.averageWordLength
|
|
141
|
+
);
|
|
142
|
+
const characters = Array.from(text);
|
|
143
|
+
|
|
144
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
145
|
+
if (index > 0) {
|
|
146
|
+
await this.sleep(jitteredIntervalMs(
|
|
147
|
+
baseIntervalMs,
|
|
148
|
+
normalized.intervalJitterMs,
|
|
149
|
+
normalized.minimumIntervalMs,
|
|
150
|
+
this.random
|
|
151
|
+
));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await keyboard.type(characters[index]!, keyboardTypeOptions(keyHoldMs));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { WaitForSelectorOptions } from 'puppeteer-core';
|
|
2
|
+
import type { PageLike } from '../browser/PuppeteerLike.js';
|
|
3
|
+
import { clearField } from './FieldClearer.js';
|
|
4
|
+
import type { HumanClickOptions, HumanInteractor, HumanMoveOptions, HumanTypeOptions } from './HumanInteractor.js';
|
|
5
|
+
import { HumanTyper, mergeHumanTypingOptions, type HumanTypingOptions, type RandomFunction, type SleepFunction } from './HumanTyping.js';
|
|
6
|
+
|
|
7
|
+
export interface NativePuppeteerInteractorOptions {
|
|
8
|
+
typing?: HumanTypingOptions;
|
|
9
|
+
typer?: HumanTyper;
|
|
10
|
+
sleep?: SleepFunction;
|
|
11
|
+
random?: RandomFunction;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function selectorWaitOptions(timeoutMs: number | undefined): WaitForSelectorOptions | undefined {
|
|
15
|
+
return timeoutMs === undefined ? undefined : { timeout: timeoutMs };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class NativePuppeteerInteractor implements HumanInteractor {
|
|
19
|
+
private readonly typer: HumanTyper;
|
|
20
|
+
private readonly typingDefaults: HumanTypingOptions | undefined;
|
|
21
|
+
|
|
22
|
+
constructor(private readonly page: PageLike, options: NativePuppeteerInteractorOptions = {}) {
|
|
23
|
+
this.typingDefaults = options.typing;
|
|
24
|
+
this.typer = options.typer ?? new HumanTyper({
|
|
25
|
+
...(options.typing !== undefined ? { defaults: options.typing } : {}),
|
|
26
|
+
...(options.sleep !== undefined ? { sleep: options.sleep } : {}),
|
|
27
|
+
...(options.random !== undefined ? { random: options.random } : {})
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async click(selector: string, options: HumanClickOptions = {}): Promise<void> {
|
|
32
|
+
if (options.waitForSelector !== false) {
|
|
33
|
+
await this.page.waitForSelector(selector, selectorWaitOptions(options.timeoutMs));
|
|
34
|
+
}
|
|
35
|
+
await this.page.click(selector);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async move(selector: string, options: HumanMoveOptions = {}): Promise<void> {
|
|
39
|
+
await this.page.waitForSelector(selector, selectorWaitOptions(options.timeoutMs));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async type(selector: string, value: string, options: HumanTypeOptions = {}): Promise<void> {
|
|
43
|
+
await this.page.waitForSelector(selector, selectorWaitOptions(options.timeoutMs));
|
|
44
|
+
|
|
45
|
+
if (options.clickBeforeTyping ?? true) {
|
|
46
|
+
await this.page.click(selector);
|
|
47
|
+
} else {
|
|
48
|
+
await this.page.focus(selector);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (options.clear ?? true) {
|
|
52
|
+
await clearField(this.page, selector, options.clearMethod === undefined ? undefined : { strategy: options.clearMethod });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await this.typer.type(
|
|
56
|
+
this.page.keyboard,
|
|
57
|
+
value,
|
|
58
|
+
mergeHumanTypingOptions(this.typingDefaults, options.typing),
|
|
59
|
+
options.delayMs
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async scrollIntoView(selector: string): Promise<void> {
|
|
64
|
+
await this.page.$eval(selector, element => {
|
|
65
|
+
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { WaitForOptions } from 'puppeteer-core';
|
|
2
|
+
import type { HumanInteractor } from './HumanInteractor.js';
|
|
3
|
+
import type { PageAdapter } from './PageAdapter.js';
|
|
4
|
+
import { resolveUrl } from '../utils/url.js';
|
|
5
|
+
|
|
6
|
+
export interface ClickNavigationOptions {
|
|
7
|
+
waitForNavigation?: boolean;
|
|
8
|
+
navigationOptions?: WaitForOptions;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class Navigator {
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly page: PageAdapter,
|
|
14
|
+
private readonly interactor: HumanInteractor,
|
|
15
|
+
private readonly baseUrl: string
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
resolve(urlOrPath: string): string {
|
|
19
|
+
return resolveUrl(this.baseUrl, urlOrPath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async goto(urlOrPath: string, options: WaitForOptions = { waitUntil: 'domcontentloaded' }): Promise<void> {
|
|
23
|
+
await this.page.goto(this.resolve(urlOrPath), options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async click(selector: string, options: ClickNavigationOptions = {}): Promise<void> {
|
|
27
|
+
if (options.waitForNavigation) {
|
|
28
|
+
await Promise.all([
|
|
29
|
+
this.page.waitForNavigation(options.navigationOptions ?? { waitUntil: 'domcontentloaded' }),
|
|
30
|
+
this.interactor.click(selector)
|
|
31
|
+
]);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await this.interactor.click(selector);
|
|
36
|
+
}
|
|
37
|
+
}
|