@eogee-tech/eosearch 0.0.0-stage → 0.2.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 +201 -0
- package/NOTICE +37 -0
- package/README.md +207 -2
- package/build-cli/cli.js +584 -0
- package/build-cli/client.js +115 -0
- package/build-cli/config.js +33 -0
- package/package.json +62 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2025 Open-WebSearch MCP Server Contributors
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
eosearch
|
|
2
|
+
Copyright 2026 eosearch contributors
|
|
3
|
+
|
|
4
|
+
This product includes software developed as part of the Open-WebSearch project.
|
|
5
|
+
|
|
6
|
+
Open-WebSearch MCP Server
|
|
7
|
+
Copyright 2025 Open-WebSearch MCP Server Contributors
|
|
8
|
+
https://github.com/Aas-ee/open-webSearch
|
|
9
|
+
Licensed under the Apache License, Version 2.0
|
|
10
|
+
|
|
11
|
+
Derivative portions of this product (the search engine adapters and network
|
|
12
|
+
utility layer, located under src/engines/, src/utils/, src/core/, src/types/,
|
|
13
|
+
src/config.ts, src/types.ts and src/runtime/) are derived from Open-WebSearch
|
|
14
|
+
and remain licensed under the Apache License, Version 2.0, with the exception
|
|
15
|
+
of the eogee vertical engine adapter (src/engines/eogee/) and the bilibili
|
|
16
|
+
vertical engine adapter (src/engines/bilibili/), which are original eosearch
|
|
17
|
+
code (connecting to the eogee.com knowledge-base gateway and to the Bilibili
|
|
18
|
+
on-site search API, respectively) and are not derived from Open-WebSearch.
|
|
19
|
+
|
|
20
|
+
Modifications made by eosearch contributors include: wiring the Zhihu engine
|
|
21
|
+
and Zhihu article fetcher into the runtime, replacing the original entrypoint
|
|
22
|
+
and MCP tool layer with the eosearch aggregation pipeline (intent routing,
|
|
23
|
+
fusion ranking, TTL caching, circuit-breaker health monitoring, deep search
|
|
24
|
+
and the eosearch tool set), adding native freshness (time-range) parameters
|
|
25
|
+
to the Baidu, Sogou, DuckDuckGo and Hacker News engine adapters together with
|
|
26
|
+
the corresponding shared search execution context field, extending the shared
|
|
27
|
+
web content extractor (src/engines/web/fetchWebContent.ts) so that same-type
|
|
28
|
+
container candidates are concatenated for list-style pages instead of taking
|
|
29
|
+
the first match and a much-shorter Readability result no longer overrides a
|
|
30
|
+
longer built-in extraction, registering a new bilibili vertical search engine
|
|
31
|
+
in the supported engine list (src/core/search/searchEngines.ts), and unwrapping
|
|
32
|
+
Baidu/Sogou redirect-wrapped result links in the aggregation pipeline, and
|
|
33
|
+
related refactoring.
|
|
34
|
+
|
|
35
|
+
The eosearch aggregation layer (src/eosearch/, src/tools/, src/index.ts) is
|
|
36
|
+
Copyright 2026 eosearch contributors and is likewise licensed under the
|
|
37
|
+
Apache License, Version 2.0.
|
package/README.md
CHANGED
|
@@ -1,3 +1,208 @@
|
|
|
1
|
-
#
|
|
1
|
+
# EoSearch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
本地优先、透明可验证的 Agent 搜索基础设施。查询经意图路由分发到多引擎并行搜索,结果融合去重、透明评分后返回结构化数据;正文抽取内置站点适配器与通用正文提取,`deep_search` 一步产出 token 预算内的干净检索上下文。
|
|
4
|
+
|
|
5
|
+
**三种交付形态**:
|
|
6
|
+
|
|
7
|
+
- **MCP 服务**:部署在服务端,用户通过 Streamable HTTP 端点接入,源码不出服务器;
|
|
8
|
+
- **CLI 命令行客户端**:npm 包 `@eogee-tech/eosearch`(命令名 `eosearch`),薄客户端只封装服务端调用,Agent 与人都能在 Shell 里直接用;npm 包内不含任何引擎与聚合代码;
|
|
9
|
+
- **Skill 开通入口**:`skill/SKILL.md` 交付给最终用户,放入客户端技能目录即生效——Agent 按其中指引完成接入,用户填入极会员密钥后 EoSearch 能力即开放;纯文档零代码。
|
|
10
|
+
|
|
11
|
+
**鉴权与计费**:与 eogee.com 极会员体系打通——极会员个人密钥即 EoSearch 的访问凭证,按 Key 计配额;不提供匿名通道,未携带有效 Key 的请求一律拒绝。
|
|
12
|
+
|
|
13
|
+
引擎爬取层派生自开源项目 [Open-WebSearch](https://github.com/Aas-ee/open-webSearch)(Apache License 2.0),聚合管线为原创实现。协议与归属声明见 [NOTICE](./NOTICE)。
|
|
14
|
+
|
|
15
|
+
## MCP 工具
|
|
16
|
+
|
|
17
|
+
| 工具 | 用途 | 关键参数 |
|
|
18
|
+
|------|------|---------|
|
|
19
|
+
| `eosearch_search` | 多引擎融合搜索,结果带透明评分 | `query` 必填;`limit`、`engines`、`zone`、`searchMode`、`freshness`、`includeDomains`、`excludeDomains`、`format` 可选 |
|
|
20
|
+
| `eosearch_batch_search` | 一次调用并行跑多个独立搜索(默认至多 5 条),每条独立路由与评分 | `queries` 必填(字符串或对象数组);`zone`、`format` 可选 |
|
|
21
|
+
| `eosearch_fetch` | 正文抽取,CSDN/掘金/知乎/Linux.do/GitHub 自动走站点适配器 | `url` 必填;`maxChars`、`readability`、`renderMode` 可选 |
|
|
22
|
+
| `eosearch_deep_search` | 搜索 + 选源 + 并行抓取正文,返回 token 预算内的检索摘录 | `query` 必填;`topN`、`tokenBudget`、`zone`、`engines`、`freshness`、`includeDomains`、`excludeDomains`、`format` 可选 |
|
|
23
|
+
|
|
24
|
+
每条搜索结果携带 `score`(0-100)、`engines`(来源引擎)与 `reasons`(可读排序理由,如 `#1 on csdn`、`also found by bing`、`download-page penalty`),评分机制完全透明。
|
|
25
|
+
|
|
26
|
+
**委派抓取**:反爬拦截、需 JS 渲染或非文本(PDF/图片)的页面,服务端无法直接提取正文时会返回带 `[委派]` 标记的分级接力指引,由调用方 Agent 用用户本机的浏览器工具完成读取(浏览器直读 → 打印 PDF 交 MinerU 免 Key 极速版 → 精确解析兜底),详见 Skill 文档。
|
|
27
|
+
|
|
28
|
+
**时效与域名过滤**:`freshness`(day/week/month/year)在 baidu、sogou、duckduckgo、hackernews 上走引擎原生时间过滤,其余引擎按标题与描述中可解析的日期做后置兜底(无日期结果保留,不误杀);`includeDomains`/`excludeDomains` 做子域名级的站内/站外过滤。被过滤掉的结果数通过 `filteredOut` 字段透明返回。
|
|
29
|
+
|
|
30
|
+
## 服务用户接入
|
|
31
|
+
|
|
32
|
+
用户开通 EoSearch 的标准路径:把 `skill/SKILL.md` 安装到客户端技能目录(如 `.zcode/skills/`、`.claude/skills/`),Agent 按其中指引完成接入;用户填入极会员个人密钥(eogee.com 个人中心「MCP密钥」申请)后,能力即开放、无需其他配置。密钥占位与各客户端(ZCode、Claude Code、Cherry Studio、Cursor 等)的完整配置示例都在 Skill 中。核心配置形如:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"eosearch": {
|
|
38
|
+
"type": "http",
|
|
39
|
+
"url": "https://eogee.com/search/v1/mcp",
|
|
40
|
+
"headers": { "X-API-Key": "极会员个人密钥" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## CLI 命令行客户端
|
|
47
|
+
|
|
48
|
+
npm 包 `@eogee-tech/eosearch` 提供 `eosearch` 命令,面向所有能用 Shell 的场景:Agent 的 Bash 工具、CI、一次性沙箱容器、管道组合。服务端与 MCP 共用同一端点与配额,CLI 每次工具调用计 1 次配额,握手不计。
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install -g @eogee-tech/eosearch # 或免安装:npx @eogee-tech/eosearch
|
|
52
|
+
export EOSEARCH_API_KEY=<极会员密钥> # 或在 ~/.eosearchrc 写 {"apiKey":"..."}
|
|
53
|
+
eosearch whoami # 自检:密钥有效即接入完成
|
|
54
|
+
eosearch search "llama.cpp 新版本" --json --limit 5
|
|
55
|
+
eosearch deep "2026 国产大模型对比" --top-n 3
|
|
56
|
+
eosearch batch "量子计算现状" "相关ETF" --json
|
|
57
|
+
eosearch fetch https://github.com/ggml-org/llama.cpp
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| 子命令 | 对应 MCP 工具 | 说明 |
|
|
61
|
+
|--------|--------------|------|
|
|
62
|
+
| `search <query>` | `eosearch_search` | `--limit`、`--engines`、`--zone`、`--search-mode`、`--freshness`、`--include-domains`、`--exclude-domains`、`--json` |
|
|
63
|
+
| `batch <q1> ... <q5>` | `eosearch_batch_search` | 并行 1-5 条查询,配额计 1 次 |
|
|
64
|
+
| `fetch <url>` | `eosearch_fetch` | `--max-chars`、`--no-readability`、`--render-mode`、`--json` |
|
|
65
|
+
| `deep <query>` | `eosearch_deep_search` | `--top-n`、`--token-budget`、其余过滤项同 search |
|
|
66
|
+
| `whoami` | initialize 握手 | 验证密钥与服务连接,不计配额 |
|
|
67
|
+
|
|
68
|
+
设计约定:密钥只从环境变量或 `~/.eosearchrc` 读取(不进命令行参数,避免泄漏进 Shell 历史);数据走 stdout、诊断走 stderr;`--json` 输出与服务端 MCP 工具字段一致;语义化退出码——0 成功、1 业务空结果、2 用法错误、3 鉴权失败、4 配额限流、5 服务不可用或执行失败;报错固定三段式(错误/原因/下一步)。
|
|
69
|
+
|
|
70
|
+
薄客户端边界:npm 包 `files` 只含 `build-cli/`(`src/cli/` 的编译产物),引擎爬取层与聚合层代码不出仓库;顶层 `eosearch` 包名已被无关包占用,故发布名为 `@eogee-tech/eosearch`,命令名仍为 `eosearch`。
|
|
71
|
+
|
|
72
|
+
## 服务端部署
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm install && npm run build
|
|
76
|
+
EOSEARCH_EOGEE_AUTH=true EOSEARCH_REQUIRE_KEY=true MODE=http PORT=3000 node build/index.js
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
前置 Nginx 反代并终止 TLS 即可对外服务。生产环境务必配置:
|
|
80
|
+
|
|
81
|
+
1. `EOSEARCH_REQUIRE_KEY=true`(拒绝匿名访问,当前生产口径);
|
|
82
|
+
2. `EOSEARCH_API_KEYS` 预留若干静态 Key 供内部测试。
|
|
83
|
+
|
|
84
|
+
服务本身零 LLM 依赖:搜索与摘录全部由确定性代码完成,理解与总结交给调用方 Agent。
|
|
85
|
+
|
|
86
|
+
健康检查端点 `GET /health` 无需鉴权,返回各引擎熔断状态,可接入监控。
|
|
87
|
+
|
|
88
|
+
## 极会员体系集成
|
|
89
|
+
|
|
90
|
+
启用 `EOSEARCH_EOGEE_AUTH=true` 后,用户传入的密钥会回源 `https://eogee.com/kb/v1/mcp`(MCP 网关)校验:无效密钥网关返回 401,有效密钥返回 200。校验结果带 TTL 缓存(有效 10 分钟、无效 2 分钟),不会每次调用都回源。
|
|
91
|
+
|
|
92
|
+
- 密钥头兼容两种写法:`X-API-Key: <key>`(eogee 生态惯例)与 `Authorization: Bearer <key>`;
|
|
93
|
+
- 回源失败时默认拒绝(503,保收费),设 `EOSEARCH_EOGEE_FAIL_OPEN=true` 改为放行(保可用);
|
|
94
|
+
- 极会员校验与静态 Key(`EOSEARCH_API_KEYS`)可叠加,静态 Key 优先命中。
|
|
95
|
+
|
|
96
|
+
### 两档配额
|
|
97
|
+
|
|
98
|
+
| 档位 | 判定条件 | 每日 | 每分钟 |
|
|
99
|
+
|------|---------|------|--------|
|
|
100
|
+
| 极会员 | 有效 Key 且等级为 premium | 不限 | 60 次 |
|
|
101
|
+
| 普通用户 | 有效 Key 且等级为 regular | 100 次 | 10 次 |
|
|
102
|
+
| 匿名 | 无 Key | 拒绝(401) | — |
|
|
103
|
+
|
|
104
|
+
匿名通道不开放:生产配置 `EOSEARCH_REQUIRE_KEY=true`,未携带有效 Key 的请求一律 401。引擎层的匿名配额能力(`EOSEARCH_ANON_*`)仅供自用/内测模式。
|
|
105
|
+
|
|
106
|
+
### 普通用户 Key 的等级识别(eogee.com 侧对接契约)
|
|
107
|
+
|
|
108
|
+
eogee.com 签发普通用户 Key 后,EoSearch 用以下任一机制识别等级,**可先用地一再升级**:
|
|
109
|
+
|
|
110
|
+
1. **Key 前缀约定(零后端改动)**:普通用户 Key 统一带固定前缀(如 `reg_`),EoSearch 侧配置 `EOSEARCH_REGULAR_KEY_PREFIX=reg_` 即可自动归入普通档;
|
|
111
|
+
2. **网关响应头(推荐,最终形态)**:网关在 initialize 响应中回传 `X-Member-Level: premium` 或 `X-Member-Level: regular` 头,EoSearch 自动解析并缓存,前缀规则随之不再依赖。
|
|
112
|
+
|
|
113
|
+
两种机制同时存在时,响应头优先。
|
|
114
|
+
|
|
115
|
+
## 鉴权与配额
|
|
116
|
+
|
|
117
|
+
| 变量 | 默认值 | 说明 |
|
|
118
|
+
|------|--------|------|
|
|
119
|
+
| `EOSEARCH_API_KEYS` | 空 | 逗号分隔的静态 Key 列表;为空且未开极会员时鉴权整体关闭(自用模式) |
|
|
120
|
+
| `EOSEARCH_EOGEE_AUTH` | `false` | 启用极会员密钥回源校验 |
|
|
121
|
+
| `EOSEARCH_EOGEE_MCP_URL` | `https://eogee.com/kb/v1/mcp` | 极会员校验网关地址 |
|
|
122
|
+
| `EOSEARCH_EOGEE_FAIL_OPEN` | `false` | 校验后端不可达时 true 放行 / false 拒绝 |
|
|
123
|
+
| `EOSEARCH_EOGEE_CACHE_TTL_MS` | `600000` | 有效密钥缓存时长 |
|
|
124
|
+
| `EOSEARCH_EOGEE_INVALID_TTL_MS` | `120000` | 无效密钥缓存时长(短于有效期,密钥吊销后尽快生效) |
|
|
125
|
+
| `EOSEARCH_EOGEE_CACHE_MAX_ENTRIES` | `5000` | 校验结果缓存容量上限(LRU 驱逐,防恶意喷射 Key 撑爆内存) |
|
|
126
|
+
| `EOSEARCH_REQUIRE_KEY` | `false` | `true` 时拒绝匿名访问(纯会员模式) |
|
|
127
|
+
| `EOSEARCH_REGULAR_KEY_PREFIX` | 空 | 普通用户 Key 前缀约定(如 `reg_`),命中则归入普通档 |
|
|
128
|
+
| `EOSEARCH_KEY_DAILY_LIMIT` | `0`(不限) | 极会员每日配额,营销口径"不限次数" |
|
|
129
|
+
| `EOSEARCH_KEY_MINUTE_LIMIT` | `60` | 极会员每分钟限速(防滥用的真正防线) |
|
|
130
|
+
| `EOSEARCH_REGULAR_DAILY_LIMIT` | `100` | 普通用户每日配额 |
|
|
131
|
+
| `EOSEARCH_REGULAR_MINUTE_LIMIT` | `10` | 普通用户每分钟限速 |
|
|
132
|
+
| `EOSEARCH_ANON_DAILY_LIMIT` | `50` | 匿名每日配额,0 = 不限(仅自用/内测模式,生产拒绝匿名) |
|
|
133
|
+
| `EOSEARCH_ANON_MINUTE_LIMIT` | `10` | 匿名每分钟限速,0 = 不限(仅自用/内测模式) |
|
|
134
|
+
|
|
135
|
+
配额只计实际工具调用(`tools/call`),连接握手与工具列表查询不计费;一次 `eosearch_batch_search` 无论含几条 query 都只计 1 次调用(单次批量的 query 数上限由 `EOSEARCH_BATCH_MAX_QUERIES` 控制,默认 5)。超限返回 429 并携带 `Retry-After`;成功调用响应携带 `X-Quota-Remaining-Today`。
|
|
136
|
+
|
|
137
|
+
## 工作原理
|
|
138
|
+
|
|
139
|
+
```mermaid
|
|
140
|
+
graph LR
|
|
141
|
+
A[MCP 四工具] --> H[鉴权与配额<br/>极会员 / 普通用户两档]
|
|
142
|
+
H --> B[意图路由<br/>语言与领域识别]
|
|
143
|
+
B --> C[引擎健康门<br/>熔断剔除]
|
|
144
|
+
C --> D[多引擎并行<br/>SERP 缓存]
|
|
145
|
+
D --> E[融合评分<br/>去重与透明 score]
|
|
146
|
+
A --> F[抽取管线<br/>站点适配器 + Readability]
|
|
147
|
+
A --> G[deep_search<br/>选源与 token 预算]
|
|
148
|
+
F --> G
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
1. **意图路由**:识别查询语言与意图(技术文档 / 代码 / 资讯 / 通用),自动选择引擎组合。中文技术查询优先 CSDN、掘金、Bing;英文代码查询优先 DuckDuckGo、Hacker News。显式传 `engines` 可绕过路由。
|
|
152
|
+
2. **融合评分**:按归一化 URL 去重聚合,评分 = 引擎内排名 + 多引擎佐证 + 查询词相关性 + 引擎权威度加权;下载站与文库页自动降权,全部分量写入 `reasons`;融合后可再按 `freshness` 与 `includeDomains`/`excludeDomains` 做后置过滤,剔除数透明计入 `filteredOut`。
|
|
153
|
+
3. **健康监控**:单引擎连续失败触发熔断(冷却后半开重试),单引擎调用有超时竞速,个别引擎卡死不拖慢整体。
|
|
154
|
+
4. **抽取管线**:CSDN、掘金、知乎、Linux.do 文章与 GitHub README 走专用适配器,失败自动回落通用抓取(Mozilla Readability)。
|
|
155
|
+
5. **缓存**:SERP 与页面带 TTL 进程内缓存,重复查询零网络开销。
|
|
156
|
+
|
|
157
|
+
## 环境变量(引擎层,沿用上游)
|
|
158
|
+
|
|
159
|
+
| 变量 | 默认值 | 说明 |
|
|
160
|
+
|------|--------|------|
|
|
161
|
+
| `MODE` | `both` | `stdio`、`http` 或 `both` |
|
|
162
|
+
| `PORT` | `3000` | HTTP 端口 |
|
|
163
|
+
| `DEFAULT_SEARCH_ENGINE` | `bing` | 显式指定未知引擎时的默认引擎 |
|
|
164
|
+
| `ALLOWED_SEARCH_ENGINES` | 全部 | 逗号分隔的引擎白名单 |
|
|
165
|
+
| `USE_PROXY` / `PROXY_URL` | 关闭 | 引擎层 HTTP 代理 |
|
|
166
|
+
| `SEARCH_MODE` | `auto` | Bing 搜索策略 |
|
|
167
|
+
| `EOSEARCH_ROUTE_TOPK` | `3` | 自动路由选择的引擎数量(1-6) |
|
|
168
|
+
| `EOSEARCH_ENGINE_TIMEOUT_MS` | `15000` | 单引擎调用超时 |
|
|
169
|
+
| `EOSEARCH_BREAKER_THRESHOLD` | `3` | 连续失败熔断阈值 |
|
|
170
|
+
| `EOSEARCH_BREAKER_COOLDOWN_MS` | `300000` | 熔断冷却时长 |
|
|
171
|
+
| `EOSEARCH_CACHE_TTL_MS` | `600000` | 搜索结果缓存 TTL |
|
|
172
|
+
| `EOSEARCH_FETCH_CACHE_TTL_MS` | `1800000` | 页面抽取缓存 TTL |
|
|
173
|
+
| `EOSEARCH_BATCH_MAX_QUERIES` | `5` | 单次批量搜索的 query 数上限(1-20) |
|
|
174
|
+
| `EOSEARCH_BATCH_CONCURRENCY` | `2` | 批量搜索的批内并发度(1-10) |
|
|
175
|
+
| `EOSEARCH_EOGEE_KB_API_KEY` | 空 | eogee KB 垂直引擎的服务 Key;配置后 eogee.com 站内内容接入搜索路由,留空则引擎不启用 |
|
|
176
|
+
| `EOSEARCH_EOGEE_KB_MCP_URL` | 同 `EOSEARCH_EOGEE_MCP_URL` | KB 网关地址,同机部署可指向内网回环 |
|
|
177
|
+
|
|
178
|
+
Playwright 浏览器兜底为可选能力,配置方式与上游一致,详见 [Open-WebSearch README](https://github.com/Aas-ee/open-webSearch#readme)。
|
|
179
|
+
|
|
180
|
+
## 支持的搜索引擎
|
|
181
|
+
|
|
182
|
+
baidu、bing、csdn、duckduckgo、exa、brave、juejin、startpage、sogou、hackernews、zhihu、linuxdo(上游标记暂不可用,显式指定仍可尝试)、eogee(eogee.com 知识库垂直源,原创实现)。
|
|
183
|
+
|
|
184
|
+
eogee 引擎把 eogee.com 站内教程与资讯接入中文技术文档与资讯意图的优先路由:配置 `EOSEARCH_EOGEE_KB_API_KEY` 后自动启用(未配置则不参与路由),通过 MCP 协议调用知识库网关 `search_articles`,多词 query 自动分词多探针并行搜索并按命中覆盖度合并。生产与 KB 同机部署时建议将 `EOSEARCH_EOGEE_KB_MCP_URL` 指向内网回环地址。
|
|
185
|
+
|
|
186
|
+
## 本地开发
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
git clone <本仓库>
|
|
190
|
+
cd eosearch
|
|
191
|
+
npm install
|
|
192
|
+
npm run build # 编译到 build/
|
|
193
|
+
npm run smoke # 冒烟测试:路由单测 + 真实搜索/抓取/deep_search
|
|
194
|
+
node test/auth-http-test.mjs <极会员Key> # 鉴权集成测试(需先以测试配置起服务)
|
|
195
|
+
npx @modelcontextprotocol/inspector build/index.js # MCP 调试器
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## 许可证与商业化说明
|
|
199
|
+
|
|
200
|
+
Apache License 2.0。引擎爬取层派生自 Open-WebSearch 项目,归属与修改声明见 [NOTICE](./NOTICE)。
|
|
201
|
+
|
|
202
|
+
Apache 2.0 的源码公开义务仅在**分发**软件时触发;将本项目部署于自有服务器对外提供服务不属于分发,源码无需公开。前提:本仓库保持私有、不发布 npm 包。依赖链均为 MIT / Apache 2.0,无 GPL 传染。
|
|
203
|
+
|
|
204
|
+
使用提示:本项目通过抓取公开搜索引擎结果页工作,引擎自身的服务条款与反爬策略不受本项目控制,请保持合理调用频率,生产环境依靠缓存命中率与限流设计控制风险。
|
|
205
|
+
|
|
206
|
+
## 数据留存说明
|
|
207
|
+
|
|
208
|
+
服务进程只在内存中缓存查询词与搜索结果(TTL 到期即弃,进程重启即清零),不落盘、无第三方统计与遥测组件。搜索引擎调用会把查询词转发给相应的搜索引擎(百度、必应、搜狗等),其各自的隐私政策不在本项目控制范围;极会员鉴权仅向 eogee.com 网关回源校验密钥有效性,不做其他数据外传。HTTP 访问日志由 Nginx 等前置网关按运维侧策略管理。
|
package/build-cli/cli.js
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli/cli.ts — EoSearch 命令行客户端入口(npm 包 @eogee-tech/eosearch)
|
|
3
|
+
// 薄客户端:本地只做参数解析与输出格式化,搜索与抓取全部在 eogee.com 服务端执行
|
|
4
|
+
// 退出码:0 成功;1 业务空结果;2 用法错误;3 鉴权失败;4 配额限流;5 服务不可用/执行失败
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { resolveConfig, configFilePath, DEFAULT_SERVER_URL } from './config.js';
|
|
7
|
+
import { callTool, whoami } from './client.js';
|
|
8
|
+
function readVersion() {
|
|
9
|
+
try {
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
return require('../package.json').version ?? '0.0.0';
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return '0.0.0';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// ---------- 用法文本 ----------
|
|
18
|
+
const MAIN_HELP = `eosearch — EoSearch 联网搜索命令行客户端(v${readVersion()})
|
|
19
|
+
|
|
20
|
+
本地只做参数解析与结果展示,搜索与抓取在服务端执行;需要极会员密钥。
|
|
21
|
+
|
|
22
|
+
用法: eosearch <子命令> [参数] [选项]
|
|
23
|
+
|
|
24
|
+
子命令:
|
|
25
|
+
search <query> 联网搜索:意图路由 + 多引擎融合评分
|
|
26
|
+
batch <q1> <q2> ... 并行执行 1-5 条独立搜索(配额计 1 次)
|
|
27
|
+
fetch <url> 抓取网页正文(自动适配 CSDN/掘金/知乎/GitHub)
|
|
28
|
+
deep <query> 深度调研:搜索 + 选源 + 抓正文 + 摘录
|
|
29
|
+
whoami 验证密钥与服务连接
|
|
30
|
+
help [子命令] 显示帮助
|
|
31
|
+
|
|
32
|
+
通用选项:
|
|
33
|
+
--json 机器可读 JSON 输出(stdout,字段与 MCP 工具一致)
|
|
34
|
+
--server-url <url> 覆盖服务地址(默认 ${DEFAULT_SERVER_URL})
|
|
35
|
+
--timeout <秒> 单次调用超时(默认 120)
|
|
36
|
+
--version, -V 显示 CLI 版本
|
|
37
|
+
--help, -h 显示帮助
|
|
38
|
+
|
|
39
|
+
密钥配置(环境变量优先,二选一):
|
|
40
|
+
环境变量 EOSEARCH_API_KEY=<你的极会员密钥>
|
|
41
|
+
配置文件 ${configFilePath()} 写入 {"apiKey":"你的极会员密钥","serverUrl":"可选服务地址"}
|
|
42
|
+
|
|
43
|
+
安全约定:密钥只从环境变量或配置文件读取,不提供命令行参数入口,避免进入 Shell 历史与进程列表。
|
|
44
|
+
|
|
45
|
+
示例:
|
|
46
|
+
eosearch search "llama.cpp 新版本" --json --limit 5
|
|
47
|
+
eosearch deep "2026 国产大模型对比" --top-n 3 --timeout 300
|
|
48
|
+
eosearch batch "量子计算现状" "相关ETF" --json
|
|
49
|
+
eosearch fetch https://github.com/ggml-org/llama.cpp
|
|
50
|
+
eosearch whoami`;
|
|
51
|
+
function subcommandHelp(command) {
|
|
52
|
+
const shared = `通用选项: --json 机器可读输出 | --server-url <url> 覆盖服务地址 | --timeout <秒> 单次超时(默认 120)
|
|
53
|
+
|
|
54
|
+
密钥: 环境变量 EOSEARCH_API_KEY,或配置文件 ${configFilePath()} 的 {"apiKey":"..."} 字段`;
|
|
55
|
+
switch (command) {
|
|
56
|
+
case 'search':
|
|
57
|
+
return `eosearch search — 联网搜索:意图路由 + 多引擎融合评分
|
|
58
|
+
|
|
59
|
+
用法: eosearch search <query> [选项]
|
|
60
|
+
|
|
61
|
+
选项:
|
|
62
|
+
--limit <1-50> 返回条数上限(默认 10)
|
|
63
|
+
--engines <a,b,...> 指定引擎列表,绕过意图路由(如 bing,baidu,bilibili)
|
|
64
|
+
--zone <auto|cn|intl> 路由偏好:cn 偏中文引擎,intl 偏国际引擎(默认 auto)
|
|
65
|
+
--search-mode <模式> request|auto|playwright(Bing 异常时用 playwright 重试)
|
|
66
|
+
--freshness <窗口> day|week|month|year,只保留该时间窗内发布的结果
|
|
67
|
+
--include-domains <a,b> 只保留这些域名(含子域)的结果
|
|
68
|
+
--exclude-domains <a,b> 丢弃这些域名(含子域)的结果
|
|
69
|
+
--json 输出服务端 JSON(字段含 score/engines/reasons)
|
|
70
|
+
|
|
71
|
+
${shared}
|
|
72
|
+
|
|
73
|
+
示例: eosearch search "llama.cpp 新版本" --freshness month --json --limit 5`;
|
|
74
|
+
case 'batch':
|
|
75
|
+
return `eosearch batch — 并行执行 1-5 条独立搜索,配额计 1 次
|
|
76
|
+
|
|
77
|
+
用法: eosearch batch <query1> <query2> ... [选项]
|
|
78
|
+
|
|
79
|
+
选项:
|
|
80
|
+
--zone <auto|cn|intl> 共享路由偏好,应用到未单独指定的查询
|
|
81
|
+
--json 输出服务端 JSON(results 数组 + failures 数组)
|
|
82
|
+
|
|
83
|
+
${shared}
|
|
84
|
+
|
|
85
|
+
示例: eosearch batch "量子计算现状" "相关ETF" "IBM 股价" --json`;
|
|
86
|
+
case 'fetch':
|
|
87
|
+
return `eosearch fetch — 抓取网页并返回清洗后的正文
|
|
88
|
+
|
|
89
|
+
用法: eosearch fetch <url> [选项]
|
|
90
|
+
|
|
91
|
+
选项:
|
|
92
|
+
--max-chars <500-200000> 返回正文长度上限(默认 20000)
|
|
93
|
+
--no-readability 关闭正文主内容抽取(默认开启)
|
|
94
|
+
--render-mode <模式> request|auto|browser(auto 需服务端装有 Playwright)
|
|
95
|
+
--json 输出 {"url":"...","content":"..."} 结构
|
|
96
|
+
|
|
97
|
+
${shared}
|
|
98
|
+
|
|
99
|
+
示例: eosearch fetch https://github.com/ggml-org/llama.cpp --max-chars 8000`;
|
|
100
|
+
case 'deep':
|
|
101
|
+
return `eosearch deep — 深度调研:搜索、选源、抓正文、按 token 预算输出摘录
|
|
102
|
+
|
|
103
|
+
用法: eosearch deep <query> [选项]
|
|
104
|
+
|
|
105
|
+
选项:
|
|
106
|
+
--top-n <1-10> 实际读取的来源数(默认 5)
|
|
107
|
+
--token-budget <数值> 摘录 token 预算,2000-120000(默认 24000)
|
|
108
|
+
--engines <a,b,...> 指定引擎列表
|
|
109
|
+
--zone <auto|cn|intl> 路由偏好
|
|
110
|
+
--freshness <窗口> day|week|month|year
|
|
111
|
+
--include-domains <a,b> 只保留这些域名
|
|
112
|
+
--exclude-domains <a,b> 丢弃这些域名
|
|
113
|
+
--json 额外返回结构化 sources 数组(url/score/adapter)
|
|
114
|
+
|
|
115
|
+
${shared}
|
|
116
|
+
|
|
117
|
+
示例: eosearch deep "2026 国产大模型对比" --top-n 3 --timeout 300`;
|
|
118
|
+
case 'whoami':
|
|
119
|
+
return `eosearch whoami — 验证密钥与服务连接(握手不计配额)
|
|
120
|
+
|
|
121
|
+
用法: eosearch whoami [选项]
|
|
122
|
+
|
|
123
|
+
输出密钥有效性、服务端名称与版本、密钥来源。
|
|
124
|
+
退出码: 0 密钥有效;3 密钥被拒;4 限流;5 服务不可达。
|
|
125
|
+
|
|
126
|
+
${shared}
|
|
127
|
+
|
|
128
|
+
示例: eosearch whoami`;
|
|
129
|
+
default:
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const SPECS = {
|
|
134
|
+
search: {
|
|
135
|
+
valueFlags: ['limit', 'engines', 'zone', 'search-mode', 'freshness', 'include-domains', 'exclude-domains', 'server-url', 'timeout'],
|
|
136
|
+
boolFlags: ['json'],
|
|
137
|
+
minPos: 1,
|
|
138
|
+
maxPos: 1
|
|
139
|
+
},
|
|
140
|
+
batch: {
|
|
141
|
+
valueFlags: ['zone', 'server-url', 'timeout'],
|
|
142
|
+
boolFlags: ['json'],
|
|
143
|
+
minPos: 1,
|
|
144
|
+
maxPos: 5
|
|
145
|
+
},
|
|
146
|
+
fetch: {
|
|
147
|
+
valueFlags: ['max-chars', 'render-mode', 'server-url', 'timeout'],
|
|
148
|
+
boolFlags: ['json', 'no-readability'],
|
|
149
|
+
minPos: 1,
|
|
150
|
+
maxPos: 1
|
|
151
|
+
},
|
|
152
|
+
deep: {
|
|
153
|
+
valueFlags: ['top-n', 'token-budget', 'engines', 'zone', 'freshness', 'include-domains', 'exclude-domains', 'server-url', 'timeout'],
|
|
154
|
+
boolFlags: ['json'],
|
|
155
|
+
minPos: 1,
|
|
156
|
+
maxPos: 1
|
|
157
|
+
},
|
|
158
|
+
whoami: {
|
|
159
|
+
valueFlags: ['server-url', 'timeout'],
|
|
160
|
+
boolFlags: ['json'],
|
|
161
|
+
minPos: 0,
|
|
162
|
+
maxPos: 0
|
|
163
|
+
},
|
|
164
|
+
help: {
|
|
165
|
+
valueFlags: [],
|
|
166
|
+
boolFlags: [],
|
|
167
|
+
minPos: 0,
|
|
168
|
+
maxPos: 1
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
class UsageError extends Error {
|
|
172
|
+
}
|
|
173
|
+
function parseArgs(argv) {
|
|
174
|
+
if (argv.length === 0)
|
|
175
|
+
return { kind: 'ok', command: 'help', positional: [], flags: new Map() };
|
|
176
|
+
const first = argv[0];
|
|
177
|
+
if (first === 'help' || first === '--help' || first === '-h') {
|
|
178
|
+
return { kind: 'ok', command: 'help', positional: argv.slice(1), flags: new Map() };
|
|
179
|
+
}
|
|
180
|
+
if (first.startsWith('-')) {
|
|
181
|
+
return { kind: 'usage', message: `未知参数 "${first}"。先写子命令,如 eosearch search <query>;eosearch help 查看全部子命令` };
|
|
182
|
+
}
|
|
183
|
+
if (!(first in SPECS)) {
|
|
184
|
+
return { kind: 'usage', message: `未知子命令 "${first}"。eosearch help 查看全部子命令` };
|
|
185
|
+
}
|
|
186
|
+
const command = first;
|
|
187
|
+
const spec = SPECS[command];
|
|
188
|
+
const rest = argv.slice(1);
|
|
189
|
+
const flags = new Map();
|
|
190
|
+
const positional = [];
|
|
191
|
+
for (let i = 0; i < rest.length; i++) {
|
|
192
|
+
const tok = rest[i];
|
|
193
|
+
if (tok === '--') {
|
|
194
|
+
positional.push(...rest.slice(i + 1));
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
if (tok.startsWith('--')) {
|
|
198
|
+
const eq = tok.indexOf('=');
|
|
199
|
+
if (eq >= 0) {
|
|
200
|
+
flags.set(tok.slice(2, eq), tok.slice(eq + 1));
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const key = tok.slice(2);
|
|
204
|
+
const next = rest[i + 1];
|
|
205
|
+
if (spec.valueFlags.includes(key) && next !== undefined && !next.startsWith('--')) {
|
|
206
|
+
flags.set(key, next);
|
|
207
|
+
i++;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
flags.set(key, true);
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (tok.startsWith('-') && tok.length === 2) {
|
|
215
|
+
flags.set(tok.slice(1), true);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
positional.push(tok);
|
|
219
|
+
}
|
|
220
|
+
for (const key of flags.keys()) {
|
|
221
|
+
if (!spec.valueFlags.includes(key) && !spec.boolFlags.includes(key) && !['help', 'h', 'version', 'V'].includes(key)) {
|
|
222
|
+
return {
|
|
223
|
+
kind: 'usage',
|
|
224
|
+
message: `子命令 ${command} 不支持选项 --${key}。eosearch ${command} --help 查看支持的选项`
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (positional.length < spec.minPos) {
|
|
229
|
+
return { kind: 'usage', message: `${command} 缺少必填参数。eosearch ${command} --help 查看用法` };
|
|
230
|
+
}
|
|
231
|
+
if (positional.length > spec.maxPos) {
|
|
232
|
+
const hint = command === 'batch' ? `batch 一次最多 ${spec.maxPos} 条查询` : `${command} 只接受 ${spec.maxPos} 个位置参数`;
|
|
233
|
+
return { kind: 'usage', message: `${hint},收到 ${positional.length} 个。eosearch ${command} --help 查看用法` };
|
|
234
|
+
}
|
|
235
|
+
return { kind: 'ok', command, positional, flags };
|
|
236
|
+
}
|
|
237
|
+
// ---------- 选项读取与校验 ----------
|
|
238
|
+
function rawFlag(flags, key) {
|
|
239
|
+
const value = flags.get(key);
|
|
240
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
241
|
+
}
|
|
242
|
+
function intFlag(flags, key, min, max) {
|
|
243
|
+
const raw = rawFlag(flags, key);
|
|
244
|
+
if (raw === null)
|
|
245
|
+
return null;
|
|
246
|
+
if (!/^\d+$/.test(raw)) {
|
|
247
|
+
throw new UsageError(`--${key} 需要 ${min}-${max} 的整数,收到 "${raw}"`);
|
|
248
|
+
}
|
|
249
|
+
const n = parseInt(raw, 10);
|
|
250
|
+
if (n < min || n > max) {
|
|
251
|
+
throw new UsageError(`--${key} 需要在 ${min}-${max} 之间,收到 ${n}`);
|
|
252
|
+
}
|
|
253
|
+
return n;
|
|
254
|
+
}
|
|
255
|
+
function enumFlag(flags, key, allowed) {
|
|
256
|
+
const raw = rawFlag(flags, key);
|
|
257
|
+
if (raw === null)
|
|
258
|
+
return null;
|
|
259
|
+
if (!allowed.includes(raw)) {
|
|
260
|
+
throw new UsageError(`--${key} 只支持 ${allowed.join('|')},收到 "${raw}"`);
|
|
261
|
+
}
|
|
262
|
+
return raw;
|
|
263
|
+
}
|
|
264
|
+
function commaList(flags, key) {
|
|
265
|
+
const raw = rawFlag(flags, key);
|
|
266
|
+
if (raw === null)
|
|
267
|
+
return null;
|
|
268
|
+
const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
269
|
+
if (list.length === 0) {
|
|
270
|
+
throw new UsageError(`--${key} 需要逗号分隔的列表,如 a.com,b.com`);
|
|
271
|
+
}
|
|
272
|
+
return list;
|
|
273
|
+
}
|
|
274
|
+
function timeoutMsOf(flags) {
|
|
275
|
+
const seconds = intFlag(flags, 'timeout', 1, 600);
|
|
276
|
+
return (seconds ?? 120) * 1000;
|
|
277
|
+
}
|
|
278
|
+
// ---------- 输出与退出 ----------
|
|
279
|
+
function emitFailure(f) {
|
|
280
|
+
console.error(`错误:${f.problem}`);
|
|
281
|
+
console.error(`原因:${f.cause}`);
|
|
282
|
+
console.error(`下一步:${f.next}`);
|
|
283
|
+
}
|
|
284
|
+
function emitUsage(message) {
|
|
285
|
+
console.error(`错误:${message}`);
|
|
286
|
+
process.exit(2);
|
|
287
|
+
}
|
|
288
|
+
function missingKeyFailure() {
|
|
289
|
+
return {
|
|
290
|
+
kind: 'failure',
|
|
291
|
+
exitCode: 3,
|
|
292
|
+
problem: '未找到 EoSearch API 密钥',
|
|
293
|
+
cause: `环境变量 EOSEARCH_API_KEY 未设置,配置文件 ${configFilePath()} 中也没有 apiKey 字段`,
|
|
294
|
+
next: '设置环境变量 export EOSEARCH_API_KEY=<你的极会员密钥>,或在 ~/.eosearchrc 写入 {"apiKey":"..."};密钥在 eogee.com 个人中心获取'
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
async function callAndExit(opts) {
|
|
298
|
+
const outcome = await callTool({
|
|
299
|
+
serverUrl: opts.config.serverUrl,
|
|
300
|
+
apiKey: opts.config.apiKey,
|
|
301
|
+
clientVersion: readVersion(),
|
|
302
|
+
toolName: opts.toolName,
|
|
303
|
+
toolArgs: opts.toolArgs,
|
|
304
|
+
timeoutMs: opts.timeoutMs
|
|
305
|
+
});
|
|
306
|
+
if (outcome.kind === 'failure') {
|
|
307
|
+
emitFailure(outcome);
|
|
308
|
+
process.exit(outcome.exitCode);
|
|
309
|
+
}
|
|
310
|
+
if (opts.json) {
|
|
311
|
+
process.stdout.write(outcome.text + '\n');
|
|
312
|
+
let parsed = null;
|
|
313
|
+
try {
|
|
314
|
+
parsed = JSON.parse(outcome.text);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
parsed = null;
|
|
318
|
+
}
|
|
319
|
+
if (parsed !== null) {
|
|
320
|
+
const empty = opts.emptyCheck(parsed);
|
|
321
|
+
if (empty) {
|
|
322
|
+
emitFailure(empty);
|
|
323
|
+
process.exit(empty.exitCode);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
process.exit(0);
|
|
327
|
+
}
|
|
328
|
+
process.stdout.write(outcome.text + '\n');
|
|
329
|
+
process.exit(0);
|
|
330
|
+
}
|
|
331
|
+
// ---------- 子命令实现 ----------
|
|
332
|
+
async function runSearch(config, positional, flags) {
|
|
333
|
+
const toolArgs = {
|
|
334
|
+
query: positional[0],
|
|
335
|
+
format: flags.get('json') === true ? 'json' : 'markdown'
|
|
336
|
+
};
|
|
337
|
+
const limit = intFlag(flags, 'limit', 1, 50);
|
|
338
|
+
if (limit !== null)
|
|
339
|
+
toolArgs.limit = limit;
|
|
340
|
+
const engines = commaList(flags, 'engines');
|
|
341
|
+
if (engines)
|
|
342
|
+
toolArgs.engines = engines;
|
|
343
|
+
const zone = enumFlag(flags, 'zone', ['auto', 'cn', 'intl']);
|
|
344
|
+
if (zone)
|
|
345
|
+
toolArgs.zone = zone;
|
|
346
|
+
const searchMode = enumFlag(flags, 'search-mode', ['request', 'auto', 'playwright']);
|
|
347
|
+
if (searchMode)
|
|
348
|
+
toolArgs.searchMode = searchMode;
|
|
349
|
+
const freshness = enumFlag(flags, 'freshness', ['day', 'week', 'month', 'year']);
|
|
350
|
+
if (freshness)
|
|
351
|
+
toolArgs.freshness = freshness;
|
|
352
|
+
const includeDomains = commaList(flags, 'include-domains');
|
|
353
|
+
if (includeDomains)
|
|
354
|
+
toolArgs.includeDomains = includeDomains;
|
|
355
|
+
const excludeDomains = commaList(flags, 'exclude-domains');
|
|
356
|
+
if (excludeDomains)
|
|
357
|
+
toolArgs.excludeDomains = excludeDomains;
|
|
358
|
+
return callAndExit({
|
|
359
|
+
config,
|
|
360
|
+
toolName: 'eosearch_search',
|
|
361
|
+
toolArgs,
|
|
362
|
+
timeoutMs: timeoutMsOf(flags),
|
|
363
|
+
json: flags.get('json') === true,
|
|
364
|
+
emptyCheck: (parsed) => {
|
|
365
|
+
const obj = parsed;
|
|
366
|
+
if (Array.isArray(obj.results) && obj.results.length === 0) {
|
|
367
|
+
return {
|
|
368
|
+
kind: 'failure',
|
|
369
|
+
exitCode: 1,
|
|
370
|
+
problem: '搜索无结果',
|
|
371
|
+
cause: '服务端多引擎融合后返回 0 条结果',
|
|
372
|
+
next: '放宽 --freshness、更换更具体的关键词,或用 --engines 指定引擎重试'
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
async function runBatch(config, positional, flags) {
|
|
380
|
+
const toolArgs = {
|
|
381
|
+
queries: positional,
|
|
382
|
+
format: flags.get('json') === true ? 'json' : 'markdown'
|
|
383
|
+
};
|
|
384
|
+
const zone = enumFlag(flags, 'zone', ['auto', 'cn', 'intl']);
|
|
385
|
+
if (zone)
|
|
386
|
+
toolArgs.zone = zone;
|
|
387
|
+
return callAndExit({
|
|
388
|
+
config,
|
|
389
|
+
toolName: 'eosearch_batch_search',
|
|
390
|
+
toolArgs,
|
|
391
|
+
timeoutMs: timeoutMsOf(flags),
|
|
392
|
+
json: flags.get('json') === true,
|
|
393
|
+
emptyCheck: (parsed) => {
|
|
394
|
+
const obj = parsed;
|
|
395
|
+
if (typeof obj.failed === 'number' && obj.failed > 0 && obj.failed < (obj.totalQueries ?? 0)) {
|
|
396
|
+
console.error(`提示:${obj.failed} 条查询失败,详见输出中 failures 字段`);
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
if (typeof obj.totalQueries === 'number' && typeof obj.failed === 'number' && obj.totalQueries > 0 && obj.failed === obj.totalQueries) {
|
|
400
|
+
return {
|
|
401
|
+
kind: 'failure',
|
|
402
|
+
exitCode: 1,
|
|
403
|
+
problem: `全部 ${obj.totalQueries} 条查询都失败`,
|
|
404
|
+
cause: '服务端执行批量搜索时每条查询都未返回结果',
|
|
405
|
+
next: '检查查询词是否过偏,减少条数或更换关键词后重试'
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async function runFetch(config, positional, flags) {
|
|
413
|
+
const url = positional[0];
|
|
414
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
415
|
+
emitUsage(`fetch 需要 http(s) 网址,收到 "${url}"`);
|
|
416
|
+
}
|
|
417
|
+
const toolArgs = { url };
|
|
418
|
+
const maxChars = intFlag(flags, 'max-chars', 500, 200000);
|
|
419
|
+
if (maxChars !== null)
|
|
420
|
+
toolArgs.maxChars = maxChars;
|
|
421
|
+
if (flags.get('no-readability') === true)
|
|
422
|
+
toolArgs.readability = false;
|
|
423
|
+
const renderMode = enumFlag(flags, 'render-mode', ['request', 'auto', 'browser']);
|
|
424
|
+
if (renderMode)
|
|
425
|
+
toolArgs.renderMode = renderMode;
|
|
426
|
+
const json = flags.get('json') === true;
|
|
427
|
+
if (!json) {
|
|
428
|
+
return callAndExit({
|
|
429
|
+
config,
|
|
430
|
+
toolName: 'eosearch_fetch',
|
|
431
|
+
toolArgs,
|
|
432
|
+
timeoutMs: timeoutMsOf(flags),
|
|
433
|
+
json: false,
|
|
434
|
+
emptyCheck: () => null
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
// fetch 的 --json:工具返回纯文本(首行元信息 + 正文),包装为结构化输出
|
|
438
|
+
const outcome = await callTool({
|
|
439
|
+
serverUrl: config.serverUrl,
|
|
440
|
+
apiKey: config.apiKey,
|
|
441
|
+
clientVersion: readVersion(),
|
|
442
|
+
toolName: 'eosearch_fetch',
|
|
443
|
+
toolArgs,
|
|
444
|
+
timeoutMs: timeoutMsOf(flags)
|
|
445
|
+
});
|
|
446
|
+
if (outcome.kind === 'failure') {
|
|
447
|
+
emitFailure(outcome);
|
|
448
|
+
process.exit(outcome.exitCode);
|
|
449
|
+
}
|
|
450
|
+
const firstLine = (outcome.text.split('\n', 1)[0] ?? '').trim();
|
|
451
|
+
const match = /^URL: (\S+)$/.exec(firstLine);
|
|
452
|
+
const payload = JSON.stringify({ url: match ? match[1] : url, content: outcome.text }, null, 2);
|
|
453
|
+
process.stdout.write(payload + '\n');
|
|
454
|
+
process.exit(0);
|
|
455
|
+
}
|
|
456
|
+
async function runDeep(config, positional, flags) {
|
|
457
|
+
const toolArgs = {
|
|
458
|
+
query: positional[0],
|
|
459
|
+
format: flags.get('json') === true ? 'json' : 'text'
|
|
460
|
+
};
|
|
461
|
+
const topN = intFlag(flags, 'top-n', 1, 10);
|
|
462
|
+
if (topN !== null)
|
|
463
|
+
toolArgs.topN = topN;
|
|
464
|
+
const tokenBudget = intFlag(flags, 'token-budget', 2000, 120000);
|
|
465
|
+
if (tokenBudget !== null)
|
|
466
|
+
toolArgs.tokenBudget = tokenBudget;
|
|
467
|
+
const engines = commaList(flags, 'engines');
|
|
468
|
+
if (engines)
|
|
469
|
+
toolArgs.engines = engines;
|
|
470
|
+
const zone = enumFlag(flags, 'zone', ['auto', 'cn', 'intl']);
|
|
471
|
+
if (zone)
|
|
472
|
+
toolArgs.zone = zone;
|
|
473
|
+
const freshness = enumFlag(flags, 'freshness', ['day', 'week', 'month', 'year']);
|
|
474
|
+
if (freshness)
|
|
475
|
+
toolArgs.freshness = freshness;
|
|
476
|
+
const includeDomains = commaList(flags, 'include-domains');
|
|
477
|
+
if (includeDomains)
|
|
478
|
+
toolArgs.includeDomains = includeDomains;
|
|
479
|
+
const excludeDomains = commaList(flags, 'exclude-domains');
|
|
480
|
+
if (excludeDomains)
|
|
481
|
+
toolArgs.excludeDomains = excludeDomains;
|
|
482
|
+
return callAndExit({
|
|
483
|
+
config,
|
|
484
|
+
toolName: 'eosearch_deep_search',
|
|
485
|
+
toolArgs,
|
|
486
|
+
timeoutMs: timeoutMsOf(flags),
|
|
487
|
+
json: flags.get('json') === true,
|
|
488
|
+
emptyCheck: () => null
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
async function runWhoami(flags) {
|
|
492
|
+
const config = resolveConfig({ serverUrl: rawFlag(flags, 'server-url') });
|
|
493
|
+
if (!config.apiKey) {
|
|
494
|
+
const f = missingKeyFailure();
|
|
495
|
+
emitFailure(f);
|
|
496
|
+
process.exit(f.exitCode);
|
|
497
|
+
}
|
|
498
|
+
const result = await whoami({
|
|
499
|
+
serverUrl: config.serverUrl,
|
|
500
|
+
apiKey: config.apiKey,
|
|
501
|
+
clientVersion: readVersion()
|
|
502
|
+
});
|
|
503
|
+
if ('kind' in result && result.kind === 'failure') {
|
|
504
|
+
const f = result;
|
|
505
|
+
emitFailure(f);
|
|
506
|
+
process.exit(f.exitCode);
|
|
507
|
+
}
|
|
508
|
+
const info = result;
|
|
509
|
+
if (flags.get('json') === true) {
|
|
510
|
+
process.stdout.write(JSON.stringify({
|
|
511
|
+
ok: true,
|
|
512
|
+
server: { name: info.serverName ?? null, version: info.serverVersion ?? null },
|
|
513
|
+
serverUrl: config.serverUrl,
|
|
514
|
+
apiKeySource: config.apiKeySource,
|
|
515
|
+
instructions: info.instructions ?? null
|
|
516
|
+
}, null, 2) + '\n');
|
|
517
|
+
process.exit(0);
|
|
518
|
+
}
|
|
519
|
+
console.log(`密钥有效,服务连接正常(密钥来自${config.apiKeySource === 'environment' ? '环境变量' : '配置文件 ~/.eosearchrc'})`);
|
|
520
|
+
console.log(`服务端:${info.serverName ?? 'eosearch'} ${info.serverVersion ?? ''}`.trimEnd());
|
|
521
|
+
console.log(`地址:${config.serverUrl}`);
|
|
522
|
+
process.exit(0);
|
|
523
|
+
}
|
|
524
|
+
// ---------- 主入口 ----------
|
|
525
|
+
async function main() {
|
|
526
|
+
const argv = process.argv.slice(2);
|
|
527
|
+
if (argv.includes('--version') || argv.includes('-V')) {
|
|
528
|
+
console.log(readVersion());
|
|
529
|
+
process.exit(0);
|
|
530
|
+
}
|
|
531
|
+
const parsed = parseArgs(argv);
|
|
532
|
+
if (parsed.kind === 'usage')
|
|
533
|
+
emitUsage(parsed.message);
|
|
534
|
+
const { command, positional, flags } = parsed;
|
|
535
|
+
if (command === 'help' || flags.get('help') === true || flags.get('h') === true) {
|
|
536
|
+
if (command === 'help' && positional.length > 0) {
|
|
537
|
+
const sub = subcommandHelp(positional[0]);
|
|
538
|
+
if (sub === null)
|
|
539
|
+
emitUsage(`未知子命令 "${positional[0]}"。eosearch help 查看全部子命令`);
|
|
540
|
+
console.log(sub);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
console.log(MAIN_HELP);
|
|
544
|
+
}
|
|
545
|
+
process.exit(0);
|
|
546
|
+
}
|
|
547
|
+
const config = resolveConfig({ serverUrl: rawFlag(flags, 'server-url') });
|
|
548
|
+
const needsKey = command !== 'help';
|
|
549
|
+
if (needsKey && !config.apiKey) {
|
|
550
|
+
const f = missingKeyFailure();
|
|
551
|
+
emitFailure(f);
|
|
552
|
+
process.exit(f.exitCode);
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
switch (command) {
|
|
556
|
+
case 'search':
|
|
557
|
+
return await runSearch(config, positional, flags);
|
|
558
|
+
case 'batch':
|
|
559
|
+
return await runBatch(config, positional, flags);
|
|
560
|
+
case 'fetch':
|
|
561
|
+
return await runFetch(config, positional, flags);
|
|
562
|
+
case 'deep':
|
|
563
|
+
return await runDeep(config, positional, flags);
|
|
564
|
+
case 'whoami':
|
|
565
|
+
return await runWhoami(flags);
|
|
566
|
+
default:
|
|
567
|
+
emitUsage(`未知子命令 "${command}"。eosearch help 查看全部子命令`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
if (error instanceof UsageError)
|
|
572
|
+
emitUsage(error.message);
|
|
573
|
+
console.error('错误:CLI 内部异常');
|
|
574
|
+
console.error(`原因:${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
575
|
+
console.error('下一步:重试一次;持续出现请带上上面的堆栈反馈');
|
|
576
|
+
process.exit(5);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
main().catch((error) => {
|
|
580
|
+
console.error('错误:CLI 未捕获异常');
|
|
581
|
+
console.error(`原因:${error instanceof Error ? error.message : String(error)}`);
|
|
582
|
+
console.error('下一步:重试一次;持续出现请带上该信息反馈');
|
|
583
|
+
process.exit(5);
|
|
584
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// cli/client.ts — 通过 MCP Streamable HTTP 调用 eosearch 服务端
|
|
2
|
+
// 每次进程:initialize 握手(不计费)→ tools/call(计费 1 次)→ 关闭会话
|
|
3
|
+
// 薄客户端边界:只封装 HTTP 调用,不含任何搜索/抓取逻辑
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
6
|
+
function failure(exitCode, problem, cause, next) {
|
|
7
|
+
return { kind: 'failure', exitCode, problem, cause, next };
|
|
8
|
+
}
|
|
9
|
+
function serverMessage(message) {
|
|
10
|
+
const match = /"message"\s*:\s*"([^"]+)"/.exec(message);
|
|
11
|
+
return match ? match[1] : null;
|
|
12
|
+
}
|
|
13
|
+
function probe(error) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
const httpMatch = /HTTP (\d{3})/.exec(message);
|
|
16
|
+
if (httpMatch)
|
|
17
|
+
return { status: parseInt(httpMatch[1], 10), message };
|
|
18
|
+
// 服务端 deny() 约定:-32001 = 401(鉴权拒绝),-32002 = 429(配额限流);
|
|
19
|
+
// SDK 抛错时可能只带 JSON body(McpError code 或文本),按约定码还原 HTTP 状态
|
|
20
|
+
const rpcCode = error?.code;
|
|
21
|
+
if (rpcCode === -32001)
|
|
22
|
+
return { status: 401, message };
|
|
23
|
+
if (rpcCode === -32002)
|
|
24
|
+
return { status: 429, message };
|
|
25
|
+
if (/"code"\s*:\s*-32001/.test(message))
|
|
26
|
+
return { status: 401, message };
|
|
27
|
+
if (/"code"\s*:\s*-32002/.test(message))
|
|
28
|
+
return { status: 429, message };
|
|
29
|
+
return { status: null, message };
|
|
30
|
+
}
|
|
31
|
+
function isNetworkError(message) {
|
|
32
|
+
return /ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|fetch failed|socket hang up/i.test(message);
|
|
33
|
+
}
|
|
34
|
+
function isTimeoutError(message) {
|
|
35
|
+
return /timed?\s*out|timeout|aborted/i.test(message);
|
|
36
|
+
}
|
|
37
|
+
function connectFailure(error, serverUrl) {
|
|
38
|
+
const { status, message } = probe(error);
|
|
39
|
+
if (status === 401) {
|
|
40
|
+
const detail = serverMessage(message) ? `(服务端:${serverMessage(message)})` : '';
|
|
41
|
+
return failure(3, '服务端拒绝了这把密钥(HTTP 401)', `向 ${serverUrl} 握手时返回 401,密钥无效或请求未携带${detail}`, '到 eogee.com 个人中心核对密钥(注意无多余空格),修正 EOSEARCH_API_KEY 或 ~/.eosearchrc 后重试 eosearch whoami');
|
|
42
|
+
}
|
|
43
|
+
if (status === 429) {
|
|
44
|
+
const detail = serverMessage(message) ?? 'rate limit';
|
|
45
|
+
return failure(4, '触发限流(HTTP 429)', detail, '等待数十秒后重试;每分钟限速与每日配额档位见 eogee.com 个人中心');
|
|
46
|
+
}
|
|
47
|
+
if (status === 404) {
|
|
48
|
+
return failure(5, '服务地址不存在(HTTP 404)', `${serverUrl} 未找到`, '确认服务地址是否为 https://eogee.com/search/v1/mcp,或用 --server-url 指定正确地址');
|
|
49
|
+
}
|
|
50
|
+
if (isNetworkError(message)) {
|
|
51
|
+
return failure(5, '无法连接服务端', message, '检查本机网络与代理设置;若怀疑服务端故障,稍后用 eosearch whoami 探测恢复情况');
|
|
52
|
+
}
|
|
53
|
+
if (status !== null && status >= 500) {
|
|
54
|
+
return failure(5, `服务端错误(HTTP ${status})`, message, '服务端临时故障,稍后重试');
|
|
55
|
+
}
|
|
56
|
+
return failure(5, '与服务端通信失败', message, '检查 --server-url 与网络环境后重试');
|
|
57
|
+
}
|
|
58
|
+
async function withClient(serverUrl, apiKey, clientVersion, fn) {
|
|
59
|
+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
|
60
|
+
requestInit: { headers: { 'X-API-Key': apiKey } }
|
|
61
|
+
});
|
|
62
|
+
const client = new Client({ name: 'eosearch-cli', version: clientVersion }, { capabilities: {} });
|
|
63
|
+
try {
|
|
64
|
+
await client.connect(transport);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
try {
|
|
68
|
+
await client.close();
|
|
69
|
+
}
|
|
70
|
+
catch { /* 关闭失败不影响错误分类 */ }
|
|
71
|
+
return { fail: connectFailure(error, serverUrl) };
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return { result: await fn(client) };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const { message } = probe(error);
|
|
78
|
+
if (isTimeoutError(message)) {
|
|
79
|
+
return {
|
|
80
|
+
fail: failure(5, '调用超时', message, '加大 --timeout(秒,默认 120)后重试;deep 与 batch 任务较慢时建议 --timeout 300')
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { fail: connectFailure(error, serverUrl) };
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
try {
|
|
87
|
+
await client.close();
|
|
88
|
+
}
|
|
89
|
+
catch { /* 忽略关闭错误 */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function callTool(args) {
|
|
93
|
+
const outcome = await withClient(args.serverUrl, args.apiKey, args.clientVersion, async (client) => client.callTool({ name: args.toolName, arguments: args.toolArgs }, undefined, { timeout: args.timeoutMs }));
|
|
94
|
+
if ('fail' in outcome)
|
|
95
|
+
return outcome.fail;
|
|
96
|
+
const result = outcome.result;
|
|
97
|
+
const text = (result.content ?? [])
|
|
98
|
+
.filter((c) => c.type === 'text' && typeof c.text === 'string')
|
|
99
|
+
.map((c) => c.text)
|
|
100
|
+
.join('\n');
|
|
101
|
+
if (result.isError) {
|
|
102
|
+
return failure(5, '服务端执行工具失败', text || '工具返回 isError 但无错误详情', '按错误信息调整参数重试;若持续失败,用 eosearch whoami 确认服务状态');
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'success', text };
|
|
105
|
+
}
|
|
106
|
+
export async function whoami(args) {
|
|
107
|
+
const outcome = await withClient(args.serverUrl, args.apiKey, args.clientVersion, async (client) => ({
|
|
108
|
+
serverName: client.getServerVersion()?.name,
|
|
109
|
+
serverVersion: client.getServerVersion()?.version,
|
|
110
|
+
instructions: client.getInstructions()
|
|
111
|
+
}));
|
|
112
|
+
if ('fail' in outcome)
|
|
113
|
+
return outcome.fail;
|
|
114
|
+
return outcome.result;
|
|
115
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// cli/config.ts — 密钥与服务地址解析
|
|
2
|
+
// 优先级(高到低):环境变量 > 用户级配置文件 ~/.eosearchrc
|
|
3
|
+
// 安全约定:密钥不提供命令行参数入口,避免进入 Shell 历史与进程列表
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
export const DEFAULT_SERVER_URL = 'https://eogee.com/search/v1/mcp';
|
|
8
|
+
export function configFilePath() {
|
|
9
|
+
return join(homedir(), '.eosearchrc');
|
|
10
|
+
}
|
|
11
|
+
function readRc() {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(readFileSync(configFilePath(), 'utf8'));
|
|
14
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function asTrimmedString(value) {
|
|
21
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
22
|
+
}
|
|
23
|
+
export function resolveConfig(overrides) {
|
|
24
|
+
const rc = readRc();
|
|
25
|
+
const envKey = asTrimmedString(process.env.EOSEARCH_API_KEY);
|
|
26
|
+
const rcKey = asTrimmedString(rc.apiKey);
|
|
27
|
+
const envUrl = asTrimmedString(process.env.EOSEARCH_SERVER_URL);
|
|
28
|
+
const rcUrl = asTrimmedString(rc.serverUrl);
|
|
29
|
+
const apiKey = envKey ?? rcKey;
|
|
30
|
+
const apiKeySource = envKey ? 'environment' : rcKey ? 'config-file' : 'none';
|
|
31
|
+
const serverUrl = overrides.serverUrl ?? envUrl ?? rcUrl ?? DEFAULT_SERVER_URL;
|
|
32
|
+
return { apiKey, apiKeySource, serverUrl };
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eogee-tech/eosearch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "EoSearch command-line client for AI agents — web search, page fetch and deep research via eogee.com (thin client; engines run server-side)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"eosearch": "build-cli/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"build-cli",
|
|
11
|
+
"NOTICE",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"build:cli": "tsc -p tsconfig.cli.json",
|
|
17
|
+
"prepublishOnly": "npm run build:cli",
|
|
18
|
+
"watch": "tsc --watch",
|
|
19
|
+
"start": "node build/index.js",
|
|
20
|
+
"inspector": "npx @modelcontextprotocol/inspector build/index.js",
|
|
21
|
+
"smoke": "node build/eosearch/smoke.js",
|
|
22
|
+
"test": "npm run build && node --test \"test/unit/*.test.mjs\""
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"cli",
|
|
27
|
+
"search",
|
|
28
|
+
"web-search",
|
|
29
|
+
"agent",
|
|
30
|
+
"rag"
|
|
31
|
+
],
|
|
32
|
+
"license": "Apache-2.0",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.11.2",
|
|
35
|
+
"@mozilla/readability": "^0.6.0",
|
|
36
|
+
"@types/cheerio": "^0.22.35",
|
|
37
|
+
"axios": "^1.16.0",
|
|
38
|
+
"cheerio": "^1.0.0",
|
|
39
|
+
"cors": "^2.8.5",
|
|
40
|
+
"express": "^5.2.1",
|
|
41
|
+
"https-proxy-agent": "^7.0.6",
|
|
42
|
+
"ipaddr.js": "^2.3.0",
|
|
43
|
+
"jsdom": "^26.1.0",
|
|
44
|
+
"koffi": "^2.16.0",
|
|
45
|
+
"request-filtering-agent": "^3.2.0",
|
|
46
|
+
"zod": "^3.25.76"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/cors": "^2.8.19",
|
|
50
|
+
"@types/express": "^5.0.6",
|
|
51
|
+
"@types/jsdom": "^21.1.7",
|
|
52
|
+
"@types/node": "^20.17.10",
|
|
53
|
+
"typescript": "^5.3.3"
|
|
54
|
+
},
|
|
55
|
+
"main": "index.js",
|
|
56
|
+
"directories": {
|
|
57
|
+
"test": "test"
|
|
58
|
+
},
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git@gitee.com:eogee/eosearch.git"
|
|
62
|
+
},
|
|
63
|
+
"author": ""
|
|
64
|
+
}
|