opencode-search-plugin 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +229 -2
- package/README.zh-CN.md +230 -0
- package/index.ts +59 -0
- package/package.json +57 -4
- package/plugins/anysearch-search.ts +227 -0
- package/plugins/brave-search.ts +270 -0
- package/plugins/jina-search.ts +242 -0
- package/plugins/linkup-search.ts +213 -0
- package/plugins/metaso-search.ts +268 -0
- package/plugins/querit-search.ts +290 -0
- package/plugins/serpapi-search.ts +269 -0
- package/plugins/serper-search.ts +272 -0
- package/plugins/youcom-search.ts +313 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// SerpApi returns Google's organic results as JSON. Only `organic_results`
|
|
5
|
+
// carries links an agent reads; answer boxes and knowledge graphs have no
|
|
6
|
+
// source URL, so they stay out. Every result field is optional -- a missing one
|
|
7
|
+
// means "Google provided none", not an error.
|
|
8
|
+
const SerpApiResponse = Schema.Struct({
|
|
9
|
+
organic_results: Schema.optional(
|
|
10
|
+
Schema.Array(
|
|
11
|
+
Schema.Struct({
|
|
12
|
+
title: Schema.String,
|
|
13
|
+
link: Schema.String,
|
|
14
|
+
snippet: Schema.optional(Schema.String),
|
|
15
|
+
date: Schema.optional(Schema.String),
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
22
|
+
const ID = "serpapi"
|
|
23
|
+
|
|
24
|
+
function envInt(name: string, fallback: number): number {
|
|
25
|
+
const raw = (process.env[name] ?? "").trim()
|
|
26
|
+
if (raw === "") return fallback
|
|
27
|
+
const value = Number(raw)
|
|
28
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function envStr(name: string): string {
|
|
32
|
+
return (process.env[name] ?? "").trim()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Search engines hand out dates in three flavours: relative ("28 minutes ago"),
|
|
36
|
+
// absolute ("Sep 30, 2026" in the US, "31 Mar 2026" elsewhere), and non-English
|
|
37
|
+
// relative ("4天前"). Relative values are rounded upstream, so month/year use
|
|
38
|
+
// 30/365 days. ISO 8601 timestamps are accepted as-is.
|
|
39
|
+
// See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
|
|
40
|
+
const RELATIVE_MS: Record<string, number> = {
|
|
41
|
+
minute: 60_000,
|
|
42
|
+
hour: 3_600_000,
|
|
43
|
+
day: 86_400_000,
|
|
44
|
+
week: 604_800_000,
|
|
45
|
+
month: 2_592_000_000,
|
|
46
|
+
year: 31_536_000_000,
|
|
47
|
+
分钟: 60_000,
|
|
48
|
+
小时: 3_600_000,
|
|
49
|
+
天: 86_400_000,
|
|
50
|
+
周: 604_800_000,
|
|
51
|
+
个月: 2_592_000_000,
|
|
52
|
+
月: 2_592_000_000,
|
|
53
|
+
年: 31_536_000_000,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RELATIVE_DATE =
|
|
57
|
+
/^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
|
|
58
|
+
|
|
59
|
+
const ABSOLUTE_DATE = [
|
|
60
|
+
/^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
|
|
61
|
+
/^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
|
|
62
|
+
/^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
|
|
63
|
+
// ISO 8601 with a time component, which some providers return verbatim.
|
|
64
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
function parseSearchDate(value: string | undefined): number {
|
|
68
|
+
const text = value?.trim()
|
|
69
|
+
if (!text) return NaN
|
|
70
|
+
|
|
71
|
+
const relative = RELATIVE_DATE.exec(text)
|
|
72
|
+
if (relative) {
|
|
73
|
+
const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
|
|
74
|
+
const amount = Number(relative[1])
|
|
75
|
+
if (unit === undefined || !Number.isFinite(amount)) return NaN
|
|
76
|
+
return Date.now() - amount * unit
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Only trust Date.parse on shapes these providers actually emit -- its lenient
|
|
80
|
+
// parser invents a year for strings like "Nov 1", which would date the result
|
|
81
|
+
// to 2001.
|
|
82
|
+
return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toResult(item: {
|
|
86
|
+
title: string
|
|
87
|
+
link: string
|
|
88
|
+
snippet?: string | undefined
|
|
89
|
+
date?: string | undefined
|
|
90
|
+
}) {
|
|
91
|
+
const published = parseSearchDate(item.date)
|
|
92
|
+
return {
|
|
93
|
+
url: item.link,
|
|
94
|
+
title: item.title,
|
|
95
|
+
content: item.snippet ?? "",
|
|
96
|
+
time: Number.isFinite(published) ? { published } : {},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
101
|
+
|
|
102
|
+
// SerpApi charges per search, and a repeat query is a fresh charge. SerpApi
|
|
103
|
+
// also keeps its own identical-query cache for an hour, which it does not bill,
|
|
104
|
+
// so a client-side cache mainly saves the round trip. Knobs are read once at
|
|
105
|
+
// load time, restart after changing them.
|
|
106
|
+
//
|
|
107
|
+
// SERPAPI_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
108
|
+
// SERPAPI_GL country code for results, e.g. "us", "cn"
|
|
109
|
+
// SERPAPI_HL language code for results, e.g. "en", "zh-cn"
|
|
110
|
+
// SERPAPI_TBS advanced filters, e.g. "qdr:d" (past day), "qdr:y"
|
|
111
|
+
// SERPAPI_API_KEY required; from https://serpapi.com
|
|
112
|
+
//
|
|
113
|
+
// There is deliberately no result-count knob: Google withdrew the `num`
|
|
114
|
+
// parameter, so SerpApi cannot honour one and a count would be a lie.
|
|
115
|
+
const CACHE_TTL = envInt("SERPAPI_CACHE_TTL", 600_000)
|
|
116
|
+
const GL = envStr("SERPAPI_GL")
|
|
117
|
+
const HL = envStr("SERPAPI_HL")
|
|
118
|
+
const TBS = envStr("SERPAPI_TBS")
|
|
119
|
+
|
|
120
|
+
const CACHE_LIMIT = 200
|
|
121
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
122
|
+
|
|
123
|
+
function cacheKey(query: string): string {
|
|
124
|
+
return query.trim().replace(/\s+/g, " ")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
128
|
+
if (CACHE_TTL <= 0) return undefined
|
|
129
|
+
const hit = cache.get(queryKey)
|
|
130
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
134
|
+
if (CACHE_TTL <= 0) return
|
|
135
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
136
|
+
if (cache.size > CACHE_LIMIT) {
|
|
137
|
+
const oldest = cache.keys().next().value
|
|
138
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
// Make "SerpApi" show up in /connect with an API-key method.
|
|
145
|
+
yield* ctx.integration.transform((editor) => {
|
|
146
|
+
editor.update(ID, (integration) => {
|
|
147
|
+
integration.name = "SerpApi"
|
|
148
|
+
})
|
|
149
|
+
editor.method.update({
|
|
150
|
+
integrationID: ID,
|
|
151
|
+
method: { type: "key", label: "SerpApi API key" },
|
|
152
|
+
})
|
|
153
|
+
editor.method.update({
|
|
154
|
+
integrationID: ID,
|
|
155
|
+
method: { type: "env", names: ["SERPAPI_API_KEY"] },
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
160
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
161
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
162
|
+
// environment fallback below applies exactly as it does when the credential
|
|
163
|
+
// simply does not exist.
|
|
164
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
165
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
166
|
+
|
|
167
|
+
// The key comes from the /connect credential, falling back to the
|
|
168
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
169
|
+
const resolveKey = () =>
|
|
170
|
+
Effect.gen(function* () {
|
|
171
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
172
|
+
const credential = connection
|
|
173
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
174
|
+
: undefined
|
|
175
|
+
return credential?.type === "key" ? credential.key : process.env.SERPAPI_API_KEY
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// SerpApi is bound to websearch only while a key exists. Deleting the key
|
|
179
|
+
// unbinds the provider, so `random` can never pick a keyless SerpApi.
|
|
180
|
+
// The transform replays on reload and reads `bound` as it stands then.
|
|
181
|
+
let bound = Boolean(yield* resolveKey())
|
|
182
|
+
|
|
183
|
+
yield* ctx.websearch.transform((editor) => {
|
|
184
|
+
if (!bound) return
|
|
185
|
+
editor.add({
|
|
186
|
+
id: ID,
|
|
187
|
+
name: "SerpApi",
|
|
188
|
+
execute: ({ query }) =>
|
|
189
|
+
Effect.gen(function* () {
|
|
190
|
+
const queryKey = cacheKey(query)
|
|
191
|
+
const cached = readCache(queryKey)
|
|
192
|
+
if (cached) return cached
|
|
193
|
+
|
|
194
|
+
// Rechecked per call: binding can lag a key deletion by the
|
|
195
|
+
// event round-trip, so this keeps that window explicit.
|
|
196
|
+
const key = yield* resolveKey()
|
|
197
|
+
if (!key) {
|
|
198
|
+
return yield* Effect.fail(
|
|
199
|
+
new Error("Connect SerpApi in /connect, or set SERPAPI_API_KEY"),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const url = new URL("https://serpapi.com/search.json")
|
|
204
|
+
url.searchParams.set("engine", "google")
|
|
205
|
+
url.searchParams.set("q", queryKey)
|
|
206
|
+
url.searchParams.set("api_key", key)
|
|
207
|
+
if (GL) url.searchParams.set("gl", GL)
|
|
208
|
+
if (HL) url.searchParams.set("hl", HL)
|
|
209
|
+
if (TBS) url.searchParams.set("tbs", TBS)
|
|
210
|
+
|
|
211
|
+
const response = yield* Effect.tryPromise({
|
|
212
|
+
try: () =>
|
|
213
|
+
fetch(url, {
|
|
214
|
+
headers: {
|
|
215
|
+
Accept: "application/json",
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
catch: (error) => new Error("SerpApi request failed: " + String(error)),
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
const detail = yield* Effect.tryPromise({
|
|
223
|
+
try: () => response.text(),
|
|
224
|
+
catch: () => new Error("response body unavailable"),
|
|
225
|
+
})
|
|
226
|
+
return yield* Effect.fail(
|
|
227
|
+
new Error("SerpApi API error " + response.status + ": " + detail),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const json = yield* Effect.tryPromise({
|
|
232
|
+
try: () => response.json(),
|
|
233
|
+
catch: (error) => new Error("Invalid SerpApi response: " + String(error)),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const body = yield* Schema.decodeUnknownEffect(SerpApiResponse)(json)
|
|
237
|
+
const results = (body.organic_results ?? []).map(toResult)
|
|
238
|
+
|
|
239
|
+
writeCache(queryKey, results)
|
|
240
|
+
return results
|
|
241
|
+
}),
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
246
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
247
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
248
|
+
yield* Effect.forkScoped(
|
|
249
|
+
ctx.event.subscribe().pipe(
|
|
250
|
+
Stream.filter(
|
|
251
|
+
(event) =>
|
|
252
|
+
event.type === "credential.updated" ||
|
|
253
|
+
event.type === "credential.switched" ||
|
|
254
|
+
event.type === "integration.updated",
|
|
255
|
+
),
|
|
256
|
+
Stream.mapEffect(() =>
|
|
257
|
+
Effect.gen(function* () {
|
|
258
|
+
const next = Boolean(yield* resolveKey())
|
|
259
|
+
if (next === bound) return
|
|
260
|
+
bound = next
|
|
261
|
+
yield* ctx.websearch.reload()
|
|
262
|
+
}),
|
|
263
|
+
),
|
|
264
|
+
Stream.runDrain,
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
export default Plugin.define({ id: "serpapi-search", effect })
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// Serper mirrors the Google result page as JSON. Only `organic` carries the
|
|
5
|
+
// links an agent reads; the rich blocks (answerBox, knowledgeGraph,
|
|
6
|
+
// peopleAlsoAsk) have no source URL, so they stay out. Every result field is
|
|
7
|
+
// optional -- a missing one means "Google provided none", not an error.
|
|
8
|
+
const SerperResponse = Schema.Struct({
|
|
9
|
+
organic: Schema.optional(
|
|
10
|
+
Schema.Array(
|
|
11
|
+
Schema.Struct({
|
|
12
|
+
title: Schema.optional(Schema.String),
|
|
13
|
+
link: Schema.String,
|
|
14
|
+
snippet: Schema.optional(Schema.String),
|
|
15
|
+
date: Schema.optional(Schema.String),
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
22
|
+
const ID = "serper"
|
|
23
|
+
|
|
24
|
+
function envInt(name: string, fallback: number): number {
|
|
25
|
+
const raw = (process.env[name] ?? "").trim()
|
|
26
|
+
if (raw === "") return fallback
|
|
27
|
+
const value = Number(raw)
|
|
28
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function envStr(name: string): string {
|
|
32
|
+
return (process.env[name] ?? "").trim()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Search engines hand out dates in three flavours: relative ("28 minutes ago"),
|
|
36
|
+
// absolute ("Sep 30, 2026" in the US, "31 Mar 2026" elsewhere), and non-English
|
|
37
|
+
// relative ("4天前"). Relative values are rounded upstream, so month/year use
|
|
38
|
+
// 30/365 days. ISO 8601 timestamps are accepted as-is.
|
|
39
|
+
// See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
|
|
40
|
+
const RELATIVE_MS: Record<string, number> = {
|
|
41
|
+
minute: 60_000,
|
|
42
|
+
hour: 3_600_000,
|
|
43
|
+
day: 86_400_000,
|
|
44
|
+
week: 604_800_000,
|
|
45
|
+
month: 2_592_000_000,
|
|
46
|
+
year: 31_536_000_000,
|
|
47
|
+
分钟: 60_000,
|
|
48
|
+
小时: 3_600_000,
|
|
49
|
+
天: 86_400_000,
|
|
50
|
+
周: 604_800_000,
|
|
51
|
+
个月: 2_592_000_000,
|
|
52
|
+
月: 2_592_000_000,
|
|
53
|
+
年: 31_536_000_000,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RELATIVE_DATE =
|
|
57
|
+
/^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
|
|
58
|
+
|
|
59
|
+
const ABSOLUTE_DATE = [
|
|
60
|
+
/^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
|
|
61
|
+
/^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
|
|
62
|
+
/^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
|
|
63
|
+
// ISO 8601 with a time component, which some providers return verbatim.
|
|
64
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
function parseSearchDate(value: string | undefined): number {
|
|
68
|
+
const text = value?.trim()
|
|
69
|
+
if (!text) return NaN
|
|
70
|
+
|
|
71
|
+
const relative = RELATIVE_DATE.exec(text)
|
|
72
|
+
if (relative) {
|
|
73
|
+
const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
|
|
74
|
+
const amount = Number(relative[1])
|
|
75
|
+
if (unit === undefined || !Number.isFinite(amount)) return NaN
|
|
76
|
+
return Date.now() - amount * unit
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Only trust Date.parse on shapes these providers actually emit -- its lenient
|
|
80
|
+
// parser invents a year for strings like "Nov 1", which would date the result
|
|
81
|
+
// to 2001.
|
|
82
|
+
return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toResult(item: {
|
|
86
|
+
title?: string | undefined
|
|
87
|
+
link: string
|
|
88
|
+
snippet?: string | undefined
|
|
89
|
+
date?: string | undefined
|
|
90
|
+
}) {
|
|
91
|
+
const published = parseSearchDate(item.date)
|
|
92
|
+
return {
|
|
93
|
+
url: item.link,
|
|
94
|
+
title: item.title || item.link,
|
|
95
|
+
content: item.snippet ?? "",
|
|
96
|
+
time: Number.isFinite(published) ? { published } : {},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
101
|
+
|
|
102
|
+
// Serper bills per query in credits and has no anonymous tier. A repeat query
|
|
103
|
+
// is a fresh billed dispatch, so responses are cached per normalized query.
|
|
104
|
+
// Knobs are read once at load time, restart after changing them.
|
|
105
|
+
//
|
|
106
|
+
// SERPER_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
107
|
+
// SERPER_NUM results per search, clamped to 1-100
|
|
108
|
+
// SERPER_GL country code for results, e.g. "us", "cn"
|
|
109
|
+
// SERPER_HL language code for results, e.g. "en", "zh-cn"
|
|
110
|
+
// SERPER_TBS time filter: "qdr:h" (hour) "qdr:d" (day) "qdr:w" (week)
|
|
111
|
+
// "qdr:m" (month) "qdr:y" (year)
|
|
112
|
+
// SERPER_API_KEY required; from https://serper.dev
|
|
113
|
+
const CACHE_TTL = envInt("SERPER_CACHE_TTL", 600_000)
|
|
114
|
+
const SEARCH_SIZE = Math.min(Math.max(envInt("SERPER_NUM", 10), 1), 100)
|
|
115
|
+
const GL = envStr("SERPER_GL")
|
|
116
|
+
const HL = envStr("SERPER_HL")
|
|
117
|
+
const TBS = envStr("SERPER_TBS")
|
|
118
|
+
|
|
119
|
+
const CACHE_LIMIT = 200
|
|
120
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
121
|
+
|
|
122
|
+
function cacheKey(query: string): string {
|
|
123
|
+
return query.trim().replace(/\s+/g, " ")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
127
|
+
if (CACHE_TTL <= 0) return undefined
|
|
128
|
+
const hit = cache.get(queryKey)
|
|
129
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
133
|
+
if (CACHE_TTL <= 0) return
|
|
134
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
135
|
+
if (cache.size > CACHE_LIMIT) {
|
|
136
|
+
const oldest = cache.keys().next().value
|
|
137
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
// Make "Serper" show up in /connect with an API-key method.
|
|
144
|
+
yield* ctx.integration.transform((editor) => {
|
|
145
|
+
editor.update(ID, (integration) => {
|
|
146
|
+
integration.name = "Serper"
|
|
147
|
+
})
|
|
148
|
+
editor.method.update({
|
|
149
|
+
integrationID: ID,
|
|
150
|
+
method: { type: "key", label: "Serper API key" },
|
|
151
|
+
})
|
|
152
|
+
editor.method.update({
|
|
153
|
+
integrationID: ID,
|
|
154
|
+
method: { type: "env", names: ["SERPER_API_KEY"] },
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
159
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
160
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
161
|
+
// environment fallback below applies exactly as it does when the credential
|
|
162
|
+
// simply does not exist.
|
|
163
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
164
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
165
|
+
|
|
166
|
+
// The key comes from the /connect credential, falling back to the
|
|
167
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
168
|
+
const resolveKey = () =>
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
171
|
+
const credential = connection
|
|
172
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
173
|
+
: undefined
|
|
174
|
+
return credential?.type === "key" ? credential.key : process.env.SERPER_API_KEY
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Serper is bound to websearch only while a key exists. Deleting the key
|
|
178
|
+
// unbinds the provider, so `random` can never pick a keyless Serper.
|
|
179
|
+
// The transform replays on reload and reads `bound` as it stands then.
|
|
180
|
+
let bound = Boolean(yield* resolveKey())
|
|
181
|
+
|
|
182
|
+
yield* ctx.websearch.transform((editor) => {
|
|
183
|
+
if (!bound) return
|
|
184
|
+
editor.add({
|
|
185
|
+
id: ID,
|
|
186
|
+
name: "Serper",
|
|
187
|
+
execute: ({ query }) =>
|
|
188
|
+
Effect.gen(function* () {
|
|
189
|
+
const queryKey = cacheKey(query)
|
|
190
|
+
const cached = readCache(queryKey)
|
|
191
|
+
if (cached) return cached
|
|
192
|
+
|
|
193
|
+
// Rechecked per call: binding can lag a key deletion by the
|
|
194
|
+
// event round-trip, so this keeps that window explicit.
|
|
195
|
+
const key = yield* resolveKey()
|
|
196
|
+
if (!key) {
|
|
197
|
+
return yield* Effect.fail(
|
|
198
|
+
new Error("Connect Serper in /connect, or set SERPER_API_KEY"),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const response = yield* Effect.tryPromise({
|
|
203
|
+
try: () =>
|
|
204
|
+
fetch("https://google.serper.dev/search", {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
Accept: "application/json",
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
"X-API-KEY": key,
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
q: queryKey,
|
|
213
|
+
num: SEARCH_SIZE,
|
|
214
|
+
...(GL ? { gl: GL } : {}),
|
|
215
|
+
...(HL ? { hl: HL } : {}),
|
|
216
|
+
...(TBS ? { tbs: TBS } : {}),
|
|
217
|
+
}),
|
|
218
|
+
}),
|
|
219
|
+
catch: (error) => new Error("Serper request failed: " + String(error)),
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
const detail = yield* Effect.tryPromise({
|
|
224
|
+
try: () => response.text(),
|
|
225
|
+
catch: () => new Error("response body unavailable"),
|
|
226
|
+
})
|
|
227
|
+
return yield* Effect.fail(
|
|
228
|
+
new Error("Serper API error " + response.status + ": " + detail),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const json = yield* Effect.tryPromise({
|
|
233
|
+
try: () => response.json(),
|
|
234
|
+
catch: (error) => new Error("Invalid Serper response: " + String(error)),
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const body = yield* Schema.decodeUnknownEffect(SerperResponse)(json)
|
|
238
|
+
const results = (body.organic ?? []).map(toResult)
|
|
239
|
+
|
|
240
|
+
// Serper charges per dispatch, so a response that never enters the
|
|
241
|
+
// cache is a query paid for again on every repeat.
|
|
242
|
+
writeCache(queryKey, results)
|
|
243
|
+
return results
|
|
244
|
+
}),
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
249
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
250
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
251
|
+
yield* Effect.forkScoped(
|
|
252
|
+
ctx.event.subscribe().pipe(
|
|
253
|
+
Stream.filter(
|
|
254
|
+
(event) =>
|
|
255
|
+
event.type === "credential.updated" ||
|
|
256
|
+
event.type === "credential.switched" ||
|
|
257
|
+
event.type === "integration.updated",
|
|
258
|
+
),
|
|
259
|
+
Stream.mapEffect(() =>
|
|
260
|
+
Effect.gen(function* () {
|
|
261
|
+
const next = Boolean(yield* resolveKey())
|
|
262
|
+
if (next === bound) return
|
|
263
|
+
bound = next
|
|
264
|
+
yield* ctx.websearch.reload()
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
Stream.runDrain,
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
export default Plugin.define({ id: "serper-search", effect })
|