opencode-search-plugin 1.0.1 → 1.1.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/README.md +66 -15
- package/README.zh-CN.md +66 -15
- package/index.ts +3 -1
- package/package.json +3 -2
- package/plugins/keenable-search.ts +277 -0
package/README.md
CHANGED
|
@@ -2,30 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
**English** · [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Ten self-contained [OpenCode](https://opencode.ai) v2 plugins that register web search providers. Each file stands on its own: copy one into your plugins directory, or install the package to get all ten.
|
|
6
6
|
|
|
7
|
-
[AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
7
|
+
[AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
8
8
|
|
|
9
9
|
## Why this exists
|
|
10
10
|
|
|
11
11
|
OpenCode v2 ships with five web search providers built in: `exa`, `firecrawl`, `parallel`, `tavily` and `tinyfish`. Outside that set, a much wider field of search APIs offers free quotas or free tiers — enough to run an agent's searches without paying for the privilege.
|
|
12
12
|
|
|
13
|
-
This repository adds plugins for
|
|
13
|
+
This repository adds plugins for ten more of them, taking the pool from five providers to fifteen. AnySearch and Keenable run without a key at all; the other eight bind only when you supply one, so installing this package never routes a query to a provider that would immediately fail. Which of them are worth your time depends on the free tier each one currently offers, so check a provider's pricing before you rely on it.
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Three equivalent ways to install; pick one. **npm is recommended**: a version published to the registry is immutable, so a pinned `@1.1.0` always installs the same bytes, whereas a GitHub tag can in principle be moved by a maintainer to point at a different commit. The first two install the same complete package (all ten); use the third for one or two files only.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
# from GitHub (add #v1.0.1 to pin)
|
|
21
|
-
opencode plugin add github:guotree/opencode-search-plugin
|
|
19
|
+
### 1. From npm (recommended)
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
```sh
|
|
22
|
+
# add @1.1.0 to pin
|
|
24
23
|
opencode plugin add opencode-search-plugin
|
|
25
24
|
```
|
|
26
25
|
|
|
27
26
|
or declare it in `opencode.json(c)`:
|
|
28
27
|
|
|
28
|
+
```jsonc
|
|
29
|
+
{
|
|
30
|
+
"$schema": "https://opencode.ai/config.json",
|
|
31
|
+
"plugins": ["opencode-search-plugin"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 2. From GitHub
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
# add #v1.1.0 to pin
|
|
39
|
+
opencode plugin add github:guotree/opencode-search-plugin
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
or declare it in `opencode.json(c)`:
|
|
43
|
+
|
|
29
44
|
```jsonc
|
|
30
45
|
{
|
|
31
46
|
"$schema": "https://opencode.ai/config.json",
|
|
@@ -33,7 +48,7 @@ or declare it in `opencode.json(c)`:
|
|
|
33
48
|
}
|
|
34
49
|
```
|
|
35
50
|
|
|
36
|
-
###
|
|
51
|
+
### 3. By copying a file
|
|
37
52
|
|
|
38
53
|
Every plugin is a single `.ts` file with no relative imports and no build step. Drop it where OpenCode auto-discovers plugins:
|
|
39
54
|
|
|
@@ -52,31 +67,55 @@ For a location that is not auto-discovered, list it explicitly:
|
|
|
52
67
|
|
|
53
68
|
### Skip the services you have no key for
|
|
54
69
|
|
|
55
|
-
Loading the package registers all
|
|
70
|
+
Loading the package registers all ten, but a provider without a key never joins the search pool — see [Keys](#keys). To keep them out of the registry entirely:
|
|
56
71
|
|
|
57
72
|
```jsonc
|
|
58
73
|
{
|
|
59
74
|
"plugins": [
|
|
60
75
|
{
|
|
61
|
-
"package": "
|
|
76
|
+
"package": "opencode-search-plugin",
|
|
62
77
|
"options": { "disable": ["serper", "serpapi", "youcom"] }
|
|
63
78
|
}
|
|
64
79
|
]
|
|
65
80
|
}
|
|
66
81
|
```
|
|
67
82
|
|
|
68
|
-
`disable` takes the provider IDs listed in the table below.
|
|
83
|
+
`disable` takes the provider IDs listed in the table below. AnySearch and Keenable need no key, so `disable` is the only way to keep those two out of the pool.
|
|
84
|
+
|
|
85
|
+
## Upgrade
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
# installed from npm
|
|
89
|
+
opencode plugin update opencode-search-plugin
|
|
90
|
+
# installed from GitHub
|
|
91
|
+
opencode plugin update github:guotree/opencode-search-plugin
|
|
92
|
+
# everything outdated
|
|
93
|
+
opencode plugin update
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The target is the specifier that was configured: an npm install is named by its package name, a GitHub install answers only to its `github:` form — naming it by its bare package name fails with `Plugin is not configured`. Run `opencode plugin check` first to see what is outdated. An install without a pinned version follows new releases; a pinned one — `@1.1.0` on npm, `#v1.1.0` on GitHub — stays on its pin until you re-run `plugin add` with a newer pin or edit the specifier in `opencode.json(c)`. A locally copied file is upgraded by copying the new version over the old one.
|
|
97
|
+
|
|
98
|
+
## Uninstall
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
# installed from npm
|
|
102
|
+
opencode plugin remove opencode-search-plugin
|
|
103
|
+
# installed from GitHub
|
|
104
|
+
opencode plugin remove github:guotree/opencode-search-plugin
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The same rule: pass the specifier you installed with. The command edits the global configuration; if the plugin is instead a `plugins` entry in `opencode.json(c)` or a locally copied file, delete the entry or the copied `.ts` file. Saved `/connect` credentials are untouched — a reinstall finds them again — and to turn off a few providers instead of the whole plugin, use `disable` above.
|
|
69
108
|
|
|
70
109
|
## Keys
|
|
71
110
|
|
|
72
|
-
Every provider except AnySearch needs an API key. Give it one of two ways:
|
|
111
|
+
Every provider except AnySearch and Keenable needs an API key. Give it one of two ways:
|
|
73
112
|
|
|
74
113
|
- **`/connect`** inside OpenCode — stored per provider, rotated without a restart, and survives environment changes.
|
|
75
114
|
- **Environment variable** — read as a fallback, so it works in headless and CI contexts.
|
|
76
115
|
|
|
77
116
|
A provider with no key stays unbound: it is absent from the websearch pool, so `"provider": "random"` can never route a query to a service that would immediately fail.
|
|
78
117
|
|
|
79
|
-
AnySearch
|
|
118
|
+
AnySearch and Keenable are the exceptions. Both have an anonymous tier, so they are always registered and only attach their credential header when a key exists. For Keenable the two tiers draw on separate budgets: a key spends the free allowance of 100,000 requests a month, while a keyless call goes to the `/public` endpoint instead and is metered by rate — 1,000 requests an hour and 10 a second per IP, on a pool shared with everyone else on your egress address. A key is what makes that shared headroom irrelevant.
|
|
80
119
|
|
|
81
120
|
Once keys are in place, pick how OpenCode chooses among them:
|
|
82
121
|
|
|
@@ -93,6 +132,7 @@ Once keys are in place, pick how OpenCode chooses among them:
|
|
|
93
132
|
| AnySearch | `anysearch` | `ANYSEARCH_API_KEY` | yes | Free tier per IP; intent router, `cn`/`intl` zones |
|
|
94
133
|
| Brave Search | `brave` | `BRAVE_SEARCH_API_KEY` | no | Independent index, 20 results max |
|
|
95
134
|
| Jina | `jina` | `JINA_API_KEY` | no | Page content is opt-in because it is token-billed |
|
|
135
|
+
| Keenable | `keenable` | `KEENABLE_API_KEY` | yes | 100,000 requests a month with a key; keyless tier is per IP |
|
|
96
136
|
| Linkup | `linkup` | `LINKUP_API_KEY` | no | `flash`/`fast`/`standard`/`deep` reasoning depth |
|
|
97
137
|
| Metaso | `metaso` | `METASO_API_KEY` | no | Billed per search, not per result |
|
|
98
138
|
| Querit | `querit` | `QUERIT_API_KEY` | no | Falls back to sentence matches when a snippet is missing |
|
|
@@ -104,7 +144,7 @@ Once keys are in place, pick how OpenCode chooses among them:
|
|
|
104
144
|
|
|
105
145
|
Knobs are environment variables, read once when OpenCode starts — change one and restart. Defaults match what each API documents, so leaving them unset is the right starting point.
|
|
106
146
|
|
|
107
|
-
### Shared by all
|
|
147
|
+
### Shared by all ten
|
|
108
148
|
|
|
109
149
|
| Variable | Default | Meaning |
|
|
110
150
|
| --- | --- | --- |
|
|
@@ -139,6 +179,15 @@ Knobs are environment variables, read once when OpenCode starts — change one a
|
|
|
139
179
|
| `JINA_GL` / `JINA_HL` | — | Country / language code |
|
|
140
180
|
| `JINA_LOCATION` | — | City-level origin to simulate, e.g. `Berlin` |
|
|
141
181
|
|
|
182
|
+
### Keenable
|
|
183
|
+
|
|
184
|
+
| Variable | Default | Meaning |
|
|
185
|
+
| --- | --- | --- |
|
|
186
|
+
| `KEENABLE_MAX_RESULTS` | `10` | Results per search, clamped to 1–50 |
|
|
187
|
+
| `KEENABLE_MODE` | — | `pro` (deeper retrieval) or `realtime` (fastest); unset leaves Keenable's own default |
|
|
188
|
+
| `KEENABLE_TITLE` | `opencode-search-plugin` | App name sent as `X-Keenable-Title`, which the keyless endpoint requires to attribute the call |
|
|
189
|
+
| `KEENABLE_API_KEY` | — | Optional; keyless access works without it |
|
|
190
|
+
|
|
142
191
|
### Linkup
|
|
143
192
|
|
|
144
193
|
| Variable | Default | Meaning |
|
|
@@ -203,12 +252,14 @@ opencode plugin list
|
|
|
203
252
|
|
|
204
253
|
# Which providers are actually in the pool?
|
|
205
254
|
# A provider missing here has no key, or is listed in `disable`.
|
|
255
|
+
# AnySearch and Keenable are always present, since neither needs one.
|
|
206
256
|
opencode api get "/api/websearch/provider"
|
|
207
257
|
```
|
|
208
258
|
|
|
209
259
|
Also worth checking:
|
|
210
260
|
|
|
211
261
|
- **A provider is missing from the pool** — it has no key. Run `/connect` or export its variable, then restart; credential changes made in `/connect` rebind without one.
|
|
262
|
+
- **A search fails with `Keenable rate limited the request (HTTP 429)`** — the keyless pool is capped at 1,000 requests an hour and 10 a second per IP, shared with the rest of your egress address. Set `KEENABLE_API_KEY` to draw on your own allowance instead.
|
|
212
263
|
- **A search fails with `Connect X in /connect, or set X_API_KEY`** — the key disappeared between binding and searching. Reconnect or re-export.
|
|
213
264
|
- **Everything is slow or burning credits** — leave `*_CACHE_TTL` at its default. Repeated queries within the window are served from memory.
|
|
214
265
|
- **Nothing happens after editing a plugin file** — OpenCode watches plugins and reloads them; if it does not, `opencode service restart`.
|
package/README.zh-CN.md
CHANGED
|
@@ -2,30 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
# opencode-search-plugin
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
十个相互独立的 [OpenCode](https://opencode.ai) v2 插件,各自注册一个网页搜索 provider。每个文件都能单独使用:复制一个进你的插件目录,或者安装这个包一次拿全十个。
|
|
6
6
|
|
|
7
|
-
[AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
7
|
+
[AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
8
8
|
|
|
9
9
|
## 为什么做这个插件
|
|
10
10
|
|
|
11
11
|
OpenCode v2 内置了五个网页搜索 provider:`exa`、`firecrawl`、`parallel`、`tavily`、`tinyfish`。在这个范围之外,还有大量搜索 API 提供免费额度或免费层级,足以支撑 agent 的日常检索而不必为此付费。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
本仓库为其中十家补上了插件,把可用池从五个 provider 扩到十五个。AnySearch 和 Keenable 无需任何 key 即可使用;其余八家只在你提供 key 之后才会绑定,因此装上这个包也不会把查询路由到一个注定失败的服务上。至于哪几家值得用,取决于它们各自当前的免费额度,长期依赖某家之前建议先确认它的定价。
|
|
14
14
|
|
|
15
15
|
## 安装
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
三种方式等价,任选其一。**推荐 npm**:已发布到 registry 的版本不可变,固定 `@1.1.0` 后每次安装都拿到同一份字节;GitHub 的 tag 在理论上可以被维护者移动、指向别的 commit。前两种装的是同一个完整包(十个全有),只想用一两个就用第三种。
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
# 从 GitHub 安装(追加 #v1.0.1 可固定到该 tag)
|
|
21
|
-
opencode plugin add github:guotree/opencode-search-plugin
|
|
19
|
+
### 1. 从 npm 安装(推荐)
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
```sh
|
|
22
|
+
# 追加 @1.1.0 可固定到该版本
|
|
24
23
|
opencode plugin add opencode-search-plugin
|
|
25
24
|
```
|
|
26
25
|
|
|
27
26
|
或者写进 `opencode.json(c)`:
|
|
28
27
|
|
|
28
|
+
```jsonc
|
|
29
|
+
{
|
|
30
|
+
"$schema": "https://opencode.ai/config.json",
|
|
31
|
+
"plugins": ["opencode-search-plugin"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 2. 从 GitHub 安装
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
# 追加 #v1.1.0 可固定到该 tag
|
|
39
|
+
opencode plugin add github:guotree/opencode-search-plugin
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
或者写进 `opencode.json(c)`:
|
|
43
|
+
|
|
29
44
|
```jsonc
|
|
30
45
|
{
|
|
31
46
|
"$schema": "https://opencode.ai/config.json",
|
|
@@ -33,7 +48,7 @@ opencode plugin add opencode-search-plugin
|
|
|
33
48
|
}
|
|
34
49
|
```
|
|
35
50
|
|
|
36
|
-
###
|
|
51
|
+
### 3. 本地复制文件
|
|
37
52
|
|
|
38
53
|
每个插件都是单个 `.ts` 文件,没有相对导入,也没有构建步骤。放进 OpenCode 会自动发现插件的位置即可:
|
|
39
54
|
|
|
@@ -52,31 +67,55 @@ opencode plugin add opencode-search-plugin
|
|
|
52
67
|
|
|
53
68
|
### 跳过你没有 key 的服务
|
|
54
69
|
|
|
55
|
-
|
|
70
|
+
装包会注册全部十家,但没有 key 的 provider 永远不会进入搜索池——见下文[密钥](#密钥)。想让它们连注册表都不出现:
|
|
56
71
|
|
|
57
72
|
```jsonc
|
|
58
73
|
{
|
|
59
74
|
"plugins": [
|
|
60
75
|
{
|
|
61
|
-
"package": "
|
|
76
|
+
"package": "opencode-search-plugin",
|
|
62
77
|
"options": { "disable": ["serper", "serpapi", "youcom"] }
|
|
63
78
|
}
|
|
64
79
|
]
|
|
65
80
|
}
|
|
66
81
|
```
|
|
67
82
|
|
|
68
|
-
`disable` 填的是下表中的 provider ID。
|
|
83
|
+
`disable` 填的是下表中的 provider ID。AnySearch 和 Keenable 不需要 key,想把这两家也排除在搜索池之外,只能用 `disable`。
|
|
84
|
+
|
|
85
|
+
## 升级
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
# 从 npm 安装的
|
|
89
|
+
opencode plugin update opencode-search-plugin
|
|
90
|
+
# 从 GitHub 安装的
|
|
91
|
+
opencode plugin update github:guotree/opencode-search-plugin
|
|
92
|
+
# 更新所有过期插件
|
|
93
|
+
opencode plugin update
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
升级目标用的是配置时的说明符:npm 安装用包名,GitHub 安装只认自己的 `github:` 形式——用裸包名会报 `Plugin is not configured`。先跑 `opencode plugin check` 查看有哪些过期。未固定版本的安装会跟上新版本;固定过的——npm 形式的 `@1.1.0`、GitHub 形式的 `#v1.1.0`——会停在原地,直到你用新 pin 重新执行 `plugin add`,或直接修改 `opencode.json(c)` 里的说明符。本地复制的文件则用新版本覆盖旧文件来升级。
|
|
97
|
+
|
|
98
|
+
## 卸载
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
# 从 npm 安装的
|
|
102
|
+
opencode plugin remove opencode-search-plugin
|
|
103
|
+
# 从 GitHub 安装的
|
|
104
|
+
opencode plugin remove github:guotree/opencode-search-plugin
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
同上:传入安装时使用的说明符。该命令修改的是全局配置;若插件是写在 `opencode.json(c)` 的 `plugins` 条目里或本地复制的文件,删掉对应条目、或删掉复制的 `.ts` 文件即可。`/connect` 里保存的密钥不受影响——重新装回就能继续用——只想关掉其中几家而不是整个插件,用上文的 `disable`。
|
|
69
108
|
|
|
70
109
|
## 密钥
|
|
71
110
|
|
|
72
|
-
除 AnySearch 外,每家服务都需要 API key。两种给法二选一:
|
|
111
|
+
除 AnySearch 和 Keenable 外,每家服务都需要 API key。两种给法二选一:
|
|
73
112
|
|
|
74
113
|
- **OpenCode 内的 `/connect`** —— 按 provider 存储,轮换后无需重启,且不受环境变量变动影响。
|
|
75
114
|
- **环境变量** —— 作为兜底读取,因此在无界面和 CI 场景下也能工作。
|
|
76
115
|
|
|
77
116
|
没有 key 的 provider 会保持未绑定状态:它不在 websearch 池中,所以 `"provider": "random"` 绝不会把查询路由到一个注定失败的服务上。
|
|
78
117
|
|
|
79
|
-
AnySearch
|
|
118
|
+
AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册,只有存在 key 时才附带各自的凭据头。Keenable 的两层额度互不通用:有 key 时消耗每月 100,000 次的免费额度,无 key 的请求则改走 `/public` 端点,按速率限制——每个 IP 每小时 1,000 次、每秒 10 次,且与同一出口地址上的所有人共用。想摆脱这个共享池的不确定性,就得配一把 key。
|
|
80
119
|
|
|
81
120
|
密钥就位后,再决定 OpenCode 如何在它们之间做选择:
|
|
82
121
|
|
|
@@ -93,6 +132,7 @@ AnySearch 是例外。它有匿名额度,因此始终注册,只有存在 key
|
|
|
93
132
|
| AnySearch | `anysearch` | `ANYSEARCH_API_KEY` | 是 | 按 IP 的免费额度;意图路由,`cn` / `intl` 分区 |
|
|
94
133
|
| Brave Search | `brave` | `BRAVE_SEARCH_API_KEY` | 否 | 独立索引,最多 20 条结果 |
|
|
95
134
|
| Jina | `jina` | `JINA_API_KEY` | 否 | 页面正文按 token 计费,因此默认不取 |
|
|
135
|
+
| Keenable | `keenable` | `KEENABLE_API_KEY` | 是 | 有 key 每月 100,000 次免费请求;无 key 层级按 IP 限速 |
|
|
96
136
|
| Linkup | `linkup` | `LINKUP_API_KEY` | 否 | `flash` / `fast` / `standard` / `deep` 四档推理深度 |
|
|
97
137
|
| Metaso | `metaso` | `METASO_API_KEY` | 否 | 按次搜索计费,不按结果条数 |
|
|
98
138
|
| Querit | `querit` | `QUERIT_API_KEY` | 否 | 摘要缺失时回退到句子级匹配 |
|
|
@@ -104,7 +144,7 @@ AnySearch 是例外。它有匿名额度,因此始终注册,只有存在 key
|
|
|
104
144
|
|
|
105
145
|
所有配置项都是环境变量,OpenCode 启动时读取一次——改了要重启。默认值取自各家 API 文档,所以一开始什么都不设就是合理的起点。
|
|
106
146
|
|
|
107
|
-
###
|
|
147
|
+
### 十家通用
|
|
108
148
|
|
|
109
149
|
| 变量 | 默认值 | 含义 |
|
|
110
150
|
| --- | --- | --- |
|
|
@@ -139,6 +179,15 @@ AnySearch 是例外。它有匿名额度,因此始终注册,只有存在 key
|
|
|
139
179
|
| `JINA_GL` / `JINA_HL` | — | 国家 / 语言代码 |
|
|
140
180
|
| `JINA_LOCATION` | — | 模拟的城市级来源,例如 `Berlin` |
|
|
141
181
|
|
|
182
|
+
### Keenable
|
|
183
|
+
|
|
184
|
+
| 变量 | 默认值 | 含义 |
|
|
185
|
+
| --- | --- | --- |
|
|
186
|
+
| `KEENABLE_MAX_RESULTS` | `10` | 每次搜索的结果数,限制在 1–50 |
|
|
187
|
+
| `KEENABLE_MODE` | — | `pro`(更深的检索)或 `realtime`(最快);不设则沿用 Keenable 自己的默认值 |
|
|
188
|
+
| `KEENABLE_TITLE` | `opencode-search-plugin` | 作为 `X-Keenable-Title` 发出的应用名,无 key 端点靠它归属调用方 |
|
|
189
|
+
| `KEENABLE_API_KEY` | — | 可选;不设也能匿名使用 |
|
|
190
|
+
|
|
142
191
|
### Linkup
|
|
143
192
|
|
|
144
193
|
| 变量 | 默认值 | 含义 |
|
|
@@ -203,12 +252,14 @@ opencode plugin list
|
|
|
203
252
|
|
|
204
253
|
# 搜索池里到底有哪些 provider?
|
|
205
254
|
# 这里少了一家,说明它没有 key,或者被写进了 `disable`。
|
|
255
|
+
# AnySearch 和 Keenable 永远在列,因为它们都不需要 key。
|
|
206
256
|
opencode api get "/api/websearch/provider"
|
|
207
257
|
```
|
|
208
258
|
|
|
209
259
|
另外几个值得检查的地方:
|
|
210
260
|
|
|
211
261
|
- **某家 provider 不在池里** —— 它没有 key。执行 `/connect` 或导出对应环境变量,然后重启;在 `/connect` 里做的凭据变更会自动重绑,不需要重启。
|
|
262
|
+
- **搜索报错 `Keenable rate limited the request (HTTP 429)`** —— 无 key 层级每个 IP 每小时 1,000 次、每秒 10 次,且与同一出口地址上的其他人共用。改用 `KEENABLE_API_KEY` 就会记到你自己的额度上。
|
|
212
263
|
- **搜索报错 `Connect X in /connect, or set X_API_KEY`** —— key 在绑定和搜索之间消失了。重新连接或重新导出。
|
|
213
264
|
- **一切都很慢、或者积分烧得飞快** —— 把 `*_CACHE_TTL` 保持在默认值。时间窗内的重复查询由内存直接返回。
|
|
214
265
|
- **改了插件文件却没反应** —— OpenCode 会监视插件并自动重载;如果没有,执行 `opencode service restart`。
|
package/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Scope } from "effect"
|
|
|
5
5
|
import { effect as anysearch } from "./plugins/anysearch-search"
|
|
6
6
|
import { effect as brave } from "./plugins/brave-search"
|
|
7
7
|
import { effect as jina } from "./plugins/jina-search"
|
|
8
|
+
import { effect as keenable } from "./plugins/keenable-search"
|
|
8
9
|
import { effect as linkup } from "./plugins/linkup-search"
|
|
9
10
|
import { effect as metaso } from "./plugins/metaso-search"
|
|
10
11
|
import { effect as querit } from "./plugins/querit-search"
|
|
@@ -15,7 +16,7 @@ import { effect as youcom } from "./plugins/youcom-search"
|
|
|
15
16
|
type ProviderEffect = (ctx: Plugin.Context) => Effect.Effect<void, never, Scope.Scope>
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
* The
|
|
19
|
+
* The ten providers this package registers, in alphabetical order. Each entry
|
|
19
20
|
* is the same effect its standalone file uses as a default export, so a file
|
|
20
21
|
* copied out of this repository behaves exactly as it does here.
|
|
21
22
|
*
|
|
@@ -27,6 +28,7 @@ const PROVIDERS: ReadonlyArray<readonly [id: string, run: ProviderEffect]> = [
|
|
|
27
28
|
["anysearch", anysearch],
|
|
28
29
|
["brave", brave],
|
|
29
30
|
["jina", jina],
|
|
31
|
+
["keenable", keenable],
|
|
30
32
|
["linkup", linkup],
|
|
31
33
|
["metaso", metaso],
|
|
32
34
|
["querit", querit],
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-search-plugin",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Ten self-contained websearch provider plugins for OpenCode v2: AnySearch, Brave, Jina, Keenable, Linkup, Metaso, Querit, SerpApi, Serper and You.com.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"./anysearch-search": "./plugins/anysearch-search.ts",
|
|
26
26
|
"./brave-search": "./plugins/brave-search.ts",
|
|
27
27
|
"./jina-search": "./plugins/jina-search.ts",
|
|
28
|
+
"./keenable-search": "./plugins/keenable-search.ts",
|
|
28
29
|
"./linkup-search": "./plugins/linkup-search.ts",
|
|
29
30
|
"./metaso-search": "./plugins/metaso-search.ts",
|
|
30
31
|
"./querit-search": "./plugins/querit-search.ts",
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema } from "effect"
|
|
3
|
+
|
|
4
|
+
// Keenable answers with a flat object: `results` carries ranked hits, `mode`
|
|
5
|
+
// echoes the tier that served the call, and there is no failure envelope --
|
|
6
|
+
// refusals arrive as plain HTTP statuses. Every field but the URL is optional,
|
|
7
|
+
// and a missing one means "Keenable provided none", not an error.
|
|
8
|
+
const KeenableResponse = Schema.Struct({
|
|
9
|
+
query: Schema.optional(Schema.String),
|
|
10
|
+
mode: Schema.optional(Schema.String),
|
|
11
|
+
results: Schema.optional(
|
|
12
|
+
Schema.Array(
|
|
13
|
+
Schema.Struct({
|
|
14
|
+
title: Schema.optional(Schema.String),
|
|
15
|
+
url: Schema.String,
|
|
16
|
+
description: Schema.optional(Schema.String),
|
|
17
|
+
snippet: Schema.optional(Schema.String),
|
|
18
|
+
published_at: Schema.optional(Schema.String),
|
|
19
|
+
acquired_at: Schema.optional(Schema.String),
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
26
|
+
const ID = "keenable"
|
|
27
|
+
|
|
28
|
+
// Keenable returns ISO 8601 timestamps, which the parser below accepts as-is.
|
|
29
|
+
// See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
|
|
30
|
+
const RELATIVE_MS: Record<string, number> = {
|
|
31
|
+
minute: 60_000,
|
|
32
|
+
hour: 3_600_000,
|
|
33
|
+
day: 86_400_000,
|
|
34
|
+
week: 604_800_000,
|
|
35
|
+
month: 2_592_000_000,
|
|
36
|
+
year: 31_536_000_000,
|
|
37
|
+
分钟: 60_000,
|
|
38
|
+
小时: 3_600_000,
|
|
39
|
+
天: 86_400_000,
|
|
40
|
+
周: 604_800_000,
|
|
41
|
+
个月: 2_592_000_000,
|
|
42
|
+
月: 2_592_000_000,
|
|
43
|
+
年: 31_536_000_000,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const RELATIVE_DATE =
|
|
47
|
+
/^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
|
|
48
|
+
|
|
49
|
+
const ABSOLUTE_DATE = [
|
|
50
|
+
/^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
|
|
51
|
+
/^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
|
|
52
|
+
/^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
|
|
53
|
+
// ISO 8601 with a time component, which Keenable returns on `published_at`.
|
|
54
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
function parseSearchDate(value: string | undefined): number {
|
|
58
|
+
const text = value?.trim()
|
|
59
|
+
if (!text) return NaN
|
|
60
|
+
|
|
61
|
+
const relative = RELATIVE_DATE.exec(text)
|
|
62
|
+
if (relative) {
|
|
63
|
+
const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
|
|
64
|
+
const amount = Number(relative[1])
|
|
65
|
+
if (unit === undefined || !Number.isFinite(amount)) return NaN
|
|
66
|
+
return Date.now() - amount * unit
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Only trust Date.parse on shapes these providers actually emit -- its lenient
|
|
70
|
+
// parser invents a year for strings like "Nov 1", which would date the result
|
|
71
|
+
// to 2001.
|
|
72
|
+
return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toResult(item: {
|
|
76
|
+
title?: string | undefined
|
|
77
|
+
url: string
|
|
78
|
+
description?: string | undefined
|
|
79
|
+
snippet?: string | undefined
|
|
80
|
+
published_at?: string | undefined
|
|
81
|
+
}) {
|
|
82
|
+
const published = parseSearchDate(item.published_at)
|
|
83
|
+
return {
|
|
84
|
+
url: item.url,
|
|
85
|
+
title: item.title || item.url,
|
|
86
|
+
// The snippet is the longer excerpt, so it is the better answer to give an
|
|
87
|
+
// agent; the description is the short summary behind it, and Keenable often
|
|
88
|
+
// returns it empty -- `||`, not `??`, so an empty snippet still falls back.
|
|
89
|
+
content: item.snippet || item.description || "",
|
|
90
|
+
time: Number.isFinite(published) ? { published } : {},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
95
|
+
|
|
96
|
+
function envInt(name: string, fallback: number): number {
|
|
97
|
+
const raw = (process.env[name] ?? "").trim()
|
|
98
|
+
if (raw === "") return fallback
|
|
99
|
+
const value = Number(raw)
|
|
100
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function envStr(name: string): string {
|
|
104
|
+
return (process.env[name] ?? "").trim()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Keenable has two tiers that draw on separate budgets. With a key, a call is
|
|
108
|
+
// metered against the free monthly allowance of 100,000 requests per
|
|
109
|
+
// organization. Without one, a call goes to the `/public` twin of each endpoint
|
|
110
|
+
// and is limited per IP instead -- 1,000 an hour and 10 a second on a pool
|
|
111
|
+
// shared with everyone else on the egress address -- and spends no credits.
|
|
112
|
+
// A missing key therefore routes elsewhere rather than failing, which is why
|
|
113
|
+
// this provider never gates on `bound`. Knobs are read once at load time,
|
|
114
|
+
// restart after changing them.
|
|
115
|
+
//
|
|
116
|
+
// KEENABLE_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
117
|
+
// KEENABLE_MAX_RESULTS results per search, clamped to 1-50 (the API's range)
|
|
118
|
+
// KEENABLE_MODE "pro" for deeper retrieval, "realtime" for speed;
|
|
119
|
+
// leave unset and the organization's default applies
|
|
120
|
+
// KEENABLE_TITLE app name sent as X-Keenable-Title, which the keyless
|
|
121
|
+
// endpoint requires to attribute traffic (max 256 chars)
|
|
122
|
+
// KEENABLE_API_KEY optional; keyless access works without it
|
|
123
|
+
const CACHE_TTL = envInt("KEENABLE_CACHE_TTL", 600_000)
|
|
124
|
+
const SEARCH_SIZE = Math.min(Math.max(envInt("KEENABLE_MAX_RESULTS", 10), 1), 50)
|
|
125
|
+
const MODE = envStr("KEENABLE_MODE")
|
|
126
|
+
const TITLE = (envStr("KEENABLE_TITLE") || "opencode-search-plugin").slice(0, 256)
|
|
127
|
+
|
|
128
|
+
const SEARCH_URL = "https://api.keenable.ai/v1/search"
|
|
129
|
+
const PUBLIC_URL = "https://api.keenable.ai/v1/search/public"
|
|
130
|
+
|
|
131
|
+
const CACHE_LIMIT = 200
|
|
132
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
133
|
+
|
|
134
|
+
function cacheKey(query: string): string {
|
|
135
|
+
return query.trim().replace(/\s+/g, " ")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
139
|
+
if (CACHE_TTL <= 0) return undefined
|
|
140
|
+
const hit = cache.get(queryKey)
|
|
141
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
145
|
+
if (CACHE_TTL <= 0) return
|
|
146
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
147
|
+
if (cache.size > CACHE_LIMIT) {
|
|
148
|
+
const oldest = cache.keys().next().value
|
|
149
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Unlike the key-requiring providers in this repo, Keenable never gates on
|
|
154
|
+
// `bound`: keyless access is a first-class mode, so the provider is always in
|
|
155
|
+
// the pool. The key is still read per call, which is why no credential-event
|
|
156
|
+
// subscription is needed -- a rotated key takes effect on the next search
|
|
157
|
+
// without a reload.
|
|
158
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
159
|
+
Effect.gen(function* () {
|
|
160
|
+
// Make "Keenable" show up in /connect with an API-key method.
|
|
161
|
+
yield* ctx.integration.transform((editor) => {
|
|
162
|
+
editor.update(ID, (integration) => {
|
|
163
|
+
integration.name = "Keenable"
|
|
164
|
+
})
|
|
165
|
+
editor.method.update({
|
|
166
|
+
integrationID: ID,
|
|
167
|
+
method: { type: "key", label: "Keenable API key" },
|
|
168
|
+
})
|
|
169
|
+
editor.method.update({
|
|
170
|
+
integrationID: ID,
|
|
171
|
+
method: { type: "env", names: ["KEENABLE_API_KEY"] },
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
176
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
177
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
178
|
+
// environment fallback below applies exactly as it does when the credential
|
|
179
|
+
// simply does not exist.
|
|
180
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
181
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
182
|
+
|
|
183
|
+
yield* ctx.websearch.transform((editor) => {
|
|
184
|
+
editor.add({
|
|
185
|
+
id: ID,
|
|
186
|
+
name: "Keenable",
|
|
187
|
+
execute: ({ query }) =>
|
|
188
|
+
Effect.gen(function* () {
|
|
189
|
+
const queryKey = cacheKey(query)
|
|
190
|
+
const cached = readCache(queryKey)
|
|
191
|
+
if (cached) return cached
|
|
192
|
+
|
|
193
|
+
// Keyless access is a first-class mode, so a missing key is not an
|
|
194
|
+
// error: it only moves the request to the `/public` endpoint.
|
|
195
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
196
|
+
const credential = connection
|
|
197
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
198
|
+
: undefined
|
|
199
|
+
const key =
|
|
200
|
+
credential?.type === "key" ? credential.key : process.env.KEENABLE_API_KEY
|
|
201
|
+
|
|
202
|
+
const headers: Record<string, string> = {
|
|
203
|
+
Accept: "application/json",
|
|
204
|
+
"Content-Type": "application/json",
|
|
205
|
+
// Required by the keyless endpoint, where an absent value is a
|
|
206
|
+
// 400. It names the calling application rather than the user, so
|
|
207
|
+
// it rides along with a key too.
|
|
208
|
+
"X-Keenable-Title": TITLE,
|
|
209
|
+
}
|
|
210
|
+
if (key) headers["X-API-Key"] = key
|
|
211
|
+
|
|
212
|
+
const response = yield* Effect.tryPromise({
|
|
213
|
+
try: () =>
|
|
214
|
+
fetch(key ? SEARCH_URL : PUBLIC_URL, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers,
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
query: queryKey,
|
|
219
|
+
max_results: SEARCH_SIZE,
|
|
220
|
+
...(MODE ? { mode: MODE } : {}),
|
|
221
|
+
}),
|
|
222
|
+
}),
|
|
223
|
+
catch: (error) => new Error("Keenable request failed: " + String(error)),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
// Only a keyed call is metered, so only a keyed call can exhaust
|
|
228
|
+
// the monthly allowance. The keyless pool is limited by rate, so
|
|
229
|
+
// it answers 429 instead.
|
|
230
|
+
if (response.status === 402 && key) {
|
|
231
|
+
return yield* Effect.fail(
|
|
232
|
+
new Error(
|
|
233
|
+
"Keenable credits exhausted (HTTP 402): the free monthly " +
|
|
234
|
+
"allowance of 100,000 requests is spent. Connect Keenable " +
|
|
235
|
+
"in /connect or set KEENABLE_API_KEY to buy credits, or " +
|
|
236
|
+
"wait for the allowance to reset.",
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
if (response.status === 429) {
|
|
241
|
+
const retry = response.headers.get("Retry-After")
|
|
242
|
+
return yield* Effect.fail(
|
|
243
|
+
new Error(
|
|
244
|
+
"Keenable rate limited the request (HTTP 429)" +
|
|
245
|
+
(retry ? ", retry after " + retry + "s" : "") +
|
|
246
|
+
(key
|
|
247
|
+
? ""
|
|
248
|
+
: ". The keyless pool allows 1,000 requests an hour and " +
|
|
249
|
+
"10 a second per IP; set KEENABLE_API_KEY to spend your " +
|
|
250
|
+
"own 100,000-request monthly allowance instead."),
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
const detail = yield* Effect.tryPromise({
|
|
255
|
+
try: () => response.text(),
|
|
256
|
+
catch: () => new Error("response body unavailable"),
|
|
257
|
+
})
|
|
258
|
+
return yield* Effect.fail(
|
|
259
|
+
new Error("Keenable API error " + response.status + ": " + detail),
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const json = yield* Effect.tryPromise({
|
|
264
|
+
try: () => response.json(),
|
|
265
|
+
catch: (error) => new Error("Invalid Keenable response: " + String(error)),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const body = yield* Schema.decodeUnknownEffect(KeenableResponse)(json)
|
|
269
|
+
const results = (body.results ?? []).map(toResult)
|
|
270
|
+
writeCache(queryKey, results)
|
|
271
|
+
return results
|
|
272
|
+
}),
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
export default Plugin.define({ id: "keenable-search", effect })
|