@v587d/capital-generation 0.0.0-stage → 2.6.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/CHANGELOG.md +794 -0
- package/LICENSE +21 -0
- package/README.md +116 -2
- package/assets/cg-icon.png +0 -0
- package/capital-config/client.js +28 -0
- package/capital-config/client.src.cjs +388 -0
- package/capital-config/index.js +113 -0
- package/capital-config/package.json +36 -0
- package/capital-watchlist/client.js +182 -0
- package/capital-watchlist/client.src.cjs +1229 -0
- package/capital-watchlist/index.js +772 -0
- package/capital-watchlist/package.json +37 -0
- package/chart-ui/README.md +112 -0
- package/chart-ui/client.js +16 -0
- package/chart-ui/client.src.cjs +126 -0
- package/chart-ui/index.js +205 -0
- package/chart-ui/package.json +30 -0
- package/cordis.patch.yml +72 -0
- package/lib/agents/bash-guard.d.ts +55 -0
- package/lib/agents/bash-guard.js +162 -0
- package/lib/agents/root-tool-policy.d.ts +164 -0
- package/lib/agents/root-tool-policy.js +309 -0
- package/lib/chart/artifact-ref.d.ts +65 -0
- package/lib/chart/artifact-ref.js +107 -0
- package/lib/chart/downsample.d.ts +8 -0
- package/lib/chart/downsample.js +53 -0
- package/lib/chart/errors.d.ts +16 -0
- package/lib/chart/errors.js +15 -0
- package/lib/chart/events.d.ts +120 -0
- package/lib/chart/events.js +471 -0
- package/lib/chart/html.d.ts +21 -0
- package/lib/chart/html.js +154 -0
- package/lib/chart/runtime.d.ts +20 -0
- package/lib/chart/runtime.js +270 -0
- package/lib/chart/series.d.ts +102 -0
- package/lib/chart/series.js +424 -0
- package/lib/chart/source-token.d.ts +64 -0
- package/lib/chart/source-token.js +217 -0
- package/lib/chart/spec.d.ts +58 -0
- package/lib/chart/spec.js +265 -0
- package/lib/chart/tool.d.ts +86 -0
- package/lib/chart/tool.js +288 -0
- package/lib/chart/vendor/LICENSE +201 -0
- package/lib/chart/vendor/VERSION.json +8 -0
- package/lib/chart/vendor/lightweight-charts.standalone.production.js +7 -0
- package/lib/data-collector/args.d.ts +38 -0
- package/lib/data-collector/args.js +114 -0
- package/lib/data-collector/dataset-tools.d.ts +3 -0
- package/lib/data-collector/dataset-tools.js +421 -0
- package/lib/data-collector/describe.d.ts +99 -0
- package/lib/data-collector/describe.js +256 -0
- package/lib/data-collector/hub.d.ts +187 -0
- package/lib/data-collector/hub.js +469 -0
- package/lib/data-collector/query-time.d.ts +58 -0
- package/lib/data-collector/query-time.js +101 -0
- package/lib/data-collector/query.d.ts +57 -0
- package/lib/data-collector/query.js +473 -0
- package/lib/data-collector/store.d.ts +499 -0
- package/lib/data-collector/store.js +1486 -0
- package/lib/data-collector/time-axis.d.ts +169 -0
- package/lib/data-collector/time-axis.js +419 -0
- package/lib/data-collector/tools.d.ts +32 -0
- package/lib/data-collector/tools.js +216 -0
- package/lib/index.d.ts +275 -0
- package/lib/index.js +458 -0
- package/lib/net/eastmoney-client.d.ts +104 -0
- package/lib/net/eastmoney-client.js +118 -0
- package/lib/net/throttle.d.ts +16 -0
- package/lib/net/throttle.js +34 -0
- package/lib/ocr/client.d.ts +82 -0
- package/lib/ocr/client.js +281 -0
- package/lib/ocr/markdown.d.ts +75 -0
- package/lib/ocr/markdown.js +209 -0
- package/lib/ocr/store.d.ts +119 -0
- package/lib/ocr/store.js +226 -0
- package/lib/sources/eastmoney-http.d.ts +2 -0
- package/lib/sources/eastmoney-http.js +1253 -0
- package/lib/sources/fuyao-core.d.ts +52 -0
- package/lib/sources/fuyao-core.js +96 -0
- package/lib/sources/fuyao-rest.d.ts +14 -0
- package/lib/sources/fuyao-rest.js +2057 -0
- package/lib/sources/security-code.d.ts +27 -0
- package/lib/sources/security-code.js +104 -0
- package/lib/sources/security-identity.d.ts +24 -0
- package/lib/sources/security-identity.js +89 -0
- package/lib/sources/tencent-http.d.ts +2 -0
- package/lib/sources/tencent-http.js +1053 -0
- package/lib/sources/wind-mcp.d.ts +8 -0
- package/lib/sources/wind-mcp.js +593 -0
- package/lib/time/tools.d.ts +74 -0
- package/lib/time/tools.js +464 -0
- package/lib/tool-exec.d.ts +29 -0
- package/lib/tool-exec.js +17 -0
- package/lib/web-retriever/engines.d.ts +61 -0
- package/lib/web-retriever/engines.js +202 -0
- package/lib/web-retriever/html-markdown.d.ts +50 -0
- package/lib/web-retriever/html-markdown.js +321 -0
- package/lib/web-retriever/local-fetch.d.ts +121 -0
- package/lib/web-retriever/local-fetch.js +562 -0
- package/lib/web-retriever/retriever.d.ts +58 -0
- package/lib/web-retriever/retriever.js +94 -0
- package/lib/web-retriever/sources.d.ts +235 -0
- package/lib/web-retriever/sources.js +961 -0
- package/lib/web-retriever/tools.d.ts +50 -0
- package/lib/web-retriever/tools.js +1029 -0
- package/lib/web-retriever/wind-client.d.ts +69 -0
- package/lib/web-retriever/wind-client.js +311 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +76 -4
- package/preset/capital-generation/README.md +161 -0
- package/preset/capital-generation/agent.patch.yml +509 -0
- package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +137 -0
- package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +416 -0
- package/preset/capital-generation/skills/capital-orchestration/SKILL.md +174 -0
- package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +296 -0
- package/preset/capital-generation/skills/capital-web-protocol/SKILL.md +240 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF-gated local HTTP fetcher used after AnySearch extraction fails.
|
|
3
|
+
*
|
|
4
|
+
* Known limitation: DNS is resolved before each request, but the resolved IP is
|
|
5
|
+
* not pinned into the subsequent HTTP connection. A DNS rebinding window remains
|
|
6
|
+
* between validation and connect. Closing that window would require a custom
|
|
7
|
+
* dispatcher; this module keeps the same explicit trade-off as dsh-search-first.
|
|
8
|
+
*/
|
|
9
|
+
export declare const LOCAL_FETCH_CLIENT_VERSION = "2.6.0";
|
|
10
|
+
export declare const LOCAL_FETCH_MAX_URL_LENGTH = 2048;
|
|
11
|
+
export type LocalFetchErrorCode = 'ABORTED' | 'TIMEOUT' | 'INVALID_URL' | 'BLOCKED_URL' | 'DNS' | 'REDIRECT' | 'HTTP' | 'TOO_LARGE' | 'UNSUPPORTED_CONTENT_TYPE';
|
|
12
|
+
export declare class LocalFetchError extends Error {
|
|
13
|
+
readonly code: LocalFetchErrorCode;
|
|
14
|
+
constructor(code: LocalFetchErrorCode, message: string);
|
|
15
|
+
}
|
|
16
|
+
export interface LocalFetchOptions {
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
maxBytes: number;
|
|
19
|
+
maxContentChars: number;
|
|
20
|
+
maxRedirects: number;
|
|
21
|
+
userAgent: string;
|
|
22
|
+
allowPrivate?: boolean;
|
|
23
|
+
resolveAddresses?: (hostname: string, signal: AbortSignal) => Promise<Array<{
|
|
24
|
+
address: string;
|
|
25
|
+
family: number;
|
|
26
|
+
}>>;
|
|
27
|
+
}
|
|
28
|
+
export interface LocalFetchOutcome {
|
|
29
|
+
url: string;
|
|
30
|
+
status: number;
|
|
31
|
+
title?: string;
|
|
32
|
+
markdown: string;
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** Return true only for an address safe to use as a public outbound target. */
|
|
36
|
+
export declare function isPublicIp(address: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* 上游返回的 URL 字段(搜索结果的 `url`、各来源条目的 `url`)会**变成用户可点击的
|
|
39
|
+
* markdown 链接**(docs/dev/web-retriever.md §4.2 回传引用格式),所以这里按"要展示"的口径再收一道;
|
|
40
|
+
* `validateUrl` 是"要请求"的口径,抛错语义不适合逐条字段。
|
|
41
|
+
* 返回 `''` 表示这个值不配成为链接,调用方据此**丢字段**(不是丢条目):
|
|
42
|
+
* - 非 http(s) 协议:`javascript:` / `data:` 点下去就是执行;
|
|
43
|
+
* - 含空白或控制符:换行能把后半截甩出链接语法,变成正文里的新内容;
|
|
44
|
+
* - 带凭据:`https://sseinfo.com.cn@evil.example/` 显示的是官方域名、跳的是别的站;
|
|
45
|
+
* - 超长串:不是链接该占的体积(条目的输出预算见 `tools.ts` 的 `SOURCE_OUTPUT_BUDGET_CHARS`)。
|
|
46
|
+
* 返回**剥完不可见字符的原串**而不是 `url.toString()`:后者会把非 ASCII 路径按
|
|
47
|
+
* percent-encoding 展开(中文 URL 一字 9 字符),模型引用时白白吃掉预算。
|
|
48
|
+
*/
|
|
49
|
+
export declare function safeUrl(value: unknown, maxChars?: number): string;
|
|
50
|
+
/**
|
|
51
|
+
* 「把这条 URL 交给**别人**去取」的闸门:只判形态与主机,不发任何请求。
|
|
52
|
+
*
|
|
53
|
+
* `ocr` 的 `url` 形态本机不抓这个文件——上游解析服务替我们取。但本插件不能成为
|
|
54
|
+
* "把内网 URL 交给第三方去戳"的通道,所以这里复用与本机直连**同一份**主机校验
|
|
55
|
+
* (形态走 `safeUrl` + `validateUrl`,公网性走 `assertAllowedHost`),只是跳过后面的
|
|
56
|
+
* 重定向与体积那几步。SSRF 面因此不新长第二份实现,也不会随两处漂移。
|
|
57
|
+
*
|
|
58
|
+
* 返回**原串**(不是 `url.href`):`href` 会把非 ASCII 路径 percent-encode 展开,
|
|
59
|
+
* 白吃输出预算;`allowPrivate` 在这里恒为 false,不提供开关——关掉的不是我们的风险面。
|
|
60
|
+
*/
|
|
61
|
+
export declare function assertPublicUrlTarget(value: unknown, signal: AbortSignal, resolveAddresses?: LocalFetchOptions['resolveAddresses']): Promise<string>;
|
|
62
|
+
/**
|
|
63
|
+
* 请求描述符:`createLocalFetcher`(GET+markdown 化,回退链用)与来源工具
|
|
64
|
+
* (`src/web-retriever/sources.ts`,硬编码主机的 JSON/HTML 接口)共用同一个出口校验。
|
|
65
|
+
*
|
|
66
|
+
* 为什么共用而不是各写一份:出口校验(URL 合法性 + DNS 公网 IP 闸门 + 手动重定向重校验 +
|
|
67
|
+
* 大小/超时上限)一旦有第二份实现,两份就会各自漂移,而**安全闸门漂移不会报错**——
|
|
68
|
+
* 本仓在"同一个便利能力接在多个入口、只覆盖接好的那个"上已经踩过两次(AGENTS.md §9.7)。
|
|
69
|
+
*/
|
|
70
|
+
export interface HttpRequest {
|
|
71
|
+
url: string;
|
|
72
|
+
method?: 'GET' | 'POST';
|
|
73
|
+
/** 额外请求头(如上证e互动要求 `Referer`)。仅由来源配方内部提供,不接受模型入参。 */
|
|
74
|
+
headers?: Record<string, string>;
|
|
75
|
+
/**
|
|
76
|
+
* 请求体。字符串按 `application/x-www-form-urlencoded` 发(互动易第二步要求 POST 但 body 为空);
|
|
77
|
+
* `FormData` 则**故意不写 content-type**——boundary 必须由实现自己带,写死就是把请求写坏。
|
|
78
|
+
*/
|
|
79
|
+
body?: string | FormData;
|
|
80
|
+
/** 覆盖本次请求的 accept 头;省略用默认。 */
|
|
81
|
+
accept?: string;
|
|
82
|
+
/**
|
|
83
|
+
* 正文编码。省略时用响应头声明的 charset,缺省 UTF-8。
|
|
84
|
+
*
|
|
85
|
+
* 为什么需要**工具内部**的显式声明:2026-09-24 实测 `basic.10jqka.com.cn` 声明
|
|
86
|
+
* `content-type: text/html`(**不带 charset**)而正文是 **GBK**——按 UTF-8 解会产出 11,800 个
|
|
87
|
+
* U+FFFD 替换字符,中文全部不可读、正则全部失配(`gbk` 解码后 0 个替换字符)。
|
|
88
|
+
* ⚠️ 这个字段**绝不能暴露成模型入参**:配错编码只会静默产出乱码,属"看起来成功"的坏形态。
|
|
89
|
+
* 与 `headers` 一样,只由来源实现按硬编码知识填写。
|
|
90
|
+
*/
|
|
91
|
+
encoding?: string;
|
|
92
|
+
/**
|
|
93
|
+
* 逐请求覆盖响应体/正文上限(省略用 `LocalFetchOptions` 的全局值)。
|
|
94
|
+
*
|
|
95
|
+
* 为什么要覆盖而不是把全局值调大:全局 `maxBytes` 是给"抓一个网页正文"定的(512 KB),
|
|
96
|
+
* 网页抓取的响应体积就是风险体积;OCR 结果 JSONL 是**我们自己提交出去再取回来**的解析产物
|
|
97
|
+
* (实测 15 页研报 258 KB),把它挤进网页的额度会让大文档静默 `truncated`——
|
|
98
|
+
* 而截断的 JSONL 是解析失败,不是"少读了几条"。调大全局值等于放宽所有网页出口。
|
|
99
|
+
*/
|
|
100
|
+
maxBytes?: number;
|
|
101
|
+
maxContentChars?: number;
|
|
102
|
+
}
|
|
103
|
+
export interface HttpOutcome {
|
|
104
|
+
url: string;
|
|
105
|
+
status: number;
|
|
106
|
+
/** 正文原文,**不做任何转换**(JSON 原文直通、HTML 原样返回,由工具自己决定如何呈现)。 */
|
|
107
|
+
text: string;
|
|
108
|
+
truncated: boolean;
|
|
109
|
+
}
|
|
110
|
+
export declare function createHttpRequester(options: LocalFetchOptions): {
|
|
111
|
+
request(request: HttpRequest, signal?: AbortSignal): Promise<HttpOutcome>;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* 回退链用的抓取器:GET 取回后按内容类型决定**呈现**(JSON 原文直通、HTML 转 markdown)。
|
|
115
|
+
*
|
|
116
|
+
* 出口校验 / 重定向 / 上限 / 取消语义全部委托给 {@link createHttpRequester}——
|
|
117
|
+
* 这里只保留"如何呈现正文"这一层,避免同一套闸门出现第二份实现(见 `HttpRequest` 注释)。
|
|
118
|
+
*/
|
|
119
|
+
export declare function createLocalFetcher(options: LocalFetchOptions): {
|
|
120
|
+
fetch(url: string, signal?: AbortSignal): Promise<LocalFetchOutcome>;
|
|
121
|
+
};
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF-gated local HTTP fetcher used after AnySearch extraction fails.
|
|
3
|
+
*
|
|
4
|
+
* Known limitation: DNS is resolved before each request, but the resolved IP is
|
|
5
|
+
* not pinned into the subsequent HTTP connection. A DNS rebinding window remains
|
|
6
|
+
* between validation and connect. Closing that window would require a custom
|
|
7
|
+
* dispatcher; this module keeps the same explicit trade-off as dsh-search-first.
|
|
8
|
+
*/
|
|
9
|
+
import { htmlToMarkdown, stripInvisibleText } from './html-markdown.js';
|
|
10
|
+
export const LOCAL_FETCH_CLIENT_VERSION = '2.6.0';
|
|
11
|
+
export const LOCAL_FETCH_MAX_URL_LENGTH = 2048;
|
|
12
|
+
export class LocalFetchError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = 'LocalFetchError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
class OperationAborted extends Error {
|
|
21
|
+
}
|
|
22
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
23
|
+
const ACCEPT_HEADER = 'text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8';
|
|
24
|
+
function errorText(error) {
|
|
25
|
+
return error instanceof Error ? error.message : String(error);
|
|
26
|
+
}
|
|
27
|
+
function parseIpv4(address) {
|
|
28
|
+
const parts = address.split('.');
|
|
29
|
+
if (parts.length !== 4)
|
|
30
|
+
return null;
|
|
31
|
+
const values = parts.map((part) => {
|
|
32
|
+
if (!/^\d{1,3}$/u.test(part))
|
|
33
|
+
return -1;
|
|
34
|
+
const value = Number(part);
|
|
35
|
+
return value >= 0 && value <= 255 ? value : -1;
|
|
36
|
+
});
|
|
37
|
+
return values.some((value) => value < 0) ? null : values;
|
|
38
|
+
}
|
|
39
|
+
function parseIpv6(address) {
|
|
40
|
+
let input = address.toLowerCase();
|
|
41
|
+
if (input.includes('%'))
|
|
42
|
+
return null;
|
|
43
|
+
if (input.includes('.')) {
|
|
44
|
+
const separator = input.lastIndexOf(':');
|
|
45
|
+
if (separator < 0)
|
|
46
|
+
return null;
|
|
47
|
+
const ipv4 = parseIpv4(input.slice(separator + 1));
|
|
48
|
+
if (ipv4 === null)
|
|
49
|
+
return null;
|
|
50
|
+
const high = ((ipv4[0] << 8) | ipv4[1]).toString(16);
|
|
51
|
+
const low = ((ipv4[2] << 8) | ipv4[3]).toString(16);
|
|
52
|
+
input = `${input.slice(0, separator)}:${high}:${low}`;
|
|
53
|
+
}
|
|
54
|
+
const compression = input.indexOf('::');
|
|
55
|
+
if (compression !== -1 && input.indexOf('::', compression + 2) !== -1)
|
|
56
|
+
return null;
|
|
57
|
+
const parseGroups = (part) => {
|
|
58
|
+
if (part === '')
|
|
59
|
+
return [];
|
|
60
|
+
const groups = part.split(':');
|
|
61
|
+
if (groups.some((group) => !/^[0-9a-f]{1,4}$/u.test(group)))
|
|
62
|
+
return [];
|
|
63
|
+
return groups.map((group) => Number.parseInt(group, 16));
|
|
64
|
+
};
|
|
65
|
+
if (compression === -1) {
|
|
66
|
+
const groups = parseGroups(input);
|
|
67
|
+
return groups.length === 8 ? groups : null;
|
|
68
|
+
}
|
|
69
|
+
const left = parseGroups(input.slice(0, compression));
|
|
70
|
+
const right = parseGroups(input.slice(compression + 2));
|
|
71
|
+
if (left.length + right.length >= 8)
|
|
72
|
+
return null;
|
|
73
|
+
return [...left, ...Array.from({ length: 8 - left.length - right.length }, () => 0), ...right];
|
|
74
|
+
}
|
|
75
|
+
function isPrivateIpv4(address) {
|
|
76
|
+
const [first, second, third] = address;
|
|
77
|
+
// 100.64/10 覆盖 100.64.0.0 ~ 100.127.255.255,因此阿里云元数据端点
|
|
78
|
+
// 100.100.100.200 已被 `first===100 && second>=64 && second<=127` 这条挡住。
|
|
79
|
+
return first === 0
|
|
80
|
+
|| first === 10
|
|
81
|
+
|| first === 127
|
|
82
|
+
|| (first === 100 && second >= 64 && second <= 127)
|
|
83
|
+
|| (first === 169 && second === 254)
|
|
84
|
+
|| (first === 172 && second >= 16 && second <= 31)
|
|
85
|
+
|| (first === 192 && second === 0 && third === 0)
|
|
86
|
+
|| (first === 192 && second === 0 && third === 2)
|
|
87
|
+
|| (first === 192 && second === 168)
|
|
88
|
+
|| (first === 198 && (second === 18 || second === 19))
|
|
89
|
+
|| (first === 198 && second === 51 && third === 100)
|
|
90
|
+
|| (first === 203 && second === 0 && third === 113)
|
|
91
|
+
|| first >= 224;
|
|
92
|
+
}
|
|
93
|
+
/** 两个 16 位组 → 点分 IPv4(用于 6to4 / Teredo 内嵌地址解出)。 */
|
|
94
|
+
function groupsToIpv4(high, low) {
|
|
95
|
+
return [high >> 8, high & 0xff, low >> 8, low & 0xff];
|
|
96
|
+
}
|
|
97
|
+
/** Return true only for an address safe to use as a public outbound target. */
|
|
98
|
+
export function isPublicIp(address) {
|
|
99
|
+
const ipv4 = parseIpv4(address);
|
|
100
|
+
if (ipv4 !== null)
|
|
101
|
+
return !isPrivateIpv4(ipv4);
|
|
102
|
+
const groups = parseIpv6(address);
|
|
103
|
+
if (groups === null)
|
|
104
|
+
return false;
|
|
105
|
+
if (groups.every((group) => group === 0))
|
|
106
|
+
return false;
|
|
107
|
+
if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1)
|
|
108
|
+
return false;
|
|
109
|
+
const isMappedIpv4 = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
|
|
110
|
+
if (isMappedIpv4)
|
|
111
|
+
return !isPrivateIpv4(groupsToIpv4(groups[6], groups[7]));
|
|
112
|
+
// 6to4(2002::/16)把 IPv4 明文嵌在第 2、3 组:`2002:7f00:0001::` 即 127.0.0.1。
|
|
113
|
+
if (groups[0] === 0x2002)
|
|
114
|
+
return !isPrivateIpv4(groupsToIpv4(groups[1], groups[2]));
|
|
115
|
+
// Teredo(2001:0000::/32)的客户端 IPv4 在第 7、8 组按位取反存储(服务端 IPv4 在第 3、4 组,
|
|
116
|
+
// 一并校验):解出内嵌 v4 后递归判定,否则 `2001::` 前缀会绕过整张私网 v4 表。
|
|
117
|
+
if (groups[0] === 0x2001 && groups[1] === 0x0000) {
|
|
118
|
+
const client = groupsToIpv4(~groups[6] & 0xffff, ~groups[7] & 0xffff);
|
|
119
|
+
const server = groupsToIpv4(groups[2], groups[3]);
|
|
120
|
+
return !isPrivateIpv4(client) && !isPrivateIpv4(server);
|
|
121
|
+
}
|
|
122
|
+
const first = groups[0];
|
|
123
|
+
if ((first & 0xfe00) === 0xfc00)
|
|
124
|
+
return false;
|
|
125
|
+
if ((first & 0xffc0) === 0xfe80)
|
|
126
|
+
return false;
|
|
127
|
+
if ((first & 0xff00) === 0xff00)
|
|
128
|
+
return false;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
function hostnameOf(url) {
|
|
132
|
+
return url.hostname.startsWith('[') && url.hostname.endsWith(']')
|
|
133
|
+
? url.hostname.slice(1, -1)
|
|
134
|
+
: url.hostname;
|
|
135
|
+
}
|
|
136
|
+
function isIpLiteral(hostname) {
|
|
137
|
+
return parseIpv4(hostname) !== null || parseIpv6(hostname) !== null;
|
|
138
|
+
}
|
|
139
|
+
function validateUrl(input) {
|
|
140
|
+
if (typeof input !== 'string' || input.length > LOCAL_FETCH_MAX_URL_LENGTH) {
|
|
141
|
+
throw new LocalFetchError('INVALID_URL', `URL must be at most ${LOCAL_FETCH_MAX_URL_LENGTH} characters`);
|
|
142
|
+
}
|
|
143
|
+
let url;
|
|
144
|
+
try {
|
|
145
|
+
url = new URL(input);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new LocalFetchError('INVALID_URL', 'invalid URL');
|
|
149
|
+
}
|
|
150
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
151
|
+
throw new LocalFetchError('INVALID_URL', 'URL must use http or https');
|
|
152
|
+
}
|
|
153
|
+
if (url.username || url.password) {
|
|
154
|
+
throw new LocalFetchError('INVALID_URL', 'URL credentials are not allowed');
|
|
155
|
+
}
|
|
156
|
+
return url;
|
|
157
|
+
}
|
|
158
|
+
/** 可展示链接的形态:字面 http(s) 开头,且整串不含空白与控制符。 */
|
|
159
|
+
const HYPERLINK_PATTERN = /^https?:\/\/[^\s\u0000-\u001F\u007F-\u009F]+$/u;
|
|
160
|
+
/**
|
|
161
|
+
* 上游返回的 URL 字段(搜索结果的 `url`、各来源条目的 `url`)会**变成用户可点击的
|
|
162
|
+
* markdown 链接**(docs/dev/web-retriever.md §4.2 回传引用格式),所以这里按"要展示"的口径再收一道;
|
|
163
|
+
* `validateUrl` 是"要请求"的口径,抛错语义不适合逐条字段。
|
|
164
|
+
* 返回 `''` 表示这个值不配成为链接,调用方据此**丢字段**(不是丢条目):
|
|
165
|
+
* - 非 http(s) 协议:`javascript:` / `data:` 点下去就是执行;
|
|
166
|
+
* - 含空白或控制符:换行能把后半截甩出链接语法,变成正文里的新内容;
|
|
167
|
+
* - 带凭据:`https://sseinfo.com.cn@evil.example/` 显示的是官方域名、跳的是别的站;
|
|
168
|
+
* - 超长串:不是链接该占的体积(条目的输出预算见 `tools.ts` 的 `SOURCE_OUTPUT_BUDGET_CHARS`)。
|
|
169
|
+
* 返回**剥完不可见字符的原串**而不是 `url.toString()`:后者会把非 ASCII 路径按
|
|
170
|
+
* percent-encoding 展开(中文 URL 一字 9 字符),模型引用时白白吃掉预算。
|
|
171
|
+
*/
|
|
172
|
+
export function safeUrl(value, maxChars = LOCAL_FETCH_MAX_URL_LENGTH) {
|
|
173
|
+
if (typeof value !== 'string')
|
|
174
|
+
return '';
|
|
175
|
+
const raw = stripInvisibleText(value.trim());
|
|
176
|
+
if (raw === '' || raw.length > maxChars || !HYPERLINK_PATTERN.test(raw))
|
|
177
|
+
return '';
|
|
178
|
+
let url;
|
|
179
|
+
try {
|
|
180
|
+
url = new URL(raw);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
if (url.username !== '' || url.password !== '')
|
|
186
|
+
return '';
|
|
187
|
+
return raw;
|
|
188
|
+
}
|
|
189
|
+
function getDns() {
|
|
190
|
+
const processLike = globalThis.process;
|
|
191
|
+
return processLike?.getBuiltinModule?.('node:dns/promises');
|
|
192
|
+
}
|
|
193
|
+
function waitForAbort(promise, signal) {
|
|
194
|
+
if (signal.aborted)
|
|
195
|
+
return Promise.reject(new OperationAborted());
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
let settled = false;
|
|
198
|
+
const cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
199
|
+
const onAbort = () => {
|
|
200
|
+
if (settled)
|
|
201
|
+
return;
|
|
202
|
+
settled = true;
|
|
203
|
+
cleanup();
|
|
204
|
+
reject(new OperationAborted());
|
|
205
|
+
};
|
|
206
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
207
|
+
promise.then((value) => {
|
|
208
|
+
if (settled)
|
|
209
|
+
return;
|
|
210
|
+
settled = true;
|
|
211
|
+
cleanup();
|
|
212
|
+
resolve(value);
|
|
213
|
+
}, (error) => {
|
|
214
|
+
if (settled)
|
|
215
|
+
return;
|
|
216
|
+
settled = true;
|
|
217
|
+
cleanup();
|
|
218
|
+
reject(error);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async function defaultResolveAddresses(hostname, signal) {
|
|
223
|
+
const dns = getDns();
|
|
224
|
+
if (!dns || typeof dns.lookup !== 'function') {
|
|
225
|
+
throw new LocalFetchError('DNS', 'DNS resolver is unavailable');
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
return await waitForAbort(dns.lookup(hostname, { all: true, order: 'verbatim' }), signal);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (error instanceof OperationAborted)
|
|
232
|
+
throw error;
|
|
233
|
+
throw new LocalFetchError('DNS', `DNS lookup failed for ${hostname}: ${errorText(error)}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function assertAllowedHost(url, options, signal) {
|
|
237
|
+
if (signal.aborted)
|
|
238
|
+
throw new OperationAborted();
|
|
239
|
+
const hostname = hostnameOf(url);
|
|
240
|
+
if (isIpLiteral(hostname)) {
|
|
241
|
+
if (!options.allowPrivate && !isPublicIp(hostname)) {
|
|
242
|
+
throw new LocalFetchError('BLOCKED_URL', `private or reserved IP is not allowed: ${hostname}`);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const resolve = options.resolveAddresses ?? defaultResolveAddresses;
|
|
247
|
+
let addresses;
|
|
248
|
+
try {
|
|
249
|
+
addresses = await waitForAbort(resolve(hostname, signal), signal);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (error instanceof OperationAborted)
|
|
253
|
+
throw error;
|
|
254
|
+
if (error instanceof LocalFetchError)
|
|
255
|
+
throw error;
|
|
256
|
+
throw new LocalFetchError('DNS', `DNS lookup failed for ${hostname}: ${errorText(error)}`);
|
|
257
|
+
}
|
|
258
|
+
if (addresses.length === 0)
|
|
259
|
+
throw new LocalFetchError('DNS', `DNS lookup returned no addresses for ${hostname}`);
|
|
260
|
+
if (!options.allowPrivate && addresses.some(({ address }) => !isPublicIp(address))) {
|
|
261
|
+
throw new LocalFetchError('BLOCKED_URL', `DNS result for ${hostname} contains a private or reserved address`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* 「把这条 URL 交给**别人**去取」的闸门:只判形态与主机,不发任何请求。
|
|
266
|
+
*
|
|
267
|
+
* `ocr` 的 `url` 形态本机不抓这个文件——上游解析服务替我们取。但本插件不能成为
|
|
268
|
+
* "把内网 URL 交给第三方去戳"的通道,所以这里复用与本机直连**同一份**主机校验
|
|
269
|
+
* (形态走 `safeUrl` + `validateUrl`,公网性走 `assertAllowedHost`),只是跳过后面的
|
|
270
|
+
* 重定向与体积那几步。SSRF 面因此不新长第二份实现,也不会随两处漂移。
|
|
271
|
+
*
|
|
272
|
+
* 返回**原串**(不是 `url.href`):`href` 会把非 ASCII 路径 percent-encode 展开,
|
|
273
|
+
* 白吃输出预算;`allowPrivate` 在这里恒为 false,不提供开关——关掉的不是我们的风险面。
|
|
274
|
+
*/
|
|
275
|
+
export async function assertPublicUrlTarget(value, signal, resolveAddresses) {
|
|
276
|
+
const display = safeUrl(value);
|
|
277
|
+
if (display === '')
|
|
278
|
+
throw new LocalFetchError('BLOCKED_URL', 'url must be a plain http(s) URL without credentials or whitespace');
|
|
279
|
+
await assertAllowedHost(validateUrl(display), { allowPrivate: false, resolveAddresses }, signal);
|
|
280
|
+
return display;
|
|
281
|
+
}
|
|
282
|
+
function contentType(response) {
|
|
283
|
+
const header = response.headers.get('content-type') ?? '';
|
|
284
|
+
const [rawMime] = header.split(';', 1);
|
|
285
|
+
const mime = rawMime.trim().toLowerCase();
|
|
286
|
+
const charsetMatch = /(?:^|;)\s*charset\s*=\s*(?:"([^"]+)"|([^;\s]+))/iu.exec(header);
|
|
287
|
+
return { mime, charset: charsetMatch?.[1] ?? charsetMatch?.[2] };
|
|
288
|
+
}
|
|
289
|
+
function isSupportedContentType(mime) {
|
|
290
|
+
return mime.startsWith('text/')
|
|
291
|
+
|| mime === 'application/xhtml+xml'
|
|
292
|
+
|| mime === 'application/xml'
|
|
293
|
+
|| isJsonMime(mime)
|
|
294
|
+
|| mime.endsWith('+xml');
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* 兼容性放行:把 JSON 当成 `text/javascript` 风格的 MIME 也算 JSON。
|
|
298
|
+
*
|
|
299
|
+
* 为什么需要它:2026-09-24 实测 `sns.sseinfo.com/allcompany.do`(上证e互动公司列表)返回
|
|
300
|
+
* `json/javascript;charset=UTF-8`,正文是标准 JSON。严格按 `application/json` 判定会把
|
|
301
|
+
* 一个**正常的 JSON 接口**判成 `UNSUPPORTED_CONTENT_TYPE`——本仓此前只见过
|
|
302
|
+
* `application/json` / `+json`,所以这条是接入该来源时才暴露的。安全性不受影响:
|
|
303
|
+
* 正文仍要 `JSON.parse` 通过,解析失败照旧报错,只是不再因为 MIME 拼法而误拒。
|
|
304
|
+
*/
|
|
305
|
+
function isJsonMime(mime) {
|
|
306
|
+
return mime === 'application/json'
|
|
307
|
+
|| mime.endsWith('+json')
|
|
308
|
+
|| mime === 'json/javascript'
|
|
309
|
+
|| mime === 'application/javascript'
|
|
310
|
+
|| mime === 'text/javascript';
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* 响应头**完全缺失** Content-Type 时的保守嗅探。
|
|
314
|
+
*
|
|
315
|
+
* 为什么需要它:实测 `https://www.cls.cn/detail/2188659` 的 GET 响应**不带任何
|
|
316
|
+
* Content-Type**(同一 URL 的 HEAD 却带 `text/html`),而正文是完好的 SSR HTML。
|
|
317
|
+
* 原实现"缺头即拒"会把这类站点误判成 `UNSUPPORTED_CONTENT_TYPE`。
|
|
318
|
+
*
|
|
319
|
+
* 边界(有意保守):只在**缺头**时启用;头存在但不支持的类型(如 `application/pdf`)
|
|
320
|
+
* 仍然照旧拒绝,不放宽既有内容类型闸门。判定只认正文开头的明确标记,认不出就维持失败。
|
|
321
|
+
*/
|
|
322
|
+
function sniffMissingContentType(text) {
|
|
323
|
+
const head = text.slice(0, 512).replace(/^\uFEFF/u, '').trimStart().toLowerCase();
|
|
324
|
+
if (/^(?:<!doctype|<html|<\?xml|<!--|<[a-z][a-z0-9-]*(?:\s|\/|>))/u.test(head))
|
|
325
|
+
return 'html';
|
|
326
|
+
if (head.startsWith('{') || head.startsWith('['))
|
|
327
|
+
return 'json';
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
function createDecoder(charset) {
|
|
331
|
+
try {
|
|
332
|
+
return new TextDecoder(charset || 'utf-8');
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
throw new LocalFetchError('UNSUPPORTED_CONTENT_TYPE', `unsupported charset: ${charset ?? '(default)'}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** 错误响应体的摘录上限:够指认原因就停,不拿它当第二条正文通道。 */
|
|
339
|
+
const ERROR_DETAIL_MAX_CHARS = 180;
|
|
340
|
+
/**
|
|
341
|
+
* 把 4xx/5xx 的**响应体**读成一小段可展示的缘由。
|
|
342
|
+
*
|
|
343
|
+
* 为什么必须带:2026-09-25 实测 PaddleOCR 对某个 PDF 直链回 `HTTP 400` +
|
|
344
|
+
* `{"code":10004,"msg":"文件格式不支持"}`——只报状态码,模型面对 `local fetch returned HTTP 400`
|
|
345
|
+
* 什么也决定不了(换链接?换形态?放弃?)。原因在上游写的正文里,不在我们造的码里。
|
|
346
|
+
* 读失败一律吞掉返回空串:这是给失败**加**信息,不能反过来把原始失败弄丢或改成另一种失败。
|
|
347
|
+
*/
|
|
348
|
+
async function errorDetail(response, signal) {
|
|
349
|
+
try {
|
|
350
|
+
const body = await readBody(response, undefined, 1_024, ERROR_DETAIL_MAX_CHARS, signal);
|
|
351
|
+
const text = body.text.replace(/[\r\n\t]+/gu, ' ').trim();
|
|
352
|
+
return text.length === 0 ? '' : `:${text}`;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return '';
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async function readBody(response, charset, maxBytes, maxContentChars, signal) {
|
|
359
|
+
const decoder = createDecoder(charset);
|
|
360
|
+
const chunks = [];
|
|
361
|
+
let totalBytes = 0;
|
|
362
|
+
let byteTruncated = false;
|
|
363
|
+
const consume = (chunk) => {
|
|
364
|
+
const remaining = Math.max(0, maxBytes - totalBytes);
|
|
365
|
+
if (chunk.byteLength > remaining) {
|
|
366
|
+
if (remaining > 0)
|
|
367
|
+
chunks.push(decoder.decode(chunk.slice(0, remaining), { stream: true }));
|
|
368
|
+
totalBytes = maxBytes;
|
|
369
|
+
byteTruncated = true;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
totalBytes += chunk.byteLength;
|
|
373
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
|
374
|
+
};
|
|
375
|
+
const body = response.body;
|
|
376
|
+
if (body !== null) {
|
|
377
|
+
const reader = body.getReader();
|
|
378
|
+
try {
|
|
379
|
+
while (true) {
|
|
380
|
+
if (signal.aborted)
|
|
381
|
+
throw new OperationAborted();
|
|
382
|
+
const item = await waitForAbort(reader.read(), signal);
|
|
383
|
+
if (item.done)
|
|
384
|
+
break;
|
|
385
|
+
consume(item.value);
|
|
386
|
+
if (byteTruncated) {
|
|
387
|
+
try {
|
|
388
|
+
await reader.cancel();
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// The byte limit has already been enforced; a cancellation failure is harmless.
|
|
392
|
+
}
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
reader.releaseLock?.();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else if (typeof response.text === 'function') {
|
|
402
|
+
const text = await waitForAbort(response.text(), signal);
|
|
403
|
+
consume(new TextEncoder().encode(text));
|
|
404
|
+
}
|
|
405
|
+
const decoded = byteTruncated ? chunks.join('') : chunks.join('') + decoder.decode();
|
|
406
|
+
const charTruncated = decoded.length > maxContentChars;
|
|
407
|
+
return {
|
|
408
|
+
text: charTruncated ? decoded.slice(0, maxContentChars) : decoded,
|
|
409
|
+
byteTruncated,
|
|
410
|
+
charTruncated,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
export function createHttpRequester(options) {
|
|
414
|
+
async function requestOnce(input, callerSignal) {
|
|
415
|
+
if (callerSignal?.aborted)
|
|
416
|
+
throw new LocalFetchError('ABORTED', 'request aborted');
|
|
417
|
+
const controller = new AbortController();
|
|
418
|
+
let timedOut = false;
|
|
419
|
+
const onCallerAbort = () => controller.abort();
|
|
420
|
+
callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
421
|
+
const timer = setTimeout(() => {
|
|
422
|
+
timedOut = true;
|
|
423
|
+
controller.abort();
|
|
424
|
+
}, options.timeoutMs);
|
|
425
|
+
try {
|
|
426
|
+
let current = validateUrl(input.url);
|
|
427
|
+
let redirects = 0;
|
|
428
|
+
while (true) {
|
|
429
|
+
if (callerSignal?.aborted)
|
|
430
|
+
throw new LocalFetchError('ABORTED', 'request aborted');
|
|
431
|
+
if (controller.signal.aborted)
|
|
432
|
+
throw new OperationAborted();
|
|
433
|
+
await assertAllowedHost(current, options, controller.signal);
|
|
434
|
+
if (callerSignal?.aborted)
|
|
435
|
+
throw new LocalFetchError('ABORTED', 'request aborted');
|
|
436
|
+
let response;
|
|
437
|
+
try {
|
|
438
|
+
response = await waitForAbort(globalThis.fetch(current.toString(), {
|
|
439
|
+
method: input.method ?? 'GET',
|
|
440
|
+
redirect: 'manual',
|
|
441
|
+
headers: {
|
|
442
|
+
'user-agent': options.userAgent,
|
|
443
|
+
accept: input.accept ?? ACCEPT_HEADER,
|
|
444
|
+
...(typeof input.body === 'string' ? { 'content-type': 'application/x-www-form-urlencoded' } : {}),
|
|
445
|
+
...(input.headers ?? {}),
|
|
446
|
+
},
|
|
447
|
+
...(input.body === undefined ? {} : { body: input.body }),
|
|
448
|
+
signal: controller.signal,
|
|
449
|
+
}), controller.signal);
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (error instanceof OperationAborted)
|
|
453
|
+
throw error;
|
|
454
|
+
throw new LocalFetchError('HTTP', `local fetch failed: ${errorText(error)}`);
|
|
455
|
+
}
|
|
456
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
457
|
+
if (redirects >= options.maxRedirects) {
|
|
458
|
+
throw new LocalFetchError('REDIRECT', `redirect limit exceeded (${options.maxRedirects})`);
|
|
459
|
+
}
|
|
460
|
+
const location = response.headers.get('location');
|
|
461
|
+
if (!location)
|
|
462
|
+
throw new LocalFetchError('REDIRECT', 'redirect response has no Location header');
|
|
463
|
+
try {
|
|
464
|
+
current = validateUrl(new URL(location, current).toString());
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
if (error instanceof LocalFetchError && error.code === 'INVALID_URL') {
|
|
468
|
+
throw new LocalFetchError('REDIRECT', `invalid redirect target: ${error.message}`);
|
|
469
|
+
}
|
|
470
|
+
throw new LocalFetchError('REDIRECT', `invalid redirect target: ${errorText(error)}`);
|
|
471
|
+
}
|
|
472
|
+
redirects += 1;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (response.status >= 400) {
|
|
476
|
+
throw new LocalFetchError('HTTP', `local fetch returned HTTP ${response.status}${await errorDetail(response, controller.signal)}`);
|
|
477
|
+
}
|
|
478
|
+
const type = contentType(response);
|
|
479
|
+
// 缺头(mime 为空)不当场拒绝:正文读进来嗅探后再判。头存在但不支持的类型
|
|
480
|
+
// 仍然在读正文前直接拒绝(保持既有闸门与快速失败)。
|
|
481
|
+
const missingContentType = type.mime === '';
|
|
482
|
+
if (!missingContentType && !isSupportedContentType(type.mime)) {
|
|
483
|
+
throw new LocalFetchError('UNSUPPORTED_CONTENT_TYPE', `unsupported content type: ${type.mime}`);
|
|
484
|
+
}
|
|
485
|
+
const body = await readBody(response,
|
|
486
|
+
// 工具内部的显式声明优先于响应头:实测存在"头不带 charset 但正文是 GBK"的站点
|
|
487
|
+
// (`basic.10jqka.com.cn`),按 UTF-8 解会整页变成替换字符(见 `HttpRequest.encoding`)。
|
|
488
|
+
input.encoding ?? type.charset, input.maxBytes ?? options.maxBytes, input.maxContentChars ?? options.maxContentChars, controller.signal);
|
|
489
|
+
if (missingContentType && sniffMissingContentType(body.text) === undefined) {
|
|
490
|
+
throw new LocalFetchError('UNSUPPORTED_CONTENT_TYPE', 'unsupported content type: (missing)');
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
url: current.toString(),
|
|
494
|
+
status: response.status,
|
|
495
|
+
text: body.text,
|
|
496
|
+
truncated: body.byteTruncated || body.charTruncated,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error instanceof OperationAborted) {
|
|
502
|
+
if (callerSignal?.aborted)
|
|
503
|
+
throw new LocalFetchError('ABORTED', 'request aborted');
|
|
504
|
+
if (timedOut)
|
|
505
|
+
throw new LocalFetchError('TIMEOUT', 'local fetch timed out');
|
|
506
|
+
throw new LocalFetchError('ABORTED', 'request aborted');
|
|
507
|
+
}
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
callerSignal?.removeEventListener('abort', onCallerAbort);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { request: requestOnce };
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* 回退链用的抓取器:GET 取回后按内容类型决定**呈现**(JSON 原文直通、HTML 转 markdown)。
|
|
519
|
+
*
|
|
520
|
+
* 出口校验 / 重定向 / 上限 / 取消语义全部委托给 {@link createHttpRequester}——
|
|
521
|
+
* 这里只保留"如何呈现正文"这一层,避免同一套闸门出现第二份实现(见 `HttpRequest` 注释)。
|
|
522
|
+
*/
|
|
523
|
+
export function createLocalFetcher(options) {
|
|
524
|
+
const requester = createHttpRequester(options);
|
|
525
|
+
async function fetchLocal(input, callerSignal) {
|
|
526
|
+
// accept 头由 requester 统一设置(与既有断言一致:HTML/文本优先、JSON 兜底)。
|
|
527
|
+
const outcome = await requester.request({ url: input }, callerSignal);
|
|
528
|
+
const head = outcome.text.slice(0, 512).replace(/^\uFEFF/u, '').trimStart().toLowerCase();
|
|
529
|
+
const looksHtml = /^(?:<!doctype|<html|<\?xml|<!--|<[a-z][a-z0-9-]*(?:\s|\/|>))/u.test(head);
|
|
530
|
+
// JSON 必须**原文直通**,不得走 markdown 化。
|
|
531
|
+
//
|
|
532
|
+
// 2026-09 实测缺陷:此前所有正文一律交给 htmlToMarkdown,而转义器会把 JSON 改坏——
|
|
533
|
+
// 数组括号变 `\[ ... \]`(`JSON.parse` 不再成立)、字段名 `content_text` 变
|
|
534
|
+
// `content\_text`(按字段名读取不可靠)、`<script>` 内容被当标签删掉(数据丢失)。
|
|
535
|
+
// 内容类型闸门与 skill 都向模型承诺支持 JSON,此前的行为是"承诺收下、交付却改坏"。
|
|
536
|
+
//
|
|
537
|
+
// ⚠️ 呈现判据**只看正文**,不看响应头:requester 已按内容类型闸门决定"收不收",
|
|
538
|
+
// 而实测存在响应头与正文不一致的站点(`sniffMissingContentType` 的注释记着
|
|
539
|
+
// cls.cn 详情页的实测案例),与其再信一次头,不如直接认正文。
|
|
540
|
+
// JSON 原样返回:用**正文**判据而不是响应头——实测 `json/javascript` 这类 MIME
|
|
541
|
+
// 在严格 `application/json` 判定下不算 JSON(见 `isJsonMime` 的记录),
|
|
542
|
+
// 且 JSON 正文必然以 `{` / `[` 开头,判定是确定的。
|
|
543
|
+
const looksJson = head.startsWith('{') || head.startsWith('[');
|
|
544
|
+
if (looksJson || !looksHtml) {
|
|
545
|
+
return {
|
|
546
|
+
url: outcome.url,
|
|
547
|
+
status: outcome.status,
|
|
548
|
+
markdown: outcome.text,
|
|
549
|
+
truncated: outcome.truncated,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const rendered = htmlToMarkdown(outcome.text, options.maxContentChars);
|
|
553
|
+
return {
|
|
554
|
+
url: outcome.url,
|
|
555
|
+
status: outcome.status,
|
|
556
|
+
...(rendered.title !== undefined ? { title: rendered.title } : {}),
|
|
557
|
+
markdown: rendered.markdown,
|
|
558
|
+
truncated: outcome.truncated || rendered.truncated || rendered.omitted,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return { fetch: fetchLocal };
|
|
562
|
+
}
|