@epsilon-asi/actors 0.0.3 → 0.0.4

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.
Files changed (112) hide show
  1. package/.ai/generators/_template.ts +37 -0
  2. package/.ai/generators/abstract.ts +24 -0
  3. package/.ai/generators/actor-task-form-filler.ts +140 -0
  4. package/.ai/generators/actor-task.ts +122 -0
  5. package/.ai/generators/auth-core.ts +126 -0
  6. package/.ai/generators/browser-runtime.ts +114 -0
  7. package/.ai/generators/cli-command.ts +96 -0
  8. package/.ai/generators/core-framework.ts +80 -0
  9. package/.ai/generators/docs.ts +92 -0
  10. package/.ai/generators/error-logging.ts +102 -0
  11. package/.ai/generators/extraction-helper.ts +96 -0
  12. package/.ai/generators/interaction-behavior.ts +129 -0
  13. package/.ai/generators/site-actor.ts +125 -0
  14. package/.ai/generators/site-login-flow.ts +117 -0
  15. package/.ai/generators/unit-test.ts +109 -0
  16. package/.ai/workflows/_template.ts +20 -0
  17. package/.ai/workflows/starter.ts +20 -0
  18. package/ai-gen.config.ts +67 -0
  19. package/package.json +4 -13
  20. package/src/auth/AuthStateDetector.ts +18 -0
  21. package/src/auth/CredentialsProvider.ts +48 -0
  22. package/src/auth/LoginFlow.ts +332 -0
  23. package/src/auth/LoginFlow.types.ts +141 -0
  24. package/src/auth/SessionStore.ts +21 -0
  25. package/src/auth/index.ts +5 -0
  26. package/src/browser/BrowserFactory.ts +253 -0
  27. package/src/browser/BrowserSession.ts +50 -0
  28. package/src/browser/PuppeteerLike.ts +65 -0
  29. package/src/browser/RuntimeConfig.ts +152 -0
  30. package/src/browser/index.ts +5 -0
  31. package/src/browser/profileValidation.ts +73 -0
  32. package/src/cli/run.ts +112 -0
  33. package/src/core/Actor.ts +167 -0
  34. package/src/core/ActorContext.ts +34 -0
  35. package/src/core/ActorRegistry.ts +26 -0
  36. package/src/core/ActorRunner.ts +240 -0
  37. package/src/core/defineActor.ts +5 -0
  38. package/src/core/index.ts +5 -0
  39. package/src/errors/AuthError.ts +7 -0
  40. package/src/errors/AutomationError.ts +26 -0
  41. package/src/errors/ConfigError.ts +7 -0
  42. package/src/errors/ExtractionError.ts +7 -0
  43. package/src/errors/NavigationError.ts +7 -0
  44. package/src/errors/SelectorError.ts +10 -0
  45. package/src/errors/index.ts +6 -0
  46. package/src/extraction/Extractor.ts +65 -0
  47. package/src/extraction/Pagination.ts +47 -0
  48. package/src/extraction/index.ts +2 -0
  49. package/src/index.ts +9 -0
  50. package/src/interaction/FieldClearer.ts +73 -0
  51. package/src/interaction/Forms.ts +27 -0
  52. package/src/interaction/GhostCursorAdapter.ts +79 -0
  53. package/src/interaction/HumanInteractor.ts +32 -0
  54. package/src/interaction/HumanTyping.ts +157 -0
  55. package/src/interaction/NativePuppeteerInteractor.ts +68 -0
  56. package/src/interaction/Navigation.ts +37 -0
  57. package/src/interaction/PageAdapter.ts +86 -0
  58. package/src/interaction/Waits.ts +5 -0
  59. package/src/interaction/index.ts +9 -0
  60. package/src/logging/ConsoleLogger.ts +44 -0
  61. package/src/logging/Logger.ts +15 -0
  62. package/src/logging/MemoryLogger.ts +34 -0
  63. package/src/logging/NullLogger.ts +8 -0
  64. package/src/logging/index.ts +4 -0
  65. package/src/sites/example/example.actor.ts +53 -0
  66. package/src/sites/example/example.selectors.ts +17 -0
  67. package/src/sites/example/example.types.ts +18 -0
  68. package/src/sites/example/index.ts +3 -0
  69. package/src/sites/index.ts +3 -0
  70. package/src/sites/myvistage-com/index.ts +3 -0
  71. package/src/sites/myvistage-com/login-action-list.json +349 -0
  72. package/src/sites/myvistage-com/myvistage-com.actor.ts +50 -0
  73. package/src/sites/myvistage-com/myvistage-com.selectors.ts +14 -0
  74. package/src/sites/myvistage-com/myvistage-com.types.ts +18 -0
  75. package/src/sites/myvistage-com/post-comment-action.json +81 -0
  76. package/src/sites/upwork-com/index.ts +6 -0
  77. package/src/sites/upwork-com/upwork-com.actor.ts +97 -0
  78. package/src/sites/upwork-com/upwork-com.runner.ts +17 -0
  79. package/src/sites/upwork-com/upwork-com.selectors.ts +10 -0
  80. package/src/sites/upwork-com/upwork-com.types.ts +102 -0
  81. package/src/sites/upwork-com/upwork-com.util.ts +41 -0
  82. package/src/utils/delay.ts +4 -0
  83. package/src/utils/index.ts +5 -0
  84. package/src/utils/invariant.ts +7 -0
  85. package/src/utils/redact.ts +53 -0
  86. package/src/utils/retry.ts +31 -0
  87. package/src/utils/url.ts +7 -0
  88. package/tests/fixtures/FakeCredentialsProvider.ts +12 -0
  89. package/tests/fixtures/FakeCursor.ts +48 -0
  90. package/tests/fixtures/FakePage.ts +266 -0
  91. package/tests/fixtures/makeContext.ts +76 -0
  92. package/tests/unit/auth/AuthStateDetector.test.ts +80 -0
  93. package/tests/unit/auth/LoginFlow.test.ts +296 -0
  94. package/tests/unit/browser/BrowserFactory.test.ts +370 -0
  95. package/tests/unit/core/ActorRunner.test.ts +370 -0
  96. package/tests/unit/core/defineActor.test.ts +112 -0
  97. package/tests/unit/extraction/Extractor.test.ts +48 -0
  98. package/tests/unit/extraction/Pagination.test.ts +54 -0
  99. package/tests/unit/interaction/FieldClearer.test.ts +29 -0
  100. package/tests/unit/interaction/Forms.test.ts +35 -0
  101. package/tests/unit/interaction/GhostCursorAdapter.test.ts +68 -0
  102. package/tests/unit/interaction/HumanTyping.test.ts +54 -0
  103. package/tests/unit/interaction/NativePuppeteerInteractor.test.ts +22 -0
  104. package/tests/unit/interaction/PageAdapter.test.ts +25 -0
  105. package/tests/unit/logging/redact.test.ts +36 -0
  106. package/tests/unit/sites/myvistage-com.actor.test.ts +19 -0
  107. package/tests/unit/sites/myvistage-com.login.test.ts +22 -0
  108. package/tests/unit/sites/myvistage-com.postComment.test.ts +70 -0
  109. package/tests/unit/sites/upwork-com.login.test.ts +52 -0
  110. package/tsconfig.build.json +9 -0
  111. package/tsconfig.json +22 -0
  112. package/vitest.config.ts +12 -0
