first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as run from './run.operation';
|
||||
|
||||
export { run };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Run',
|
||||
value: 'run',
|
||||
description: 'Run an Airtop agent',
|
||||
action: 'Run an agent',
|
||||
},
|
||||
],
|
||||
default: 'run',
|
||||
},
|
||||
...run.description,
|
||||
];
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IDataObject, ResourceMapperField } from 'n8n-workflow';
|
||||
|
||||
export interface AgentSchemaProperty {
|
||||
description?: string;
|
||||
type: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
export interface AirtopAgentSchema {
|
||||
$schema?: string;
|
||||
additionalProperties?: boolean;
|
||||
properties?: Record<string, AgentSchemaProperty>;
|
||||
required?: string[];
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
}
|
||||
|
||||
export interface AirtopAgentResponse extends IDataObject {
|
||||
agent: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
versionData: {
|
||||
configVarsSchema?: AirtopAgentSchema;
|
||||
resultSchema?: AirtopAgentSchema;
|
||||
};
|
||||
webhookId: string;
|
||||
}
|
||||
|
||||
export interface AgentsListResponse extends IDataObject {
|
||||
agents: Array<AirtopAgentResponse['agent']>;
|
||||
}
|
||||
|
||||
export interface AgentParametersInput {
|
||||
value?: IDataObject;
|
||||
schema: ResourceMapperField[];
|
||||
}
|
||||
|
||||
export interface AgentInvocationResponse extends IDataObject {
|
||||
invocationId: string;
|
||||
}
|
||||
|
||||
export interface AgentResultResponse extends IDataObject {
|
||||
status: 'Completed' | 'Running' | 'Failed' | 'Unknown';
|
||||
output?: IDataObject;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { IDataObject, IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { AgentParametersInput, AgentResultResponse, AirtopAgentResponse } from './agent.types';
|
||||
import { AIRTOP_HOOKS_BASE_URL, BASE_URL_V2, ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
/**
|
||||
* Gets the agent input parameters schema.
|
||||
*/
|
||||
export async function getAgentDetails(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
agentId: string,
|
||||
): Promise<AirtopAgentResponse> {
|
||||
return await apiRequest.call<
|
||||
IExecuteFunctions | ILoadOptionsFunctions,
|
||||
['GET', string],
|
||||
Promise<AirtopAgentResponse>
|
||||
>(this, 'GET', `${BASE_URL_V2}/agents/${agentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the agent parameters with the schema.
|
||||
*/
|
||||
export function validateAgentParameters(
|
||||
this: IExecuteFunctions,
|
||||
params: AgentParametersInput,
|
||||
): IDataObject {
|
||||
const inputParameters = params?.value ?? {};
|
||||
const requiredParameters = (params?.schema ?? [])
|
||||
.filter((field) => field.required)
|
||||
.map((field) => field.id);
|
||||
// check for empty values on required fields
|
||||
const missingRequiredParameters = requiredParameters.filter((reqParam) => {
|
||||
return (
|
||||
inputParameters[reqParam] === undefined ||
|
||||
inputParameters[reqParam] === null ||
|
||||
inputParameters[reqParam] === ''
|
||||
);
|
||||
});
|
||||
if (missingRequiredParameters.length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Missing required parameters: ${missingRequiredParameters.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { configVars: inputParameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the agent status
|
||||
*/
|
||||
export async function getAgentStatus(
|
||||
this: IExecuteFunctions,
|
||||
agentId: string,
|
||||
invocationId: string,
|
||||
): Promise<AgentResultResponse> {
|
||||
const resultUrl = `${AIRTOP_HOOKS_BASE_URL}/agents/${agentId}/invocations/${invocationId}/result`;
|
||||
return await apiRequest.call<IExecuteFunctions, ['GET', string], Promise<AgentResultResponse>>(
|
||||
this,
|
||||
'GET',
|
||||
resultUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the agent execution status until it's completed or fails.
|
||||
*/
|
||||
export async function pollAgentStatus(
|
||||
this: IExecuteFunctions,
|
||||
agentId: string,
|
||||
invocationId: string,
|
||||
timeoutSeconds: number,
|
||||
): Promise<AgentResultResponse | undefined> {
|
||||
const airtopNode = this.getNode();
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let response: AgentResultResponse | undefined;
|
||||
|
||||
this.logger.info(`[${airtopNode.name}] Polling agent status for invocationId: ${invocationId}`);
|
||||
|
||||
while (true) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
throwOperationErrorIf(elapsed >= timeoutMs, ERROR_MESSAGES.TIMEOUT_REACHED, airtopNode);
|
||||
|
||||
response = await getAgentStatus.call(this, agentId, invocationId);
|
||||
|
||||
if (response?.output || response?.error) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Wait one second before next poll
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an operation error if the statement is true.
|
||||
*/
|
||||
export function throwOperationErrorIf(statement: boolean, message: string, node: INode) {
|
||||
if (statement) {
|
||||
throw new NodeOperationError(node, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { AgentInvocationResponse, AgentParametersInput } from './agent.types';
|
||||
import {
|
||||
getAgentDetails,
|
||||
pollAgentStatus,
|
||||
throwOperationErrorIf,
|
||||
validateAgentParameters,
|
||||
} from './agent.utils';
|
||||
import { AGENT_MIN_TIMEOUT_SECONDS, AIRTOP_HOOKS_BASE_URL, ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Agent',
|
||||
name: 'agentId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
description:
|
||||
'The Airtop agent to run. Visit <a href="https://portal.airtop.ai/agents" target="_blank">Airtop Agents</a> for more information.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select an Agent...',
|
||||
typeOptions: {
|
||||
searchListMethod: 'listSearchAgents',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. agent_abc123',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '.+',
|
||||
errorMessage: 'Agent ID cannot be empty',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Agent Parameters',
|
||||
name: 'agentParameters',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['agentId.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'agentsResourceMapping',
|
||||
mode: 'map',
|
||||
supportAutoMap: false,
|
||||
addAllFields: true,
|
||||
noFieldsError: 'No input parameters found for the selected agent',
|
||||
multiKeyMatch: false,
|
||||
allowEmptyValues: true,
|
||||
fieldWords: {
|
||||
singular: 'parameter',
|
||||
plural: 'parameters',
|
||||
},
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
},
|
||||
hide: {
|
||||
agentId: [''],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Await Agent',
|
||||
name: 'awaitExecution',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to wait for the agent to complete its execution',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
type: 'number',
|
||||
default: 600,
|
||||
description: 'Timeout in seconds to wait for the agent to finish',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
awaitExecution: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const airtopNode = this.getNode();
|
||||
const agentId = this.getNodeParameter('agentId', index, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const agentParameters = this.getNodeParameter(
|
||||
'agentParameters',
|
||||
index,
|
||||
{},
|
||||
) as AgentParametersInput;
|
||||
|
||||
const awaitExecution = this.getNodeParameter('awaitExecution', index, true) as boolean;
|
||||
|
||||
const timeout = this.getNodeParameter('timeout', index, 600) as number;
|
||||
|
||||
// Validate timeout
|
||||
throwOperationErrorIf(
|
||||
timeout < AGENT_MIN_TIMEOUT_SECONDS,
|
||||
ERROR_MESSAGES.AGENT_TIMEOUT_INVALID,
|
||||
airtopNode,
|
||||
);
|
||||
|
||||
// Convert fixedCollection parameters to API format
|
||||
const validatedAgentParameters = validateAgentParameters.call(this, agentParameters);
|
||||
|
||||
const { webhookId } = await getAgentDetails.call(this, agentId);
|
||||
const invokeUrl = `${AIRTOP_HOOKS_BASE_URL}/agents/${agentId}/webhooks/${webhookId}`;
|
||||
|
||||
const invocationResponse = await apiRequest.call<
|
||||
IExecuteFunctions,
|
||||
['POST', string, IDataObject],
|
||||
Promise<AgentInvocationResponse>
|
||||
>(this, 'POST', invokeUrl, validatedAgentParameters);
|
||||
|
||||
const invocationId = invocationResponse.invocationId;
|
||||
throwOperationErrorIf(
|
||||
!invocationId,
|
||||
"No 'invocationId' received from agent webhook response",
|
||||
airtopNode,
|
||||
);
|
||||
|
||||
if (!awaitExecution) {
|
||||
return this.helpers.returnJsonArray({
|
||||
invocationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Poll for agent's execution status
|
||||
const result = await pollAgentStatus.call(this, agentId, invocationId, timeout);
|
||||
throwOperationErrorIf(
|
||||
Boolean(result?.error),
|
||||
`${result?.error ?? 'Unknown error'}. Agent Invocation ID: ${invocationId}`,
|
||||
airtopNode,
|
||||
);
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
invocationId,
|
||||
status: result?.status ?? 'Unknown',
|
||||
output: result?.output ?? {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const SESSION_MODE = {
|
||||
NEW: 'new',
|
||||
EXISTING: 'existing',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Session related fields
|
||||
*/
|
||||
|
||||
export const sessionIdField: INodeProperties = {
|
||||
displayName: 'Session ID',
|
||||
name: 'sessionId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json["sessionId"] }}',
|
||||
description:
|
||||
'The ID of the <a href="https://docs.airtop.ai/guides/how-to/creating-a-session" target="_blank">Session</a> to use',
|
||||
};
|
||||
|
||||
export const windowIdField: INodeProperties = {
|
||||
displayName: 'Window ID',
|
||||
name: 'windowId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json["windowId"] }}',
|
||||
description:
|
||||
'The ID of the <a href="https://docs.airtop.ai/guides/how-to/creating-a-session#windows" target="_blank">Window</a> to use',
|
||||
};
|
||||
|
||||
export const profileNameField: INodeProperties = {
|
||||
displayName: 'Profile Name',
|
||||
name: 'profileName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The name of the Airtop profile to load or create',
|
||||
hint: '<a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Learn more</a> about Airtop profiles',
|
||||
placeholder: 'e.g. my-x-profile',
|
||||
};
|
||||
|
||||
export const urlField: INodeProperties = {
|
||||
displayName: 'URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. https://google.com',
|
||||
description: 'URL to load in the window',
|
||||
};
|
||||
|
||||
/**
|
||||
* Extraction related fields
|
||||
*/
|
||||
|
||||
export const outputSchemaField: INodeProperties = {
|
||||
displayName: 'JSON Output Schema',
|
||||
name: 'outputSchema',
|
||||
description: 'JSON schema defining the structure of the output',
|
||||
hint: 'If you want to force your output to be JSON, provide a valid JSON schema describing the output. You can generate one automatically in the <a href="https://portal.airtop.ai/" target="_blank">Airtop API Playground</a>.',
|
||||
type: 'json',
|
||||
default: '',
|
||||
};
|
||||
|
||||
export const parseJsonOutputField: INodeProperties = {
|
||||
displayName: 'Parse JSON Output',
|
||||
name: 'parseJsonOutput',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
"Whether to parse the model's response to JSON in the output. Requires the 'JSON Output Schema' parameter to be set.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Interaction related fields
|
||||
*/
|
||||
|
||||
export const elementDescriptionField: INodeProperties = {
|
||||
displayName: 'Element Description',
|
||||
name: 'elementDescription',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'A specific description of the element to execute the interaction on',
|
||||
placeholder: 'e.g. the search box at the top of the page',
|
||||
};
|
||||
|
||||
export function getSessionModeFields(resource: string, operations: string[]): INodeProperties[] {
|
||||
return [
|
||||
{
|
||||
displayName: 'Session Mode',
|
||||
name: 'sessionMode',
|
||||
type: 'options',
|
||||
default: 'existing',
|
||||
description: 'Choose between creating a new session or using an existing one',
|
||||
options: [
|
||||
{
|
||||
name: 'Automatically Create Session',
|
||||
description: 'Automatically create a new session and window for this operation',
|
||||
value: SESSION_MODE.NEW,
|
||||
},
|
||||
{
|
||||
name: 'Use Existing Session',
|
||||
description: 'Use an existing session and window for this operation',
|
||||
value: SESSION_MODE.EXISTING,
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
operation: operations,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.EXISTING],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.EXISTING],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...urlField,
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.NEW],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...profileNameField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.NEW],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Auto-Terminate Session',
|
||||
name: 'autoTerminateSession',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to terminate the session after the operation is complete. When disabled, you must manually terminate the session. By default, idle sessions timeout after 10 minutes',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.NEW],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const includeHiddenElementsField: INodeProperties = {
|
||||
displayName: 'Include Hidden Elements',
|
||||
name: 'includeHiddenElements',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to include hidden elements in the interaction',
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, IDataObject } from 'n8n-workflow';
|
||||
|
||||
import type { IAirtopNodeExecutionData, IAirtopResponse } from '../../transport/types';
|
||||
|
||||
/**
|
||||
* Parse JSON when the 'Parse JSON Output' parameter is enabled
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @param response - The Airtop API response to parse
|
||||
* @returns The parsed output
|
||||
*/
|
||||
export function parseJsonIfPresent(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
response: IAirtopResponse,
|
||||
): IAirtopResponse {
|
||||
const parseJsonOutput = this.getNodeParameter('additionalFields.parseJsonOutput', index, false);
|
||||
const outputJsonSchema = this.getNodeParameter(
|
||||
'additionalFields.outputSchema',
|
||||
index,
|
||||
'',
|
||||
) as string;
|
||||
|
||||
if (!parseJsonOutput || !outputJsonSchema.startsWith('{')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = JSON.parse(response.data?.modelResponse ?? '') as IDataObject;
|
||||
return {
|
||||
sessionId: response.sessionId,
|
||||
windowId: response.windowId,
|
||||
output,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), 'Output is not a valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the output when used as a tool
|
||||
* @param output - The output to clean up
|
||||
* @returns The cleaned up output
|
||||
*/
|
||||
export function cleanOutputForToolUse(output: IAirtopNodeExecutionData[]) {
|
||||
const getOutput = (executionData: IAirtopNodeExecutionData) => {
|
||||
// Return error message
|
||||
if (executionData.json?.errors?.length) {
|
||||
const errorMessage = executionData.json?.errors[0].message as string;
|
||||
return {
|
||||
output: `Error: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Return output parsed from JSON
|
||||
if (executionData.json?.output) {
|
||||
return executionData.json?.output;
|
||||
}
|
||||
|
||||
// Return model response
|
||||
if (executionData.json?.data?.modelResponse) {
|
||||
return {
|
||||
output: executionData.json?.data?.modelResponse,
|
||||
};
|
||||
}
|
||||
|
||||
// Return everything else
|
||||
return {
|
||||
output: { ...(executionData.json?.data ?? {}) },
|
||||
};
|
||||
};
|
||||
|
||||
return output.map((executionData) => ({
|
||||
...executionData,
|
||||
json: {
|
||||
...getOutput(executionData),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IExecuteFunctions, IDataObject } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateSessionAndWindowId,
|
||||
createSessionAndWindow,
|
||||
shouldCreateNewSession,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponse } from '../../transport/types';
|
||||
|
||||
/**
|
||||
* Execute the node operation. Creates and terminates a new session if needed.
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @param request - The request to execute
|
||||
* @returns The response from the request
|
||||
*/
|
||||
export async function executeRequestWithSessionManagement(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
request: {
|
||||
method: 'POST' | 'DELETE';
|
||||
path: string;
|
||||
body: IDataObject;
|
||||
},
|
||||
): Promise<IAirtopResponse> {
|
||||
let airtopSessionId = '';
|
||||
try {
|
||||
const { sessionId, windowId } = shouldCreateNewSession.call(this, index)
|
||||
? await createSessionAndWindow.call(this, index)
|
||||
: validateSessionAndWindowId.call(this, index);
|
||||
airtopSessionId = sessionId;
|
||||
|
||||
const shouldTerminateSession = this.getNodeParameter('autoTerminateSession', index, false);
|
||||
|
||||
const endpoint = request.path.replace('{sessionId}', sessionId).replace('{windowId}', windowId);
|
||||
const response = await apiRequest.call(this, request.method, endpoint, request.body);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
if (shouldTerminateSession) {
|
||||
await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`);
|
||||
this.logger.info(`[${this.getNode().name}] Session terminated.`);
|
||||
return response;
|
||||
}
|
||||
|
||||
return { sessionId, windowId, ...response };
|
||||
} catch (error) {
|
||||
// terminate session on error
|
||||
if (airtopSessionId) {
|
||||
await apiRequest.call(this, 'DELETE', `/sessions/${airtopSessionId}`);
|
||||
this.logger.info(`[${this.getNode().name}] Session terminated.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as getPaginated from './getPaginated.operation';
|
||||
import * as query from './query.operation';
|
||||
import * as scrape from './scrape.operation';
|
||||
import { getSessionModeFields } from '../common/fields';
|
||||
|
||||
export { getPaginated, query, scrape };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Query Page',
|
||||
value: 'query',
|
||||
description: 'Query a page to extract data or ask a question given the data on the page',
|
||||
action: 'Query page',
|
||||
},
|
||||
{
|
||||
name: 'Query Page with Pagination',
|
||||
value: 'getPaginated',
|
||||
description: 'Extract content from paginated or dynamically loaded pages',
|
||||
action: 'Query page with pagination',
|
||||
},
|
||||
{
|
||||
name: 'Smart Scrape',
|
||||
value: 'scrape',
|
||||
description: 'Scrape a page and return the data as markdown',
|
||||
action: 'Smart scrape page',
|
||||
},
|
||||
],
|
||||
default: 'getPaginated',
|
||||
},
|
||||
...getSessionModeFields('extraction', ['getPaginated', 'query', 'scrape']),
|
||||
...getPaginated.description,
|
||||
...query.description,
|
||||
];
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { outputSchemaField, parseJsonOutputField } from '../common/fields';
|
||||
import { parseJsonIfPresent } from '../common/output.utils';
|
||||
import { executeRequestWithSessionManagement } from '../common/session.utils';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
operation: ['getPaginated'],
|
||||
},
|
||||
},
|
||||
description: 'The prompt to extract data from the pages',
|
||||
placeholder: 'e.g. Extract all the product names and prices',
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
operation: ['getPaginated'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
...outputSchemaField,
|
||||
},
|
||||
{
|
||||
...parseJsonOutputField,
|
||||
},
|
||||
{
|
||||
displayName: 'Interaction Mode',
|
||||
name: 'interactionMode',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'The strategy for interacting with the page',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
description: 'Automatically choose the most cost-effective mode',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
name: 'Accurate',
|
||||
description: 'Prioritize accuracy over cost',
|
||||
value: 'accurate',
|
||||
},
|
||||
{
|
||||
name: 'Cost Efficient',
|
||||
description: 'Minimize costs while ensuring effectiveness',
|
||||
value: 'cost-efficient',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Pagination Mode',
|
||||
name: 'paginationMode',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'The pagination approach to use',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
description: 'Look for pagination links first, then try infinite scrolling',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
name: 'Paginated',
|
||||
description: 'Only use pagination links',
|
||||
value: 'paginated',
|
||||
},
|
||||
{
|
||||
name: 'Infinite Scroll',
|
||||
description: 'Scroll the page to load more content',
|
||||
value: 'infinite-scroll',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const prompt = this.getNodeParameter('prompt', index, '') as string;
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
|
||||
const configFields = ['paginationMode', 'interactionMode', 'outputSchema'];
|
||||
const configuration = configFields.reduce(
|
||||
(config, key) => (additionalFields[key] ? { ...config, [key]: additionalFields[key] } : config),
|
||||
{},
|
||||
);
|
||||
|
||||
const result = await executeRequestWithSessionManagement.call(this, index, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/paginated-extraction',
|
||||
body: {
|
||||
prompt,
|
||||
configuration,
|
||||
},
|
||||
});
|
||||
|
||||
const nodeOutput = parseJsonIfPresent.call(this, index, result);
|
||||
return this.helpers.returnJsonArray(nodeOutput);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { outputSchemaField, parseJsonOutputField } from '../common/fields';
|
||||
import { parseJsonIfPresent } from '../common/output.utils';
|
||||
import { executeRequestWithSessionManagement } from '../common/session.utils';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. Is there a login form in this page?',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
operation: ['query'],
|
||||
},
|
||||
},
|
||||
description: 'The prompt to query the page content',
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
operation: ['query'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
...outputSchemaField,
|
||||
},
|
||||
{
|
||||
...parseJsonOutputField,
|
||||
},
|
||||
{
|
||||
displayName: 'Include Visual Analysis',
|
||||
name: 'includeVisualAnalysis',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to analyze the web page visually when fulfilling the request',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const prompt = this.getNodeParameter('prompt', index, '') as string;
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index, {});
|
||||
const outputSchema = additionalFields.outputSchema;
|
||||
const includeVisualAnalysis = additionalFields.includeVisualAnalysis;
|
||||
|
||||
const result = await executeRequestWithSessionManagement.call(this, index, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/page-query',
|
||||
body: {
|
||||
prompt,
|
||||
configuration: {
|
||||
experimental: {
|
||||
includeVisualAnalysis: includeVisualAnalysis ? 'enabled' : 'disabled',
|
||||
},
|
||||
...(outputSchema ? { outputSchema } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const nodeOutput = parseJsonIfPresent.call(this, index, result);
|
||||
return this.helpers.returnJsonArray(nodeOutput);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { executeRequestWithSessionManagement } from '../common/session.utils';
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const result = await executeRequestWithSessionManagement.call(this, index, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/scrape-content',
|
||||
body: {},
|
||||
});
|
||||
|
||||
return this.helpers.returnJsonArray({ ...result });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as deleteFile from './delete.operation';
|
||||
import * as get from './get.operation';
|
||||
import * as getMany from './getMany.operation';
|
||||
import * as load from './load.operation';
|
||||
import * as upload from './upload.operation';
|
||||
|
||||
export { deleteFile, get, getMany, upload, load };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'deleteFile',
|
||||
description: 'Delete an uploaded file',
|
||||
action: 'Delete a file',
|
||||
},
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
description: 'Get a details of an uploaded file',
|
||||
action: 'Get a file',
|
||||
},
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'getMany',
|
||||
description: 'Get details of multiple uploaded files',
|
||||
action: 'Get many files',
|
||||
},
|
||||
{
|
||||
name: 'Load',
|
||||
value: 'load',
|
||||
description: 'Load a file into a session',
|
||||
action: 'Load a file',
|
||||
},
|
||||
{
|
||||
name: 'Upload',
|
||||
value: 'upload',
|
||||
description: 'Upload a file into a session',
|
||||
action: 'Upload a file',
|
||||
},
|
||||
],
|
||||
default: 'getMany',
|
||||
},
|
||||
...deleteFile.description,
|
||||
...get.description,
|
||||
...getMany.description,
|
||||
...load.description,
|
||||
...upload.description,
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'ID of the file to delete',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['deleteFile'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', index, '') as string;
|
||||
|
||||
if (!fileId) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID'),
|
||||
);
|
||||
}
|
||||
|
||||
await apiRequest.call(this, 'DELETE', `/files/${fileId}`);
|
||||
|
||||
return this.helpers.returnJsonArray({ data: { message: 'File deleted successfully' } });
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponseWithFiles } from '../../transport/types';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['get'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'ID of the file to retrieve',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Output Binary File',
|
||||
name: 'outputBinaryFile',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to output the file in binary format if the file is ready for download',
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', index, '') as string;
|
||||
const outputBinaryFile = this.getNodeParameter('outputBinaryFile', index, false);
|
||||
|
||||
if (!fileId) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID'),
|
||||
);
|
||||
}
|
||||
|
||||
const response = (await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/files/${fileId}`,
|
||||
)) as IAirtopResponseWithFiles;
|
||||
|
||||
const { fileName = '', downloadUrl = '', status = '' } = response?.data ?? {};
|
||||
|
||||
// Handle binary file output
|
||||
if (outputBinaryFile && downloadUrl && status === 'available') {
|
||||
const buffer = (await this.helpers.httpRequest({
|
||||
url: downloadUrl,
|
||||
json: false,
|
||||
encoding: 'arraybuffer',
|
||||
})) as Buffer;
|
||||
const file = await this.helpers.prepareBinaryData(buffer, fileName);
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
...response,
|
||||
},
|
||||
binary: { data: file },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({ ...response });
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { requestAllFiles } from './helpers';
|
||||
import { wrapData } from '../../../../utils/utilities';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponse } from '../../transport/types';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['getMany'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['getMany'],
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 100,
|
||||
},
|
||||
default: 10,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName: 'Session IDs',
|
||||
name: 'sessionIds',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Comma-separated list of <a href="https://docs.airtop.ai/api-reference/airtop-api/sessions/create" target="_blank">Session IDs</a> to filter files by. When empty, all files from all sessions will be returned.',
|
||||
placeholder: 'e.g. 6aac6f73-bd89-4a76-ab32-5a6c422e8b0b, a13c6f73-bd89-4a76-ab32-5a6c422e8224',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Output Files in Single Item',
|
||||
name: 'outputSingleItem',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to output one item containing all files or output each file as a separate item',
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnAll = this.getNodeParameter('returnAll', index, false);
|
||||
const limit = this.getNodeParameter('limit', index, 10);
|
||||
const sessionIds = this.getNodeParameter('sessionIds', index, '') as string;
|
||||
const outputSingleItem = this.getNodeParameter('outputSingleItem', index, true) as boolean;
|
||||
|
||||
const endpoint = '/files';
|
||||
let files: IAirtopResponse[] = [];
|
||||
|
||||
const responseData = returnAll
|
||||
? await requestAllFiles.call(this, sessionIds)
|
||||
: await apiRequest.call(this, 'GET', endpoint, {}, { sessionIds, limit });
|
||||
|
||||
if (responseData.data?.files && Array.isArray(responseData.data?.files)) {
|
||||
files = responseData.data.files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the files in one of two formats:
|
||||
* - A single JSON item containing an array of all files (when outputSingleItem = true)
|
||||
* - Multiple JSON items, one per file
|
||||
* Data structure reference: https://docs.n8n.io/courses/level-two/chapter-1/#data-structure-of-n8n
|
||||
*/
|
||||
if (outputSingleItem) {
|
||||
return this.helpers.returnJsonArray({ ...responseData });
|
||||
}
|
||||
return wrapData(files);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import pick from 'lodash/pick';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants';
|
||||
import { waitForSessionEvent } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type {
|
||||
IAirtopFileInputRequest,
|
||||
IAirtopResponseWithFiles,
|
||||
IAirtopServerEvent,
|
||||
} from '../../transport/types';
|
||||
|
||||
/**
|
||||
* Fetches all files from the Airtop API using pagination
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param sessionIds - Comma-separated string of session IDs to filter files by
|
||||
* @returns Promise resolving to a response object containing the complete array of files
|
||||
*/
|
||||
export async function requestAllFiles(
|
||||
this: IExecuteFunctions,
|
||||
sessionIds: string,
|
||||
): Promise<IAirtopResponseWithFiles> {
|
||||
const endpoint = '/files';
|
||||
let hasMore = true;
|
||||
let currentOffset = 0;
|
||||
const limit = 100;
|
||||
const files: IAirtopResponseWithFiles['data']['files'] = [];
|
||||
let responseData: IAirtopResponseWithFiles;
|
||||
|
||||
while (hasMore) {
|
||||
// request files
|
||||
responseData = (await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
endpoint,
|
||||
{},
|
||||
{ offset: currentOffset, limit, sessionIds },
|
||||
)) as IAirtopResponseWithFiles;
|
||||
// add files to the array
|
||||
if (responseData.data?.files && Array.isArray(responseData.data?.files)) {
|
||||
files.push.apply(files, responseData.data.files);
|
||||
}
|
||||
// check if there are more files
|
||||
hasMore = Boolean(responseData.data?.pagination?.hasMore);
|
||||
currentOffset += limit;
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
files,
|
||||
pagination: {
|
||||
hasMore,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the Airtop API until a file reaches "available" status or times out
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param fileId - The unique identifier of the file to poll
|
||||
* @param timeout - Maximum time in milliseconds to wait before failing (defaults to OPERATION_TIMEOUT)
|
||||
* @param intervalSeconds - Time in seconds to wait between polling attempts (defaults to 1)
|
||||
* @returns Promise resolving to the file ID when the file is available
|
||||
* @throws NodeApiError if the operation times out or API request fails
|
||||
*/
|
||||
export async function pollFileUntilAvailable(
|
||||
this: IExecuteFunctions,
|
||||
fileId: string,
|
||||
timeout = OPERATION_TIMEOUT,
|
||||
intervalSeconds = 1,
|
||||
): Promise<string> {
|
||||
let fileStatus = '';
|
||||
const startTime = Date.now();
|
||||
while (fileStatus !== 'available') {
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
if (elapsedTime >= timeout) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `/files/${fileId}`);
|
||||
fileStatus = response.data?.status as string;
|
||||
// Wait before the next polling attempt
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
|
||||
}
|
||||
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file entry in Airtop, uploads the file content, and waits until processing completes
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param fileName - Name to assign to the uploaded file
|
||||
* @param fileBuffer - Buffer containing the binary file data to upload
|
||||
* @param fileType - Classification of the file in Airtop (e.g., 'customer_upload')
|
||||
* @param pollingFunction - Function to use for checking file availability (defaults to pollFileUntilAvailable)
|
||||
* @returns Promise resolving to the file ID once upload is complete and file is available
|
||||
* @throws NodeApiError if file creation, upload, or polling fails
|
||||
*/
|
||||
export async function createAndUploadFile(
|
||||
this: IExecuteFunctions,
|
||||
fileName: string,
|
||||
fileBuffer: Buffer,
|
||||
fileType: string,
|
||||
pollingFunction = pollFileUntilAvailable,
|
||||
): Promise<string> {
|
||||
// Create file entry
|
||||
const createResponse = await apiRequest.call(this, 'POST', '/files', {
|
||||
fileName,
|
||||
fileType,
|
||||
});
|
||||
|
||||
const fileId = createResponse.data?.id;
|
||||
const uploadUrl = createResponse.data?.uploadUrl as string;
|
||||
|
||||
if (!fileId || !uploadUrl) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: 'Failed to create file entry: missing file ID or upload URL',
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
|
||||
// Upload the file
|
||||
await this.helpers.httpRequest({
|
||||
method: 'PUT',
|
||||
url: uploadUrl,
|
||||
body: fileBuffer,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
|
||||
// Poll until the file is available
|
||||
return await pollingFunction.call(this, fileId as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a file to be ready in a session by polling file's information
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param sessionId - ID of the session to check for file availability
|
||||
* @param fileId - ID of the file
|
||||
* @param timeout - Maximum time in milliseconds to wait before failing (defaults to OPERATION_TIMEOUT)
|
||||
* @returns Promise that resolves when a file in the session becomes available
|
||||
* @throws NodeApiError if the timeout is reached before a file becomes available
|
||||
*/
|
||||
export async function waitForFileInSession(
|
||||
this: IExecuteFunctions,
|
||||
sessionId: string,
|
||||
fileId: string,
|
||||
timeout = OPERATION_TIMEOUT,
|
||||
): Promise<void> {
|
||||
// Wait for a "file_upload_status" event with status "available" or "upload_failed"
|
||||
const condition = (sessionEvent: IAirtopServerEvent) => {
|
||||
const { event, status } = sessionEvent;
|
||||
return (
|
||||
sessionEvent.fileId === fileId &&
|
||||
event === 'file_upload_status' &&
|
||||
(status === 'available' || status === 'upload_failed')
|
||||
);
|
||||
};
|
||||
|
||||
const event = await waitForSessionEvent.call(this, sessionId, condition, timeout);
|
||||
if (event.status === 'upload_failed') {
|
||||
const error = new NodeApiError(this.getNode(), {
|
||||
message: event.eventData?.error ?? `Upload failed for File ID: ${fileId}`,
|
||||
code: 500,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a file with a session and waits until the file is ready for use
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param fileId - ID of the file to associate with the session
|
||||
* @param sessionId - ID of the session to add the file to
|
||||
* @param pollingFunction - Function to use for checking file availability in session (defaults to waitForFileInSession)
|
||||
* @returns Promise that resolves when the file is ready for use in the session
|
||||
*/
|
||||
export async function pushFileToSession(
|
||||
this: IExecuteFunctions,
|
||||
fileId: string,
|
||||
sessionId: string,
|
||||
pollingFunction = waitForFileInSession,
|
||||
): Promise<void> {
|
||||
// Push file into session
|
||||
await apiRequest.call(this, 'POST', `/files/${fileId}/push`, { sessionIds: [sessionId] });
|
||||
await pollingFunction.call(this, sessionId, fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates a file upload input in a specific window within a session
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param fileId - ID of the file to use for the input
|
||||
* @param windowId - ID of the window where the file input will be triggered
|
||||
* @param sessionId - ID of the session containing the window
|
||||
* @returns Promise that resolves when the file input has been triggered
|
||||
*/
|
||||
export async function triggerFileInput(
|
||||
this: IExecuteFunctions,
|
||||
request: IAirtopFileInputRequest,
|
||||
): Promise<void> {
|
||||
await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${request.sessionId}/windows/${request.windowId}/file-input`,
|
||||
pick(request, ['fileId', 'elementDescription', 'includeHiddenElements']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file Buffer from either a URL or binary data
|
||||
* This function supports two source types:
|
||||
* - URL: Downloads the file from the specified URL and returns it as a Buffer
|
||||
* - Binary: Retrieves binary data from the workflow's binary data storage
|
||||
*
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param source - Source type, either 'url' or 'binary'
|
||||
* @param value - Either a URL string or binary data property name depending on source type
|
||||
* @param itemIndex - Index of the workflow item to get binary data from (when source is 'binary')
|
||||
* @returns Promise resolving to a Buffer containing the file data
|
||||
* @throws NodeApiError if the source type is unsupported or retrieval fails
|
||||
*/
|
||||
export async function createFileBuffer(
|
||||
this: IExecuteFunctions,
|
||||
source: string,
|
||||
value: string,
|
||||
itemIndex: number,
|
||||
): Promise<Buffer> {
|
||||
if (source === 'url') {
|
||||
const buffer = (await this.helpers.httpRequest({
|
||||
url: value,
|
||||
json: false,
|
||||
encoding: 'arraybuffer',
|
||||
})) as Buffer;
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
if (source === 'binary') {
|
||||
const binaryData = await this.helpers.getBinaryDataBuffer(itemIndex, value);
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: `Unsupported source type: ${source}. Please use 'url' or 'binary'`,
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { pushFileToSession, triggerFileInput } from './helpers';
|
||||
import {
|
||||
sessionIdField,
|
||||
windowIdField,
|
||||
elementDescriptionField,
|
||||
includeHiddenElementsField,
|
||||
} from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['load'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...sessionIdField,
|
||||
description: 'The session ID to load the file into',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
description: 'The window ID to trigger the file input in',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'ID of the file to load into the session',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...elementDescriptionField,
|
||||
description: 'Optional description of the file input to interact with',
|
||||
placeholder: 'e.g. the file upload selection box',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...includeHiddenElementsField,
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', index, '') as string;
|
||||
const sessionId = this.getNodeParameter('sessionId', index, '') as string;
|
||||
const windowId = this.getNodeParameter('windowId', index, '') as string;
|
||||
const elementDescription = this.getNodeParameter('elementDescription', index, '') as string;
|
||||
const includeHiddenElements = this.getNodeParameter(
|
||||
'includeHiddenElements',
|
||||
index,
|
||||
false,
|
||||
) as boolean;
|
||||
|
||||
try {
|
||||
await pushFileToSession.call(this, fileId, sessionId);
|
||||
await triggerFileInput.call(this, {
|
||||
fileId,
|
||||
windowId,
|
||||
sessionId,
|
||||
elementDescription,
|
||||
includeHiddenElements,
|
||||
});
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
sessionId,
|
||||
windowId,
|
||||
data: {
|
||||
message: 'File loaded successfully',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), error as Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createAndUploadFile,
|
||||
pushFileToSession,
|
||||
triggerFileInput,
|
||||
createFileBuffer,
|
||||
} from './helpers';
|
||||
import { validateRequiredStringField } from '../../GenericFunctions';
|
||||
import {
|
||||
sessionIdField,
|
||||
windowIdField,
|
||||
elementDescriptionField,
|
||||
includeHiddenElementsField,
|
||||
} from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['upload'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...sessionIdField,
|
||||
description: 'The session ID to load the file into',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
description: 'The window ID to trigger the file input in',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'File Name',
|
||||
name: 'fileName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description:
|
||||
'Name for the file to upload. For a session, all files loaded should have <b>unique names</b>.',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'File Type',
|
||||
name: 'fileType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Browser Download',
|
||||
value: 'browser_download',
|
||||
},
|
||||
{
|
||||
name: 'Screenshot',
|
||||
value: 'screenshot',
|
||||
},
|
||||
{
|
||||
name: 'Video',
|
||||
value: 'video',
|
||||
},
|
||||
{
|
||||
name: 'Customer Upload',
|
||||
value: 'customer_upload',
|
||||
},
|
||||
],
|
||||
default: 'customer_upload',
|
||||
description: "Choose the type of file to upload. Defaults to 'Customer Upload'.",
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Source',
|
||||
name: 'source',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'URL',
|
||||
value: 'url',
|
||||
},
|
||||
{
|
||||
name: 'Binary',
|
||||
value: 'binary',
|
||||
},
|
||||
],
|
||||
default: 'url',
|
||||
description: 'Source of the file to upload',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Binary Property',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['binary'],
|
||||
...displayOptions.show,
|
||||
},
|
||||
},
|
||||
description: 'Name of the binary property containing the file data',
|
||||
},
|
||||
{
|
||||
displayName: 'URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['url'],
|
||||
...displayOptions.show,
|
||||
},
|
||||
},
|
||||
description: 'URL from where to fetch the file to upload',
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger File Input',
|
||||
name: 'triggerFileInputParameter',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to automatically trigger the file input dialog in the current window. If disabled, the file will only be uploaded to the session without opening the file input dialog.',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...elementDescriptionField,
|
||||
description: 'Optional description of the file input to interact with',
|
||||
placeholder: 'e.g. the file upload selection box',
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerFileInputParameter: [true],
|
||||
...displayOptions.show,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...includeHiddenElementsField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerFileInputParameter: [true],
|
||||
...displayOptions.show,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateRequiredStringField.call(this, index, 'sessionId', 'Session ID');
|
||||
const windowId = validateRequiredStringField.call(this, index, 'windowId', 'Window ID');
|
||||
const fileName = this.getNodeParameter('fileName', index, '') as string;
|
||||
const fileType = this.getNodeParameter('fileType', index, 'customer_upload') as string;
|
||||
const source = this.getNodeParameter('source', index, 'url') as string;
|
||||
const url = this.getNodeParameter('url', index, '') as string;
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', index, '');
|
||||
const triggerFileInputParameter = this.getNodeParameter(
|
||||
'triggerFileInputParameter',
|
||||
index,
|
||||
true,
|
||||
) as boolean;
|
||||
const elementDescription = this.getNodeParameter('elementDescription', index, '') as string;
|
||||
const includeHiddenElements = this.getNodeParameter(
|
||||
'includeHiddenElements',
|
||||
index,
|
||||
false,
|
||||
) as boolean;
|
||||
// Get the file content based on source type
|
||||
const fileValue = source === 'url' ? url : binaryPropertyName;
|
||||
|
||||
try {
|
||||
const fileBuffer = await createFileBuffer.call(this, source, fileValue, index);
|
||||
const fileId = await createAndUploadFile.call(this, fileName, fileBuffer, fileType);
|
||||
// Push file to session
|
||||
await pushFileToSession.call(this, fileId, sessionId);
|
||||
|
||||
if (triggerFileInputParameter) {
|
||||
await triggerFileInput.call(this, {
|
||||
fileId,
|
||||
windowId,
|
||||
sessionId,
|
||||
elementDescription,
|
||||
includeHiddenElements,
|
||||
});
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
sessionId,
|
||||
windowId,
|
||||
data: {
|
||||
fileId,
|
||||
message: 'File uploaded successfully',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), error as Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as click from './click.operation';
|
||||
import * as fill from './fill.operation';
|
||||
import * as hover from './hover.operation';
|
||||
import * as scroll from './scroll.operation';
|
||||
import * as type from './type.operation';
|
||||
import { sessionIdField, windowIdField } from '../common/fields';
|
||||
export { click, fill, hover, scroll, type };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Click an Element',
|
||||
value: 'click',
|
||||
description: 'Execute a click on an element given a description',
|
||||
action: 'Click an element',
|
||||
},
|
||||
{
|
||||
name: 'Fill Form',
|
||||
value: 'fill',
|
||||
description: 'Fill a form with the provided information',
|
||||
action: 'Fill form',
|
||||
},
|
||||
{
|
||||
name: 'Hover on an Element',
|
||||
value: 'hover',
|
||||
description: 'Execute a hover action on an element given a description',
|
||||
action: 'Hover on an element',
|
||||
},
|
||||
{
|
||||
name: 'Scroll',
|
||||
value: 'scroll',
|
||||
description: 'Execute a scroll action on the page',
|
||||
action: 'Scroll on page',
|
||||
},
|
||||
{
|
||||
name: 'Type',
|
||||
value: 'type',
|
||||
description: 'Execute a Type action on an element given a description',
|
||||
action: 'Type text',
|
||||
},
|
||||
],
|
||||
default: 'click',
|
||||
},
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...click.description,
|
||||
...fill.description,
|
||||
...hover.description,
|
||||
...scroll.description,
|
||||
...type.description,
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['click', 'hover', 'type', 'scroll'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Visual Scope',
|
||||
name: 'visualScope',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'Defines the strategy for visual analysis of the current window',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
description: 'Provides the simplest out-of-the-box experience for most web pages',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
name: 'Viewport',
|
||||
description: 'For analysis of the current browser view only',
|
||||
value: 'viewport',
|
||||
},
|
||||
{
|
||||
name: 'Page',
|
||||
description: 'For analysis of the entire page',
|
||||
value: 'page',
|
||||
},
|
||||
{
|
||||
name: 'Scan',
|
||||
description:
|
||||
"For a full page analysis on sites that have compatibility issues with 'Page' mode",
|
||||
value: 'scan',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Wait Until Event After Navigation',
|
||||
name: 'waitForNavigation',
|
||||
type: 'options',
|
||||
default: 'load',
|
||||
description:
|
||||
"The condition to wait for the navigation to complete after an interaction (click, type or hover). Defaults to 'Fully Loaded'.",
|
||||
options: [
|
||||
{
|
||||
name: 'Fully Loaded (Slower)',
|
||||
value: 'load',
|
||||
},
|
||||
{
|
||||
name: 'DOM Only Loaded (Faster)',
|
||||
value: 'domcontentloaded',
|
||||
},
|
||||
{
|
||||
name: 'All Network Activity Has Stopped',
|
||||
value: 'networkidle0',
|
||||
},
|
||||
{
|
||||
name: 'Most Network Activity Has Stopped',
|
||||
value: 'networkidle2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { constructInteractionRequest } from './helpers';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { elementDescriptionField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...elementDescriptionField,
|
||||
placeholder: 'e.g. the green "save" button at the top of the page',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['click'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Click Type',
|
||||
name: 'clickType',
|
||||
type: 'options',
|
||||
default: 'click',
|
||||
description: 'The type of click to perform. Defaults to left click.',
|
||||
options: [
|
||||
{
|
||||
name: 'Left Click',
|
||||
value: 'click',
|
||||
},
|
||||
{
|
||||
name: 'Double Click',
|
||||
value: 'doubleClick',
|
||||
},
|
||||
{
|
||||
name: 'Right Click',
|
||||
value: 'rightClick',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['click'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const elementDescription = validateRequiredStringField.call(
|
||||
this,
|
||||
index,
|
||||
'elementDescription',
|
||||
'Element Description',
|
||||
);
|
||||
|
||||
const clickType = validateRequiredStringField.call(this, index, 'clickType', 'Click Type');
|
||||
|
||||
const request = constructInteractionRequest.call(this, index, {
|
||||
elementDescription,
|
||||
configuration: {
|
||||
clickType,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/click`,
|
||||
request,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
NodeApiError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponse } from '../../transport/types';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Form Data',
|
||||
name: 'formData',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['fill'],
|
||||
},
|
||||
},
|
||||
description: 'The information to fill into the form written in natural language',
|
||||
placeholder: 'e.g. "Name: John Doe, Email: john.doe@example.com"',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
timeout = OPERATION_TIMEOUT,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const formData = validateRequiredStringField.call(this, index, 'formData', 'Form Data');
|
||||
|
||||
// run automation
|
||||
const asyncAutomationResponse = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/async/sessions/${sessionId}/windows/${windowId}/execute-automation`,
|
||||
{
|
||||
automationId: 'auto',
|
||||
parameters: {
|
||||
customData: formData,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const reqId = asyncAutomationResponse.requestId as string;
|
||||
|
||||
// Poll status every second until it's completed or timeout is reached
|
||||
const startTime = Date.now();
|
||||
let automationStatusResponse: IAirtopResponse;
|
||||
|
||||
while (true) {
|
||||
automationStatusResponse = await apiRequest.call(this, 'GET', `/requests/${reqId}/status`);
|
||||
const status = automationStatusResponse?.status ?? '';
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), automationStatusResponse);
|
||||
|
||||
if (status === 'completed' || status === 'error') {
|
||||
break;
|
||||
}
|
||||
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
if (elapsedTime >= timeout) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
|
||||
// Wait one second
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...automationStatusResponse });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { IAirtopInteractionRequest } from '../../transport/types';
|
||||
|
||||
export function constructInteractionRequest(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
parameters: Partial<IAirtopInteractionRequest> = {},
|
||||
): IAirtopInteractionRequest {
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
const request: IAirtopInteractionRequest = {
|
||||
...parameters,
|
||||
configuration: {
|
||||
...(parameters.configuration ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (additionalFields.visualScope) {
|
||||
request.configuration.visualAnalysis = {
|
||||
scope: additionalFields.visualScope as string,
|
||||
};
|
||||
}
|
||||
|
||||
if (additionalFields.waitForNavigation) {
|
||||
request.waitForNavigation = true;
|
||||
request.configuration.waitForNavigationConfig = {
|
||||
waitUntil: additionalFields.waitForNavigation as string,
|
||||
};
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { constructInteractionRequest } from './helpers';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { elementDescriptionField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...elementDescriptionField,
|
||||
required: true,
|
||||
placeholder: 'e.g. the rounded user profile image at the top right of the page',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['hover'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const elementDescription = validateRequiredStringField.call(
|
||||
this,
|
||||
index,
|
||||
'elementDescription',
|
||||
'Element Description',
|
||||
);
|
||||
|
||||
const request = constructInteractionRequest.call(this, index, {
|
||||
elementDescription,
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/hover`,
|
||||
request,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { constructInteractionRequest } from './helpers';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
validateScrollByAmount,
|
||||
validateScrollingMode,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Scroll Mode',
|
||||
name: 'scrollingMode',
|
||||
type: 'options',
|
||||
description: 'Choose the mode of scrolling',
|
||||
options: [
|
||||
{
|
||||
name: 'Automatic',
|
||||
value: 'automatic',
|
||||
description: 'Describe with natural language the element to scroll to',
|
||||
},
|
||||
{
|
||||
name: 'Manual',
|
||||
value: 'manual',
|
||||
description: 'Define the direction and amount to scroll by',
|
||||
},
|
||||
],
|
||||
default: 'automatic',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Element Description',
|
||||
default: '',
|
||||
description: 'A natural language description of the element to scroll to',
|
||||
name: 'scrollToElement',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. the page section titled "Contact Us"',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
scrollingMode: ['automatic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Scroll To Edge',
|
||||
name: 'scrollToEdge',
|
||||
type: 'fixedCollection',
|
||||
default: {},
|
||||
placeholder: 'Add Edge Direction',
|
||||
description:
|
||||
"The direction to scroll to. When 'Scroll By' is defined, 'Scroll To Edge' action will be executed first, then 'Scroll By' action.",
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
scrollingMode: ['manual'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Page Edges',
|
||||
name: 'edgeValues',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Vertically',
|
||||
name: 'yAxis',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [
|
||||
{
|
||||
name: 'Empty',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
name: 'Top',
|
||||
value: 'top',
|
||||
},
|
||||
{
|
||||
name: 'Bottom',
|
||||
value: 'bottom',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Horizontally',
|
||||
name: 'xAxis',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [
|
||||
{
|
||||
name: 'Empty',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
name: 'Left',
|
||||
value: 'left',
|
||||
},
|
||||
{
|
||||
name: 'Right',
|
||||
value: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Scroll By',
|
||||
name: 'scrollBy',
|
||||
type: 'fixedCollection',
|
||||
default: {},
|
||||
description:
|
||||
"The amount to scroll by. When 'Scroll To Edge' is defined, 'Scroll By' action will be executed after 'Scroll To Edge'.",
|
||||
placeholder: 'Add Scroll Amount',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
scrollingMode: ['manual'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'scrollValues',
|
||||
displayName: 'Scroll Values',
|
||||
description: 'The amount in pixels or percentage to scroll by',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Vertically',
|
||||
name: 'yAxis',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. 200px, 50%, -100px',
|
||||
},
|
||||
{
|
||||
displayName: 'Horizontally',
|
||||
name: 'xAxis',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. 50px, 10%, -200px',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Scrollable Area',
|
||||
name: 'scrollWithin',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Scroll within an element on the page',
|
||||
placeholder: 'e.g. the left sidebar',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
|
||||
const scrollingMode = validateScrollingMode.call(this, index);
|
||||
const isAutomatic = scrollingMode === 'automatic';
|
||||
|
||||
const scrollToElement = isAutomatic
|
||||
? validateRequiredStringField.call(this, index, 'scrollToElement', 'Element Description')
|
||||
: '';
|
||||
|
||||
const scrollToEdge = this.getNodeParameter('scrollToEdge.edgeValues', index, {}) as {
|
||||
xAxis?: string;
|
||||
yAxis?: string;
|
||||
};
|
||||
|
||||
const scrollBy = validateScrollByAmount.call(this, index, 'scrollBy.scrollValues');
|
||||
|
||||
const scrollWithin = this.getNodeParameter('scrollWithin', index, '') as string;
|
||||
|
||||
const request: IDataObject = {
|
||||
// when scrollingMode is 'Manual'
|
||||
...(!isAutomatic ? { scrollToEdge } : {}),
|
||||
...(!isAutomatic ? { scrollBy } : {}),
|
||||
// when scrollingMode is 'Automatic'
|
||||
...(isAutomatic ? { scrollToElement } : {}),
|
||||
// when scrollWithin is defined
|
||||
...(scrollWithin ? { scrollWithin } : {}),
|
||||
};
|
||||
|
||||
const fullRequest = constructInteractionRequest.call(this, index, request);
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/scroll`,
|
||||
fullRequest,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { constructInteractionRequest } from './helpers';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { elementDescriptionField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Text',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['type'],
|
||||
},
|
||||
},
|
||||
description: 'The text to type into the browser window',
|
||||
placeholder: 'e.g. email@example.com',
|
||||
},
|
||||
{
|
||||
displayName: 'Press Enter Key',
|
||||
name: 'pressEnterKey',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to press the Enter key after typing the text',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['type'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...elementDescriptionField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['type'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const text = validateRequiredStringField.call(this, index, 'text', 'Text');
|
||||
const pressEnterKey = this.getNodeParameter('pressEnterKey', index) as boolean;
|
||||
const elementDescription = this.getNodeParameter('elementDescription', index) as string;
|
||||
|
||||
const request = constructInteractionRequest.call(this, index, {
|
||||
text,
|
||||
pressEnterKey,
|
||||
elementDescription,
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/type`,
|
||||
request,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
agent: 'run';
|
||||
session: 'create' | 'save' | 'terminate';
|
||||
window: 'create' | 'close' | 'takeScreenshot' | 'load';
|
||||
extraction: 'getPaginated' | 'query' | 'scrape';
|
||||
interaction: 'click' | 'fill' | 'hover' | 'type';
|
||||
file: 'getMany' | 'get' | 'deleteFile' | 'upload' | 'load';
|
||||
};
|
||||
|
||||
export type AirtopType = AllEntities<NodeMap>;
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as agent from './agent/Agent.resource';
|
||||
import { cleanOutputForToolUse } from './common/output.utils';
|
||||
import * as extraction from './extraction/Extraction.resource';
|
||||
import * as file from './file/File.resource';
|
||||
import * as interaction from './interaction/Interaction.resource';
|
||||
import type { AirtopType } from './node.type';
|
||||
import * as session from './session/Session.resource';
|
||||
import * as window from './window/Window.resource';
|
||||
import type { IAirtopNodeExecutionData } from '../transport/types';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const operationResult: INodeExecutionData[] = [];
|
||||
let responseData: IAirtopNodeExecutionData[] = [];
|
||||
const nodeType = this.getNode().type;
|
||||
const isCalledAsTool = nodeType.includes('airtopTool');
|
||||
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter<AirtopType>('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const airtopNodeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as AirtopType;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
switch (airtopNodeData.resource) {
|
||||
case 'agent':
|
||||
responseData = await agent[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
case 'session':
|
||||
responseData = await session[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
case 'window':
|
||||
responseData = await window[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
case 'interaction':
|
||||
responseData = await interaction[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
case 'extraction':
|
||||
responseData = await extraction[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
case 'file':
|
||||
responseData = await file[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The resource "${resource}" is not supported!`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get cleaner output when called as tool
|
||||
if (isCalledAsTool) {
|
||||
responseData = cleanOutputForToolUse(responseData);
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(responseData, {
|
||||
itemData: { item: i },
|
||||
});
|
||||
|
||||
operationResult.push.apply(operationResult, executionData);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
operationResult.push({
|
||||
json: this.getInputData(i)[0].json,
|
||||
error: error as NodeOperationError,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [operationResult];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as save from './save.operation';
|
||||
import * as terminate from './terminate.operation';
|
||||
import * as waitForDownload from './waitForDownload.operation';
|
||||
|
||||
export { create, save, terminate, waitForDownload };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create Session',
|
||||
value: 'create',
|
||||
description: 'Create an Airtop browser session',
|
||||
action: 'Create a session',
|
||||
},
|
||||
{
|
||||
name: 'Save Profile on Termination',
|
||||
value: 'save',
|
||||
description:
|
||||
'Save in a profile changes made in your browsing session such as cookies and local storage',
|
||||
action: 'Save a profile on session termination',
|
||||
},
|
||||
{
|
||||
name: 'Terminate Session',
|
||||
value: 'terminate',
|
||||
description: 'Terminate a session',
|
||||
action: 'Terminate a session',
|
||||
},
|
||||
{
|
||||
name: 'Wait for Download',
|
||||
value: 'waitForDownload',
|
||||
description: 'Wait for a file download to become available',
|
||||
action: 'Wait for a download',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
...create.description,
|
||||
...save.description,
|
||||
...terminate.description,
|
||||
...waitForDownload.description,
|
||||
];
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { COUNTRIES } from '../../countries';
|
||||
import {
|
||||
createSession,
|
||||
validateProfileName,
|
||||
validateProxy,
|
||||
validateSaveProfileOnTermination,
|
||||
validateTimeoutMinutes,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { profileNameField } from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['create'],
|
||||
},
|
||||
};
|
||||
|
||||
const countryOptions = COUNTRIES.map(({ name, value }) => ({ name, value }));
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...profileNameField,
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Save Profile',
|
||||
name: 'saveProfileOnTermination',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to automatically save the <a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Airtop profile</a> for this session upon termination',
|
||||
displayOptions,
|
||||
},
|
||||
/* Session Recording */
|
||||
{
|
||||
displayName: 'Record Session',
|
||||
name: 'record',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to record the browser session. <a href="https://docs.airtop.ai/guides/how-to/recording-a-session" target="_blank">More details</a>.',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Idle Timeout',
|
||||
name: 'timeoutMinutes',
|
||||
type: 'number',
|
||||
default: 10,
|
||||
validateType: 'number',
|
||||
description: 'Minutes to wait before the session is terminated due to inactivity',
|
||||
displayOptions,
|
||||
},
|
||||
/**
|
||||
* Proxy Configuration
|
||||
*/
|
||||
{
|
||||
displayName: 'Proxy',
|
||||
name: 'proxy',
|
||||
type: 'options',
|
||||
default: 'none',
|
||||
description: 'Choose how to configure the proxy for this session',
|
||||
options: [
|
||||
{
|
||||
name: 'None',
|
||||
value: 'none',
|
||||
description: 'No proxy will be used',
|
||||
},
|
||||
{
|
||||
name: 'Integrated',
|
||||
value: 'integrated',
|
||||
description: 'Use Airtop-provided proxy',
|
||||
},
|
||||
{
|
||||
name: 'Proxy URL',
|
||||
value: 'proxyUrl',
|
||||
description: 'Use a proxy URL to configure the proxy',
|
||||
},
|
||||
],
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Proxy Configuration',
|
||||
name: 'proxyConfig',
|
||||
type: 'collection',
|
||||
default: { country: 'US', sticky: true },
|
||||
description: 'The Airtop-provided configuration to use for the proxy',
|
||||
placeholder: 'Add Attribute',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Country',
|
||||
name: 'country',
|
||||
type: 'options',
|
||||
default: 'US',
|
||||
description:
|
||||
'The country to use for the proxy. Not all countries are guaranteed to provide a proxy. Learn more <a href="https://docs.airtop.ai/api-reference/airtop-api/sessions/create#request.body.configuration.proxy.Proxy.Airtop-Proxy-Configuration.country" target="_blank">here</a>.',
|
||||
options: countryOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Keep Same IP',
|
||||
name: 'sticky',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to try to maintain the same IP address for the duration of the session. Airtop can guarantee that the same IP address will be available for up to a maximum of 30 minutes.',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
...displayOptions.show,
|
||||
proxy: ['integrated'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Proxy URL',
|
||||
name: 'proxyUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The URL of the proxy to use',
|
||||
validateType: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
...displayOptions.show,
|
||||
proxy: ['proxyUrl'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions,
|
||||
options: [
|
||||
{
|
||||
displayName: 'Auto Solve Captchas',
|
||||
name: 'solveCaptcha',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to automatically solve <a href="https://docs.airtop.ai/guides/how-to/solving-captchas" target="_blank">captcha challenges</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Extension IDs',
|
||||
name: 'extensionIds',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. extId1, extId2, ...',
|
||||
description:
|
||||
'Comma-separated extension IDs from the Google Web Store to be loaded into the session. Learn more <a href="https://docs.airtop.ai/guides/how-to/using-chrome-extensions" target="_blank">here</a>.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const profileName = validateProfileName.call(this, index);
|
||||
const record = this.getNodeParameter('record', index, false);
|
||||
const timeoutMinutes = validateTimeoutMinutes.call(this, index);
|
||||
const saveProfileOnTermination = validateSaveProfileOnTermination.call(this, index, profileName);
|
||||
const { proxy } = validateProxy.call(this, index);
|
||||
const solveCaptcha = this.getNodeParameter(
|
||||
'additionalFields.solveCaptcha',
|
||||
index,
|
||||
false,
|
||||
) as boolean;
|
||||
|
||||
const extensions = this.getNodeParameter('additionalFields.extensionIds', index, '') as string;
|
||||
const extensionIds = extensions ? extensions.split(',').map((id) => id.trim()) : [];
|
||||
|
||||
const body: IDataObject = {
|
||||
configuration: {
|
||||
profileName,
|
||||
timeoutMinutes,
|
||||
proxy,
|
||||
solveCaptcha,
|
||||
record,
|
||||
...(extensionIds.length > 0 ? { extensionIds } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const { sessionId, data } = await createSession.call(this, body);
|
||||
|
||||
if (saveProfileOnTermination) {
|
||||
await apiRequest.call(
|
||||
this,
|
||||
'PUT',
|
||||
`/sessions/${sessionId}/save-profile-on-termination/${profileName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, ...data });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateAirtopApiResponse,
|
||||
validateProfileName,
|
||||
validateRequiredStringField,
|
||||
validateSessionId,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { sessionIdField, profileNameField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName:
|
||||
"Note: This operation is not needed if you enabled 'Save Profile' in the 'Create Session' operation",
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['save'],
|
||||
},
|
||||
},
|
||||
default: 'This operation will save the profile on session termination',
|
||||
},
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['save'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...profileNameField,
|
||||
required: true,
|
||||
description:
|
||||
'The name of the <a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Profile</a> to save',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['save'],
|
||||
},
|
||||
},
|
||||
hint: 'Name of the profile you want to save. Must consist only of alphanumeric characters and hyphens "-"',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
let profileName = validateRequiredStringField.call(this, index, 'profileName', 'Profile Name');
|
||||
profileName = validateProfileName.call(this, index);
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'PUT',
|
||||
`/sessions/${sessionId}/save-profile-on-termination/${profileName}`,
|
||||
);
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, profileName, ...response } as IDataObject);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { validateAirtopApiResponse, validateSessionId } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { sessionIdField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['terminate'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
const response = await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`);
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ success: true } as IDataObject);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { DEFAULT_DOWNLOAD_TIMEOUT_SECONDS } from '../../constants';
|
||||
import { validateSessionId, waitForSessionEvent } from '../../GenericFunctions';
|
||||
import { sessionIdField } from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['waitForDownload'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions,
|
||||
options: [
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
description: 'Time in seconds to wait for the download to become available',
|
||||
name: 'timeout',
|
||||
type: 'number',
|
||||
default: DEFAULT_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
const timeout = this.getNodeParameter(
|
||||
'timeout',
|
||||
index,
|
||||
DEFAULT_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
) as number;
|
||||
|
||||
// Wait for a file_status event with status 'available'
|
||||
const event = await waitForSessionEvent.call(
|
||||
this,
|
||||
sessionId,
|
||||
(sessionEvent) => sessionEvent.event === 'file_status' && sessionEvent.status === 'available',
|
||||
timeout,
|
||||
);
|
||||
|
||||
// Extract fileId and downloadUrl from the event
|
||||
const result: IDataObject = {
|
||||
fileId: event.fileId,
|
||||
downloadUrl: event.downloadUrl,
|
||||
};
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
sessionId,
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as close from './close.operation';
|
||||
import * as create from './create.operation';
|
||||
import * as getLiveView from './getLiveView.operation';
|
||||
import * as list from './list.operation';
|
||||
import * as load from './load.operation';
|
||||
import * as takeScreenshot from './takeScreenshot.operation';
|
||||
import { sessionIdField, windowIdField } from '../common/fields';
|
||||
|
||||
export { create, close, takeScreenshot, load, list, getLiveView };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
sortable: false,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Close Window',
|
||||
value: 'close',
|
||||
description: 'Close a window inside a session',
|
||||
action: 'Close a window',
|
||||
},
|
||||
{
|
||||
name: 'Create a New Browser Window',
|
||||
value: 'create',
|
||||
description: 'Create a new browser window inside a session. Can load a URL when created.',
|
||||
action: 'Create a window',
|
||||
},
|
||||
{
|
||||
name: 'Get Live View',
|
||||
value: 'getLiveView',
|
||||
description: 'Get information about a browser window, including the live view URL',
|
||||
action: 'Get live view',
|
||||
},
|
||||
{
|
||||
name: 'List Windows',
|
||||
value: 'list',
|
||||
description: 'List all browser windows in a session',
|
||||
action: 'List windows',
|
||||
},
|
||||
{
|
||||
name: 'Load URL',
|
||||
value: 'load',
|
||||
description: 'Load a URL in an existing window',
|
||||
action: 'Load a page',
|
||||
},
|
||||
{
|
||||
name: 'Take Screenshot',
|
||||
value: 'takeScreenshot',
|
||||
description: 'Take a screenshot of the current window',
|
||||
action: 'Take screenshot',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['close', 'takeScreenshot', 'load', 'getLiveView'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...create.description,
|
||||
...list.description,
|
||||
...getLiveView.description,
|
||||
...load.description,
|
||||
...takeScreenshot.description,
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { validateAirtopApiResponse, validateSessionAndWindowId } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'DELETE',
|
||||
`/sessions/${sessionId}/windows/${windowId}`,
|
||||
);
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateAirtopApiResponse,
|
||||
validateSessionId,
|
||||
validateUrl,
|
||||
validateScreenResolution,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponse } from '../../transport/types';
|
||||
import { urlField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...urlField,
|
||||
description: 'Initial URL to load in the window. Defaults to https://www.google.com.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Live View Options
|
||||
{
|
||||
displayName: 'Get Live View',
|
||||
name: 'getLiveView',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to get the URL of the window\'s <a href="https://docs.airtop.ai/guides/how-to/creating-a-live-view" target="_blank">Live View</a>',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Include Navigation Bar',
|
||||
name: 'includeNavigationBar',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to include the navigation bar in the Live View. When enabled, the navigation bar will be visible allowing you to navigate between pages.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
getLiveView: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Screen Resolution',
|
||||
name: 'screenResolution',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'The screen resolution of the Live View. Setting a resolution will force the window to open at that specific size.',
|
||||
placeholder: 'e.g. 1280x720',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
getLiveView: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Disable Resize',
|
||||
name: 'disableResize',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to disable the window from being resized in the Live View',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
getLiveView: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Wait Until',
|
||||
name: 'waitUntil',
|
||||
type: 'options',
|
||||
description: 'Wait until the specified loading event occurs',
|
||||
default: 'load',
|
||||
options: [
|
||||
{
|
||||
name: 'Load',
|
||||
value: 'load',
|
||||
description: 'Wait until the page dom and its assets have loaded',
|
||||
},
|
||||
{
|
||||
name: 'DOM Content Loaded',
|
||||
value: 'domContentLoaded',
|
||||
description: 'Wait until the page DOM has loaded',
|
||||
},
|
||||
{
|
||||
name: 'Complete',
|
||||
value: 'complete',
|
||||
description: 'Wait until all iframes in the page have loaded',
|
||||
},
|
||||
{
|
||||
name: 'No Wait',
|
||||
value: 'noWait',
|
||||
description: 'Do not wait for any loading event and it will return immediately',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
const url = validateUrl.call(this, index);
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
// Live View Options
|
||||
const getLiveView = this.getNodeParameter('getLiveView', index, false);
|
||||
const includeNavigationBar = this.getNodeParameter('includeNavigationBar', index, false);
|
||||
const screenResolution = validateScreenResolution.call(this, index);
|
||||
const disableResize = this.getNodeParameter('disableResize', index, false);
|
||||
|
||||
let response: IAirtopResponse;
|
||||
|
||||
const body: IDataObject = {
|
||||
url,
|
||||
...additionalFields,
|
||||
};
|
||||
|
||||
response = await apiRequest.call(this, 'POST', `/sessions/${sessionId}/windows`, body);
|
||||
|
||||
if (!response?.data?.windowId) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: 'Failed to create window',
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const windowId = String(response.data.windowId);
|
||||
|
||||
if (getLiveView) {
|
||||
// Get Window info
|
||||
response = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/sessions/${sessionId}/windows/${windowId}`,
|
||||
undefined,
|
||||
{
|
||||
...(includeNavigationBar && { includeNavigationBar: true }),
|
||||
...(screenResolution && { screenResolution }),
|
||||
...(disableResize && { disableResize: true }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES } from '../../constants';
|
||||
import { validateAirtopApiResponse, validateSessionAndWindowId } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['getLiveView'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Include Navigation Bar',
|
||||
name: 'includeNavigationBar',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to include the navigation bar in the Live View. When enabled, the navigation bar will be visible allowing you to navigate between pages.',
|
||||
},
|
||||
{
|
||||
displayName: 'Screen Resolution',
|
||||
name: 'screenResolution',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'The screen resolution of the Live View. Setting a resolution will force the window to open at that specific size.',
|
||||
placeholder: 'e.g. 1280x720',
|
||||
},
|
||||
{
|
||||
displayName: 'Disable Resize',
|
||||
name: 'disableResize',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to disable the window from being resized in the Live View',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
|
||||
const queryParams: Record<string, any> = {};
|
||||
|
||||
if (additionalFields.includeNavigationBar) {
|
||||
queryParams.includeNavigationBar = true;
|
||||
}
|
||||
|
||||
if (additionalFields.screenResolution) {
|
||||
const screenResolution = ((additionalFields.screenResolution as string) || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const regex = /^\d{3,4}x\d{3,4}$/; // Expected format: 1280x720
|
||||
|
||||
if (!regex.test(screenResolution)) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SCREEN_RESOLUTION_INVALID, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
queryParams.screenResolution = screenResolution;
|
||||
}
|
||||
|
||||
if (additionalFields.disableResize) {
|
||||
queryParams.disableResize = true;
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/sessions/${sessionId}/windows/${windowId}`,
|
||||
undefined,
|
||||
queryParams,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { validateAirtopApiResponse, validateSessionId } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `/sessions/${sessionId}/windows`, undefined);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateUrl,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { urlField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...urlField,
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['load'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['load'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Wait Until',
|
||||
name: 'waitUntil',
|
||||
type: 'options',
|
||||
default: 'load',
|
||||
description: "Wait until the specified loading event occurs. Defaults to 'Fully Loaded'.",
|
||||
options: [
|
||||
{
|
||||
name: 'Complete',
|
||||
value: 'complete',
|
||||
description: "Wait until the page and all it's iframes have loaded it's dom and assets",
|
||||
},
|
||||
{
|
||||
name: 'DOM Only Loaded',
|
||||
value: 'domContentLoaded',
|
||||
description: 'Wait until the dom has loaded',
|
||||
},
|
||||
{
|
||||
name: 'Fully Loaded',
|
||||
value: 'load',
|
||||
description: "Wait until the page dom and it's assets have loaded",
|
||||
},
|
||||
{
|
||||
name: 'No Wait',
|
||||
value: 'noWait',
|
||||
description: 'Do not wait for any loading event and will return immediately',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
let url = validateRequiredStringField.call(this, index, 'url', 'URL');
|
||||
url = validateUrl.call(this, index);
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}`,
|
||||
{
|
||||
url,
|
||||
waitUntil: additionalFields.waitUntil,
|
||||
},
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IBinaryData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
convertScreenshotToBinary,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Output Binary Image',
|
||||
description: 'Whether to output the image as a binary file instead of a base64 encoded string',
|
||||
name: 'outputImageAsBinary',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['takeScreenshot'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const outputImageAsBinary = this.getNodeParameter('outputImageAsBinary', index, false) as boolean;
|
||||
|
||||
let data: IBinaryData | undefined; // for storing the binary data
|
||||
let image = ''; // for storing the base64 encoded image
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/screenshot`,
|
||||
);
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
// process screenshot on success
|
||||
if (response.meta?.screenshots?.length) {
|
||||
if (outputImageAsBinary) {
|
||||
const buffer = convertScreenshotToBinary(response.meta.screenshots[0]);
|
||||
data = await this.helpers.prepareBinaryData(buffer, 'screenshot.jpg', 'image/jpeg');
|
||||
} else {
|
||||
image = response?.meta?.screenshots?.[0].dataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
sessionId,
|
||||
windowId,
|
||||
image,
|
||||
},
|
||||
...(data ? { binary: { data } } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user