Repository navigation
Expand file tree
/
Copy pathserper.ts
More file actions
190 lines (174 loc) · 7.17 KB
/
Copy pathserper.ts
File metadata and controls
190 lines (174 loc) · 7.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
import { existsSync, readFileSync } from "node:fs";
import { activityMonitor } from "./activity.ts";
import { normalizeDomain } from "./domain-filter-normalization.ts";
import type { SearchOptions, SearchResponse } from "./perplexity.ts";
import { formatSearchResultsAsAnswer } from "./search-answer-formatting.ts";
import { normalizeSearchResultCount } from "./search-result-count-normalization.ts";
import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.ts";
import { fetchWithCredentialRedirects, getWebSearchConfigPath, resolveProviderApiUrl } from "./utils.ts";
const SERPER_API_BASE_URL = "https://google.serper.dev";
const CONFIG_PATH = getWebSearchConfigPath();
const SEARCH_TIMEOUT_MS = 60_000;
const RECENCY_TBS: Record<NonNullable<SearchOptions["recencyFilter"]>, string> = {
day: "qdr:d",
week: "qdr:w",
month: "qdr:m",
year: "qdr:y",
};
interface WebSearchConfig {
serperApiKey?: unknown;
serperBaseUrl?: unknown;
}
interface SerperResult {
title?: unknown;
link?: unknown;
snippet?: unknown;
}
let cachedConfig: WebSearchConfig | null = null;
function loadConfig(): WebSearchConfig {
if (cachedConfig) return cachedConfig;
if (!existsSync(CONFIG_PATH)) {
cachedConfig = {};
return cachedConfig;
}
const raw = readFileSync(CONFIG_PATH, "utf-8");
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
}
cachedConfig = parsed as WebSearchConfig;
return cachedConfig;
}
async function getApiKey(signal?: AbortSignal): Promise<string | null> {
return resolveCredential({
provider: "Serper",
configuredValue: loadConfig().serperApiKey,
environmentValue: process.env.SERPER_API_KEY,
signal,
});
}
async function requireApiKey(signal?: AbortSignal): Promise<string> {
const apiKey = await getApiKey(signal);
if (!apiKey) {
throw new Error(
"Serper API key not found. Either:\n" +
` 1. Create ${CONFIG_PATH} with { "serperApiKey": "your-key" }\n` +
" 2. Set SERPER_API_KEY environment variable\n" +
"Get a key at https://serper.dev",
);
}
return apiKey;
}
interface DomainFilters {
include: string[];
exclude: string[];
}
function parseDomainFilter(domainFilter: string[] | undefined): DomainFilters {
const filters: DomainFilters = { include: [], exclude: [] };
for (const raw of domainFilter ?? []) {
const domain = normalizeDomain(raw);
if (!domain) continue;
const target = raw.trim().startsWith("-") ? filters.exclude : filters.include;
if (!target.includes(domain)) target.push(domain);
}
return filters;
}
function passesDomainFilters(url: string, filters: DomainFilters): boolean {
if (filters.include.length === 0 && filters.exclude.length === 0) return true;
let hostname: string;
try {
hostname = new URL(url).hostname.toLowerCase();
} catch {
return false;
}
const matches = (domain: string) => hostname === domain || hostname.endsWith(`.${domain}`);
if (filters.exclude.some(matches)) return false;
return filters.include.length === 0 || filters.include.some(matches);
}
function buildQuery(query: string, filters: DomainFilters): string {
const parts = [query];
if (filters.include.length === 1) parts.push(`site:${filters.include[0]}`);
if (filters.include.length > 1) parts.push(`(${filters.include.map(domain => `site:${domain}`).join(" OR ")})`);
for (const domain of filters.exclude) parts.push(`-site:${domain}`);
return parts.join(" ");
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function invalidResponse(message: string): Error {
return new Error(`Serper API returned invalid response: ${message}`);
}
function parseResponse(value: unknown): SerperResult[] {
if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidResponse("expected an object envelope");
const envelope = value as Record<string, unknown>;
if (!Array.isArray(envelope.organic)) throw invalidResponse("expected organic array");
return envelope.organic as SerperResult[];
}
export function isSerperAvailable(): boolean {
return hasCredentialSource({ provider: "Serper", configuredValue: loadConfig().serperApiKey, environmentValue: process.env.SERPER_API_KEY });
}
export async function searchWithSerper(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
const apiUrl = resolveProviderApiUrl("serper", `${SERPER_API_BASE_URL}/search`, loadConfig().serperBaseUrl);
const apiKey = await requireApiKey(options.signal);
const numResults = normalizeSearchResultCount(options.numResults);
const filters = parseDomainFilter(options.domainFilter);
const requestCount = options.domainFilter?.length ? Math.min(20, numResults + 5) : numResults;
const activityId = activityMonitor.logStart({ type: "api", query });
let response: Response;
try {
response = await fetchWithCredentialRedirects(apiUrl, {
method: "POST",
headers: { "X-API-KEY": apiKey, "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ q: buildQuery(query, filters), num: requestCount, ...(options.recencyFilter ? { tbs: RECENCY_TBS[options.recencyFilter] } : {}) }),
signal: options.signal ? AbortSignal.any([AbortSignal.timeout(SEARCH_TIMEOUT_MS), options.signal]) : AbortSignal.timeout(SEARCH_TIMEOUT_MS),
}, ["X-API-KEY"]);
} catch (err) {
const message = errorMessage(err);
const redactedMessage = redactCredential(message, apiKey);
if (redactedMessage.toLowerCase().includes("abort")) activityMonitor.logComplete(activityId, 0);
else activityMonitor.logError(activityId, redactedMessage);
if (redactedMessage === message) throw err;
const redactedError = new Error(redactedMessage);
if (err instanceof Error) redactedError.name = err.name;
throw redactedError;
}
if (!response.ok) {
activityMonitor.logComplete(activityId, response.status);
const errorText = redactCredential(await response.text(), apiKey);
throw new Error(`Serper API error ${response.status}: ${errorText.slice(0, 300)}`);
}
let rawData: unknown;
try {
rawData = await response.json();
} catch (err) {
activityMonitor.logComplete(activityId, response.status);
throw new Error(`Serper API returned invalid JSON: ${errorMessage(err)}`);
}
let entries: SerperResult[];
try {
entries = parseResponse(rawData);
} catch (err) {
activityMonitor.logComplete(activityId, response.status);
throw err;
}
activityMonitor.logComplete(activityId, response.status);
const results: SearchResponse["results"] = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object") continue;
if (typeof entry.link !== "string" || !entry.link) continue;
if (!passesDomainFilters(entry.link, filters)) continue;
results.push({
title: typeof entry.title === "string" && entry.title.trim() ? entry.title.trim() : `Source ${results.length + 1}`,
url: entry.link,
snippet: typeof entry.snippet === "string" ? entry.snippet : "",
});
if (results.length >= numResults) break;
}
return { answer: formatSearchResultsAsAnswer(results), results };
}