306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
|
|
import { env } from "../env.js";
|
|
|
|
const BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
|
|
const BRAVE_SEARCH_TIMEOUT_MS = 12_000;
|
|
const DEFAULT_BRAVE_REQUEST_INTERVAL_MS = 1_000;
|
|
const RATE_LIMIT_INTERVAL_SAFETY_RATIO = 0.05;
|
|
const MIN_RATE_LIMIT_INTERVAL_SAFETY_MS = 2;
|
|
const RATE_LIMIT_RESET_SAFETY_MS = 50;
|
|
const MAX_RATE_LIMIT_RETRIES = 3;
|
|
const MAX_RATE_LIMIT_RETRY_DELAY_MS = 8_000;
|
|
|
|
type RateLimitPolicy = {
|
|
limit: number;
|
|
windowSeconds: number;
|
|
};
|
|
|
|
let requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
|
let lastRequestAtMs = 0;
|
|
let nextRequestAtMs = 0;
|
|
let quotaUnavailableUntilMs = 0;
|
|
let requestQueue = Promise.resolve();
|
|
|
|
export type BraveSearchOptions = {
|
|
numResults: number;
|
|
includeDomains?: string[];
|
|
excludeDomains?: string[];
|
|
};
|
|
|
|
export type BraveSearchResult = {
|
|
title: string | null;
|
|
url: string | null;
|
|
publishedDate: string | null;
|
|
author: string | null;
|
|
summary: string | null;
|
|
text: string | null;
|
|
highlights: string[];
|
|
};
|
|
|
|
export type BraveSearchResponse = {
|
|
query: string;
|
|
requestId: string | null;
|
|
results: BraveSearchResult[];
|
|
};
|
|
|
|
function clipText(input: string, maxCharacters: number) {
|
|
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
|
|
}
|
|
|
|
function compactWhitespace(input: string) {
|
|
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function requireBraveSearchApiKey() {
|
|
if (!env.BRAVE_SEARCH_API_KEY) {
|
|
throw new Error("BRAVE_SEARCH_API_KEY not set");
|
|
}
|
|
return env.BRAVE_SEARCH_API_KEY;
|
|
}
|
|
|
|
function sleep(milliseconds: number) {
|
|
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
function addIntervalSafety(intervalMs: number) {
|
|
return intervalMs + Math.max(MIN_RATE_LIMIT_INTERVAL_SAFETY_MS, Math.ceil(intervalMs * RATE_LIMIT_INTERVAL_SAFETY_RATIO));
|
|
}
|
|
|
|
function parseCommaSeparatedNumbers(value: string | null) {
|
|
if (!value) return [];
|
|
return value.split(",").map((part) => Number(part.trim())).map((number) => (Number.isFinite(number) ? number : null));
|
|
}
|
|
|
|
function parseRateLimitPolicy(value: string | null): RateLimitPolicy[] {
|
|
if (!value) return [];
|
|
return value.split(",").flatMap((part) => {
|
|
const match = part.trim().match(/^(\d+)\s*;\s*w=(\d+)$/i);
|
|
if (!match) return [];
|
|
const limit = Number(match[1]);
|
|
const windowSeconds = Number(match[2]);
|
|
return limit > 0 && windowSeconds > 0 ? [{ limit, windowSeconds }] : [];
|
|
});
|
|
}
|
|
|
|
function getBurstPolicyIndex(policies: RateLimitPolicy[]) {
|
|
if (!policies.length) return null;
|
|
let burstIndex = 0;
|
|
for (let index = 1; index < policies.length; index += 1) {
|
|
if (policies[index]!.windowSeconds < policies[burstIndex]!.windowSeconds) burstIndex = index;
|
|
}
|
|
return burstIndex;
|
|
}
|
|
|
|
function updateRateLimitState(headers: Headers) {
|
|
const policies = parseRateLimitPolicy(headers.get("x-ratelimit-policy"));
|
|
const burstIndex = getBurstPolicyIndex(policies);
|
|
if (burstIndex === null) return;
|
|
|
|
const burstPolicy = policies[burstIndex]!;
|
|
const learnedIntervalMs = addIntervalSafety(Math.ceil((burstPolicy.windowSeconds * 1_000) / burstPolicy.limit));
|
|
if (learnedIntervalMs < requestIntervalMs && lastRequestAtMs > 0) {
|
|
nextRequestAtMs = Math.min(nextRequestAtMs, lastRequestAtMs + learnedIntervalMs);
|
|
}
|
|
requestIntervalMs = learnedIntervalMs;
|
|
|
|
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
|
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
|
for (let index = 0; index < policies.length; index += 1) {
|
|
if ((remaining[index] ?? null) === null || remaining[index]! >= 1 || (resetSeconds[index] ?? 0) <= 0) continue;
|
|
const unavailableUntilMs = Date.now() + resetSeconds[index]! * 1_000 + RATE_LIMIT_RESET_SAFETY_MS;
|
|
if (index === burstIndex) {
|
|
nextRequestAtMs = Math.max(nextRequestAtMs, unavailableUntilMs);
|
|
} else {
|
|
quotaUnavailableUntilMs = Math.max(quotaUnavailableUntilMs, unavailableUntilMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertLongTermQuotaAvailable() {
|
|
if (quotaUnavailableUntilMs <= Date.now()) {
|
|
quotaUnavailableUntilMs = 0;
|
|
return;
|
|
}
|
|
const resetSeconds = Math.ceil((quotaUnavailableUntilMs - Date.now()) / 1_000);
|
|
throw new Error(`Brave Search API long-term quota is exhausted; reset is expected in ${resetSeconds} seconds.`);
|
|
}
|
|
|
|
async function waitForRateLimitSlot() {
|
|
const reservation = requestQueue.then(async () => {
|
|
while (true) {
|
|
assertLongTermQuotaAvailable();
|
|
const waitMs = nextRequestAtMs - Date.now();
|
|
if (waitMs <= 0) break;
|
|
await sleep(waitMs);
|
|
}
|
|
lastRequestAtMs = Date.now();
|
|
nextRequestAtMs = lastRequestAtMs + requestIntervalMs;
|
|
});
|
|
requestQueue = reservation.catch(() => undefined);
|
|
await reservation;
|
|
}
|
|
|
|
function get429RetryDelayMs(headers: Headers, retryNumber: number) {
|
|
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
|
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
|
const exhaustedResetSeconds = resetSeconds.filter((reset, index): reset is number => reset !== null && (remaining[index] ?? 0) < 1);
|
|
const headerDelayMs = exhaustedResetSeconds.length ? Math.max(...exhaustedResetSeconds) * 1_000 : 0;
|
|
const exponentialDelayMs = 2 ** retryNumber * 1_000;
|
|
const delayMs = Math.max(headerDelayMs + RATE_LIMIT_RESET_SAFETY_MS, exponentialDelayMs);
|
|
return delayMs <= MAX_RATE_LIMIT_RETRY_DELAY_MS ? delayMs : null;
|
|
}
|
|
|
|
async function fetchBrave(url: URL) {
|
|
const apiKey = requireBraveSearchApiKey();
|
|
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt += 1) {
|
|
await waitForRateLimitSlot();
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), BRAVE_SEARCH_TIMEOUT_MS);
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
signal: controller.signal,
|
|
headers: {
|
|
...buildBrowserLikeRequestHeaders("application/json"),
|
|
"X-Subscription-Token": apiKey,
|
|
},
|
|
});
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
updateRateLimitState(response.headers);
|
|
if (response.status !== 429 || attempt === MAX_RATE_LIMIT_RETRIES) return response;
|
|
|
|
const retryDelayMs = get429RetryDelayMs(response.headers, attempt);
|
|
await response.arrayBuffer();
|
|
if (retryDelayMs === null) {
|
|
throw new Error("Brave Search API rate limit quota is exhausted beyond the retry window.");
|
|
}
|
|
await sleep(retryDelayMs);
|
|
}
|
|
|
|
throw new Error("Brave Search API request failed after rate-limit retries.");
|
|
}
|
|
|
|
function normalizeDomain(input: string) {
|
|
const trimmed = input.trim().toLowerCase();
|
|
if (!trimmed) return null;
|
|
|
|
try {
|
|
const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
|
return parsed.hostname.replace(/^www\./, "");
|
|
} catch {
|
|
return trimmed.split(/[/?#]/, 1)[0]?.replace(/^www\./, "") || null;
|
|
}
|
|
}
|
|
|
|
function normalizeDomains(input: string[] | undefined) {
|
|
return Array.from(new Set((input ?? []).map(normalizeDomain).filter((domain): domain is string => Boolean(domain))));
|
|
}
|
|
|
|
function hostnameMatchesDomain(urlRaw: string | null, domain: string) {
|
|
if (!urlRaw) return false;
|
|
try {
|
|
const hostname = new URL(urlRaw).hostname.toLowerCase().replace(/^www\./, "");
|
|
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function filterResultsByDomains(results: BraveSearchResult[], options: BraveSearchOptions) {
|
|
const includeDomains = normalizeDomains(options.includeDomains);
|
|
const excludeDomains = normalizeDomains(options.excludeDomains);
|
|
return results.filter((result) => {
|
|
if (includeDomains.length && !includeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
|
if (excludeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function buildBraveQuery(query: string, options: BraveSearchOptions) {
|
|
const includeDomains = normalizeDomains(options.includeDomains);
|
|
const excludeDomains = normalizeDomains(options.excludeDomains);
|
|
const includeClause =
|
|
includeDomains.length === 0
|
|
? ""
|
|
: includeDomains.length === 1
|
|
? `site:${includeDomains[0]}`
|
|
: `(${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
|
|
const excludeClause = excludeDomains.map((domain) => `-site:${domain}`).join(" ");
|
|
return [query, includeClause, excludeClause].filter(Boolean).join(" ");
|
|
}
|
|
|
|
function buildSearchUrl(query: string, options: BraveSearchOptions) {
|
|
const url = new URL(BRAVE_WEB_SEARCH_URL);
|
|
url.searchParams.set("q", buildBraveQuery(query, options));
|
|
url.searchParams.set("count", String(options.numResults));
|
|
url.searchParams.set("safesearch", "moderate");
|
|
url.searchParams.set("result_filter", "web");
|
|
url.searchParams.set("text_decorations", "false");
|
|
url.searchParams.set("extra_snippets", "true");
|
|
return url;
|
|
}
|
|
|
|
function stringOrNull(value: unknown) {
|
|
if (typeof value !== "string") return null;
|
|
const normalized = compactWhitespace(value);
|
|
return normalized || null;
|
|
}
|
|
|
|
function stringArray(value: unknown) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter((item): item is string => typeof item === "string").map(compactWhitespace).filter(Boolean);
|
|
}
|
|
|
|
function mapWebResult(result: any): BraveSearchResult {
|
|
const description = stringOrNull(result?.description);
|
|
const extraSnippets = stringArray(result?.extra_snippets);
|
|
const snippets = [description, ...extraSnippets].filter((snippet): snippet is string => Boolean(snippet));
|
|
const combinedText = snippets.join("\n\n");
|
|
|
|
return {
|
|
title: stringOrNull(result?.title),
|
|
url: stringOrNull(result?.url),
|
|
publishedDate: stringOrNull(result?.page_age),
|
|
author: stringOrNull(result?.profile?.name) ?? stringOrNull(result?.article?.author),
|
|
summary: description ? clipText(description, 1_400) : null,
|
|
text: combinedText ? clipText(combinedText, 700) : null,
|
|
highlights: snippets.slice(0, 3).map((snippet) => clipText(snippet, 280)),
|
|
};
|
|
}
|
|
|
|
export async function searchBrave(query: string, options: BraveSearchOptions): Promise<BraveSearchResponse> {
|
|
const url = buildSearchUrl(query, options);
|
|
const response = await fetchBrave(url);
|
|
|
|
if (!response.ok) {
|
|
await response.arrayBuffer();
|
|
throw new Error(`Brave Search API request failed with status ${response.status}.`);
|
|
}
|
|
|
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
if (!contentType.includes("application/json")) {
|
|
await response.arrayBuffer();
|
|
throw new Error(`Brave Search API returned ${contentType || "unknown content type"}.`);
|
|
}
|
|
|
|
const data: any = await response.json();
|
|
const results = Array.isArray(data?.web?.results) ? data.web.results.map(mapWebResult) : [];
|
|
return {
|
|
query,
|
|
requestId: response.headers.get("x-request-id"),
|
|
results: filterResultsByDomains(results, options).slice(0, options.numResults),
|
|
};
|
|
}
|
|
|
|
export function resetBraveRateLimitStateForTests() {
|
|
requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
|
lastRequestAtMs = 0;
|
|
nextRequestAtMs = 0;
|
|
quotaUnavailableUntilMs = 0;
|
|
requestQueue = Promise.resolve();
|
|
}
|