@v587d/capital-generation 0.0.0-stage → 2.6.1
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 +814 -0
- package/LICENSE +21 -0
- package/README.md +118 -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 +84 -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,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnySearch 的最小 REST 客户端。
|
|
3
|
+
*
|
|
4
|
+
* web_retriever 目前只支持一个后端,因此不预留多引擎注册表、动态 loader 或
|
|
5
|
+
* provider 目录。未来真正接入第二个后端时,再根据实际差异抽象接口。
|
|
6
|
+
*/
|
|
7
|
+
/** 第三方页面的正文与链接:与本机直抓共用同一份不可见字符剥离与可展示链接口径。 */
|
|
8
|
+
import { stripInvisibleText } from './html-markdown.js';
|
|
9
|
+
import { safeUrl } from './local-fetch.js';
|
|
10
|
+
/** AnySearch 上游 error_code → 本地错误码映射。对齐官方 fetch-provider 的 webErrorCode 表。 */
|
|
11
|
+
const ERROR_CODE_MAP = {
|
|
12
|
+
invalid_extract_url: 'INVALID_URL',
|
|
13
|
+
extract_target_blocked: 'TARGET_BLOCKED',
|
|
14
|
+
extract_content_too_large: 'CONTENT_TOO_LARGE',
|
|
15
|
+
extract_unsupported_content: 'UNSUPPORTED_CONTENT',
|
|
16
|
+
extract_timeout: 'TIMEOUT',
|
|
17
|
+
};
|
|
18
|
+
/** 带结构化错误码的 AnySearch 失败;message 沿用既有拼法,保证既有断言仍通过。 */
|
|
19
|
+
export class AnySearchError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
upstreamCode;
|
|
22
|
+
status;
|
|
23
|
+
constructor(code, path, detail, extra) {
|
|
24
|
+
super(`AnySearch ${path} error: ${detail}`);
|
|
25
|
+
this.name = 'AnySearchError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
if (extra?.upstreamCode !== undefined)
|
|
28
|
+
this.upstreamCode = extra.upstreamCode;
|
|
29
|
+
if (extra?.status !== undefined)
|
|
30
|
+
this.status = extra.status;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* AnySearch 失败后是否值得改走本机直连回退。
|
|
35
|
+
* 只有调用方取消、URL 非法与内容类型不受支持三类不回退;其余都回退。
|
|
36
|
+
*/
|
|
37
|
+
export function shouldFallbackToLocalFetch(code) {
|
|
38
|
+
return code !== 'ABORTED' && code !== 'INVALID_URL' && code !== 'UNSUPPORTED_CONTENT';
|
|
39
|
+
}
|
|
40
|
+
export const DEFAULT_BASE_URL = 'https://api.anysearch.com';
|
|
41
|
+
export const REQUEST_TIMEOUT_MS = 60_000;
|
|
42
|
+
export const MAX_CONTENT_CHARS = 20_000;
|
|
43
|
+
function errorText(error) {
|
|
44
|
+
return error instanceof Error ? error.message : String(error);
|
|
45
|
+
}
|
|
46
|
+
function responseData(raw) {
|
|
47
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
48
|
+
return null;
|
|
49
|
+
const record = raw;
|
|
50
|
+
if (record.data && typeof record.data === 'object' && !Array.isArray(record.data)) {
|
|
51
|
+
return record.data;
|
|
52
|
+
}
|
|
53
|
+
return record;
|
|
54
|
+
}
|
|
55
|
+
export function normalizeSearchHit(raw) {
|
|
56
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
57
|
+
return null;
|
|
58
|
+
const record = raw;
|
|
59
|
+
// 候选链接不合法就整条丢掉:既不能 fetch、也不能作为引用给用户点。
|
|
60
|
+
const url = safeUrl(record.url);
|
|
61
|
+
if (url === '')
|
|
62
|
+
return null;
|
|
63
|
+
return {
|
|
64
|
+
url,
|
|
65
|
+
...(typeof record.title === 'string' && record.title ? { title: stripInvisibleText(record.title) } : {}),
|
|
66
|
+
...(typeof record.snippet === 'string' && record.snippet ? { snippet: stripInvisibleText(record.snippet) } : {}),
|
|
67
|
+
...(typeof record.content === 'string' && record.content
|
|
68
|
+
? { content: stripInvisibleText(record.content).slice(0, MAX_CONTENT_CHARS) }
|
|
69
|
+
: {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function normalizeSearchResponse(query, raw) {
|
|
73
|
+
const data = responseData(raw);
|
|
74
|
+
if (!data || !Array.isArray(data.results)) {
|
|
75
|
+
return { query, ok: false, sources: [], error: 'AnySearch returned an invalid search response' };
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
query,
|
|
79
|
+
ok: true,
|
|
80
|
+
sources: data.results
|
|
81
|
+
.map(normalizeSearchHit)
|
|
82
|
+
.filter((hit) => hit !== null),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function normalizeFetchResponse(url, raw) {
|
|
86
|
+
const data = responseData(raw);
|
|
87
|
+
if (!data)
|
|
88
|
+
return { url, ok: false, error: 'AnySearch returned an invalid fetch response' };
|
|
89
|
+
const title = typeof data.title === 'string' && data.title ? stripInvisibleText(data.title) : undefined;
|
|
90
|
+
const sourceContent = typeof data.content === 'string' && data.content ? stripInvisibleText(data.content) : undefined;
|
|
91
|
+
const content = sourceContent === undefined ? undefined : sourceContent.slice(0, MAX_CONTENT_CHARS);
|
|
92
|
+
const truncated = sourceContent !== undefined && sourceContent.length > MAX_CONTENT_CHARS;
|
|
93
|
+
if (!title && !content)
|
|
94
|
+
return { url, ok: false, error: 'fetch returned no content' };
|
|
95
|
+
return {
|
|
96
|
+
url,
|
|
97
|
+
ok: true,
|
|
98
|
+
...(title ? { title } : {}),
|
|
99
|
+
...(content ? { content } : {}),
|
|
100
|
+
...(truncated ? { truncated: true } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export function envKey(name) {
|
|
104
|
+
const processLike = globalThis.process;
|
|
105
|
+
return processLike?.env?.[name] || undefined;
|
|
106
|
+
}
|
|
107
|
+
/** 创建 AnySearch REST 客户端;API key 每次请求重新解析,不缓存。 */
|
|
108
|
+
export function createAnySearchClient(baseURL = DEFAULT_BASE_URL, resolveApiKey = async () => envKey('ANYSEARCH_API_KEY')) {
|
|
109
|
+
const root = baseURL.replace(/\/+$/, '');
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(`${root}/v1/search`);
|
|
112
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
113
|
+
throw new Error('baseURL must use http or https');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
throw new Error(`invalid AnySearch baseURL: ${errorText(error)}`);
|
|
118
|
+
}
|
|
119
|
+
async function request(path, body, signal) {
|
|
120
|
+
if (signal?.aborted)
|
|
121
|
+
throw new AnySearchError('ABORTED', path, 'request aborted');
|
|
122
|
+
const timeout = new AbortController();
|
|
123
|
+
const onAbort = () => timeout.abort(signal?.reason);
|
|
124
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
125
|
+
let timedOut = false;
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
timedOut = true;
|
|
128
|
+
timeout.abort(new Error('AnySearch request timed out'));
|
|
129
|
+
}, REQUEST_TIMEOUT_MS);
|
|
130
|
+
const fail = (code, detail, extra) => new AnySearchError(code, path, detail, extra);
|
|
131
|
+
try {
|
|
132
|
+
const apiKey = await resolveApiKey();
|
|
133
|
+
const headers = {
|
|
134
|
+
accept: 'application/json',
|
|
135
|
+
'content-type': 'application/json',
|
|
136
|
+
'user-agent': 'dsh/0.1.4',
|
|
137
|
+
'x-anysearch-client': 'dsh/0.1.4',
|
|
138
|
+
};
|
|
139
|
+
if (apiKey)
|
|
140
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
141
|
+
let response;
|
|
142
|
+
try {
|
|
143
|
+
response = await fetch(`${root}${path}`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
redirect: 'error',
|
|
146
|
+
headers,
|
|
147
|
+
body: JSON.stringify(body),
|
|
148
|
+
signal: timeout.signal,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (timedOut)
|
|
153
|
+
throw fail('TIMEOUT', 'request timed out');
|
|
154
|
+
if (signal?.aborted)
|
|
155
|
+
throw fail('ABORTED', 'request aborted');
|
|
156
|
+
throw fail('NETWORK', errorText(error));
|
|
157
|
+
}
|
|
158
|
+
const text = await response.text();
|
|
159
|
+
let parsed;
|
|
160
|
+
try {
|
|
161
|
+
const value = JSON.parse(text);
|
|
162
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
163
|
+
parsed = value;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// 非 JSON 响应转成下面的明确错误。
|
|
168
|
+
}
|
|
169
|
+
const code = parsed?.code;
|
|
170
|
+
const message = typeof parsed?.message === 'string' && parsed.message ? parsed.message : undefined;
|
|
171
|
+
const errorCode = typeof parsed?.error_code === 'string' ? parsed.error_code : undefined;
|
|
172
|
+
const detail = () => errorCode
|
|
173
|
+
? `${message ?? 'request failed'} (${errorCode})`
|
|
174
|
+
: (message ?? `HTTP ${response.status}`);
|
|
175
|
+
const upstream = errorCode ? { upstreamCode: errorCode } : {};
|
|
176
|
+
if (response.status === 401 || response.status === 403) {
|
|
177
|
+
throw fail('AUTH', detail(), { ...upstream, status: response.status });
|
|
178
|
+
}
|
|
179
|
+
if (response.status === 429) {
|
|
180
|
+
throw fail('RATE_LIMIT', detail(), { ...upstream, status: response.status });
|
|
181
|
+
}
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
throw fail('HTTP', detail(), { ...upstream, status: response.status });
|
|
184
|
+
}
|
|
185
|
+
if (!parsed)
|
|
186
|
+
throw fail('INVALID_RESPONSE', 'invalid JSON response');
|
|
187
|
+
if (code !== undefined && code !== 0) {
|
|
188
|
+
const mapped = errorCode ? (ERROR_CODE_MAP[errorCode] ?? 'UPSTREAM') : 'UPSTREAM';
|
|
189
|
+
throw fail(mapped, detail(), upstream);
|
|
190
|
+
}
|
|
191
|
+
return parsed.data && typeof parsed.data === 'object' ? parsed.data : parsed;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
signal?.removeEventListener('abort', onAbort);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
search: (requestArgs, signal) => request('/v1/search', requestArgs, signal),
|
|
200
|
+
extract: (requestArgs, signal) => request('/v1/extract', requestArgs, signal),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML → GFM Markdown 转换(本地直抓回退的呈现层,todo 4 `local-fetch.ts` 消费)。
|
|
3
|
+
*
|
|
4
|
+
* 语义逐条移植自官方 DSH `web_fetch` 渲染器
|
|
5
|
+
* (`dsh-LLM-icon/.research/dsh-src/packages/web/tool-web/src/fetch.ts`):
|
|
6
|
+
* turndown + GFM 插件、`removeNonVisibleContent` 规则、无跨列展开的表格规则、
|
|
7
|
+
* 512 层嵌套词法守卫与固定省略标记。官方实现里与本模块无关的呈现职责
|
|
8
|
+
* (`Fetched <url>` 头、截断脚注、WeakMap memoization)一律不搬——"网页正文不是指令"
|
|
9
|
+
* 由 web_retriever persona 的硬规则承担,不在正文里插水印。
|
|
10
|
+
*
|
|
11
|
+
* 官方没做、这里补上的第四道:`stripInvisibleText`(零宽 / 双向覆盖 / C0-C1 控制符剥离)。
|
|
12
|
+
* 它放在本模块导出,是因为 invisibles 的入口不止 HTML:`extractTitle` 会解码数字实体
|
|
13
|
+
* (`‮` 解出来就是 RLO),AnySearch 返回的清洗正文也原样带这些字符。
|
|
14
|
+
* 三个内容入口(本地转换、title、具名来源页面字段)共用这一份实现(AGENTS.md §9.7)。
|
|
15
|
+
*
|
|
16
|
+
* 为什么必须补这三道保护(turndown 的已知缺口):
|
|
17
|
+
* - turndown 默认**不删** `<script>/<style>`,其正文会当文本带进输出;
|
|
18
|
+
* - turndown 完全**不处理**隐藏元素;
|
|
19
|
+
* - turndown 的转换按元素递归,超深嵌套下可能爆栈或长时间占用事件循环——
|
|
20
|
+
* 官方实测:嵌套 512 层 ≈ 0.15s、2000 层 ≈ 2s、20000 层 ≈ 5s,期间协作式
|
|
21
|
+
* 超时定时器无法触发,所以超过 512 层直接放弃转换。
|
|
22
|
+
*/
|
|
23
|
+
/** 嵌套深度上限:超过则跳过转换、返回固定省略标记。健壮性不变量,不是可调参数。 */
|
|
24
|
+
export declare const MAX_CONVERSION_DEPTH = 512;
|
|
25
|
+
/** 转换结果:`title` 从原始 HTML 提取(源串截断不影响);无 title 时缺省该字段。 */
|
|
26
|
+
export interface HtmlMarkdownResult {
|
|
27
|
+
title?: string;
|
|
28
|
+
/** 转换后的 GFM markdown;转换不安全时为固定省略标记。 */
|
|
29
|
+
markdown: string;
|
|
30
|
+
/** 源串或输出曾被 `maxChars` 截断。 */
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
/** true 表示转换被放弃(超深或 turndown 抛错),`markdown` 为省略标记。 */
|
|
33
|
+
omitted: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function stripInvisibleText(value: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* 从 HTML 开头提取 `<title>` 并解码实体、压空白。找不到或解码后为空返回
|
|
38
|
+
* `undefined`。注意:`<title>` 的文本按官方行为仍会留在 markdown 正文里
|
|
39
|
+
* (官方 remove 列表不含 TITLE),本函数只负责回执的 `title` 字段。
|
|
40
|
+
*/
|
|
41
|
+
export declare function extractTitle(html: string): string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* 把 HTML 转成有界 GFM markdown。
|
|
44
|
+
*
|
|
45
|
+
* 流程:先按 `maxChars` 截断源串(记 `truncated`,与官方 renderBody 一致)→
|
|
46
|
+
* 超深或 turndown 抛错 → 固定省略标记 + `omitted: true`(不让原始标记外漏)→
|
|
47
|
+
* 否则转换,剥掉不可见字符(`stripInvisibleText`),再按 `maxChars` 截断输出。
|
|
48
|
+
* `title` 一律从**原始** HTML 提取。
|
|
49
|
+
*/
|
|
50
|
+
export declare function htmlToMarkdown(html: string, maxChars: number): HtmlMarkdownResult;
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML → GFM Markdown 转换(本地直抓回退的呈现层,todo 4 `local-fetch.ts` 消费)。
|
|
3
|
+
*
|
|
4
|
+
* 语义逐条移植自官方 DSH `web_fetch` 渲染器
|
|
5
|
+
* (`dsh-LLM-icon/.research/dsh-src/packages/web/tool-web/src/fetch.ts`):
|
|
6
|
+
* turndown + GFM 插件、`removeNonVisibleContent` 规则、无跨列展开的表格规则、
|
|
7
|
+
* 512 层嵌套词法守卫与固定省略标记。官方实现里与本模块无关的呈现职责
|
|
8
|
+
* (`Fetched <url>` 头、截断脚注、WeakMap memoization)一律不搬——"网页正文不是指令"
|
|
9
|
+
* 由 web_retriever persona 的硬规则承担,不在正文里插水印。
|
|
10
|
+
*
|
|
11
|
+
* 官方没做、这里补上的第四道:`stripInvisibleText`(零宽 / 双向覆盖 / C0-C1 控制符剥离)。
|
|
12
|
+
* 它放在本模块导出,是因为 invisibles 的入口不止 HTML:`extractTitle` 会解码数字实体
|
|
13
|
+
* (`‮` 解出来就是 RLO),AnySearch 返回的清洗正文也原样带这些字符。
|
|
14
|
+
* 三个内容入口(本地转换、title、具名来源页面字段)共用这一份实现(AGENTS.md §9.7)。
|
|
15
|
+
*
|
|
16
|
+
* 为什么必须补这三道保护(turndown 的已知缺口):
|
|
17
|
+
* - turndown 默认**不删** `<script>/<style>`,其正文会当文本带进输出;
|
|
18
|
+
* - turndown 完全**不处理**隐藏元素;
|
|
19
|
+
* - turndown 的转换按元素递归,超深嵌套下可能爆栈或长时间占用事件循环——
|
|
20
|
+
* 官方实测:嵌套 512 层 ≈ 0.15s、2000 层 ≈ 2s、20000 层 ≈ 5s,期间协作式
|
|
21
|
+
* 超时定时器无法触发,所以超过 512 层直接放弃转换。
|
|
22
|
+
*/
|
|
23
|
+
import TurndownService from 'turndown';
|
|
24
|
+
import { gfm } from '@joplin/turndown-plugin-gfm';
|
|
25
|
+
/** 嵌套深度上限:超过则跳过转换、返回固定省略标记。健壮性不变量,不是可调参数。 */
|
|
26
|
+
export const MAX_CONVERSION_DEPTH = 512;
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// 不可见字符剥离(外部内容共用的出口)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* 网页里可以出现**完全没有字形**的字符,而它们会原样进入模型上下文与用户看到的引用:
|
|
32
|
+
* - C0/C1 控制符与 DEL:`\r`、`\x00` 之类能把一行截断或让后续文本覆盖已显示内容;
|
|
33
|
+
* - 软连字符 `U+00AD`、零宽系列 `U+200B–U+200F`(含 LRM/RLM 方向标记)/ `U+2060–U+2064` / BOM:
|
|
34
|
+
* 肉眼不可见,却能把 `忽略前面的指令` 写成关键词字面检查**认不出**的形状;
|
|
35
|
+
* - 双向覆盖 `U+202A–U+202E` / 隔离符 `U+2066–U+2069`:改变一行的**显示顺序**
|
|
36
|
+
* (Trojan Source 一类攻击),回显给用户的就是假的文本;
|
|
37
|
+
* - `U+2028` / `U+2029`:行/段分隔符,渲染上与 `\n` 等价、按行处理时又不算换行。
|
|
38
|
+
*
|
|
39
|
+
* 只删不替换成占位符:这些字符在合法正文里没有承载信息,删除是保序的。
|
|
40
|
+
* 换行 `\n`、回车 `\r`、制表 `\t` 保留(markdown 呈现需要)。
|
|
41
|
+
*/
|
|
42
|
+
const INVISIBLE_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/gu;
|
|
43
|
+
export function stripInvisibleText(value) {
|
|
44
|
+
return value.replace(INVISIBLE_PATTERN, '');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 模块作用域唯一的 turndown 实例。官方注释明说该实例跨 `turndown()` 调用无状态、
|
|
48
|
+
* 可共享;规则只注册一次,全部转换复用(测试用幂等用例钉住这一点)。
|
|
49
|
+
* 规则注册顺序与官方一致:先 `use(gfm)`,再 remove 规则,最后表格规则——
|
|
50
|
+
* turndown 按后注册先匹配解析规则,表格规则因此覆盖 gfm 插件自带的跨列展开实现。
|
|
51
|
+
*/
|
|
52
|
+
const turndown = new TurndownService({
|
|
53
|
+
headingStyle: 'atx',
|
|
54
|
+
codeBlockStyle: 'fenced',
|
|
55
|
+
bulletListMarker: '-',
|
|
56
|
+
});
|
|
57
|
+
turndown.use(gfm);
|
|
58
|
+
/** 命中即整块删除(replacement 返回空串),脚本/样式/隐藏元素的内容不进输出。 */
|
|
59
|
+
turndown.addRule('removeNonVisibleContent', {
|
|
60
|
+
filter(node) {
|
|
61
|
+
if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'IFRAME', 'OBJECT', 'EMBED'].includes(node.nodeName))
|
|
62
|
+
return true;
|
|
63
|
+
if (node.hasAttribute('hidden') || node.getAttribute('aria-hidden')?.toLowerCase() === 'true')
|
|
64
|
+
return true;
|
|
65
|
+
if (node.nodeName === 'INPUT' && node.getAttribute('type')?.toLowerCase() === 'hidden')
|
|
66
|
+
return true;
|
|
67
|
+
const declarations = node.getAttribute('style')?.split(';') ?? [];
|
|
68
|
+
return declarations.some((declaration) => {
|
|
69
|
+
const separator = declaration.indexOf(':');
|
|
70
|
+
if (separator === -1)
|
|
71
|
+
return false;
|
|
72
|
+
const property = declaration.slice(0, separator).trim().toLowerCase();
|
|
73
|
+
const value = declaration.slice(separator + 1).trim().toLowerCase().replace(/\s*!important\s*$/u, '');
|
|
74
|
+
return (property === 'display' && value === 'none')
|
|
75
|
+
|| (property === 'visibility' && (value === 'hidden' || value === 'collapse'));
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
replacement() {
|
|
79
|
+
return '';
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
/** 渲染一个 GFM 表格单元格,不解释 HTML 的跨列计数。 */
|
|
83
|
+
function renderTableCell(content, index) {
|
|
84
|
+
const prefix = index === 0 ? '| ' : ' ';
|
|
85
|
+
const escaped = content.trim().replace(/\n\r/g, '<br>').replace(/\n/g, '<br>').replace(/\|+/g, '\\|').padEnd(3, ' ');
|
|
86
|
+
return `${prefix}${escaped} |`;
|
|
87
|
+
}
|
|
88
|
+
/** 该行是否表格的 Markdown 表头行。 */
|
|
89
|
+
function isTableHeadingRow(row) {
|
|
90
|
+
const cells = Array.from(row.cells);
|
|
91
|
+
const section = row.parentElement;
|
|
92
|
+
const table = section.parentElement;
|
|
93
|
+
return (section.nodeName === 'THEAD' || table.rows[0] === row)
|
|
94
|
+
&& cells.every(cell => cell.nodeName === 'TH');
|
|
95
|
+
}
|
|
96
|
+
/** 把 HTML 单元格对齐方式映射成 GFM 分隔符。 */
|
|
97
|
+
function tableBorder(cell) {
|
|
98
|
+
const alignment = (cell.getAttribute('align') || cell.style.textAlign || '').toLowerCase();
|
|
99
|
+
if (alignment === 'left')
|
|
100
|
+
return ':---';
|
|
101
|
+
if (alignment === 'right')
|
|
102
|
+
return '---:';
|
|
103
|
+
if (alignment === 'center')
|
|
104
|
+
return ':---:';
|
|
105
|
+
return '---';
|
|
106
|
+
}
|
|
107
|
+
turndown.addRule('tableCellWithoutSpanExpansion', {
|
|
108
|
+
filter: ['th', 'td'],
|
|
109
|
+
replacement(content, node) {
|
|
110
|
+
const cell = node;
|
|
111
|
+
const row = cell.parentNode;
|
|
112
|
+
// GFM 无法表达跨列。忽略 colspan 让转换工作量与输出和源成正比,
|
|
113
|
+
// 而不是跟数字属性走(gfm 插件自带规则会按 colspan 展开成多个空单元格)。
|
|
114
|
+
return renderTableCell(content, Array.from(row.childNodes).indexOf(cell));
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
turndown.addRule('tableRowWithoutSpanExpansion', {
|
|
118
|
+
filter: 'tr',
|
|
119
|
+
replacement(content, node) {
|
|
120
|
+
const row = node;
|
|
121
|
+
const border = isTableHeadingRow(row)
|
|
122
|
+
? Array.from(row.cells, (cell, index) => renderTableCell(tableBorder(cell), index)).join('')
|
|
123
|
+
: '';
|
|
124
|
+
return `\n${content}${border.length > 0 ? `\n${border}` : ''}`;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// 嵌套深度词法守卫(官方 fetch.ts:120-223 的移植)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
/** 永不接收闭合标签的元素:不进词法栈。 */
|
|
131
|
+
const VOID_ELEMENTS = new Set([
|
|
132
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
133
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
134
|
+
]);
|
|
135
|
+
/** 正文按纯文本解析、直到匹配闭合标签的元素。 */
|
|
136
|
+
const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'noscript']);
|
|
137
|
+
/** 一个字符能否出现在 raw-text 闭合标签名之后。 */
|
|
138
|
+
function isTagBoundary(char) {
|
|
139
|
+
return char === undefined || char === '>' || char === '/' || /\s/.test(char);
|
|
140
|
+
}
|
|
141
|
+
/** 找 raw-text 元素的匹配闭合标签,不把正文里长得像标记的文本当标签。 */
|
|
142
|
+
function findRawTextEnd(lowerHtml, name, from) {
|
|
143
|
+
const prefix = `</${name}`;
|
|
144
|
+
let candidate = lowerHtml.indexOf(prefix, from);
|
|
145
|
+
while (candidate !== -1 && !isTagBoundary(lowerHtml[candidate + prefix.length])) {
|
|
146
|
+
candidate = lowerHtml.indexOf(prefix, candidate + prefix.length);
|
|
147
|
+
}
|
|
148
|
+
return candidate;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 保守地拒绝词法元素栈超过转换深度上限的 HTML。单遍扫描:忽略注释里的闭合
|
|
152
|
+
* 标签、跳过 raw-text 正文、尊重引号里的 `>`、只接受与栈顶同名的闭合标签——
|
|
153
|
+
* 畸形输入因此只会多计嵌套,不会把嵌套藏掉。
|
|
154
|
+
*/
|
|
155
|
+
function exceedsConversionDepth(html) {
|
|
156
|
+
const lowerHtml = html.toLowerCase();
|
|
157
|
+
const openElements = [];
|
|
158
|
+
let offset = 0;
|
|
159
|
+
let inComment = false;
|
|
160
|
+
while (offset < html.length) {
|
|
161
|
+
const start = html.indexOf('<', offset);
|
|
162
|
+
if (inComment) {
|
|
163
|
+
const end = html.indexOf('-->', offset);
|
|
164
|
+
if (end !== -1 && (start === -1 || end < start)) {
|
|
165
|
+
inComment = false;
|
|
166
|
+
offset = end + 3;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (start === -1)
|
|
171
|
+
break;
|
|
172
|
+
if (!inComment && html.startsWith('<!--', start)) {
|
|
173
|
+
inComment = true;
|
|
174
|
+
offset = start + 4;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
let cursor = start + 1;
|
|
178
|
+
const closing = html[cursor] === '/';
|
|
179
|
+
if (closing)
|
|
180
|
+
cursor += 1;
|
|
181
|
+
const nameStart = cursor;
|
|
182
|
+
while (/[a-zA-Z0-9-]/.test(html[cursor] ?? ''))
|
|
183
|
+
cursor += 1;
|
|
184
|
+
if (cursor === nameStart || !/[a-zA-Z]/.test(html.charAt(nameStart))) {
|
|
185
|
+
offset = start + 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const name = lowerHtml.slice(nameStart, cursor);
|
|
189
|
+
let quote;
|
|
190
|
+
while (cursor < html.length) {
|
|
191
|
+
const char = html[cursor];
|
|
192
|
+
cursor += 1;
|
|
193
|
+
if (quote !== undefined) {
|
|
194
|
+
if (char === quote)
|
|
195
|
+
quote = undefined;
|
|
196
|
+
}
|
|
197
|
+
else if (char === '"' || char === "'") {
|
|
198
|
+
quote = char;
|
|
199
|
+
}
|
|
200
|
+
else if (char === '>') {
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (html[cursor - 1] !== '>')
|
|
205
|
+
break;
|
|
206
|
+
if (closing) {
|
|
207
|
+
if (!inComment && openElements.at(-1) === name)
|
|
208
|
+
openElements.pop();
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
let last = cursor - 2;
|
|
212
|
+
while (/\s/.test(html.charAt(last)))
|
|
213
|
+
last -= 1;
|
|
214
|
+
if (!VOID_ELEMENTS.has(name) && html[last] !== '/') {
|
|
215
|
+
openElements.push(name);
|
|
216
|
+
if (openElements.length > MAX_CONVERSION_DEPTH)
|
|
217
|
+
return true;
|
|
218
|
+
if (!inComment && RAW_TEXT_ELEMENTS.has(name)) {
|
|
219
|
+
const end = findRawTextEnd(lowerHtml, name, cursor);
|
|
220
|
+
if (end === -1)
|
|
221
|
+
break;
|
|
222
|
+
offset = end;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
offset = cursor;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// title 提取
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
/** 只在开头固定窗口内找 `<title>`:title 位于 head、靠文档开头,窗口上限避免对超长正文全量扫描。 */
|
|
235
|
+
const TITLE_SCAN_CHARS = 2048;
|
|
236
|
+
const TITLE_PATTERN = /<title[^>]*>([^<]*)<\/title>/i;
|
|
237
|
+
const ENTITY_PATTERN = /&(?:amp|lt|gt|quot|nbsp|#[0-9]+|#[xX][0-9a-fA-F]+);/g;
|
|
238
|
+
const NAMED_ENTITIES = new Map([
|
|
239
|
+
['amp', '&'],
|
|
240
|
+
['lt', '<'],
|
|
241
|
+
['gt', '>'],
|
|
242
|
+
['quot', '"'],
|
|
243
|
+
['nbsp', '\u00a0'],
|
|
244
|
+
]);
|
|
245
|
+
/** 解码数字字符引用(十进制与十六进制);非法码点返回 null(保留原文)。 */
|
|
246
|
+
function decodeNumericEntity(body) {
|
|
247
|
+
const hex = body[1] === 'x' || body[1] === 'X';
|
|
248
|
+
const code = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
249
|
+
if (!(code >= 0 && code <= 0x10ffff))
|
|
250
|
+
return null;
|
|
251
|
+
return String.fromCodePoint(code);
|
|
252
|
+
}
|
|
253
|
+
/** 单遍解码实体:`&lt;` 只解成 `<`,不会第二轮再解成 `<`。 */
|
|
254
|
+
function decodeEntities(text) {
|
|
255
|
+
return text.replace(ENTITY_PATTERN, (match) => {
|
|
256
|
+
const body = match.slice(1, -1);
|
|
257
|
+
const named = NAMED_ENTITIES.get(body);
|
|
258
|
+
if (named !== undefined)
|
|
259
|
+
return named;
|
|
260
|
+
return decodeNumericEntity(body) ?? match;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 从 HTML 开头提取 `<title>` 并解码实体、压空白。找不到或解码后为空返回
|
|
265
|
+
* `undefined`。注意:`<title>` 的文本按官方行为仍会留在 markdown 正文里
|
|
266
|
+
* (官方 remove 列表不含 TITLE),本函数只负责回执的 `title` 字段。
|
|
267
|
+
*/
|
|
268
|
+
export function extractTitle(html) {
|
|
269
|
+
const match = TITLE_PATTERN.exec(html.slice(0, TITLE_SCAN_CHARS));
|
|
270
|
+
if (match === null)
|
|
271
|
+
return undefined;
|
|
272
|
+
const title = stripInvisibleText(decodeEntities(match[1])).replace(/\s+/g, ' ').trim();
|
|
273
|
+
return title === '' ? undefined : title;
|
|
274
|
+
}
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// 入口
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
/** 转换不安全(超深或 turndown 抛错)时返回的固定省略标记。 */
|
|
279
|
+
const OMITTED_MARKDOWN = '[HTML content omitted: unable to convert safely.]';
|
|
280
|
+
function omittedResult(title, sourceTruncated, maxChars) {
|
|
281
|
+
const outputTruncated = OMITTED_MARKDOWN.length > maxChars;
|
|
282
|
+
return {
|
|
283
|
+
...(title !== undefined ? { title } : {}),
|
|
284
|
+
markdown: outputTruncated ? OMITTED_MARKDOWN.slice(0, maxChars) : OMITTED_MARKDOWN,
|
|
285
|
+
truncated: sourceTruncated || outputTruncated,
|
|
286
|
+
omitted: true,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* 把 HTML 转成有界 GFM markdown。
|
|
291
|
+
*
|
|
292
|
+
* 流程:先按 `maxChars` 截断源串(记 `truncated`,与官方 renderBody 一致)→
|
|
293
|
+
* 超深或 turndown 抛错 → 固定省略标记 + `omitted: true`(不让原始标记外漏)→
|
|
294
|
+
* 否则转换,剥掉不可见字符(`stripInvisibleText`),再按 `maxChars` 截断输出。
|
|
295
|
+
* `title` 一律从**原始** HTML 提取。
|
|
296
|
+
*/
|
|
297
|
+
export function htmlToMarkdown(html, maxChars) {
|
|
298
|
+
const title = extractTitle(html);
|
|
299
|
+
const content = html.slice(0, maxChars);
|
|
300
|
+
const sourceTruncated = content.length !== html.length;
|
|
301
|
+
if (exceedsConversionDepth(content)) {
|
|
302
|
+
return omittedResult(title, sourceTruncated, maxChars);
|
|
303
|
+
}
|
|
304
|
+
let converted;
|
|
305
|
+
try {
|
|
306
|
+
converted = turndown.turndown(content);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// turndown 的 DOM 遍历按元素递归;词法守卫建模不了的畸形标记仍可能抛
|
|
310
|
+
// RangeError。转换失败与超深走同一个省略标记,不把源标记带回模型上下文。
|
|
311
|
+
return omittedResult(title, sourceTruncated, maxChars);
|
|
312
|
+
}
|
|
313
|
+
const sanitized = stripInvisibleText(converted);
|
|
314
|
+
const outputTruncated = sanitized.length > maxChars;
|
|
315
|
+
return {
|
|
316
|
+
...(title !== undefined ? { title } : {}),
|
|
317
|
+
markdown: outputTruncated ? sanitized.slice(0, maxChars) : sanitized,
|
|
318
|
+
truncated: sourceTruncated || outputTruncated,
|
|
319
|
+
omitted: false,
|
|
320
|
+
};
|
|
321
|
+
}
|