@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.
Files changed (117) hide show
  1. package/CHANGELOG.md +814 -0
  2. package/LICENSE +21 -0
  3. package/README.md +118 -2
  4. package/assets/cg-icon.png +0 -0
  5. package/capital-config/client.js +28 -0
  6. package/capital-config/client.src.cjs +388 -0
  7. package/capital-config/index.js +113 -0
  8. package/capital-config/package.json +36 -0
  9. package/capital-watchlist/client.js +182 -0
  10. package/capital-watchlist/client.src.cjs +1229 -0
  11. package/capital-watchlist/index.js +772 -0
  12. package/capital-watchlist/package.json +37 -0
  13. package/chart-ui/README.md +112 -0
  14. package/chart-ui/client.js +16 -0
  15. package/chart-ui/client.src.cjs +126 -0
  16. package/chart-ui/index.js +205 -0
  17. package/chart-ui/package.json +30 -0
  18. package/cordis.patch.yml +72 -0
  19. package/lib/agents/bash-guard.d.ts +55 -0
  20. package/lib/agents/bash-guard.js +162 -0
  21. package/lib/agents/root-tool-policy.d.ts +164 -0
  22. package/lib/agents/root-tool-policy.js +309 -0
  23. package/lib/chart/artifact-ref.d.ts +65 -0
  24. package/lib/chart/artifact-ref.js +107 -0
  25. package/lib/chart/downsample.d.ts +8 -0
  26. package/lib/chart/downsample.js +53 -0
  27. package/lib/chart/errors.d.ts +16 -0
  28. package/lib/chart/errors.js +15 -0
  29. package/lib/chart/events.d.ts +120 -0
  30. package/lib/chart/events.js +471 -0
  31. package/lib/chart/html.d.ts +21 -0
  32. package/lib/chart/html.js +154 -0
  33. package/lib/chart/runtime.d.ts +20 -0
  34. package/lib/chart/runtime.js +270 -0
  35. package/lib/chart/series.d.ts +102 -0
  36. package/lib/chart/series.js +424 -0
  37. package/lib/chart/source-token.d.ts +64 -0
  38. package/lib/chart/source-token.js +217 -0
  39. package/lib/chart/spec.d.ts +58 -0
  40. package/lib/chart/spec.js +265 -0
  41. package/lib/chart/tool.d.ts +86 -0
  42. package/lib/chart/tool.js +288 -0
  43. package/lib/chart/vendor/LICENSE +201 -0
  44. package/lib/chart/vendor/VERSION.json +8 -0
  45. package/lib/chart/vendor/lightweight-charts.standalone.production.js +7 -0
  46. package/lib/data-collector/args.d.ts +38 -0
  47. package/lib/data-collector/args.js +114 -0
  48. package/lib/data-collector/dataset-tools.d.ts +3 -0
  49. package/lib/data-collector/dataset-tools.js +421 -0
  50. package/lib/data-collector/describe.d.ts +99 -0
  51. package/lib/data-collector/describe.js +256 -0
  52. package/lib/data-collector/hub.d.ts +187 -0
  53. package/lib/data-collector/hub.js +469 -0
  54. package/lib/data-collector/query-time.d.ts +58 -0
  55. package/lib/data-collector/query-time.js +101 -0
  56. package/lib/data-collector/query.d.ts +57 -0
  57. package/lib/data-collector/query.js +473 -0
  58. package/lib/data-collector/store.d.ts +499 -0
  59. package/lib/data-collector/store.js +1486 -0
  60. package/lib/data-collector/time-axis.d.ts +169 -0
  61. package/lib/data-collector/time-axis.js +419 -0
  62. package/lib/data-collector/tools.d.ts +32 -0
  63. package/lib/data-collector/tools.js +216 -0
  64. package/lib/index.d.ts +275 -0
  65. package/lib/index.js +458 -0
  66. package/lib/net/eastmoney-client.d.ts +104 -0
  67. package/lib/net/eastmoney-client.js +118 -0
  68. package/lib/net/throttle.d.ts +16 -0
  69. package/lib/net/throttle.js +34 -0
  70. package/lib/ocr/client.d.ts +82 -0
  71. package/lib/ocr/client.js +281 -0
  72. package/lib/ocr/markdown.d.ts +75 -0
  73. package/lib/ocr/markdown.js +209 -0
  74. package/lib/ocr/store.d.ts +119 -0
  75. package/lib/ocr/store.js +226 -0
  76. package/lib/sources/eastmoney-http.d.ts +2 -0
  77. package/lib/sources/eastmoney-http.js +1253 -0
  78. package/lib/sources/fuyao-core.d.ts +52 -0
  79. package/lib/sources/fuyao-core.js +96 -0
  80. package/lib/sources/fuyao-rest.d.ts +14 -0
  81. package/lib/sources/fuyao-rest.js +2057 -0
  82. package/lib/sources/security-code.d.ts +27 -0
  83. package/lib/sources/security-code.js +104 -0
  84. package/lib/sources/security-identity.d.ts +24 -0
  85. package/lib/sources/security-identity.js +89 -0
  86. package/lib/sources/tencent-http.d.ts +2 -0
  87. package/lib/sources/tencent-http.js +1053 -0
  88. package/lib/sources/wind-mcp.d.ts +8 -0
  89. package/lib/sources/wind-mcp.js +593 -0
  90. package/lib/time/tools.d.ts +74 -0
  91. package/lib/time/tools.js +464 -0
  92. package/lib/tool-exec.d.ts +29 -0
  93. package/lib/tool-exec.js +17 -0
  94. package/lib/web-retriever/engines.d.ts +61 -0
  95. package/lib/web-retriever/engines.js +202 -0
  96. package/lib/web-retriever/html-markdown.d.ts +50 -0
  97. package/lib/web-retriever/html-markdown.js +321 -0
  98. package/lib/web-retriever/local-fetch.d.ts +121 -0
  99. package/lib/web-retriever/local-fetch.js +562 -0
  100. package/lib/web-retriever/retriever.d.ts +58 -0
  101. package/lib/web-retriever/retriever.js +94 -0
  102. package/lib/web-retriever/sources.d.ts +235 -0
  103. package/lib/web-retriever/sources.js +961 -0
  104. package/lib/web-retriever/tools.d.ts +50 -0
  105. package/lib/web-retriever/tools.js +1029 -0
  106. package/lib/web-retriever/wind-client.d.ts +69 -0
  107. package/lib/web-retriever/wind-client.js +311 -0
  108. package/locale/en.json +6 -0
  109. package/locale/zh.json +6 -0
  110. package/package.json +84 -4
  111. package/preset/capital-generation/README.md +161 -0
  112. package/preset/capital-generation/agent.patch.yml +509 -0
  113. package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +137 -0
  114. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +416 -0
  115. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +174 -0
  116. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +296 -0
  117. package/preset/capital-generation/skills/capital-web-protocol/SKILL.md +240 -0
