{"version":3,"file":"serpapi.mjs","names":[],"sources":["../../src/providers/serpapi.ts"],"sourcesContent":["import type {\n  ImageSearchRequestOptions,\n  ImageSearchResult,\n  ProviderConfig,\n  SearchRequestOptions,\n  SearchResult,\n} from \"../core/types.ts\";\nimport { Provider, type ProviderSearchPage } from \"../core/provider.ts\";\nimport {\n  AuthError,\n  InvalidSearchContinuationError,\n  WebError,\n  normalizeError,\n} from \"../core/errors.ts\";\n\ninterface SerpApiResult {\n  readonly position: number;\n  readonly title: string;\n  readonly link: string;\n  readonly snippet: string;\n  readonly displayed_link?: string;\n  readonly favicon?: string;\n  readonly date?: string;\n  readonly source?: string;\n  readonly thumbnail?: string;\n}\n\ninterface SerpApiSearchResponse {\n  readonly search_metadata: {\n    readonly id: string;\n    readonly status: string;\n  };\n  readonly organic_results?: readonly SerpApiResult[];\n  readonly serpapi_pagination?: {\n    readonly next?: string;\n  };\n}\n\ninterface SerpApiVisualMatch {\n  readonly position?: number;\n  readonly title?: string;\n  readonly link?: string;\n  readonly source?: string;\n  readonly thumbnail?: string;\n  readonly thumbnail_width?: number;\n  readonly thumbnail_height?: number;\n  readonly image?: string;\n  readonly image_width?: number;\n  readonly image_height?: number;\n  readonly exact_matches?: boolean;\n}\n\ninterface SerpApiImageSearchResponse {\n  readonly search_metadata?: {\n    readonly id: string;\n    readonly status: string;\n  };\n  readonly visual_matches?: readonly SerpApiVisualMatch[];\n  readonly error?: string;\n}\n\nexport class SerpApiProvider extends Provider {\n  static readonly providerName = \"serpapi\";\n  static readonly defaultBaseURL = \"https://serpapi.com\";\n\n  private readonly apiKey: string;\n\n  constructor(config: Readonly<ProviderConfig>) {\n    super(config, SerpApiProvider);\n    if (!config.apiKey) {\n      throw new AuthError(\"Missing API key for SerpAPI. Set SERPAPI_API_KEY\", \"serpapi\");\n    }\n\n    this.apiKey = config.apiKey;\n  }\n\n  async search(query: string, options?: SearchRequestOptions): Promise<SearchResult[]> {\n    return (await this.searchPage(query, options)).results;\n  }\n\n  async searchPage(\n    query: string,\n    options?: SearchRequestOptions,\n    continuation?: string,\n  ): Promise<ProviderSearchPage> {\n    try {\n      const page = serpApiPage(continuation);\n      const limit = resultLimit(options?.maxResults);\n      const url = `${this.baseURL}/search?engine=google&q=${encodeURIComponent(query)}&api_key=${this.apiKey}&num=${limit}${page.start === 0 ? \"\" : `&start=${page.start}`}`;\n      const response = await this.client.getJSON<SerpApiSearchResponse>(\n        url,\n        undefined,\n        options?.signal,\n      );\n      const organic = response.organic_results ?? [];\n      const results = organic.slice(page.offset, page.offset + limit);\n      return {\n        results: results.map(mapResult),\n        ...serpApiContinuation(\n          page,\n          page.offset + results.length,\n          organic.length,\n          response.serpapi_pagination?.next,\n        ),\n      };\n    } catch (error) {\n      throw normalizeError(error, \"serpapi\");\n    }\n  }\n\n  async searchByImage(\n    imageUrl: string,\n    options?: ImageSearchRequestOptions,\n  ): Promise<ImageSearchResult[]> {\n    try {\n      const url = new URL(`${this.baseURL}/search`);\n      url.searchParams.set(\"engine\", \"google_lens\");\n      url.searchParams.set(\"type\", \"visual_matches\");\n      url.searchParams.set(\"url\", imageUrl);\n      url.searchParams.set(\"api_key\", this.apiKey);\n      const response = await this.client.getJSON<SerpApiImageSearchResponse>(\n        url.href,\n        undefined,\n        options?.signal,\n      );\n      return visualMatches(response)\n        .flatMap(mapImageResult)\n        .slice(0, options?.maxResults ?? 10);\n    } catch (error) {\n      throw normalizeError(error, \"serpapi\");\n    }\n  }\n}\n\n/**\n * Slice size for one call: a positive integer, ten when the caller gave nothing usable.\n * @param maxResults - Requested result count.\n * @returns {number} The number of organic results to hand back.\n */\nfunction resultLimit(maxResults?: number): number {\n  if (maxResults === undefined || !Number.isFinite(maxResults)) return 10;\n  return Math.max(Math.trunc(maxResults), 1);\n}\n\n/** Google `start` offset of the page plus the position inside it where the next slice begins. */\ninterface SerpApiPage {\n  readonly start: number;\n  readonly offset: number;\n}\n\n/**\n * Google pays little attention to `num` and hands a different page for an offset off the ten\n * grid, so a page is walked in slices of `maxResults` and `start` moves only once it is used up.\n * @param continuation - `start`, or `start:offset` for a slice inside the page.\n * @returns {SerpApiPage} The page to request and the slice to return from it.\n */\nfunction serpApiPage(continuation?: string): SerpApiPage {\n  if (continuation === undefined) return { start: 0, offset: 0 };\n  const match = /^(?<start>0|[1-9]\\d*)(?::(?<offset>[1-9]\\d*))?$/u.exec(continuation);\n  if (!match?.groups) throw new InvalidSearchContinuationError();\n  const start = Number(match.groups.start);\n  const offset = match.groups.offset === undefined ? 0 : Number(match.groups.offset);\n  if (start + offset === 0 || !Number.isSafeInteger(start + offset)) {\n    throw new InvalidSearchContinuationError();\n  }\n  return { start, offset };\n}\n\nfunction serpApiContinuation(\n  page: SerpApiPage,\n  nextOffset: number,\n  pageLength: number,\n  next?: string,\n): Record<string, string> {\n  if (nextOffset < pageLength) return { continuation: `${page.start}:${nextOffset}` };\n  return nextPageContinuation(next);\n}\n\nfunction nextPageContinuation(next?: string): Record<string, string> {\n  if (next === undefined) return {};\n  try {\n    const start = new URL(next).searchParams.get(\"start\");\n    return start === null ? {} : { continuation: String(serpApiPage(start).start) };\n  } catch {\n    return {};\n  }\n}\n\n/**\n * Lens reports an empty page as `error` under a `Success` status, so only a failed search throws.\n * @param response - Google Lens response body.\n * @returns {readonly SerpApiVisualMatch[]} Visual matches, empty when Lens found none.\n */\nfunction visualMatches(response: SerpApiImageSearchResponse): readonly SerpApiVisualMatch[] {\n  if (response.error && response.search_metadata?.status !== \"Success\") {\n    throw new WebError(response.error);\n  }\n  return response.visual_matches ?? [];\n}\n\nfunction mapImageResult(result: SerpApiVisualMatch): ImageSearchResult[] {\n  const imageUrl = result.image ?? result.thumbnail;\n  if (!result.link || !imageUrl) return [];\n\n  return [\n    {\n      pageUrl: result.link,\n      imageUrl,\n      title: result.title ?? result.source ?? \"\",\n      provider: \"serpapi\",\n      source: result.source,\n      thumbnailUrl: result.thumbnail,\n      imageWidth: result.image_width,\n      imageHeight: result.image_height,\n      thumbnailWidth: result.thumbnail_width,\n      thumbnailHeight: result.thumbnail_height,\n      position: result.position,\n      exactMatch: result.exact_matches,\n    },\n  ];\n}\n\nfunction mapResult(result: SerpApiResult): SearchResult {\n  return {\n    url: result.link,\n    title: result.title,\n    snippet: result.snippet,\n    favicon: result.favicon,\n    publishedDate: result.date,\n    image: result.thumbnail,\n    metadata: {\n      position: result.position,\n      source: result.source,\n      displayedLink: result.displayed_link,\n    },\n  };\n}\n"],"mappings":"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"}