{"version":3,"file":"brave.mjs","names":[],"sources":["../../src/providers/brave.ts"],"sourcesContent":["import type { SearchResult, SearchRequestOptions, ProviderConfig } from \"../core/types.ts\";\nimport { Provider, type ProviderSearchPage } from \"../core/provider.ts\";\nimport { AuthError, InvalidSearchContinuationError, normalizeError } from \"../core/errors.ts\";\n\ninterface BraveResult {\n  readonly title: string;\n  readonly url: string;\n  readonly description?: string | null;\n  readonly extra_snippets?: readonly string[];\n  readonly age?: string;\n  readonly page_age?: string | null;\n  readonly language?: string;\n  readonly family_friendly?: boolean;\n  readonly meta_url?: {\n    readonly favicon?: string;\n  };\n}\n\ninterface BraveSearchResponse {\n  readonly query?: {\n    readonly more_results_available?: boolean;\n  };\n  readonly web?: {\n    readonly results: readonly BraveResult[];\n  };\n}\n\nexport class BraveProvider extends Provider {\n  static readonly providerName = \"brave\";\n  static readonly defaultBaseURL = \"https://api.search.brave.com\";\n\n  private readonly apiKey: string;\n\n  constructor(config: Readonly<ProviderConfig>) {\n    super(config, BraveProvider);\n    if (!config.apiKey) {\n      throw new AuthError(\"Missing API key for Brave Search. Set BRAVE_API_KEY\", \"brave\");\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 offset = braveOffset(continuation);\n      const url = braveSearchUrl(this.baseURL, query, options ?? {}, offset);\n      const headers = { \"X-Subscription-Token\": this.apiKey };\n      const response = await this.client.getJSON<BraveSearchResponse>(\n        url,\n        headers,\n        options?.signal,\n      );\n      return {\n        results: (response.web?.results ?? []).map(mapResult),\n        ...braveContinuation(response, offset),\n      };\n    } catch (error) {\n      throw normalizeError(error, \"brave\");\n    }\n  }\n}\n\nfunction braveSearchUrl(\n  baseURL: string,\n  query: string,\n  options: SearchRequestOptions,\n  offset: number,\n): string {\n  const offsetParam = offset === 0 ? \"\" : `&offset=${offset}`;\n  return `${baseURL}/res/v1/web/search?q=${encodeURIComponent(query)}&count=${options.maxResults ?? 10}&extra_snippets=true&text_decorations=false${offsetParam}${freshnessParam(options)}`;\n}\n\n/**\n * Brave documents only the closed `freshness=YYYY-MM-DDtoYYYY-MM-DD` and drops any other spelling\n * without a word, so a lone bound is closed with the epoch or today's UTC date.\n * @param options - Search options requested by the caller.\n * @returns {string} The `freshness` query parameter, or nothing without a date bound.\n */\nfunction freshnessParam(options: SearchRequestOptions): string {\n  const start = day(options.startPublishedDate);\n  const end = day(options.endPublishedDate);\n  if (start === undefined && end === undefined) return \"\";\n  return `&freshness=${start ?? FRESHNESS_FLOOR}to${end ?? day(new Date().toISOString())}`;\n}\n\n/**\n * The day in UTC, so two bounds with different offsets keep the order the core checked them in.\n * @param value - ISO 8601 date or datetime, or nothing.\n * @returns {string | undefined} `YYYY-MM-DD`, cut from the text when it does not parse.\n */\nfunction day(value?: string): string | undefined {\n  if (!value) return undefined;\n  const time = Date.parse(value);\n  return Number.isNaN(time) ? value.slice(0, 10) : new Date(time).toISOString().slice(0, 10);\n}\n\nfunction braveContinuation(\n  response: Readonly<BraveSearchResponse>,\n  offset: number,\n): Record<string, string> {\n  if (response.query?.more_results_available !== true || offset >= 9) return {};\n  return { continuation: String(offset + 1) };\n}\n\nfunction braveOffset(continuation?: string): number {\n  if (continuation === undefined) return 0;\n  if (!/^[1-9]$/u.test(continuation)) throw new InvalidSearchContinuationError();\n  return Number(continuation);\n}\n\n/**\n * `page_age` is Brave's date for the page, published or last modified, `null` when it has none.\n * @param result - One Brave web result.\n * @returns {SearchResult} Normalized search result.\n */\nfunction mapResult(result: BraveResult): SearchResult {\n  return {\n    url: result.url,\n    title: result.title,\n    snippet: decodeHtml(result.description ?? \"\"),\n    ...(result.page_age ? { publishedDate: result.page_age } : {}),\n    favicon: result.meta_url?.favicon,\n    text: extraSnippetText(result.extra_snippets),\n  };\n}\n\nconst FRESHNESS_FLOOR = \"1970-01-01\";\n\nconst HTML_ENTITY = /&(?:#x([0-9a-f]+)|#([0-9]+)|([a-z]+));/giu;\n\nconst NAMED_ENTITIES: ReadonlyMap<string, string> = new Map([\n  [\"amp\", \"&\"],\n  [\"lt\", \"<\"],\n  [\"gt\", \">\"],\n  [\"quot\", '\"'],\n  [\"apos\", \"'\"],\n]);\n\n/**\n * Brave escapes `description` like an HTML fragment, `title` and `extra_snippets` come as text.\n * @param text - Description as Brave sends it.\n * @returns {string} The description as text. An unknown entity stays as written.\n */\nfunction decodeHtml(text: string): string {\n  return text.replaceAll(\n    HTML_ENTITY,\n    (\n      entity: string,\n      hex: string | undefined,\n      decimal: string | undefined,\n      name: string | undefined,\n    ) => {\n      if (name !== undefined) return NAMED_ENTITIES.get(name.toLowerCase()) ?? entity;\n      const codePoint = Number.parseInt(hex ?? decimal ?? \"\", hex === undefined ? 10 : 16);\n      return isScalarValue(codePoint) ? String.fromCodePoint(codePoint) : entity;\n    },\n  );\n}\n\n/**\n * @param codePoint - Number parsed from a numeric character reference.\n * @returns {boolean} Whether `String.fromCodePoint` can encode it as one well formed character.\n */\nfunction isScalarValue(codePoint: number): boolean {\n  return codePoint <= 0x10ffff && (codePoint < 0xd800 || codePoint > 0xdfff);\n}\n\nfunction extraSnippetText(snippets?: readonly string[]): string | undefined {\n  const parts = snippets?.filter((snippet) => snippet.length > 0) ?? [];\n  return parts.length > 0 ? parts.join(\"\\n\") : undefined;\n}\n"],"mappings":"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"}