@@ -0,0 +1,961 @@
1
+ /**
2
+ * 具名来源的结构化取数(provider + operation)。
3
+ *
4
+ * 与 `web_retriever_fetch` 的分工:fetch 是"一个 URL → 一篇正文"的**检索**工作面;
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)。
14
+ */
15
+ import { createHttpRequester, safeUrl } from './local-fetch.js';
16
+ import { stripInvisibleText } from './html-markdown.js';
17
+ export const SOURCE_IDS = ['cls', 'wscn', 'cninfo_irm', 'sseinfo', 'eastmoney', 'ths', 'sina'];
18
+ /** 单条正文裁剪上限:条目数由各工具的 limit 参数封顶,单条长度在这里封顶。 */
19
+ export const MAX_ITEM_CHARS = 1200;
20
+ /** 一条工具输出里的条目上限(与工具 schema 的 maximum 一致)。 */
21
+ export const MAX_ITEMS = 50;
22
+ export class SourceError extends Error {
23
+ source;
24
+ code;
25
+ constructor(source, code, message) {
26
+ super(message);
27
+ this.source = source;
28
+ this.code = code;
29
+ this.name = 'SourceError';
30
+ }
31
+ }
32
+ const REQUEST_TIMEOUT_MS = 20_000;
33
+ function createHash(algorithm) {
34
+ const processLike = globalThis.process;
35
+ return processLike?.getBuiltinModule?.('node:crypto')?.createHash?.(algorithm);
36
+ }
37
+ /** 每个来源工具调用一次;transport 惰性建、按配置缓存。 */
38
+ export function createSourceTransport(options) {
39
+ return createHttpRequester({ ...options, timeoutMs: options.timeoutMs || REQUEST_TIMEOUT_MS });
40
+ }
41
+ /**
42
+ * 东财客户端传输层的**闸门适配**:三个 `eastmoney_*` 工具与其余六个来源共用同一份出口校验
43
+ * (URL 合法性 + DNS 公网 IP 闸门 + 手动重定向逐跳重校验 + 大小/超时上限),不再是第二份
44
+ * 裸 `fetch`(AGENTS.md §9.7)。节流器不在这里——仍由进程级共享的东财节流器提供
45
+ * (`sharedEastmoneyThrottle()`),本适配只替换"怎么发出去"。
46
+ *
47
+ * 带分类 `code` 的 `LocalFetchError` 原样穿过(`createEastmoneyClient` 见 code 即不包裹),
48
+ * 取消因此保持 `ABORTED` 语义、不被降级成网络失败(docs/dev/web-retriever.md §4.2)。
49
+ */
50
+ export function createGatedEastmoneyTransport(requester) {
51
+ return async (url, init) => {
52
+ const outcome = await requester.request({ url, ...(init.headers === undefined ? {} : { headers: init.headers }) }, init.signal);
53
+ return {
54
+ status: outcome.status,
55
+ text: async () => outcome.text,
56
+ json: async () => JSON.parse(outcome.text),
57
+ };
58
+ };
59
+ }
60
+ /** 条目链接的最大长度:链接只是引用,不该吃掉整份输出的字符预算(`tools.ts` 的条目预算)。 */
61
+ const ITEM_URL_MAX_CHARS = 500;
62
+ function clip(text) {
63
+ if (typeof text !== 'string')
64
+ return '';
65
+ // 上游字段(页面文本、JSON 正文)与抓取正文同一口径:先剥不可见字符再裁剪。
66
+ const trimmed = stripInvisibleText(text).trim();
67
+ return trimmed.length > MAX_ITEM_CHARS ? `${trimmed.slice(0, MAX_ITEM_CHARS)}…` : trimmed;
68
+ }
69
+ /** 条目的可点击链接:非 http(s) / 含控制符 / 带凭据 / 超长一律丢字段(不丢条目)。 */
70
+ function itemUrl(value) {
71
+ return safeUrl(value, ITEM_URL_MAX_CHARS);
72
+ }
73
+ /**
74
+ * 把 HTML 片段压成可读纯文本(sseinfo 的正文块里带 `<p>` / `<br/>`)。
75
+ *
76
+ * ⛔ 顺序必须是"先解码实体、后剥标签":反过来时外部内容里的 `&lt;system&gt;…&lt;/system&gt;`
77
+ * 会在剥完标签后被解码成**活标签**,进入模型上下文就是提示注入面。解码出的新标签同样要剥,
78
+ * 所以标签剥离循环到有界轮数(双重编码的 `&amp;lt;` 解出来是字面 `&lt;`,惰性问题不大)。
79
+ */
80
+ function stripHtml(fragment) {
81
+ let text = stripInvisibleText(fragment)
82
+ .replace(/&nbsp;/gu, ' ')
83
+ .replace(/&#39;/gu, "'")
84
+ .replace(/&quot;/gu, '"')
85
+ .replace(/&lt;/gu, '<')
86
+ .replace(/&gt;/gu, '>')
87
+ .replace(/&amp;/gu, '&');
88
+ for (let round = 0; round < 3; round += 1) {
89
+ const stripped = text
90
+ .replace(/<br\s*\/?>/giu, '\n')
91
+ .replace(/<\/p>/giu, '\n')
92
+ .replace(/<[^>]+>/gu, '');
93
+ if (stripped === text)
94
+ break;
95
+ text = stripped;
96
+ }
97
+ return text
98
+ .replace(/[ \t]{2,}/gu, ' ')
99
+ .replace(/\n{3,}/gu, '\n\n')
100
+ .trim();
101
+ }
102
+ function cstStamp(seconds) {
103
+ const date = new Date(seconds * 1000 + 8 * 3600 * 1000);
104
+ return `${date.toISOString().slice(0, 19).replace('T', ' ')} CST`;
105
+ }
106
+ function asRecord(value) {
107
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
108
+ ? value
109
+ : undefined;
110
+ }
111
+ function parseJson(text, source, what) {
112
+ try {
113
+ return JSON.parse(text);
114
+ }
115
+ catch (error) {
116
+ throw new SourceError(source, 'INVALID_RESPONSE', `${what} 不是合法 JSON:${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+ }
119
+ function requireRows(value, source, what) {
120
+ if (!Array.isArray(value))
121
+ throw new SourceError(source, 'INVALID_RESPONSE', `${what} 不是数组(上游结构可能已变)`);
122
+ return value;
123
+ }
124
+ function clipItems(items) {
125
+ return items.length > MAX_ITEMS ? items.slice(0, MAX_ITEMS) : items;
126
+ }
127
+ /**
128
+ * 上游数值有时是**字符串**(东财 `predictThisYearEps: '1.0700000000'`,保留精度),
129
+ * 有时是 number。按值转换而不是按类型判定,否则"字符串型数值"会被静默当成缺失。
130
+ * 认不出(空串 / 非数值)一律 `null`——绝不猜一个数字出来。
131
+ */
132
+ function toNumber(value) {
133
+ if (typeof value === 'number')
134
+ return Number.isFinite(value) ? value : null;
135
+ if (typeof value !== 'string' || value.trim() === '')
136
+ return null;
137
+ const parsed = Number(value);
138
+ return Number.isFinite(parsed) ? parsed : null;
139
+ }
140
+ // ── 财联社快讯(cls.cn)───────────────────────────────────────────────────────
141
+ const CLS_URL = 'https://www.cls.cn/v1/roll/get_roll_list';
142
+ 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
+ */
150
+ export function clsSign(params) {
151
+ const query = Object.keys(params).sort().map((key) => `${key}=${params[key]}`).join('&');
152
+ const sha1 = createHash('sha1');
153
+ const md5 = createHash('md5');
154
+ if (sha1 === undefined || md5 === undefined) {
155
+ throw new SourceError('cls', 'UPSTREAM', '运行时缺少 node:crypto,无法计算财联社签名');
156
+ }
157
+ return md5.update(sha1.update(query).digest('hex')).digest('hex');
158
+ }
159
+ export async function clsTelegraph(transport, input) {
160
+ const params = {
161
+ appName: 'CailianpressWeb',
162
+ os: 'web',
163
+ sv: '7.7.5',
164
+ last_time: '',
165
+ refresh_type: '1',
166
+ rn: String(input.limit),
167
+ };
168
+ const query = Object.keys(params).sort().map((key) => `${key}=${params[key]}`).join('&');
169
+ const outcome = await transport.request({ url: `${CLS_URL}?${query}&sign=${clsSign(params)}`, headers: { referer: CLS_REFERER } }, input.signal);
170
+ const body = asRecord(parseJson(outcome.text, 'cls', '财联社响应'));
171
+ if (!body)
172
+ throw new SourceError('cls', 'INVALID_RESPONSE', '财联社响应不是对象');
173
+ if (body.errno !== 0) {
174
+ throw new SourceError('cls', 'UPSTREAM', `财联社返回 errno=${String(body.errno)}(上游口径变更或风控)`);
175
+ }
176
+ const data = asRecord(body.data);
177
+ const rows = requireRows(data?.roll_data, 'cls', '财联社 data.roll_data');
178
+ const items = [];
179
+ for (const raw of rows) {
180
+ const row = asRecord(raw);
181
+ if (!row)
182
+ continue;
183
+ // 非 roll 条目(推广 / 广告位)不属于"快讯",不得混入证据。
184
+ if (row.in_roll === false || row.is_ad === true)
185
+ continue;
186
+ const ctime = typeof row.ctime === 'number' ? row.ctime : undefined;
187
+ if (ctime === undefined)
188
+ throw new SourceError('cls', 'INVALID_RESPONSE', '财联社条目缺 ctime(时间认不出时宁可失败,不给空时间列)');
189
+ const brief = typeof row.brief === 'string' ? row.brief : '';
190
+ const title = typeof row.title === 'string' && row.title.trim() !== '' ? row.title : brief;
191
+ items.push({
192
+ id: typeof row.id === 'number' || typeof row.id === 'string' ? row.id : null,
193
+ time: cstStamp(ctime),
194
+ title: clip(title),
195
+ content: clip(typeof row.content === 'string' && row.content.trim() !== '' ? row.content : brief),
196
+ url: itemUrl(row.shareurl),
197
+ });
198
+ }
199
+ return {
200
+ source: 'cls',
201
+ operation: 'telegraph',
202
+ items: clipItems(items),
203
+ ...(items.length > MAX_ITEMS ? { note: `已截断为前 ${MAX_ITEMS} 条` } : {}),
204
+ };
205
+ }
206
+ // ── 华尔街见闻 7×24 快讯(api-one-wscn.awtmt.com)────────────────────────────
207
+ const WSCN_URL = 'https://api-one-wscn.awtmt.com/apiv1/content/lives';
208
+ export async function wscnLives(transport, input) {
209
+ const params = new URLSearchParams({ channel: input.channel, limit: String(input.limit) });
210
+ if (input.cursor !== undefined)
211
+ params.set('cursor', input.cursor);
212
+ const outcome = await transport.request({ url: `${WSCN_URL}?${params.toString()}` }, input.signal);
213
+ const body = asRecord(parseJson(outcome.text, 'wscn', '华尔街见闻响应'));
214
+ if (!body)
215
+ throw new SourceError('wscn', 'INVALID_RESPONSE', '华尔街见闻响应不是对象');
216
+ if (body.code !== 20000) {
217
+ throw new SourceError('wscn', 'UPSTREAM', `华尔街见闻返回 code=${String(body.code)}`);
218
+ }
219
+ const data = asRecord(body.data);
220
+ const rows = requireRows(data?.items, 'wscn', '华尔街见闻 data.items');
221
+ const items = [];
222
+ for (const raw of rows) {
223
+ const row = asRecord(raw);
224
+ if (!row)
225
+ continue;
226
+ const stamp = row.display_time;
227
+ if (typeof stamp !== 'number' || !Number.isFinite(stamp)) {
228
+ throw new SourceError('wscn', 'INVALID_RESPONSE', '华尔街见闻条目 display_time 不是数字');
229
+ }
230
+ items.push({
231
+ id: typeof row.id === 'number' || typeof row.id === 'string' ? row.id : null,
232
+ time: cstStamp(stamp),
233
+ title: clip(row.title),
234
+ content: clip(row.content_text),
235
+ // score 是见闻的重要性字段:实测 1 / 2,少量 3(越高越重要)。
236
+ importance: typeof row.score === 'number' ? row.score : null,
237
+ url: itemUrl(row.uri) || (typeof row.id === 'number' ? `https://wallstreetcn.com/livenews/${row.id}` : ''),
238
+ });
239
+ }
240
+ const next = data?.next_cursor;
241
+ return {
242
+ source: 'wscn',
243
+ operation: 'lives',
244
+ items: clipItems(items),
245
+ ...(typeof next === 'string' || typeof next === 'number' ? { next_cursor: String(next) } : {}),
246
+ };
247
+ }
248
+ // ── 互动易问答(irm.cninfo.com.cn,深市)────────────────────────────────────
249
+ const IRM_KEYWORD_URL = 'https://irm.cninfo.com.cn/newircs/index/queryKeyboardInfo';
250
+ const IRM_QUESTION_URL = 'https://irm.cninfo.com.cn/newircs/company/question';
251
+ function formBody(fields) {
252
+ return new URLSearchParams(fields).toString();
253
+ }
254
+ /**
255
+ * 两步取数:`keyWord=代码` → `secid`(即 orgId)→ 按 orgId 查问答。
256
+ *
257
+ * ⚠️ 实测坑(2026-09-24 本仓):第二步**必须 POST**(参数放 query 串)——
258
+ * 同一 URL 用 GET 会得到 **HTTP 405**。外部文献只写了"参数放 query string",
259
+ * 没写这一条,而少了 POST 就是 405。
260
+ * 覆盖范围:只覆盖深市;沪市实测返回 0 条(走 `sseinfo_qa`);北交所两个平台都没有。
261
+ */
262
+ export async function cninfoIrm(transport, input) {
263
+ const keyword = await transport.request({ url: IRM_KEYWORD_URL, method: 'POST', body: formBody({ keyWord: input.code }) }, input.signal);
264
+ const keywordBody = asRecord(parseJson(keyword.text, 'cninfo_irm', '互动易代码查询响应'));
265
+ const matches = Array.isArray(keywordBody?.data) ? keywordBody.data : [];
266
+ const first = asRecord(matches[0]);
267
+ const orgId = typeof first?.secid === 'string' ? first.secid : undefined;
268
+ if (orgId === undefined) {
269
+ throw new SourceError('cninfo_irm', 'NOT_FOUND', `互动易查不到 ${input.code}(该代码不在深市,或代码有误)`);
270
+ }
271
+ const params = new URLSearchParams({
272
+ _t: '1',
273
+ stockcode: input.code,
274
+ orgId,
275
+ pageSize: String(input.pageSize),
276
+ pageNum: String(input.pageNum),
277
+ keyWord: '',
278
+ startDay: '',
279
+ endDay: '',
280
+ });
281
+ const page = await transport.request({ url: `${IRM_QUESTION_URL}?${params.toString()}`, method: 'POST', body: '' }, input.signal);
282
+ const pageBody = asRecord(parseJson(page.text, 'cninfo_irm', '互动易问答响应'));
283
+ const rows = requireRows(pageBody?.rows, 'cninfo_irm', '互动易 rows');
284
+ const total = typeof pageBody?.total === 'number' ? pageBody.total : undefined;
285
+ const items = [];
286
+ for (const raw of rows) {
287
+ const row = asRecord(raw);
288
+ if (!row)
289
+ continue;
290
+ const stamp = row.pubDate;
291
+ if (typeof stamp !== 'number' || !Number.isFinite(stamp)) {
292
+ throw new SourceError('cninfo_irm', 'INVALID_RESPONSE', '互动易条目 pubDate 不是毫秒时间戳');
293
+ }
294
+ const answer = typeof row.attachedContent === 'string' ? clip(row.attachedContent) : '';
295
+ const question = clip(row.mainContent);
296
+ items.push({
297
+ code: typeof row.stockCode === 'string' ? row.stockCode : input.code,
298
+ company: typeof row.companyShortName === 'string' ? row.companyShortName : '',
299
+ question,
300
+ answer,
301
+ // 空串与"未回复"必须分开表达:未回复是事实,空正文是缺口。
302
+ status: answer === '' ? 'unanswered' : 'answered',
303
+ answerer: typeof row.attachedAuthor === 'string' ? row.attachedAuthor : '',
304
+ time: cstStamp(stamp / 1000),
305
+ });
306
+ }
307
+ return {
308
+ source: 'cninfo_irm',
309
+ operation: 'questions',
310
+ items: clipItems(items),
311
+ note: `深市专用:互动易对沪市公司返回 0 条,沪市问答请用 sseinfo_qa${total === undefined ? '' : `;公司累计问答 ${total} 条`}`,
312
+ };
313
+ }
314
+ // ── 上证e互动(sns.sseinfo.com,沪市)──────────────────────────────────────
315
+ const SSE_BASE = 'https://sns.sseinfo.com';
316
+ const SSE_REFERER = `${SSE_BASE}/`;
317
+ const SSE_KIND = { answered: 11, questions: 10 };
318
+ /** 公司列表末页之后固定返回这句话(外部文献实测第 74 页起)。 */
319
+ const SSE_COMPANY_END = '没有任何上市公司的信息';
320
+ const SSE_EMPTY_NOTE = /class="m_feed_note"[^>]*>[^<]*(暂无|暂时没有)[^<]*</u;
321
+ /**
322
+ * 公司列表页:每页 32 家、按代码升序。
323
+ *
324
+ * ⚠️ "uid 与代码必须来自**同一个 `<a>`**"在本函数里是**结构性保证**,不是靠正则窗口长度凑的:
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
+ * 窗口大则错配;**只有按元素切分才是对的**。
333
+ */
334
+ export function parseCompanyList(content) {
335
+ const pairs = [];
336
+ for (const match of content.matchAll(/<a\b(?:(?!<\/a>)[\s\S])*?<\/a>/gu)) {
337
+ const element = match[0];
338
+ const uid = /uid=['"]?(\d+)['"]?/u.exec(element);
339
+ const code = /company\/(\d{6})\.png/u.exec(element);
340
+ if (uid !== null && code !== null)
341
+ pairs.push({ uid: uid[1], code: code[1] });
342
+ }
343
+ return pairs.sort((left, right) => left.code.localeCompare(right.code));
344
+ }
345
+ /**
346
+ * 代码 → uid:公司列表分页升序,用倍增 + 二分定位;结果缓存在**模块级有界 Map**。
347
+ *
348
+ * 为什么允许这个缓存:一次定位要 10–13 个请求(外部文献口径),且映射是**代码→uid 的元数据**,
349
+ * 不是内容、不是检索结果,不存在"串了别人的材料"的风险。仍按 SSE_UID_CACHE_LIMIT 封顶,
350
+ * 且**每次使用前都做升序自检**——宁可失败也不返回错配的问答。
351
+ */
352
+ const SSE_UID_CACHE_LIMIT = 200;
353
+ const sseUidCache = new Map();
354
+ const ssePageCache = new Map();
355
+ export function resetSseCachesForTest() {
356
+ sseUidCache.clear();
357
+ ssePageCache.clear();
358
+ }
359
+ async function sseCompanyPage(transport, page, signal) {
360
+ const cached = ssePageCache.get(page);
361
+ if (cached !== undefined)
362
+ return cached;
363
+ const outcome = await transport.request({ url: `${SSE_BASE}/allcompany.do`, method: 'POST', body: formBody({ code: '0', order: '2', areaId: '0', page: String(page) }), headers: { referer: SSE_REFERER } }, signal);
364
+ const body = asRecord(parseJson(outcome.text, 'sseinfo', '上证e互动公司列表响应'));
365
+ const content = typeof body?.content === 'string' ? body.content : undefined;
366
+ if (content === undefined) {
367
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', `上证e互动公司列表第 ${page} 页没有 content 字符串(页面结构可能已变)`);
368
+ }
369
+ const pairs = parseCompanyList(content);
370
+ if (pairs.length === 0 && page === 1) {
371
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', '上证e互动公司列表第 1 页解析出 0 家公司(页面格式可能已变)');
372
+ }
373
+ if (pairs.length === 0 && !content.includes(SSE_COMPANY_END)) {
374
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', `上证e互动公司列表第 ${page} 页既没有公司也没有末页提示(页面格式可能已变)`);
375
+ }
376
+ // 兑现"每次使用前做升序自检":页面进二分包面前先验序。parseCompanyList 已按 code 排序,
377
+ // 因此这里真正抓的是**重复 code**(正则配对错位、一家公司配了两次不同 uid)——
378
+ // 二分会据此定位 uid,宁可响亮失败也不返回错配的问答。
379
+ assertAscending(pairs, page);
380
+ if (ssePageCache.size >= SSE_UID_CACHE_LIMIT) {
381
+ // 逐条淘汰最旧的一项,**不能整表 clear()**:解析一次公司可能扫过十几页,
382
+ // 而整表清空会把**本次刚刚定位到的** uid 一起丢掉 ⇒ 下一次调用(或同一函数后半段)
383
+ // 又变成 NOT_FOUND。实测形态:单个 resolve 成功、紧接着的 sseinfoQa 却报"没有 600000"。
384
+ const oldest = ssePageCache.keys().next().value;
385
+ if (oldest !== undefined)
386
+ ssePageCache.delete(oldest);
387
+ }
388
+ ssePageCache.set(page, pairs);
389
+ // 这里**只**缓存页面本身。曾把整页的 code→uid 也灌进 uid 缓存,结果一次解析就塞满容量并
390
+ // 淘汰掉正在解析的代码(见 `resolveSseCompanyUid` 的注释)。uid 缓存只由"确认命中"写入。
391
+ return pairs;
392
+ }
393
+ function assertAscending(pairs, page) {
394
+ for (let index = 1; index < pairs.length; index += 1) {
395
+ if (pairs[index - 1].code >= pairs[index].code) {
396
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', `上证e互动公司列表第 ${page} 页不是按代码升序(配对可能错位,拒绝据此定位 uid)`);
397
+ }
398
+ }
399
+ }
400
+ /**
401
+ * 一次 uid 定位的**总**时限。`REQUEST_TIMEOUT_MS` 只管单个请求,而定位一次要串行 10–13 个
402
+ * 请求(最坏 13 × 20s ≈ 4 分钟),所以这段必须有自己的上限——工具层的超时由宿主决定,
403
+ * 不能拿"整轮对话还剩多少"来兜一个内部元数据查询。`sseinfoQa` 的 `uidLocateBudgetMs`
404
+ * 仅供测试注入缩短(与 `wind-client.ts` 的 `retryDelaysMs` 同一约定)。
405
+ */
406
+ export const SSE_UID_LOCATE_TIMEOUT_MS = 60_000;
407
+ /** 给"一串串行请求"套一个总时限,并与调用方的取消信号复合。 */
408
+ function startDeadline(outer, budgetMs) {
409
+ const controller = new AbortController();
410
+ let fired = false;
411
+ const timer = setTimeout(() => {
412
+ fired = true;
413
+ controller.abort(new Error(`deadline after ${budgetMs}ms`));
414
+ }, budgetMs);
415
+ const onOuterAbort = () => controller.abort(outer?.reason);
416
+ if (outer) {
417
+ if (outer.aborted)
418
+ onOuterAbort();
419
+ else
420
+ outer.addEventListener('abort', onOuterAbort, { once: true });
421
+ }
422
+ return {
423
+ signal: controller.signal,
424
+ exceeded: () => fired && outer?.aborted !== true,
425
+ dispose: () => {
426
+ clearTimeout(timer);
427
+ outer?.removeEventListener('abort', onOuterAbort);
428
+ },
429
+ };
430
+ }
431
+ /**
432
+ * 代码 → uid:公司列表分页升序,用**倍增 + 二分**定位。
433
+ *
434
+ * ⚠️ 缓存层次是本函数最容易写错的地方(2026-09-24 实测踩坑,症状很绕):
435
+ * - `ssePageCache` 缓存**页**,供二分复用,不参与"这个代码是谁"的判定;
436
+ * - `sseUidCache` 只缓存**已解析出的那个代码**,绝不在扫描页面时把整页代码灌进去。
437
+ *
438
+ * 为什么后者是硬约束:二分一次会扫过 ~10–13 页、每页 32 家。若把扫描到的**全部**代码都写进
439
+ * uid 缓存,一次解析就能塞进 200+ 条,把容量占满并**淘汰掉正在解析的那个代码**——表现为
440
+ * "单次解析成功、稍后再查同一个代码却报 NOT_FOUND",而且只在特定调用序列下复现。
441
+ * 因此 uid 只从"已确认命中"的那一页里取,取不到就按升序自检后的页范围继续二分。
442
+ */
443
+ export async function resolveSseCompanyUid(transport, code, signal) {
444
+ const cached = sseUidCache.get(code);
445
+ if (cached !== undefined)
446
+ return cached;
447
+ let high = 1;
448
+ // 倍增找到"最后一条 ≥ code"(或空页)的第一页。
449
+ for (let guard = 0; guard < 16; guard += 1) {
450
+ const pairs = await sseCompanyPage(transport, high, signal);
451
+ if (pairs.length === 0 || pairs[pairs.length - 1].code >= code)
452
+ break;
453
+ high *= 2;
454
+ }
455
+ let low = 1;
456
+ while (low <= high) {
457
+ const mid = Math.floor((low + high) / 2);
458
+ const pairs = await sseCompanyPage(transport, mid, signal);
459
+ if (pairs.length === 0 || code < pairs[0].code) {
460
+ high = mid - 1;
461
+ continue;
462
+ }
463
+ if (code > pairs[pairs.length - 1].code) {
464
+ low = mid + 1;
465
+ continue;
466
+ }
467
+ const hit = pairs.find((pair) => pair.code === code);
468
+ if (hit !== undefined) {
469
+ if (sseUidCache.size >= SSE_UID_CACHE_LIMIT) {
470
+ const oldest = sseUidCache.keys().next().value;
471
+ if (oldest !== undefined)
472
+ sseUidCache.delete(oldest);
473
+ }
474
+ sseUidCache.set(code, hit.uid);
475
+ return hit.uid;
476
+ }
477
+ break;
478
+ }
479
+ throw new SourceError('sseinfo', 'NOT_FOUND', `上证e互动没有 ${code}(该公司不在上交所,或已退市)`);
480
+ }
481
+ /**
482
+ * 解析问答列表页。以回复块 `class="m_feed_detail m_qa"` 为界切问题段与回复段——
483
+ * 不靠 id 区分("最新回复"与"最新提问"两种列表的标记不同)。
484
+ */
485
+ export function parseSseFeed(text) {
486
+ const items = [];
487
+ const chunks = text.split(/<div class="m_feed_item[^"]*" id="item-/u).slice(1);
488
+ for (const chunk of chunks) {
489
+ const numbered = /^(\d+)/u.exec(chunk);
490
+ if (!numbered) {
491
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', `上证e互动条目 id 不是数字(页面结构可能已变):${chunk.slice(0, 60)}`);
492
+ }
493
+ const itemId = numbered[1];
494
+ const [askPart, answerPart = ''] = chunk.split('class="m_feed_detail m_qa"');
495
+ const question = /<div class="m_feed_txt"[^>]*>\s*<a[^>]*>:(.*?)\((\d{6})\)<\/a>(.*?)<\/div>/su.exec(askPart);
496
+ const askTime = /<div class="m_feed_from"[^>]*>\s*<span>([^<]+)<\/span>/u.exec(askPart);
497
+ if (!question || !askTime) {
498
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', `上证e互动第 ${itemId} 条结构改变,无法解析问题或时间`);
499
+ }
500
+ let answer = '';
501
+ let answerTime = '';
502
+ if (answerPart !== '') {
503
+ const body = /<div class="m_feed_txt"[^>]*>(.*?)<\/div>/su.exec(answerPart);
504
+ const when = /<div class="m_feed_from"[^>]*>\s*<span>([^<]+)<\/span>/u.exec(answerPart);
505
+ if (!body || !when) {
506
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', `上证e互动第 ${itemId} 条有回复块但解析不出回复内容或时间`);
507
+ }
508
+ answer = clip(stripHtml(body[1]));
509
+ answerTime = sseTime(when[1]) ?? '';
510
+ if (answerTime === '') {
511
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', `上证e互动第 ${itemId} 条的回复时间认不出:${when[1]}`);
512
+ }
513
+ }
514
+ const questionTime = sseTime(askTime[1]);
515
+ if (questionTime === undefined) {
516
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', `上证e互动第 ${itemId} 条的提问时间认不出:${askTime[1]}`);
517
+ }
518
+ items.push({
519
+ id: itemId,
520
+ code: question[2],
521
+ question: clip(stripHtml(question[3])),
522
+ answer,
523
+ status: answer === '' ? 'unanswered' : 'answered',
524
+ time: questionTime,
525
+ answer_time: answerTime,
526
+ });
527
+ }
528
+ return items;
529
+ }
530
+ /**
531
+ * 页面时间统一成「YYYY-MM-DD HH:MM」(北京时间)。
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 主机上),所以先取"北京当下"再回推。
541
+ */
542
+ export function sseTime(text, now = new Date()) {
543
+ const absolute = /(\d{4})年(\d{2})月(\d{2})日\s*(\d{2}:\d{2})/u.exec(text);
544
+ if (absolute !== null) {
545
+ return `${absolute[1]}-${absolute[2]}-${absolute[3]} ${absolute[4]}`;
546
+ }
547
+ const beijing = new Date(now.getTime() + 8 * 3600 * 1000);
548
+ const stamp = () => beijing.toISOString().slice(0, 16).replace('T', ' ');
549
+ const relative = /(今天|昨天)\s*(\d{2}:\d{2})/u.exec(text);
550
+ if (relative !== null) {
551
+ if (relative[1] === '昨天')
552
+ beijing.setUTCDate(beijing.getUTCDate() - 1);
553
+ return `${beijing.toISOString().slice(0, 10)} ${relative[2]}`;
554
+ }
555
+ const elapsed = /(\d+)\s*(分钟|小时)前/u.exec(text);
556
+ if (elapsed !== null) {
557
+ const amount = Number(elapsed[1]) * (elapsed[2] === '小时' ? 60 : 1);
558
+ beijing.setUTCMinutes(beijing.getUTCMinutes() - amount);
559
+ return stamp();
560
+ }
561
+ // 「刚刚」没有分钟精度,按当前分钟处理;其余一律返回 undefined(调用方据此失败,不猜)。
562
+ return /^刚刚$/u.test(text.trim()) ? stamp() : undefined;
563
+ }
564
+ export async function sseinfoQa(transport, input) {
565
+ const type = SSE_KIND[input.kind];
566
+ let text;
567
+ if (input.code === undefined) {
568
+ const params = new URLSearchParams({
569
+ type: String(type),
570
+ pageSize: String(input.pageSize),
571
+ lastid: '-1',
572
+ show: '1',
573
+ page: String(input.page),
574
+ });
575
+ const outcome = await transport.request({ url: `${SSE_BASE}/ajax/feeds.do?${params.toString()}`, headers: { referer: SSE_REFERER } }, input.signal);
576
+ text = outcome.text;
577
+ }
578
+ else {
579
+ const budgetMs = input.uidLocateBudgetMs ?? SSE_UID_LOCATE_TIMEOUT_MS;
580
+ const deadline = startDeadline(input.signal, budgetMs);
581
+ let uid;
582
+ try {
583
+ uid = await resolveSseCompanyUid(transport, input.code, deadline.signal);
584
+ }
585
+ catch (error) {
586
+ // 总时限到点是"这个来源查得慢",不是调用方取消:必须翻成 SourceError,
587
+ // 否则工具层会按 ABORTED 原样上抛,上层误读成"用户中断了这一轮"。
588
+ if (deadline.exceeded()) {
589
+ throw new SourceError('sseinfo', 'TIMEOUT', `上证e互动公司 uid 定位超过 ${Math.round(budgetMs / 1000)}s(一次定位需 10–13 个串行请求);稍后重试,或先用不带 code 的 sseinfo_qa 取全市场问答`);
590
+ }
591
+ throw error;
592
+ }
593
+ finally {
594
+ deadline.dispose();
595
+ }
596
+ const params = new URLSearchParams({
597
+ typeCode: 'company',
598
+ type: String(type),
599
+ pageSize: String(input.pageSize),
600
+ uid,
601
+ page: String(input.page),
602
+ });
603
+ const outcome = await transport.request({ url: `${SSE_BASE}/ajax/userfeeds.do?${params.toString()}`, method: 'POST', body: '', headers: { referer: SSE_REFERER } }, input.signal);
604
+ text = outcome.text;
605
+ }
606
+ const parsed = parseSseFeed(text);
607
+ // 只有页面明确写着「暂无 / 暂时没有」才算真没有问答;解析出 0 条又没这句话,是结构变了。
608
+ if (parsed.length === 0 && !SSE_EMPTY_NOTE.test(text)) {
609
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', '上证e互动页面既没有问答也没有「暂无」提示(结构可能已变)');
610
+ }
611
+ const notes = [];
612
+ let items = parsed;
613
+ if (input.code !== undefined) {
614
+ // ⚠️ 上游有数据漂移:2026-09-24 实测 `600519`(uid 518)在 `kind='questions'` 下返回的是
615
+ // **600518** 的问答。此处**按条过滤**而不是整次失败——整次失败会把该公司页面上真实存在的
616
+ // 问答一起丢掉,比"少一条被污染的数据"更糟。丢弃条数与原因写进 note,绝不静默。
617
+ const foreign = parsed.filter((item) => item.code !== input.code);
618
+ items = parsed.filter((item) => item.code === input.code);
619
+ if (foreign.length > 0) {
620
+ notes.push(`已丢弃 ${foreign.length} 条非 ${input.code} 的问答(上游返回了 ${[...new Set(foreign.map((item) => String(item.code)))].join('/')},疑似上游数据漂移)`);
621
+ }
622
+ if (items.length === 0 && foreign.length > 0) {
623
+ throw new SourceError('sseinfo', 'INVALID_RESPONSE', `${input.code} 本次返回的问答全部属于其他公司(上游数据漂移),已全部丢弃且无可用条目`);
624
+ }
625
+ notes.push('平台只开放近期问答(公司维度实测约近 1 个月);北交所公司两个平台都没有');
626
+ }
627
+ return {
628
+ source: 'sseinfo',
629
+ operation: input.code === undefined ? `market_${input.kind}` : `company_${input.kind}`,
630
+ items: clipItems(items),
631
+ ...(notes.length > 0 ? { note: notes.join(';') } : {}),
632
+ };
633
+ }
634
+ // ── 东财全球资讯 7×24(np-weblist)──────────────────────────────────────────
635
+ const EM_724_URL = 'https://np-weblist.eastmoney.com/comm/web/getFastNewsList';
636
+ const EM_REFERER = 'https://kuaixun.eastmoney.com/';
637
+ /**
638
+ * 取东财 7×24 全球快讯。媒体的独立备份来源(与 `cls_telegraph` / `wscn_lives` 互备):
639
+ * 三条不同源、不同风控面,一条不可用另一条仍在。
640
+ *
641
+ * 实测(2026-09-24):GET + JSON,`code` 为**字符串** `"1"` 表示成功;条目含 `title` /
642
+ * `showTime`(已是北京时间字符串)/ `summary`。服务端会把它聚合到的财联社内容一并返回,
643
+ * 所以同一事件可能与其他来源重复——**去重由调用方按标题判断**,本工具不猜。
644
+ */
645
+ export async function eastmoneyFastNews(client, input) {
646
+ const params = new URLSearchParams({
647
+ client: 'web',
648
+ biz: 'web_724',
649
+ fastColumn: '102',
650
+ sortEnd: '',
651
+ pageSize: String(input.limit),
652
+ req_trace: emTraceId(),
653
+ });
654
+ const outcome = await client.fetchText(`${EM_724_URL}?${params.toString()}`, {
655
+ headers: { referer: EM_REFERER },
656
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
657
+ });
658
+ const body = asRecord(parseJson(outcome.text, 'eastmoney', '东财 7×24 响应'));
659
+ const data = asRecord(body?.data);
660
+ // 上游用字符串 "1" 表示成功(不是数字 1):按字符串比较,避免把成功判成失败。
661
+ if (String(body?.code ?? '') !== '1') {
662
+ throw new SourceError('eastmoney', 'UPSTREAM', `东财 7×24 返回 code=${String(body?.code)}(上游口径变更或风控)`);
663
+ }
664
+ const rows = requireRows(data?.fastNewsList, 'eastmoney', '东财 7×24 data.fastNewsList');
665
+ const items = [];
666
+ for (const raw of rows) {
667
+ const row = asRecord(raw);
668
+ if (!row)
669
+ continue;
670
+ items.push({
671
+ time: clip(row.showTime),
672
+ title: clip(row.title),
673
+ content: clip(row.summary),
674
+ url: itemUrl(row.url),
675
+ });
676
+ }
677
+ return { source: 'eastmoney', operation: 'fast_news', items: clipItems(items) };
678
+ }
679
+ /** 请求去噪用的随机串(东财要求,缺失不影响正确性)。 */
680
+ function emTraceId() {
681
+ return `${Date.now().toString(16)}-${Math.floor(Math.random() * 1e16).toString(16)}`;
682
+ }
683
+ // ── 东财个股新闻(search-api-web,JSONP)────────────────────────────────────
684
+ const EM_STOCK_NEWS_URL = 'https://search-api-web.eastmoney.com/search/jsonp';
685
+ const EM_SO_REFERER = 'https://so.eastmoney.com/';
686
+ /**
687
+ * 取某只股票的东财新闻(JSONP 接口,正文在 `cmsArticleWebOld` 数组里)。
688
+ *
689
+ * ⚠️ 必须区分两种"没结果"(参考实践记为 #18,本仓 2026-09-24 实测为正常返回):
690
+ * 上游对部分 IP 间歇风控时只返回 `passportWeb`(股民资料)而**没有** `cmsArticleWebOld` 键——
691
+ * 这与"该股确实没有新闻"(键在、数组为空)是**两件事**。本函数按**键是否存在**判定,
692
+ * 缺键时报 `UPSTREAM`,绝不静默返回空表。
693
+ */
694
+ export async function eastmoneyStockNews(client, input) {
695
+ const inner = JSON.stringify({
696
+ uid: '',
697
+ keyword: input.code,
698
+ type: ['cmsArticleWebOld'],
699
+ client: 'web',
700
+ clientType: 'web',
701
+ clientVersion: 'curr',
702
+ param: { cmsArticleWebOld: { searchScope: 'default', sort: 'default', pageIndex: 1, pageSize: input.limit, preTag: '', postTag: '' } },
703
+ });
704
+ const params = new URLSearchParams({ cb: 'jQuery_news', param: inner });
705
+ const outcome = await client.fetchText(`${EM_STOCK_NEWS_URL}?${params.toString()}`, {
706
+ headers: { referer: EM_SO_REFERER },
707
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
708
+ });
709
+ const open = outcome.text.indexOf('(');
710
+ const close = outcome.text.lastIndexOf(')');
711
+ if (open < 0 || close <= open) {
712
+ throw new SourceError('eastmoney', 'INVALID_RESPONSE', '东财个股新闻不是 JSONP 形态(上游结构可能已变)');
713
+ }
714
+ const body = asRecord(parseJson(outcome.text.slice(open + 1, close), 'eastmoney', '东财个股新闻响应'));
715
+ const result = asRecord(body?.result);
716
+ const articles = result?.cmsArticleWebOld;
717
+ if (!Array.isArray(articles)) {
718
+ throw new SourceError('eastmoney', 'UPSTREAM', `东财个股新闻未返回文章列表(result 键:${Object.keys(result ?? {}).join('/') || '无'})——通常是上游对该 IP 的间歇风控,隔几分钟或换网络再试,别当成"该股没有新闻"`);
719
+ }
720
+ const items = [];
721
+ for (const raw of articles) {
722
+ const row = asRecord(raw);
723
+ if (!row)
724
+ continue;
725
+ items.push({
726
+ time: clip(row.date),
727
+ title: clip(stripHtml(typeof row.title === 'string' ? row.title : '')),
728
+ content: clip(stripHtml(typeof row.content === 'string' ? row.content : '')),
729
+ media: clip(row.mediaName),
730
+ url: itemUrl(row.url),
731
+ });
732
+ }
733
+ return { source: 'eastmoney', operation: 'stock_news', items: clipItems(items) };
734
+ }
735
+ // ── 东财研报列表(reportapi)──────────────────────────────────────────────
736
+ const EM_REPORT_URL = 'https://reportapi.eastmoney.com/report/list';
737
+ const EM_DATA_REFERER = 'https://data.eastmoney.com/';
738
+ const EM_PDF_BASE = 'https://pdf.dfcfw.com/pdf/';
739
+ /**
740
+ * 研报 PDF 直链由 `infoCode` 派生:`H3_<infoCode>_1.pdf`(`_1` = 第一个附件,个股研报都是
741
+ * `H3` 类)。2026-09-25 实测三个不同年份的 `infoCode` 全部 `HTTP 200` + `application/pdf`、
742
+ * 无需 Referer。
743
+ *
744
+ * 形状不认识就**不给链接**(宁缺勿造):从上游文本里拼一条可能指向别人文档的 URL,
745
+ * 比让模型说"这条没直链"危险得多。
746
+ */
747
+ function emReportPdfUrl(infoCode) {
748
+ return typeof infoCode === 'string' && /^AP\d{6,}$/u.test(infoCode) ? `${EM_PDF_BASE}H3_${infoCode}_1.pdf` : undefined;
749
+ }
750
+ /**
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`,看起来像"这只票没研报"。
762
+ */
763
+ export async function eastmoneyReports(client, input) {
764
+ const params = new URLSearchParams({
765
+ industryCode: '*',
766
+ pageSize: String(input.pageSize),
767
+ industry: '*',
768
+ rating: '*',
769
+ ratingChange: '*',
770
+ beginTime: '2000-01-01',
771
+ endTime: '2030-01-01',
772
+ pageNo: String(input.pageNo),
773
+ fields: '',
774
+ qType: '0',
775
+ orgCode: '',
776
+ code: input.code,
777
+ rcode: '',
778
+ p: String(input.pageNo),
779
+ pageNum: String(input.pageNo),
780
+ pageNumber: String(input.pageNo),
781
+ });
782
+ const outcome = await client.fetchText(`${EM_REPORT_URL}?${params.toString()}`, {
783
+ headers: { referer: EM_DATA_REFERER },
784
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
785
+ });
786
+ const body = asRecord(parseJson(outcome.text, 'eastmoney', '东财研报响应'));
787
+ const rows = Array.isArray(body?.data) ? body.data : [];
788
+ const totalPage = typeof body?.TotalPage === 'number' ? body.TotalPage : 1;
789
+ const hits = typeof body?.hits === 'number' ? body.hits : rows.length;
790
+ const items = [];
791
+ for (const raw of rows) {
792
+ const row = asRecord(raw);
793
+ if (!row)
794
+ continue;
795
+ const pdfUrl = emReportPdfUrl(row.infoCode);
796
+ items.push({
797
+ date: typeof row.publishDate === 'string' ? row.publishDate.slice(0, 10) : '',
798
+ title: clip(row.title),
799
+ org: clip(row.orgSName),
800
+ researcher: clip(row.researcher),
801
+ rating: clip(row.emRatingName),
802
+ rating_change: typeof row.ratingChange === 'number' ? row.ratingChange : null,
803
+ // ⚠️ 实测该字段是**字符串**(`'1.0700000000'`,保留精度),不是 number——
804
+ // 早先按 `typeof === 'number'` 判定会让它**恒为 null**(看起来像"这篇研报没有预测")。
805
+ eps_this_year: toNumber(row.predictThisYearEps),
806
+ eps_next_year: toNumber(row.predictNextYearEps),
807
+ info_code: clip(row.infoCode),
808
+ // 列表把正文的**入口**一并交出来:没有 `pdf_url`,模型手上就没有任何稳定 `.pdf` 直链,
809
+ // 而 `ocr` 这条通路只吃直链或本地文件。
810
+ ...(pdfUrl === undefined ? {} : { pdf_url: pdfUrl }),
811
+ attach_pages: typeof row.attachPages === 'number' && Number.isFinite(row.attachPages) ? row.attachPages : null,
812
+ });
813
+ }
814
+ return {
815
+ source: 'eastmoney',
816
+ operation: 'reports',
817
+ items: clipItems(items),
818
+ ...(totalPage > input.pageNo ? { next_cursor: String(input.pageNo + 1) } : {}),
819
+ note: `东财研报库共 ${hits} 篇;列表不含摘要,正文在各条的 pdf_url(研报 PDF)里,交 ocr 解析(整篇一次算完)`,
820
+ };
821
+ }
822
+ // ── 新浪研报列表(第二来源)───────────────────────────────────────────────
823
+ const SINA_REPORT_URL = 'https://vip.stock.finance.sina.com.cn/q/go.php/vReport_List/kind';
824
+ const SINA_REFERER = 'https://finance.sina.com.cn/';
825
+ /**
826
+ * 取新浪研报列表(研报第二来源,与 `eastmoney_reports` 互为备份)。
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 且与"真的没有研报"长得一样,因此本函数按"带序号行数"与空页提示语双重判定。
835
+ */
836
+ export async function sinaReports(transport, input) {
837
+ const params = new URLSearchParams();
838
+ let url;
839
+ if (input.code === undefined) {
840
+ url = `${SINA_REPORT_URL}/lastest/index.phtml`;
841
+ params.set('p', String(input.page));
842
+ }
843
+ else {
844
+ url = `${SINA_REPORT_URL}/search/index.phtml`;
845
+ // 科创板/创业板等必须显式带交易所前缀,否则上游返回假空页(见上文实测第 2 条)。
846
+ params.set('symbol', sinaSymbol(input.code));
847
+ params.set('t1', 'all');
848
+ params.set('p', String(input.page));
849
+ }
850
+ const outcome = await transport.request({ url: `${url}?${params.toString()}`, headers: { referer: SINA_REFERER }, encoding: 'gbk' }, input.signal);
851
+ const text = outcome.text;
852
+ const emptyPage = text.includes('没有找到相关内容');
853
+ const numbered = (text.match(/<tr>\s*<td>\d+<\/td>/gu) ?? []).length;
854
+ const rowPattern = /<tr>\s*<td>\d+<\/td>\s*<td class="tal f14">\s*<a[^>]*?title="([^"]*)"[^>]*?href="([^"]*?\/rptid\/(\d+)\/[^"]*)"[^>]*>[\s\S]*?<\/a>\s*<\/td>\s*<td>([^<]*)<\/td>\s*<td>([^<]*)<\/td>\s*<td>([\s\S]*?)<\/td>\s*<td>([\s\S]*?)<\/td>\s*<\/tr>/gu;
855
+ const items = [];
856
+ for (const match of text.matchAll(rowPattern)) {
857
+ const href = match[2];
858
+ // 页面偶尔给协议相对链接(`//host/...`):补成 https 后走同一份链接口径。
859
+ const url = itemUrl(href.startsWith('//') ? `https:${href}` : href);
860
+ items.push({
861
+ // ⚠️ 列顺序按**页面表头**实测为准:序号 / 标题 / **报告类型** / **发布日期** / 机构 / 研究员。
862
+ // 即第 4 列是类型、第 5 列才是日期。早先按"日期在前"的想当然写反了,
863
+ // 产出 `date:"公司"`、`type:"2026-09-01"` —— 每列都有值、却整行错位,比报错危险。
864
+ date: clip(match[5]),
865
+ title: clip(stripHtml(match[1])),
866
+ type: clip(match[4]),
867
+ org: clip(stripHtml(match[6])),
868
+ researcher: clip(stripHtml(match[7])),
869
+ report_id: match[3],
870
+ url,
871
+ });
872
+ }
873
+ // 结构自检:带序号的行必须全部解析出来,否则是页面结构变了(不是"没有研报")。
874
+ if (items.length !== numbered) {
875
+ throw new SourceError('sina', 'INVALID_RESPONSE', `新浪研报页有 ${numbered} 行带序号、只解析出 ${items.length} 条(页面行结构可能已变)`);
876
+ }
877
+ if (items.length === 0 && !emptyPage) {
878
+ throw new SourceError('sina', 'INVALID_RESPONSE', '新浪研报页没有研报也没有「没有找到相关内容」提示(页面结构可能已变)');
879
+ }
880
+ return {
881
+ source: 'sina',
882
+ operation: input.code === undefined ? 'latest' : 'by_stock',
883
+ items: clipItems(items),
884
+ ...(items.length === 0 ? { note: '上游明确返回「没有找到相关内容」:该标的近期无研报,或已翻过末页' } : {}),
885
+ };
886
+ }
887
+ /**
888
+ * 新浪研报的 symbol 参数:一律带交易所前缀(实测缺前缀会让科创板返回假空页)。
889
+ *
890
+ * ⚠️ 顺序要紧:`92xxxx`(北交所**新号段**,如 832982→920982)以 9 开头,若先判 `9 → sh`
891
+ * 就会被发到上交所并拿到假空页。所以北交所号段(43/83/87/920)必须先判,
892
+ * 只有 `900xxx`(沪市 B 股)才归 sh。
893
+ */
894
+ export function sinaSymbol(code) {
895
+ if (/^(920|43|83|87)/u.test(code))
896
+ return `bj${code}`;
897
+ if (/^(6|9)/u.test(code))
898
+ return `sh${code}`;
899
+ if (/^(0|2|3)/u.test(code))
900
+ return `sz${code}`;
901
+ if (/^4/u.test(code))
902
+ return `bj${code}`;
903
+ return code;
904
+ }
905
+ // ── 同花顺机构一致预期 EPS(basic.10jqka.com.cn)─────────────────────────
906
+ const THS_EPS_URL = 'https://basic.10jqka.com.cn/new';
907
+ const THS_REFERER = 'https://basic.10jqka.com.cn/';
908
+ /**
909
+ * 取同花顺「机构一致预期 EPS」表(HTML 表格,**GBK**)。
910
+ *
911
+ * ⚠️ 两个必须记住的实测结论(2026-09-24):
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
+ * 另附页面摘要块里的"预测机构数 + 同比增速"(与表格同源,作为交叉印证)。
919
+ */
920
+ export async function thsEpsForecast(transport, input) {
921
+ const outcome = await transport.request({ url: `${THS_EPS_URL}/${input.code}/worth.html`, headers: { referer: THS_REFERER }, encoding: 'gbk' }, input.signal);
922
+ const html = outcome.text;
923
+ // 认表头而不是认位置:页面里有 30+ 张表,只有这张的列里有「每股收益 / EPS、均值 / mean」。
924
+ // 同时接受中英两种表头写法——真实页面是中文,但按"结构"而不是"某一个字面词"识别更耐改版。
925
+ const table = [...html.matchAll(/<table[\s\S]*?<\/table>/gu)]
926
+ .find((match) => /每股收益|EPS|每股收益\(元\)/iu.test(match[0]) && /均值|mean/iu.test(match[0]));
927
+ if (table === undefined) {
928
+ throw new SourceError('ths', 'INVALID_RESPONSE', '同花顺页面里没有含「每股收益 / 均值」列的表格(页面结构可能已变)');
929
+ }
930
+ const items = [];
931
+ for (const rowMatch of table[0].matchAll(/<tr[\s\S]*?<\/tr>/gu)) {
932
+ const cells = [...rowMatch[0].matchAll(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gu)]
933
+ .map((cell) => stripHtml(cell[1]).replace(/\s+/gu, ' ').trim());
934
+ const year = cells[0] ?? '';
935
+ if (!/^\d{4}$/u.test(year))
936
+ continue;
937
+ const num = (value) => {
938
+ const parsed = Number(value);
939
+ return value === undefined || value === '' || !Number.isFinite(parsed) ? null : parsed;
940
+ };
941
+ items.push({
942
+ year,
943
+ institutions: num(cells[1]),
944
+ eps_min: num(cells[2]),
945
+ // 「均值」就是机构一致预期 EPS —— 这是本工具的核心产出。
946
+ eps_mean: num(cells[3]),
947
+ eps_max: num(cells[4]),
948
+ industry_average: num(cells[5]),
949
+ });
950
+ }
951
+ if (items.length === 0) {
952
+ throw new SourceError('ths', 'INVALID_RESPONSE', '同花顺一致预期表没有解析出任何年度行(页面结构可能已变)');
953
+ }
954
+ const institutionCount = /共有\s*<strong>(\d+)<\/strong>\s*家机构/u.exec(html);
955
+ return {
956
+ source: 'ths',
957
+ operation: 'eps_forecast',
958
+ items: clipItems(items),
959
+ note: `${institutionCount === null ? '' : `页面摘要:${institutionCount[1]} 家机构作出预测;`}eps_mean 即机构一致预期 EPS;预测机构数偏少(<3)时一致预期参考价值有限——请把 institutions 一并向用户披露`,
960
+ };
961
+ }