package/src/cli/run.ts ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ import { ActorRegistry } from '../core/ActorRegistry.js';
3
+ import { ActorRunner } from '../core/ActorRunner.js';
4
+ import { exampleActor } from '../sites/example/example.actor.js';
5
+ import type { ExistingChromeProfileConfig, RemoteDebuggingChromeConfig, RuntimeConfig, RunningChromeInstanceConfig } from '../browser/RuntimeConfig.js';
6
+
7
+ async function main(): Promise<void> {
8
+ const [actorId, taskName, inputJson] = process.argv.slice(2);
9
+ if (actorId === undefined || taskName === undefined) {
10
+ console.error('Usage: paf <actorId> <taskName> [jsonInput]');
11
+ process.exitCode = 1;
12
+ return;
13
+ }
14
+
15
+ const config = readRuntimeConfigFromEnv();
16
+ const registry = new ActorRegistry().register(exampleActor);
17
+ const runner = new ActorRunner({ config, registry });
18
+ const input = inputJson === undefined ? undefined : JSON.parse(inputJson);
19
+ const result = await runner.runRegistered(actorId, taskName, input);
20
+ console.log(JSON.stringify(result, null, 2));
21
+ }
22
+
23
+ function readRuntimeConfigFromEnv(): RuntimeConfig {
24
+ const userDataDir = process.env.CHROME_USER_DATA_DIR;
25
+ const profileDirectory = process.env.CHROME_PROFILE_DIRECTORY;
26
+ const browserURL = process.env.CHROME_REMOTE_DEBUGGING_URL;
27
+ const browserWSEndpoint = process.env.CHROME_WS_ENDPOINT ?? process.env.CHROME_BROWSER_WS_ENDPOINT ?? process.env.CHROME_REMOTE_DEBUGGING_WS_ENDPOINT;
28
+ const connectToDefaultEndpoint = process.env.CHROME_CONNECT === '1' || process.env.CHROME_CONNECT === 'true';
29
+ const remoteDebuggingHost = process.env.CHROME_REMOTE_DEBUGGING_HOST;
30
+ const remoteDebuggingPort = parseOptionalInteger(process.env.CHROME_REMOTE_DEBUGGING_PORT);
31
+ const reuseExistingPage = parseOptionalBoolean(process.env.CHROME_REUSE_EXISTING_PAGE);
32
+
33
+ if (userDataDir !== undefined && userDataDir.length > 0) {
34
+ const browser: ExistingChromeProfileConfig = {
35
+ mode: 'existing-profile',
36
+ userDataDir,
37
+ channel: 'chrome',
38
+ headless: false
39
+ };
40
+
41
+ if (profileDirectory !== undefined && profileDirectory.length > 0) browser.profileDirectory = profileDirectory;
42
+
43
+ const runningInstance = buildRunningInstanceConfig({
44
+ enabled: parseOptionalBoolean(process.env.CHROME_CONNECT_TO_RUNNING_PROFILE),
45
+ browserURL,
46
+ browserWSEndpoint,
47
+ remoteDebuggingHost,
48
+ remoteDebuggingPort,
49
+ reuseExistingPage
50
+ });
51
+ if (runningInstance !== undefined) browser.runningInstance = runningInstance;
52
+
53
+ return { browser };
54
+ }
55
+
56
+ if (connectToDefaultEndpoint || (browserURL !== undefined && browserURL.length > 0) || (browserWSEndpoint !== undefined && browserWSEndpoint.length > 0)) {
57
+ const browser: RemoteDebuggingChromeConfig = { mode: 'remote-debugging' };
58
+
59
+ if (browserURL !== undefined && browserURL.length > 0) browser.browserURL = browserURL;
60
+ if (browserWSEndpoint !== undefined && browserWSEndpoint.length > 0) browser.browserWSEndpoint = browserWSEndpoint;
61
+ if (remoteDebuggingHost !== undefined && remoteDebuggingHost.length > 0) browser.remoteDebuggingHost = remoteDebuggingHost;
62
+ if (remoteDebuggingPort !== undefined) browser.remoteDebuggingPort = remoteDebuggingPort;
63
+ if (reuseExistingPage !== undefined) browser.reuseExistingPage = reuseExistingPage;
64
+
65
+ return { browser };
66
+ }
67
+
68
+ throw new Error('Set CHROME_USER_DATA_DIR to launch/connect to a Chrome profile, or set CHROME_REMOTE_DEBUGGING_URL/CHROME_CONNECT to connect to an already-running Chrome.');
69
+ }
70
+
71
+ function parseOptionalInteger(value: string | undefined): number | undefined {
72
+ if (value === undefined || value.length === 0) return undefined;
73
+ const parsed = Number.parseInt(value, 10);
74
+ if (!Number.isInteger(parsed)) {
75
+ throw new Error(`Expected an integer for CHROME_REMOTE_DEBUGGING_PORT, got: ${value}`);
76
+ }
77
+ return parsed;
78
+ }
79
+
80
+ interface EnvRunningInstanceInput {
81
+ enabled?: boolean | undefined;
82
+ browserURL?: string | undefined;
83
+ browserWSEndpoint?: string | undefined;
84
+ remoteDebuggingHost?: string | undefined;
85
+ remoteDebuggingPort?: number | undefined;
86
+ reuseExistingPage?: boolean | undefined;
87
+ }
88
+
89
+ function buildRunningInstanceConfig(input: EnvRunningInstanceInput): RunningChromeInstanceConfig | undefined {
90
+ const config: RunningChromeInstanceConfig = {};
91
+
92
+ if (input.enabled !== undefined) config.enabled = input.enabled;
93
+ if (input.browserURL !== undefined && input.browserURL.length > 0) config.browserURL = input.browserURL;
94
+ if (input.browserWSEndpoint !== undefined && input.browserWSEndpoint.length > 0) config.browserWSEndpoint = input.browserWSEndpoint;
95
+ if (input.remoteDebuggingHost !== undefined && input.remoteDebuggingHost.length > 0) config.remoteDebuggingHost = input.remoteDebuggingHost;
96
+ if (input.remoteDebuggingPort !== undefined) config.remoteDebuggingPort = input.remoteDebuggingPort;
97
+ if (input.reuseExistingPage !== undefined) config.reuseExistingPage = input.reuseExistingPage;
98
+
99
+ return Object.keys(config).length === 0 ? undefined : config;
100
+ }
101
+
102
+ function parseOptionalBoolean(value: string | undefined): boolean | undefined {
103
+ if (value === undefined || value.length === 0) return undefined;
104
+ if (value === '1' || value.toLowerCase() === 'true') return true;
105
+ if (value === '0' || value.toLowerCase() === 'false') return false;
106
+ throw new Error(`Expected a boolean-like value for environment variable, got: ${value}`);
107
+ }
108
+
109
+ main().catch(error => {
110
+ console.error(error);
111
+ process.exitCode = 1;
112
+ });
@@ -0,0 +1,167 @@
1
+ import type { WaitForOptions } from 'puppeteer-core';
2
+ import type { LoginFlowDefinition } from '../auth/LoginFlow.types.js';
3
+ import type { ConfigError } from '../errors/ConfigError.js';
4
+ import { ConfigError as RuntimeConfigError } from '../errors/ConfigError.js';
5
+ import type { ClearFieldStrategy } from '../interaction/FieldClearer.js';
6
+ import type { HumanTypingOptions } from '../interaction/HumanTyping.js';
7
+ import type { ActorContext } from './ActorContext.js';
8
+
9
+ export type ActorTask<TInput = any, TOutput = unknown> = (
10
+ context: ActorContext,
11
+ input: TInput
12
+ ) => Promise<TOutput> | TOutput;
13
+
14
+ export type ActorTaskMap = Record<string, ActorTask<any, any>>;
15
+
16
+ export interface ActorHooks {
17
+ beforeRun?: (context: ActorContext, taskName: string, input: unknown) => Promise<void> | void;
18
+ afterRun?: (context: ActorContext, taskName: string, output: unknown) => Promise<void> | void;
19
+ onError?: (context: ActorContext, taskName: string, error: unknown) => Promise<void> | void;
20
+ }
21
+
22
+ export interface Actor<TTasks extends ActorTaskMap = ActorTaskMap> {
23
+ id: string;
24
+ baseUrl: string;
25
+ auth?: LoginFlowDefinition;
26
+ tasks: TTasks;
27
+ hooks?: ActorHooks;
28
+ }
29
+
30
+ export type FormTaskValue = string | number | boolean;
31
+ export type FormTaskValueResolver<TInput> = (
32
+ input: TInput,
33
+ context: ActorContext
34
+ ) => Promise<FormTaskValue | null | undefined> | FormTaskValue | null | undefined;
35
+
36
+ export interface FormTaskFieldDefinition<TInput extends Record<string, unknown>> {
37
+ selector: string;
38
+ inputKey?: keyof TInput & string;
39
+ value?: FormTaskValue | FormTaskValueResolver<TInput>;
40
+ required?: boolean;
41
+ timeoutMs?: number;
42
+ clickBeforeTyping?: boolean;
43
+ clearFieldBeforeTyping?: boolean;
44
+ clearMethod?: ClearFieldStrategy;
45
+ typing?: HumanTypingOptions;
46
+ }
47
+
48
+ export interface FormTaskSubmitDefinition {
49
+ selector: string;
50
+ timeoutMs?: number;
51
+ waitForNavigation?: boolean;
52
+ navigationOptions?: WaitForOptions;
53
+ }
54
+
55
+ export interface FormTaskHooks<TInput extends Record<string, unknown>> {
56
+ beforeFill?: (context: ActorContext, input: TInput) => Promise<void> | void;
57
+ afterField?: (
58
+ context: ActorContext,
59
+ field: FormTaskFieldDefinition<TInput>,
60
+ input: TInput,
61
+ value: string
62
+ ) => Promise<void> | void;
63
+ beforeSubmit?: (context: ActorContext, input: TInput) => Promise<void> | void;
64
+ afterSubmit?: (context: ActorContext, input: TInput) => Promise<void> | void;
65
+ }
66
+
67
+ export interface FormTaskDefinition<TInput extends Record<string, unknown>, TOutput = void> {
68
+ url?: string;
69
+ fields: FormTaskFieldDefinition<TInput>[];
70
+ submit: FormTaskSubmitDefinition;
71
+ hooks?: FormTaskHooks<TInput>;
72
+ onComplete?: (context: ActorContext, input: TInput) => Promise<TOutput> | TOutput;
73
+ }
74
+
75
+ export function defineFormTask<TInput extends Record<string, unknown>, TOutput = void>(
76
+ definition: FormTaskDefinition<TInput, TOutput>
77
+ ): ActorTask<TInput, TOutput> {
78
+ return async (context, input) => {
79
+ if (definition.url !== undefined) {
80
+ await context.nav.goto(definition.url);
81
+ }
82
+
83
+ await definition.hooks?.beforeFill?.(context, input);
84
+
85
+ for (const field of definition.fields) {
86
+ const resolved = await resolveFormTaskValue(field, input, context);
87
+
88
+ if (resolved === null || resolved === undefined) {
89
+ if (field.required ?? true) {
90
+ throw new RuntimeConfigError(`Form task field ${field.selector} is missing a value.`);
91
+ }
92
+ continue;
93
+ }
94
+
95
+ const fillOptions: {
96
+ required: boolean;
97
+ timeoutMs?: number;
98
+ clickBeforeTyping?: boolean;
99
+ clear?: boolean;
100
+ clearMethod?: ClearFieldStrategy;
101
+ typing?: HumanTypingOptions;
102
+ } = {
103
+ required: field.required ?? true
104
+ };
105
+
106
+ if (field.timeoutMs !== undefined) fillOptions.timeoutMs = field.timeoutMs;
107
+ if (field.clickBeforeTyping !== undefined) fillOptions.clickBeforeTyping = field.clickBeforeTyping;
108
+ if (field.clearFieldBeforeTyping !== undefined) fillOptions.clear = field.clearFieldBeforeTyping;
109
+ if (field.clearMethod !== undefined) fillOptions.clearMethod = field.clearMethod;
110
+ if (field.typing !== undefined) fillOptions.typing = field.typing;
111
+
112
+ const textValue = String(resolved);
113
+ await context.forms.fillText(field.selector, textValue, fillOptions);
114
+ await definition.hooks?.afterField?.(context, field, input, textValue);
115
+ }
116
+
117
+ await definition.hooks?.beforeSubmit?.(context, input);
118
+
119
+ const clickOptions = definition.submit.timeoutMs === undefined
120
+ ? undefined
121
+ : { timeoutMs: definition.submit.timeoutMs };
122
+
123
+ if (definition.submit.waitForNavigation) {
124
+ await Promise.all([
125
+ context.page.waitForNavigation(definition.submit.navigationOptions ?? { waitUntil: 'domcontentloaded' }),
126
+ context.cursor.click(definition.submit.selector, clickOptions)
127
+ ]);
128
+ } else {
129
+ await context.cursor.click(definition.submit.selector, clickOptions);
130
+ }
131
+
132
+ await definition.hooks?.afterSubmit?.(context, input);
133
+
134
+ if (definition.onComplete !== undefined) {
135
+ return definition.onComplete(context, input);
136
+ }
137
+
138
+ return undefined as TOutput;
139
+ };
140
+ }
141
+
142
+ export function defineFormFillerTask<TInput extends Record<string, unknown>, TOutput = void>(
143
+ definition: FormTaskDefinition<TInput, TOutput>
144
+ ): ActorTask<TInput, TOutput> {
145
+ return defineFormTask(definition);
146
+ }
147
+
148
+ async function resolveFormTaskValue<TInput extends Record<string, unknown>>(
149
+ field: FormTaskFieldDefinition<TInput>,
150
+ input: TInput,
151
+ context: ActorContext
152
+ ): Promise<FormTaskValue | null | undefined> {
153
+ if (field.value !== undefined) {
154
+ if (typeof field.value === 'function') {
155
+ return field.value(input, context);
156
+ }
157
+ return field.value;
158
+ }
159
+
160
+ if (field.inputKey === undefined) {
161
+ throw new RuntimeConfigError(
162
+ `Form task field ${field.selector} requires either an inputKey or a value resolver.`
163
+ );
164
+ }
165
+
166
+ return input[field.inputKey] as FormTaskValue | null | undefined;
167
+ }
@@ -0,0 +1,34 @@
1
+ import type { BrowserSession } from '../browser/BrowserSession.js';
2
+ import type { RuntimeConfig } from '../browser/RuntimeConfig.js';
3
+ import type { LoginFlowDefinition } from '../auth/LoginFlow.types.js';
4
+ import type { Extractor } from '../extraction/Extractor.js';
5
+ import type { Pagination } from '../extraction/Pagination.js';
6
+ import type { FormFiller } from '../interaction/Forms.js';
7
+ import type { HumanInteractor } from '../interaction/HumanInteractor.js';
8
+ import type { Navigator } from '../interaction/Navigation.js';
9
+ import type { PageAdapter } from '../interaction/PageAdapter.js';
10
+ import type { Logger } from '../logging/Logger.js';
11
+
12
+ export interface ActorMetadata {
13
+ id: string;
14
+ baseUrl: string;
15
+ }
16
+
17
+ export interface AuthController {
18
+ isLoggedIn(definition?: LoginFlowDefinition): Promise<boolean>;
19
+ ensureAuthenticated(definition?: LoginFlowDefinition): Promise<void>;
20
+ }
21
+
22
+ export interface ActorContext {
23
+ actor: ActorMetadata;
24
+ config: RuntimeConfig;
25
+ session: BrowserSession;
26
+ page: PageAdapter;
27
+ cursor: HumanInteractor;
28
+ forms: FormFiller;
29
+ nav: Navigator;
30
+ extract: Extractor;
31
+ pagination: Pagination;
32
+ auth: AuthController;
33
+ logger: Logger;
34
+ }
@@ -0,0 +1,26 @@
1
+ import { ConfigError } from '../errors/ConfigError.js';
2
+ import type { Actor, ActorTaskMap } from './Actor.js';
3
+
4
+ export class ActorRegistry {
5
+ private readonly actors = new Map<string, Actor>();
6
+
7
+ register<TTasks extends ActorTaskMap>(actor: Actor<TTasks>): this {
8
+ if (this.actors.has(actor.id)) {
9
+ throw new ConfigError(`Actor already registered: ${actor.id}`);
10
+ }
11
+ this.actors.set(actor.id, actor as Actor);
12
+ return this;
13
+ }
14
+
15
+ get(actorId: string): Actor {
16
+ const actor = this.actors.get(actorId);
17
+ if (actor === undefined) {
18
+ throw new ConfigError(`No actor registered with id: ${actorId}`);
19
+ }
20
+ return actor;
21
+ }
22
+
23
+ list(): Actor[] {
24
+ return [...this.actors.values()];
25
+ }
26
+ }
@@ -0,0 +1,240 @@
1
+ import { EnvCredentialsProvider, type CredentialsProvider } from '../auth/CredentialsProvider.js';
2
+ import { AuthStateDetector } from '../auth/AuthStateDetector.js';
3
+ import { LoginFlow } from '../auth/LoginFlow.js';
4
+ import type { LoginFlowDefinition } from '../auth/LoginFlow.types.js';
5
+ import { BrowserFactory } from '../browser/BrowserFactory.js';
6
+ import type { BrowserSession } from '../browser/BrowserSession.js';
7
+ import type { PageLike } from '../browser/PuppeteerLike.js';
8
+ import type { RuntimeConfig } from '../browser/RuntimeConfig.js';
9
+ import { AutomationError } from '../errors/AutomationError.js';
10
+ import { ConfigError } from '../errors/ConfigError.js';
11
+ import { Extractor } from '../extraction/Extractor.js';
12
+ import { Pagination } from '../extraction/Pagination.js';
13
+ import { FormFiller } from '../interaction/Forms.js';
14
+ import { GhostCursorAdapter } from '../interaction/GhostCursorAdapter.js';
15
+ import type { HumanInteractor } from '../interaction/HumanInteractor.js';
16
+ import { Navigator } from '../interaction/Navigation.js';
17
+ import { PuppeteerPageAdapter, type PageAdapter } from '../interaction/PageAdapter.js';
18
+ import type { Logger } from '../logging/Logger.js';
19
+ import { ConsoleLogger } from '../logging/ConsoleLogger.js';
20
+ import type { Actor, ActorTaskMap } from './Actor.js';
21
+ import type { ActorContext, AuthController } from './ActorContext.js';
22
+ import type { ActorRegistry } from './ActorRegistry.js';
23
+
24
+ export interface ActorRunnerOptions {
25
+ config: RuntimeConfig;
26
+ browserFactory?: BrowserFactory;
27
+ credentialsProvider?: CredentialsProvider;
28
+ logger?: Logger;
29
+ interactorFactory?: (page: PageLike) => HumanInteractor;
30
+ pageAdapterFactory?: (page: PageLike) => PageAdapter;
31
+ loginFlow?: LoginFlow;
32
+ authStateDetector?: AuthStateDetector;
33
+ registry?: ActorRegistry;
34
+ }
35
+
36
+ type TaskName<TTasks extends ActorTaskMap> = keyof TTasks & string;
37
+ type TaskInput<TTask> = TTask extends (...args: infer TArgs) => unknown
38
+ ? TArgs extends [unknown, infer TInput, ...unknown[]]
39
+ ? TInput
40
+ : undefined
41
+ : never;
42
+ type TaskOutput<TTask> = TTask extends (...args: unknown[]) => infer TResult ? Awaited<TResult> : never;
43
+ type SequenceOutputs<TTasks extends ActorTaskMap, TTaskNames extends readonly TaskName<TTasks>[]> = {
44
+ [TIndex in keyof TTaskNames]: TTaskNames[TIndex] extends TaskName<TTasks>
45
+ ? TaskOutput<TTasks[TTaskNames[TIndex]]>
46
+ : never;
47
+ };
48
+
49
+ export class ActorRunner {
50
+ private readonly browserFactory: BrowserFactory;
51
+ private readonly credentialsProvider: CredentialsProvider;
52
+ private readonly logger: Logger;
53
+ private readonly interactorFactory: (page: PageLike) => HumanInteractor;
54
+ private readonly pageAdapterFactory: (page: PageLike) => PageAdapter;
55
+ private readonly loginFlow: LoginFlow;
56
+ private readonly authStateDetector: AuthStateDetector;
57
+ protected readonly registry: ActorRegistry | undefined;
58
+ protected readonly options: ActorRunnerOptions;
59
+
60
+ constructor(options: ActorRunnerOptions) {
61
+ this.options = options;
62
+ this.browserFactory = options.browserFactory ?? new BrowserFactory();
63
+ this.credentialsProvider = options.credentialsProvider ?? new EnvCredentialsProvider();
64
+ this.logger = options.logger ?? new ConsoleLogger('info');
65
+ this.interactorFactory = options.interactorFactory ?? (page => new GhostCursorAdapter(
66
+ page,
67
+ options.config.interaction?.typing === undefined ? {} : { typing: options.config.interaction.typing }
68
+ ));
69
+ this.pageAdapterFactory = options.pageAdapterFactory ?? (page => new PuppeteerPageAdapter(page));
70
+ this.authStateDetector = options.authStateDetector ?? new AuthStateDetector();
71
+ this.loginFlow = options.loginFlow ?? new LoginFlow(this.credentialsProvider, this.authStateDetector);
72
+ this.registry = options.registry;
73
+ }
74
+
75
+ async run<TTasks extends ActorTaskMap, TTaskKey extends TaskName<TTasks>>(
76
+ actor: Actor<TTasks>,
77
+ taskName: TTaskKey,
78
+ input?: TaskInput<TTasks[TTaskKey]>
79
+ ): Promise<TaskOutput<TTasks[TTaskKey]>> {
80
+ const session = await this.createSession();
81
+ const context = this.createContext(actor, session);
82
+
83
+ try {
84
+ return await this.executeTask(actor, context, taskName, input, true);
85
+ } finally {
86
+ await this.closeSession(session);
87
+ }
88
+ }
89
+
90
+ async runRegistered<TOutput = unknown, TInput = unknown>(
91
+ actorId: string,
92
+ taskName: string,
93
+ input?: TInput
94
+ ): Promise<TOutput> {
95
+ const actor = this.getRegisteredActor(actorId) as Actor<ActorTaskMap>;
96
+ return this.run(actor, taskName, input as never) as Promise<TOutput>;
97
+ }
98
+
99
+ protected async createSession(): Promise<BrowserSession> {
100
+ return this.browserFactory.launch(this.options.config);
101
+ }
102
+
103
+ protected async closeSession(session: BrowserSession): Promise<void> {
104
+ if (session.shouldCloseOnFinish()) {
105
+ await session.close();
106
+ }
107
+ }
108
+
109
+ protected async executeTask<TTasks extends ActorTaskMap, TTaskKey extends TaskName<TTasks>>(
110
+ actor: Actor<TTasks>,
111
+ context: ActorContext,
112
+ taskName: TTaskKey,
113
+ input: TaskInput<TTasks[TTaskKey]> | undefined,
114
+ ensureAuthentication: boolean
115
+ ): Promise<TaskOutput<TTasks[TTaskKey]>> {
116
+ const task = this.getTask(actor, taskName);
117
+
118
+ try {
119
+ await actor.hooks?.beforeRun?.(context, taskName, input);
120
+
121
+ if (ensureAuthentication && actor.auth !== undefined) {
122
+ await context.auth.ensureAuthenticated(actor.auth);
123
+ }
124
+
125
+ const output = await task(context, input as never) as TaskOutput<TTasks[TTaskKey]>;
126
+ await actor.hooks?.afterRun?.(context, taskName, output);
127
+ return output;
128
+ } catch (error) {
129
+ await actor.hooks?.onError?.(context, taskName, error);
130
+ throw this.wrapError(error, actor.id, taskName, context.page.url());
131
+ }
132
+ }
133
+
134
+ protected getRegisteredActor(actorId: string): Actor {
135
+ if (this.registry === undefined) {
136
+ throw new ConfigError('runRegistered requires an ActorRegistry.');
137
+ }
138
+ return this.registry.get(actorId);
139
+ }
140
+
141
+ protected createContext(actor: Actor, session: BrowserSession): ActorContext {
142
+ const page = this.pageAdapterFactory(session.page);
143
+ const cursor = this.interactorFactory(session.page);
144
+ const forms = new FormFiller(page, cursor);
145
+ const nav = new Navigator(page, cursor, actor.baseUrl);
146
+ const extract = new Extractor(page);
147
+ const pagination = new Pagination(page, cursor);
148
+
149
+ let context!: ActorContext;
150
+
151
+ const auth: AuthController = {
152
+ isLoggedIn: async (definition?: LoginFlowDefinition) => {
153
+ const targetDefinition = definition ?? actor.auth;
154
+ if (targetDefinition === undefined) {
155
+ return false;
156
+ }
157
+ return this.authStateDetector.isLoggedIn(context, targetDefinition);
158
+ },
159
+ ensureAuthenticated: async (definition?: LoginFlowDefinition) => {
160
+ const targetDefinition = definition ?? actor.auth;
161
+ if (targetDefinition === undefined) {
162
+ return;
163
+ }
164
+ await this.loginFlow.ensureAuthenticated(context, targetDefinition);
165
+ }
166
+ };
167
+
168
+ context = {
169
+ actor: { id: actor.id, baseUrl: actor.baseUrl },
170
+ config: this.options.config,
171
+ session,
172
+ page,
173
+ cursor,
174
+ forms,
175
+ nav,
176
+ extract,
177
+ pagination,
178
+ auth,
179
+ logger: this.logger
180
+ };
181
+
182
+ return context;
183
+ }
184
+
185
+ protected getTask<TTasks extends ActorTaskMap, TTaskKey extends TaskName<TTasks>>(
186
+ actor: Actor<TTasks>,
187
+ taskName: TTaskKey
188
+ ): TTasks[TTaskKey] {
189
+ const task = actor.tasks[taskName];
190
+ if (task === undefined) {
191
+ throw new ConfigError(`Actor ${actor.id} does not define task: ${taskName}`);
192
+ }
193
+ return task;
194
+ }
195
+
196
+ protected wrapError(error: unknown, actorId: string, taskName: string, url?: string): Error {
197
+ if (error instanceof AutomationError) {
198
+ return error;
199
+ }
200
+
201
+ const meta = url === undefined ? { actorId, taskName, cause: error } : { actorId, taskName, url, cause: error };
202
+ return new AutomationError(`Actor ${actorId} task ${taskName} failed.`, meta);
203
+ }
204
+ }
205
+
206
+ export class ActorSequenceRunner extends ActorRunner {
207
+ async runSequence<TTasks extends ActorTaskMap, TTaskNames extends readonly TaskName<TTasks>[]>(
208
+ actor: Actor<TTasks>,
209
+ taskNames: [...TTaskNames],
210
+ input?: TaskInput<TTasks[TTaskNames[number]]>
211
+ ): Promise<SequenceOutputs<TTasks, TTaskNames>> {
212
+ if (taskNames.length === 0) {
213
+ throw new ConfigError('runSequence requires at least one task.');
214
+ }
215
+
216
+ const session = await this.createSession();
217
+ const context = this.createContext(actor, session);
218
+ const outputs: unknown[] = [];
219
+
220
+ try {
221
+ for (const [index, taskName] of taskNames.entries()) {
222
+ const output = await this.executeTask(actor, context, taskName, input as never, index === 0);
223
+ outputs.push(output);
224
+ }
225
+
226
+ return outputs as SequenceOutputs<TTasks, TTaskNames>;
227
+ } finally {
228
+ await this.closeSession(session);
229
+ }
230
+ }
231
+
232
+ async runRegisteredSequence<TOutput = unknown, TInput = unknown>(
233
+ actorId: string,
234
+ taskNames: string[],
235
+ input?: TInput
236
+ ): Promise<TOutput[]> {
237
+ const actor = this.getRegisteredActor(actorId) as Actor<ActorTaskMap>;
238
+ return this.runSequence(actor, taskNames, input as never) as Promise<TOutput[]>;
239
+ }
240
+ }
@@ -0,0 +1,5 @@
1
+ import type { Actor, ActorTaskMap } from './Actor.js';
2
+
3
+ export function defineActor<TTasks extends ActorTaskMap>(actor: Actor<TTasks>): Actor<TTasks> {
4
+ return actor;
5
+ }
@@ -0,0 +1,5 @@
1
+ export * from './Actor.js';
2
+ export * from './ActorContext.js';
3
+ export * from './ActorRegistry.js';
4
+ export * from './ActorRunner.js';
5
+ export * from './defineActor.js';
@@ -0,0 +1,7 @@
1
+ import { AutomationError, type AutomationErrorMeta } from './AutomationError.js';
2
+
3
+ export class AuthError extends AutomationError {
4
+ constructor(message: string, meta: AutomationErrorMeta = {}) {
5
+ super(message, meta);
6
+ }
7
+ }
@@ -0,0 +1,26 @@
1
+ export interface AutomationErrorMeta {
2
+ actorId?: string;
3
+ taskName?: string;
4
+ url?: string;
5
+ screenshotPath?: string;
6
+ cause?: unknown;
7
+ details?: Record<string, unknown>;
8
+ }
9
+
10
+ export class AutomationError extends Error {
11
+ readonly actorId: string | undefined;
12
+ readonly taskName: string | undefined;
13
+ readonly url: string | undefined;
14
+ readonly screenshotPath: string | undefined;
15
+ readonly details: Record<string, unknown> | undefined;
16
+
17
+ constructor(message: string, meta: AutomationErrorMeta = {}) {
18
+ super(message, meta.cause instanceof Error ? { cause: meta.cause } : undefined);
19
+ this.name = new.target.name;
20
+ this.actorId = meta.actorId;
21
+ this.taskName = meta.taskName;
22
+ this.url = meta.url;
23
+ this.screenshotPath = meta.screenshotPath;
24
+ this.details = meta.details;
25
+ }
26
+ }
@@ -0,0 +1,7 @@
1
+ import { AutomationError, type AutomationErrorMeta } from './AutomationError.js';
2
+
3
+ export class ConfigError extends AutomationError {
4
+ constructor(message: string, meta: AutomationErrorMeta = {}) {
5
+ super(message, meta);
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ import { AutomationError, type AutomationErrorMeta } from './AutomationError.js';
2
+
3
+ export class ExtractionError extends AutomationError {
4
+ constructor(message: string, meta: AutomationErrorMeta = {}) {
5
+ super(message, meta);
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ import { AutomationError, type AutomationErrorMeta } from './AutomationError.js';
2
+
3
+ export class NavigationError extends AutomationError {
4
+ constructor(message: string, meta: AutomationErrorMeta = {}) {
5
+ super(message, meta);
6
+ }
7
+ }
@@ -0,0 +1,10 @@
1
+ import { AutomationError, type AutomationErrorMeta } from './AutomationError.js';
2
+
3
+ export class SelectorError extends AutomationError {
4
+ readonly selector: string | undefined;
5
+
6
+ constructor(message: string, selector?: string, meta: AutomationErrorMeta = {}) {
7
+ super(message, meta);
8
+ this.selector = selector;
9
+ }
10
+ }
@@ -0,0 +1,6 @@
1
+ export * from './AutomationError.js';
2
+ export * from './AuthError.js';
3
+ export * from './ConfigError.js';
4
+ export * from './ExtractionError.js';
5
+ export * from './NavigationError.js';
6
+ export * from './SelectorError.js';