@x1a0f3n9/dsh-web-search-free 0.1.5-rc.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
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.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/web/web-search-free/README.md
5
+ README.md: 28948c1bbec3e37ce9db20d6b0691429e1685224
6
+ README.zh.md: 4d0052fcf6658601e867dfd9c64858cc371a2f64
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ ---
2
+ description: "The keyless Bing/DuckDuckGo search provider for ctx.web: how deployments mount HTML search that works without a vendor API key."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @x1a0f3n9/dsh-web-search-free
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ With `dsh-web-search-free`, the harness searches the web through Bing HTML first and DuckDuckGo HTML if Bing yields no sources. Choose it when a deployment needs `web_search` for every chat model and does not hold a Perplexity, Exa, or DeepSeek search key. The backend follows HTML redirects and sends a Mozilla-compatible product token; it returns no generated answer, so results carry no `content` — only citeable sources. URL-only hits are kept. The model-facing `web_search` tool lives in `dsh-tool-web`.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount the provider in a composition that already loads the web service; it registers as the `free` search provider, so `ctx.web.search()` resolves it automatically when it is the only usable search backend — or pin it with `searchProvider: free`. The shipped base lists it last in `searchProviderOrder: [perplexity, exa, free]`.
29
+
30
+ ### When to choose it
31
+
32
+ Choose this backend when search must work without a vendor API key, including Grok and other pi-ai chat routes. The provider is unavailable — and every search call fails with a structured error — when either HTML origin does not parse, the user agent is empty, or a configured `numResults` is not a positive integer.
33
+
34
+ ### Minimal configuration
35
+
36
+ Load the web service and the provider; bases and the user agent have public defaults, and no environment key is read.
37
+
38
+ ```yaml
39
+ - name: '@x1a0f3n9/dsh-web'
40
+ - name: '@x1a0f3n9/dsh-web-search-free'
41
+ ```
42
+
43
+ | Field | Default | Meaning |
44
+ |---|---|---|
45
+ | `bingBaseURL` | `https://www.bing.com` | Bing origin; `/search` is appended. An unparseable value makes the provider unavailable |
46
+ | `ddgBaseURL` | `https://html.duckduckgo.com` | DuckDuckGo HTML origin; `/html/` is appended. An unparseable value makes the provider unavailable |
47
+ | `userAgent` | a Mozilla-compatible product token | `User-Agent` sent on every request; empty makes the provider unavailable |
48
+ | `numResults` | (unset) | Default result count when a request carries no `maxResults`; must be a positive integer |
49
+
50
+ The generated [configuration catalog](../../../docs/config-catalog.md#x1a0f3n9dsh-web-search-free) is the exhaustive source for every accepted field and its JSDoc.
51
+
52
+ ### What a search returns
53
+
54
+ Bing sources with a recoverable http(s) URL become `WebSearchSource` values (`url`, optional `title`, optional `snippet`). If Bing returns none, DuckDuckGo HTML is queried the same way. A request's `maxResults` wins over the configured `numResults` default and is sent to Bing as `count`; DuckDuckGo has no count control, and the service still enforces the bound. Neither engine returns a generated answer, so the result carries no `content`. If either engine succeeds with zero hits, the result is empty rather than an error. Both engines must fail before the call throws.
55
+
56
+ ### Failures and recovery
57
+
58
+ Provider failures — HTTP errors, network failures, unreadable bodies — surface as `WebError` `WEB_PROVIDER_ERROR` after both engines fail; an aborted request surfaces as `WEB_ABORTED` and does not fall through. HTML engines geo-redirect, so this backend follows redirects instead of rejecting them. Callers route on the code; the model-facing `web_search` tool surfaces failures to the model under its own error wrapper.
59
+
60
+ -----
61
+
62
+ <a id="understand-the-implementation"></a>
63
+ ## Understand the implementation
64
+
65
+ <details>
66
+ <summary>Implementation internals — click to expand</summary>
67
+
68
+ This section explains the design decisions behind the provider; the observable behavior is fully covered in [Use this package](#use-this-package).
69
+
70
+ ### Design philosophy
71
+
72
+ The provider is a thin HTML adapter with two deliberate rules:
73
+
74
+ - **Keyless search is a `ctx.web` backend, not the chat model's API.** Grok and other chat routes have no search endpoint here; this package is how those turns still call `web_search`.
75
+ - **No invented answers.** Bing and DuckDuckGo HTML return citations, so `content` is omitted rather than fabricating provider prose the model might trust.
76
+
77
+ ### Source map
78
+
79
+ | File | Role |
80
+ |---|---|
81
+ | [`src/index.ts`](src/index.ts) | Plugin entry: config schema and provider registration |
82
+ | [`src/provider.ts`](src/provider.ts) | The `FreeSearchProvider`: Bing-then-DuckDuckGo dispatch, abort classification, empty-vs-error policy |
83
+ | [`src/html.ts`](src/html.ts) | SERP HTML parsers and tracking-URL recovery |
84
+ | — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. |
85
+
86
+ ### Request and mapping flow
87
+
88
+ `search()` GETs `{bingBaseURL}/search` with `redirect: 'follow'`. Non-empty Bing sources return immediately. Otherwise it GETs `{ddgBaseURL}/html/`. An abort — a `DOMException` named `AbortError`, already mapped to `WEB_ABORTED` — does not fall through; anything else from one engine is an engine error, and only dual failure becomes `WEB_PROVIDER_ERROR`.
89
+
90
+ </details>
91
+
92
+ -----
93
+
94
+ <a id="further-exploration"></a>
95
+ ## Further Exploration
96
+
97
+ Read these pages when the package-level contract is not enough. They move from the shared vocabulary to the service, the model-facing tools, and the design rationale.
98
+
99
+ - [Web subsystem](../../../docs/subsystems/web.md) — the exhaustive search request/result vocabulary and error codes.
100
+ - [Web package map](../README.md) — the seven-package family and each role.
101
+ - [dsh-web](../web/README.md) — the web service this provider registers into.
102
+ - [dsh-tool-web](../tool-web/README.md) — the model-facing `web_search` tool that renders this provider's sources.
103
+ - [Generated configuration catalog](../../../docs/config-catalog.md#x1a0f3n9dsh-web-search-free) — every accepted config field and its source declaration.
104
+ - [Web capability seam decision](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) — why search and fetch share one provider-selection service.
105
+
106
+ -----
107
+
108
+ <a id="model-experience"></a>
109
+ ## Model Experience
110
+
111
+ Indirectly, through `dsh-tool-web`, which retains this provider's URLs, titles, and snippets or its exact `Bing search aborted`, `DuckDuckGo search aborted`, `Bing search request failed: <error>`, `DuckDuckGo search request failed: <error>`, `Bing search HTTP <status>`, `DuckDuckGo search HTTP <status>`, `Bing returned an unreadable response body: <error>`, `DuckDuckGo returned an unreadable response body: <error>`, and `Free search failed: Bing: <error>; DuckDuckGo: <error>` failures under the consumer's error wrapper.
112
+
113
+ #### KV Cache effect
114
+
115
+ No direct invalidation; the named consumer owns any request-prefix changes.
116
+
117
+ ## Known Limitations and Deferred Work
118
+
119
+ <a id="known-limitations-and-deferred-work"></a>
120
+
121
+
122
+ These limits define when the provider is a poor fit. They are current package constraints.
123
+
124
+ - **HTML layout is vendor-controlled** — selectors are pinned in tests against fixture markup; a live SERP redesign can yield empty sources until the parsers are updated.
125
+ - **No generated answer and no `publishedAt`** — sources carry URL, title, and snippet only, so the tool has no provider prose and no publication date.
126
+ - **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (such as `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.
127
+
128
+ <a id="dev-note"></a>
129
+ ### Dev Note
130
+
131
+ <details>
132
+ <summary>Working context for maintainers — click to expand</summary>
133
+
134
+ This Dev Note is working context for maintainers: open questions and undecided directions. It is explicitly non-authoritative — shipped behavior, limits, and rationale live in the sections above and the linked Agent Notes.
135
+
136
+ #### Future: additional keyless engines
137
+
138
+ A third HTML engine can join the fallback list if Bing and DuckDuckGo both start returning empty pages in a region. That change belongs in this provider, not in a second `ctx.web` package, so the ordered policy stays one id: `free`.
139
+
140
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,140 @@
1
+ ---
2
+ description: "ctx.web 的无需密钥 Bing/DuckDuckGo 搜索提供方:部署方如何挂载不依赖厂商 API 密钥的 HTML 搜索。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @x1a0f3n9/dsh-web-search-free
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ 有了 `dsh-web-search-free`,harness 会先通过 Bing HTML 搜索 web,Bing 没有来源时再走 DuckDuckGo HTML。当部署需要让每个聊天模型都能 `web_search`、又没有 Perplexity、Exa 或 DeepSeek 搜索密钥时选择它。此后端会跟随 HTML 重定向,并发送 Mozilla 兼容的产品标识;它不返回生成答案,因此结果不携带 `content`——只产出可引用的来源。只含 URL 的命中也会保留。面向模型的 `web_search` 工具位于 `dsh-tool-web`。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 在已加载 web 服务的组合中挂载本提供方;它以 `free` 搜索提供方身份注册,因此当它是唯一可用的搜索后端时,`ctx.web.search()` 会自动解析到它——也可以用 `searchProvider: free` 固定。已交付的 base 把它放在 `searchProviderOrder: [perplexity, exa, free]` 的最后。
29
+
30
+ ### 何时选择
31
+
32
+ 当搜索必须在没有厂商 API 密钥时也能工作——包括 Grok 和其他 pi-ai 聊天路由——时选择此后端。任一 HTML 源无法解析、User-Agent 为空,或已配置的 `numResults` 不是正整数时,提供方不可用——每次搜索调用都会以结构化错误失败。
33
+
34
+ ### 最小配置
35
+
36
+ 加载 web 服务与本提供方;源站和 User-Agent 都有公开默认值,不读取任何环境密钥。
37
+
38
+ ```yaml
39
+ - name: '@x1a0f3n9/dsh-web'
40
+ - name: '@x1a0f3n9/dsh-web-search-free'
41
+ ```
42
+
43
+ | 字段 | 默认值 | 含义 |
44
+ |---|---|---|
45
+ | `bingBaseURL` | `https://www.bing.com` | Bing 源站;追加 `/search`。无法解析时提供方不可用 |
46
+ | `ddgBaseURL` | `https://html.duckduckgo.com` | DuckDuckGo HTML 源站;追加 `/html/`。无法解析时提供方不可用 |
47
+ | `userAgent` | Mozilla 兼容的产品标识 | 每次请求发送的 `User-Agent`;为空时提供方不可用 |
48
+ | `numResults` | (未设置) | 请求不含 `maxResults` 时使用的默认结果数;必须是正整数 |
49
+
50
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#x1a0f3n9dsh-web-search-free)是每个受支持字段及其 JSDoc 的穷尽式真源。
51
+
52
+ ### 搜索返回什么
53
+
54
+ Bing 中可恢复的 http(s) URL 会变成 `WebSearchSource`(`url`、可选 `title`、可选 `snippet`)。如果 Bing 没有来源,就用同样方式查询 DuckDuckGo HTML。请求的 `maxResults` 优先于已配置的默认 `numResults`,并作为 Bing 的 `count` 发送;DuckDuckGo 没有数量控制,服务仍会强制执行上限。两个引擎都不返回生成答案,因此结果不携带 `content`。任一引擎成功但零命中时,结果为空而不是错误。两个引擎都失败后,调用才会抛错。
55
+
56
+ ### 失败与恢复
57
+
58
+ 提供方失败——HTTP 错误、网络失败、响应体不可读——在两个引擎都失败后以 `WebError` `WEB_PROVIDER_ERROR` 呈现;中止请求以 `WEB_ABORTED` 呈现,并且不会回退到下一个引擎。HTML 搜索引擎会按地区重定向,因此此后端跟随重定向而不是拒绝它们。调用方根据错误码进行分流;面向模型的 `web_search` 工具会在自己的错误包装层内把失败呈现给模型。
59
+
60
+ -----
61
+
62
+ <a id="understand-the-implementation"></a>
63
+ ## 理解实现
64
+
65
+ <details>
66
+ <summary>实现细节——点击展开</summary>
67
+
68
+ 本节解释提供方背后的设计决策;可观察行为已在[使用本包](#use-this-package)中完整说明。
69
+
70
+ ### 设计理念
71
+
72
+ 该提供方是 HTML 之上的薄适配器,遵循两条刻意的规则:
73
+
74
+ - **无需密钥的搜索是 `ctx.web` 后端,不是聊天模型的 API。** Grok 和其他聊天路由在这里没有搜索端点;本包就是这些回合仍然能调用 `web_search` 的方式。
75
+ - **不虚构答案。** Bing 和 DuckDuckGo HTML 返回的是引用,因此省略 `content`,而不是编造模型可能信任的提供方文本。
76
+
77
+ ### 源码地图
78
+
79
+ | 文件 | 职责 |
80
+ |---|---|
81
+ | [`src/index.ts`](src/index.ts) | 插件入口:配置 schema 与提供方注册 |
82
+ | [`src/provider.ts`](src/provider.ts) | `FreeSearchProvider`:先 Bing 再 DuckDuckGo 的分发、中止分类、空结果与错误策略 |
83
+ | [`src/html.ts`](src/html.ts) | SERP HTML 解析与跟踪 URL 还原 |
84
+ | — | 不发布运行时不变量配套入口;除所属 seam 强制执行的约定外,本包没有独立的事件序列或可变数据关系。 |
85
+
86
+ ### 请求与映射流程
87
+
88
+ `search()` 以 `redirect: 'follow'` GET `{bingBaseURL}/search`。Bing 只要有非空来源就立即返回。否则再 GET `{ddgBaseURL}/html/`。中止——名为 `AbortError` 的 `DOMException`,已经映射为 `WEB_ABORTED`——不会回退;单个引擎的其他失败记为引擎错误,只有双失败才变成 `WEB_PROVIDER_ERROR`。
89
+
90
+ </details>
91
+
92
+ -----
93
+
94
+ <a id="further-exploration"></a>
95
+ ## 进一步探索
96
+
97
+ 当包级约定不够用时阅读以下页面。它们从共享词汇逐步进入服务、面向模型的工具与设计依据。
98
+
99
+ - [web 子系统](../../../docs/subsystems/web.zh.md)——穷尽式的搜索请求/结果词汇与错误码。
100
+ - [web 包映射](../README.zh.md)——七包家族与各角色。
101
+ - [dsh-web](../web/README.zh.md)——本提供方注册进入的 web 服务。
102
+ - [dsh-tool-web](../tool-web/README.zh.md)——渲染本提供方来源的面向模型 `web_search` 工具。
103
+ - [生成配置目录](../../../docs/config-catalog.zh.md#x1a0f3n9dsh-web-search-free)——每个受支持配置字段及其源声明。
104
+ - [web 能力 seam 决策](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md)——搜索与抓取为何共用一项提供方选择服务。
105
+
106
+ -----
107
+
108
+ <a id="model-experience"></a>
109
+ ## 模型体验
110
+
111
+ 通过 `dsh-tool-web` 间接影响模型体验。该工具保留本提供方的 URL、标题与 snippet;如果发生失败,则会在消费方的错误包装层内保留原样错误消息 `Bing search aborted`、`DuckDuckGo search aborted`、`Bing search request failed: <error>`、`DuckDuckGo search request failed: <error>`、`Bing search HTTP <status>`、`DuckDuckGo search HTTP <status>`、`Bing returned an unreadable response body: <error>`、`DuckDuckGo returned an unreadable response body: <error>` 和 `Free search failed: Bing: <error>; DuckDuckGo: <error>`。
112
+
113
+ #### KV Cache 影响
114
+
115
+ 不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
116
+
117
+ ## 已知限制与延期工作
118
+
119
+ <a id="known-limitations-and-deferred-work"></a>
120
+
121
+
122
+ 这些限制说明提供方在哪些情况下不合适。它们是当前包约束。
123
+
124
+ - **HTML 布局由厂商控制**——选择器在测试里对着夹具 markup 钉死;线上 SERP 改版可能导致空来源,直到解析器更新。
125
+ - **没有生成答案,也没有 `publishedAt`**——来源只有 URL、标题和 snippet,因此工具没有提供方散文,也没有发布日期。
126
+ - **按错误形状分类中止**——只有名为 `AbortError` 的 `DOMException` 才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)呈现为 `WEB_PROVIDER_ERROR`。
127
+
128
+ <a id="dev-note"></a>
129
+ ### 开发备注
130
+
131
+ <details>
132
+ <summary>维护者的工作上下文——点击展开</summary>
133
+
134
+ 本开发备注是维护者的工作上下文:开放问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文和相关 Agent Note 为准。
135
+
136
+ #### 未来:更多无需密钥的引擎
137
+
138
+ 如果某个地区 Bing 和 DuckDuckGo 都开始返回空页,可以再加入第三个 HTML 引擎。这个改动应留在本提供方内,而不是再做一个 `ctx.web` 包,这样有序列表仍然只有一个 id:`free`。
139
+
140
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,329 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { WebError } from "@x1a0f3n9/dsh-web";
3
+ //#region lib/types/html.js
4
+ /**
5
+ * HTML parsers for the keyless Bing and DuckDuckGo search backends.
6
+ * Layout-sensitive: tests pin the selectors against fixture markup, not live pages.
7
+ *
8
+ * @module @x1a0f3n9/dsh-web-search-free/html
9
+ */
10
+ const NAMED_ENTITIES = {
11
+ amp: "&",
12
+ lt: "<",
13
+ gt: ">",
14
+ quot: "\"",
15
+ apos: "'",
16
+ nbsp: " "
17
+ };
18
+ /** True when `value` is an absolute http(s) URL. */
19
+ function isHttpUrl(value) {
20
+ return /^https?:\/\//i.test(value);
21
+ }
22
+ /**
23
+ * Decode a common subset of HTML entities in attribute values and text.
24
+ *
25
+ * @param text - raw HTML text that may contain named or numeric entities.
26
+ * @returns the decoded text; unknown entities are left unchanged.
27
+ */
28
+ function decodeHtmlEntities(text) {
29
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body) => {
30
+ if (body.startsWith("#")) {
31
+ const hex = body[1] === "x" || body[1] === "X";
32
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
33
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
34
+ return String.fromCodePoint(code);
35
+ }
36
+ return NAMED_ENTITIES[body.toLowerCase()] ?? match;
37
+ });
38
+ }
39
+ /**
40
+ * Strip tags and collapse whitespace from an HTML fragment.
41
+ *
42
+ * @param html - an HTML fragment that may contain tags and entities.
43
+ * @returns plain text, or `undefined` when nothing remains.
44
+ */
45
+ function visibleText(html) {
46
+ const text = decodeHtmlEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
47
+ return text.length > 0 ? text : void 0;
48
+ }
49
+ /**
50
+ * Read one HTML attribute value, decoding entities.
51
+ *
52
+ * @param attrs - the raw attribute string inside a start tag.
53
+ * @param name - the attribute name to read.
54
+ * @returns the decoded value, or `undefined` when the attribute is absent.
55
+ */
56
+ function attributeValue(attrs, name) {
57
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(attrs);
58
+ const raw = match?.[1] ?? match?.[2];
59
+ return raw === void 0 ? void 0 : decodeHtmlEntities(raw);
60
+ }
61
+ /**
62
+ * Recover the destination URL from a Bing result href, including `/ck/a` tracking links.
63
+ *
64
+ * @param href - the `href` attribute from a Bing result anchor.
65
+ * @returns an absolute http(s) URL, or `undefined` when none can be recovered.
66
+ */
67
+ function decodeBingHref(href) {
68
+ const trimmed = decodeHtmlEntities(href.trim());
69
+ let url;
70
+ try {
71
+ url = new URL(trimmed, "https://www.bing.com/");
72
+ } catch {
73
+ return;
74
+ }
75
+ const tracked = url.searchParams.get("u");
76
+ if (tracked !== null && tracked.length > 0) {
77
+ const decoded = decodeBingTrackedParam(tracked);
78
+ if (decoded !== void 0) return decoded;
79
+ }
80
+ if (isHttpUrl(trimmed) && !trimmed.includes("bing.com/ck/") && !isBingHost(url.hostname)) return trimmed;
81
+ if (isHttpUrl(url.href) && !isBingHost(url.hostname)) return url.href;
82
+ if (isHttpUrl(trimmed) && !trimmed.includes("bing.com/ck/")) return trimmed;
83
+ }
84
+ /**
85
+ * Recover the destination URL from a DuckDuckGo HTML result href, including `uddg=`.
86
+ *
87
+ * @param href - the `href` attribute from a DuckDuckGo result anchor.
88
+ * @returns an absolute http(s) URL, or `undefined` when none can be recovered.
89
+ */
90
+ function decodeDdgHref(href) {
91
+ const trimmed = decodeHtmlEntities(href.trim());
92
+ const absolute = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
93
+ let url;
94
+ try {
95
+ url = new URL(absolute, "https://html.duckduckgo.com/");
96
+ } catch {
97
+ return;
98
+ }
99
+ const uddg = url.searchParams.get("uddg");
100
+ if (uddg !== null && isHttpUrl(uddg)) return uddg;
101
+ if (isHttpUrl(absolute) && !url.hostname.endsWith("duckduckgo.com")) return absolute;
102
+ return isHttpUrl(absolute) ? absolute : void 0;
103
+ }
104
+ /**
105
+ * Parse Bing SERP HTML into portable sources.
106
+ *
107
+ * @param html - a Bing `/search` HTML document.
108
+ * @returns sources that have a recoverable http(s) URL; snippet-less hits are kept.
109
+ */
110
+ function parseBingHtml(html) {
111
+ const sources = [];
112
+ const seen = /* @__PURE__ */ new Set();
113
+ for (const match of html.matchAll(/<li\b[^>]*\bb_algo\b[^>]*>([\s\S]*?)<\/li>/gi)) {
114
+ const block = String(match[1]);
115
+ const heading = /<h2\b[^>]*>[\s\S]*?<a\b([^>]*)>([\s\S]*?)<\/a>/i.exec(block);
116
+ const href = heading === null ? void 0 : attributeValue(String(heading[1]), "href");
117
+ const title = heading === null ? void 0 : visibleText(String(heading[2]));
118
+ const cite = visibleText(/<cite\b[^>]*>([\s\S]*?)<\/cite>/i.exec(block)?.[1] ?? "");
119
+ const snippet = visibleText(/<p\b[^>]*>([\s\S]*?)<\/p>/i.exec(block)?.[1] ?? /class="[^"]*\bb_lineclamp\d*\b[^"]*"[^>]*>([\s\S]*?)<\/(?:p|div)>/i.exec(block)?.[1] ?? "");
120
+ pushSource(sources, seen, (href === void 0 ? void 0 : decodeBingHref(href)) ?? urlFromCite(cite), title, snippet);
121
+ }
122
+ return sources;
123
+ }
124
+ /**
125
+ * Parse DuckDuckGo HTML SERP markup into portable sources.
126
+ *
127
+ * @param html - a DuckDuckGo `/html/` document.
128
+ * @returns sources that have a recoverable http(s) URL; snippet-less hits are kept.
129
+ */
130
+ function parseDdgHtml(html) {
131
+ const sources = [];
132
+ const seen = /* @__PURE__ */ new Set();
133
+ for (const match of html.matchAll(/<a\b([^>]*\bresult__a\b[^>]*)>([\s\S]*?)<\/a>/gi)) {
134
+ const attrs = String(match[1]);
135
+ const title = visibleText(String(match[2]));
136
+ const href = attributeValue(attrs, "href");
137
+ const url = href === void 0 ? void 0 : decodeDdgHref(href);
138
+ const start = Number(match.index);
139
+ const after = html.slice(start + match[0].length, start + match[0].length + 1200);
140
+ pushSource(sources, seen, url, title, visibleText(/<a\b[^>]*\bresult__snippet\b[^>]*>([\s\S]*?)<\/a>/i.exec(after)?.[1] ?? /class="[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\//i.exec(after)?.[1] ?? ""));
141
+ }
142
+ return sources;
143
+ }
144
+ /** Bing tracking `u` values are base64 (optionally prefixed with two marker bytes). */
145
+ function decodeBingTrackedParam(value) {
146
+ if (isHttpUrl(value)) return value;
147
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
148
+ for (const candidate of [padded.slice(2), padded]) {
149
+ if (candidate.length === 0) continue;
150
+ const decoded = Buffer.from(candidate, "base64").toString("utf8");
151
+ if (isHttpUrl(decoded)) return decoded;
152
+ }
153
+ }
154
+ function isBingHost(hostname) {
155
+ return hostname === "bing.com" || hostname.endsWith(".bing.com");
156
+ }
157
+ function urlFromCite(cite) {
158
+ if (cite === void 0) return void 0;
159
+ if (isHttpUrl(cite)) return cite;
160
+ const host = String(cite.split(/[›»|/]/)[0]).trim();
161
+ if (/^[\w.-]+\.[a-z]{2,}$/i.test(host)) return `https://${host}`;
162
+ }
163
+ function pushSource(sources, seen, url, title, snippet) {
164
+ if (url === void 0 || seen.has(url)) return;
165
+ seen.add(url);
166
+ sources.push({
167
+ url,
168
+ ...title !== void 0 ? { title } : {},
169
+ ...snippet !== void 0 ? { snippet } : {}
170
+ });
171
+ }
172
+ //#endregion
173
+ //#region lib/types/provider.js
174
+ /**
175
+ * `FreeSearchProvider`: a keyless `WebSearchProvider` that reads Bing HTML first
176
+ * and DuckDuckGo HTML if Bing yields no sources. It maps recovered URLs, titles,
177
+ * and snippets onto the seam vocabulary and omits `content` because neither
178
+ * engine returns a generated answer.
179
+ *
180
+ * @module @x1a0f3n9/dsh-web-search-free/provider
181
+ */
182
+ /** Stable id this provider registers under. */
183
+ const FREE_PROVIDER_ID = "free";
184
+ /** Default Bing search origin; `/search` is the operation. */
185
+ const FREE_DEFAULT_BING_BASE_URL = "https://www.bing.com";
186
+ /** Default DuckDuckGo HTML origin; `/html/` is the operation. */
187
+ const FREE_DEFAULT_DDG_BASE_URL = "https://html.duckduckgo.com";
188
+ /**
189
+ * HTML engines treat a product-only UA as a bot. Identify the product inside a
190
+ * Mozilla-compatible token so the request is still attributable.
191
+ */
192
+ const FREE_DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; deepseek-harness/0.0.1; +https://github.com/LunFengChen/deepseek-harness)";
193
+ /** The keyless Bing-then-DuckDuckGo search provider. */
194
+ var FreeSearchProvider = class {
195
+ options;
196
+ id = FREE_PROVIDER_ID;
197
+ constructor(options) {
198
+ this.options = options;
199
+ }
200
+ available() {
201
+ return isValidBaseUrl(this.options.bingBaseURL) && isValidBaseUrl(this.options.ddgBaseURL) && this.options.userAgent.length > 0 && (this.options.numResults === void 0 || isPositiveInteger(this.options.numResults));
202
+ }
203
+ async search(request, signal) {
204
+ const limit = request.maxResults ?? this.options.numResults;
205
+ const bing = await this.runEngine("bing", request.query, limit, signal);
206
+ if (bing.kind === "ok" && bing.sources.length > 0) return {
207
+ sources: [...bing.sources],
208
+ truncated: false
209
+ };
210
+ const ddg = await this.runEngine("ddg", request.query, limit, signal);
211
+ if (ddg.kind === "ok" && ddg.sources.length > 0) return {
212
+ sources: [...ddg.sources],
213
+ truncated: false
214
+ };
215
+ if (bing.kind === "ok" || ddg.kind === "ok") return {
216
+ sources: [],
217
+ truncated: false
218
+ };
219
+ throw new WebError(`Free search failed: Bing: ${bing.message}; DuckDuckGo: ${ddg.message}`, "WEB_PROVIDER_ERROR");
220
+ }
221
+ async runEngine(engine, query, limit, signal) {
222
+ const label = engine === "bing" ? "Bing" : "DuckDuckGo";
223
+ try {
224
+ const html = await this.fetchHtml(engine, query, limit, signal);
225
+ return {
226
+ kind: "ok",
227
+ sources: engine === "bing" ? parseBingHtml(html) : parseDdgHtml(html)
228
+ };
229
+ } catch (error) {
230
+ if (isAbortedWebError(error)) throw error;
231
+ if (error instanceof WebError) return {
232
+ kind: "error",
233
+ message: error.message
234
+ };
235
+ return {
236
+ kind: "error",
237
+ message: `${label} search request failed: ${String(error)}`
238
+ };
239
+ }
240
+ }
241
+ async fetchHtml(engine, query, limit, signal) {
242
+ const label = engine === "bing" ? "Bing" : "DuckDuckGo";
243
+ const url = engine === "bing" ? bingSearchUrl(this.options.bingBaseURL, query, limit) : ddgSearchUrl(this.options.ddgBaseURL, query);
244
+ let response;
245
+ try {
246
+ response = await fetch(url, {
247
+ method: "GET",
248
+ redirect: "follow",
249
+ headers: {
250
+ "accept": "text/html,application/xhtml+xml",
251
+ "user-agent": this.options.userAgent
252
+ },
253
+ ...signal !== void 0 ? { signal } : {}
254
+ });
255
+ } catch (error) {
256
+ if (isAbortError(error)) throw new WebError(`${label} search aborted`, "WEB_ABORTED", { cause: error });
257
+ throw new WebError(`${label} search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
258
+ }
259
+ let body;
260
+ try {
261
+ body = await response.text();
262
+ } catch (error) {
263
+ if (isAbortError(error)) throw new WebError(`${label} search aborted`, "WEB_ABORTED", { cause: error });
264
+ throw new WebError(`${label} returned an unreadable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
265
+ }
266
+ if (!response.ok) throw new WebError(`${label} search HTTP ${String(response.status)}`, "WEB_PROVIDER_ERROR");
267
+ return body;
268
+ }
269
+ };
270
+ /** Build the Bing `/search` URL, attaching `count` when a positive limit is known. */
271
+ function bingSearchUrl(baseURL, query, limit) {
272
+ const url = new URL("search", withTrailingSlash(baseURL));
273
+ url.searchParams.set("q", query);
274
+ if (limit !== void 0) url.searchParams.set("count", String(limit));
275
+ return url.href;
276
+ }
277
+ /** Build the DuckDuckGo `/html/` URL. */
278
+ function ddgSearchUrl(baseURL, query) {
279
+ const url = new URL("html/", withTrailingSlash(baseURL));
280
+ url.searchParams.set("q", query);
281
+ return url.href;
282
+ }
283
+ function withTrailingSlash(baseURL) {
284
+ return baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
285
+ }
286
+ /** True when `baseURL` parses as an absolute URL (a cheap local config check). */
287
+ function isValidBaseUrl(baseURL) {
288
+ return URL.canParse(baseURL);
289
+ }
290
+ /** True for a request limit that can be sent to Bing (a positive whole number). */
291
+ function isPositiveInteger(value) {
292
+ return Number.isInteger(value) && value > 0;
293
+ }
294
+ /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
295
+ function isAbortError(error) {
296
+ return error instanceof DOMException && error.name === "AbortError";
297
+ }
298
+ function isAbortedWebError(error) {
299
+ return error instanceof WebError && error.code === "WEB_ABORTED";
300
+ }
301
+ //#endregion
302
+ //#region lib/types/index.js
303
+ /**
304
+ * Keyless Bing/DuckDuckGo `WebSearchProvider` plugin. It contributes to the
305
+ * `ctx.web` registry without owning the service.
306
+ *
307
+ * @module @x1a0f3n9/dsh-web-search-free
308
+ */
309
+ /** Cordis plugin name used by loader diagnostics. */
310
+ const name = "web-search-free";
311
+ /** The web seam this provider registers into. */
312
+ const inject = ["web"];
313
+ const Config = z.object({
314
+ bingBaseURL: z.string(),
315
+ ddgBaseURL: z.string(),
316
+ userAgent: z.string(),
317
+ numResults: z.number().step(1).min(1)
318
+ });
319
+ /** Register the keyless search provider with `ctx.web`. */
320
+ function apply(ctx, config) {
321
+ ctx.web.registerSearchProvider(new FreeSearchProvider({
322
+ bingBaseURL: config.bingBaseURL ?? "https://www.bing.com",
323
+ ddgBaseURL: config.ddgBaseURL ?? "https://html.duckduckgo.com",
324
+ userAgent: config.userAgent ?? "Mozilla/5.0 (compatible; deepseek-harness/0.0.1; +https://github.com/LunFengChen/deepseek-harness)",
325
+ ...config.numResults !== void 0 ? { numResults: config.numResults } : {}
326
+ }));
327
+ }
328
+ //#endregion
329
+ export { Config, FREE_DEFAULT_BING_BASE_URL, FREE_DEFAULT_DDG_BASE_URL, FREE_DEFAULT_USER_AGENT, FREE_PROVIDER_ID, FreeSearchProvider, apply, bingSearchUrl, ddgSearchUrl, decodeBingHref, decodeDdgHref, decodeHtmlEntities, inject, isHttpUrl, name, parseBingHtml, parseDdgHtml, visibleText };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * HTML parsers for the keyless Bing and DuckDuckGo search backends.
3
+ * Layout-sensitive: tests pin the selectors against fixture markup, not live pages.
4
+ *
5
+ * @module @x1a0f3n9/dsh-web-search-free/html
6
+ */
7
+ import type { WebSearchSource } from '@x1a0f3n9/dsh-web';
8
+ /** True when `value` is an absolute http(s) URL. */
9
+ export declare function isHttpUrl(value: string): boolean;
10
+ /**
11
+ * Decode a common subset of HTML entities in attribute values and text.
12
+ *
13
+ * @param text - raw HTML text that may contain named or numeric entities.
14
+ * @returns the decoded text; unknown entities are left unchanged.
15
+ */
16
+ export declare function decodeHtmlEntities(text: string): string;
17
+ /**
18
+ * Strip tags and collapse whitespace from an HTML fragment.
19
+ *
20
+ * @param html - an HTML fragment that may contain tags and entities.
21
+ * @returns plain text, or `undefined` when nothing remains.
22
+ */
23
+ export declare function visibleText(html: string): string | undefined;
24
+ /**
25
+ * Read one HTML attribute value, decoding entities.
26
+ *
27
+ * @param attrs - the raw attribute string inside a start tag.
28
+ * @param name - the attribute name to read.
29
+ * @returns the decoded value, or `undefined` when the attribute is absent.
30
+ */
31
+ export declare function attributeValue(attrs: string, name: string): string | undefined;
32
+ /**
33
+ * Recover the destination URL from a Bing result href, including `/ck/a` tracking links.
34
+ *
35
+ * @param href - the `href` attribute from a Bing result anchor.
36
+ * @returns an absolute http(s) URL, or `undefined` when none can be recovered.
37
+ */
38
+ export declare function decodeBingHref(href: string): string | undefined;
39
+ /**
40
+ * Recover the destination URL from a DuckDuckGo HTML result href, including `uddg=`.
41
+ *
42
+ * @param href - the `href` attribute from a DuckDuckGo result anchor.
43
+ * @returns an absolute http(s) URL, or `undefined` when none can be recovered.
44
+ */
45
+ export declare function decodeDdgHref(href: string): string | undefined;
46
+ /**
47
+ * Parse Bing SERP HTML into portable sources.
48
+ *
49
+ * @param html - a Bing `/search` HTML document.
50
+ * @returns sources that have a recoverable http(s) URL; snippet-less hits are kept.
51
+ */
52
+ export declare function parseBingHtml(html: string): WebSearchSource[];
53
+ /**
54
+ * Parse DuckDuckGo HTML SERP markup into portable sources.
55
+ *
56
+ * @param html - a DuckDuckGo `/html/` document.
57
+ * @returns sources that have a recoverable http(s) URL; snippet-less hits are kept.
58
+ */
59
+ export declare function parseDdgHtml(html: string): WebSearchSource[];
60
+ //# sourceMappingURL=html.d.ts.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Keyless Bing/DuckDuckGo `WebSearchProvider` plugin. It contributes to the
3
+ * `ctx.web` registry without owning the service.
4
+ *
5
+ * @module @x1a0f3n9/dsh-web-search-free
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ import z from '@deepseek-ai/schemastery';
9
+ export { FREE_DEFAULT_BING_BASE_URL, FREE_DEFAULT_DDG_BASE_URL, FREE_DEFAULT_USER_AGENT, FREE_PROVIDER_ID, FreeSearchProvider, bingSearchUrl, ddgSearchUrl, } from './provider.ts';
10
+ export type { FreeSearchProviderOptions } from './provider.ts';
11
+ export { decodeBingHref, decodeDdgHref, decodeHtmlEntities, isHttpUrl, parseBingHtml, parseDdgHtml, visibleText, } from './html.ts';
12
+ /** Cordis plugin name used by loader diagnostics. */
13
+ export declare const name = "web-search-free";
14
+ /** The web seam this provider registers into. */
15
+ export declare const inject: string[];
16
+ /** Plugin config (all optional — `apply` fills constant defaults). */
17
+ export interface Config {
18
+ /** Bing origin; `/search` is appended. Defaults to the public site. */
19
+ bingBaseURL?: string;
20
+ /** DuckDuckGo HTML origin; `/html/` is appended. Defaults to the public site. */
21
+ ddgBaseURL?: string;
22
+ /** `User-Agent` sent on every request. Defaults to a Mozilla-compatible product token. */
23
+ userAgent?: string;
24
+ /** Default result count when a request carries no `maxResults`. Omitted = none. */
25
+ numResults?: number;
26
+ }
27
+ export declare const Config: z<Config>;
28
+ /** Register the keyless search provider with `ctx.web`. */
29
+ export declare function apply(ctx: Context, config: Config): void;
30
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `FreeSearchProvider`: a keyless `WebSearchProvider` that reads Bing HTML first
3
+ * and DuckDuckGo HTML if Bing yields no sources. It maps recovered URLs, titles,
4
+ * and snippets onto the seam vocabulary and omits `content` because neither
5
+ * engine returns a generated answer.
6
+ *
7
+ * @module @x1a0f3n9/dsh-web-search-free/provider
8
+ */
9
+ import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@x1a0f3n9/dsh-web';
10
+ /** Stable id this provider registers under. */
11
+ export declare const FREE_PROVIDER_ID = "free";
12
+ /** Default Bing search origin; `/search` is the operation. */
13
+ export declare const FREE_DEFAULT_BING_BASE_URL = "https://www.bing.com";
14
+ /** Default DuckDuckGo HTML origin; `/html/` is the operation. */
15
+ export declare const FREE_DEFAULT_DDG_BASE_URL = "https://html.duckduckgo.com";
16
+ /**
17
+ * HTML engines treat a product-only UA as a bot. Identify the product inside a
18
+ * Mozilla-compatible token so the request is still attributable.
19
+ */
20
+ export declare const FREE_DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; deepseek-harness/0.0.1; +https://github.com/LunFengChen/deepseek-harness)";
21
+ /** Resolved provider options (the plugin's `apply` supplies constant defaults). */
22
+ export interface FreeSearchProviderOptions {
23
+ /** Bing origin; `/search` is appended. */
24
+ bingBaseURL: string;
25
+ /** DuckDuckGo HTML origin; `/html/` is appended. */
26
+ ddgBaseURL: string;
27
+ /** `User-Agent` sent on every request. */
28
+ userAgent: string;
29
+ /** Default result count when a request carries no `maxResults`. */
30
+ numResults?: number;
31
+ }
32
+ /** The keyless Bing-then-DuckDuckGo search provider. */
33
+ export declare class FreeSearchProvider implements WebSearchProvider {
34
+ private readonly options;
35
+ readonly id = "free";
36
+ constructor(options: FreeSearchProviderOptions);
37
+ available(): boolean;
38
+ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
39
+ private runEngine;
40
+ private fetchHtml;
41
+ }
42
+ /** Build the Bing `/search` URL, attaching `count` when a positive limit is known. */
43
+ export declare function bingSearchUrl(baseURL: string, query: string, limit: number | undefined): string;
44
+ /** Build the DuckDuckGo `/html/` URL. */
45
+ export declare function ddgSearchUrl(baseURL: string, query: string): string;
46
+ //# sourceMappingURL=provider.d.ts.map
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@x1a0f3n9/dsh-web-search-free",
3
+ "description": "Keyless Bing/DuckDuckGo HTML search provider for the DeepSeek Harness web capability seam (ctx.web)",
4
+ "version": "0.1.5-rc.2",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/web/web-search-free"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./src/*": "./src/*",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib/index.js",
26
+ "lib/types/**/*.d.ts"
27
+ ],
28
+ "license": "MIT",
29
+ "peerDependencies": {
30
+ "@x1a0f3n9/dsh-web": "^0.1.5-rc.2",
31
+ "@deepseek-ai/cordis": "^4.0.2"
32
+ },
33
+ "dependencies": {
34
+ "@deepseek-ai/schemastery": "^3.18.2"
35
+ },
36
+ "devDependencies": {
37
+ "@deepseek-ai/cordis": "^4.0.2",
38
+ "@x1a0f3n9/dsh-web": "^0.1.5-rc.2",
39
+ "@x1a0f3n9/dsh-http-proxy": "^0.1.5-rc.2"
40
+ }
41
+ }