diff --git a/packages/components/nodes/tools/Serper/Serper.test.ts b/packages/components/nodes/tools/Serper/Serper.test.ts new file mode 100644 index 00000000000..8096e92d0ac --- /dev/null +++ b/packages/components/nodes/tools/Serper/Serper.test.ts @@ -0,0 +1,372 @@ +// Mock node-fetch before any imports +jest.mock('node-fetch', () => jest.fn()) +import fetch from 'node-fetch' +const mockFetch = fetch as jest.MockedFunction + +// Mock Flowise utility functions +jest.mock('../../../src/utils', () => ({ + getBaseClasses: jest.fn(() => ['Serper', 'Tool', 'StructuredTool']), + getCredentialData: jest.fn(() => Promise.resolve({ serperApiKey: 'test-api-key-123' })), + getCredentialParam: jest.fn((paramName: string, credentialData: any) => credentialData[paramName]) +})) + +import { SerperTool, SerperConfig, SERPER_ENDPOINTS } from './core' + +let Serper_Tools: any +beforeAll(async () => { + const mod = await import('./Serper') + Serper_Tools = (mod as any).default?.nodeClass ?? require('./Serper').nodeClass +}) + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +function makeNodeData(inputs: Record = {}) { + return { + id: 'test-node-id', + label: 'Serper', + name: 'serper', + type: 'Serper', + icon: 'serper.svg', + version: 2.0, + category: 'Tools', + baseClasses: ['Serper', 'Tool'], + credential: 'mock-credential-id', + inputs: { endpoint: 'search', ...inputs } + } +} + +function mockOkResponse(body: any) { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => body, + text: async () => JSON.stringify(body) + } as any) +} + +function mockErrorResponse(status: number, text: string) { + mockFetch.mockResolvedValueOnce({ + ok: false, + status, + statusText: text, + text: async () => text, + json: async () => ({ error: text }) + } as any) +} + +// ─── Node Structure Tests ────────────────────────────────────────────────────── + +describe('Serper_Tools Node Structure', () => { + let node: any + + beforeEach(() => { + node = new Serper_Tools() + }) + + it('has correct metadata', () => { + expect(node.label).toBe('Serper') + expect(node.name).toBe('serper') + expect(node.type).toBe('Serper') + expect(node.icon).toBe('serper.svg') + expect(node.category).toBe('Tools') + expect(node.version).toBe(2.0) + }) + + it('uses serperApi credential', () => { + expect(node.credential.type).toBe('credential') + expect(node.credential.credentialNames).toContain('serperApi') + }) + + it('has endpoint as first input', () => { + expect(node.inputs[0].name).toBe('endpoint') + expect(node.inputs[0].type).toBe('options') + expect(node.inputs[0].default).toBe('search') + }) + + it('exposes all 11 Serper endpoints in the endpoint input', () => { + const endpointInput = node.inputs.find((i: any) => i.name === 'endpoint') + const optionNames = endpointInput.options.map((o: any) => o.name) + for (const ep of SERPER_ENDPOINTS) { + expect(optionNames).toContain(ep) + } + }) + + it('has all expected optional inputs', () => { + const inputNames = node.inputs.map((i: any) => i.name) + expect(inputNames).toContain('gl') + expect(inputNames).toContain('hl') + expect(inputNames).toContain('num') + expect(inputNames).toContain('page') + expect(inputNames).toContain('tbs') + expect(inputNames).toContain('location') + expect(inputNames).toContain('autocorrect') + expect(inputNames).toContain('imgar') + expect(inputNames).toContain('imgtype') + expect(inputNames).toContain('ll') + }) + + it('marks optional inputs as additionalParams', () => { + const optionalInputs = node.inputs.filter((i: any) => i.name !== 'endpoint') + for (const input of optionalInputs) { + expect(input.additionalParams).toBe(true) + expect(input.optional).toBe(true) + } + }) + + it('has non-empty baseClasses', () => { + expect(node.baseClasses.length).toBeGreaterThan(0) + }) + + it('has correct imgar options', () => { + const imgarInput = node.inputs.find((i: any) => i.name === 'imgar') + const imgarValues = imgarInput.options.map((o: any) => o.name) + expect(imgarValues).toContain('') + expect(imgarValues).toContain('t') + expect(imgarValues).toContain('w') + expect(imgarValues).toContain('s') + expect(imgarValues).toContain('xw') + }) + + it('has correct imgtype options', () => { + const imgtypeInput = node.inputs.find((i: any) => i.name === 'imgtype') + const values = imgtypeInput.options.map((o: any) => o.name) + expect(values).toContain('') + expect(values).toContain('photo') + expect(values).toContain('face') + expect(values).toContain('clipart') + expect(values).toContain('lineart') + expect(values).toContain('animated') + }) +}) + +// ─── Node Init Tests ─────────────────────────────────────────────────────────── + +describe('Serper_Tools.init()', () => { + let node: any + + beforeEach(() => { + jest.clearAllMocks() + node = new Serper_Tools() + }) + + it('returns a SerperTool instance', async () => { + const tool = await node.init(makeNodeData(), '', {}) + expect(tool).toBeInstanceOf(SerperTool) + }) + + it('creates tool with default search endpoint when no endpoint specified', async () => { + const tool = await node.init(makeNodeData({}), '', {}) + expect(tool.name).toBe('serper_search') + }) + + it.each(SERPER_ENDPOINTS)('creates correct tool for endpoint: %s', async (endpoint) => { + const tool = await node.init(makeNodeData({ endpoint }), '', {}) + expect(tool.name).toBe(`serper_${endpoint}`) + }) + + it('passes gl and hl to the tool config', async () => { + const tool = await node.init(makeNodeData({ gl: 'de', hl: 'de' }), '', {}) + mockOkResponse({ organic: [{ title: 'Test', snippet: 'Snippet', link: 'http://test.com' }] }) + await tool._call('test query') + expect(mockFetch).toHaveBeenCalledWith( + 'https://google.serper.dev/search', + expect.objectContaining({ + body: expect.stringContaining('"gl":"de"') + }) + ) + }) + + it('passes num parameter to the tool config', async () => { + const tool = await node.init(makeNodeData({ num: 20 }), '', {}) + mockOkResponse({ organic: [{ title: 'Test', snippet: 'Snippet', link: 'http://test.com' }] }) + await tool._call('test query') + expect(mockFetch).toHaveBeenCalledWith( + 'https://google.serper.dev/search', + expect.objectContaining({ + body: expect.stringContaining('"num":20') + }) + ) + }) + + it('passes tbs string directly to API', async () => { + const tool = await node.init(makeNodeData({ tbs: 'qdr:w' }), '', {}) + mockOkResponse({ organic: [] }) + await tool._call('test') + expect(mockFetch).toHaveBeenCalledWith( + 'https://google.serper.dev/search', + expect.objectContaining({ + body: expect.stringContaining('"tbs":"qdr:w"') + }) + ) + }) + + it('passes custom date range tbs to API', async () => { + const tool = await node.init(makeNodeData({ tbs: 'cdr:1,cd_min:01/01/2024,cd_max:06/30/2024' }), '', {}) + mockOkResponse({ organic: [] }) + await tool._call('test') + expect(mockFetch).toHaveBeenCalledWith( + 'https://google.serper.dev/search', + expect.objectContaining({ + body: expect.stringContaining('cdr:1') + }) + ) + }) + + it('passes imgtype to API for images endpoint', async () => { + const tool = await node.init(makeNodeData({ endpoint: 'images', imgtype: 'photo' }), '', {}) + mockOkResponse({ images: [] }) + await tool._call('test') + expect(mockFetch).toHaveBeenCalledWith( + 'https://google.serper.dev/images', + expect.objectContaining({ + body: expect.stringContaining('"imgtype":"photo"') + }) + ) + }) +}) + +// ─── SerperTool Core Tests ───────────────────────────────────────────────────── + +describe('SerperTool', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('sets name based on endpoint', () => { + const tool = new SerperTool({ apiKey: 'key', endpoint: 'news' }) + expect(tool.name).toBe('serper_news') + }) + + it.each(SERPER_ENDPOINTS)('has a description for endpoint: %s', (endpoint) => { + const tool = new SerperTool({ apiKey: 'key', endpoint }) + expect(tool.description).toBeTruthy() + expect(tool.description.length).toBeGreaterThan(10) + }) + + it('calls the correct Serper endpoint URL', async () => { + mockOkResponse({ organic: [] }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'news' }) + await tool._call('latest news') + expect(mockFetch).toHaveBeenCalledWith('https://google.serper.dev/news', expect.any(Object)) + }) + + it('sends X-API-KEY header', async () => { + mockOkResponse({ organic: [] }) + const tool = new SerperTool({ apiKey: 'my-secret-key', endpoint: 'search' }) + await tool._call('test') + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'X-API-KEY': 'my-secret-key' }) + }) + ) + }) + + it('throws on HTTP error', async () => { + mockErrorResponse(401, 'Unauthorized') + const tool = new SerperTool({ apiKey: 'bad-key', endpoint: 'search' }) + await expect(tool._call('test')).rejects.toThrow('401') + }) + + it('returns answerBox.answer when present', async () => { + mockOkResponse({ answerBox: { answer: '42' }, organic: [] }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'search' }) + const result = await tool._call('what is 6x7') + expect(result).toBe('42') + }) + + it('returns knowledgeGraph description when no answerBox', async () => { + mockOkResponse({ knowledgeGraph: { description: 'Paris is the capital of France.' }, organic: [] }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'search' }) + const result = await tool._call('Paris') + expect(result).toBe('Paris is the capital of France.') + }) + + it('formats organic results correctly', async () => { + mockOkResponse({ + organic: [ + { title: 'Result 1', snippet: 'Snippet 1', link: 'https://example.com/1' }, + { title: 'Result 2', snippet: 'Snippet 2', link: 'https://example.com/2' } + ] + }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'search' }) + const result = await tool._call('test') + expect(result).toContain('[1]') + expect(result).toContain('Result 1') + expect(result).toContain('[2]') + expect(result).toContain('https://example.com/2') + }) + + it('handles scrape endpoint with separate URL', async () => { + mockOkResponse({ text: 'Page content here' }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'scrape' }) + const result = await tool._call('https://example.com') + expect(mockFetch).toHaveBeenCalledWith('https://scrape.serper.dev', expect.any(Object)) + expect(result).toBe('Page content here') + }) + + it('returns autocomplete suggestions as newline-separated list', async () => { + mockOkResponse({ suggestions: [{ value: 'hello world' }, { value: 'hello kitty' }] }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'autocomplete' }) + const result = await tool._call('hello') + expect(result).toBe('hello world\nhello kitty') + }) + + it('falls back to JSON when no structured data', async () => { + const raw = { someUnknownField: 'value' } + mockOkResponse(raw) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'search' }) + const result = await tool._call('obscure query') + expect(result).toBe(JSON.stringify(raw)) + }) + + it('formats news results with date and source', async () => { + mockOkResponse({ + news: [{ title: 'Breaking News', snippet: 'Details...', link: 'https://news.com/1', date: '1 hour ago', source: 'CNN' }] + }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'news' }) + const result = await tool._call('breaking news') + expect(result).toContain('Breaking News') + expect(result).toContain('Date: 1 hour ago') + expect(result).toContain('Source: CNN') + }) + + it('formats shopping results with price', async () => { + mockOkResponse({ + shopping: [{ title: 'Widget', price: '$9.99', source: 'Amazon', link: 'https://amazon.com/w' }] + }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'shopping' }) + const result = await tool._call('widget') + expect(result).toContain('Price: $9.99') + }) + + it('formats scholar results with citation count', async () => { + mockOkResponse({ + organic: [{ title: 'A Study', snippet: 'Abstract...', link: 'https://scholar.com/1', publicationInfo: 'Nature 2023', citedBy: 42 }] + }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'scholar' }) + const result = await tool._call('machine learning') + expect(result).toContain('Publication: Nature 2023') + expect(result).toContain('Cited by: 42') + }) + + it('formats places results with address and rating', async () => { + mockOkResponse({ + places: [{ title: 'Cafe Central', address: '1 Main St', rating: 4.5, ratingCount: 200 }] + }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'places' }) + const result = await tool._call('cafe near me') + expect(result).toContain('Address: 1 Main St') + expect(result).toContain('Rating: 4.5 (200 reviews)') + }) + + it('formats patent results with inventor and assignee', async () => { + mockOkResponse({ + organic: [{ title: 'Patent #1', snippet: 'A device...', link: 'https://patents.google.com/1', priorityDate: '2020-01-01', inventor: 'Jane Doe', assignee: 'ACME Corp' }] + }) + const tool = new SerperTool({ apiKey: 'key', endpoint: 'patents' }) + const result = await tool._call('wireless device') + expect(result).toContain('Inventor: Jane Doe') + expect(result).toContain('Assignee: ACME Corp') + }) +}) diff --git a/packages/components/nodes/tools/Serper/Serper.ts b/packages/components/nodes/tools/Serper/Serper.ts index 2dbefafadbc..dacc18bcd6f 100644 --- a/packages/components/nodes/tools/Serper/Serper.ts +++ b/packages/components/nodes/tools/Serper/Serper.ts @@ -1,6 +1,6 @@ -import { Serper } from '@langchain/community/tools/serper' import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' +import { SerperTool } from './core' class Serper_Tools implements INode { label: string @@ -17,25 +17,177 @@ class Serper_Tools implements INode { constructor() { this.label = 'Serper' this.name = 'serper' - this.version = 1.0 + this.version = 2.0 this.type = 'Serper' this.icon = 'serper.svg' this.category = 'Tools' - this.description = 'Wrapper around Serper.dev - Google Search API' - this.inputs = [] + this.description = 'Access the full Serper.dev Google API — Web Search, News, Images, Videos, Places, Maps, Shopping, Scholar, Patents, Autocomplete & Web Scraping' this.credential = { label: 'Connect Credential', name: 'credential', type: 'credential', credentialNames: ['serperApi'] } - this.baseClasses = [this.type, ...getBaseClasses(Serper)] + this.inputs = [ + { + label: 'Search Endpoint', + name: 'endpoint', + type: 'options', + options: [ + { label: 'Web Search', name: 'search', description: 'Standard Google web search results' }, + { label: 'News', name: 'news', description: 'Google News articles and headlines' }, + { label: 'Images', name: 'images', description: 'Google Image search' }, + { label: 'Videos', name: 'videos', description: 'Google Video search (YouTube, etc.)' }, + { label: 'Places', name: 'places', description: 'Google Places — local business search' }, + { label: 'Maps', name: 'maps', description: 'Google Maps search with coordinates support' }, + { label: 'Shopping', name: 'shopping', description: 'Google Shopping — product & price search' }, + { label: 'Scholar', name: 'scholar', description: 'Google Scholar — academic papers & research' }, + { label: 'Patents', name: 'patents', description: 'Google Patents search' }, + { label: 'Autocomplete', name: 'autocomplete', description: 'Google search query suggestions' }, + { label: 'Web Scrape', name: 'scrape', description: 'Extract text content from a URL (5 credits/request, uses scrape.serper.dev)' } + ], + default: 'search', + description: 'The Serper API endpoint to use for this tool' + }, + { + label: 'Country (gl)', + name: 'gl', + type: 'string', + optional: true, + additionalParams: true, + placeholder: 'us', + description: 'Google country code to localize results (ISO 3166-1 alpha-2). Examples: us, gb, de, fr, jp, au, ca, es, it, br. Leave empty for global results.' + }, + { + label: 'Language (hl)', + name: 'hl', + type: 'string', + optional: true, + additionalParams: true, + placeholder: 'en', + description: 'Interface language code (ISO 639-1). Examples: en, de, fr, es, ja, ko, pt, it, zh. Affects UI language of search results metadata.' + }, + { + label: 'Number of Results (num)', + name: 'num', + type: 'number', + default: 10, + optional: true, + additionalParams: true, + description: 'Number of results to return (1–100, default: 10). Applies to Search, News, Images, Videos, Shopping, Scholar, Patents. Higher values consume more API credits.' + }, + { + label: 'Page (page)', + name: 'page', + type: 'number', + default: 1, + optional: true, + additionalParams: true, + description: 'Page number for paginating through results (default: 1). Use with num to navigate through result sets.' + }, + { + label: 'Time Filter (tbs)', + name: 'tbs', + type: 'string', + optional: true, + additionalParams: true, + placeholder: 'qdr:w', + description: "Time-based search filter using Google's internal tbs syntax. Presets: qdr:h (past hour), qdr:d (past 24h), qdr:w (past week), qdr:m (past month), qdr:y (past year). Custom date range (Google format, MM/DD/YYYY): cdr:1,cd_min:01/01/2024,cd_max:06/30/2024. Leave empty for any time. Supports {variables}." + }, + { + label: 'Location', + name: 'location', + type: 'string', + optional: true, + additionalParams: true, + placeholder: 'New York, New York, United States', + description: 'Geographic location string to further localize search results. Examples: "London, England, United Kingdom", "Munich, Bavaria, Germany". Applies to Search and News endpoints.' + }, + { + label: 'Autocorrect', + name: 'autocorrect', + type: 'boolean', + default: true, + optional: true, + additionalParams: true, + description: 'Enable Google query autocorrection (default: true). Disable for exact-match queries.' + }, + { + label: 'Image Aspect Ratio (imgar)', + name: 'imgar', + type: 'options', + options: [ + { label: 'Any', name: '' }, + { label: 'Tall (portrait)', name: 't' }, + { label: 'Wide (landscape)', name: 'w' }, + { label: 'Square', name: 's' }, + { label: 'Panoramic (extra wide)', name: 'xw' } + ], + default: '', + optional: true, + additionalParams: true, + description: 'Filter images by aspect ratio. Only applies to the Images endpoint.' + }, + { + label: 'Image Type (imgtype)', + name: 'imgtype', + type: 'options', + options: [ + { label: 'Any', name: '' }, + { label: 'Photo', name: 'photo' }, + { label: 'Face', name: 'face' }, + { label: 'Clipart', name: 'clipart' }, + { label: 'Line drawing', name: 'lineart' }, + { label: 'Animated (GIF)', name: 'animated' } + ], + default: '', + optional: true, + additionalParams: true, + description: 'Filter images by content type. Only applies to the Images endpoint.' + }, + { + label: 'Maps Coordinates (ll)', + name: 'll', + type: 'string', + optional: true, + additionalParams: true, + placeholder: '@40.7504178,-73.9824837,14z', + description: 'Latitude, longitude and zoom level for Maps endpoint. Format: @{lat},{lon},{zoom}z. Example: "@48.8566,2.3522,12z" for Paris. Only applies to the Maps endpoint.' + } + ] + this.baseClasses = [this.type, ...getBaseClasses(SerperTool)] } async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const credentialData = await getCredentialData(nodeData.credential ?? '', options) const serperApiKey = getCredentialParam('serperApiKey', credentialData, nodeData) - return new Serper(serperApiKey) + + const endpoint = ((nodeData.inputs?.endpoint as string) || 'search') as any + const gl = nodeData.inputs?.gl as string | undefined + const hl = nodeData.inputs?.hl as string | undefined + const num = nodeData.inputs?.num as number | undefined + const page = nodeData.inputs?.page as number | undefined + const tbs = nodeData.inputs?.tbs as string | undefined + const location = nodeData.inputs?.location as string | undefined + const autocorrect = nodeData.inputs?.autocorrect as boolean | undefined + const imgar = nodeData.inputs?.imgar as string | undefined + const imgtype = nodeData.inputs?.imgtype as string | undefined + const ll = nodeData.inputs?.ll as string | undefined + + return new SerperTool({ + apiKey: serperApiKey, + endpoint, + ...(gl && { gl }), + ...(hl && { hl }), + ...(num !== undefined && { num }), + ...(page !== undefined && { page }), + ...(tbs && { tbs }), + ...(location && { location }), + ...(autocorrect !== undefined && { autocorrect }), + ...(imgar && { imgar }), + ...(imgtype && { imgtype }), + ...(ll && { ll }) + }) } } diff --git a/packages/components/nodes/tools/Serper/core.ts b/packages/components/nodes/tools/Serper/core.ts new file mode 100644 index 00000000000..1c3cfacebe2 --- /dev/null +++ b/packages/components/nodes/tools/Serper/core.ts @@ -0,0 +1,197 @@ +import fetch from 'node-fetch' +import { Tool } from '@langchain/core/tools' + +export const SERPER_ENDPOINTS = ['search', 'news', 'images', 'videos', 'places', 'maps', 'shopping', 'scholar', 'patents', 'autocomplete', 'scrape'] as const +export type SerperEndpoint = (typeof SERPER_ENDPOINTS)[number] + +export interface SerperConfig { + apiKey: string + endpoint: SerperEndpoint + gl?: string + hl?: string + num?: number + page?: number + autocorrect?: boolean + tbs?: string + location?: string + imgar?: string + imgtype?: string + ll?: string +} + +const ENDPOINT_DESCRIPTIONS: Record = { + search: 'Search the web using Google Search. Input should be a search query string.', + news: 'Search Google News for recent news articles and headlines. Input should be a search query string.', + images: 'Search Google Images for pictures. Input should be a search query string describing what images to find.', + videos: 'Search Google Videos for video content. Input should be a search query string.', + places: 'Search Google Places for local businesses, restaurants, and points of interest. Input should be a location or business type query.', + maps: 'Search Google Maps for locations and businesses on a map. Input should be a location or business query.', + shopping: 'Search Google Shopping for products and prices. Input should be a product name or description.', + scholar: 'Search Google Scholar for academic papers and research. Input should be a research topic or paper title.', + patents: 'Search Google Patents for patent filings. Input should be a patent topic or invention description.', + autocomplete: 'Get Google search autocomplete suggestions for a partial query. Input should be a partial search query.', + scrape: 'Extract and return the full text content of a webpage. Input must be a complete URL starting with https:// or http://.' +} + +const RESULT_KEY_MAP: Partial> = { + news: 'news', + images: 'images', + videos: 'videos', + places: 'places', + maps: 'places', + shopping: 'shopping', + scholar: 'organic', + patents: 'organic', + search: 'organic' +} + +export class SerperTool extends Tool { + static lc_name() { + return 'SerperTool' + } + + name: string + description: string + private config: SerperConfig + + constructor(config: SerperConfig) { + super() + this.config = config + this.name = `serper_${config.endpoint}` + this.description = ENDPOINT_DESCRIPTIONS[config.endpoint] + } + + async _call(input: string): Promise { + try { + if (this.config.endpoint === 'scrape') { + return await this._scrape(input.trim()) + } + return await this._search(input.trim()) + } catch (error: any) { + throw new Error(`Serper API error (${this.config.endpoint}): ${error.message}`) + } + } + + private async _search(query: string): Promise { + const { apiKey, endpoint, gl, hl, num, page, autocorrect, tbs, location, imgar, imgtype, ll } = this.config + + const body: Record = { q: query } + if (gl) body.gl = gl + if (hl) body.hl = hl + if (num !== undefined && num > 0) body.num = num + if (page !== undefined && page > 1) body.page = page + if (autocorrect !== undefined) body.autocorrect = autocorrect + if (tbs) body.tbs = tbs + if (location) body.location = location + if (imgar) body.imgar = imgar + if (imgtype) body.imgtype = imgtype + if (ll) body.ll = ll + + const res = await fetch(`https://google.serper.dev/${endpoint}`, { + method: 'POST', + headers: { + 'X-API-KEY': apiKey, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + const errorText = await res.text().catch(() => res.statusText) + throw new Error(`HTTP ${res.status}: ${errorText}`) + } + + const json: any = await res.json() + return this._parseSearchResponse(json) + } + + private async _scrape(url: string): Promise { + const res = await fetch('https://scrape.serper.dev', { + method: 'POST', + headers: { + 'X-API-KEY': this.config.apiKey, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url }) + }) + + if (!res.ok) { + const errorText = await res.text().catch(() => res.statusText) + throw new Error(`HTTP ${res.status}: ${errorText}`) + } + + const json: any = await res.json() + if (json == null) { + throw new Error('Invalid response from Serper API') + } + + if (json.text) return json.text + if (json.metadata?.title) return json.metadata.title + '\n\n' + JSON.stringify(json) + return JSON.stringify(json) + } + + private _parseSearchResponse(json: any): string { + if (json == null) { + throw new Error('Invalid search response data') + } + const endpoint = this.config.endpoint + + if (json.suggestions && Array.isArray(json.suggestions)) { + return json.suggestions.map((s: any) => s.value || s).join('\n') + } + + if (json.answerBox) { + if (json.answerBox.answer) return json.answerBox.answer + if (json.answerBox.snippet) return json.answerBox.snippet + if (json.answerBox.snippet_highlighted_words?.length) return json.answerBox.snippet_highlighted_words[0] + } + + if (json.sportsResults?.game_spotlight) return json.sportsResults.game_spotlight + + if (json.knowledgeGraph?.description) return json.knowledgeGraph.description + + const resultKey = RESULT_KEY_MAP[endpoint] + if (resultKey && json[resultKey] && Array.isArray(json[resultKey]) && json[resultKey].length > 0) { + return this._formatResultArray(json[resultKey], endpoint) + } + + return JSON.stringify(json) + } + + private _formatResultArray(results: any[], endpoint: SerperEndpoint): string { + return results + .map((r: any, i: number) => { + const parts: string[] = [`[${i + 1}]`] + + if (r.title) parts.push(r.title) + if (r.snippet) parts.push(`- ${r.snippet}`) + if (r.link || r.website) parts.push(`URL: ${r.link || r.website}`) + if (r.date) parts.push(`Date: ${r.date}`) + if (r.source) parts.push(`Source: ${r.source}`) + + if (r.imageUrl) parts.push(`Image: ${r.imageUrl}`) + if (r.imageWidth && r.imageHeight) parts.push(`(${r.imageWidth}x${r.imageHeight})`) + + if (r.duration) parts.push(`Duration: ${r.duration}`) + if (r.channel) parts.push(`Channel: ${r.channel}`) + + if (r.address) parts.push(`Address: ${r.address}`) + if (r.rating !== undefined) parts.push(`Rating: ${r.rating}${r.ratingCount ? ` (${r.ratingCount} reviews)` : ''}`) + if (r.phoneNumber) parts.push(`Phone: ${r.phoneNumber}`) + if (r.category || r.type) parts.push(`Category: ${r.category || r.type}`) + + if (r.price) parts.push(`Price: ${r.price}`) + if (r.delivery) parts.push(`Delivery: ${r.delivery}`) + + if (r.publicationInfo) parts.push(`Publication: ${r.publicationInfo}`) + if (r.citedBy !== undefined) parts.push(`Cited by: ${r.citedBy}`) + + if (r.priorityDate) parts.push(`Priority Date: ${r.priorityDate}`) + if (r.inventor) parts.push(`Inventor: ${r.inventor}`) + if (r.assignee) parts.push(`Assignee: ${r.assignee}`) + + return parts.join(' ') + }) + .join('\n\n') + } +}