diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 19b7f5e..5429123 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,19 +2,26 @@ name: Publish to NPM on: push: + branches: + - main tags: - 'v*' workflow_dispatch: +concurrency: + group: npm-publish + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' permissions: - contents: read + contents: write id-token: write steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - uses: actions/setup-node@v6 with: @@ -22,6 +29,40 @@ jobs: registry-url: 'https://registry.npmjs.org' package-manager-cache: false + - name: Decide whether to publish + id: plan + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + if npm view "n8n-nodes-browserbase@${VERSION}" version >/dev/null 2>&1; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "n8n-nodes-browserbase@${VERSION} is already on npm" + else + echo "publish=true" >> "$GITHUB_OUTPUT" + fi + + if [ "${GITHUB_REF}" = "refs/heads/main" ] && ! git rev-parse "refs/tags/v${VERSION}" >/dev/null 2>&1; then + echo "github_release=true" >> "$GITHUB_OUTPUT" + else + echo "github_release=false" >> "$GITHUB_OUTPUT" + fi + - run: npm ci + if: steps.plan.outputs.publish == 'true' + - run: npm run build + if: steps.plan.outputs.publish == 'true' + - run: npm publish --provenance --access public + if: steps.plan.outputs.publish == 'true' + + - name: Create GitHub release + if: steps.plan.outputs.github_release == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "v${{ steps.plan.outputs.version }}" \ + --target "${GITHUB_SHA}" \ + --title "v${{ steps.plan.outputs.version }}" \ + --generate-notes diff --git a/README.md b/README.md index c8287ef..21e9ca5 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Optional fields: | Field | Description | | --- | --- | | `Number of Results` | How many results to return, from 1 to 25 | +| `Provider` | Optional search provider: `parallel` or `exa`. Omit to use the project default. | +| `Mode` | Optional Parallel mode: `fast` or `advanced`. Only sent when Provider is Parallel. | Search output includes: @@ -80,15 +82,21 @@ Required fields: | Field | Description | | --- | --- | | `URL` | URL to fetch | +| `Format` | `raw` (default), `markdown`, or `json`. Markdown and JSON are Fetch Extract. | + +`JSON` format also requires a JSON Schema. The extracted object comes back in `content`. Optional fetch settings: - Follow redirects - Allow insecure SSL - Use Browserbase proxies +- Proxy country, state, and city (`proxies` is sent as a geolocation object when a country is set) Fetch output includes: +- `id` +- `format` - `statusCode` - `headers` - `content` diff --git a/nodes/Browserbase/Browserbase.node.ts b/nodes/Browserbase/Browserbase.node.ts index 9e7a86b..2a5be30 100644 --- a/nodes/Browserbase/Browserbase.node.ts +++ b/nodes/Browserbase/Browserbase.node.ts @@ -10,6 +10,7 @@ import { type INodePropertyOptions, type INodeType, type INodeTypeDescription, + type IDataObject, type IHttpRequestMethods, NodeOperationError, } from 'n8n-workflow'; @@ -17,6 +18,16 @@ import { const STAGEHAND_BASE_URL = 'https://api.stagehand.browserbase.com'; const API_BASE_URL = 'https://api.browserbase.com'; const DEFAULT_MAX_STEPS = 40; +const SEARCH_QUERY_MAX_LENGTH = 200; + +const FETCH_SCHEMA_EXAMPLE = `{ + "type": "object", + "properties": { + "title": { "type": "string" }, + "summary": { "type": "string" } + }, + "required": ["title"] +}`; // Single source of truth for CUA "Agent Model" options — mirrors Stagehand's // AVAILABLE_CUA_MODELS @@ -101,6 +112,27 @@ type SessionOptions = { userMetadata?: string; }; +function readJsonObject(value: unknown): Record | undefined { + if (value == null || value === '') { + return undefined; + } + + let parsed: unknown = value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + parsed = JSON.parse(trimmed) as unknown; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + + return parsed as Record; +} + function normalizeUrl(url: string): string { if (!url) { return url; @@ -953,6 +985,65 @@ function buildProperties(): INodeProperties[] { }, }, }, + { + displayName: 'Search Options', + name: 'searchOptions', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['search'], + }, + }, + options: [ + { + displayName: 'Mode', + name: 'mode', + type: 'options', + displayOptions: { + show: { + provider: ['parallel'], + }, + }, + options: [ + { + name: 'Advanced', + value: 'advanced', + description: 'Parallel default mode', + }, + { + name: 'Fast', + value: 'fast', + description: 'Lower-cost Parallel mode', + }, + ], + default: 'advanced', + description: + 'Parallel search mode. Only sent when Provider is Parallel. Other providers reject mode.', + }, + { + displayName: 'Provider', + name: 'provider', + type: 'options', + options: [ + { + name: 'Exa', + value: 'exa', + description: 'Exa search. Do not set Mode.', + }, + { + name: 'Parallel', + value: 'parallel', + description: 'Parallel search. Supports Fast and Advanced modes.', + }, + ], + default: 'parallel', + description: + 'Search provider for this request. Leave unset to use the project default.', + }, + ], + }, { displayName: 'URL', name: 'fetchUrl', @@ -967,6 +1058,53 @@ function buildProperties(): INodeProperties[] { }, }, }, + { + displayName: 'Format', + name: 'format', + type: 'options', + displayOptions: { + show: { + operation: ['fetch'], + }, + }, + options: [ + { + name: 'Raw', + value: 'raw', + description: 'Upstream response body', + }, + { + name: 'Markdown', + value: 'markdown', + description: 'Page converted to markdown. Billed as Fetch Extract.', + }, + { + name: 'JSON', + value: 'json', + description: 'Structured extraction from the JSON Schema. Billed as Fetch Extract.', + }, + ], + default: 'raw', + description: + 'How content is returned. Raw is the response body. Markdown and JSON are converted first.', + }, + { + displayName: 'JSON Schema', + name: 'schema', + type: 'json', + default: FETCH_SCHEMA_EXAMPLE, + required: true, + typeOptions: { + rows: 8, + }, + description: 'JSON Schema for the object returned in content. Required when Format is JSON.', + displayOptions: { + show: { + operation: ['fetch'], + format: ['json'], + }, + }, + }, { displayName: 'Fetch Options', name: 'fetchOptions', @@ -993,12 +1131,39 @@ function buildProperties(): INodeProperties[] { default: false, description: 'Whether to follow HTTP redirects', }, + { + displayName: 'Proxy City', + name: 'proxyCity', + type: 'string', + default: '', + placeholder: 'e.g. New York', + description: + 'City for the proxy exit. Requires Proxy Country. Use spaces for multi-word names.', + }, + { + displayName: 'Proxy Country', + name: 'proxyCountry', + type: 'string', + default: '', + placeholder: 'e.g. US', + description: + 'ISO 3166-1 alpha-2 country code. When set, the request is proxied from that country.', + }, + { + displayName: 'Proxy State', + name: 'proxyState', + type: 'string', + default: '', + placeholder: 'e.g. NY', + description: 'US state code. Only valid when Proxy Country is US.', + }, { displayName: 'Use Proxies', name: 'proxies', type: 'boolean', default: false, - description: 'Whether to route the request through Browserbase proxies', + description: + 'Whether to route the request through Browserbase proxies. Ignored when Proxy Country is set.', }, ], }, @@ -1098,8 +1263,33 @@ export class Browserbase implements INodeType { headers: BrowserbaseHeaders, baseUrl: string, ): Promise { - const query = executeFunctions.getNodeParameter('query', itemIndex) as string; + const query = (executeFunctions.getNodeParameter('query', itemIndex) as string).trim(); + if (!query || query.length > SEARCH_QUERY_MAX_LENGTH) { + throw new NodeOperationError( + executeFunctions.getNode(), + `Query must be between 1 and ${SEARCH_QUERY_MAX_LENGTH} characters.`, + ); + } + const numResults = executeFunctions.getNodeParameter('numResults', itemIndex) as number; + const searchOptions = executeFunctions.getNodeParameter('searchOptions', itemIndex, {}) as { + provider?: string; + mode?: string; + }; + + const body: Record = { + query, + numResults, + }; + + if (searchOptions.provider) { + body.provider = searchOptions.provider; + } + + // Mode is only valid for Parallel. Sending it to Exa (or with no provider) returns 400. + if (searchOptions.provider === 'parallel' && searchOptions.mode) { + body.mode = searchOptions.mode; + } const response = await this.apiCall( executeFunctions, @@ -1107,10 +1297,7 @@ export class Browserbase implements INodeType { baseUrl, '/v1/search', headers, - { - query, - numResults, - }, + body, ); const results = (response.results as Array> | undefined) ?? []; @@ -1133,32 +1320,105 @@ export class Browserbase implements INodeType { baseUrl: string, ): Promise { const url = normalizeUrl(executeFunctions.getNodeParameter('fetchUrl', itemIndex) as string); + const format = executeFunctions.getNodeParameter('format', itemIndex, 'raw') as string; const fetchOptions = executeFunctions.getNodeParameter('fetchOptions', itemIndex, {}) as { allowRedirects?: boolean; allowInsecureSsl?: boolean; proxies?: boolean; + proxyCountry?: string; + proxyState?: string; + proxyCity?: string; }; + const country = (fetchOptions.proxyCountry ?? '').trim().toUpperCase(); + const state = (fetchOptions.proxyState ?? '').trim().toUpperCase(); + const city = (fetchOptions.proxyCity ?? '').trim(); + + if ((state || city) && !country) { + throw new NodeOperationError( + executeFunctions.getNode(), + 'Proxy Country is required when Proxy State or Proxy City is set.', + ); + } + + if (country && !/^[A-Z]{2}$/.test(country)) { + throw new NodeOperationError( + executeFunctions.getNode(), + 'Proxy Country must be a 2-letter ISO country code (for example, US).', + ); + } + + if (state && country !== 'US') { + throw new NodeOperationError( + executeFunctions.getNode(), + 'Proxy State is only valid when Proxy Country is US.', + ); + } + + if (state && !/^[A-Z]{2}$/.test(state)) { + throw new NodeOperationError( + executeFunctions.getNode(), + 'Proxy State must be a 2-letter US state code (for example, NY).', + ); + } + + let proxies: boolean | { geolocation: Record } = fetchOptions.proxies ?? false; + if (country) { + proxies = { + geolocation: { + country, + ...(state ? { state } : {}), + ...(city ? { city } : {}), + }, + }; + } + + const body: Record = { + url, + allowRedirects: fetchOptions.allowRedirects ?? false, + allowInsecureSsl: fetchOptions.allowInsecureSsl ?? false, + proxies, + format, + }; + + if (format === 'json') { + let schema: Record | undefined; + try { + schema = readJsonObject(executeFunctions.getNodeParameter('schema', itemIndex)); + } catch { + throw new NodeOperationError( + executeFunctions.getNode(), + 'JSON Schema must be valid JSON.', + ); + } + + if (!schema) { + throw new NodeOperationError( + executeFunctions.getNode(), + 'JSON Schema is required when Format is JSON, and it must be a JSON object.', + ); + } + + body.schema = schema; + } + const response = await this.apiCall( executeFunctions, 'POST', baseUrl, '/v1/fetch', headers, - { - url, - allowRedirects: fetchOptions.allowRedirects ?? false, - allowInsecureSsl: fetchOptions.allowInsecureSsl ?? false, - proxies: fetchOptions.proxies ?? false, - }, + body, ); return { json: { + id: response.id as string | undefined, url, + format, statusCode: response.statusCode as number | undefined, headers: (response.headers as Record | undefined) ?? {}, - content: response.content as string | undefined, + content: response.content as IDataObject | string | undefined, contentType: response.contentType as string | undefined, encoding: response.encoding as string | undefined, }, diff --git a/package-lock.json b/package-lock.json index b184c3b..b0de2c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "n8n-nodes-browserbase", - "version": "1.4.1", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "n8n-nodes-browserbase", - "version": "1.4.1", + "version": "1.5.0", "license": "MIT", "devDependencies": { "@n8n/node-cli": "*", diff --git a/package.json b/package.json index 90ef187..655bb67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-browserbase", - "version": "1.4.1", + "version": "1.5.0", "description": "n8n community node for Browserbase agent, search, and fetch workflows", "license": "MIT", "homepage": "https://github.com/browserbase/n8n-node",