@v587d/capital-generation 2.8.3 → 2.9.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 +81 -1
- package/README.md +40 -19
- package/chart-ui/README.md +5 -4
- package/chart-ui/client.src.cjs +7 -16
- package/chart-ui/index.js +12 -33
- package/chart-ui/package.json +2 -2
- package/config/client.js +18 -39
- package/config/client.src.cjs +79 -217
- package/config/index.js +22 -72
- package/config/package.json +2 -2
- package/cordis.patch.yml +46 -51
- package/lib/agents/bash-guard.d.ts +9 -55
- package/lib/agents/bash-guard.js +27 -67
- package/lib/agents/preset-roster.d.ts +22 -0
- package/lib/agents/preset-roster.js +87 -0
- package/lib/agents/role-tool-policy.d.ts +46 -0
- package/lib/agents/role-tool-policy.js +205 -0
- package/lib/agents/root-tool-policy.d.ts +28 -124
- package/lib/agents/root-tool-policy.js +164 -237
- package/lib/agents/selected-skills-provider.d.ts +2 -6
- package/lib/agents/selected-skills-provider.js +27 -84
- package/lib/capital-settings.d.ts +27 -0
- package/lib/capital-settings.js +23 -0
- package/lib/chart/artifact-ref.d.ts +13 -8
- package/lib/chart/artifact-ref.js +27 -10
- package/lib/chart/downsample.d.ts +2 -5
- package/lib/chart/downsample.js +3 -8
- package/lib/chart/errors.d.ts +3 -7
- package/lib/chart/errors.js +1 -2
- package/lib/chart/event-context.d.ts +8 -0
- package/lib/chart/event-context.js +39 -0
- package/lib/chart/events.d.ts +21 -78
- package/lib/chart/events.js +43 -189
- package/lib/chart/html.d.ts +2 -5
- package/lib/chart/html.js +8 -15
- package/lib/chart/present-tool.d.ts +28 -0
- package/lib/chart/present-tool.js +128 -0
- package/lib/chart/runtime.d.ts +5 -18
- package/lib/chart/runtime.js +14 -34
- package/lib/chart/series.d.ts +10 -17
- package/lib/chart/series.js +22 -47
- package/lib/chart/source-token.d.ts +5 -13
- package/lib/chart/source-token.js +18 -17
- package/lib/chart/spec.d.ts +6 -12
- package/lib/chart/spec.js +7 -31
- package/lib/chart/tool.d.ts +7 -29
- package/lib/chart/tool.js +15 -25
- package/lib/chart-render-entry.d.ts +10 -0
- package/lib/chart-render-entry.js +14 -0
- package/lib/chart-source-entry.d.ts +13 -0
- package/lib/chart-source-entry.js +20 -0
- package/lib/collect-entry.d.ts +9 -0
- package/lib/collect-entry.js +10 -0
- package/lib/collector-runtime.d.ts +22 -0
- package/lib/collector-runtime.js +97 -0
- package/lib/data-collector/args.d.ts +6 -24
- package/lib/data-collector/args.js +6 -24
- package/lib/data-collector/dataset-tools.d.ts +7 -1
- package/lib/data-collector/dataset-tools.js +27 -56
- package/lib/data-collector/describe.d.ts +8 -33
- package/lib/data-collector/describe.js +17 -70
- package/lib/data-collector/hub.d.ts +27 -85
- package/lib/data-collector/hub.js +19 -85
- package/lib/data-collector/query-time.d.ts +7 -26
- package/lib/data-collector/query-time.js +8 -27
- package/lib/data-collector/store.d.ts +41 -117
- package/lib/data-collector/store.js +32 -111
- package/lib/data-collector/time-axis.d.ts +16 -76
- package/lib/data-collector/time-axis.js +25 -111
- package/lib/data-collector/tools.d.ts +6 -6
- package/lib/data-collector/tools.js +34 -43
- package/lib/dataset-read-entry.d.ts +9 -0
- package/lib/dataset-read-entry.js +7 -0
- package/lib/index.d.ts +7 -117
- package/lib/index.js +43 -422
- package/lib/net/eastmoney-client.d.ts +13 -65
- package/lib/net/eastmoney-client.js +12 -53
- package/lib/net/throttle.d.ts +2 -13
- package/lib/net/throttle.js +3 -14
- package/lib/ocr/client.d.ts +12 -21
- package/lib/ocr/client.js +23 -40
- package/lib/ocr/markdown.d.ts +20 -26
- package/lib/ocr/markdown.js +23 -36
- package/lib/ocr/store.d.ts +14 -47
- package/lib/ocr/store.js +15 -45
- package/lib/ocr-entry.d.ts +9 -0
- package/lib/ocr-entry.js +10 -0
- package/lib/policy-entry.d.ts +17 -0
- package/lib/policy-entry.js +22 -0
- package/lib/present-entry.d.ts +9 -0
- package/lib/present-entry.js +13 -0
- package/lib/preset-claim.d.ts +22 -0
- package/lib/preset-claim.js +24 -0
- package/lib/preset-ids.d.ts +38 -0
- package/lib/preset-ids.js +46 -0
- package/lib/retriever-config.d.ts +37 -0
- package/lib/retriever-config.js +36 -0
- package/lib/retriever-runtime.d.ts +31 -0
- package/lib/retriever-runtime.js +82 -0
- package/lib/root-extras-entry.d.ts +17 -0
- package/lib/root-extras-entry.js +25 -0
- package/lib/runtime-entry.d.ts +15 -0
- package/lib/runtime-entry.js +5 -0
- package/lib/runtime.d.ts +21 -0
- package/lib/runtime.js +33 -0
- package/lib/search-entry.d.ts +9 -0
- package/lib/search-entry.js +9 -0
- package/lib/sources/eastmoney-http.js +20 -68
- package/lib/sources/ftshare-rest.d.ts +6 -34
- package/lib/sources/ftshare-rest.js +84 -292
- package/lib/sources/fuyao-core.d.ts +8 -18
- package/lib/sources/fuyao-core.js +10 -21
- package/lib/sources/fuyao-rest.d.ts +2 -6
- package/lib/sources/fuyao-rest.js +23 -102
- package/lib/sources/security-code.d.ts +6 -12
- package/lib/sources/security-code.js +6 -12
- package/lib/sources/security-identity.d.ts +1 -4
- package/lib/sources/security-identity.js +3 -8
- package/lib/sources/tencent-http.js +14 -28
- package/lib/sources/tencent-public-core.d.ts +8 -60
- package/lib/sources/tencent-public-core.js +12 -77
- package/lib/sources/wind-mcp.d.ts +0 -5
- package/lib/sources/wind-mcp.js +23 -78
- package/lib/sources-entry.d.ts +9 -0
- package/lib/sources-entry.js +9 -0
- package/lib/time/tools.d.ts +8 -31
- package/lib/time/tools.js +30 -66
- package/lib/time-entry.d.ts +9 -0
- package/lib/time-entry.js +7 -0
- package/lib/tool-exec.d.ts +45 -11
- package/lib/tool-exec.js +59 -6
- package/lib/user-customization.d.ts +14 -0
- package/lib/user-customization.js +47 -0
- package/lib/watchlist/tools.d.ts +3 -13
- package/lib/watchlist/tools.js +10 -30
- package/lib/watchlist-entry.d.ts +9 -0
- package/lib/watchlist-entry.js +6 -0
- package/lib/web-retriever/engines.d.ts +6 -11
- package/lib/web-retriever/engines.js +9 -14
- package/lib/web-retriever/html-markdown.d.ts +14 -34
- package/lib/web-retriever/html-markdown.js +39 -75
- package/lib/web-retriever/local-fetch.d.ts +22 -58
- package/lib/web-retriever/local-fetch.js +33 -77
- package/lib/web-retriever/retriever.d.ts +6 -18
- package/lib/web-retriever/retriever.js +7 -7
- package/lib/web-retriever/sources.d.ts +37 -124
- package/lib/web-retriever/sources.js +64 -196
- package/lib/web-retriever/tools.d.ts +43 -27
- package/lib/web-retriever/tools.js +99 -137
- package/lib/web-retriever/wind-client.d.ts +14 -31
- package/lib/web-retriever/wind-client.js +16 -32
- package/locale/zh.json +1 -1
- package/package.json +72 -7
- package/preset/capital-generation/README.md +87 -53
- package/preset/capital-generation/agent.patch.yml +532 -337
- package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +6 -4
- package/preset/capital-generation/skills/capital-data-profile/SKILL.md +2 -2
- package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +1 -1
- package/preset/capital-generation/skills/capital-equity-research/SKILL.md +4 -2
- package/preset/capital-generation/skills/capital-event-impact/SKILL.md +1 -1
- package/preset/capital-generation/skills/capital-orchestration/SKILL.md +30 -33
- package/preset/capital-generation/skills/capital-portfolio-review/SKILL.md +1 -1
- package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +14 -16
- package/preset/capital-generation/skills/capital-web-evidence/SKILL.md +1 -1
- package/watchlist/client.js +15 -148
- package/watchlist/client.src.cjs +148 -662
- package/watchlist/holding-rules.js +11 -38
- package/watchlist/index.js +127 -397
- package/watchlist/package.json +2 -2
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* 这里是"一个具名来源 + 业务参数 → 确定、有序、可翻页、同参可复现的结果集"的**查询**工作面。
|
|
6
|
-
* 判据不是"返回 JSON 还是 HTML"(`SearchHit` 本来就带 `content?`,形状无法区分),
|
|
7
|
-
* 而是结果集是否确定、是否可复现、以及"这次调用承诺了什么"——见
|
|
8
|
-
* `docs/design/web-retriever-source-expansion.md` §2.2。
|
|
9
|
-
*
|
|
10
|
-
* 七条来源各自是一次具体实现,**不做配方注册表 / 通用适配层**:只有这样,
|
|
11
|
-
* 每个来源的端点、签名、字段语义才能就地写清楚,不引入一层只为"统一"而存在的间接。
|
|
12
|
-
* 出口校验(URL 合法性 + DNS 公网 IP 闸门 + 重定向重校验 + 大小/超时上限)统一复用
|
|
13
|
-
* `createHttpRequester`,本文件不自建网络路径(AGENTS.md §9.7)。
|
|
2
|
+
* Named sources: source + business params -> ordered, pageable, reproducible rows.
|
|
3
|
+
* Each stays a concrete implementation (no recipe registry); all egress goes
|
|
4
|
+
* through `createHttpRequester` (SSRF/DNS/redirect/size guards, AGENTS.md §9.7).
|
|
14
5
|
*/
|
|
15
6
|
import { createHttpRequester, safeUrl } from './local-fetch.js';
|
|
16
7
|
import { stripInvisibleText } from './html-markdown.js';
|
|
17
8
|
export const SOURCE_IDS = ['cls', 'wscn', 'cninfo_irm', 'sseinfo', 'eastmoney', 'ths', 'sina'];
|
|
18
|
-
/**
|
|
9
|
+
/** Per-item text cap; item count is capped separately by each tool's limit. */
|
|
19
10
|
export const MAX_ITEM_CHARS = 1200;
|
|
20
|
-
/**
|
|
11
|
+
/** Max items per output (must match the tool schema maximum). */
|
|
21
12
|
export const MAX_ITEMS = 50;
|
|
22
13
|
export class SourceError extends Error {
|
|
23
14
|
source;
|
|
@@ -34,18 +25,13 @@ function createHash(algorithm) {
|
|
|
34
25
|
const processLike = globalThis.process;
|
|
35
26
|
return processLike?.getBuiltinModule?.('node:crypto')?.createHash?.(algorithm);
|
|
36
27
|
}
|
|
37
|
-
/**
|
|
28
|
+
/** Built per source-tool call; lazy transport, configured timeout. */
|
|
38
29
|
export function createSourceTransport(options) {
|
|
39
30
|
return createHttpRequester({ ...options, timeoutMs: options.timeoutMs || REQUEST_TIMEOUT_MS });
|
|
40
31
|
}
|
|
41
32
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* 裸 `fetch`(AGENTS.md §9.7)。节流器不在这里——仍由进程级共享的东财节流器提供
|
|
45
|
-
* (`sharedEastmoneyThrottle()`),本适配只替换"怎么发出去"。
|
|
46
|
-
*
|
|
47
|
-
* 带分类 `code` 的 `LocalFetchError` 原样穿过(`createEastmoneyClient` 见 code 即不包裹),
|
|
48
|
-
* 取消因此保持 `ABORTED` 语义、不被降级成网络失败(docs/dev/web-retriever.md §4.2)。
|
|
33
|
+
* Eastmoney shares the other sources' egress gate; throttling stays process-wide.
|
|
34
|
+
* Coded `LocalFetchError`s pass through unwrapped so ABORTED is not downgraded.
|
|
49
35
|
*/
|
|
50
36
|
export function createGatedEastmoneyTransport(requester) {
|
|
51
37
|
return async (url, init) => {
|
|
@@ -57,26 +43,21 @@ export function createGatedEastmoneyTransport(requester) {
|
|
|
57
43
|
};
|
|
58
44
|
};
|
|
59
45
|
}
|
|
60
|
-
/**
|
|
46
|
+
/** Link cap: a link is a reference, not the bulk of the output budget. */
|
|
61
47
|
const ITEM_URL_MAX_CHARS = 500;
|
|
62
48
|
function clip(text) {
|
|
63
49
|
if (typeof text !== 'string')
|
|
64
50
|
return '';
|
|
65
|
-
//
|
|
51
|
+
// Same as fetched body: strip invisibles, then clip.
|
|
66
52
|
const trimmed = stripInvisibleText(text).trim();
|
|
67
53
|
return trimmed.length > MAX_ITEM_CHARS ? `${trimmed.slice(0, MAX_ITEM_CHARS)}…` : trimmed;
|
|
68
54
|
}
|
|
69
|
-
/**
|
|
55
|
+
/** Drop the field (not the item) for non-http(s), control chars, credentials or length. */
|
|
70
56
|
function itemUrl(value) {
|
|
71
57
|
return safeUrl(value, ITEM_URL_MAX_CHARS);
|
|
72
58
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
*
|
|
76
|
-
* ⛔ 顺序必须是"先解码实体、后剥标签":反过来时外部内容里的 `<system>…</system>`
|
|
77
|
-
* 会在剥完标签后被解码成**活标签**,进入模型上下文就是提示注入面。解码出的新标签同样要剥,
|
|
78
|
-
* 所以标签剥离循环到有界轮数(双重编码的 `&lt;` 解出来是字面 `<`,惰性问题不大)。
|
|
79
|
-
*/
|
|
59
|
+
// Decode entities before stripping tags, then strip until stable: the reverse order
|
|
60
|
+
// turns escaped `<system>` text into live tags (prompt injection surface).
|
|
80
61
|
function stripHtml(fragment) {
|
|
81
62
|
let text = stripInvisibleText(fragment)
|
|
82
63
|
.replace(/ /gu, ' ')
|
|
@@ -124,11 +105,7 @@ function requireRows(value, source, what) {
|
|
|
124
105
|
function clipItems(items) {
|
|
125
106
|
return items.length > MAX_ITEMS ? items.slice(0, MAX_ITEMS) : items;
|
|
126
107
|
}
|
|
127
|
-
/**
|
|
128
|
-
* 上游数值有时是**字符串**(东财 `predictThisYearEps: '1.0700000000'`,保留精度),
|
|
129
|
-
* 有时是 number。按值转换而不是按类型判定,否则"字符串型数值"会被静默当成缺失。
|
|
130
|
-
* 认不出(空串 / 非数值)一律 `null`——绝不猜一个数字出来。
|
|
131
|
-
*/
|
|
108
|
+
/** Upstream numbers may be strings (precision); parse by value, unknown => null. */
|
|
132
109
|
function toNumber(value) {
|
|
133
110
|
if (typeof value === 'number')
|
|
134
111
|
return Number.isFinite(value) ? value : null;
|
|
@@ -137,16 +114,9 @@ function toNumber(value) {
|
|
|
137
114
|
const parsed = Number(value);
|
|
138
115
|
return Number.isFinite(parsed) ? parsed : null;
|
|
139
116
|
}
|
|
140
|
-
// ── 财联社快讯(cls.cn)───────────────────────────────────────────────────────
|
|
141
117
|
const CLS_URL = 'https://www.cls.cn/v1/roll/get_roll_list';
|
|
142
118
|
const CLS_REFERER = 'https://www.cls.cn/';
|
|
143
|
-
/**
|
|
144
|
-
* 财联社 v1 接口的本地签名:`sign = md5(sha1(按 key 字典序拼接的 query 串))`。
|
|
145
|
-
*
|
|
146
|
-
* 旧 `nodeapi/telegraphList` 于 2026-05 下线,现接口强制校验 sign,但**纯本地计算、无需任何 key**
|
|
147
|
-
* (外部实践文献 5.2 节,2026-07 复活;2026-09-24 本仓按此式复现,`errno=0` 正常返回)。
|
|
148
|
-
* 签名必须由代码在请求前算出——这正是"模型只给参数、不给 URL"的原因。
|
|
149
|
-
*/
|
|
119
|
+
/** Local sign `md5(sha1(query sorted by key))`; no API key, so the model passes params, never URLs. */
|
|
150
120
|
export function clsSign(params) {
|
|
151
121
|
const query = Object.keys(params).sort().map((key) => `${key}=${params[key]}`).join('&');
|
|
152
122
|
const sha1 = createHash('sha1');
|
|
@@ -180,7 +150,7 @@ export async function clsTelegraph(transport, input) {
|
|
|
180
150
|
const row = asRecord(raw);
|
|
181
151
|
if (!row)
|
|
182
152
|
continue;
|
|
183
|
-
//
|
|
153
|
+
// Promos/ads are not telegraphs; keep them out of evidence.
|
|
184
154
|
if (row.in_roll === false || row.is_ad === true)
|
|
185
155
|
continue;
|
|
186
156
|
const ctime = typeof row.ctime === 'number' ? row.ctime : undefined;
|
|
@@ -203,7 +173,6 @@ export async function clsTelegraph(transport, input) {
|
|
|
203
173
|
...(items.length > MAX_ITEMS ? { note: `已截断为前 ${MAX_ITEMS} 条` } : {}),
|
|
204
174
|
};
|
|
205
175
|
}
|
|
206
|
-
// ── 华尔街见闻 7×24 快讯(api-one-wscn.awtmt.com)────────────────────────────
|
|
207
176
|
const WSCN_URL = 'https://api-one-wscn.awtmt.com/apiv1/content/lives';
|
|
208
177
|
export async function wscnLives(transport, input) {
|
|
209
178
|
const params = new URLSearchParams({ channel: input.channel, limit: String(input.limit) });
|
|
@@ -232,7 +201,7 @@ export async function wscnLives(transport, input) {
|
|
|
232
201
|
time: cstStamp(stamp),
|
|
233
202
|
title: clip(row.title),
|
|
234
203
|
content: clip(row.content_text),
|
|
235
|
-
// score
|
|
204
|
+
// score is wscn's importance (observed 1-3, higher = more important).
|
|
236
205
|
importance: typeof row.score === 'number' ? row.score : null,
|
|
237
206
|
url: itemUrl(row.uri) || (typeof row.id === 'number' ? `https://wallstreetcn.com/livenews/${row.id}` : ''),
|
|
238
207
|
});
|
|
@@ -245,19 +214,14 @@ export async function wscnLives(transport, input) {
|
|
|
245
214
|
...(typeof next === 'string' || typeof next === 'number' ? { next_cursor: String(next) } : {}),
|
|
246
215
|
};
|
|
247
216
|
}
|
|
248
|
-
// ── 互动易问答(irm.cninfo.com.cn,深市)────────────────────────────────────
|
|
249
217
|
const IRM_KEYWORD_URL = 'https://irm.cninfo.com.cn/newircs/index/queryKeyboardInfo';
|
|
250
218
|
const IRM_QUESTION_URL = 'https://irm.cninfo.com.cn/newircs/company/question';
|
|
251
219
|
function formBody(fields) {
|
|
252
220
|
return new URLSearchParams(fields).toString();
|
|
253
221
|
}
|
|
254
222
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* ⚠️ 实测坑(2026-09-24 本仓):第二步**必须 POST**(参数放 query 串)——
|
|
258
|
-
* 同一 URL 用 GET 会得到 **HTTP 405**。外部文献只写了"参数放 query string",
|
|
259
|
-
* 没写这一条,而少了 POST 就是 405。
|
|
260
|
-
* 覆盖范围:只覆盖深市;沪市实测返回 0 条(走 `sseinfo_qa`);北交所两个平台都没有。
|
|
223
|
+
* Two steps: `keyWord=code` -> `secid` (orgId) -> questions; step two must be POST
|
|
224
|
+
* (GET on the same URL returns 405). Shenzhen only; SSE questions via `sseinfo_qa`.
|
|
261
225
|
*/
|
|
262
226
|
export async function cninfoIrm(transport, input) {
|
|
263
227
|
const keyword = await transport.request({ url: IRM_KEYWORD_URL, method: 'POST', body: formBody({ keyWord: input.code }) }, input.signal);
|
|
@@ -298,7 +262,7 @@ export async function cninfoIrm(transport, input) {
|
|
|
298
262
|
company: typeof row.companyShortName === 'string' ? row.companyShortName : '',
|
|
299
263
|
question,
|
|
300
264
|
answer,
|
|
301
|
-
//
|
|
265
|
+
// Empty answer and "not answered yet" are different facts.
|
|
302
266
|
status: answer === '' ? 'unanswered' : 'answered',
|
|
303
267
|
answerer: typeof row.attachedAuthor === 'string' ? row.attachedAuthor : '',
|
|
304
268
|
time: cstStamp(stamp / 1000),
|
|
@@ -311,25 +275,16 @@ export async function cninfoIrm(transport, input) {
|
|
|
311
275
|
note: `深市专用:互动易对沪市公司返回 0 条,沪市问答请用 sseinfo_qa${total === undefined ? '' : `;公司累计问答 ${total} 条`}`,
|
|
312
276
|
};
|
|
313
277
|
}
|
|
314
|
-
// ── 上证e互动(sns.sseinfo.com,沪市)──────────────────────────────────────
|
|
315
278
|
const SSE_BASE = 'https://sns.sseinfo.com';
|
|
316
279
|
const SSE_REFERER = `${SSE_BASE}/`;
|
|
317
280
|
const SSE_KIND = { answered: 11, questions: 10 };
|
|
318
|
-
/**
|
|
281
|
+
/** Fixed sentence returned past the last company-list page. */
|
|
319
282
|
const SSE_COMPANY_END = '没有任何上市公司的信息';
|
|
320
283
|
const SSE_EMPTY_NOTE = /class="m_feed_note"[^>]*>[^<]*(暂无|暂时没有)[^<]*</u;
|
|
321
284
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* 先用 `/<a\b(?:(?!<\/a>)[\s\S])*?<\/a>/g` 切出每个 `<a>` 元素,再在**单个元素内**找 uid 与
|
|
326
|
-
* `company/<6位>.png`。逐元素解析的另一个好处是**跨条目错配在结构上不可能发生**。
|
|
327
|
-
*
|
|
328
|
-
* 为什么不能用"一次正则 + 限制间隔长度"(本函数第一版就是这么写的,被测试当场抓住):
|
|
329
|
-
* 真实页面在 `</a>` 与 `<img>` 之间有一个 `<br/>`,而 A 条目的 `<br/>` 之后紧跟 B 条目,
|
|
330
|
-
* 于是任何形如 `uid=…[\s\S]{0,N}?…png` 的写法在 **N 足够大**时都会跨到下一个条目上——
|
|
331
|
-
* 实测把 `600369` 配到了前一条的 `uid=65`。窗口小则匹配 0 条(外部文献的 `[^>]*>` 就是这个下场),
|
|
332
|
-
* 窗口大则错配;**只有按元素切分才是对的**。
|
|
285
|
+
* Company list page: 32 companies, ascending by code. uid and code come from the same
|
|
286
|
+
* `<a>` element, so cross-item mispairing is structurally impossible (a windowed regex
|
|
287
|
+
* either misses rows or pairs across them).
|
|
333
288
|
*/
|
|
334
289
|
export function parseCompanyList(content) {
|
|
335
290
|
const pairs = [];
|
|
@@ -342,13 +297,8 @@ export function parseCompanyList(content) {
|
|
|
342
297
|
}
|
|
343
298
|
return pairs.sort((left, right) => left.code.localeCompare(right.code));
|
|
344
299
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
*
|
|
348
|
-
* 为什么允许这个缓存:一次定位要 10–13 个请求(外部文献口径),且映射是**代码→uid 的元数据**,
|
|
349
|
-
* 不是内容、不是检索结果,不存在"串了别人的材料"的风险。仍按 SSE_UID_CACHE_LIMIT 封顶,
|
|
350
|
-
* 且**每次使用前都做升序自检**——宁可失败也不返回错配的问答。
|
|
351
|
-
*/
|
|
300
|
+
// Code -> uid via doubling + binary search over the ascending list; the caches hold
|
|
301
|
+
// page metadata only (no content) and stay size-capped.
|
|
352
302
|
const SSE_UID_CACHE_LIMIT = 200;
|
|
353
303
|
const sseUidCache = new Map();
|
|
354
304
|
const ssePageCache = new Map();
|
|
@@ -373,21 +323,16 @@ async function sseCompanyPage(transport, page, signal) {
|
|
|
373
323
|
if (pairs.length === 0 && !content.includes(SSE_COMPANY_END)) {
|
|
374
324
|
throw new SourceError('sseinfo', 'INVALID_RESPONSE', `上证e互动公司列表第 ${page} 页既没有公司也没有末页提示(页面格式可能已变)`);
|
|
375
325
|
}
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
// 二分会据此定位 uid,宁可响亮失败也不返回错配的问答。
|
|
326
|
+
// Verify ordering before binary search: parseCompanyList sorts, so this catches
|
|
327
|
+
// duplicate codes (mispairing); fail loudly rather than locate the wrong uid.
|
|
379
328
|
assertAscending(pairs, page);
|
|
380
329
|
if (ssePageCache.size >= SSE_UID_CACHE_LIMIT) {
|
|
381
|
-
//
|
|
382
|
-
// 而整表清空会把**本次刚刚定位到的** uid 一起丢掉 ⇒ 下一次调用(或同一函数后半段)
|
|
383
|
-
// 又变成 NOT_FOUND。实测形态:单个 resolve 成功、紧接着的 sseinfoQa 却报"没有 600000"。
|
|
330
|
+
// Evict only the oldest entry: clearing all would drop the uid just resolved.
|
|
384
331
|
const oldest = ssePageCache.keys().next().value;
|
|
385
332
|
if (oldest !== undefined)
|
|
386
333
|
ssePageCache.delete(oldest);
|
|
387
334
|
}
|
|
388
335
|
ssePageCache.set(page, pairs);
|
|
389
|
-
// 这里**只**缓存页面本身。曾把整页的 code→uid 也灌进 uid 缓存,结果一次解析就塞满容量并
|
|
390
|
-
// 淘汰掉正在解析的代码(见 `resolveSseCompanyUid` 的注释)。uid 缓存只由"确认命中"写入。
|
|
391
336
|
return pairs;
|
|
392
337
|
}
|
|
393
338
|
function assertAscending(pairs, page) {
|
|
@@ -398,13 +343,11 @@ function assertAscending(pairs, page) {
|
|
|
398
343
|
}
|
|
399
344
|
}
|
|
400
345
|
/**
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* 不能拿"整轮对话还剩多少"来兜一个内部元数据查询。`sseinfoQa` 的 `uidLocateBudgetMs`
|
|
404
|
-
* 仅供测试注入缩短(与 `wind-client.ts` 的 `retryDelaysMs` 同一约定)。
|
|
346
|
+
* One uid lookup = 10-13 serial requests, so no per-request timeout can bound it;
|
|
347
|
+
* `uidLocateBudgetMs` exists only so tests can shorten this.
|
|
405
348
|
*/
|
|
406
349
|
export const SSE_UID_LOCATE_TIMEOUT_MS = 60_000;
|
|
407
|
-
/**
|
|
350
|
+
/** Total deadline for a chain of serial requests, combined with caller cancel. */
|
|
408
351
|
function startDeadline(outer, budgetMs) {
|
|
409
352
|
const controller = new AbortController();
|
|
410
353
|
let fired = false;
|
|
@@ -429,23 +372,15 @@ function startDeadline(outer, budgetMs) {
|
|
|
429
372
|
};
|
|
430
373
|
}
|
|
431
374
|
/**
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* ⚠️ 缓存层次是本函数最容易写错的地方(2026-09-24 实测踩坑,症状很绕):
|
|
435
|
-
* - `ssePageCache` 缓存**页**,供二分复用,不参与"这个代码是谁"的判定;
|
|
436
|
-
* - `sseUidCache` 只缓存**已解析出的那个代码**,绝不在扫描页面时把整页代码灌进去。
|
|
437
|
-
*
|
|
438
|
-
* 为什么后者是硬约束:二分一次会扫过 ~10–13 页、每页 32 家。若把扫描到的**全部**代码都写进
|
|
439
|
-
* uid 缓存,一次解析就能塞进 200+ 条,把容量占满并**淘汰掉正在解析的那个代码**——表现为
|
|
440
|
-
* "单次解析成功、稍后再查同一个代码却报 NOT_FOUND",而且只在特定调用序列下复现。
|
|
441
|
-
* 因此 uid 只从"已确认命中"的那一页里取,取不到就按升序自检后的页范围继续二分。
|
|
375
|
+
* Cache layering: pages feed the binary search, the uid cache holds only the resolved
|
|
376
|
+
* code — caching scanned pages there would evict it and turn success into NOT_FOUND.
|
|
442
377
|
*/
|
|
443
378
|
export async function resolveSseCompanyUid(transport, code, signal) {
|
|
444
379
|
const cached = sseUidCache.get(code);
|
|
445
380
|
if (cached !== undefined)
|
|
446
381
|
return cached;
|
|
447
382
|
let high = 1;
|
|
448
|
-
//
|
|
383
|
+
// Doubling phase: first page whose last code >= target, or an empty page.
|
|
449
384
|
for (let guard = 0; guard < 16; guard += 1) {
|
|
450
385
|
const pairs = await sseCompanyPage(transport, high, signal);
|
|
451
386
|
if (pairs.length === 0 || pairs[pairs.length - 1].code >= code)
|
|
@@ -478,10 +413,7 @@ export async function resolveSseCompanyUid(transport, code, signal) {
|
|
|
478
413
|
}
|
|
479
414
|
throw new SourceError('sseinfo', 'NOT_FOUND', `上证e互动没有 ${code}(该公司不在上交所,或已退市)`);
|
|
480
415
|
}
|
|
481
|
-
/**
|
|
482
|
-
* 解析问答列表页。以回复块 `class="m_feed_detail m_qa"` 为界切问题段与回复段——
|
|
483
|
-
* 不靠 id 区分("最新回复"与"最新提问"两种列表的标记不同)。
|
|
484
|
-
*/
|
|
416
|
+
/** Split Q/A on the reply block class; per-item id markers differ between the two list kinds. */
|
|
485
417
|
export function parseSseFeed(text) {
|
|
486
418
|
const items = [];
|
|
487
419
|
const chunks = text.split(/<div class="m_feed_item[^"]*" id="item-/u).slice(1);
|
|
@@ -528,16 +460,8 @@ export function parseSseFeed(text) {
|
|
|
528
460
|
return items;
|
|
529
461
|
}
|
|
530
462
|
/**
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
* 实测(2026-09-24 全量抽样 150 条)非绝对写法**只有三类**,必须全部支持:
|
|
534
|
-
* - 相对当天:`2分钟前` / `57分钟前` / `1小时前` / `2小时前`;
|
|
535
|
-
* - 相对昨天:`昨天 18:16`;
|
|
536
|
-
* - 绝对:`2026年09月24日 09:30`。
|
|
537
|
-
*
|
|
538
|
-
* 少支持任何一类,"最新提问"列表就会因某一条认不出而**整次失败**(本仓真机冒烟两次都栽在
|
|
539
|
-
* 这里:先是 `昨天 18:16`,再是 `2分钟前`)。相对写法按**北京时间**换算——host 本地时区不可依赖
|
|
540
|
-
* (本仓跑在 UTC 主机上),所以先取"北京当下"再回推。
|
|
463
|
+
* Normalize to Beijing "YYYY-MM-DD HH:MM" (never host local time). All three observed
|
|
464
|
+
* forms (X分钟前 / 昨天 HH:MM / absolute) must parse or one row fails the page.
|
|
541
465
|
*/
|
|
542
466
|
export function sseTime(text, now = new Date()) {
|
|
543
467
|
const absolute = /(\d{4})年(\d{2})月(\d{2})日\s*(\d{2}:\d{2})/u.exec(text);
|
|
@@ -558,7 +482,7 @@ export function sseTime(text, now = new Date()) {
|
|
|
558
482
|
beijing.setUTCMinutes(beijing.getUTCMinutes() - amount);
|
|
559
483
|
return stamp();
|
|
560
484
|
}
|
|
561
|
-
//
|
|
485
|
+
// 「刚刚」has no minute precision: use the current minute; otherwise fail (no guessing).
|
|
562
486
|
return /^刚刚$/u.test(text.trim()) ? stamp() : undefined;
|
|
563
487
|
}
|
|
564
488
|
export async function sseinfoQa(transport, input) {
|
|
@@ -583,8 +507,8 @@ export async function sseinfoQa(transport, input) {
|
|
|
583
507
|
uid = await resolveSseCompanyUid(transport, input.code, deadline.signal);
|
|
584
508
|
}
|
|
585
509
|
catch (error) {
|
|
586
|
-
//
|
|
587
|
-
//
|
|
510
|
+
// Deadline expiry means upstream is slow, not that the caller cancelled:
|
|
511
|
+
// map it to TIMEOUT or the tool layer reports ABORTED.
|
|
588
512
|
if (deadline.exceeded()) {
|
|
589
513
|
throw new SourceError('sseinfo', 'TIMEOUT', `上证e互动公司 uid 定位超过 ${Math.round(budgetMs / 1000)}s(一次定位需 10–13 个串行请求);稍后重试,或先用不带 code 的 sseinfo_qa 取全市场问答`);
|
|
590
514
|
}
|
|
@@ -604,16 +528,14 @@ export async function sseinfoQa(transport, input) {
|
|
|
604
528
|
text = outcome.text;
|
|
605
529
|
}
|
|
606
530
|
const parsed = parseSseFeed(text);
|
|
607
|
-
//
|
|
531
|
+
// Zero rows plus no 「暂无」 note means the page structure changed.
|
|
608
532
|
if (parsed.length === 0 && !SSE_EMPTY_NOTE.test(text)) {
|
|
609
533
|
throw new SourceError('sseinfo', 'INVALID_RESPONSE', '上证e互动页面既没有问答也没有「暂无」提示(结构可能已变)');
|
|
610
534
|
}
|
|
611
535
|
const notes = [];
|
|
612
536
|
let items = parsed;
|
|
613
537
|
if (input.code !== undefined) {
|
|
614
|
-
//
|
|
615
|
-
// **600518** 的问答。此处**按条过滤**而不是整次失败——整次失败会把该公司页面上真实存在的
|
|
616
|
-
// 问答一起丢掉,比"少一条被污染的数据"更糟。丢弃条数与原因写进 note,绝不静默。
|
|
538
|
+
// Upstream drift (a page can carry rows for other codes): drop + note, don't fail the call.
|
|
617
539
|
const foreign = parsed.filter((item) => item.code !== input.code);
|
|
618
540
|
items = parsed.filter((item) => item.code === input.code);
|
|
619
541
|
if (foreign.length > 0) {
|
|
@@ -631,16 +553,11 @@ export async function sseinfoQa(transport, input) {
|
|
|
631
553
|
...(notes.length > 0 ? { note: notes.join(';') } : {}),
|
|
632
554
|
};
|
|
633
555
|
}
|
|
634
|
-
// ── 东财全球资讯 7×24(np-weblist)──────────────────────────────────────────
|
|
635
556
|
const EM_724_URL = 'https://np-weblist.eastmoney.com/comm/web/getFastNewsList';
|
|
636
557
|
const EM_REFERER = 'https://kuaixun.eastmoney.com/';
|
|
637
558
|
/**
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* 实测(2026-09-24):GET + JSON,`code` 为**字符串** `"1"` 表示成功;条目含 `title` /
|
|
642
|
-
* `showTime`(已是北京时间字符串)/ `summary`。服务端会把它聚合到的财联社内容一并返回,
|
|
643
|
-
* 所以同一事件可能与其他来源重复——**去重由调用方按标题判断**,本工具不猜。
|
|
559
|
+
* Independent CLS/WSCN backup; success `code` is the string "1". The server aggregates
|
|
560
|
+
* CLS content, so title dedupe is the caller's job.
|
|
644
561
|
*/
|
|
645
562
|
export async function eastmoneyFastNews(client, input) {
|
|
646
563
|
const params = new URLSearchParams({
|
|
@@ -657,7 +574,6 @@ export async function eastmoneyFastNews(client, input) {
|
|
|
657
574
|
});
|
|
658
575
|
const body = asRecord(parseJson(outcome.text, 'eastmoney', '东财 7×24 响应'));
|
|
659
576
|
const data = asRecord(body?.data);
|
|
660
|
-
// 上游用字符串 "1" 表示成功(不是数字 1):按字符串比较,避免把成功判成失败。
|
|
661
577
|
if (String(body?.code ?? '') !== '1') {
|
|
662
578
|
throw new SourceError('eastmoney', 'UPSTREAM', `东财 7×24 返回 code=${String(body?.code)}(上游口径变更或风控)`);
|
|
663
579
|
}
|
|
@@ -676,20 +592,15 @@ export async function eastmoneyFastNews(client, input) {
|
|
|
676
592
|
}
|
|
677
593
|
return { source: 'eastmoney', operation: 'fast_news', items: clipItems(items) };
|
|
678
594
|
}
|
|
679
|
-
/**
|
|
595
|
+
/** Random string eastmoney asks for; absence would not break correctness. */
|
|
680
596
|
function emTraceId() {
|
|
681
597
|
return `${Date.now().toString(16)}-${Math.floor(Math.random() * 1e16).toString(16)}`;
|
|
682
598
|
}
|
|
683
|
-
// ── 东财个股新闻(search-api-web,JSONP)────────────────────────────────────
|
|
684
599
|
const EM_STOCK_NEWS_URL = 'https://search-api-web.eastmoney.com/search/jsonp';
|
|
685
600
|
const EM_SO_REFERER = 'https://so.eastmoney.com/';
|
|
686
601
|
/**
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
* ⚠️ 必须区分两种"没结果"(参考实践记为 #18,本仓 2026-09-24 实测为正常返回):
|
|
690
|
-
* 上游对部分 IP 间歇风控时只返回 `passportWeb`(股民资料)而**没有** `cmsArticleWebOld` 键——
|
|
691
|
-
* 这与"该股确实没有新闻"(键在、数组为空)是**两件事**。本函数按**键是否存在**判定,
|
|
692
|
-
* 缺键时报 `UPSTREAM`,绝不静默返回空表。
|
|
602
|
+
* A missing `cmsArticleWebOld` key is intermittent upstream throttling, not "no news"
|
|
603
|
+
* (which is a present-but-empty array); report UPSTREAM on the missing key.
|
|
693
604
|
*/
|
|
694
605
|
export async function eastmoneyStockNews(client, input) {
|
|
695
606
|
const inner = JSON.stringify({
|
|
@@ -732,33 +643,17 @@ export async function eastmoneyStockNews(client, input) {
|
|
|
732
643
|
}
|
|
733
644
|
return { source: 'eastmoney', operation: 'stock_news', items: clipItems(items) };
|
|
734
645
|
}
|
|
735
|
-
// ── 东财研报列表(reportapi)──────────────────────────────────────────────
|
|
736
646
|
const EM_REPORT_URL = 'https://reportapi.eastmoney.com/report/list';
|
|
737
647
|
const EM_DATA_REFERER = 'https://data.eastmoney.com/';
|
|
738
648
|
const EM_PDF_BASE = 'https://pdf.dfcfw.com/pdf/';
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
* `H3` 类)。2026-09-25 实测三个不同年份的 `infoCode` 全部 `HTTP 200` + `application/pdf`、
|
|
742
|
-
* 无需 Referer。
|
|
743
|
-
*
|
|
744
|
-
* 形状不认识就**不给链接**(宁缺勿造):从上游文本里拼一条可能指向别人文档的 URL,
|
|
745
|
-
* 比让模型说"这条没直链"危险得多。
|
|
746
|
-
*/
|
|
649
|
+
// PDF link = `H3_<infoCode>_1.pdf`; an unknown shape means no link — a fabricated one
|
|
650
|
+
// could point at someone else's document.
|
|
747
651
|
function emReportPdfUrl(infoCode) {
|
|
748
652
|
return typeof infoCode === 'string' && /^AP\d{6,}$/u.test(infoCode) ? `${EM_PDF_BASE}H3_${infoCode}_1.pdf` : undefined;
|
|
749
653
|
}
|
|
750
654
|
/**
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
* ⚠️ **没有摘要可给**(2026-09-24 实测该接口 51 个字段):列表只有标题 / 机构 / 研究员 /
|
|
754
|
-
* 日期 / 评级。正文在那份 PDF 里——本机直连只处理文本(`application/pdf` 会被
|
|
755
|
-
* `web_retriever_fetch` 拒),要走 `ocr` 解析(§4.3)。所以本函数把**每条的 PDF 直链**交出去
|
|
756
|
-
* (`pdf_url`),通路才是完整的:没有它,模型手上没有任何稳定 `.pdf` URL,而人设又禁止编链接。
|
|
757
|
-
*
|
|
758
|
-
* 有用的派生信息:每篇研报自带分析师预测 EPS(`predictThisYearEps` / `predictNextYearEps` /
|
|
759
|
-
* `predictNextTwoYearEps`)与对应 PE,以及评级与评级变动(`emRatingName` / `ratingChange`)。
|
|
760
|
-
*
|
|
761
|
-
* ⚠️ 该接口只认**纯 6 位代码**:带 `SH` 前缀会返回 `hits=0`,看起来像"这只票没研报"。
|
|
655
|
+
* No abstract in the list; the per-item PDF link is the only path to the text (via `ocr`).
|
|
656
|
+
* Only bare 6-digit codes work (a prefixed code returns hits=0); EPS fields are strings.
|
|
762
657
|
*/
|
|
763
658
|
export async function eastmoneyReports(client, input) {
|
|
764
659
|
const params = new URLSearchParams({
|
|
@@ -800,13 +695,9 @@ export async function eastmoneyReports(client, input) {
|
|
|
800
695
|
researcher: clip(row.researcher),
|
|
801
696
|
rating: clip(row.emRatingName),
|
|
802
697
|
rating_change: typeof row.ratingChange === 'number' ? row.ratingChange : null,
|
|
803
|
-
// ⚠️ 实测该字段是**字符串**(`'1.0700000000'`,保留精度),不是 number——
|
|
804
|
-
// 早先按 `typeof === 'number'` 判定会让它**恒为 null**(看起来像"这篇研报没有预测")。
|
|
805
698
|
eps_this_year: toNumber(row.predictThisYearEps),
|
|
806
699
|
eps_next_year: toNumber(row.predictNextYearEps),
|
|
807
700
|
info_code: clip(row.infoCode),
|
|
808
|
-
// 列表把正文的**入口**一并交出来:没有 `pdf_url`,模型手上就没有任何稳定 `.pdf` 直链,
|
|
809
|
-
// 而 `ocr` 这条通路只吃直链或本地文件。
|
|
810
701
|
...(pdfUrl === undefined ? {} : { pdf_url: pdfUrl }),
|
|
811
702
|
attach_pages: typeof row.attachPages === 'number' && Number.isFinite(row.attachPages) ? row.attachPages : null,
|
|
812
703
|
});
|
|
@@ -819,19 +710,11 @@ export async function eastmoneyReports(client, input) {
|
|
|
819
710
|
note: `东财研报库共 ${hits} 篇;列表不含摘要,正文在各条的 pdf_url(研报 PDF)里,交 ocr 解析(整篇一次算完)`,
|
|
820
711
|
};
|
|
821
712
|
}
|
|
822
|
-
// ── 新浪研报列表(第二来源)───────────────────────────────────────────────
|
|
823
713
|
const SINA_REPORT_URL = 'https://vip.stock.finance.sina.com.cn/q/go.php/vReport_List/kind';
|
|
824
714
|
const SINA_REFERER = 'https://finance.sina.com.cn/';
|
|
825
715
|
/**
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
* ⚠️ 两条**与参考实践不同的实测结论**(2026-09-24,本仓复核):
|
|
829
|
-
* 1. 参考实践说"两次请求间隔 <1 秒会返回假的『没有找到相关内容』空页,必须 6 秒"。
|
|
830
|
-
* 本仓实测**连续 4 次无间隔全部成功**(分别为 40/39/39/38 条带序号行)——那个"限流"是误判。
|
|
831
|
-
* 2. 真正会让**个股**查询返回假空页的是**缺交易所前缀**:`688017` 纯 6 位 → 假空页;
|
|
832
|
-
* `sh688017` / `sz000001` → 正常。而 `600519` 纯 6 位恰好也能用(前缀可推断),
|
|
833
|
-
* 所以"纯 6 位能用"的样本会误导人。本函数**一律补前缀**。
|
|
834
|
-
* ⚠️ 空页 HTTP 200 且与"真的没有研报"长得一样,因此本函数按"带序号行数"与空页提示语双重判定。
|
|
716
|
+
* Always add the exchange prefix: bare codes get a fake empty page for STAR/ChiNext.
|
|
717
|
+
* That page is HTTP 200 and looks like "no reports", so also verify the numbered row count.
|
|
835
718
|
*/
|
|
836
719
|
export async function sinaReports(transport, input) {
|
|
837
720
|
const params = new URLSearchParams();
|
|
@@ -842,7 +725,6 @@ export async function sinaReports(transport, input) {
|
|
|
842
725
|
}
|
|
843
726
|
else {
|
|
844
727
|
url = `${SINA_REPORT_URL}/search/index.phtml`;
|
|
845
|
-
// 科创板/创业板等必须显式带交易所前缀,否则上游返回假空页(见上文实测第 2 条)。
|
|
846
728
|
params.set('symbol', sinaSymbol(input.code));
|
|
847
729
|
params.set('t1', 'all');
|
|
848
730
|
params.set('p', String(input.page));
|
|
@@ -855,12 +737,10 @@ export async function sinaReports(transport, input) {
|
|
|
855
737
|
const items = [];
|
|
856
738
|
for (const match of text.matchAll(rowPattern)) {
|
|
857
739
|
const href = match[2];
|
|
858
|
-
//
|
|
740
|
+
// Occasionally protocol-relative (`//host/...`): promote to https, one link form.
|
|
859
741
|
const url = itemUrl(href.startsWith('//') ? `https:${href}` : href);
|
|
860
742
|
items.push({
|
|
861
|
-
//
|
|
862
|
-
// 即第 4 列是类型、第 5 列才是日期。早先按"日期在前"的想当然写反了,
|
|
863
|
-
// 产出 `date:"公司"`、`type:"2026-09-01"` —— 每列都有值、却整行错位,比报错危险。
|
|
743
|
+
// Column order from the page header: type is col 4, date is col 5.
|
|
864
744
|
date: clip(match[5]),
|
|
865
745
|
title: clip(stripHtml(match[1])),
|
|
866
746
|
type: clip(match[4]),
|
|
@@ -870,7 +750,7 @@ export async function sinaReports(transport, input) {
|
|
|
870
750
|
url,
|
|
871
751
|
});
|
|
872
752
|
}
|
|
873
|
-
//
|
|
753
|
+
// Every numbered row must parse; mismatch means the page structure changed.
|
|
874
754
|
if (items.length !== numbered) {
|
|
875
755
|
throw new SourceError('sina', 'INVALID_RESPONSE', `新浪研报页有 ${numbered} 行带序号、只解析出 ${items.length} 条(页面行结构可能已变)`);
|
|
876
756
|
}
|
|
@@ -885,11 +765,8 @@ export async function sinaReports(transport, input) {
|
|
|
885
765
|
};
|
|
886
766
|
}
|
|
887
767
|
/**
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
* ⚠️ 顺序要紧:`92xxxx`(北交所**新号段**,如 832982→920982)以 9 开头,若先判 `9 → sh`
|
|
891
|
-
* 就会被发到上交所并拿到假空页。所以北交所号段(43/83/87/920)必须先判,
|
|
892
|
-
* 只有 `900xxx`(沪市 B 股)才归 sh。
|
|
768
|
+
* Always prefix the exchange; order matters: BJ segments (43/83/87/920) start with 9/4
|
|
769
|
+
* and must be tested before `9 => sh`; only 900xxx is Shanghai B.
|
|
893
770
|
*/
|
|
894
771
|
export function sinaSymbol(code) {
|
|
895
772
|
if (/^(920|43|83|87)/u.test(code))
|
|
@@ -902,26 +779,17 @@ export function sinaSymbol(code) {
|
|
|
902
779
|
return `bj${code}`;
|
|
903
780
|
return code;
|
|
904
781
|
}
|
|
905
|
-
// ── 同花顺机构一致预期 EPS(basic.10jqka.com.cn)─────────────────────────
|
|
906
782
|
const THS_EPS_URL = 'https://basic.10jqka.com.cn/new';
|
|
907
783
|
const THS_REFERER = 'https://basic.10jqka.com.cn/';
|
|
908
784
|
/**
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
* 1. 该站声明 `content-type: text/html`(**不带 charset**)而正文是 GBK:
|
|
913
|
-
* 按 UTF-8 解会得到 11,800 个替换字符,中文与正则全部失效。因此本请求显式声明 `encoding: 'gbk'`
|
|
914
|
-
* (`HttpRequest.encoding` 只允许来源实现填写,不暴露给模型)。
|
|
915
|
-
* 2. 页面里有 **32 个 `<table>`**,只需认"含『每股收益』表头"的那一张(行结构固定为
|
|
916
|
-
* 年度 / 预测机构数 / 最小值 / 均值 / 最大值 / 行业平均数)。**均值即机构一致预期 EPS**。
|
|
917
|
-
*
|
|
918
|
-
* 另附页面摘要块里的"预测机构数 + 同比增速"(与表格同源,作为交叉印证)。
|
|
785
|
+
* The page declares text/html without charset but is GBK, so decode explicitly
|
|
786
|
+
* (`encoding` is source-only, never a model input). Of its 32 tables, pick the one
|
|
787
|
+
* with 每股收益/均值 headers; the mean is the consensus EPS.
|
|
919
788
|
*/
|
|
920
789
|
export async function thsEpsForecast(transport, input) {
|
|
921
790
|
const outcome = await transport.request({ url: `${THS_EPS_URL}/${input.code}/worth.html`, headers: { referer: THS_REFERER }, encoding: 'gbk' }, input.signal);
|
|
922
791
|
const html = outcome.text;
|
|
923
|
-
//
|
|
924
|
-
// 同时接受中英两种表头写法——真实页面是中文,但按"结构"而不是"某一个字面词"识别更耐改版。
|
|
792
|
+
// Match by header text, not table position; accept ZH/EN spellings.
|
|
925
793
|
const table = [...html.matchAll(/<table[\s\S]*?<\/table>/gu)]
|
|
926
794
|
.find((match) => /每股收益|EPS|每股收益\(元\)/iu.test(match[0]) && /均值|mean/iu.test(match[0]));
|
|
927
795
|
if (table === undefined) {
|
|
@@ -942,7 +810,7 @@ export async function thsEpsForecast(transport, input) {
|
|
|
942
810
|
year,
|
|
943
811
|
institutions: num(cells[1]),
|
|
944
812
|
eps_min: num(cells[2]),
|
|
945
|
-
//
|
|
813
|
+
// Mean = consensus EPS, the core output.
|
|
946
814
|
eps_mean: num(cells[3]),
|
|
947
815
|
eps_max: num(cells[4]),
|
|
948
816
|
industry_average: num(cells[5]),
|