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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -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 } } : {}),
},
];
}