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 +94 -2
- package/lib/domain_filter.js +100 -0
- package/lib/fetch_provider.js +44 -19
- package/lib/index.js +67 -153
- package/lib/research.js +26 -8
- package/lib/weather.js +177 -0
- package/package.json +4 -2
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.
|
|
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
|
+
}
|
package/lib/fetch_provider.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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); // & -> &
|
|
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
|
|
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
|
|
576
|
-
|
|
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[
|
|
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
|
-
//
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
655
|
+
/** T1 平台 fetch(宿主签名 fetch(request, signal?),第二参必须是 AbortSignal 本体——
|
|
656
|
+
* 传 { signal } 包装对象会一路传到 https.request 抛 TypeError,浏览器层也会缺 addEventListener)。 */
|
|
653
657
|
async function platformFetch(ctx, url, signal) {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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.
|
|
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": [
|