@zaofan/dsh-qqbot 1.8.0 → 1.8.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/README.md +26 -0
- package/README_EN.md +25 -0
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +21 -1
- package/dist/channel-tools.js.map +1 -1
- package/dist/gateway/debounce.d.ts +1 -0
- package/dist/gateway/debounce.d.ts.map +1 -1
- package/dist/gateway/debounce.js +48 -11
- package/dist/gateway/debounce.js.map +1 -1
- package/dist/session/session-manager.d.ts.map +1 -1
- package/dist/session/session-manager.js +9 -0
- package/dist/session/session-manager.js.map +1 -1
- package/dist/transport/quote-text.d.ts +8 -3
- package/dist/transport/quote-text.d.ts.map +1 -1
- package/dist/transport/quote-text.js +17 -5
- package/dist/transport/quote-text.js.map +1 -1
- package/docs/USER-GUIDE.md +31 -1
- package/examples/ext-tools/README.md +81 -47
- package/examples/ext-tools/bing_search.mjs +330 -0
- package/package.json +1 -1
package/docs/USER-GUIDE.md
CHANGED
|
@@ -506,7 +506,37 @@ export default {
|
|
|
506
506
|
},
|
|
507
507
|
};
|
|
508
508
|
```
|
|
509
|
-
写完在 QQ 里发 `/tools-reload`(或直接让 AI 调 `tools_reload` 工具)即可用, 无需重启。
|
|
509
|
+
写完在 QQ 里发 `/tools-reload`(或直接让 AI 调 `tools_reload` 工具)即可用, 无需重启。
|
|
510
|
+
|
|
511
|
+
### ★ 跨通道工具:`expose: 'all'`(v1.8.0+)
|
|
512
|
+
|
|
513
|
+
默认情况下扩展工具**只存在于 QQ 会话**里(那是插件挂工具的地方;别处的会话完全看不到 ⇒ 零 token 占用)。
|
|
514
|
+
加一行就能让它**对所有会话可见**(web / 终端等非 QQ 通道),并在那边**直接操作 QQ**:
|
|
515
|
+
|
|
516
|
+
```js
|
|
517
|
+
export default {
|
|
518
|
+
name: 'qq_send',
|
|
519
|
+
expose: 'all', // ← 开关
|
|
520
|
+
description: '查 QQ 聊天对象 / 向群或私聊发消息',
|
|
521
|
+
inputSchema: { action: { type: 'string' }, target: { type: 'string' }, text: { type: 'string' } },
|
|
522
|
+
run: async (args, env) => {
|
|
523
|
+
const qq = env.qq; // ← 只有 expose:'all' 才会注入
|
|
524
|
+
return { ok: true, msg: (await qq.sendText('group', args.target, args.text)) ? '已发送 ✓' : '发送失败' };
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
- **注册名**变成 `<原名>__<人设id>`(例 `qq_send__whale-girl`),多实例/多人设时一眼看出归属。
|
|
530
|
+
⚠️ **工具名只能用 `[a-zA-Z0-9_-]`** —— 出现 `@` 等字符会被 LLM API **整轮拒绝**(`Invalid 'tools[N].name'`,机器人会全面瘫痪,
|
|
531
|
+
连 `/bot-new` 都救不回来)。插件现在**注册前会校验并拒绝**非法名,不会再让一个坏名字拖垮全链路。
|
|
532
|
+
- **`env.qq`(账号包)**:`{ preset, appId, appSecret, owners, groups:[{gid,name,lastAt}], c2c:[{openid,name,lastAt}], sender, sendText(scope,id,text), sendMarkdown, sendImage, apiCall }`。
|
|
533
|
+
`groups/c2c` = 群台账(`groups.json`)+ 各实例活跃会话合并去重,用来"按名字寻址"。
|
|
534
|
+
- **发送"被动优先、失败转主动"**:`env.qq.sendText()` 先查**目标会话最近一条入站消息的 msgId**、带上它发
|
|
535
|
+
(被动回复:不吃主动消息配额、QQ 也不会吞);失败(msg_id 过期/被动配额用尽/没有窗口)自动**去掉 msgId 重发**。
|
|
536
|
+
⚠️ **被动窗口只在"最近有互动"的会话成立** —— 冷群没有窗口,必然走主动。
|
|
537
|
+
- **注册时机 = 插件启动**(不用等 QQ 消息),幂等、失败静默。
|
|
538
|
+
- **新用户白拿示例**:插件启动时把自带的 `examples/ext-tools/*.mjs`、`examples/botplay/*.mjs` 铺进
|
|
539
|
+
`{dataRoot}/.qqbot-extensions/{tools,botplay}/` —— **只补不存在**的,绝不覆盖你自己写的(因此也不会重复搬)。
|
|
510
540
|
|
|
511
541
|
### 给 AI 的要点(让 AI 帮用户写扩展时照此办)
|
|
512
542
|
1. 命令/工具文件都放**账号数据目录**的 `.qqbot-extensions/` 下(dataRoot 优先, 无则 cwd), 别放插件包内。
|
|
@@ -1,47 +1,81 @@
|
|
|
1
|
-
# 扩展工具示例(`examples/ext-tools/`)
|
|
2
|
-
|
|
3
|
-
这些文件是给 `{数据根}/.qqbot-extensions/tools/`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
1
|
+
# 扩展工具示例(`examples/ext-tools/`)
|
|
2
|
+
|
|
3
|
+
这些文件是给 `{数据根}/.qqbot-extensions/tools/` 用的**扩展工具**示例。
|
|
4
|
+
|
|
5
|
+
**自 v1.8.0 起:插件启动时会自动把它们铺进你的扩展目录**(`{数据根}/.qqbot-extensions/tools/`)——
|
|
6
|
+
**只补不存在**的文件、绝不覆盖你改过的,因此也不会重复搬。当然你也可以手动复制。
|
|
7
|
+
(热更新:写/改完发 `/tools-reload`;改**已有同名**工具时插件会自动热替换,无需重启宿主。)
|
|
8
|
+
|
|
9
|
+
## qq_send.mjs — 查 QQ 聊天对象 + 发消息(演示「跨通道工具」)
|
|
10
|
+
|
|
11
|
+
`expose: 'all'` 的样板:声明之后它**对所有会话可见**(含 web / 终端等非 QQ 通道),
|
|
12
|
+
靠 `env.qq` 账号包在那边**直接操作 QQ**(这就是"隔着通道指挥 QQ")。
|
|
13
|
+
|
|
14
|
+
| 环节 | 说明 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| 安装 | 插件自动铺(或手动把 `qq_send.mjs` 复制到 `{数据根}/.qqbot-extensions/tools/`) |
|
|
17
|
+
| 注册名 | `<原名>__<人设id>`,例 `qq_send__whale-girl`(⚠️ **工具名只能用 `[a-zA-Z0-9_-]`**) |
|
|
18
|
+
| 查询 | `action:"list"` → 列出已知群 / 私聊(带最近活跃时间),可用 `query` 过滤 |
|
|
19
|
+
| 发送 | `action:"send"` + `target`(openid **或群名/昵称**)+ `text`;按名字命中多个**绝不猜**,只回候选 |
|
|
20
|
+
| 连发 | `texts:[多条]`(内容不同)或 `text`+`count`(同一条重复 N 次),默认每条间隔 600ms 防 QQ 吞消息 |
|
|
21
|
+
| 发送链路 | 插件侧**被动优先**:先取目标会话最近的 `msgId` 走被动回复(不吃主动配额),失败自动去掉 `msgId` 转主动 |
|
|
22
|
+
| 前提 | 它走 `env.qq` ⇒ 必须声明 `expose: 'all'`,且对应人设实例在线 |
|
|
23
|
+
|
|
24
|
+
## vote.mjs — 群投票(演示「按钮回调」)
|
|
25
|
+
|
|
26
|
+
| 步骤 | 说明 |
|
|
27
|
+
|---|---|
|
|
28
|
+
| 安装 | 复制 `vote.mjs` 到 `{数据根}/.qqbot-extensions/tools/` |
|
|
29
|
+
| 使用 | 让 AI 调一次 `vote` 工具(题目 + `\|` 分隔的选项),它会发一张带按钮的卡片 |
|
|
30
|
+
| 点击 | 点下去 = 一票;同一人重复点只记第一次;每次点击都有回执文本 |
|
|
31
|
+
| 数据 | `{数据根}/.qqbot-extensions/tools/data/vote.json`(插件升级/重装不丢) |
|
|
32
|
+
|
|
33
|
+
## bing_search.mjs — 用 Bing 搜网页(演示「纯工具 + 结果自检」)
|
|
34
|
+
|
|
35
|
+
不依赖 `env.qq`、不碰会话状态,只做一件事:把搜索做好。三个示例里它最好改 —— 想换别的搜索源,
|
|
36
|
+
照着它的三段结构(取结果 → 自检 → 收尾排版)替换就行;也不注册卡片、不需要重启宿主。
|
|
37
|
+
|
|
38
|
+
| 环节 | 说明 |
|
|
39
|
+
|---|---|
|
|
40
|
+
| 安装 | 插件自动铺(或手动把 `bing_search.mjs` 复制到 `{数据根}/.qqbot-extensions/tools/`) |
|
|
41
|
+
| 查询 | `query`(必填,越短越准);`count` 条数(默认 8,上限 20);`site` 限定站点;`mkt` 语言市场 |
|
|
42
|
+
| 调试开关 | `quote:true` 强制加引号完全匹配;`raw:true` 跳过查询词卫生(拿来对比排查) |
|
|
43
|
+
| 查询词卫生 | 自动剥掉「是什么 / 是什么意思 / 怎么用」这类长尾巴 —— 长词形查询常被搜索引擎切碎,剥掉后明显更准 |
|
|
44
|
+
| 相关性自检 | 多词混排先算一次命中率;不过关就回退:**加引号完全匹配 → 只搜最长核心词**,并把实际用了哪一档写在结果开头 |
|
|
45
|
+
| 结果格式 | 每条 = `[来源域名] 标题` + 链接 + 摘要(截 180 字);同一域名最多 3 条,防刷屏 |
|
|
46
|
+
| 抓取通道 | 先走 Bing 的 RSS 端点(`?format=rss`,解析最稳、不需要 key),失败回退 HTML 抓取;国内可直连 `cn.bing.com` |
|
|
47
|
+
| 暴露范围 | 声明了 `expose: 'all'` ⇒ 全部会话可用(含 web / 终端);只想在 QQ 会话可见就删掉那一行 |
|
|
48
|
+
|
|
49
|
+
## 写自己的「按钮回调」工具,只要记住三件事
|
|
50
|
+
|
|
51
|
+
1. **发卡前登记卡片**(不登记 = 按钮点了没人认):
|
|
52
|
+
```js
|
|
53
|
+
env.registerInteractionCard({
|
|
54
|
+
cardId, buttonIds: ['o0', 'o1'],
|
|
55
|
+
expireAt: Date.now() + 86400_000, // 可选,缺省 7 天
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
2. **按钮 data 用 `ext:` 前缀**,三段式且与登记值完全一致:
|
|
59
|
+
```js
|
|
60
|
+
{ action: { type: 1, permission: { type: 2 }, data: `ext:${你的工具名}:${cardId}:${buttonId}` } }
|
|
61
|
+
```
|
|
62
|
+
`type: 1` 才是回调按钮(0=跳转、2=指令都不产生回调事件)。
|
|
63
|
+
3. **导出 `onInteraction(ctx, info)`**:
|
|
64
|
+
```js
|
|
65
|
+
export default {
|
|
66
|
+
name: 'mytool', run(args, env) {…},
|
|
67
|
+
async onInteraction(ctx, info) {
|
|
68
|
+
// info: { cardId, buttonId, buttonLabel, clickedBefore, event }
|
|
69
|
+
// ctx.performer: { openid, name, isOwner } ← 点击者身份
|
|
70
|
+
// ctx 与 run() 的 env 是同一套能力(emit/store/api/appendWake…)
|
|
71
|
+
return '已处理 ✅'; // 非空字符串 = 发给点击者的回执
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 边界(插件侧保证的)
|
|
77
|
+
|
|
78
|
+
- 不是 `ext:` 前缀、或查不到登记记录 / 已过期 → 插件**不消费**该事件(交回原有兜底,最后只 ack);
|
|
79
|
+
- 钩子抛错不会影响其它链路,点击者会收到一句 `⚠️ 处理失败:…`;
|
|
80
|
+
- 按钮回调的 ack 由插件统一做(不用管 `PUT /interactions/{id}`);
|
|
81
|
+
- 注册表是纯旁路:**写失败也不影响发卡**,只是这张卡的按钮点了没反应。
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bing_search.mjs — 用 Bing 搜网页, 返回 标题 / 链接 / 摘要 / 来源域名
|
|
3
|
+
*
|
|
4
|
+
* 这是一个"纯工具型"扩展示例: 不依赖 env.qq、不碰会话状态, 只把一件事做好 —— 搜索。
|
|
5
|
+
* 它顺手示范了三个**通用**技巧, 写别的检索类工具时可以直接抄:
|
|
6
|
+
*
|
|
7
|
+
* ① 查询词卫生: 把 "<词>是什么 / 是什么意思 / 怎么用" 这类长尾巴剥掉再搜。
|
|
8
|
+
* 长词形查询在搜索引擎里常被切碎(只抓住其中一两个字), 剥掉后结果会明显更准。
|
|
9
|
+
* ② 相关性自检 + 自动回退: 多词混排(如 "DeepSeek Harness 插件市场 dshmarket")很容易搜回一堆
|
|
10
|
+
* 不沾边的东西。这里先算一次命中率, 不过关就依次回退 —— 加引号做完全匹配 → 只搜最长的那个
|
|
11
|
+
* 核心词, 并把"这次实际用了哪一档"写在结果开头, 不让人误信。
|
|
12
|
+
* ③ 结果带来源域名 + 同域限流: 每条标出站点, 同一域名最多 3 条; 既能判断信息源, 也防刷屏。
|
|
13
|
+
*
|
|
14
|
+
* 装配: 放进 {数据根}/.qqbot-extensions/tools/ 后发 /tools-reload(等价于让 AI 调 tools_reload);
|
|
15
|
+
* 插件会把工具注册进所有活会话, 之后新建的会话自动带上。
|
|
16
|
+
* 网络: 先走 Bing 的 RSS 端点(?format=rss, 解析最稳、不需要 key), 失败再回退 HTML 抓取;
|
|
17
|
+
* 国内可直连 cn.bing.com。
|
|
18
|
+
* 暴露: 见下方 expose —— 声明 'all' 即对全部会话可见(含 web / 终端等非 QQ 通道); 删掉该行就只在 QQ 会话可见。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const DEFAULT_COUNT = 8;
|
|
22
|
+
const MAX_COUNT = 20;
|
|
23
|
+
const TIMEOUT_MS = 12000;
|
|
24
|
+
const SNIPPET_CHARS = 180;
|
|
25
|
+
const PER_DOMAIN_MAX = 3;
|
|
26
|
+
/** 相关性门槛: 低于它才认为"这批结果跟查询不沾边", 触发回退 */
|
|
27
|
+
const REL_FLOOR = 0.34;
|
|
28
|
+
|
|
29
|
+
const UA =
|
|
30
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
|
31
|
+
|
|
32
|
+
/** 查询词尾巴(疑问/解释性后缀), 剥掉后再搜 */
|
|
33
|
+
const TAIL_RE =
|
|
34
|
+
/(是什么意思啊?|是怎么个意思|什么意思啊?|是啥意思|是什么样子的?|是什么样的|是什么|是啥|是哪个|是哪种|怎么使用呀?|怎么使用|如何使用|该咋用|怎么用|用法|怎么样|如何|为什么|为啥|原因是什么|有哪些|有什么|的科普|科普一下|科普|的解释|的解释是|的解释吗|介绍一下|介绍下|介绍|的定义|解释)$/;
|
|
35
|
+
|
|
36
|
+
const PREFIX_RE =
|
|
37
|
+
/^(请问一下|请问|帮我查一下|帮我查|帮忙查一下|帮忙查|查一下|查查|搜一下|搜搜|搜索一下|搜索|search)[\s::,,]*/i;
|
|
38
|
+
|
|
39
|
+
const ENTITY_MAP = {
|
|
40
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ensp: ' ', emsp: ' ',
|
|
41
|
+
middot: '·', hellip: '…', mdash: '—', ndash: '–', ldquo: '“', rdquo: '”',
|
|
42
|
+
lsquo: '‘', rsquo: '’', laquo: '«', raquo: '»', '#39': "'", '#x27': "'", '#x2F': '/',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function decodeEntities(input) {
|
|
46
|
+
const s = String(input ?? '');
|
|
47
|
+
return s
|
|
48
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
49
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
|
50
|
+
.replace(/&([a-zA-Z#0-9]+);/g, (m, name) => (name in ENTITY_MAP ? ENTITY_MAP[name] : m));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function plain(input) {
|
|
54
|
+
return decodeEntities(String(input ?? '').replace(/<!\[CDATA\[|\]\]>/g, '').replace(/<[^>]*>/g, ' '))
|
|
55
|
+
.replace(/\s+/g, ' ')
|
|
56
|
+
.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clip(s, n) {
|
|
60
|
+
const t = String(s ?? '').trim();
|
|
61
|
+
return t.length > n ? t.slice(0, n) + '…' : t;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function domainOf(url) {
|
|
65
|
+
try {
|
|
66
|
+
return new URL(url).hostname.replace(/^www\./i, '');
|
|
67
|
+
} catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 解开 Bing 的跳转包装(www.bing.com/ck/a?...&u=a1<base64url>) */
|
|
73
|
+
function unwrapBingLink(url) {
|
|
74
|
+
const u = String(url ?? '').trim();
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(u);
|
|
77
|
+
if (!/(^|\.)bing\.com$/i.test(parsed.hostname)) return u;
|
|
78
|
+
const b64 = parsed.searchParams.get('u');
|
|
79
|
+
if (!b64 || !b64.startsWith('a1')) return u;
|
|
80
|
+
const raw = b64.slice(2).replace(/-/g, '+').replace(/_/g, '/');
|
|
81
|
+
const pad = raw + '='.repeat((4 - (raw.length % 4)) % 4);
|
|
82
|
+
const decoded = Buffer.from(pad, 'base64').toString('utf8');
|
|
83
|
+
return /^https?:\/\//i.test(decoded) ? decoded : u;
|
|
84
|
+
} catch {
|
|
85
|
+
return u;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 查询词卫生: 剥前缀、去问号、循环剥疑问尾巴 —— 至少留 2 个字 */
|
|
90
|
+
function sanitizeQuery(raw) {
|
|
91
|
+
let q = String(raw ?? '').trim();
|
|
92
|
+
q = q.replace(PREFIX_RE, '').replace(/[??。!!]+$/g, '').trim();
|
|
93
|
+
let guard = 0;
|
|
94
|
+
let changed = true;
|
|
95
|
+
while (changed && guard++ < 8) {
|
|
96
|
+
changed = false;
|
|
97
|
+
const next = q.replace(TAIL_RE, '').trim();
|
|
98
|
+
if (next !== q && next.length >= 2) {
|
|
99
|
+
q = next;
|
|
100
|
+
changed = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
q = q.replace(/[??]+$/g, '').trim();
|
|
104
|
+
return q;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 拆词: 空白/逗号/加号/竖线分隔; 只留长度 >= 2 的片段 */
|
|
108
|
+
function tokenize(q) {
|
|
109
|
+
return String(q ?? '')
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.split(/[\s,,、+||/]+/)
|
|
112
|
+
.map((t) => t.replace(/^["'“”]+|["'“”]+$/g, '').trim())
|
|
113
|
+
.filter((t) => t.length >= 2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 相关性: 有多少条结果的标题/摘要里真的出现了查询片段。
|
|
118
|
+
* 词多的时候不能"命中一个就算" —— "DeepSeek Harness 插件市场 dshmarket" 只命中
|
|
119
|
+
* 一个 deepseek 就判合格的话, 回退逻辑永远不会触发, 用户拿到的还是官网首页那种泛结果。
|
|
120
|
+
* 所以: 词 >= 3 个时要求至少命中 2 个, 且必须命中一个"强词"(长英文词 / 3 字以上中文)。
|
|
121
|
+
*/
|
|
122
|
+
function relevance(query, results) {
|
|
123
|
+
const toks = tokenize(query);
|
|
124
|
+
if (toks.length === 0) return results.length ? 1 : 0;
|
|
125
|
+
if (results.length === 0) return 0;
|
|
126
|
+
const strong = toks.filter((t) => t.length >= 5 || /[\u4e00-\u9fa5]{3,}/.test(t));
|
|
127
|
+
const need = toks.length >= 3 ? 2 : 1;
|
|
128
|
+
let hit = 0;
|
|
129
|
+
for (const r of results) {
|
|
130
|
+
const hay = `${r.title} ${r.snippet}`.toLowerCase();
|
|
131
|
+
const matched = toks.filter((t) => hay.includes(t));
|
|
132
|
+
const strongOk = strong.length === 0 || strong.some((t) => hay.includes(t));
|
|
133
|
+
if (matched.length >= need && strongOk) hit++;
|
|
134
|
+
}
|
|
135
|
+
return hit / results.length;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseRss(xml) {
|
|
139
|
+
const out = [];
|
|
140
|
+
const items = xml.match(/<item>[\s\S]*?<\/item>/gi) || [];
|
|
141
|
+
for (const item of items) {
|
|
142
|
+
const title = plain((item.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '');
|
|
143
|
+
const link = unwrapBingLink(
|
|
144
|
+
decodeEntities(
|
|
145
|
+
((item.match(/<link>([\s\S]*?)<\/link>/i) || [])[1] || '').replace(/<!\[CDATA\[|\]\]>/g, '').trim()
|
|
146
|
+
)
|
|
147
|
+
);
|
|
148
|
+
const snippet = plain((item.match(/<description[^>]*>([\s\S]*?)<\/description>/i) || [])[1] || '');
|
|
149
|
+
if (title && /^https?:\/\//i.test(link)) out.push({ title, url: link, snippet });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseHtml(html) {
|
|
155
|
+
const out = [];
|
|
156
|
+
const blocks = html.match(/<li class="b_algo"[\s\S]*?<\/li>/gi) || [];
|
|
157
|
+
for (const block of blocks) {
|
|
158
|
+
const a = block.match(/<h2[^>]*>[\s\S]*?<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
159
|
+
if (!a) continue;
|
|
160
|
+
const url = unwrapBingLink(decodeEntities(a[1]));
|
|
161
|
+
const title = plain(a[2]);
|
|
162
|
+
const p =
|
|
163
|
+
block.match(/<p[^>]*>([\s\S]*?)<\/p>/i) ||
|
|
164
|
+
block.match(/<div class="b_caption"[^>]*>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/i);
|
|
165
|
+
const snippet = plain(p ? p[1] : '');
|
|
166
|
+
if (title && /^https?:\/\//i.test(url)) out.push({ title, url, snippet });
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function fetchWithTimeout(url) {
|
|
172
|
+
const ctl = new AbortController();
|
|
173
|
+
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch(url, {
|
|
176
|
+
redirect: 'follow',
|
|
177
|
+
signal: ctl.signal,
|
|
178
|
+
headers: {
|
|
179
|
+
'user-agent': UA,
|
|
180
|
+
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
181
|
+
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const text = await res.text();
|
|
185
|
+
return { status: res.status, text };
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 依次尝试 RSS / HTML 两个端点, 返回 { results, endpoint, errors } */
|
|
192
|
+
async function searchBing(query, count, mkt) {
|
|
193
|
+
const q = encodeURIComponent(query);
|
|
194
|
+
const tries = [
|
|
195
|
+
{ url: `https://cn.bing.com/search?q=${q}&format=rss&count=${count}&mkt=${mkt}`, kind: 'rss' },
|
|
196
|
+
{ url: `https://www.bing.com/search?q=${q}&format=rss&count=${count}&mkt=${mkt}`, kind: 'rss' },
|
|
197
|
+
{ url: `https://cn.bing.com/search?q=${q}&count=${count}&mkt=${mkt}&setlang=zh-CN`, kind: 'html' },
|
|
198
|
+
{ url: `https://www.bing.com/search?q=${q}&count=${count}&mkt=${mkt}`, kind: 'html' },
|
|
199
|
+
];
|
|
200
|
+
const errors = [];
|
|
201
|
+
for (const t of tries) {
|
|
202
|
+
try {
|
|
203
|
+
const { status, text } = await fetchWithTimeout(t.url);
|
|
204
|
+
if (status !== 200 || !text) {
|
|
205
|
+
errors.push(`${t.kind}:${status}`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const results = t.kind === 'rss' ? parseRss(text) : parseHtml(text);
|
|
209
|
+
if (results.length > 0) return { results, endpoint: t.kind, errors };
|
|
210
|
+
errors.push(`${t.kind}:0 条`);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
errors.push(`${t.kind}:${e?.name === 'AbortError' ? '超时' : (e?.message ?? String(e))}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { results: [], endpoint: '', errors };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function dedupe(results) {
|
|
219
|
+
const seenUrl = new Set();
|
|
220
|
+
const perDomain = new Map();
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const r of results) {
|
|
223
|
+
const key = r.url.replace(/[#?].*$/, '').replace(/\/$/, '').toLowerCase();
|
|
224
|
+
if (seenUrl.has(key)) continue;
|
|
225
|
+
const dom = domainOf(r.url);
|
|
226
|
+
const used = perDomain.get(dom) || 0;
|
|
227
|
+
if (dom && used >= PER_DOMAIN_MAX) continue;
|
|
228
|
+
seenUrl.add(key);
|
|
229
|
+
perDomain.set(dom, used + 1);
|
|
230
|
+
out.push({ ...r, domain: dom || '(未知来源)' });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 一轮搜索 + 打分 */
|
|
236
|
+
async function attempt(query, label, count, mkt) {
|
|
237
|
+
const { results, endpoint, errors } = await searchBing(query, count, mkt);
|
|
238
|
+
const list = dedupe(results).slice(0, count);
|
|
239
|
+
return { label, query, list, endpoint, errors, rel: relevance(query, list) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 带自检的搜索:
|
|
244
|
+
* 1) 卫生处理后的查询
|
|
245
|
+
* 2) 相关性不过关 → 加引号完全匹配
|
|
246
|
+
* 3) 还不过关 → 只搜最长的那个核心词
|
|
247
|
+
* 取相关性最高的一轮结果返回。
|
|
248
|
+
*/
|
|
249
|
+
async function searchSmart(baseQuery, count, mkt) {
|
|
250
|
+
const tried = [];
|
|
251
|
+
let best = await attempt(baseQuery, '主查询', count, mkt);
|
|
252
|
+
tried.push(best);
|
|
253
|
+
if (best.list.length > 0 && best.rel >= REL_FLOOR) return { best, tried, fallback: false };
|
|
254
|
+
|
|
255
|
+
const quoted = await attempt(`"${baseQuery}"`, '加引号', count, mkt);
|
|
256
|
+
tried.push(quoted);
|
|
257
|
+
if (quoted.list.length > 0 && quoted.rel > best.rel) best = quoted;
|
|
258
|
+
if (best.list.length > 0 && best.rel >= REL_FLOOR) return { best, tried, fallback: true };
|
|
259
|
+
|
|
260
|
+
const toks = tokenize(baseQuery).sort((a, b) => b.length - a.length);
|
|
261
|
+
if (toks.length > 1) {
|
|
262
|
+
const core = await attempt(toks[0], `核心词 ${toks[0]}`, count, mkt);
|
|
263
|
+
tried.push(core);
|
|
264
|
+
if (core.list.length > 0 && core.rel >= best.rel) best = core;
|
|
265
|
+
}
|
|
266
|
+
return { best, tried, fallback: true };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export default {
|
|
270
|
+
name: 'bing_search',
|
|
271
|
+
/**
|
|
272
|
+
* 暴露范围(dsh-qqbot 1.8.0+):'all' = 注册进全部会话, 不止 QQ 通道 ——
|
|
273
|
+
* web、终端等非 QQ 会话也能拿到本工具; 不写/写 'qq' 就只在 QQ 会话可见。
|
|
274
|
+
* (注册名会自动带人设归属, 如 bing_search__whale-girl)
|
|
275
|
+
*/
|
|
276
|
+
expose: 'all',
|
|
277
|
+
description:
|
|
278
|
+
'用 Bing 搜网页, 返回标题+链接+摘要+来源域名。查询词已做卫生处理(自动剥掉"是什么/是什么意思"这类长尾巴再搜, 长词形查询不再被切碎); 多词混排导致结果跑偏时会自动回退(加引号/只搜核心词)并注明。查事实/梗/人物/新闻/资料时用它, 比模型记忆可靠。',
|
|
279
|
+
inputSchema: {
|
|
280
|
+
query: { type: 'string', required: true, description: '搜索词, 越短越准, 如 "性转马尔福"' },
|
|
281
|
+
count: { type: 'integer', description: `返回条数, 默认 ${DEFAULT_COUNT}, 上限 ${MAX_COUNT}` },
|
|
282
|
+
site: { type: 'string', description: '限定站点, 如 zh.moegirl.org.cn(会拼成 site:xxx 前缀)' },
|
|
283
|
+
quote: { type: 'boolean', description: 'true=直接给查询词加英文双引号做完全匹配' },
|
|
284
|
+
raw: { type: 'boolean', description: 'true=不做查询词卫生, 原样搜(排查对比用)' },
|
|
285
|
+
mkt: { type: 'string', description: '市场/语言, 默认 zh-CN, 查英文资料可传 en-US' },
|
|
286
|
+
},
|
|
287
|
+
run: async (args) => {
|
|
288
|
+
const a = args || {};
|
|
289
|
+
const original = String(a.query ?? '').trim();
|
|
290
|
+
if (!original) return { ok: false, msg: '缺少 query(要搜什么?)' };
|
|
291
|
+
|
|
292
|
+
let used = a.raw ? original : sanitizeQuery(original);
|
|
293
|
+
if (!used || used.length < 2) used = original;
|
|
294
|
+
if (a.site) used = `site:${String(a.site).trim()} ${used}`;
|
|
295
|
+
if (a.quote && !used.startsWith('"')) used = `"${used}"`;
|
|
296
|
+
|
|
297
|
+
const n = Math.max(1, Math.min(MAX_COUNT, Math.round(Number(a.count) || DEFAULT_COUNT)));
|
|
298
|
+
const mkt = String(a.mkt || 'zh-CN').trim() || 'zh-CN';
|
|
299
|
+
|
|
300
|
+
const { best, fallback } = await searchSmart(used, n, mkt);
|
|
301
|
+
|
|
302
|
+
if (!best.list.length) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
msg: `Bing 没有可用的结果(查询词: "${used}")。尝试记录: ${best.errors.join('; ') || '无'}\n换个更短的核心词再试一次。`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const notes = [];
|
|
310
|
+
if (used !== original) notes.push(`卫生处理: "${original}" → "${used}"`);
|
|
311
|
+
if (fallback) notes.push(`已自动回退, 实际用的是【${best.label}: ${best.query}】`);
|
|
312
|
+
if (best.rel < REL_FLOOR) {
|
|
313
|
+
notes.push(
|
|
314
|
+
fallback
|
|
315
|
+
? `按核心词的相关性约 ${Math.round(best.rel * 100)}%(有些结果没直接提到该词), 自行判断够不够用`
|
|
316
|
+
: `相关性偏低(${Math.round(best.rel * 100)}% 命中), 结果可能不沾边, 建议换更短的核心词`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const lines = [
|
|
321
|
+
`Bing 搜到 ${best.list.length} 条(${notes.length ? notes.join(';') : '主查询直出'};${(best.endpoint || 'rss').toUpperCase()} 通道):`,
|
|
322
|
+
];
|
|
323
|
+
best.list.forEach((r, i) => {
|
|
324
|
+
lines.push(`${i + 1}. [${r.domain}] ${r.title}`);
|
|
325
|
+
lines.push(` ${r.url}`);
|
|
326
|
+
if (r.snippet) lines.push(` ${clip(r.snippet, SNIPPET_CHARS)}`);
|
|
327
|
+
});
|
|
328
|
+
return { ok: true, msg: lines.join('\n') };
|
|
329
|
+
},
|
|
330
|
+
};
|