diff --git a/apps/sim/blocks/blocks/serply.ts b/apps/sim/blocks/blocks/serply.ts new file mode 100644 index 00000000000..196397f046d --- /dev/null +++ b/apps/sim/blocks/blocks/serply.ts @@ -0,0 +1,70 @@ +import { Search } from '@sim/emcn/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { SearchResponse } from '@/tools/serply/search' + +export const SerplyBlock: BlockConfig = { + type: 'serply', + name: 'Serply', + description: 'Search the web using Serply', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Serply into the workflow. Can search the web.', + docsLink: 'https://docs.sim.ai/integrations/serply', + category: 'tools', + integrationType: IntegrationType.Search, + bgColor: '#4F46E5', + icon: Search, + canvasPresentation: { + defaultTitle: 'Serply', + sentences: { + default: [ + { text: 'Search Google for', field: 'query', core: true }, + { text: ', returning up to', field: 'num', after: 'results' }, + ], + }, + }, + subBlocks: [ + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: 'Enter your search query...', + required: true, + }, + { + id: 'num', + title: 'Number of Results', + type: 'dropdown', + options: [ + { label: '10', id: '10' }, + { label: '20', id: '20' }, + { label: '30', id: '30' }, + { label: '50', id: '50' }, + ], + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Serply API key', + password: true, + required: true, + }, + ], + tools: { + access: ['serply_search'], + }, + inputs: { + query: { type: 'string', description: 'Search query terms' }, + apiKey: { type: 'string', description: 'Serply API key' }, + num: { type: 'number', description: 'Number of results' }, + }, + outputs: { + searchResults: { type: 'json', description: 'Search results data' }, + }, +} + +export const SerplyBlockMeta = { + tags: ['web-scraping', 'seo'], + url: 'https://serply.io', +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 2f54c9453e2..73c1806fbee 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -283,6 +283,7 @@ import { SendblueBlock, SendblueBlockMeta } from '@/blocks/blocks/sendblue' import { SendGridBlock, SendGridBlockMeta } from '@/blocks/blocks/sendgrid' import { SentryBlock, SentryBlockMeta } from '@/blocks/blocks/sentry' import { SerperBlock, SerperBlockMeta } from '@/blocks/blocks/serper' +import { SerplyBlock, SerplyBlockMeta } from '@/blocks/blocks/serply' import { ServiceNowBlock, ServiceNowBlockMeta } from '@/blocks/blocks/servicenow' import { SESBlock, SESBlockMeta } from '@/blocks/blocks/ses' import { SftpBlock, SftpBlockMeta } from '@/blocks/blocks/sftp' @@ -614,6 +615,7 @@ export const BLOCK_REGISTRY: Record = { sendgrid: SendGridBlock, sentry: SentryBlock, serper: SerperBlock, + serply: SerplyBlock, servicenow: ServiceNowBlock, ses: SESBlock, sftp: SftpBlock, @@ -914,6 +916,7 @@ export const BLOCK_META_REGISTRY: Record = { sendgrid: SendGridBlockMeta, sentry: SentryBlockMeta, serper: SerperBlockMeta, + serply: SerplyBlockMeta, servicenow: ServiceNowBlockMeta, ses: SESBlockMeta, sftp: SftpBlockMeta, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index c343f781b36..208069e46ec 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3929,6 +3929,7 @@ import { updateProjectTool, } from '@/tools/sentry' import { serperSearchTool } from '@/tools/serper' +import { searchTool as serplySearchTool } from '@/tools/serply/search' import { servicenowAddIncidentCommentTool, servicenowAggregateTool, @@ -6441,6 +6442,7 @@ export const tools: Record = { github_latest_commit: githubLatestCommitTool, github_latest_commit_v2: githubLatestCommitV2Tool, serper_search: serperSearchTool, + serply_search: serplySearchTool, similarweb_website_overview: similarwebWebsiteOverviewTool, similarweb_traffic_visits: similarwebTrafficVisitsTool, similarweb_bounce_rate: similarwebBounceRateTool, diff --git a/apps/sim/tools/serply/search.test.ts b/apps/sim/tools/serply/search.test.ts new file mode 100644 index 00000000000..39f8fb4f92e --- /dev/null +++ b/apps/sim/tools/serply/search.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { searchTool } from '@/tools/serply/search' + +describe('serply searchTool', () => { + it('sends the api key, accept header, and an explicit user agent', () => { + const headers = searchTool.request.headers({ query: 'sim', apiKey: 'test-key' }) + + expect(headers['X-Api-Key']).toBe('test-key') + expect(headers.Accept).toBe('application/json') + expect(headers['User-Agent']).toBeTruthy() + }) + + it('builds the query URL with the optional num param', () => { + const url = (searchTool.request.url as (params: any) => string)({ + query: 'sim workflows', + apiKey: 'test-key', + num: 20, + }) + + expect(url).toBe('https://api.serply.io/v1/search/?q=sim+workflows&num=20') + }) + + it('maps organic results into searchResults', async () => { + const response = new Response( + JSON.stringify({ + results: [ + { title: 'Sim', link: 'https://sim.ai', description: 'AI workspace' }, + { title: 'No link', description: 'dropped upstream? kept here' }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + + const result = await searchTool.transformResponse!(response, {} as never) + + expect(result.success).toBe(true) + expect(result.output.searchResults).toEqual([ + { title: 'Sim', link: 'https://sim.ai', snippet: 'AI workspace' }, + { title: 'No link', link: '', snippet: 'dropped upstream? kept here' }, + ]) + }) + + it('returns an empty array when the response has no results', async () => { + const response = new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + + const result = await searchTool.transformResponse!(response, {} as never) + + expect(result.output.searchResults).toEqual([]) + }) +}) diff --git a/apps/sim/tools/serply/search.ts b/apps/sim/tools/serply/search.ts new file mode 100644 index 00000000000..7717405aa53 --- /dev/null +++ b/apps/sim/tools/serply/search.ts @@ -0,0 +1,95 @@ +import type { OutputProperty, ToolConfig, ToolResponse } from '@/tools/types' + +export const SERPLY_SEARCH_RESULT_OUTPUT_PROPERTIES = { + title: { type: 'string', description: 'Result title' }, + link: { type: 'string', description: 'Result URL' }, + snippet: { type: 'string', description: 'Result description/snippet', optional: true }, +} as const satisfies Record + +export interface SearchParams { + query: string + apiKey: string + num?: number +} + +export interface SearchResult { + title: string + link: string + snippet?: string +} + +export interface SearchResponse extends ToolResponse { + output: { + searchResults: SearchResult[] + } +} + +export const searchTool: ToolConfig = { + id: 'serply_search', + name: 'Web Search', + description: + 'A web search tool that provides access to Google search results through the Serply SERP API. Returns organic results with titles, links, and snippets.', + version: '1.0.0', + + params: { + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The search query (e.g., "latest AI news", "best restaurants in NYC")', + }, + num: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to return (e.g., 10, 20, 50)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Serply API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.serply.io/v1/search/') + url.searchParams.set('q', params.query) + if (params.num) url.searchParams.set('num', String(Number(params.num))) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + 'X-Api-Key': params.apiKey, + Accept: 'application/json', + // Serply sits behind Cloudflare, which rejects requests without an + // explicit User-Agent, so always send one. + 'User-Agent': 'sim-serply-tool', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + const results = Array.isArray(data.results) ? data.results : [] + + const searchResults: SearchResult[] = results.map((item: any) => ({ + title: item.title || '', + link: item.link || '', + snippet: item.description || undefined, + })) + + return { + success: true, + output: { searchResults }, + } + }, + + outputs: { + searchResults: { + type: 'array', + description: 'Organic search results with titles, links, and snippets', + items: { type: 'object', properties: SERPLY_SEARCH_RESULT_OUTPUT_PROPERTIES }, + }, + }, +}