dsh-free-search 0.8.5 → 0.8.7

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/lib/client.js CHANGED
@@ -51,6 +51,13 @@ window.__ModuleLoader__.load({
51
51
  ".dshfs-select{color-scheme:light dark}",
52
52
  ".dshfs-select option, .dshfs-select optgroup{background-color:#ffffff;color:#1d1d1f}",
53
53
  "@media (prefers-color-scheme: dark){.dshfs-select{color-scheme:dark}.dshfs-select option, .dshfs-select optgroup{background-color:#1e1f24;color:#f5f5f7}}",
54
+ ".dshfs-textarea{border:1px solid rgba(0,0,0,.1);font:inherit;font-variant-numeric:tabular-nums;color:inherit;background:rgba(0,0,0,.03);border-radius:10px;padding:8px 12px;min-height:96px;height:auto;font-size:14px;line-height:1.5;transition:all 160ms cubic-bezier(.4,0,.2,1);width:100%;box-sizing:border-box;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,\"Liberation Mono\",monospace}",
55
+ "@media (prefers-color-scheme: dark){.dshfs-textarea{border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.05)}}",
56
+ ".dshfs-textarea:hover:not(:disabled){border-color:rgba(0,0,0,.2);background:rgba(0,0,0,.05)}",
57
+ "@media (prefers-color-scheme: dark){.dshfs-textarea:hover:not(:disabled){border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.08)}}",
58
+ ".dshfs-textarea:focus-visible{outline:none;border-color:#0071e3;box-shadow:0 0 0 3px rgba(0,113,227,.25);background:#ffffff}",
59
+ "@media (prefers-color-scheme: dark){.dshfs-textarea:focus-visible{border-color:#0a84ff;box-shadow:0 0 0 3px rgba(10,132,255,.25);background:#1c1c1e}}",
60
+ ".dshfs-textarea:disabled{opacity:.5;cursor:default}",
54
61
  ".dshfs-ttl{width:100px}",
55
62
  ".dshfs-fieldRow{display:flex;align-items:center;gap:12px;flex-wrap:wrap}",
56
63
  ".dshfs-keyStorage{width:auto;min-width:200px}",
@@ -60,7 +67,7 @@ window.__ModuleLoader__.load({
60
67
  ".dshfs-platform{display:flex;align-items:center;gap:6px;color:inherit;font-size:14px;cursor:pointer}",
61
68
  ".dshfs-platform input{accent-color:#0071e3;width:16px;height:16px}",
62
69
  ".dshfs-priorityList{display:flex;flex-direction:column;gap:6px}",
63
- ".dshfs-priorityRow{display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:rgba(0,0,0,.02)}",
70
+ ".dshfs-priorityRow{display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:rgba(0,0,0,.02);box-sizing:border-box;min-height:42px}",
64
71
  "@media (prefers-color-scheme: dark){.dshfs-priorityRow{border-color:rgba(255,255,255,.09);background:rgba(255,255,255,.035)}}",
65
72
  ".dshfs-priorityRowDisabled{opacity:.58}",
66
73
  ".dshfs-priorityIndex{width:26px;flex:none;color:#6e6e73;font-size:12px;font-variant-numeric:tabular-nums;text-align:right}",
@@ -204,6 +211,9 @@ window.__ModuleLoader__.load({
204
211
  platformHint: "为 agent 的 platform_search 工具启用平台。禁用的平台会被跳过。",
205
212
  cacheTtl: "结果缓存时长(分钟)",
206
213
  cacheTtlHint: "0 关闭缓存,最长 5 分钟。缩短可加快时效,延长可防限流、省额度。",
214
+ blockedDomainsLabel: "域名黑名单(blockedDomains)",
215
+ blockedDomainsHint: "一行一个域名,命中的站点及其所有子域都会从搜索结果里剔除(csdn.net 覆盖 blog.csdn.net)。留空=不过滤。可以直接粘贴完整网址,scheme、路径和端口会被自动去掉。",
216
+ blockedDomainsWarn: (corrected, dupes, dropped) => `已忽略 ${dupes} 个重复项、${dropped} 行无法识别;${corrected} 行已自动修正为裸域名。规则对下一次搜索生效,旧结果缓存会同步失效。`,
207
217
  unavailable: "设置不可用 —— free-search 桥接未暴露。",
208
218
  saveFailed: "保存失败",
209
219
  saveFailedDetail: (d) => `保存失败:${d}`,
@@ -310,6 +320,9 @@ window.__ModuleLoader__.load({
310
320
  platformHint: "Enable platforms for the agent's platform_search tool. Disabled platforms are skipped.",
311
321
  cacheTtl: "Result cache TTL (minutes)",
312
322
  cacheTtlHint: "0 disables caching, max 5 minutes. Lower = fresher results, higher = less rate-limiting / fewer credits used.",
323
+ blockedDomainsLabel: "Domain blocklist (blockedDomains)",
324
+ blockedDomainsHint: "One domain per line. A matched site and all of its subdomains are dropped from search results (csdn.net covers blog.csdn.net). Leave empty to disable. Full URLs are accepted — scheme, path and port are stripped automatically.",
325
+ blockedDomainsWarn: (corrected, dupes, dropped) => `Ignored ${dupes} duplicate(s) and ${dropped} unrecognized line(s); corrected ${corrected} line(s) to bare domains. Rules apply to the next search, and the result cache is invalidated accordingly.`,
313
326
  unavailable: "Settings unavailable — the free-search bridge is not exposed.",
314
327
  saveFailed: "save failed",
315
328
  saveFailedDetail: (d) => `save failed: ${d}`,
@@ -355,7 +368,7 @@ window.__ModuleLoader__.load({
355
368
  };
356
369
  const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
357
370
  // 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致;跟随上游 v0.7.5)
358
- const PLUGIN_VERSION = "0.8.5";
371
+ const PLUGIN_VERSION = "0.8.6";
359
372
  const ENGINES = [
360
373
  { id: "auto", label: "Auto · 智能路由", badge: "AUTO", link: null },
361
374
  { id: "multi", label: "Multi Search · 多引擎", badge: "MULTI", link: null },
@@ -414,6 +427,45 @@ window.__ModuleLoader__.load({
414
427
  if (!Array.isArray(value)) return FAILURE_CLASSES.map((cls) => cls.id);
415
428
  return [...new Set(value.filter((id) => FAILURE_CLASSES.some((cls) => cls.id === id)))];
416
429
  };
430
+ // 域名黑名单:本地解析(显示 + 保存)。
431
+ // 注意:清洗规则必须与 lib/domain_filter.js 的 normalizeDomainList 保持一致——
432
+ // 服务端也会规范化一次(用户可直接编辑 YAML 绕过 UI),两边结果相同才不会有"存进去对、读回来错"。
433
+ // 这里额外返回统计量,用于字段下方的非阻断提示。
434
+ const parseBlockedDomains = (text) => {
435
+ const lines = String(text ?? "")
436
+ .split("\n")
437
+ .map((line) => line.trim())
438
+ .filter(Boolean);
439
+ const stats = { corrected: 0, dupes: 0, dropped: 0 };
440
+ const seen = new Set();
441
+ const domains = [];
442
+ for (const line of lines) {
443
+ let s = line.toLowerCase();
444
+ const before = s;
445
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
446
+ const cut = s.search(/[/?#]/);
447
+ if (cut >= 0) s = s.slice(0, cut);
448
+ if (!s) {
449
+ stats.dropped++;
450
+ continue;
451
+ }
452
+ const colon = s.lastIndexOf(":");
453
+ if (colon >= 0 && /^\d+$/.test(s.slice(colon + 1))) s = s.slice(0, colon);
454
+ s = s.replace(/^www\./, "").replace(/\.+$/, "");
455
+ if (!s || !/^[a-z0-9][a-z0-9.-]*$/.test(s) || s.startsWith(".")) {
456
+ stats.dropped++;
457
+ continue;
458
+ }
459
+ if (before !== s) stats.corrected++;
460
+ if (seen.has(s)) {
461
+ stats.dupes++;
462
+ continue;
463
+ }
464
+ seen.add(s);
465
+ domains.push(s);
466
+ }
467
+ return { domains, stats };
468
+ };
417
469
 
418
470
  async function bridgeDescribe() {
419
471
  const response = await fetch(`${BRIDGE_PREFIX}/describe`, {
@@ -520,6 +572,7 @@ window.__ModuleLoader__.load({
520
572
  const [zhPool, setZhPool] = react.useState(DEFAULT_ZH_POOL);
521
573
  const [enPool, setEnPool] = react.useState(DEFAULT_EN_POOL);
522
574
  const [fallbackOn, setFallbackOn] = react.useState(FAILURE_CLASSES.map((cls) => cls.id));
575
+ const [blockedDomains, setBlockedDomains] = react.useState("");
523
576
  const [safeSearch, setSafeSearch] = react.useState("off");
524
577
  const [bingMarket, setBingMarket] = react.useState("zh-CN");
525
578
  const [anysearchKey, setAnysearchKey] = react.useState("");
@@ -624,6 +677,8 @@ window.__ModuleLoader__.load({
624
677
  setZhPool(normalizePoolIds(v.zhPool, DEFAULT_ZH_POOL));
625
678
  setEnPool(normalizePoolIds(v.enPool, DEFAULT_EN_POOL));
626
679
  setFallbackOn(normalizeFallbackOn(v.fallbackOn));
680
+ // 黑名单以文本形式回显(一行一个);配置里存的是数组
681
+ setBlockedDomains((Array.isArray(v.blockedDomains) ? v.blockedDomains : []).join("\n"));
627
682
  setSafeSearch(v.safeSearch === "strict" || v.safeSearch === "moderate" ? v.safeSearch : "off");
628
683
  setBingMarket(v.bingMarket === undefined ? "zh-CN" : v.bingMarket);
629
684
  setLang(v.lang === "en" ? "en" : "zh");
@@ -763,6 +818,7 @@ window.__ModuleLoader__.load({
763
818
  ops.push({ op: "set", path: ["zhPool"], value: normalizePoolIds(zhPool, DEFAULT_ZH_POOL) });
764
819
  ops.push({ op: "set", path: ["enPool"], value: normalizePoolIds(enPool, DEFAULT_EN_POOL) });
765
820
  ops.push({ op: "set", path: ["fallbackOn"], value: normalizeFallbackOn(fallbackOn) });
821
+ ops.push({ op: "set", path: ["blockedDomains"], value: parseBlockedDomains(blockedDomains).domains });
766
822
  ops.push({ op: "set", path: ["lang"], value: lang });
767
823
  ops.push({ op: "set", path: ["keyStorage"], value: keyStorage });
768
824
  ops.push({ op: "set", path: ["safeSearch"], value: safeSearch });
@@ -1340,6 +1396,42 @@ react_jsx_runtime.jsx("div", {
1340
1396
  }),
1341
1397
  ],
1342
1398
  }),
1399
+ react_jsx_runtime.jsx("div", {
1400
+ className: "dshfs-field",
1401
+ children: [
1402
+ react_jsx_runtime.jsx("div", {
1403
+ className: "dshfs-label",
1404
+ children: t.blockedDomainsLabel,
1405
+ }),
1406
+ react_jsx_runtime.jsx("textarea", {
1407
+ className: "dshfs-textarea",
1408
+ rows: 5,
1409
+ placeholder: "csdn.net\n51cto.com",
1410
+ value: blockedDomains,
1411
+ spellCheck: false,
1412
+ disabled: !ready || saving,
1413
+ onChange: (e) => {
1414
+ setBlockedDomains(e.target.value);
1415
+ setDirty(true);
1416
+ setFailed(false);
1417
+ setSaveError("");
1418
+ },
1419
+ }),
1420
+ react_jsx_runtime.jsx("p", {
1421
+ className: "dshfs-hint",
1422
+ children: t.blockedDomainsHint,
1423
+ }),
1424
+ (() => {
1425
+ // 轻量提示:从不阻断保存,只告诉用户哪些行被自动处理了
1426
+ const { stats } = parseBlockedDomains(blockedDomains);
1427
+ if (stats.corrected === 0 && stats.dupes === 0 && stats.dropped === 0) return null;
1428
+ return react_jsx_runtime.jsx("p", {
1429
+ className: "dshfs-hint",
1430
+ children: t.blockedDomainsWarn(stats.corrected, stats.dupes, stats.dropped),
1431
+ });
1432
+ })(),
1433
+ ],
1434
+ }),
1343
1435
  react_jsx_runtime.jsx("div", {
1344
1436
  className: "dshfs-field",
1345
1437
  children: [
@@ -0,0 +1,100 @@
1
+ // #72 域名黑名单:在结果进入上下文前过滤内容农场(CSDN 等)。
2
+ // 匹配语义(issue #72 约定):
3
+ // - 域名相等或其子域命中(csdn.net 覆盖 blog.csdn.net)
4
+ // - 忽略大小写;www. 前缀归一;无 scheme / 无路径;
5
+ // - 不做正则(notcsdn.net 不得被 csdn.net 命中)
6
+ // 生效方式:provider.search 入口按当次请求的 blockedDomains 设置"活动黑名单",
7
+ // uniqueSources 在收敛(去重 + limit)之前过滤——被屏蔽条目让位给后续候选,
8
+ // 因此 maxResults 仍能填满,且 truncated 只在真正触及上限时为 true。
9
+
10
+ let active = [];
11
+ /**
12
+ * 配置清洗:把用户随手填的域名/URL 归一成裸域名规则。
13
+ * 处理 trim、scheme、路径/query/hash、端口、www.、大小写、去重(保序)、丢弃空值与非法行。
14
+ *
15
+ * 为什么要服务端兜底:blockedDomains 是 .volatile() 配置,用户可以直接编辑 YAML 绕过 UI,
16
+ * 所以规范化必须在 setActiveBlockedDomains 里做,而不是只做在设置页。
17
+ * isDomainBlocked 的匹配语义不受影响——规则只是被清洗成更可靠的等价形式。
18
+ */
19
+ export function normalizeDomainList(list) {
20
+ if (!Array.isArray(list)) return [];
21
+ const out = [];
22
+ const seen = new Set();
23
+ for (const raw of list) {
24
+ let s = String(raw ?? "").trim().toLowerCase();
25
+ if (!s) continue;
26
+ // 去 scheme(http:// https:// 或其它的 protocol://)
27
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
28
+ // 去路径 / query / hash
29
+ const cut = s.search(/[/?#]/);
30
+ if (cut >= 0) s = s.slice(0, cut);
31
+ if (!s) continue;
32
+ // 去端口(仅当冒号后是纯数字)
33
+ const colon = s.lastIndexOf(":");
34
+ if (colon >= 0 && /^\d+$/.test(s.slice(colon + 1))) s = s.slice(0, colon);
35
+ // 去 www. 前缀与尾随点号
36
+ s = s.replace(/^www\./, "").replace(/\.+$/, "");
37
+ // 只接受"看起来像域名"的行:空格、下划线、中文等直接丢弃(它们永远匹配不到 host)
38
+ if (!s || !/^[a-z0-9][a-z0-9.-]*$/.test(s) || s.startsWith(".")) continue;
39
+ if (seen.has(s)) continue;
40
+ seen.add(s);
41
+ out.push(s);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** 设置本次搜索生效的黑名单(provider.search / research 入口调用)。空数组 = 不过滤。 */
47
+ export function setActiveBlockedDomains(list) {
48
+ active = normalizeDomainList(list);
49
+ }
50
+ export function getActiveBlockedDomains() {
51
+ return [...active];
52
+ }
53
+
54
+ function normalizeHost(host) {
55
+ return String(host ?? "").toLowerCase().trim().replace(/^www\./, "");
56
+ }
57
+ function normalizeRule(domain) {
58
+ return String(domain ?? "").toLowerCase().trim().replace(/^www\./, "").replace(/\/+$/, "");
59
+ }
60
+
61
+ /** 单个 URL 是否命中黑名单;URL 无法解析时返回 false(不误伤)。 */
62
+ export function isDomainBlocked(url, blocked = active) {
63
+ if (!blocked || blocked.length === 0) return false;
64
+ let host;
65
+ try {
66
+ host = normalizeHost(new URL(String(url)).hostname);
67
+ } catch {
68
+ return false;
69
+ }
70
+ if (!host) return false;
71
+ return blocked.some((raw) => {
72
+ const rule = normalizeRule(raw);
73
+ if (!rule) return false;
74
+ return host === rule || host.endsWith("." + rule);
75
+ });
76
+ }
77
+
78
+ /** 列表过滤:结果条目统一为 { url } 形态,命中黑名的整条丢弃(保持原顺序=让位语义)。 */
79
+ export function filterBlockedDomains(items, blocked = active) {
80
+ if (!blocked || blocked.length === 0) return items;
81
+ return items.filter((item) => !isDomainBlocked(item?.url, blocked));
82
+ }
83
+
84
+ /**
85
+ * 结果级收口:引擎返回的 { sources } 在交给上层(provider 返回值 / multi 合并 /
86
+ * runEngineTest / 工具 execute)之前统一过滤。
87
+ *
88
+ * 为什么需要它:uniqueSources 只是 40+ 个引擎里 29 个的"收敛去重"helper,
89
+ * searchAnysearch / searchPlatform 等 11 个引擎并不经过它(各自直接 return sources)。
90
+ * 只在 uniqueSources 里过滤会漏掉这些引擎——#72 首版就是这么漏的。
91
+ * 因此过滤必须落在"结果离开引擎"的所有咽喉点上,而不是某一个 helper 里。
92
+ *
93
+ * 无黑名单 / 无命中时原样返回(零分配),调用方可以直接重新赋值。
94
+ */
95
+ export function applyBlockedFilterToResult(result) {
96
+ if (!result || !Array.isArray(result.sources)) return result;
97
+ const sources = filterBlockedDomains(result.sources);
98
+ if (sources.length === result.sources.length) return result;
99
+ return { ...result, sources };
100
+ }
@@ -8,7 +8,9 @@
8
8
  import https from "node:https";
9
9
  import zlib from "node:zlib";
10
10
  import { spawn } from "node:child_process";
11
- import { existsSync } from "node:fs";
11
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
12
14
  import { callExa, callFirecrawl, callCrawl4ai } from "./fetch_upstream.js";
13
15
 
14
16
  const T1_MIN_CHARS = 300;
@@ -40,35 +42,65 @@ function findSystemBrowser() {
40
42
  return cachedBrowserPath;
41
43
  }
42
44
 
43
- function browserDumpDom(browserPath, url, { timeoutMs = 20000, signal } = {}) {
45
+ function browserDumpDom(browserPath, url, { timeoutMs = 25000, signal } = {}) {
44
46
  return new Promise((resolve) => {
47
+ // 独立 user-data-dir:避免与用户正在运行的浏览器实例抢同一 profile(共享 profile 时无输出/立即退出)
48
+ let tmpDir;
45
49
  let child;
46
50
  try {
47
- child = spawn(browserPath, ["--headless=new", "--disable-gpu", "--no-first-run", "--dump-dom", "--virtual-time-budget=10000", url], {
48
- stdio: ["ignore", "pipe", "ignore"],
49
- windowsHide: true,
50
- });
51
+ tmpDir = mkdtempSync(join(tmpdir(), "dshfs-browser-"));
52
+ child = spawn(browserPath, [
53
+ "--headless=new",
54
+ "--disable-gpu",
55
+ "--no-first-run",
56
+ "--no-sandbox",
57
+ // 抑制崩溃上报/组件更新/账号同步:Windows 实测这些会在 tmp profile 下反复报错
58
+ // (Crashpad settings.dat 写入失败、get_updates failed)并偶发导致退出码异常/无输出
59
+ "--disable-breakpad",
60
+ "--disable-crashpad",
61
+ "--disable-component-update",
62
+ "--disable-sync",
63
+ `--user-data-dir=${tmpDir}`,
64
+ "--dump-dom",
65
+ "--virtual-time-budget=10000",
66
+ url,
67
+ ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
51
68
  } catch {
69
+ if (tmpDir) try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
52
70
  resolve(null);
53
71
  return;
54
72
  }
55
73
  let out = "";
56
74
  let done = false;
75
+ // 杀整棵进程树:Edge/Chrome 是多进程架构,child.kill() 只杀主进程会残留渲染子进程
76
+ const killTree = () => {
77
+ try {
78
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
79
+ } catch {
80
+ try { child.kill(); } catch { /* 忽略 */ }
81
+ }
82
+ };
57
83
  const finish = (ok) => {
58
84
  if (done) return;
59
85
  done = true;
60
- try {
61
- child.kill();
62
- } catch {
63
- // 忽略
86
+ killTree();
87
+ if (tmpDir) {
88
+ // 浏览器进程退出后才能删 profile 目录;延迟清理,失败无所谓
89
+ setTimeout(() => { try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 忽略 */ } }, 500);
64
90
  }
65
91
  resolve(ok && out.length > 0 ? out : null);
66
92
  };
67
93
  child.stdout.on("data", (c) => {
68
94
  if (out.length < 5 * 1024 * 1024) out += c.toString("utf8");
69
95
  });
96
+ // stderr 必须 pipe 且读取丢弃:Windows 上 Edge 无头模式 stderr 指向 NUL 会卡死(实测 exit=1/0 输出)
97
+ child.stderr.on("data", () => {});
70
98
  child.on("error", () => finish(false));
71
- child.on("exit", () => finish(true));
99
+ // 以 exit 为准:Edge/Chrome 的渲染子进程会继承 stdout pipe,"close" 可能迟迟不触发;
100
+ // 稍等 200ms 让残余数据 flush 到齐(exit 与 stdout 到达之间有窗口)。
101
+ child.on("exit", (code) => {
102
+ setTimeout(() => finish(code === 0 || out.length > 0), 200);
103
+ });
72
104
  setTimeout(() => finish(false), timeoutMs);
73
105
  if (signal) {
74
106
  if (signal.aborted) finish(false);
@@ -139,17 +171,10 @@ export function createFetchProvider({ resolveApiKey, logger }) {
139
171
  const url = String(request?.url || "");
140
172
  if (!/^https:\/\//i.test(url)) throw new Error(`fs-quality-fetch: only https URLs are supported (got "${url.slice(0, 60)}")`);
141
173
  const errors = [];
142
- // Referer:伪装为"站内点击"(部分反爬站对无 Referer 的匿名请求更严格)
143
- const headers = { ...BROWSERISH };
144
- try {
145
- headers.Referer = new URL(url).origin + "/";
146
- } catch {
147
- // URL 已在上面校验过,理论到不了这里
148
- }
149
174
 
150
175
  // T1:直连(文本类内容;二进制交给后层——exa 取正文,PDF 等仍由 firecrawl 处理)
151
176
  try {
152
- const r = await httpsGetFollow(url, { headers, signal });
177
+ const r = await httpsGetFollow(url, { headers: BROWSERISH, signal });
153
178
  if (r.status < 400 && TEXTUAL.test(r.contentType) && r.text.length >= T1_MIN_CHARS) {
154
179
  return { url: r.url, statusCode: r.status, body: { kind: "html", content: r.text }, truncated: false };
155
180
  }
package/lib/index.js CHANGED
@@ -9,6 +9,8 @@ import { mcpCall, mcpContentText } from "./mcp_adapter.js";
9
9
  import { registerResearchTool } from "./research.js";
10
10
  import { registerDomainSearchTool } from "./vertical.js";
11
11
  import { createFetchProvider } from "./fetch_provider.js";
12
+ import { fetchWeather, sanitizeWeatherResult, WEATHER_OUTPUT_SCHEMA } from "./weather.js";
13
+ import { setActiveBlockedDomains, getActiveBlockedDomains, isDomainBlocked, applyBlockedFilterToResult } from "./domain_filter.js";
12
14
 
13
15
  // 兼容 guard(#55):Node 会优先解析插件目录下的嵌套依赖,若那里残留旧的
14
16
  // @deepseek-ai/schemastery(如 node_modules/dsh-free-search/node_modules/... -> 3.18.1),
@@ -240,7 +242,7 @@ function routeEngines(query, { timeRange, disabledEngines = [], fallbackOrder =
240
242
  }
241
243
 
242
244
  // 当前插件版本(发布时与 package.json 同步;跟随上游 v0.7.5,不在 PR 里 bump)
243
- const PLUGIN_VERSION = "0.8.5";
245
+ const PLUGIN_VERSION = "0.8.6";
244
246
  // 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
245
247
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
246
248
  const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
@@ -360,10 +362,12 @@ const CACHE_MAX_ENTRIES = 50;
360
362
  // fallback 条目(实际引擎 ≠ 首选引擎时)TTL = 配置 TTL 的 1/5(默认 5 分钟 → 60s):
361
363
  // 首选引擎恢复后最多 1 分钟即可拿到新结果,避免回退结果被完整 TTL 钉死;首选成功条目仍用完整 TTL。
362
364
 
363
- function buildCacheKey(query, maxResults, timeRangeLabel, preferred, disabledEngines = [], fallbackOrder = []) {
365
+ function buildCacheKey(query, maxResults, timeRangeLabel, preferred, disabledEngines = [], fallbackOrder = [], blockedDomains = []) {
364
366
  const disabledKey = normalizeDisabledEngines(disabledEngines).slice().sort().join(",");
365
367
  const orderKey = normalizeFallbackOrder(fallbackOrder).join(",");
366
- return [query ?? "", maxResults ?? 5, timeRangeLabel ?? "", preferred, disabledKey, orderKey].join("\u0000");
368
+ // #72:黑名单参与 key——改动 blockedDomains 后旧缓存自然失效,不会 5 分钟内仍命中旧结果
369
+ const blockedKey = (Array.isArray(blockedDomains) ? blockedDomains : []).map((d) => String(d).toLowerCase().trim().replace(/^www\./, "")).slice().sort().join(",");
370
+ return [query ?? "", maxResults ?? 5, timeRangeLabel ?? "", preferred, disabledKey, orderKey, blockedKey].join("\u0000");
367
371
  }
368
372
  //#endregion
369
373
 
@@ -424,10 +428,14 @@ function extractDdgUrl(rel) {
424
428
  return rel;
425
429
  }
426
430
 
431
+ // #72:收敛前过滤域名黑名单——先剔除被屏蔽条目,再去重 + 截断,
432
+ // 因此后面的候选会自动补位(用户得到的是"满员的好结果",而不是变短的结果)。
427
433
  function uniqueSources(sources, limit) {
434
+ const blocked = getActiveBlockedDomains();
435
+ const kept = blocked.length ? sources.filter((s) => !isDomainBlocked(s.url, blocked)) : sources;
428
436
  const seen = new Set();
429
437
  const out = [];
430
- for (const s of sources) {
438
+ for (const s of kept) {
431
439
  if (s.url && !seen.has(s.url)) {
432
440
  seen.add(s.url);
433
441
  out.push(s);
@@ -569,14 +577,31 @@ function looksRelevant(query, sources) {
569
577
  });
570
578
  }
571
579
 
580
+ // Bing 会把结果 URL 包成 bing.com/ck/a 跳转链接:真实地址在 u 参数(base64url,a1 前缀)
581
+ function extractBingUrl(href) {
582
+ if (!href) return href;
583
+ const url = decodeEntities(href); // &amp; -> &
584
+ if (!/^https?:\/\/(www\.)?bing\.com\/ck\/a/i.test(url)) return href;
585
+ try {
586
+ const u = new URL(url).searchParams.get("u");
587
+ if (!u) return href;
588
+ const b64 = u.startsWith("a1") ? u.slice(2) : u;
589
+ const decoded = Buffer.from(b64.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
590
+ return /^https?:\/\//i.test(decoded) ? decoded : href;
591
+ } catch {
592
+ return href;
593
+ }
594
+ }
595
+
572
596
  async function searchBing(query, maxResults, options, signal) {
573
- // mkt:显式 bingMarket 优先;否则按 lang 映射(zh→zh-CN, ru→ru-RU ...)
597
+ // mkt:仅显式 bingMarket 才发。默认不发——mkt=zh-CN 在部分地区/线路会返回空 SERP 容器
598
+ // (#71:海外 IP + mkt=zh-CN → b_algo=0),Bing 会按 Accept-Language 自行本地化。
574
599
  const profile = LANG_PROFILES[options?.lang] ?? LANG_PROFILES.zh;
575
- const market = options?.bingMarket ?? profile.market;
576
- const params = new URLSearchParams({ q: query, mkt: market });
600
+ const params = new URLSearchParams({ q: query });
601
+ if (options?.bingMarket) params.set("mkt", options.bingMarket);
577
602
  // Accept-Language:显式 bingMarket 时按 market 反推(避免中文 header 污染),否则用 lang 档案
578
603
  const acceptLang = options?.bingMarket
579
- ? (MARKET_TO_LANG[market] ?? ACCEPT_LANG)
604
+ ? (MARKET_TO_LANG[options.bingMarket] ?? ACCEPT_LANG)
580
605
  : (profile.acceptLang ?? ACCEPT_LANG);
581
606
  const adlt = options?.safeSearch ?? "off";
582
607
  if (adlt === "off") params.set("adlt", "off");
@@ -591,7 +616,7 @@ async function searchBing(query, maxResults, options, signal) {
591
616
  const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/);
592
617
  if (!hrefMatch) continue;
593
618
  sources.push({
594
- url: hrefMatch[1],
619
+ url: extractBingUrl(hrefMatch[1]),
595
620
  ...(titleMatch ? { title: stripTags(titleMatch[1]) } : {}),
596
621
  ...(snippetMatch ? { snippet: stripTags(snippetMatch[1]) } : {}),
597
622
  });
@@ -1203,112 +1228,9 @@ async function searchYoutube(query, maxResults, signal) {
1203
1228
  return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
1204
1229
  }
1205
1230
 
1206
- // ---------- Weather(Open-Meteo:geocoding + forecast,全部免 key) ----------
1207
- const OPEN_METEO_GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
1208
- const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
1209
- // WMO weather code → 描述(中文/英文)
1210
- const WMO_CODES = {
1211
- 0: ["晴", "Clear sky"],
1212
- 1: ["大部晴朗", "Mainly clear"],
1213
- 2: ["多云", "Partly cloudy"],
1214
- 3: ["阴", "Overcast"],
1215
- 45: ["雾", "Fog"],
1216
- 48: ["雾凇", "Depositing rime fog"],
1217
- 51: ["毛毛雨(弱)", "Light drizzle"],
1218
- 53: ["毛毛雨", "Moderate drizzle"],
1219
- 55: ["毛毛雨(强)", "Dense drizzle"],
1220
- 56: ["冻毛毛雨(弱)", "Light freezing drizzle"],
1221
- 57: ["冻毛毛雨(强)", "Dense freezing drizzle"],
1222
- 61: ["小雨", "Slight rain"],
1223
- 63: ["中雨", "Moderate rain"],
1224
- 65: ["大雨", "Heavy rain"],
1225
- 66: ["冻雨(弱)", "Light freezing rain"],
1226
- 67: ["冻雨(强)", "Heavy freezing rain"],
1227
- 71: ["小雪", "Slight snow"],
1228
- 73: ["中雪", "Moderate snow"],
1229
- 75: ["大雪", "Heavy snow"],
1230
- 77: ["雪粒", "Snow grains"],
1231
- 80: ["阵雨(弱)", "Slight rain showers"],
1232
- 81: ["阵雨", "Moderate rain showers"],
1233
- 82: ["阵雨(强)", "Violent rain showers"],
1234
- 85: ["阵雪(弱)", "Slight snow showers"],
1235
- 86: ["阵雪(强)", "Heavy snow showers"],
1236
- 95: ["雷暴", "Thunderstorm"],
1237
- 96: ["雷暴伴小冰雹", "Thunderstorm with slight hail"],
1238
- 99: ["雷暴伴大冰雹", "Thunderstorm with heavy hail"],
1239
- };
1240
- function wmoText(code, lang) {
1241
- const hit = WMO_CODES[code];
1242
- if (!hit) return lang === "en" ? "Unknown" : "未知";
1243
- return (lang === "en" ? hit[1] : hit[0]) + ` (WMO ${code})`;
1244
- }
1245
-
1246
- async function geocodeLocation(name, signal) {
1247
- const params = new URLSearchParams({ name, count: "1", language: "zh", format: "json" });
1248
- const response = await fetch(`${OPEN_METEO_GEOCODING_URL}?${params}`, {
1249
- method: "GET",
1250
- redirect: "error",
1251
- headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
1252
- signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(15000)]),
1253
- });
1254
- if (!response.ok) throw new Error(`geocoding API error (HTTP ${response.status})`);
1255
- const data = await response.json();
1256
- const hit = data.results?.[0];
1257
- if (!hit) return null;
1258
- return {
1259
- name: hit.name,
1260
- admin: hit.admin1 ?? "",
1261
- country: hit.country ?? "",
1262
- latitude: hit.latitude,
1263
- longitude: hit.longitude,
1264
- };
1265
- }
1266
-
1267
- async function fetchWeather(location, days, lang, signal) {
1268
- const geo = await geocodeLocation(location, signal);
1269
- if (!geo) throw new Error(`could not geocode location "${location}" — try a nearby larger city`);
1270
- const params = new URLSearchParams({
1271
- latitude: String(geo.latitude),
1272
- longitude: String(geo.longitude),
1273
- current: "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
1274
- daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
1275
- timezone: "auto",
1276
- forecast_days: String(Math.min(Math.max(Number(days) || 3, 1), 7)),
1277
- });
1278
- const response = await fetch(`${OPEN_METEO_FORECAST_URL}?${params}`, {
1279
- method: "GET",
1280
- redirect: "error",
1281
- headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
1282
- signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(15000)]),
1283
- });
1284
- if (!response.ok) throw new Error(`forecast API error (HTTP ${response.status})`);
1285
- const data = await response.json();
1286
- const cur = data.current ?? {};
1287
- const daily = data.daily ?? {};
1288
- return {
1289
- location: [geo.name, geo.admin, geo.country].filter(Boolean).join(", "),
1290
- latitude: geo.latitude,
1291
- longitude: geo.longitude,
1292
- current: {
1293
- temperature: cur.temperature_2m,
1294
- apparent: cur.apparent_temperature,
1295
- humidity: cur.relative_humidity_2m,
1296
- wind: cur.wind_speed_10m,
1297
- code: cur.weather_code,
1298
- text: wmoText(cur.weather_code, lang),
1299
- },
1300
- daily: (daily.time ?? []).map((date, i) => ({
1301
- date,
1302
- min: daily.temperature_2m_min?.[i],
1303
- max: daily.temperature_2m_max?.[i],
1304
- code: daily.weather_code?.[i],
1305
- text: wmoText(daily.weather_code?.[i], lang),
1306
- precipitationProbability: daily.precipitation_probability_max?.[i],
1307
- })),
1308
- timezone: data.timezone ?? "",
1309
- };
1310
- }
1311
-
1231
+ // Weather(Open-Meteo)实现已抽至 lib/weather.js(fetchWeather / WEATHER_OUTPUT_SCHEMA /
1232
+ // sanitizeWeatherResult);契约由 tools/assert-weather-contract.mjs 离线校验(#73)。
1233
+
1312
1234
  // Vimeo:无官方免 key 搜索 API。尽力抓搜索页里内嵌的 /videos/<id> 数据;
1313
1235
  // 抓不到时由调用方退回网页搜索(site:vimeo.com)。
1314
1236
  async function searchVimeo(query, maxResults, signal) {
@@ -3158,6 +3080,8 @@ const Config = z.object({
3158
3080
  disabledEngines: z.array(z.string()).default([]).volatile(),
3159
3081
  fallbackOrder: z.array(z.string()).default([]).volatile(),
3160
3082
  fallbackOn: z.array(z.string()).default(DEFAULT_FALLBACK_ON).volatile(),
3083
+ // #72 域名黑名单(默认空=不过滤;子域匹配;provider.search 入口置活动值,uniqueSources 收敛前过滤)
3084
+ blockedDomains: z.array(z.string()).default([]).volatile(),
3161
3085
  cache: z.boolean().default(true).volatile(), // 单 query 结果缓存开关(防限流/省额度)
3162
3086
  cacheTtl: z.number().default(5).volatile(), // 缓存时长(分钟),0-5 可配置(使用处再 clamp)
3163
3087
  keyStorage: z.string().default("credentials").volatile(), // key 存储位置:credentials(凭据中心)| settings(设置页)
@@ -3405,6 +3329,8 @@ function apply(ctx, config) {
3405
3329
  throw new Error("query is required");
3406
3330
  }
3407
3331
  const cfg = current();
3332
+ // #72:本次请求生效的域名黑名单(模块级活动值;各结果咽喉点过滤读取它)
3333
+ setActiveBlockedDomains(cfg.blockedDomains);
3408
3334
  const disabledEngines = normalizeDisabledEngines(cfg.disabledEngines);
3409
3335
  const fallbackOnSet = new Set(normalizeFallbackOn(cfg.fallbackOn));
3410
3336
  const disabledSet = new Set(disabledEngines);
@@ -3425,7 +3351,7 @@ function apply(ctx, config) {
3425
3351
  const cacheTtlMs = (Math.min(Math.max(Number(cfg.cacheTtl) ?? 5, 0), 5)) * 60 * 1000;
3426
3352
  const cacheEnabled = cfg.cache !== false && cacheTtlMs > 0;
3427
3353
  const cacheKey = cacheEnabled
3428
- ? buildCacheKey(request.query, request.maxResults, timeRangeLabel, preferred, disabledEngines, fallbackOrder)
3354
+ ? buildCacheKey(request.query, request.maxResults, timeRangeLabel, preferred, disabledEngines, fallbackOrder, cfg.blockedDomains)
3429
3355
  : null;
3430
3356
  if (cacheKey !== null) {
3431
3357
  const hit = searchCache.get(cacheKey);
@@ -3433,7 +3359,8 @@ function apply(ctx, config) {
3433
3359
  if (signal?.aborted) throw new Error("search aborted");
3434
3360
  searchCache.delete(cacheKey);
3435
3361
  searchCache.set(cacheKey, hit);
3436
- return { ...hit.value, sources: hit.value.sources?.slice(), _cache: "hit" };
3362
+ // #72:缓存命中同样过一遍黑名单——首版修复前写入的缓存里可能残留屏蔽域名
3363
+ return { ...applyBlockedFilterToResult({ ...hit.value, sources: hit.value.sources?.slice() }), _cache: "hit" };
3437
3364
  }
3438
3365
  if (hit) searchCache.delete(cacheKey);
3439
3366
  }
@@ -3710,6 +3637,12 @@ function apply(ctx, config) {
3710
3637
  continue;
3711
3638
  }
3712
3639
 
3640
+ if (result.sources.length > 0) {
3641
+ // #72 结果级收口:无论走哪个引擎(其中 11 个不经过 uniqueSources),
3642
+ // 在写缓存与返回之前统一按黑名单过滤;全被屏蔽时视为该引擎 0 结果,继续下一个引擎。
3643
+ result = applyBlockedFilterToResult(result);
3644
+ }
3645
+
3713
3646
  if (result.sources.length > 0) {
3714
3647
  usedEngine = engine;
3715
3648
  clearCooldown(engine);
@@ -3879,6 +3812,9 @@ function apply(ctx, config) {
3879
3812
  // 测试工具:让 agent 逐个测试所有搜索引擎,报告可用性
3880
3813
  const runEngineTest = async (engine, query, timeRange, maxResults = 2) => {
3881
3814
  const cfg = current();
3815
+ // #72:runEngineTest 也被 multi_search 工具 / 设置页 bridge 直接调用,
3816
+ // 不经过 provider.search,所以这里必须自行设置活动黑名单
3817
+ setActiveBlockedDomains(cfg.blockedDomains);
3882
3818
  const q = query || "DeepSeek Harness";
3883
3819
  const n = Math.min(Math.max(Number(maxResults) || 2, 1), 20);
3884
3820
  const tr = parseTimeRange(timeRange);
@@ -4013,7 +3949,8 @@ function apply(ctx, config) {
4013
3949
  }
4014
3950
  return {
4015
3951
  ok: true,
4016
- sources: (result.sources ?? []).map((s) =>
3952
+ // #72:成功结果出引擎前统一过滤,覆盖不经过 uniqueSources 的 11 个引擎
3953
+ sources: applyBlockedFilterToResult({ sources: result.sources ?? [] }).sources.map((s) =>
4017
3954
  s.snippet ? { ...s, snippet: cleanSnippet(s.snippet, snippetCap(engine)) } : s
4018
3955
  ),
4019
3956
  truncated: result.truncated ?? false,
@@ -4027,6 +3964,8 @@ function apply(ctx, config) {
4027
3964
 
4028
3965
  runMultiSearch = async ({ query, maxResults, engines, timeRange } = {}) => {
4029
3966
  if (!query || !String(query).trim()) throw new Error("query is required");
3967
+ // #72:multi_search 工具直连本函数、不经过 provider.search,自行设置活动黑名单
3968
+ setActiveBlockedDomains(current().blockedDomains);
4030
3969
  const q = String(query).trim();
4031
3970
  const limit = Math.min(Math.max(Number(maxResults) || 8, 1), 20);
4032
3971
  const disabled = new Set(normalizeDisabledEngines(current().disabledEngines));
@@ -4091,8 +4030,13 @@ function apply(ctx, config) {
4091
4030
 
4092
4031
  const urlMap = new Map();
4093
4032
  let itemSeq = 0;
4033
+ // #72:合并前过滤——引擎里未被 uniqueSources 覆盖的结果(如 anysearch)也在这里被挡住
4034
+ const blockedRaw = getActiveBlockedDomains();
4094
4035
  for (const { engine, sources } of rawResults) {
4095
- for (const source of sources) {
4036
+ const keptSources = blockedRaw.length
4037
+ ? sources.filter((s) => !isDomainBlocked(s?.url, blockedRaw))
4038
+ : sources;
4039
+ for (const source of keptSources) {
4096
4040
  if (!source || !source.url) continue;
4097
4041
  let normUrl = String(source.url).trim();
4098
4042
  try {
@@ -4354,7 +4298,8 @@ function apply(ctx, config) {
4354
4298
  }
4355
4299
  }
4356
4300
  // lossless JSON 不允许 undefined 字段:剔除缺失字段
4357
- const sources = (result.sources ?? []).map((s) => {
4301
+ // #72:platform_search 直连 searchPlatform(不经 provider.search/runEngineTest),在此收口过滤
4302
+ const sources = applyBlockedFilterToResult({ sources: result.sources ?? [] }).sources.map((s) => {
4358
4303
  const source = {};
4359
4304
  if (s.url !== undefined && s.url !== null && s.url !== "") source.url = s.url;
4360
4305
  if (s.title !== undefined && s.title !== null && s.title !== "") source.title = String(s.title);
@@ -4477,38 +4422,7 @@ function apply(ctx, config) {
4477
4422
  days: { type: "number", description: "Forecast days (1-7, default 3)." },
4478
4423
  },
4479
4424
  output: {
4480
- schema: {
4481
- type: "object",
4482
- additionalProperties: false,
4483
- properties: {
4484
- location: { type: "string" },
4485
- current: {
4486
- type: "object",
4487
- additionalProperties: false,
4488
- properties: {
4489
- temperature: { type: "number" },
4490
- apparent: { type: "number" },
4491
- humidity: { type: "number" },
4492
- wind: { type: "number" },
4493
- text: { type: "string" },
4494
- },
4495
- },
4496
- daily: {
4497
- type: "array",
4498
- items: {
4499
- type: "object",
4500
- additionalProperties: false,
4501
- properties: {
4502
- date: { type: "string" },
4503
- min: { type: "number" },
4504
- max: { type: "number" },
4505
- text: { type: "string" },
4506
- precipitationProbability: { type: "number" },
4507
- },
4508
- },
4509
- },
4510
- },
4511
- },
4425
+ schema: WEATHER_OUTPUT_SCHEMA,
4512
4426
  render(args, value) {
4513
4427
  const lang = current().lang === "en" ? "en" : "zh";
4514
4428
  const c = value.current ?? {};
@@ -4533,7 +4447,7 @@ function apply(ctx, config) {
4533
4447
  const lang = current().lang === "en" ? "en" : "zh";
4534
4448
  const location = String(args.location ?? "").trim();
4535
4449
  if (!location) throw new Error("weather: location is required");
4536
- return await fetchWeather(location, args.days, lang, undefined);
4450
+ return sanitizeWeatherResult(await fetchWeather(location, args.days, lang, undefined));
4537
4451
  },
4538
4452
  finalizeContent(exec, result) {
4539
4453
  const text = result.content;
package/lib/research.js CHANGED
@@ -7,6 +7,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
7
7
  import { getFresh, put as cachePut, search as cacheSearch } from "./cache.js";
8
8
  import { rerank, embed, cosineMatrix, decide, judgeQueryIntent } from "./rerank.js";
9
9
  import { httpsJson, htmlToText, callExa, callFirecrawl, callCrawl4ai } from "./fetch_upstream.js";
10
+ import { isDomainBlocked } from "./domain_filter.js";
10
11
 
11
12
  // 模型组件的受控替身(测试/评审用;抓取层的替身在 fetch_upstream.js 的 CONTROLLED_STUBS)
12
13
  export const CONTROLLED_STUBS = { rerank: null, embed: null, decide: null, judgeQueryIntent: null };
@@ -227,6 +228,8 @@ export function buildClusters(sources) {
227
228
  let clusterSeq = 0;
228
229
  for (const s of sources) {
229
230
  if (!s || typeof s.url !== "string") continue;
231
+ // #72:黑名单域在候选构建阶段就剔除——items / 簇 / 正文三层都不会再出现它
232
+ if (isDomainBlocked(s.url)) continue;
230
233
  const key = normDedupeKey(s.url);
231
234
  if (seenKeys.has(key)) continue; // 同键(www/m/mobile 归一)只留首个原始 URL
232
235
  seenKeys.add(key);
@@ -649,21 +652,36 @@ export function canUseCache(fetchedAtMs, freshness, nowMs = Date.now()) {
649
652
  return [true, ""];
650
653
  }
651
654
 
652
- /** T1 平台 fetch(宿主第二参数支持 signal;deadline abort 在途请求)。 */
655
+ /** T1 平台 fetch(宿主签名 fetch(request, signal?),第二参必须是 AbortSignal 本体——
656
+ * 传 { signal } 包装对象会一路传到 https.request 抛 TypeError,浏览器层也会缺 addEventListener)。 */
653
657
  async function platformFetch(ctx, url, signal) {
654
- try {
655
- return await ctx.web.fetch({ url }, signal ? { signal } : undefined);
656
- } catch (e) {
657
- if (signal && !signal.aborted && e instanceof TypeError && /argument|parameters/i.test(String(e?.message))) {
658
- return await ctx.web.fetch({ url }); // 老宿主不认第二参数:退回单参调用
659
- }
660
- throw e;
658
+ return await ctx.web.fetch({ url }, signal);
659
+ }
660
+
661
+ // 从 HTML 提取页面更新时间:article:modified_time / og:updated_time / JSON-LD dateModified / <time datetime>,
662
+ // 取不到返回空串。
663
+ function parseUpdatedFromHtml(html) {
664
+ const s = String(html ?? "");
665
+ const patterns = [
666
+ /<meta[^>]+property=["']article:modified_time["'][^>]+content=["']([^"']+)["']/i,
667
+ /<meta[^>]+content=["']([^"']+)["'][^>]+property=["']article:modified_time["']/i,
668
+ /<meta[^>]+property=["']og:updated_time["'][^>]+content=["']([^"']+)["']/i,
669
+ /<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:updated_time["']/i,
670
+ /"dateModified"\s*:\s*"([^"]+)"/i,
671
+ /<time[^>]+datetime=["']([^"']+)["']/i,
672
+ ];
673
+ for (const re of patterns) {
674
+ const m = s.match(re);
675
+ if (m && m[1]) return m[1];
661
676
  }
677
+ return "";
662
678
  }
663
679
 
664
680
  /** 单 URL 抓取(含缓存两分与四层回退;每次网络尝试都过预算 gate)。成功返回:
665
681
  * { content, tier, truncated, fetchedAt(ISO,缓存命中=真实原抓时间), updatedAt(可取得时), cache } */
666
682
  async function fetchTiered(ctx, url, title, state, budget, intent) {
683
+ // #72:黑名单站点直接不抓(缓存也不读);不算失败,不写 note——这是用户主动配置的过滤
684
+ if (isDomainBlocked(url)) return null;
667
685
  const now = Date.now();
668
686
  const cached = getFresh(url, DOC_CACHE_MAX_AGE_MS);
669
687
  if (cached && (intent.freshness === "none" || canUseCache(cached.fetched_at, intent.freshness, now)[0])) {
package/lib/weather.js ADDED
@@ -0,0 +1,177 @@
1
+ // Weather(Open-Meteo:geocoding + forecast,全部免 key)
2
+ // 与 lib/index.js 的 weather 工具配套。这里同时导出 output schema 与 sanitizeWeatherResult(),
3
+ // 使 tools/assert-weather-contract.mjs 能离线校验「返回值 ↔ schema」逐字段对齐——
4
+ // 0.8.4/0.8.5 曾因 schema 漏声明 latitude/longitude/timezone/current.code/daily[].code
5
+ // 导致 additionalProperties:false 校验失败、工具 100% 不可用(issue #73)。
6
+
7
+ const OPEN_METEO_GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
8
+ const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
9
+
10
+ // WMO weather code → 描述(中文/英文)
11
+ const WMO_CODES = {
12
+ 0: ["晴", "Clear sky"],
13
+ 1: ["大部晴朗", "Mainly clear"],
14
+ 2: ["多云", "Partly cloudy"],
15
+ 3: ["阴", "Overcast"],
16
+ 45: ["雾", "Fog"],
17
+ 48: ["雾凇", "Depositing rime fog"],
18
+ 51: ["毛毛雨(弱)", "Light drizzle"],
19
+ 53: ["毛毛雨", "Moderate drizzle"],
20
+ 55: ["毛毛雨(强)", "Dense drizzle"],
21
+ 56: ["冻毛毛雨(弱)", "Light freezing drizzle"],
22
+ 57: ["冻毛毛雨(强)", "Dense freezing drizzle"],
23
+ 61: ["小雨", "Slight rain"],
24
+ 63: ["中雨", "Moderate rain"],
25
+ 65: ["大雨", "Heavy rain"],
26
+ 66: ["冻雨(弱)", "Light freezing rain"],
27
+ 67: ["冻雨(强)", "Heavy freezing rain"],
28
+ 71: ["小雪", "Slight snow"],
29
+ 73: ["中雪", "Moderate snow"],
30
+ 75: ["大雪", "Heavy snow"],
31
+ 77: ["雪粒", "Snow grains"],
32
+ 80: ["阵雨(弱)", "Slight rain showers"],
33
+ 81: ["阵雨", "Moderate rain showers"],
34
+ 82: ["阵雨(强)", "Violent rain showers"],
35
+ 85: ["阵雪(弱)", "Slight snow showers"],
36
+ 86: ["阵雪(强)", "Heavy snow showers"],
37
+ 95: ["雷暴", "Thunderstorm"],
38
+ 96: ["雷暴伴小冰雹", "Thunderstorm with slight hail"],
39
+ 99: ["雷暴伴大冰雹", "Thunderstorm with heavy hail"],
40
+ };
41
+ function wmoText(code, lang) {
42
+ const hit = WMO_CODES[code];
43
+ if (!hit) return lang === "en" ? "Unknown" : "未知";
44
+ return (lang === "en" ? hit[1] : hit[0]) + ` (WMO ${code})`;
45
+ }
46
+
47
+ export async function geocodeLocation(name, signal) {
48
+ const params = new URLSearchParams({ name, count: "1", language: "zh", format: "json" });
49
+ const response = await fetch(`${OPEN_METEO_GEOCODING_URL}?${params}`, {
50
+ method: "GET",
51
+ redirect: "error",
52
+ headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
53
+ signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(15000)]),
54
+ });
55
+ if (!response.ok) throw new Error(`geocoding API error (HTTP ${response.status})`);
56
+ const data = await response.json();
57
+ const hit = data.results?.[0];
58
+ if (!hit) return null;
59
+ return {
60
+ name: hit.name,
61
+ admin: hit.admin1 ?? "",
62
+ country: hit.country ?? "",
63
+ latitude: hit.latitude,
64
+ longitude: hit.longitude,
65
+ };
66
+ }
67
+
68
+ export async function fetchWeather(location, days, lang, signal) {
69
+ const geo = await geocodeLocation(location, signal);
70
+ if (!geo) throw new Error(`could not geocode location "${location}" — try a nearby larger city`);
71
+ const params = new URLSearchParams({
72
+ latitude: String(geo.latitude),
73
+ longitude: String(geo.longitude),
74
+ current: "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
75
+ daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
76
+ timezone: "auto",
77
+ forecast_days: String(Math.min(Math.max(Number(days) || 3, 1), 7)),
78
+ });
79
+ const response = await fetch(`${OPEN_METEO_FORECAST_URL}?${params}`, {
80
+ method: "GET",
81
+ redirect: "error",
82
+ headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
83
+ signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(15000)]),
84
+ });
85
+ if (!response.ok) throw new Error(`forecast API error (HTTP ${response.status})`);
86
+ const data = await response.json();
87
+ const cur = data.current ?? {};
88
+ const daily = data.daily ?? {};
89
+ return {
90
+ location: [geo.name, geo.admin, geo.country].filter(Boolean).join(", "),
91
+ latitude: geo.latitude,
92
+ longitude: geo.longitude,
93
+ current: {
94
+ temperature: cur.temperature_2m,
95
+ apparent: cur.apparent_temperature,
96
+ humidity: cur.relative_humidity_2m,
97
+ wind: cur.wind_speed_10m,
98
+ code: cur.weather_code,
99
+ text: wmoText(cur.weather_code, lang),
100
+ },
101
+ daily: (daily.time ?? []).map((date, i) => ({
102
+ date,
103
+ min: daily.temperature_2m_min?.[i],
104
+ max: daily.temperature_2m_max?.[i],
105
+ code: daily.weather_code?.[i],
106
+ text: wmoText(daily.weather_code?.[i], lang),
107
+ precipitationProbability: daily.precipitation_probability_max?.[i],
108
+ })),
109
+ timezone: data.timezone ?? "",
110
+ };
111
+ }
112
+
113
+ // weather 工具的 output schema。与 fetchWeather 返回值逐字段对齐:
114
+ // 任何一边增删字段都必须同步,否则 additionalProperties:false 会让工具调用失败(#73)。
115
+ export const WEATHER_OUTPUT_SCHEMA = {
116
+ type: "object",
117
+ additionalProperties: false,
118
+ properties: {
119
+ location: { type: "string" },
120
+ latitude: { type: "number" },
121
+ longitude: { type: "number" },
122
+ timezone: { type: "string" },
123
+ current: {
124
+ type: "object",
125
+ additionalProperties: false,
126
+ properties: {
127
+ temperature: { type: "number" },
128
+ apparent: { type: "number" },
129
+ humidity: { type: "number" },
130
+ wind: { type: "number" },
131
+ code: { type: "number" },
132
+ text: { type: "string" },
133
+ },
134
+ },
135
+ daily: {
136
+ type: "array",
137
+ items: {
138
+ type: "object",
139
+ additionalProperties: false,
140
+ properties: {
141
+ date: { type: "string" },
142
+ min: { type: "number" },
143
+ max: { type: "number" },
144
+ code: { type: "number" },
145
+ text: { type: "string" },
146
+ precipitationProbability: { type: "number" },
147
+ },
148
+ },
149
+ },
150
+ },
151
+ };
152
+
153
+ // lossless JSON 不允许 undefined 字段:按"存在且有值"构造对象(与其它工具的做法一致)。
154
+ export function sanitizeWeatherResult(r) {
155
+ return {
156
+ location: r.location,
157
+ ...(r.latitude !== undefined ? { latitude: r.latitude } : {}),
158
+ ...(r.longitude !== undefined ? { longitude: r.longitude } : {}),
159
+ ...(r.timezone ? { timezone: r.timezone } : {}),
160
+ current: {
161
+ ...(r.current?.temperature !== undefined ? { temperature: r.current.temperature } : {}),
162
+ ...(r.current?.apparent !== undefined ? { apparent: r.current.apparent } : {}),
163
+ ...(r.current?.humidity !== undefined ? { humidity: r.current.humidity } : {}),
164
+ ...(r.current?.wind !== undefined ? { wind: r.current.wind } : {}),
165
+ ...(r.current?.code !== undefined ? { code: r.current.code } : {}),
166
+ ...(r.current?.text ? { text: r.current.text } : {}),
167
+ },
168
+ daily: (r.daily ?? []).map((d) => ({
169
+ date: d.date,
170
+ ...(d.min !== undefined ? { min: d.min } : {}),
171
+ ...(d.max !== undefined ? { max: d.max } : {}),
172
+ ...(d.code !== undefined ? { code: d.code } : {}),
173
+ ...(d.text ? { text: d.text } : {}),
174
+ ...(d.precipitationProbability !== undefined ? { precipitationProbability: d.precipitationProbability } : {}),
175
+ })),
176
+ };
177
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-free-search",
3
- "version": "0.8.5",
4
- "description": "Free web search for DeepSeek Harness: 24 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; Zhihu global/site via MCP and Perplexity/SerpBase/DeepSeek/You.com/Baidu/Kimi/Aliyun/Doubao with key; OpenAI/Gemini/Claude model built-in search, explicit-only) + auto smart routing + global engine enable/disable & fallback priority + editable zh/en language pools + multi mode (parallel full language-pool merge) + multi_search cross-source tool + failure-aware fallback (quota/auth cooldown, transient retry, fallbackOn) + time filtering + platform search + web_fetch, with web settings UI.",
3
+ "version": "0.8.7",
4
+ "description": "Free web search for DeepSeek Harness: 24 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; Zhihu global/site via MCP and Perplexity/SerpBase/DeepSeek/You.com/Baidu/Kimi/Aliyun/Doubao with key; OpenAI/Gemini/Claude model built-in search, explicit-only) + auto smart routing + global engine enable/disable & fallback priority + editable zh/en language pools + multi mode (parallel full language-pool merge) + multi_search cross-source tool + failure-aware fallback (quota/auth cooldown, transient retry, fallbackOn) + time filtering + platform search + domain blocklist (blockedDomains) + web_fetch, with web settings UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -19,6 +19,8 @@
19
19
  "lib/vertical.js",
20
20
  "lib/fetch_provider.js",
21
21
  "lib/fetch_upstream.js",
22
+ "lib/weather.js",
23
+ "lib/domain_filter.js",
22
24
  "cordis.patch.yml"
23
25
  ],
24
26
  "keywords": [