dsh-free-search 0.7.5 → 0.8.0
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/cache.js +49 -0
- package/lib/client.js +161 -4
- package/lib/fetch_provider.js +94 -0
- package/lib/fetch_upstream.js +106 -0
- package/lib/index.js +123 -18
- package/lib/rerank.js +94 -0
- package/lib/research.js +1342 -0
- package/lib/vertical.js +645 -0
- package/package.json +8 -2
- package/LICENSE +0 -21
- package/README.md +0 -749
- package/cordis.patch.yml +0 -34
package/lib/cache.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// research 文档持久缓存(node:sqlite,零依赖)。Node <22.5 无 node:sqlite 时整体降级为 no-op,插件照常工作。
|
|
2
|
+
// ponytail: cache_search 用 LIKE 而非 FTS5 MATCH——个人规模(百级行)LIKE 即时返回,且无 FTS5 查询语法转义坑;行数过万再升 FTS5 虚表。
|
|
3
|
+
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
let db = null;
|
|
10
|
+
try {
|
|
11
|
+
const req = createRequire(import.meta.url);
|
|
12
|
+
const { DatabaseSync } = req("node:sqlite");
|
|
13
|
+
// 缓存位置可配置:RESEARCH_CACHE_DIR 优先(放哪、是否随备份走由用户决定),
|
|
14
|
+
// 默认 ~/.cache/dsh-free-search/research.db;删掉该目录即清空缓存,无后台任务残留。
|
|
15
|
+
const dir = process.env.RESEARCH_CACHE_DIR || join(homedir(), ".cache", "dsh-free-search");
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
db = new DatabaseSync(join(dir, "research.db"));
|
|
18
|
+
db.exec("CREATE TABLE IF NOT EXISTS pages(url TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL, fetched_at INTEGER NOT NULL)");
|
|
19
|
+
} catch { /* Node <22.5:缓存禁用 */ }
|
|
20
|
+
|
|
21
|
+
/** 新鲜则返回 {title, content, fetched_at},否则 null。 */
|
|
22
|
+
export function getFresh(url, maxAgeMs) {
|
|
23
|
+
if (!db) return null;
|
|
24
|
+
const r = db.prepare("SELECT title, content, fetched_at FROM pages WHERE url = ?").get(url);
|
|
25
|
+
if (!r || Date.now() - r.fetched_at > maxAgeMs) return null;
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function put(url, title, content) {
|
|
30
|
+
if (!db) return;
|
|
31
|
+
db.prepare("DELETE FROM pages WHERE url = ?").run(url);
|
|
32
|
+
db.prepare("INSERT INTO pages(url, title, content, fetched_at) VALUES (?, ?, ?, ?)").run(url, title || "", content, Date.now());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 全文 LIKE 检索已缓存页面(7 天内),返回 [{url, title, excerpt, fetched_at}]。 */
|
|
36
|
+
const STALE_MS = 7 * 24 * 3600 * 1000; // 与 research.js 的 DOC_CACHE_MAX_AGE_MS 对齐
|
|
37
|
+
export function search(query, limit = 8) {
|
|
38
|
+
if (!db) return [];
|
|
39
|
+
db.prepare("DELETE FROM pages WHERE fetched_at < ?").run(Date.now() - STALE_MS); // ponytail: 查询时惰性 GC,无后台任务
|
|
40
|
+
const like = `%${String(query).replace(/[%_]/g, " ")}%`; // ponytail: 通配符转空格而非 ESCAPE 子句,查询词含 %/_ 属罕见
|
|
41
|
+
const rows = db
|
|
42
|
+
.prepare("SELECT url, title, content, fetched_at FROM pages WHERE (title LIKE ? OR content LIKE ?) AND fetched_at > ? ORDER BY fetched_at DESC LIMIT ?")
|
|
43
|
+
.all(like, like, Date.now() - STALE_MS, limit);
|
|
44
|
+
return rows.map((r) => {
|
|
45
|
+
const i = r.content.toLowerCase().indexOf(String(query).toLowerCase());
|
|
46
|
+
const start = Math.max(0, i - 40);
|
|
47
|
+
return { url: r.url, title: r.title, excerpt: (start > 0 ? "…" : "") + r.content.slice(start, start + 160).replace(/\s+/g, " "), fetched_at: new Date(r.fetched_at).toISOString() };
|
|
48
|
+
});
|
|
49
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -133,7 +133,7 @@ window.__ModuleLoader__.load({
|
|
|
133
133
|
searchEngine: "搜索引擎",
|
|
134
134
|
visit: "访问官网 →",
|
|
135
135
|
getKey: "获取 API Key →",
|
|
136
|
-
engineHint: "Multi Search
|
|
136
|
+
engineHint: "Multi Search 会按查询语言并发对应语言池的全部已启用引擎并合并去重(中文/英文池可在下方编辑,每池最低 2 个引擎);Auto 根据查询语言及时间过滤自动优选单个引擎。Bing 是稳定的免费引擎。API KEY 引擎需在下方填写凭据。",
|
|
137
137
|
apiKeys: "API 密钥(可选)",
|
|
138
138
|
anysearchPh: (c) => c ? "AnySearch API 密钥(已配置)" : "AnySearch API 密钥(可选,不填免费匿名,填了提额)",
|
|
139
139
|
exaPh: (c) => c ? "Exa API 密钥(已配置)" : "Exa API 密钥(可选,不填也可免费使用)",
|
|
@@ -164,6 +164,12 @@ window.__ModuleLoader__.load({
|
|
|
164
164
|
fallbackPriority: "全局回退优先级",
|
|
165
165
|
fallbackPriorityHint: "从上到下为回退顺序。首选引擎仍先尝试;Auto 保留语言/时间路由,但自定义后同一分组内及后续回退按此顺序。禁用引擎不会执行,但会保留当前位置。",
|
|
166
166
|
fallbackPriorityReset: "恢复默认顺序",
|
|
167
|
+
zhPoolLabel: "中文查询池(zhPool)",
|
|
168
|
+
enPoolLabel: "英文查询池(enPool)",
|
|
169
|
+
languagePoolHint: "Auto 路由的起始组与 Multi 模式的并发池:按查询语言二选一,Multi 并发池内全部引擎(不再是前 3 个)。每池最低 2 个引擎、无上限;禁用/冷却/缺 key 的引擎会自动跳过。",
|
|
170
|
+
poolAddFirst: "添加引擎…",
|
|
171
|
+
poolRemove: "移除",
|
|
172
|
+
poolReset: "恢复默认池",
|
|
167
173
|
fallbackOnLabel: "回退触发条件(fallbackOn)",
|
|
168
174
|
fallbackOnHint: "勾选哪些失败类别允许触发回退到下一个引擎。全部勾选=保持现状(搜索永不直接失败);取消某类=遇到该类失败就停下并把该引擎的错误报出,不再换引擎。缺 key、被禁用、不支持时间过滤不算失败,始终会换引擎。",
|
|
169
175
|
classQuota: "额度/预算耗尽 (quota)",
|
|
@@ -225,7 +231,7 @@ window.__ModuleLoader__.load({
|
|
|
225
231
|
searchEngine: "Search engine",
|
|
226
232
|
visit: "Visit website →",
|
|
227
233
|
getKey: "Get API Key →",
|
|
228
|
-
engineHint: "Multi Search queries
|
|
234
|
+
engineHint: "Multi Search queries every enabled engine of the query's language pool concurrently and merges/deduplicates results (zh/en pools editable below, minimum 2 engines each). Auto smartly routes a single engine by query language and time filter. API KEY engines need credentials below.",
|
|
229
235
|
apiKeys: "API keys (optional)",
|
|
230
236
|
anysearchPh: (c) => c ? "AnySearch API key (configured)" : "AnySearch API key (optional, free anonymous without; key raises quota)",
|
|
231
237
|
exaPh: (c) => c ? "Exa API key (configured)" : "Exa API key (optional, free without)",
|
|
@@ -256,6 +262,12 @@ window.__ModuleLoader__.load({
|
|
|
256
262
|
fallbackPriority: "Global fallback priority",
|
|
257
263
|
fallbackPriorityHint: "Top to bottom is the fallback order. The preferred engine is still tried first. Auto keeps language/time routing, while customized priority orders engines within a route group and the remaining fallback chain. Disabled engines keep their position but are skipped.",
|
|
258
264
|
fallbackPriorityReset: "Reset default order",
|
|
265
|
+
zhPoolLabel: "Chinese query pool (zhPool)",
|
|
266
|
+
enPoolLabel: "English query pool (enPool)",
|
|
267
|
+
languagePoolHint: "The route head for Auto and the concurrent pool for Multi: picked by query language. Multi runs every engine in the pool (no longer top 3). Each pool keeps a minimum of 2 engines, no upper limit; disabled/cooling/keyless engines are skipped automatically.",
|
|
268
|
+
poolAddFirst: "Add engine…",
|
|
269
|
+
poolRemove: "Remove",
|
|
270
|
+
poolReset: "Reset default pool",
|
|
259
271
|
fallbackOnLabel: "Fallback triggers (fallbackOn)",
|
|
260
272
|
fallbackOnHint: "Choose which failure classes may trigger a fallback to the next engine. All checked = current behaviour (search never fails outright); unchecking one means such a failure stops the search and surfaces that engine's error instead of trying another engine. Missing keys, disabled engines and unsupported time filters are not failures and always advance.",
|
|
261
273
|
classQuota: "Quota/budget exhausted (quota)",
|
|
@@ -313,8 +325,8 @@ window.__ModuleLoader__.load({
|
|
|
313
325
|
},
|
|
314
326
|
};
|
|
315
327
|
const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
|
|
316
|
-
// 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION
|
|
317
|
-
const PLUGIN_VERSION = "0.
|
|
328
|
+
// 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致;跟随上游 v0.7.5)
|
|
329
|
+
const PLUGIN_VERSION = "0.8.0";
|
|
318
330
|
const ENGINES = [
|
|
319
331
|
{ id: "auto", label: "Auto · 智能路由", badge: "AUTO", link: null },
|
|
320
332
|
{ id: "multi", label: "Multi Search · 多引擎", badge: "MULTI", link: null },
|
|
@@ -342,11 +354,20 @@ window.__ModuleLoader__.load({
|
|
|
342
354
|
{ id: "claude", label: "Claude · 内置搜索", badge: "API KEY", link: "https://console.anthropic.com/settings/keys" },
|
|
343
355
|
];
|
|
344
356
|
const ENGINE_IDS = ENGINES.filter((engine) => engine.id !== "auto" && engine.id !== "multi").map((engine) => engine.id);
|
|
357
|
+
// 与 lib/index.js 的 EXPLICIT_ONLY_ENGINES 同源:按次计费、只在显式选中时运行,因此不提供入池候选。
|
|
358
|
+
const EXPLICIT_ONLY_ENGINES = ["openai", "gemini", "claude"];
|
|
345
359
|
const DEFAULT_FALLBACK_ORDER = ["exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "serply", "deepseek-official", "you", "baidu", "kimi", "aliyun", "doubao", "bing", "anysearch", "ddg", "ddg-lite", "searxng"];
|
|
346
360
|
const normalizeFallbackOrder = (value) => {
|
|
347
361
|
const configured = Array.isArray(value) ? value.filter((id) => ENGINE_IDS.includes(id)) : [];
|
|
348
362
|
return [...new Set([...configured, ...DEFAULT_FALLBACK_ORDER, ...ENGINE_IDS])];
|
|
349
363
|
};
|
|
364
|
+
// 语言分池(评审意见 B):zh/en 两池默认值与 lib/index.js 保持一致
|
|
365
|
+
const DEFAULT_ZH_POOL = ["bing", "baidu", "aliyun", "anysearch"];
|
|
366
|
+
const DEFAULT_EN_POOL = ["bing", "exa", "tavily"];
|
|
367
|
+
const normalizePoolIds = (value, fallback) => {
|
|
368
|
+
const configured = [...new Set((Array.isArray(value) ? value : []).filter((id) => ENGINE_IDS.includes(id)))];
|
|
369
|
+
return configured.length > 0 ? configured : fallback.slice();
|
|
370
|
+
};
|
|
350
371
|
const FAILURE_CLASSES = [
|
|
351
372
|
{ id: "quota", labelKey: "classQuota" },
|
|
352
373
|
{ id: "auth", labelKey: "classAuth" },
|
|
@@ -460,6 +481,8 @@ window.__ModuleLoader__.load({
|
|
|
460
481
|
const [provider, setProvider] = react.useState("bing");
|
|
461
482
|
const [disabledEngines, setDisabledEngines] = react.useState([]);
|
|
462
483
|
const [fallbackOrder, setFallbackOrder] = react.useState(DEFAULT_FALLBACK_ORDER);
|
|
484
|
+
const [zhPool, setZhPool] = react.useState(DEFAULT_ZH_POOL);
|
|
485
|
+
const [enPool, setEnPool] = react.useState(DEFAULT_EN_POOL);
|
|
463
486
|
const [fallbackOn, setFallbackOn] = react.useState(FAILURE_CLASSES.map((cls) => cls.id));
|
|
464
487
|
const [safeSearch, setSafeSearch] = react.useState("off");
|
|
465
488
|
const [bingMarket, setBingMarket] = react.useState("zh-CN");
|
|
@@ -514,6 +537,8 @@ window.__ModuleLoader__.load({
|
|
|
514
537
|
: []
|
|
515
538
|
);
|
|
516
539
|
setFallbackOrder(normalizeFallbackOrder(v.fallbackOrder));
|
|
540
|
+
setZhPool(normalizePoolIds(v.zhPool, DEFAULT_ZH_POOL));
|
|
541
|
+
setEnPool(normalizePoolIds(v.enPool, DEFAULT_EN_POOL));
|
|
517
542
|
setFallbackOn(normalizeFallbackOn(v.fallbackOn));
|
|
518
543
|
setSafeSearch(v.safeSearch === "strict" || v.safeSearch === "moderate" ? v.safeSearch : "off");
|
|
519
544
|
setBingMarket(v.bingMarket === undefined ? "zh-CN" : v.bingMarket);
|
|
@@ -642,6 +667,8 @@ window.__ModuleLoader__.load({
|
|
|
642
667
|
const ops = [{ op: "set", path: ["provider"], value: provider }];
|
|
643
668
|
ops.push({ op: "set", path: ["disabledEngines"], value: disabledEngines });
|
|
644
669
|
ops.push({ op: "set", path: ["fallbackOrder"], value: normalizeFallbackOrder(fallbackOrder) });
|
|
670
|
+
ops.push({ op: "set", path: ["zhPool"], value: normalizePoolIds(zhPool, DEFAULT_ZH_POOL) });
|
|
671
|
+
ops.push({ op: "set", path: ["enPool"], value: normalizePoolIds(enPool, DEFAULT_EN_POOL) });
|
|
645
672
|
ops.push({ op: "set", path: ["fallbackOn"], value: normalizeFallbackOn(fallbackOn) });
|
|
646
673
|
ops.push({ op: "set", path: ["lang"], value: lang });
|
|
647
674
|
ops.push({ op: "set", path: ["keyStorage"], value: keyStorage });
|
|
@@ -783,6 +810,134 @@ window.__ModuleLoader__.load({
|
|
|
783
810
|
setFailed(false);
|
|
784
811
|
setSaveError("");
|
|
785
812
|
};
|
|
813
|
+
// 语言分池(评审意见 B):两块池列表,行结构复用回退优先级(↑↓ 排序),外加增删;每池最低 2 个引擎、不设上限
|
|
814
|
+
const renderPoolField = (label, pool, setPool, defaultPool, withHint) => {
|
|
815
|
+
const removable = pool.length > 2;
|
|
816
|
+
const candidates = ENGINE_IDS.filter((id) => !pool.includes(id) && !EXPLICIT_ONLY_ENGINES.includes(id));
|
|
817
|
+
return react_jsx_runtime.jsx("div", {
|
|
818
|
+
className: "dshfs-field",
|
|
819
|
+
children: [
|
|
820
|
+
react_jsx_runtime.jsx("div", {
|
|
821
|
+
className: "dshfs-label",
|
|
822
|
+
children: label,
|
|
823
|
+
}),
|
|
824
|
+
react_jsx_runtime.jsx("div", {
|
|
825
|
+
className: "dshfs-priorityList",
|
|
826
|
+
children: pool.map((engineId, index) => {
|
|
827
|
+
const engine = ENGINES.find((item) => item.id === engineId);
|
|
828
|
+
if (!engine) return null;
|
|
829
|
+
const isDisabled = disabledEngines.includes(engineId);
|
|
830
|
+
return react_jsx_runtime.jsx("div", {
|
|
831
|
+
className: "dshfs-priorityRow" + (isDisabled ? " dshfs-priorityRowDisabled" : ""),
|
|
832
|
+
children: [
|
|
833
|
+
react_jsx_runtime.jsx("span", { className: "dshfs-priorityIndex", children: String(index + 1) }),
|
|
834
|
+
react_jsx_runtime.jsx("span", {
|
|
835
|
+
className: "dshfs-priorityName",
|
|
836
|
+
children: `${engine.label} (${engine.badge})${isDisabled ? " · " + t.priorityDisabled : ""}`,
|
|
837
|
+
}),
|
|
838
|
+
react_jsx_runtime.jsx("span", {
|
|
839
|
+
className: "dshfs-priorityActions",
|
|
840
|
+
children: [
|
|
841
|
+
react_jsx_runtime.jsx("button", {
|
|
842
|
+
type: "button",
|
|
843
|
+
className: "dshfs-btn dshfs-priorityBtn",
|
|
844
|
+
disabled: !ready || saving || index === 0,
|
|
845
|
+
onClick: () => {
|
|
846
|
+
setPool((prev) => {
|
|
847
|
+
const next = [...prev];
|
|
848
|
+
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
|
849
|
+
return next;
|
|
850
|
+
});
|
|
851
|
+
setDirty(true);
|
|
852
|
+
setFailed(false);
|
|
853
|
+
setSaveError("");
|
|
854
|
+
},
|
|
855
|
+
"aria-label": `${engine.label} ↑`,
|
|
856
|
+
title: "↑",
|
|
857
|
+
children: "↑",
|
|
858
|
+
}, "up"),
|
|
859
|
+
react_jsx_runtime.jsx("button", {
|
|
860
|
+
type: "button",
|
|
861
|
+
className: "dshfs-btn dshfs-priorityBtn",
|
|
862
|
+
disabled: !ready || saving || index === pool.length - 1,
|
|
863
|
+
onClick: () => {
|
|
864
|
+
setPool((prev) => {
|
|
865
|
+
const next = [...prev];
|
|
866
|
+
[next[index + 1], next[index]] = [next[index], next[index + 1]];
|
|
867
|
+
return next;
|
|
868
|
+
});
|
|
869
|
+
setDirty(true);
|
|
870
|
+
setFailed(false);
|
|
871
|
+
setSaveError("");
|
|
872
|
+
},
|
|
873
|
+
"aria-label": `${engine.label} ↓`,
|
|
874
|
+
title: "↓",
|
|
875
|
+
children: "↓",
|
|
876
|
+
}, "down"),
|
|
877
|
+
react_jsx_runtime.jsx("button", {
|
|
878
|
+
type: "button",
|
|
879
|
+
className: "dshfs-btn dshfs-priorityBtn",
|
|
880
|
+
disabled: !ready || saving || !removable,
|
|
881
|
+
onClick: () => {
|
|
882
|
+
setPool((prev) => prev.filter((id) => id !== engineId));
|
|
883
|
+
setDirty(true);
|
|
884
|
+
setFailed(false);
|
|
885
|
+
setSaveError("");
|
|
886
|
+
},
|
|
887
|
+
"aria-label": `${engine.label} ${t.poolRemove}`,
|
|
888
|
+
title: t.poolRemove,
|
|
889
|
+
children: "−",
|
|
890
|
+
}, "remove"),
|
|
891
|
+
],
|
|
892
|
+
}),
|
|
893
|
+
],
|
|
894
|
+
}, engineId);
|
|
895
|
+
}),
|
|
896
|
+
}),
|
|
897
|
+
react_jsx_runtime.jsx("div", {
|
|
898
|
+
children: [
|
|
899
|
+
react_jsx_runtime.jsx("select", {
|
|
900
|
+
className: "dshfs-select",
|
|
901
|
+
value: "",
|
|
902
|
+
style: { colorScheme: selectColorScheme },
|
|
903
|
+
disabled: !ready || saving || candidates.length === 0,
|
|
904
|
+
onChange: (e) => {
|
|
905
|
+
const id = e.target.value;
|
|
906
|
+
if (!id) return;
|
|
907
|
+
setPool((prev) => [...prev, id]);
|
|
908
|
+
setDirty(true);
|
|
909
|
+
setFailed(false);
|
|
910
|
+
setSaveError("");
|
|
911
|
+
},
|
|
912
|
+
children: [
|
|
913
|
+
react_jsx_runtime.jsx("option", { value: "", children: t.poolAddFirst }, "placeholder"),
|
|
914
|
+
...candidates.map((id) => {
|
|
915
|
+
const engine = ENGINES.find((item) => item.id === id);
|
|
916
|
+
return react_jsx_runtime.jsx("option", { value: id, children: `${engine.label} (${engine.badge})` }, id);
|
|
917
|
+
}),
|
|
918
|
+
],
|
|
919
|
+
}),
|
|
920
|
+
react_jsx_runtime.jsx("button", {
|
|
921
|
+
type: "button",
|
|
922
|
+
className: "dshfs-btn",
|
|
923
|
+
disabled: !ready || saving || pool.join(",") === defaultPool.join(","),
|
|
924
|
+
onClick: () => {
|
|
925
|
+
setPool(defaultPool.slice());
|
|
926
|
+
setDirty(true);
|
|
927
|
+
setFailed(false);
|
|
928
|
+
setSaveError("");
|
|
929
|
+
},
|
|
930
|
+
children: t.poolReset,
|
|
931
|
+
}, "reset"),
|
|
932
|
+
],
|
|
933
|
+
}),
|
|
934
|
+
...(withHint ? [react_jsx_runtime.jsx("p", {
|
|
935
|
+
className: "dshfs-hint",
|
|
936
|
+
children: t.languagePoolHint,
|
|
937
|
+
})] : []),
|
|
938
|
+
],
|
|
939
|
+
});
|
|
940
|
+
};
|
|
786
941
|
const badgeClass =
|
|
787
942
|
currentEngine.badge === "AUTO"
|
|
788
943
|
? "dshfs-badge dshfs-badgeAuto"
|
|
@@ -987,6 +1142,8 @@ react_jsx_runtime.jsx("div", {
|
|
|
987
1142
|
}),
|
|
988
1143
|
],
|
|
989
1144
|
}),
|
|
1145
|
+
renderPoolField(t.zhPoolLabel, zhPool, setZhPool, DEFAULT_ZH_POOL, false),
|
|
1146
|
+
renderPoolField(t.enPoolLabel, enPool, setEnPool, DEFAULT_EN_POOL, true),
|
|
990
1147
|
react_jsx_runtime.jsx("div", {
|
|
991
1148
|
className: "dshfs-field",
|
|
992
1149
|
children: [
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// 质量链 fetch provider:接管宿主 web_fetch(替代基础 http provider)。
|
|
2
|
+
// T1 直连(跟随重定向,仅文本类内容)→ T2 exa contents($1/1000 页,清洗正文 markdown,文本质量好且便宜)
|
|
3
|
+
// → T3 firecrawl(Markdown,反爬/JS/PDF)→ T4 crawl4ai(Markdown 备份)。
|
|
4
|
+
// firecrawl 额度保护:仅在前层(直连/exa)失败时才走;exa 失败静默降级不抛。
|
|
5
|
+
// 契约:WebFetchResult { url, statusCode, body: {kind:'html'|'text', content}, truncated }(CLOSED 联合类型)。
|
|
6
|
+
// ponytail: 无服务级令牌桶——web_fetch 为单发用户/agent 驱动调用,非扇出;若未来批量调用再引入共享调度。
|
|
7
|
+
|
|
8
|
+
import https from "node:https";
|
|
9
|
+
import { callExa, callFirecrawl, callCrawl4ai } from "./fetch_upstream.js";
|
|
10
|
+
|
|
11
|
+
const T1_MIN_CHARS = 300;
|
|
12
|
+
const TEXTUAL = /text\/|json|xml|javascript|application\/json/i;
|
|
13
|
+
|
|
14
|
+
/** GET 跟随重定向(≤5 跳),返回最终 URL / 状态 / 正文 / 内容类型。 */
|
|
15
|
+
function httpsGetFollow(url, { headers = {}, timeoutMs = 20000, signal } = {}) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const hop = (current, left) => {
|
|
18
|
+
const u = new URL(current);
|
|
19
|
+
const req = https.request(
|
|
20
|
+
{ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: "GET", headers, timeout: timeoutMs, signal },
|
|
21
|
+
(res) => {
|
|
22
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && left > 0) {
|
|
23
|
+
res.resume();
|
|
24
|
+
const next = new URL(res.headers.location, current).toString();
|
|
25
|
+
return hop(next, left - 1);
|
|
26
|
+
}
|
|
27
|
+
const chunks = [];
|
|
28
|
+
res.on("data", (c) => chunks.push(c));
|
|
29
|
+
res.on("end", () => resolve({ url: current, status: res.statusCode, text: Buffer.concat(chunks).toString("utf8"), contentType: res.headers["content-type"] || "" }));
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
req.on("timeout", () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
|
|
33
|
+
req.on("error", reject);
|
|
34
|
+
req.end();
|
|
35
|
+
};
|
|
36
|
+
hop(url, 5);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BROWSERISH = { "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", Accept: "text/html,application/xhtml+xml,*/*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" };
|
|
41
|
+
|
|
42
|
+
export function createFetchProvider({ resolveApiKey, logger }) {
|
|
43
|
+
return {
|
|
44
|
+
id: "fs-quality-fetch",
|
|
45
|
+
/** 廉价本地可用性检查(seam 契约要求,禁止网络调用)。 */
|
|
46
|
+
available() { return true; },
|
|
47
|
+
async fetch(request, signal) {
|
|
48
|
+
const url = String(request?.url || "");
|
|
49
|
+
if (!/^https:\/\//i.test(url)) throw new Error(`fs-quality-fetch: only https URLs are supported (got "${url.slice(0, 60)}")`);
|
|
50
|
+
const errors = [];
|
|
51
|
+
|
|
52
|
+
// T1:直连(文本类内容;二进制交给后层——exa 取正文,PDF 等仍由 firecrawl 处理)
|
|
53
|
+
try {
|
|
54
|
+
const r = await httpsGetFollow(url, { headers: BROWSERISH, signal });
|
|
55
|
+
if (r.status < 400 && TEXTUAL.test(r.contentType) && r.text.length >= T1_MIN_CHARS) {
|
|
56
|
+
return { url: r.url, statusCode: r.status, body: { kind: "html", content: r.text }, truncated: false };
|
|
57
|
+
}
|
|
58
|
+
if (r.status >= 400) errors.push(`direct HTTP ${r.status}`);
|
|
59
|
+
else errors.push(`direct: ${r.contentType || "unknown type"} ${r.text.length}B`);
|
|
60
|
+
} catch (e) { errors.push(`direct: ${e.message}`); }
|
|
61
|
+
|
|
62
|
+
// T2:exa contents($1/1000 页)——直连失败(反爬/JS/过短)时承接;firecrawl 额度保护:仅在本层失败后才走 T3
|
|
63
|
+
const exaKey = await resolveApiKey("EXA_API_KEY", "exaApiKey").catch(() => undefined);
|
|
64
|
+
if (exaKey) {
|
|
65
|
+
try {
|
|
66
|
+
const text = await callExa(url, exaKey, signal);
|
|
67
|
+
if (text.trim().length >= T1_MIN_CHARS) return { url, statusCode: 200, body: { kind: "text", content: text }, truncated: false };
|
|
68
|
+
errors.push("exa: short content");
|
|
69
|
+
} catch (e) { errors.push(`exa: ${e.message}`); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const firecrawlKey = await resolveApiKey("FIRECRAWL_API_KEY", "firecrawlApiKey").catch(() => undefined);
|
|
73
|
+
if (firecrawlKey) {
|
|
74
|
+
try {
|
|
75
|
+
const md = await callFirecrawl(url, firecrawlKey);
|
|
76
|
+
if (md.trim().length >= T1_MIN_CHARS) return { url, statusCode: 200, body: { kind: "text", content: md }, truncated: false };
|
|
77
|
+
errors.push("firecrawl: short content");
|
|
78
|
+
} catch (e) { errors.push(`firecrawl: ${e.message}`); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const crawl4aiKey = await resolveApiKey("CRAWL4AI_API_KEY", undefined).catch(() => undefined);
|
|
82
|
+
if (crawl4aiKey) {
|
|
83
|
+
try {
|
|
84
|
+
const text = await callCrawl4ai(url, crawl4aiKey);
|
|
85
|
+
if (text.trim().length >= T1_MIN_CHARS) return { url, statusCode: 200, body: { kind: "text", content: text }, truncated: false };
|
|
86
|
+
errors.push("crawl4ai: short content");
|
|
87
|
+
} catch (e) { errors.push(`crawl4ai: ${e.message}`); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
logger?.warn?.(`fs-quality-fetch: all tiers failed for ${url}: ${errors.join("; ")}`);
|
|
91
|
+
throw new Error(`fs-quality-fetch: could not fetch ${url} (${errors.join("; ")})`);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// fetch_upstream:web_fetch 质量链的云端抓取层(自 research.js 摘取,原生通道版专用)。
|
|
2
|
+
// 依赖面:仅 node:https。fetch_provider.js 从这里拿 callExa / callFirecrawl / callCrawl4ai。
|
|
3
|
+
// 注意:不用全局 fetch(undici) 访问 api.crawl4ai.com——其 Pingora 网关会空 401 拦截 undici 特征。
|
|
4
|
+
|
|
5
|
+
import https from "node:https";
|
|
6
|
+
|
|
7
|
+
// ---------- 受控替身注入点(测试基建;默认全 null = 生产直连真实服务,行为不变) ----------
|
|
8
|
+
export const CONTROLLED_STUBS = { exa: null, firecrawl: null, crawl4ai: null };
|
|
9
|
+
|
|
10
|
+
export function htmlToText(html) {
|
|
11
|
+
return String(html)
|
|
12
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
13
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
14
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ")
|
|
15
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
16
|
+
.replace(/<[^>]+>/g, " ")
|
|
17
|
+
.replace(/ /gi, " ")
|
|
18
|
+
.replace(/&/gi, "&")
|
|
19
|
+
.replace(/</gi, "<")
|
|
20
|
+
.replace(/>/gi, ">")
|
|
21
|
+
.replace(/"/gi, '"')
|
|
22
|
+
.replace(/'/gi, "'")
|
|
23
|
+
.replace(/[ \t]+/g, " ")
|
|
24
|
+
.replace(/\n\s*\n+/g, "\n\n")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------- 云端抓取(node:https,保留头大小写;undici 会被 crawl4ai 网关拦) ----------
|
|
29
|
+
|
|
30
|
+
export function httpsJson(method, url, { headers = {}, body, timeoutMs = 30000, signal } = {}) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const u = new URL(url);
|
|
33
|
+
const req = https.request(
|
|
34
|
+
{ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method, headers, timeout: timeoutMs },
|
|
35
|
+
(res) => {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
res.on("data", (c) => chunks.push(c));
|
|
38
|
+
res.on("end", () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString("utf8"), location: res.headers.location || "" }));
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
req.on("timeout", () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
|
|
42
|
+
req.on("error", reject);
|
|
43
|
+
if (signal) {
|
|
44
|
+
const onAbort = () => req.destroy(new Error("aborted"));
|
|
45
|
+
if (signal.aborted) onAbort();
|
|
46
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
47
|
+
}
|
|
48
|
+
if (body) req.write(body);
|
|
49
|
+
req.end();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** firecrawl /v2/scrape:返回 markdown。undici 特征对其无碍,但统一走 httpsJson。 */
|
|
54
|
+
export async function fetchViaFirecrawl(url, apiKey, signal) {
|
|
55
|
+
const r = await httpsJson("POST", "https://api.firecrawl.dev/v2/scrape", {
|
|
56
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
57
|
+
body: JSON.stringify({ url, formats: ["markdown"] }),
|
|
58
|
+
signal,
|
|
59
|
+
});
|
|
60
|
+
if (r.status !== 200) throw new Error(`firecrawl HTTP ${r.status}`);
|
|
61
|
+
const data = JSON.parse(r.text);
|
|
62
|
+
const md = data?.data?.markdown;
|
|
63
|
+
if (!md) throw new Error("firecrawl: no markdown");
|
|
64
|
+
return md;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** crawl4ai POST /scrape(注意:此部署无 /v1 前缀;认证头 X-API-Key)。 */
|
|
68
|
+
export async function fetchViaCrawl4ai(url, apiKey, signal) {
|
|
69
|
+
const r = await httpsJson("POST", "https://api.crawl4ai.com/scrape", {
|
|
70
|
+
headers: { "X-API-Key": apiKey, "Content-Type": "application/json", "User-Agent": "dsh-free-search/0.8" },
|
|
71
|
+
body: JSON.stringify({ url }),
|
|
72
|
+
signal,
|
|
73
|
+
});
|
|
74
|
+
if (r.status === 429) throw new Error("crawl4ai rate-limited (community plan)");
|
|
75
|
+
if (r.status !== 200) throw new Error(`crawl4ai HTTP ${r.status}`);
|
|
76
|
+
const data = JSON.parse(r.text);
|
|
77
|
+
const content = data?.markdown || (data?.html ? htmlToText(data.html) : "");
|
|
78
|
+
if (!content) throw new Error("crawl4ai: empty content");
|
|
79
|
+
return content;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** exa POST /contents($1/1000 页):{ids:[url], text:true} → results[].text(清洗正文 markdown)。
|
|
83
|
+
* 端点形状(https://exa.ai/docs/reference/get-contents,2026-10-05 核实):
|
|
84
|
+
* - 认证 Authorization: Bearer(x-api-key 亦可);ids 与 urls 二选一等价(ids 向后兼容)。
|
|
85
|
+
* - text 布尔/对象均可(对象形如 {maxCharacters};此处取全文不加限)。
|
|
86
|
+
* - 响应 results[](含 text)+ statuses[](每 URL 成败,失败带 error.tag/httpStatusCode)。
|
|
87
|
+
* 与 fetch_provider 质量链共用:T2 层。 */
|
|
88
|
+
export async function fetchViaExa(url, apiKey, signal) {
|
|
89
|
+
const r = await httpsJson("POST", "https://api.exa.ai/contents", {
|
|
90
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
91
|
+
body: JSON.stringify({ ids: [url], text: true }),
|
|
92
|
+
signal,
|
|
93
|
+
});
|
|
94
|
+
if (r.status !== 200) throw new Error(`exa HTTP ${r.status}`);
|
|
95
|
+
const data = JSON.parse(r.text);
|
|
96
|
+
const st = (data?.statuses ?? []).find((s) => s.id === url) ?? data?.statuses?.[0];
|
|
97
|
+
if (st && st.status !== "success") throw new Error(`exa: status ${st.status}${st.error?.httpStatusCode ? ` (HTTP ${st.error.httpStatusCode})` : ""}`);
|
|
98
|
+
const hit = (data?.results ?? []).find((x) => x.id === url || x.url === url) ?? data?.results?.[0];
|
|
99
|
+
const text = hit?.text;
|
|
100
|
+
if (!text) throw new Error("exa: no text");
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const callFirecrawl = (u, k, s) => (CONTROLLED_STUBS.firecrawl ?? fetchViaFirecrawl)(u, k, s);
|
|
105
|
+
export const callCrawl4ai = (u, k, s) => (CONTROLLED_STUBS.crawl4ai ?? fetchViaCrawl4ai)(u, k, s);
|
|
106
|
+
export const callExa = (u, k, s) => (CONTROLLED_STUBS.exa ?? fetchViaExa)(u, k, s);
|