dsh-free-search 0.8.4 → 0.8.6
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 +1 -1
- package/lib/fetch_provider.js +141 -4
- package/lib/index.js +29 -145
- package/lib/research.js +21 -8
- package/lib/weather.js +177 -0
- package/package.json +2 -1
package/lib/client.js
CHANGED
|
@@ -355,7 +355,7 @@ window.__ModuleLoader__.load({
|
|
|
355
355
|
};
|
|
356
356
|
const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
|
|
357
357
|
// 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致;跟随上游 v0.7.5)
|
|
358
|
-
const PLUGIN_VERSION = "0.8.
|
|
358
|
+
const PLUGIN_VERSION = "0.8.6";
|
|
359
359
|
const ENGINES = [
|
|
360
360
|
{ id: "auto", label: "Auto · 智能路由", badge: "AUTO", link: null },
|
|
361
361
|
{ id: "multi", label: "Multi Search · 多引擎", badge: "MULTI", link: null },
|
package/lib/fetch_provider.js
CHANGED
|
@@ -6,12 +6,110 @@
|
|
|
6
6
|
// ponytail: 无服务级令牌桶——web_fetch 为单发用户/agent 驱动调用,非扇出;若未来批量调用再引入共享调度。
|
|
7
7
|
|
|
8
8
|
import https from "node:https";
|
|
9
|
+
import zlib from "node:zlib";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
9
14
|
import { callExa, callFirecrawl, callCrawl4ai } from "./fetch_upstream.js";
|
|
10
15
|
|
|
11
16
|
const T1_MIN_CHARS = 300;
|
|
12
17
|
const TEXTUAL = /text\/|json|xml|javascript|application\/json/i;
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
// ---------- T1.5:系统浏览器无头渲染(执行 JS 过挑战;无浏览器/失败/超时则静默跳过) ----------
|
|
20
|
+
const BROWSER_CANDIDATES = [
|
|
21
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
22
|
+
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
23
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
24
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
25
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
26
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
27
|
+
];
|
|
28
|
+
let cachedBrowserPath; // undefined = 未检测;null = 无浏览器
|
|
29
|
+
function findSystemBrowser() {
|
|
30
|
+
if (cachedBrowserPath !== undefined) return cachedBrowserPath;
|
|
31
|
+
cachedBrowserPath = null;
|
|
32
|
+
for (const p of BROWSER_CANDIDATES) {
|
|
33
|
+
try {
|
|
34
|
+
if (existsSync(p)) {
|
|
35
|
+
cachedBrowserPath = p;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// 忽略检测错误
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return cachedBrowserPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function browserDumpDom(browserPath, url, { timeoutMs = 25000, signal } = {}) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
// 独立 user-data-dir:避免与用户正在运行的浏览器实例抢同一 profile(共享 profile 时无输出/立即退出)
|
|
48
|
+
let tmpDir;
|
|
49
|
+
let child;
|
|
50
|
+
try {
|
|
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 });
|
|
68
|
+
} catch {
|
|
69
|
+
if (tmpDir) try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
|
|
70
|
+
resolve(null);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let out = "";
|
|
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
|
+
};
|
|
83
|
+
const finish = (ok) => {
|
|
84
|
+
if (done) return;
|
|
85
|
+
done = true;
|
|
86
|
+
killTree();
|
|
87
|
+
if (tmpDir) {
|
|
88
|
+
// 浏览器进程退出后才能删 profile 目录;延迟清理,失败无所谓
|
|
89
|
+
setTimeout(() => { try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 忽略 */ } }, 500);
|
|
90
|
+
}
|
|
91
|
+
resolve(ok && out.length > 0 ? out : null);
|
|
92
|
+
};
|
|
93
|
+
child.stdout.on("data", (c) => {
|
|
94
|
+
if (out.length < 5 * 1024 * 1024) out += c.toString("utf8");
|
|
95
|
+
});
|
|
96
|
+
// stderr 必须 pipe 且读取丢弃:Windows 上 Edge 无头模式 stderr 指向 NUL 会卡死(实测 exit=1/0 输出)
|
|
97
|
+
child.stderr.on("data", () => {});
|
|
98
|
+
child.on("error", () => finish(false));
|
|
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
|
+
});
|
|
104
|
+
setTimeout(() => finish(false), timeoutMs);
|
|
105
|
+
if (signal) {
|
|
106
|
+
if (signal.aborted) finish(false);
|
|
107
|
+
else signal.addEventListener("abort", () => finish(false), { once: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** GET 跟随重定向(≤5 跳),返回最终 URL / 状态 / 正文 / 内容类型;支持 gzip/br/deflate 解压。 */
|
|
15
113
|
function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}) {
|
|
16
114
|
return new Promise((resolve, reject) => {
|
|
17
115
|
const hop = (current, left) => {
|
|
@@ -24,9 +122,19 @@ function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}) {
|
|
|
24
122
|
const next = new URL(res.headers.location, current).toString();
|
|
25
123
|
return hop(next, left - 1);
|
|
26
124
|
}
|
|
125
|
+
const enc = String(res.headers["content-encoding"] || "").toLowerCase();
|
|
126
|
+
let stream = res;
|
|
127
|
+
try {
|
|
128
|
+
if (enc.includes("br")) stream = res.pipe(zlib.createBrotliDecompress());
|
|
129
|
+
else if (enc.includes("gzip")) stream = res.pipe(zlib.createGunzip());
|
|
130
|
+
else if (enc.includes("deflate")) stream = res.pipe(zlib.createInflate());
|
|
131
|
+
} catch {
|
|
132
|
+
stream = res;
|
|
133
|
+
}
|
|
27
134
|
const chunks = [];
|
|
28
|
-
|
|
29
|
-
|
|
135
|
+
stream.on("data", (c) => chunks.push(c));
|
|
136
|
+
stream.on("end", () => resolve({ url: current, status: res.statusCode, text: Buffer.concat(chunks).toString("utf8"), contentType: res.headers["content-type"] || "" }));
|
|
137
|
+
stream.on("error", reject);
|
|
30
138
|
}
|
|
31
139
|
);
|
|
32
140
|
req.on("timeout", () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
|
|
@@ -37,7 +145,22 @@ function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}) {
|
|
|
37
145
|
});
|
|
38
146
|
}
|
|
39
147
|
|
|
40
|
-
|
|
148
|
+
// 完整浏览器请求头(Chrome 128 形态):部分反爬站(如百度系)对精简头直接 403,完整头可通过。
|
|
149
|
+
// 注意:声明了 Accept-Encoding 就必须解压(httpsGetFollow 已支持 gzip/br/deflate)。
|
|
150
|
+
const BROWSERISH = {
|
|
151
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
|
152
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
153
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
154
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
155
|
+
"sec-ch-ua": '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"',
|
|
156
|
+
"sec-ch-ua-mobile": "?0",
|
|
157
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
158
|
+
"Sec-Fetch-Dest": "document",
|
|
159
|
+
"Sec-Fetch-Mode": "navigate",
|
|
160
|
+
"Sec-Fetch-Site": "none",
|
|
161
|
+
"Sec-Fetch-User": "?1",
|
|
162
|
+
"Upgrade-Insecure-Requests": "1",
|
|
163
|
+
};
|
|
41
164
|
|
|
42
165
|
export function createFetchProvider({ resolveApiKey, logger }) {
|
|
43
166
|
return {
|
|
@@ -59,6 +182,20 @@ export function createFetchProvider({ resolveApiKey, logger }) {
|
|
|
59
182
|
else errors.push(`direct: ${r.contentType || "unknown type"} ${r.text.length}B`);
|
|
60
183
|
} catch (e) { errors.push(`direct: ${e.message}`); }
|
|
61
184
|
|
|
185
|
+
// T1.5:系统浏览器无头渲染(执行 JS 过挑战,抓直连拿不到的页面;无浏览器自动跳过)
|
|
186
|
+
const browserPath = findSystemBrowser();
|
|
187
|
+
if (browserPath) {
|
|
188
|
+
try {
|
|
189
|
+
const html = await browserDumpDom(browserPath, url, { signal });
|
|
190
|
+
if (html && html.length >= T1_MIN_CHARS && !/403 Forbidden|\bcaptcha\b/i.test(html.slice(0, 3000))) {
|
|
191
|
+
return { url, statusCode: 200, body: { kind: "html", content: html }, truncated: false };
|
|
192
|
+
}
|
|
193
|
+
errors.push(html ? `browser: short or challenge page (${html.length}B)` : "browser: no output (timeout or launch failure)");
|
|
194
|
+
} catch (e) {
|
|
195
|
+
errors.push(`browser: ${e.message}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
62
199
|
// T2:exa contents($1/1000 页)——直连失败(反爬/JS/过短)时承接;firecrawl 额度保护:仅在本层失败后才走 T3
|
|
63
200
|
const exaKey = await resolveApiKey("EXA_API_KEY", "exaApiKey").catch(() => undefined);
|
|
64
201
|
if (exaKey) {
|
package/lib/index.js
CHANGED
|
@@ -9,6 +9,7 @@ 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";
|
|
12
13
|
|
|
13
14
|
// 兼容 guard(#55):Node 会优先解析插件目录下的嵌套依赖,若那里残留旧的
|
|
14
15
|
// @deepseek-ai/schemastery(如 node_modules/dsh-free-search/node_modules/... -> 3.18.1),
|
|
@@ -240,7 +241,7 @@ function routeEngines(query, { timeRange, disabledEngines = [], fallbackOrder =
|
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
// 当前插件版本(发布时与 package.json 同步;跟随上游 v0.7.5,不在 PR 里 bump)
|
|
243
|
-
const PLUGIN_VERSION = "0.8.
|
|
244
|
+
const PLUGIN_VERSION = "0.8.6";
|
|
244
245
|
// 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
|
|
245
246
|
const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
|
|
246
247
|
const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
|
|
@@ -569,14 +570,31 @@ function looksRelevant(query, sources) {
|
|
|
569
570
|
});
|
|
570
571
|
}
|
|
571
572
|
|
|
573
|
+
// Bing 会把结果 URL 包成 bing.com/ck/a 跳转链接:真实地址在 u 参数(base64url,a1 前缀)
|
|
574
|
+
function extractBingUrl(href) {
|
|
575
|
+
if (!href) return href;
|
|
576
|
+
const url = decodeEntities(href); // & -> &
|
|
577
|
+
if (!/^https?:\/\/(www\.)?bing\.com\/ck\/a/i.test(url)) return href;
|
|
578
|
+
try {
|
|
579
|
+
const u = new URL(url).searchParams.get("u");
|
|
580
|
+
if (!u) return href;
|
|
581
|
+
const b64 = u.startsWith("a1") ? u.slice(2) : u;
|
|
582
|
+
const decoded = Buffer.from(b64.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
583
|
+
return /^https?:\/\//i.test(decoded) ? decoded : href;
|
|
584
|
+
} catch {
|
|
585
|
+
return href;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
572
589
|
async function searchBing(query, maxResults, options, signal) {
|
|
573
|
-
// mkt
|
|
590
|
+
// mkt:仅显式 bingMarket 才发。默认不发——mkt=zh-CN 在部分地区/线路会返回空 SERP 容器
|
|
591
|
+
// (#71:海外 IP + mkt=zh-CN → b_algo=0),Bing 会按 Accept-Language 自行本地化。
|
|
574
592
|
const profile = LANG_PROFILES[options?.lang] ?? LANG_PROFILES.zh;
|
|
575
|
-
const
|
|
576
|
-
|
|
593
|
+
const params = new URLSearchParams({ q: query });
|
|
594
|
+
if (options?.bingMarket) params.set("mkt", options.bingMarket);
|
|
577
595
|
// Accept-Language:显式 bingMarket 时按 market 反推(避免中文 header 污染),否则用 lang 档案
|
|
578
596
|
const acceptLang = options?.bingMarket
|
|
579
|
-
? (MARKET_TO_LANG[
|
|
597
|
+
? (MARKET_TO_LANG[options.bingMarket] ?? ACCEPT_LANG)
|
|
580
598
|
: (profile.acceptLang ?? ACCEPT_LANG);
|
|
581
599
|
const adlt = options?.safeSearch ?? "off";
|
|
582
600
|
if (adlt === "off") params.set("adlt", "off");
|
|
@@ -591,7 +609,7 @@ async function searchBing(query, maxResults, options, signal) {
|
|
|
591
609
|
const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/);
|
|
592
610
|
if (!hrefMatch) continue;
|
|
593
611
|
sources.push({
|
|
594
|
-
url: hrefMatch[1],
|
|
612
|
+
url: extractBingUrl(hrefMatch[1]),
|
|
595
613
|
...(titleMatch ? { title: stripTags(titleMatch[1]) } : {}),
|
|
596
614
|
...(snippetMatch ? { snippet: stripTags(snippetMatch[1]) } : {}),
|
|
597
615
|
});
|
|
@@ -1203,112 +1221,9 @@ async function searchYoutube(query, maxResults, signal) {
|
|
|
1203
1221
|
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
1204
1222
|
}
|
|
1205
1223
|
|
|
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
|
-
|
|
1224
|
+
// Weather(Open-Meteo)实现已抽至 lib/weather.js(fetchWeather / WEATHER_OUTPUT_SCHEMA /
|
|
1225
|
+
// sanitizeWeatherResult);契约由 tools/assert-weather-contract.mjs 离线校验(#73)。
|
|
1226
|
+
|
|
1312
1227
|
// Vimeo:无官方免 key 搜索 API。尽力抓搜索页里内嵌的 /videos/<id> 数据;
|
|
1313
1228
|
// 抓不到时由调用方退回网页搜索(site:vimeo.com)。
|
|
1314
1229
|
async function searchVimeo(query, maxResults, signal) {
|
|
@@ -4477,38 +4392,7 @@ function apply(ctx, config) {
|
|
|
4477
4392
|
days: { type: "number", description: "Forecast days (1-7, default 3)." },
|
|
4478
4393
|
},
|
|
4479
4394
|
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
|
-
},
|
|
4395
|
+
schema: WEATHER_OUTPUT_SCHEMA,
|
|
4512
4396
|
render(args, value) {
|
|
4513
4397
|
const lang = current().lang === "en" ? "en" : "zh";
|
|
4514
4398
|
const c = value.current ?? {};
|
|
@@ -4533,7 +4417,7 @@ function apply(ctx, config) {
|
|
|
4533
4417
|
const lang = current().lang === "en" ? "en" : "zh";
|
|
4534
4418
|
const location = String(args.location ?? "").trim();
|
|
4535
4419
|
if (!location) throw new Error("weather: location is required");
|
|
4536
|
-
return await fetchWeather(location, args.days, lang, undefined);
|
|
4420
|
+
return sanitizeWeatherResult(await fetchWeather(location, args.days, lang, undefined));
|
|
4537
4421
|
},
|
|
4538
4422
|
finalizeContent(exec, result) {
|
|
4539
4423
|
const text = result.content;
|
package/lib/research.js
CHANGED
|
@@ -649,16 +649,29 @@ export function canUseCache(fetchedAtMs, freshness, nowMs = Date.now()) {
|
|
|
649
649
|
return [true, ""];
|
|
650
650
|
}
|
|
651
651
|
|
|
652
|
-
/** T1 平台 fetch
|
|
652
|
+
/** T1 平台 fetch(宿主签名 fetch(request, signal?),第二参必须是 AbortSignal 本体——
|
|
653
|
+
* 传 { signal } 包装对象会一路传到 https.request 抛 TypeError,浏览器层也会缺 addEventListener)。 */
|
|
653
654
|
async function platformFetch(ctx, url, signal) {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
655
|
+
return await ctx.web.fetch({ url }, signal);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 从 HTML 提取页面更新时间:article:modified_time / og:updated_time / JSON-LD dateModified / <time datetime>,
|
|
659
|
+
// 取不到返回空串。
|
|
660
|
+
function parseUpdatedFromHtml(html) {
|
|
661
|
+
const s = String(html ?? "");
|
|
662
|
+
const patterns = [
|
|
663
|
+
/<meta[^>]+property=["']article:modified_time["'][^>]+content=["']([^"']+)["']/i,
|
|
664
|
+
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']article:modified_time["']/i,
|
|
665
|
+
/<meta[^>]+property=["']og:updated_time["'][^>]+content=["']([^"']+)["']/i,
|
|
666
|
+
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:updated_time["']/i,
|
|
667
|
+
/"dateModified"\s*:\s*"([^"]+)"/i,
|
|
668
|
+
/<time[^>]+datetime=["']([^"']+)["']/i,
|
|
669
|
+
];
|
|
670
|
+
for (const re of patterns) {
|
|
671
|
+
const m = s.match(re);
|
|
672
|
+
if (m && m[1]) return m[1];
|
|
661
673
|
}
|
|
674
|
+
return "";
|
|
662
675
|
}
|
|
663
676
|
|
|
664
677
|
/** 单 URL 抓取(含缓存两分与四层回退;每次网络尝试都过预算 gate)。成功返回:
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-free-search",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"lib/vertical.js",
|
|
20
20
|
"lib/fetch_provider.js",
|
|
21
21
|
"lib/fetch_upstream.js",
|
|
22
|
+
"lib/weather.js",
|
|
22
23
|
"cordis.patch.yml"
|
|
23
24
|
],
|
|
24
25
|
"keywords": [
|