@godchen520/dsh-web-search-bing 0.0.0-stage → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +128 -2
- package/cordis.patch.yml +27 -0
- package/lib/index.js +568 -0
- package/lib/types/index.d.ts +69 -0
- package/package.json +59 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 dsh-web-search-bing contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,129 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-web-search-bing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
5
|
+
|
|
6
|
+
> 面向 DeepSeek Harness (DSH) 的**免费 Bing 搜索 provider**。无需 API Key,不消耗模型额度。
|
|
7
|
+
|
|
8
|
+
## 特点
|
|
9
|
+
|
|
10
|
+
- **完全免费**:走 Bing 公共 HTML 搜索页面,无需 API Key、无需注册
|
|
11
|
+
- **国内可达**:默认 `cn.bing.com`,中国大陆可直连(DuckDuckGo 在此不可达)
|
|
12
|
+
- **零配额消耗**:不消耗 DeepSeek 或任何 LLM 的搜索配额
|
|
13
|
+
- **可配置**:搜索端点、中英文结果、单页结果数,且设置**热生效**(改完不用重载插件)
|
|
14
|
+
- **适配 DSH 0.2.x**:使用新版 schema 驱动的设置模型(导出 `Config`,由 host 自动生成设置表单)
|
|
15
|
+
|
|
16
|
+
## 与内置 DeepSeek 搜索的区别
|
|
17
|
+
|
|
18
|
+
| | 内置 `dsh-web-search-deepseek`(`deepseek-official`) | 本插件(`bing-free`) |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| 原理 | 调 DeepSeek Messages API + 原生 `web_search` 工具,**每次搜索是一次 LLM 请求** | GET `cn.bing.com/search`,解析 HTML |
|
|
21
|
+
| 凭证 | 需 API Key | **零凭证** |
|
|
22
|
+
| 配额 | **消耗 DeepSeek 模型额度** | **不消耗任何额度** |
|
|
23
|
+
| 设置项 | key / endpoint / model / maxTokens / maxUses | endpoint / maxResults / ensearch |
|
|
24
|
+
|
|
25
|
+
内置的是「用模型额度换高质量检索」,本插件是「用网页抓取换零成本」。两者可共存,通过 `web.searchProvider` 选择。
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 1. 从 npm 安装(推荐)
|
|
31
|
+
cd $DSH_HOME/profiles/web
|
|
32
|
+
pnpm add @godchen520/dsh-web-search-bing
|
|
33
|
+
|
|
34
|
+
# 也可以从 GitHub 安装(跟随最新提交):
|
|
35
|
+
# pnpm add github:godchen520/dsh-web-search-bing
|
|
36
|
+
|
|
37
|
+
# 2. package.json 的 dependencies 会自动出现:
|
|
38
|
+
# "@godchen520/dsh-web-search-bing": "^1.2.2"
|
|
39
|
+
|
|
40
|
+
# 3. package.json 的 dsh.profile.bundles 里加上 "@godchen520/dsh-web-search-bing"
|
|
41
|
+
|
|
42
|
+
# 4. 重启
|
|
43
|
+
dsh web
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> **注意**:只加进 `dependencies` 而**不加进 `dsh.profile.bundles`** 的话,插件的
|
|
47
|
+
> `cordis.patch.yml` 不会被应用——插件是「装了但没启用」,搜索仍走内置 provider。
|
|
48
|
+
|
|
49
|
+
## 配置
|
|
50
|
+
|
|
51
|
+
设置项由 host 从插件导出的 `Config` schema 自动生成,出现在 **设置 → Plugins** 页面
|
|
52
|
+
(注意:专属的「网页搜索」设置页是内置 DeepSeek provider 的,本插件不在那里)。
|
|
53
|
+
|
|
54
|
+
四个字段都是 `.volatile()`,保存后**下一次搜索即时生效**:
|
|
55
|
+
|
|
56
|
+
| 参数 | 默认值 | 说明 |
|
|
57
|
+
|------|--------|------|
|
|
58
|
+
| `endpoint` | `https://cn.bing.com/search` | 搜索端点(可换镜像/代理) |
|
|
59
|
+
| `maxResults` | `10` | 单页解析结果上限(seam 还会按 `searchMaxResults` 再截断) |
|
|
60
|
+
| `ensearch` | `0` | `0`=中文结果,`1`=英文 |
|
|
61
|
+
| `preferRss` | `true` | 优先走 RSS;设 `false` 强制走 HTML 抓取 |
|
|
62
|
+
|
|
63
|
+
## 工作原理
|
|
64
|
+
|
|
65
|
+
**双通道:RSS 优先,HTML 兜底。**
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
web_search 工具 → ctx.web.search() → bing-free provider
|
|
69
|
+
│
|
|
70
|
+
├─ 1) GET cn.bing.com/search?q=...&ensearch=0&format=rss ← 默认主路径
|
|
71
|
+
│ 结构化 XML:<title> / <link> / <description> / <pubDate>
|
|
72
|
+
│ 有 <item> → 直接映射(含 publishedAt),完成
|
|
73
|
+
│ 无 <item> / 非 feed / 请求失败 ↓
|
|
74
|
+
│
|
|
75
|
+
└─ 2) GET cn.bing.com/search?q=...&ensearch=0 ← 兜底
|
|
76
|
+
解析 <li class="b_algo"> → {url, title, snippet}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| | RSS 通道(主) | HTML 通道(兜底) |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| 体积 | ~4 KB | ~100 KB |
|
|
82
|
+
| 摘要 | 完整段落 | 被 lineclamp 截断 + 需清 UI 尾巴 |
|
|
83
|
+
| `publishedAt` | ✅ 有(`<pubDate>`) | ❌ 无 |
|
|
84
|
+
| 抗变化 | 标准 RSS 字段 | 依赖 `b_algo` / `b_lineclamp*` class 名 |
|
|
85
|
+
| 相关性 | — **两者完全相同**(同一批结果、同一顺序) | — |
|
|
86
|
+
|
|
87
|
+
其他行为:
|
|
88
|
+
|
|
89
|
+
- 带浏览器 UA(Bing 会拒绝裸 Node/undici agent)。
|
|
90
|
+
- 命中 captcha/风控页时抛结构化 `WEB_PROVIDER_ERROR`,**不会**伪装成「无结果」。
|
|
91
|
+
- 支持 `AbortSignal` 取消,且**取消不会被兜底吞掉**。
|
|
92
|
+
- `&count=` 对两种通道都无效,Bing 每页固定约 10 条。
|
|
93
|
+
|
|
94
|
+
## 兼容性
|
|
95
|
+
|
|
96
|
+
| DSH 版本 | 状态 |
|
|
97
|
+
|---|---|
|
|
98
|
+
| **0.2.x(如 0.2.0-rc.2)** | ✅ 支持(当前版本) |
|
|
99
|
+
| 0.1.7 ~ 0.1.x | ✅ 支持(`readField` 同时兼容 `.get()` 访问器与纯值) |
|
|
100
|
+
| ≤ 0.1.6 | ❌ 不支持(那时用 `installSettingsSection` 手写注册,API 已移除) |
|
|
101
|
+
|
|
102
|
+
本插件只 `import` 两个包:`@deepseek-ai/schemastery`、`@deepseek-ai/dsh-web`。
|
|
103
|
+
不再依赖 `@deepseek-ai/dsh-settings`(新版已移除 `settingsNamespace` / `installSettingsSection`)。
|
|
104
|
+
|
|
105
|
+
## 测试
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npm install # 首次:安装官方包(测试需要 import 真实的 lib/index.js)
|
|
109
|
+
npm test # 离线测试:40 + 41 项,确定性,可进 CI
|
|
110
|
+
npm run test:live # 额外真实请求 cn.bing.com(+7 项,需要网络)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| 文件 | 内容 |
|
|
114
|
+
|------|------|
|
|
115
|
+
| `tests/parse.smoke.test.cjs` | **40 项纯解析**:HTML 解析 / 命名实体 / UI 尾巴清理 / RSS feed / 本地化 `pubDate`。自包含,不依赖官方包 |
|
|
116
|
+
| `tests/e2e.provider.test.mjs` | **41 项端到端**:直接 `import` 真实的 `BingFreeSearchProvider`(不复制逻辑),用桩 `fetch` 驱动两种传输 |
|
|
117
|
+
|
|
118
|
+
端到端覆盖的关键行为:
|
|
119
|
+
|
|
120
|
+
- **RSS 优先**:默认只发一次请求且带 `format=rss`;`publishedAt` 正确映射(含中文 `pubDate`)
|
|
121
|
+
- **HTML 兜底**:`preferRss: false` 时强制走 HTML,且不请求 RSS
|
|
122
|
+
- **回退触发**:RSS 返回非 feed、返回 HTTP 500 —— 都要回退到 HTML
|
|
123
|
+
- **取消不被吞**:`AbortSignal` 取消后抛 `WEB_ABORTED`,不继续回退
|
|
124
|
+
- **空 query / captcha 页**:抛结构化 `WEB_PROVIDER_ERROR`,不伪装成「无结果」
|
|
125
|
+
- **`maxResults` 截断**
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# dsh-web-search-bing profile patch layer.
|
|
2
|
+
#
|
|
3
|
+
# Two effects, both in the HOST composition (`ctx.web` is host-plane):
|
|
4
|
+
#
|
|
5
|
+
# 1. Insert one loader row for this plugin package, which registers the
|
|
6
|
+
# `bing-free` search provider via `ctx.web.registerSearchProvider(...)`.
|
|
7
|
+
# It runs after dsh-web-app's own patch layer because this bundle is listed
|
|
8
|
+
# after `@deepseek-ai/dsh-web-app` in the profile's `dsh.profile.bundles`.
|
|
9
|
+
#
|
|
10
|
+
# 2. Override the base `web` row's provider selection from `deepseek-official`
|
|
11
|
+
# (which spends a DeepSeek model turn per search) to `bing-free` (which is
|
|
12
|
+
# a free public HTML query via cn.bing.com, accessible from mainland China).
|
|
13
|
+
# The base row restates ONLY `searchProvider`; replacing its whole config with
|
|
14
|
+
# the same single key keeps the override intent-explicit.
|
|
15
|
+
#
|
|
16
|
+
# Because `bing-free` is explicitly configured, the seam never hits
|
|
17
|
+
# WEB_PROVIDER_AMBIGUOUS even though `deepseek-official` may also be registered
|
|
18
|
+
# and available. To fall back to DeepSeek search later, change
|
|
19
|
+
# `searchProvider` below back to `deepseek-official` (and optionally remove this
|
|
20
|
+
# insert row).
|
|
21
|
+
- id: web
|
|
22
|
+
config:
|
|
23
|
+
searchProvider: bing-free
|
|
24
|
+
|
|
25
|
+
- insert:
|
|
26
|
+
- id: web-search-bing
|
|
27
|
+
name: '@godchen520/dsh-web-search-bing'
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #region provider
|
|
6
|
+
* Bing-backed free web search provider for the `ctx.web` seam.
|
|
7
|
+
*
|
|
8
|
+
* It needs NO API key and makes NO model call, so the `web_search` tool works
|
|
9
|
+
* here without spending DeepSeek (or any LLM) search quota. The default
|
|
10
|
+
* endpoint is `cn.bing.com`, reachable from mainland China where DuckDuckGo is
|
|
11
|
+
* not.
|
|
12
|
+
*
|
|
13
|
+
* ## Two transports, RSS first
|
|
14
|
+
*
|
|
15
|
+
* Bing serves the same ranking two ways, and this provider prefers the cleaner
|
|
16
|
+
* one:
|
|
17
|
+
*
|
|
18
|
+
* 1. **RSS** (`&format=rss`) — ~4 KB of structured XML with `<title>`, `<link>`,
|
|
19
|
+
* `<description>` and `<pubDate>` per item. No HTML to scrape, so the
|
|
20
|
+
* entity-decoding and UI-chrome pitfalls that HTML parsing needs cannot
|
|
21
|
+
* arise, and `<pubDate>` maps straight onto `publishedAt`.
|
|
22
|
+
* 2. **HTML** (`<li class="b_algo">`) — the fallback. Used when RSS yields no
|
|
23
|
+
* items or fails outright, so a change to Bing's feed can never take the
|
|
24
|
+
* provider down.
|
|
25
|
+
*
|
|
26
|
+
* RSS does not change relevance: both transports return the same ranking. The
|
|
27
|
+
* gain is robustness, payload size, and excerpt quality.
|
|
28
|
+
*
|
|
29
|
+
* ## Configuration model (DSH >= 0.1.7 / 0.2.x)
|
|
30
|
+
*
|
|
31
|
+
* The old `installSettingsSection(ctx, ns, Config, …)` registration is gone:
|
|
32
|
+
* the host generates a plugin's settings form from the exported {@link Config}
|
|
33
|
+
* schema, and runtime values arrive through the `config` argument. A field
|
|
34
|
+
* marked `.volatile()` is a live accessor read with `.get()`; an unmarked field
|
|
35
|
+
* is a plain value. {@link readField} accepts both, so the provider also works
|
|
36
|
+
* with a plain object config (tests, older compositions).
|
|
37
|
+
*
|
|
38
|
+
* @module dsh-web-search-bing/provider
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Stable id this provider registers under. */
|
|
42
|
+
const PROVIDER_ID = "bing-free";
|
|
43
|
+
|
|
44
|
+
/** Default endpoint: Bing China, Chinese results. `q=` is appended at call time. */
|
|
45
|
+
const DEFAULT_ENDPOINT = "https://cn.bing.com/search";
|
|
46
|
+
|
|
47
|
+
/** Default `ensearch` param: `0` = Chinese results, `1` = English. */
|
|
48
|
+
const DEFAULT_ENSEARCH = 0;
|
|
49
|
+
|
|
50
|
+
/** Default upper bound on results parsed per page. */
|
|
51
|
+
const DEFAULT_MAX_RESULTS = 10;
|
|
52
|
+
|
|
53
|
+
/** Whether the RSS transport is tried before the HTML one. */
|
|
54
|
+
const DEFAULT_PREFER_RSS = true;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Browser-ish User-Agent. Bing may reject bare Node/undici agents; a
|
|
58
|
+
* conservative desktop UA keeps it serving normal result pages.
|
|
59
|
+
*/
|
|
60
|
+
const USER_AGENT =
|
|
61
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Named HTML/XML entities Bing emits in titles and snippets. ` `/` `
|
|
65
|
+
* are the ones that actually showed up in live HTML result pages; the rest
|
|
66
|
+
* cover common punctuation and symbols so text reads cleanly. `apos` is the
|
|
67
|
+
* XML-only one the RSS feed can carry.
|
|
68
|
+
*/
|
|
69
|
+
const NAMED_ENTITIES = {
|
|
70
|
+
quot: '"',
|
|
71
|
+
amp: "&",
|
|
72
|
+
apos: "'",
|
|
73
|
+
lt: "<",
|
|
74
|
+
gt: ">",
|
|
75
|
+
nbsp: " ",
|
|
76
|
+
ensp: " ",
|
|
77
|
+
emsp: " ",
|
|
78
|
+
thinsp: " ",
|
|
79
|
+
middot: "·",
|
|
80
|
+
hellip: "…",
|
|
81
|
+
mdash: "—",
|
|
82
|
+
ndash: "–",
|
|
83
|
+
lsquo: "\u2018",
|
|
84
|
+
rsquo: "\u2019",
|
|
85
|
+
ldquo: "\u201c",
|
|
86
|
+
rdquo: "\u201d",
|
|
87
|
+
bull: "•",
|
|
88
|
+
times: "×",
|
|
89
|
+
copy: "©",
|
|
90
|
+
reg: "®",
|
|
91
|
+
trade: "™",
|
|
92
|
+
deg: "°",
|
|
93
|
+
laquo: "«",
|
|
94
|
+
raquo: "»",
|
|
95
|
+
sect: "§",
|
|
96
|
+
para: "¶",
|
|
97
|
+
dagger: "†",
|
|
98
|
+
permil: "‰",
|
|
99
|
+
euro: "€",
|
|
100
|
+
pound: "£",
|
|
101
|
+
yen: "¥",
|
|
102
|
+
cent: "¢",
|
|
103
|
+
larr: "←",
|
|
104
|
+
rarr: "→",
|
|
105
|
+
harr: "↔"
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Decode entities and strip markup. Handles numeric (`&#xNN;` / `&#NNN;`) and
|
|
110
|
+
* named (` `, `·`, `'`) entities, then removes any residual
|
|
111
|
+
* tags and collapses whitespace. `&` resolves in the same single pass as
|
|
112
|
+
* every other named entity, so a double-encoded `&lt;` yields a literal
|
|
113
|
+
* `<` rather than `<`.
|
|
114
|
+
*
|
|
115
|
+
* @param value - the raw string to decode.
|
|
116
|
+
* @returns the decoded string.
|
|
117
|
+
*/
|
|
118
|
+
function decodeHtml(value) {
|
|
119
|
+
if (value == null) return "";
|
|
120
|
+
return value
|
|
121
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
122
|
+
.replace(/&#(\d+);/g, (_m, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
|
123
|
+
.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name) => NAMED_ENTITIES[name] ?? match)
|
|
124
|
+
.replace(/<[^>]*>/g, "")
|
|
125
|
+
.replace(/\s+/g, " ")
|
|
126
|
+
.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Strip Bing's HTML snippet UI chrome. Every HTML result snippet ends with
|
|
131
|
+
* Bing's "read more" affordance (a `阅读更多` / `Read more` link inside the same
|
|
132
|
+
* `<p>`), usually preceded by a line-clamp ellipsis. Neither is page content, so
|
|
133
|
+
* the model gets cleaner excerpts without them. Only a TRAILING label and
|
|
134
|
+
* ellipsis are removed — a period ending a real sentence stays.
|
|
135
|
+
*
|
|
136
|
+
* The RSS transport never needs this: its `<description>` is already the clean
|
|
137
|
+
* excerpt.
|
|
138
|
+
*
|
|
139
|
+
* @param text - the decoded snippet text.
|
|
140
|
+
* @returns the snippet without the trailing UI affordance.
|
|
141
|
+
*/
|
|
142
|
+
function cleanSnippet(text) {
|
|
143
|
+
return text
|
|
144
|
+
.replace(/\s*(?:阅读更多|Read\s?more)\s*$/iu, "")
|
|
145
|
+
.replace(/(?:\s*(?:…|\.{3}))+\s*$/u, "")
|
|
146
|
+
.replace(/\s+/g, " ")
|
|
147
|
+
.trim();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse one `<li class="b_algo">` block from Bing's HTML into a source.
|
|
152
|
+
*
|
|
153
|
+
* @param blockHtml - the inner HTML of one result `<li>`.
|
|
154
|
+
* @returns `{ url, title, snippet }` or `null` when the block has no usable link.
|
|
155
|
+
*/
|
|
156
|
+
function parseBingResultBlock(blockHtml) {
|
|
157
|
+
// Title + URL: <h2><a href="URL" ...>Title</a></h2>
|
|
158
|
+
const titleMatch = /<h2[^>]*>\s*<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i.exec(blockHtml);
|
|
159
|
+
if (titleMatch == null) return null;
|
|
160
|
+
const href = decodeHtml(titleMatch[1].trim());
|
|
161
|
+
const title = decodeHtml(titleMatch[2]);
|
|
162
|
+
// Resolve URL — Bing sometimes uses relative or redirect URLs.
|
|
163
|
+
let url;
|
|
164
|
+
try {
|
|
165
|
+
if (href.startsWith("//")) url = `https:${href}`;
|
|
166
|
+
else if (href.startsWith("/")) url = `https://cn.bing.com${href}`;
|
|
167
|
+
else url = new URL(href).href;
|
|
168
|
+
} catch {
|
|
169
|
+
url = href;
|
|
170
|
+
}
|
|
171
|
+
if (!(url.startsWith("https://") || url.startsWith("http://"))) return null;
|
|
172
|
+
// Snippet: try multiple selectors Bing uses across versions.
|
|
173
|
+
let snippet = "";
|
|
174
|
+
const snippetPatterns = [
|
|
175
|
+
/<p[^>]*class\s*=\s*["'][^"']*\bb_lineclamp[^"']*["'][^>]*>([\s\S]*?)<\/p>/i,
|
|
176
|
+
/<div[^>]*class\s*=\s*["'][^"']*\bcaption\b[^"']*["'][^>]*>\s*<p[^>]*>([\s\S]*?)<\/p>/i,
|
|
177
|
+
/<p[^>]*class\s*=\s*["'][^"']*\bb_algoSlug\b[^"']*["'][^>]*>([\s\S]*?)<\/p>/i,
|
|
178
|
+
/<div[^>]*>\s*<p[^>]*>([\s\S]*?)<\/p>\s*<\/div>/i
|
|
179
|
+
];
|
|
180
|
+
for (const pat of snippetPatterns) {
|
|
181
|
+
const m = pat.exec(blockHtml);
|
|
182
|
+
if (m == null) continue;
|
|
183
|
+
const candidate = cleanSnippet(decodeHtml(m[1]));
|
|
184
|
+
if (candidate.length > snippet.length) snippet = candidate;
|
|
185
|
+
}
|
|
186
|
+
return { url, title, snippet };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Parse Bing's HTML results page into source objects.
|
|
191
|
+
*
|
|
192
|
+
* Bing renders organic results as `<li class="b_algo">` inside an `<ol>`.
|
|
193
|
+
* Parsing is tolerant: it extracts every `<li class="b_algo">` block, parses
|
|
194
|
+
* title + URL + snippet from each, and dedupes by URL.
|
|
195
|
+
*
|
|
196
|
+
* @param html - the raw result page body.
|
|
197
|
+
* @param maxResults - parsed source cap; unset means parse the whole page.
|
|
198
|
+
* @returns the normalized sources.
|
|
199
|
+
*/
|
|
200
|
+
function parseBingHtml(html, maxResults) {
|
|
201
|
+
const sources = [];
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
// Split on <li class="b_algo"> boundaries — each block is one result.
|
|
204
|
+
const blocks = html.split(/<li\s+class\s*=\s*["']?\s*b_algo\b/i);
|
|
205
|
+
// Skip the first element (everything before the first result).
|
|
206
|
+
for (let i = 1; i < blocks.length && (maxResults == null || sources.length < maxResults); i++) {
|
|
207
|
+
// Trim to the next </li> to avoid parsing across blocks.
|
|
208
|
+
const block = blocks[i].split(/<\/li>/i)[0] ?? blocks[i];
|
|
209
|
+
const parsed = parseBingResultBlock(block);
|
|
210
|
+
if (parsed == null || seen.has(parsed.url)) continue;
|
|
211
|
+
seen.add(parsed.url);
|
|
212
|
+
sources.push({
|
|
213
|
+
url: parsed.url,
|
|
214
|
+
...(parsed.title.length > 0 ? { title: parsed.title } : {}),
|
|
215
|
+
...(parsed.snippet.length > 0 ? { snippet: parsed.snippet } : {})
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return sources;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** English month prefixes, for the RFC-822 dates Bing's feed emits. */
|
|
222
|
+
const MONTH_PREFIXES = {
|
|
223
|
+
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
|
224
|
+
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolve an RFC-822 month token to its number. Bing localizes the feed, so the
|
|
229
|
+
* token is either an English abbreviation (`Sep`) or `<n>月`.
|
|
230
|
+
*
|
|
231
|
+
* @param token - the month token.
|
|
232
|
+
* @returns the 1-based month, or `undefined` when unrecognized.
|
|
233
|
+
*/
|
|
234
|
+
function monthNumber(token) {
|
|
235
|
+
const numeric = /^(\d{1,2})\s*月?$/.exec(token);
|
|
236
|
+
if (numeric != null) {
|
|
237
|
+
const n = Number(numeric[1]);
|
|
238
|
+
return n >= 1 && n <= 12 ? n : undefined;
|
|
239
|
+
}
|
|
240
|
+
return MONTH_PREFIXES[token.slice(0, 3).toLowerCase()];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Zero-pad a date/time component to two digits. */
|
|
244
|
+
function pad2(value) {
|
|
245
|
+
return String(value).padStart(2, "0");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Parse a feed `<pubDate>` into an ISO-8601 timestamp the seam can carry as
|
|
250
|
+
* `publishedAt`.
|
|
251
|
+
*
|
|
252
|
+
* Bing localizes the weekday and month (`周一, 28 9月 2026 21:19:00 GMT`), which
|
|
253
|
+
* `new Date()` cannot read — verified as `Invalid Date`. The English form
|
|
254
|
+
* (`Wed, 30 Sep 2026 13:24:00 GMT`) parses natively, so that is tried first and
|
|
255
|
+
* the localized form is assembled by hand.
|
|
256
|
+
*
|
|
257
|
+
* @param raw - the raw `<pubDate>` text.
|
|
258
|
+
* @returns the ISO-8601 timestamp, or `undefined` when unparseable.
|
|
259
|
+
*/
|
|
260
|
+
function parseRssDate(raw) {
|
|
261
|
+
if (raw == null) return undefined;
|
|
262
|
+
const text = raw.trim();
|
|
263
|
+
if (text.length === 0) return undefined;
|
|
264
|
+
const direct = new Date(text);
|
|
265
|
+
if (!Number.isNaN(direct.getTime())) return direct.toISOString();
|
|
266
|
+
const m = /(\d{1,2})\s+(\S+?)\s+(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(GMT|UTC|Z|([+-])(\d{2}):?(\d{2}))?/i.exec(text);
|
|
267
|
+
if (m == null) return undefined;
|
|
268
|
+
const month = monthNumber(m[2]);
|
|
269
|
+
if (month === undefined) return undefined;
|
|
270
|
+
const year = Number(m[3]);
|
|
271
|
+
const day = Number(m[1]);
|
|
272
|
+
const hour = Number(m[4]);
|
|
273
|
+
const minute = Number(m[5]);
|
|
274
|
+
const second = m[6] === undefined ? 0 : Number(m[6]);
|
|
275
|
+
const zone = m[8] === undefined ? "Z" : `${m[8]}${m[9]}:${m[10]}`;
|
|
276
|
+
const parsed = new Date(
|
|
277
|
+
`${year}-${pad2(month)}-${pad2(day)}T${pad2(hour)}:${pad2(minute)}:${pad2(second)}${zone}`
|
|
278
|
+
);
|
|
279
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Read one XML element's text from an `<item>` block, unwrapping CDATA and
|
|
284
|
+
* decoding entities.
|
|
285
|
+
*
|
|
286
|
+
* @param block - the `<item>` inner XML.
|
|
287
|
+
* @param tag - the element name.
|
|
288
|
+
* @returns the decoded text, or `""` when absent.
|
|
289
|
+
*/
|
|
290
|
+
function xmlField(block, tag) {
|
|
291
|
+
const m = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, "i").exec(block);
|
|
292
|
+
if (m == null) return "";
|
|
293
|
+
return decodeHtml(m[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1"));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Parse Bing's RSS feed into source objects.
|
|
298
|
+
*
|
|
299
|
+
* Every `<item>` carries `<title>`, `<link>`, `<description>` and `<pubDate>`,
|
|
300
|
+
* so the mapping is direct and needs no HTML heuristics. Items without a usable
|
|
301
|
+
* link are skipped and duplicates are dropped.
|
|
302
|
+
*
|
|
303
|
+
* @param xml - the raw feed body.
|
|
304
|
+
* @param maxResults - parsed source cap; unset means parse the whole feed.
|
|
305
|
+
* @returns the normalized sources (empty when the body is not a feed).
|
|
306
|
+
*/
|
|
307
|
+
function parseRssFeed(xml, maxResults) {
|
|
308
|
+
const sources = [];
|
|
309
|
+
const seen = new Set();
|
|
310
|
+
const itemRe = /<item(?:\s[^>]*)?>([\s\S]*?)<\/item>/gi;
|
|
311
|
+
let match;
|
|
312
|
+
while ((match = itemRe.exec(xml)) !== null && (maxResults == null || sources.length < maxResults)) {
|
|
313
|
+
const block = match[1];
|
|
314
|
+
const url = xmlField(block, "link");
|
|
315
|
+
if (url.length === 0 || !/^https?:\/\//i.test(url) || seen.has(url)) continue;
|
|
316
|
+
seen.add(url);
|
|
317
|
+
const title = xmlField(block, "title");
|
|
318
|
+
const description = xmlField(block, "description");
|
|
319
|
+
const publishedAt = parseRssDate(xmlField(block, "pubDate"));
|
|
320
|
+
sources.push({
|
|
321
|
+
url,
|
|
322
|
+
...(title.length > 0 ? { title } : {}),
|
|
323
|
+
...(description.length > 0 ? { snippet: description } : {}),
|
|
324
|
+
...(publishedAt !== undefined ? { publishedAt } : {})
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return sources;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* True when an otherwise-2xx Bing page is actually a captcha/bot gate rather
|
|
332
|
+
* than a result page.
|
|
333
|
+
*
|
|
334
|
+
* @param html - the response body.
|
|
335
|
+
* @returns whether the page looks like a block/captcha gate.
|
|
336
|
+
*/
|
|
337
|
+
function looksBlocked(html) {
|
|
338
|
+
const lower = html.slice(0, 20000).toLowerCase();
|
|
339
|
+
return /(captcha|verify you are human|are you a robot|unusual traffic|access denied|challenge-platform)/.test(lower);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Build a fetch error that the seam surfaces as a structured provider failure.
|
|
344
|
+
*
|
|
345
|
+
* @param message - the human-readable failure.
|
|
346
|
+
* @param cause - the underlying error, when any.
|
|
347
|
+
* @returns a {@link WebError} with code `WEB_PROVIDER_ERROR`.
|
|
348
|
+
*/
|
|
349
|
+
function providerError(message, cause) {
|
|
350
|
+
return new WebError(message, "WEB_PROVIDER_ERROR", cause === void 0 ? {} : { cause });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Read one config field across both config shapes DSH hands a plugin: a
|
|
355
|
+
* `.volatile()` field is a live accessor (`{ get() }`), a plain field is the
|
|
356
|
+
* value itself. Missing/nullish values fall back to the schema default.
|
|
357
|
+
*
|
|
358
|
+
* @param field - the accessor, the plain value, or `undefined`.
|
|
359
|
+
* @param fallback - the value to use when the field is absent or nullish.
|
|
360
|
+
* @returns the resolved value.
|
|
361
|
+
*/
|
|
362
|
+
function readField(field, fallback) {
|
|
363
|
+
if (field === void 0 || field === null) return fallback;
|
|
364
|
+
if (typeof field.get === "function") {
|
|
365
|
+
const live = field.get();
|
|
366
|
+
return live === void 0 || live === null ? fallback : live;
|
|
367
|
+
}
|
|
368
|
+
return field;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Rethrow an abort as the provider's stable cancellation error; ignore anything else. */
|
|
372
|
+
function rethrowIfAborted(error, signal) {
|
|
373
|
+
if (signal?.aborted === true) throw new WebError("Bing free search aborted", "WEB_ABORTED", { cause: signal.reason });
|
|
374
|
+
if (error instanceof WebError && error.code === "WEB_ABORTED") throw error;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The Bing-backed free search provider. `available()` is trivially `true`: the
|
|
379
|
+
* provider needs no key, credential, or environment setup.
|
|
380
|
+
*
|
|
381
|
+
* Each search tries the RSS transport first (when `preferRss`), falling back to
|
|
382
|
+
* the HTML transport when the feed yields no items or fails. Cancellation is
|
|
383
|
+
* never swallowed by the fallback.
|
|
384
|
+
*/
|
|
385
|
+
class BingFreeSearchProvider {
|
|
386
|
+
id = PROVIDER_ID;
|
|
387
|
+
|
|
388
|
+
constructor(endpoint, maxResults, ensearch, preferRss) {
|
|
389
|
+
this.endpoint = endpoint;
|
|
390
|
+
this.maxResults = maxResults;
|
|
391
|
+
this.ensearch = ensearch;
|
|
392
|
+
this.preferRss = preferRss;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
available() {
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Run one search: RSS first, HTML as the fallback.
|
|
401
|
+
*
|
|
402
|
+
* @param request - the seam's search request.
|
|
403
|
+
* @param signal - optional cancellation signal.
|
|
404
|
+
* @returns the normalized result.
|
|
405
|
+
*/
|
|
406
|
+
async search(request, signal) {
|
|
407
|
+
const query = (request.query ?? "").trim();
|
|
408
|
+
if (query.length === 0) throw providerError("Bing free search requires a non-empty query");
|
|
409
|
+
if (this.preferRss !== false) {
|
|
410
|
+
let rssFailure;
|
|
411
|
+
try {
|
|
412
|
+
const sources = await this.searchViaRss(query, signal);
|
|
413
|
+
// A non-empty feed is authoritative; an empty one falls through to HTML
|
|
414
|
+
// so a feed-format change cannot silently produce "no results".
|
|
415
|
+
if (sources.length > 0) return { sources, truncated: false };
|
|
416
|
+
} catch (error) {
|
|
417
|
+
rethrowIfAborted(error, signal);
|
|
418
|
+
rssFailure = error;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const sources = await this.searchViaHtml(query, signal);
|
|
422
|
+
return { sources, truncated: false };
|
|
423
|
+
} catch (error) {
|
|
424
|
+
rethrowIfAborted(error, signal);
|
|
425
|
+
// Report the RSS failure when the fallback failed too and RSS said
|
|
426
|
+
// something specific; otherwise the fallback's own error is clearer.
|
|
427
|
+
throw rssFailure ?? error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { sources: await this.searchViaHtml(query, signal), truncated: false };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** GET one URL as text, mapping network/HTTP failures to `WEB_PROVIDER_ERROR`. */
|
|
434
|
+
async getText(url, signal) {
|
|
435
|
+
let response;
|
|
436
|
+
try {
|
|
437
|
+
response = await fetch(url, {
|
|
438
|
+
method: "GET",
|
|
439
|
+
redirect: "follow",
|
|
440
|
+
headers: {
|
|
441
|
+
"user-agent": USER_AGENT,
|
|
442
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
443
|
+
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8"
|
|
444
|
+
},
|
|
445
|
+
...(signal !== void 0 ? { signal } : {})
|
|
446
|
+
});
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (signal?.aborted === true) throw new WebError("Bing free search aborted", "WEB_ABORTED", { cause: signal.reason });
|
|
449
|
+
throw providerError(`Bing search request failed: ${String(error)}`, error);
|
|
450
|
+
}
|
|
451
|
+
if (!response.ok) throw providerError(`Bing search returned HTTP ${response.status}`);
|
|
452
|
+
try {
|
|
453
|
+
return await response.text();
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (signal?.aborted === true) throw new WebError("Bing free search aborted", "WEB_ABORTED", { cause: signal.reason });
|
|
456
|
+
throw providerError(`Bing returned an unreadable response body: ${String(error)}`, error);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Build the request URL for one transport. */
|
|
461
|
+
buildUrl(query, rss) {
|
|
462
|
+
const params = new URLSearchParams({ q: query, ensearch: String(this.ensearch) });
|
|
463
|
+
if (rss) params.set("format", "rss");
|
|
464
|
+
return `${this.endpoint}?${params.toString()}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Search through Bing's RSS feed — the clean, structured transport.
|
|
469
|
+
*
|
|
470
|
+
* @param query - the search query.
|
|
471
|
+
* @param signal - optional cancellation signal.
|
|
472
|
+
* @returns the normalized sources (empty when the feed carries no items).
|
|
473
|
+
*/
|
|
474
|
+
async searchViaRss(query, signal) {
|
|
475
|
+
const xml = await this.getText(this.buildUrl(query, true), signal);
|
|
476
|
+
return parseRssFeed(xml, this.maxResults);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Search by scraping Bing's HTML result page — the fallback transport.
|
|
481
|
+
*
|
|
482
|
+
* @param query - the search query.
|
|
483
|
+
* @param signal - optional cancellation signal.
|
|
484
|
+
* @returns the normalized sources.
|
|
485
|
+
*/
|
|
486
|
+
async searchViaHtml(query, signal) {
|
|
487
|
+
const html = await this.getText(this.buildUrl(query, false), signal);
|
|
488
|
+
if (looksBlocked(html)) {
|
|
489
|
+
throw providerError(
|
|
490
|
+
"Bing answered with a captcha/gate instead of results. Retry later, or switch the searchProvider to another backend."
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return parseBingHtml(html, this.maxResults);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* #endregion
|
|
499
|
+
* #region index
|
|
500
|
+
* Register a free Bing-backed provider in `ctx.web`. It calls Bing's public
|
|
501
|
+
* search endpoints and needs no API key, so `web_search` runs without any
|
|
502
|
+
* DeepSeek/model search quota. The provider id is `bing-free`; the bundle's
|
|
503
|
+
* `cordis.patch.yml` sets `web.searchProvider` to it.
|
|
504
|
+
*
|
|
505
|
+
* The config fields are `.volatile()`, so the host-generated settings form edits
|
|
506
|
+
* them live: a saved change reaches the next search without reloading this
|
|
507
|
+
* plugin.
|
|
508
|
+
* @module dsh-web-search-bing
|
|
509
|
+
*/
|
|
510
|
+
|
|
511
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
512
|
+
const name = "web-search-bing";
|
|
513
|
+
|
|
514
|
+
/** The web seam this provider registers into. */
|
|
515
|
+
const inject = ["web"];
|
|
516
|
+
|
|
517
|
+
const Config = z.object({
|
|
518
|
+
endpoint: z.string().default(DEFAULT_ENDPOINT).volatile(),
|
|
519
|
+
maxResults: z.number().step(1).min(1).default(DEFAULT_MAX_RESULTS).volatile(),
|
|
520
|
+
ensearch: z.number().step(1).min(0).max(1).default(DEFAULT_ENSEARCH).volatile(),
|
|
521
|
+
preferRss: z.boolean().default(DEFAULT_PREFER_RSS).volatile()
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Settings namespace this provider's page is filed under. In DSH >= 0.1.7 the
|
|
526
|
+
* namespace is the loader entry id, which this bundle names `web-search-bing`;
|
|
527
|
+
* the constant is exported for documentation/UI parity with the shipped
|
|
528
|
+
* provider rather than for registration.
|
|
529
|
+
*/
|
|
530
|
+
const WEB_SEARCH_BING_SETTINGS_NAMESPACE = "web-search-bing";
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Register the free Bing search provider with `ctx.web`.
|
|
534
|
+
*
|
|
535
|
+
* `config` is the schema-validated configuration for this plugin entry. The
|
|
536
|
+
* thunk re-reads it per search, so a live settings edit applies to the next
|
|
537
|
+
* `web_search` call while the registered provider object stays the same.
|
|
538
|
+
*/
|
|
539
|
+
function apply(ctx, config) {
|
|
540
|
+
const resolve = () => ({
|
|
541
|
+
endpoint: readField(config?.endpoint, DEFAULT_ENDPOINT),
|
|
542
|
+
maxResults: readField(config?.maxResults, DEFAULT_MAX_RESULTS),
|
|
543
|
+
ensearch: readField(config?.ensearch, DEFAULT_ENSEARCH),
|
|
544
|
+
preferRss: readField(config?.preferRss, DEFAULT_PREFER_RSS)
|
|
545
|
+
});
|
|
546
|
+
ctx.web.registerSearchProvider({
|
|
547
|
+
id: PROVIDER_ID,
|
|
548
|
+
available: () => true,
|
|
549
|
+
search: (request, signal) => {
|
|
550
|
+
const { endpoint, maxResults, ensearch, preferRss } = resolve();
|
|
551
|
+
return new BingFreeSearchProvider(endpoint, maxResults, ensearch, preferRss).search(request, signal);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export {
|
|
557
|
+
Config,
|
|
558
|
+
DEFAULT_ENDPOINT,
|
|
559
|
+
DEFAULT_ENSEARCH,
|
|
560
|
+
DEFAULT_MAX_RESULTS,
|
|
561
|
+
DEFAULT_PREFER_RSS,
|
|
562
|
+
PROVIDER_ID,
|
|
563
|
+
BingFreeSearchProvider,
|
|
564
|
+
WEB_SEARCH_BING_SETTINGS_NAMESPACE,
|
|
565
|
+
apply,
|
|
566
|
+
inject,
|
|
567
|
+
name
|
|
568
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the Bing-backed free web search provider bundle.
|
|
3
|
+
*
|
|
4
|
+
* Targets DSH 0.2.x: the plugin exports a schemastery `Config` schema and the
|
|
5
|
+
* host generates its settings form from it. Every field is `.volatile()`, so at
|
|
6
|
+
* runtime each arrives as a live accessor (`{ get(): T }`); an unmarked or
|
|
7
|
+
* plain object config supplies the bare value instead. `apply` accepts both.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-web-search-bing
|
|
10
|
+
*/
|
|
11
|
+
import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web';
|
|
12
|
+
|
|
13
|
+
/** Stable provider id registered with `ctx.web` (`bing-free`). */
|
|
14
|
+
export declare const PROVIDER_ID: string;
|
|
15
|
+
/** Default public HTML search endpoint (`https://cn.bing.com/search`). */
|
|
16
|
+
export declare const DEFAULT_ENDPOINT: string;
|
|
17
|
+
/** Default `ensearch` param (`0` = Chinese results, `1` = English). */
|
|
18
|
+
export declare const DEFAULT_ENSEARCH: number;
|
|
19
|
+
/** Default upper bound on results parsed per page. */
|
|
20
|
+
export declare const DEFAULT_MAX_RESULTS: number;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Settings namespace this provider's form is filed under. In DSH >= 0.1.7 the
|
|
24
|
+
* namespace is the loader entry id (`web-search-bing`), so this constant is
|
|
25
|
+
* documentation/UI parity rather than a registration handle.
|
|
26
|
+
*/
|
|
27
|
+
export declare const WEB_SEARCH_BING_SETTINGS_NAMESPACE: string;
|
|
28
|
+
|
|
29
|
+
/** One config value as the host hands it to `apply`: a live accessor or a plain value. */
|
|
30
|
+
export type ConfigField<T> = T | { get(): T } | undefined;
|
|
31
|
+
|
|
32
|
+
/** The validated plugin configuration DSH passes to {@link apply}. */
|
|
33
|
+
export interface BingSearchConfig {
|
|
34
|
+
/** Public search endpoint (mirror/proxy allowed). */
|
|
35
|
+
readonly endpoint?: ConfigField<string>;
|
|
36
|
+
/** Upper bound on parsed results per page. */
|
|
37
|
+
readonly maxResults?: ConfigField<number>;
|
|
38
|
+
/** `ensearch` query param: `0` = Chinese results, `1` = English. */
|
|
39
|
+
readonly ensearch?: ConfigField<number>;
|
|
40
|
+
/** Try the RSS transport before the HTML one (default `true`). */
|
|
41
|
+
readonly preferRss?: ConfigField<boolean>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The Bing-backed free provider, satisfying {@link WebSearchProvider}.
|
|
46
|
+
* `available()` is always `true` (no key, credential, or environment needed).
|
|
47
|
+
*
|
|
48
|
+
* Each search prefers Bing's RSS feed (structured XML, carries `publishedAt`)
|
|
49
|
+
* and falls back to scraping the HTML result page when the feed is empty or
|
|
50
|
+
* fails. Cancellation is never swallowed by the fallback.
|
|
51
|
+
*/
|
|
52
|
+
export declare class BingFreeSearchProvider implements WebSearchProvider {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
constructor(endpoint?: string, maxResults?: number, ensearch?: number, preferRss?: boolean);
|
|
55
|
+
available(): boolean;
|
|
56
|
+
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Default for {@link BingSearchConfig.preferRss}. */
|
|
60
|
+
export declare const DEFAULT_PREFER_RSS: boolean;
|
|
61
|
+
|
|
62
|
+
/** Cordis plugin name (loader diagnostics). */
|
|
63
|
+
export declare const name: string;
|
|
64
|
+
/** Services injected by this plugin. */
|
|
65
|
+
export declare const inject: string[];
|
|
66
|
+
/** Schemastery schema for this plugin's settings form (all fields `.volatile()`). */
|
|
67
|
+
export declare const Config: unknown;
|
|
68
|
+
/** Cordis plugin apply entry: registers the provider with `ctx.web`. */
|
|
69
|
+
export declare function apply(ctx: any, config?: BingSearchConfig): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godchen520/dsh-web-search-bing",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"description": "Free Bing-backed web search provider for the DeepSeek Harness web capability seam (ctx.web). Prefers Bing's RSS feed (clean structured XML) with HTML scraping as the fallback, against cn.bing.com (reachable from mainland China), so web_search works with no API key and without consuming DeepSeek (or any LLM) search quota. Targets DSH 0.2.x (settings forms generated from the exported Config schema; no dsh-settings import).",
|
|
4
|
+
"version": "1.2.2",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/godchen520/dsh-web-search-bing.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/godchen520/dsh-web-search-bing#readme",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "lib/index.js",
|
|
13
|
+
"types": "lib/types/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./lib/types/index.d.ts",
|
|
17
|
+
"default": "./lib/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"lib/index.js",
|
|
23
|
+
"lib/types",
|
|
24
|
+
"cordis.patch.yml"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node tests/parse.smoke.test.cjs && node tests/e2e.provider.test.mjs",
|
|
34
|
+
"test:live": "node tests/parse.smoke.test.cjs && node tests/e2e.provider.test.mjs --live"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@deepseek-ai/dsh-web": "^0.2.0-rc.2",
|
|
43
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
44
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@deepseek-ai/dsh-web": "^0.2.0-rc.2",
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"deepseek-harness",
|
|
52
|
+
"dsh",
|
|
53
|
+
"dsh-plugin",
|
|
54
|
+
"plugin",
|
|
55
|
+
"search",
|
|
56
|
+
"web-search",
|
|
57
|
+
"bing",
|
|
58
|
+
"cn-bing",
|
|
59
|
+
"free"
|
|
60
|
+
]
|
|
61
|
+
}
|