@v587d/capital-generation 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +7 -12
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/automation/TABLER-ICONS-LICENSE +21 -0
  5. package/automation/builtins.js +237 -10
  6. package/automation/client.js +79 -17
  7. package/automation/client.src.cjs +873 -210
  8. package/automation/contract.js +4 -0
  9. package/automation/defaults.js +6 -4
  10. package/automation/index.js +12 -7
  11. package/automation/package.json +5 -2
  12. package/automation/route.js +9 -2
  13. package/automation/runner.js +33 -6
  14. package/automation/scheduler.js +34 -29
  15. package/automation/schema.js +39 -5
  16. package/automation/service.js +102 -10
  17. package/automation/settings.js +6 -0
  18. package/automation/store.js +64 -21
  19. package/automation/task-icons.js +75 -0
  20. package/automation/workspaces.js +113 -13
  21. package/chart-ui/client.js +2 -2
  22. package/chart-ui/package.json +1 -1
  23. package/config/client.js +19 -5
  24. package/config/client.src.cjs +365 -44
  25. package/config/index.js +7 -5
  26. package/config/package.json +1 -1
  27. package/lib/agents/root-tool-policy.d.ts +3 -3
  28. package/lib/agents/root-tool-policy.js +4 -2
  29. package/lib/chart/runtime.d.ts +1 -1
  30. package/lib/chart/runtime.js +95 -0
  31. package/lib/chart/series.d.ts +4 -4
  32. package/lib/chart/series.js +44 -10
  33. package/lib/data-collector/dataset-tools.js +22 -0
  34. package/lib/data-collector/describe.js +1 -0
  35. package/lib/data-collector/hub.js +5 -0
  36. package/lib/data-collector/store.d.ts +5 -0
  37. package/lib/data-collector/store.js +17 -0
  38. package/lib/data-collector/time-axis.d.ts +15 -0
  39. package/lib/data-collector/time-axis.js +73 -0
  40. package/lib/data-collector/tools.d.ts +1 -1
  41. package/lib/data-collector/tools.js +12 -1
  42. package/lib/index.d.ts +5 -0
  43. package/lib/index.js +7 -5
  44. package/lib/sources/tencent-http.js +7 -2
  45. package/lib/sources/tencent-public-core.d.ts +2 -0
  46. package/lib/sources/tencent-public-core.js +5 -0
  47. package/lib/sources/wind-mcp.js +3 -3
  48. package/lib/sources-entry.d.ts +1 -1
  49. package/lib/web-retriever/local-fetch.d.ts +1 -1
  50. package/lib/web-retriever/local-fetch.js +1 -1
  51. package/lib/web-retriever/sources.d.ts +58 -0
  52. package/lib/web-retriever/sources.js +290 -0
  53. package/lib/web-retriever/tools.js +199 -8
  54. package/lib/web-retriever/wind-client.d.ts +1 -1
  55. package/lib/web-retriever/wind-client.js +1 -1
  56. package/package.json +1 -1
  57. package/preset/capital-generation/agent.patch.yml +20 -19
  58. package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +6 -3
  59. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +9 -2
  60. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +2 -1
  61. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +2 -0
  62. package/preset/capital-generation/skills/capital-web-evidence/SKILL.md +18 -13
  63. package/preset/capital-generation/skills/capital-web-protocol/SKILL.md +30 -15
  64. package/watchlist/package.json +1 -1
package/lib/index.js CHANGED
@@ -49,12 +49,13 @@ const RetrieverSchema = z.object({
49
49
  .description('PaddleOCR 文档解析(ocr 工具)配置;Token 走 credentials 引用 PADDLE_OCR_TOKEN'),
50
50
  });
51
51
  const AutomationSchema = z.object({
52
- tickMs: z.number().default(60_000).description('投研自动化调度间隔毫秒(30000–3600000)'),
53
- runTimeoutMinutes: z.number().default(30).description('单次运行 wall-clock 上限分钟(5–120)'),
52
+ tickMs: z.number().default(60_000).description('投研自动化自动检查间隔(毫秒,30000–3600000)'),
53
+ runTimeoutMinutes: z.number().default(60).description('单次运行最长运行时间(分钟,5–120)'),
54
54
  idleTimeoutMinutes: z.number().default(5).description('单次运行空闲超时分钟(1–30)'),
55
- maxSteps: z.number().default(300).description('单次运行最大步骤数(10–2000)'),
56
- maxToolCalls: z.number().default(1_200).description('单次运行最大工具调用数(10–6000)'),
55
+ maxSteps: z.number().default(300).description('单次运行最大步骤数(100–2000)'),
56
+ maxToolCalls: z.number().default(1_200).description('单次运行最大工具调用数(100–5000)'),
57
57
  inAppNotifications: z.boolean().default(true).description('是否写入应用内通知'),
58
+ defaultWorkspaceId: z.string().default('').description('统一默认工作区 id;空 = 专属默认工作区'),
58
59
  });
59
60
  // Derive toggles from the catalog: a hand-copied list silently drops defaults at the entry→plugin boundary.
60
61
  const SelectedSkillsSchema = z.object(Object.fromEntries(SELECTED_SKILL_CATALOG.map(({ key, name }) => [
@@ -77,11 +78,12 @@ export const Config = z.object({
77
78
  .description('FTShare(非凸科技)credentials 引用名,空 = FTSHARE_API_KEY'),
78
79
  automation: AutomationSchema.default({
79
80
  tickMs: 60_000,
80
- runTimeoutMinutes: 30,
81
+ runTimeoutMinutes: 60,
81
82
  idleTimeoutMinutes: 5,
82
83
  maxSteps: 300,
83
84
  maxToolCalls: 1_200,
84
85
  inAppNotifications: true,
86
+ defaultWorkspaceId: '',
85
87
  }).description('投研自动化默认值;任务级覆盖优先'),
86
88
  retriever: RetrieverSchema.default({ baseURL: '', credentialRef: 'ANYSEARCH_API_KEY', windDocs: { endpoint: '', credentialRef: 'WIND_API_KEY', timeoutMs: 60000 }, localFetch: { ...LOCAL_FETCH_DEFAULTS }, paddleOcr: { ...PADDLE_OCR_DEFAULTS } })
87
89
  .description('web_retriever 配置(可选;缺省使用 AnySearch 默认地址与凭据名)'),
@@ -3,7 +3,7 @@ import { getDataTimeContract } from '../time/tools.js';
3
3
  import { isLikelyIndex, normalizeSecurityCodes, parseSecurityCode, requireTencentSecurity } from './security-code.js';
4
4
  // Shared core holds HK/US snapshots, smartbox and the one throttle chain; this file only defines
5
5
  // capabilities and K-line/tick orchestration. Two throttles = no throttle.
6
- import { QUOTE_URL, fetchOffshoreIndexQuotes, fetchOffshoreQuotes, isRecord, normalizeHkCode, normalizeOffshoreIndexCode, normalizeUsCode, numberOrNull, requiredNumber, resolveUsKlineSymbol, sourceError, tencentGetText as getText, } from './tencent-public-core.js';
6
+ import { QUOTE_URL, aShareQuoteTime, fetchOffshoreIndexQuotes, fetchOffshoreQuotes, isRecord, normalizeHkCode, normalizeOffshoreIndexCode, normalizeUsCode, numberOrNull, requiredNumber, resolveUsKlineSymbol, sourceError, tencentGetText as getText, } from './tencent-public-core.js';
7
7
  const TICK_URL = 'https://stock.gtimg.cn/data/index.php';
8
8
  const KLINE_HOSTS = [
9
9
  'https://web.ifzq.gtimg.cn',
@@ -43,6 +43,7 @@ const quoteOutput = {
43
43
  limit_down: { oneOf: [{ type: 'number' }, { type: 'null' }] },
44
44
  vol_ratio: { oneOf: [{ type: 'number' }, { type: 'null' }] },
45
45
  pe_static: { oneOf: [{ type: 'number' }, { type: 'null' }] },
46
+ quote_time: { type: 'string', description: '交易所当地时间(北京时间),不是抓取时刻' },
46
47
  is_stale: { type: 'boolean' },
47
48
  stale_reason: { oneOf: [{ type: 'string' }, { type: 'null' }] },
48
49
  },
@@ -196,6 +197,9 @@ function parseQuoteRows(text, requested) {
196
197
  const values = match[3].split('~');
197
198
  if (values.length < 53)
198
199
  throw sourceError(`Tencent quote ${symbol} returned ${values.length} fields, expected at least 53`, 'tencent_invalid_response');
200
+ const quoteTime = aShareQuoteTime(values[30]);
201
+ if (quoteTime === null)
202
+ throw sourceError(`Tencent quote ${symbol} has an unreadable timestamp ${JSON.stringify(values[30] ?? '')}`, 'tencent_invalid_response');
199
203
  const value = (index) => numberOrNull(values[index]);
200
204
  const row = {
201
205
  code: code.canonical,
@@ -219,6 +223,7 @@ function parseQuoteRows(text, requested) {
219
223
  limit_down: value(48),
220
224
  vol_ratio: value(49),
221
225
  pe_static: value(52),
226
+ quote_time: quoteTime,
222
227
  };
223
228
  const stale = row.amount_wan === 0 && row.price !== null && row.price === row.last_close;
224
229
  row.is_stale = stale;
@@ -733,7 +738,7 @@ export function createTencentSources() {
733
738
  cacheMaxAgeMs: 60_000,
734
739
  name: 'get_tencent_quote',
735
740
  summary: '腾讯实时行情、估值、涨跌停和 ETF 快照',
736
- description: '批量获取 SH/SZ 股票、指数或 ETF 的腾讯实时快照。输入 codes 为完整证券代码数组(如 600519.SH、000001.SZ、000001.SH、510300.SH),返回保留腾讯字段语义的行数组:价格、涨跌、成交额(万元)、换手率、PE/PB、市值和涨跌停价。44 为流通市值、45 为总市值;43 为振幅,不是 PB。腾讯返回成交额为 0 且最新价等于昨收时标记 is_stale=true,不把它当作当日有效成交。BJ 代码当前明确拒绝。',
741
+ description: '批量获取 SH/SZ 股票、指数或 ETF 的腾讯实时快照。输入 codes 为完整证券代码数组(如 600519.SH、000001.SZ、000001.SH、510300.SH),返回保留腾讯字段语义的行数组:价格、涨跌、成交额(万元)、换手率、PE/PB、市值和涨跌停价。quote_time 是交易所当地时间(北京时间)的最后成交时刻,不是抓取时刻;判断“数据是不是今天”要用它,不要用 captured_at 代替。44 为流通市值、45 为总市值;43 为振幅,不是 PB。腾讯返回成交额为 0 且最新价等于昨收时标记 is_stale=true,不把它当作当日有效成交。BJ 代码当前明确拒绝。',
737
742
  inputSchema: { type: 'object', properties: { codes: { type: 'array', minItems: 1, maxItems: 50, items: { type: 'string', description: '完整证券代码,如 600519.SH' } } }, required: ['codes'], additionalProperties: false },
738
743
  outputSchema: quoteOutput,
739
744
  paginated: false,
@@ -14,6 +14,8 @@ export declare const HK_QUOTE_FIELDS = 78;
14
14
  export declare const US_QUOTE_FIELDS = 73;
15
15
  /** Exchange-local time; separator drifts per row (`/` vs `-`), normalize to hyphen. */
16
16
  export declare function offshoreQuoteTime(value: unknown): string | null;
17
+ /** A-share snapshots give a compact `YYYYMMDDHHMMSS` (Beijing time), not the HK/US spaced form. */
18
+ export declare function aShareQuoteTime(value: unknown): string | null;
17
19
  export declare function snapshotRows(text: string, requested: Set<string>): Array<{
18
20
  symbol: string;
19
21
  fields: string[];
@@ -56,6 +56,11 @@ export function offshoreQuoteTime(value) {
56
56
  const match = /^(\d{4})[-/](\d{2})[-/](\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(String(value ?? '').trim());
57
57
  return match ? `${match[1]}-${match[2]}-${match[3]} ${match[4]}` : null;
58
58
  }
59
+ /** A-share snapshots give a compact `YYYYMMDDHHMMSS` (Beijing time), not the HK/US spaced form. */
60
+ export function aShareQuoteTime(value) {
61
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(String(value ?? '').trim());
62
+ return match ? `${match[1]}-${match[2]}-${match[3]} ${match[4]}:${match[5]}:${match[6]}` : null;
63
+ }
59
64
  export function snapshotRows(text, requested) {
60
65
  const rows = [];
61
66
  for (const line of text.split(';')) {
@@ -472,7 +472,7 @@ export function createWindSources(resolveApiKey) {
472
472
  tool: 'search_economic_indicator',
473
473
  server: 'economic_data',
474
474
  cacheMaxAgeMs: 6 * 3_600_000,
475
- summary: 'Wind EDB 宏观/行业/汇率指标检索(只找码不取数)',
475
+ summary: '[计费] Wind EDB 宏观/行业/汇率指标检索(只找码不取数)',
476
476
  description: EDB_SEARCH_DESCRIPTION,
477
477
  inputSchema: {
478
478
  type: 'object',
@@ -491,7 +491,7 @@ export function createWindSources(resolveApiKey) {
491
491
  tool: 'query_economic_indicator_data',
492
492
  server: 'economic_data',
493
493
  cacheMaxAgeMs: 6 * 3_600_000,
494
- summary: 'Wind EDB 指标时间序列(宏观/行业/汇率,按日期区间)',
494
+ summary: '[计费] Wind EDB 指标时间序列(宏观/行业/汇率,按日期区间)',
495
495
  description: EDB_QUERY_DESCRIPTION,
496
496
  inputSchema: {
497
497
  type: 'object',
@@ -514,7 +514,7 @@ export function createWindSources(resolveApiKey) {
514
514
  tool: 'get_stock_kline',
515
515
  server: 'stock_data',
516
516
  cacheMaxAgeMs: 60_000,
517
- summary: 'Wind 日周月与 1–30 分钟 K 线(按日期区间,A股+港股+美股)',
517
+ summary: '[计费] Wind 日周月与 1–30 分钟 K 线(按日期区间,A股+港股+美股)',
518
518
  description: KLINE_DESCRIPTION,
519
519
  inputSchema: {
520
520
  type: 'object',
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `tools/retrieve-sources` row: the nine named-source tools, gated by
2
+ * `tools/retrieve-sources` row: the eleven named-source tools, gated by
3
3
  * `localFetch.enabled`. When disabled they stay registered and fail loudly per call;
4
4
  * unregistering at boot would freeze one config read for the process lifetime.
5
5
  */
@@ -3,7 +3,7 @@
3
3
  * Known limit: DNS resolves per request but the IP is not pinned to the connection,
4
4
  * so a rebinding window remains (same trade-off as dsh-search-first).
5
5
  */
6
- export declare const LOCAL_FETCH_CLIENT_VERSION = "2.15.0";
6
+ export declare const LOCAL_FETCH_CLIENT_VERSION = "2.16.0";
7
7
  export declare const LOCAL_FETCH_MAX_URL_LENGTH = 2048;
8
8
  export type LocalFetchErrorCode = 'ABORTED' | 'TIMEOUT' | 'INVALID_URL' | 'BLOCKED_URL' | 'DNS' | 'REDIRECT' | 'HTTP' | 'TOO_LARGE' | 'UNSUPPORTED_CONTENT_TYPE';
9
9
  export declare class LocalFetchError extends Error {
@@ -4,7 +4,7 @@
4
4
  * so a rebinding window remains (same trade-off as dsh-search-first).
5
5
  */
6
6
  import { htmlToMarkdown, stripInvisibleText } from './html-markdown.js';
7
- export const LOCAL_FETCH_CLIENT_VERSION = '2.15.0';
7
+ export const LOCAL_FETCH_CLIENT_VERSION = '2.16.0';
8
8
  export const LOCAL_FETCH_MAX_URL_LENGTH = 2048;
9
9
  export class LocalFetchError extends Error {
10
10
  code;
@@ -128,6 +128,64 @@ export declare function eastmoneyReports(client: EastmoneyClient, input: {
128
128
  pageNo: number;
129
129
  signal?: AbortSignal;
130
130
  }): Promise<SourceOutcome>;
131
+ export type AnnouncementMarket = 'A' | 'H' | 'U';
132
+ export interface NormalizedAnnouncementCode {
133
+ code: string;
134
+ market: AnnouncementMarket;
135
+ }
136
+ /**
137
+ * Accept the forms users actually paste (`600519.SH` / `SH600519` / `0700.HK` / `AAPL.O`).
138
+ * The market is inferred only from an unambiguous shape; a mixed list is rejected rather
139
+ * than silently sent to one `ann_type`.
140
+ */
141
+ export declare function normalizeAnnouncementCode(value: unknown, market?: AnnouncementMarket | 'auto'): NormalizedAnnouncementCode;
142
+ export declare function normalizeAnnouncementCodes(values: unknown, market?: AnnouncementMarket | 'auto'): {
143
+ codes: string[];
144
+ market: AnnouncementMarket;
145
+ };
146
+ /**
147
+ * Eastmoney republishes issuer announcements; it is a complete issuer-scoped list by day,
148
+ * whereas Wind's NL RAG is semantic and can miss the newest item. The list is the primary
149
+ * path for "what was disclosed"; the authoritative PDF remains cninfo / the issuer.
150
+ */
151
+ export declare function eastmoneyAnnouncements(client: EastmoneyClient, input: {
152
+ codes: string[];
153
+ market: AnnouncementMarket;
154
+ startDate: string;
155
+ endDate: string;
156
+ cursor: number;
157
+ pageSize: number;
158
+ signal?: AbortSignal;
159
+ }): Promise<SourceOutcome>;
160
+ export interface AnnouncementBody {
161
+ source: 'eastmoney';
162
+ operation: 'announcement_body';
163
+ art_code: string;
164
+ page: number;
165
+ offset: number;
166
+ page_chars: number;
167
+ total_pages: number;
168
+ content: string;
169
+ next_offset?: number;
170
+ next_page?: number;
171
+ done: boolean;
172
+ pdf_url?: string;
173
+ title?: string;
174
+ notice_date?: string;
175
+ note?: string;
176
+ }
177
+ /**
178
+ * The detail API returns at most 5,000 characters per `page_index`; `page_size` is the
179
+ * total page count, so `done` is knowable without fetching a sentinel page. US filings
180
+ * often return an empty body while still carrying a PDF — that is a fact, not an error.
181
+ */
182
+ export declare function eastmoneyAnnouncementBody(client: EastmoneyClient, input: {
183
+ artCode: string;
184
+ page: number;
185
+ offset: number;
186
+ limit: number;
187
+ signal?: AbortSignal;
188
+ }): Promise<AnnouncementBody>;
131
189
  /**
132
190
  * Always add the exchange prefix: bare codes get a fake empty page for STAR/ChiNext.
133
191
  * That page is HTTP 200 and looks like "no reports", so also verify the numbered row count.
@@ -780,6 +780,296 @@ export async function eastmoneyReports(client, input) {
780
780
  note: `东财研报库共 ${hits} 篇;列表不含摘要,正文在各条的 pdf_url(研报 PDF)里,交 ocr 解析(整篇一次算完)`,
781
781
  };
782
782
  }
783
+ const EM_ANNOUNCEMENT_LIST_URL = 'https://np-anotice-stock.eastmoney.com/api/security/ann';
784
+ const EM_ANNOUNCEMENT_BODY_URL = 'https://np-cnotice-stock.eastmoney.com/api/content/ann';
785
+ const EM_ANNOUNCEMENT_REFERER = 'https://data.eastmoney.com/';
786
+ const EM_ANNOUNCEMENT_PAGE_SIZE = 100;
787
+ /** Scan cap: a 7-day watchlist window rarely needs more; the note must say when it is hit. */
788
+ const EM_ANNOUNCEMENT_MAX_SCAN_PAGES = 5;
789
+ /**
790
+ * Accept the forms users actually paste (`600519.SH` / `SH600519` / `0700.HK` / `AAPL.O`).
791
+ * The market is inferred only from an unambiguous shape; a mixed list is rejected rather
792
+ * than silently sent to one `ann_type`.
793
+ */
794
+ export function normalizeAnnouncementCode(value, market = 'auto') {
795
+ const raw = typeof value === 'string' ? value.trim().toUpperCase() : '';
796
+ if (raw.length === 0)
797
+ throw new Error('announcement code is required');
798
+ if (raw.length > 32)
799
+ throw new Error(`announcement code is too long: ${raw.slice(0, 32)}…`);
800
+ const suffix = /\.(SH|SZ|BJ|HK|O|N|US)$/u.exec(raw)?.[1];
801
+ let body = suffix === undefined ? raw : raw.slice(0, -(suffix.length + 1));
802
+ const prefix = /^(SH|SZ|BJ|HK)(?=\d)/u.exec(body)?.[1];
803
+ if (prefix !== undefined)
804
+ body = body.slice(prefix.length);
805
+ if (body.length === 0)
806
+ throw new Error(`announcement code is empty after removing prefix/suffix: ${raw}`);
807
+ const suffixMarket = suffix === undefined && prefix === undefined
808
+ ? undefined
809
+ : suffix === 'HK' || prefix === 'HK' ? 'H'
810
+ : suffix === 'O' || suffix === 'N' || suffix === 'US' ? 'U'
811
+ : 'A';
812
+ const shaped = /^\d{6}$/u.test(body)
813
+ ? 'A'
814
+ : /^\d{1,5}$/u.test(body)
815
+ ? 'H'
816
+ : /^[A-Z][A-Z0-9.-]{0,9}$/u.test(body) && !body.includes('..')
817
+ ? 'U'
818
+ : undefined;
819
+ if (shaped === undefined)
820
+ throw new Error(`announcement code shape not recognized: ${raw}`);
821
+ if (suffixMarket !== undefined && suffixMarket !== shaped && !(suffixMarket === 'A' && shaped === 'H')) {
822
+ throw new Error(`announcement code ${raw} has a suffix for ${suffixMarket} but a ${shaped} code; pass the right market or fix the code`);
823
+ }
824
+ const target = market === 'auto' ? (suffixMarket ?? shaped) : market;
825
+ if (target === 'A' && !/^\d{6}$/u.test(body))
826
+ throw new Error(`A-share announcement code must be six digits: ${raw}`);
827
+ if (target === 'H') {
828
+ if (!/^\d{1,5}$/u.test(body))
829
+ throw new Error(`HK announcement code must be 1–5 digits: ${raw}`);
830
+ body = body.padStart(5, '0');
831
+ }
832
+ if (target === 'U' && !(/^[A-Z][A-Z0-9.-]{0,9}$/u.test(body) && !body.includes('..'))) {
833
+ throw new Error(`US announcement ticker is invalid: ${raw}`);
834
+ }
835
+ return { code: body, market: target };
836
+ }
837
+ export function normalizeAnnouncementCodes(values, market = 'auto') {
838
+ if (!Array.isArray(values) || values.length === 0)
839
+ throw new Error('codes must be a non-empty array');
840
+ if (values.length > 50)
841
+ throw new Error('codes accepts at most 50 codes per call');
842
+ const normalized = values.map((value) => normalizeAnnouncementCode(value, market));
843
+ const markets = new Set(normalized.map((item) => item.market));
844
+ if (markets.size !== 1) {
845
+ throw new Error('codes must be from one market per call (A / H / U); split mixed lists and call once per market');
846
+ }
847
+ return { codes: [...new Set(normalized.map((item) => item.code))], market: normalized[0].market };
848
+ }
849
+ function announcementDate(value, field) {
850
+ const text = value.trim().slice(0, 10);
851
+ if (!/^\d{4}-\d{2}-\d{2}$/u.test(text))
852
+ throw new Error(`parameter ${field} must be YYYY-MM-DD`);
853
+ const check = new Date(`${text}T00:00:00Z`);
854
+ if (Number.isNaN(check.getTime()) || check.toISOString().slice(0, 10) !== text) {
855
+ throw new Error(`parameter ${field} must be a valid YYYY-MM-DD date`);
856
+ }
857
+ return text;
858
+ }
859
+ function announcementRowDate(row) {
860
+ const display = typeof row.display_time === 'string' ? row.display_time.trim() : '';
861
+ const notice = typeof row.notice_date === 'string' ? row.notice_date.trim() : '';
862
+ const raw = display || notice;
863
+ if (raw.length < 10)
864
+ throw new SourceError('eastmoney', 'INVALID_RESPONSE', '东财公告条目缺少 display_time / notice_date');
865
+ const date = raw.slice(0, 10);
866
+ const check = new Date(`${date}T00:00:00Z`);
867
+ if (!/^\d{4}-\d{2}-\d{2}$/u.test(date) || Number.isNaN(check.getTime()) || check.toISOString().slice(0, 10) !== date) {
868
+ throw new SourceError('eastmoney', 'INVALID_RESPONSE', `东财公告条目的时间不可解析:${JSON.stringify(raw.slice(0, 40))}`);
869
+ }
870
+ return { date, publishedAt: display || notice };
871
+ }
872
+ function announcementOwner(row, codes) {
873
+ const list = Array.isArray(row.codes) ? row.codes : [];
874
+ for (const raw of list) {
875
+ const item = asRecord(raw);
876
+ const code = typeof item?.stock_code === 'string' ? item.stock_code.trim() : '';
877
+ if (codes.includes(code)) {
878
+ return { code, name: typeof item?.short_name === 'string' ? item.short_name : '' };
879
+ }
880
+ }
881
+ const first = asRecord(list[0]);
882
+ return {
883
+ code: typeof first?.stock_code === 'string' ? first.stock_code : codes[0],
884
+ name: typeof first?.short_name === 'string' ? first.short_name : '',
885
+ };
886
+ }
887
+ /**
888
+ * Eastmoney republishes issuer announcements; it is a complete issuer-scoped list by day,
889
+ * whereas Wind's NL RAG is semantic and can miss the newest item. The list is the primary
890
+ * path for "what was disclosed"; the authoritative PDF remains cninfo / the issuer.
891
+ */
892
+ export async function eastmoneyAnnouncements(client, input) {
893
+ const start = announcementDate(input.startDate, 'start_date');
894
+ const end = announcementDate(input.endDate, 'end_date');
895
+ if (start > end)
896
+ throw new Error('parameter start_date must not be later than end_date');
897
+ const byArtCode = new Map();
898
+ let totalHits = 0;
899
+ let scannedPages = 0;
900
+ let reachedOlder = false;
901
+ let oldestScanned;
902
+ for (let page = 1; page <= EM_ANNOUNCEMENT_MAX_SCAN_PAGES; page += 1) {
903
+ const params = new URLSearchParams({
904
+ sr: '-1',
905
+ page_size: String(EM_ANNOUNCEMENT_PAGE_SIZE),
906
+ page_index: String(page),
907
+ ann_type: input.market,
908
+ client_source: 'web',
909
+ stock_list: input.codes.join(','),
910
+ });
911
+ const outcome = await client.fetchText(`${EM_ANNOUNCEMENT_LIST_URL}?${params.toString()}`, {
912
+ headers: { referer: EM_ANNOUNCEMENT_REFERER },
913
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
914
+ });
915
+ const body = asRecord(parseJson(outcome.text, 'eastmoney', '东财公告列表响应'));
916
+ const data = asRecord(body?.data);
917
+ if (!data)
918
+ throw new SourceError('eastmoney', 'INVALID_RESPONSE', '东财公告列表缺少 data(上游结构可能已变)');
919
+ if (data.list !== null && data.list !== undefined && !Array.isArray(data.list)) {
920
+ throw new SourceError('eastmoney', 'INVALID_RESPONSE', '东财公告列表 data.list 不是数组(上游结构可能已变)');
921
+ }
922
+ const rows = Array.isArray(data.list) ? data.list : [];
923
+ totalHits = typeof data.total_hits === 'number' ? data.total_hits : rows.length;
924
+ scannedPages = page;
925
+ for (const raw of rows) {
926
+ const row = asRecord(raw);
927
+ if (!row)
928
+ continue;
929
+ const { date, publishedAt } = announcementRowDate(row);
930
+ if (oldestScanned === undefined || date < oldestScanned)
931
+ oldestScanned = date;
932
+ if (date < start) {
933
+ reachedOlder = true;
934
+ continue;
935
+ }
936
+ if (date > end)
937
+ continue;
938
+ const artCode = typeof row.art_code === 'string' ? row.art_code.trim() : '';
939
+ if (artCode.length === 0)
940
+ throw new SourceError('eastmoney', 'INVALID_RESPONSE', '东财公告条目缺少 art_code');
941
+ if (byArtCode.has(artCode))
942
+ continue;
943
+ const owner = announcementOwner(row, input.codes);
944
+ const category = Array.isArray(row.columns)
945
+ ? row.columns.map((column) => (asRecord(column)?.column_name)).filter((name) => typeof name === 'string').join('/')
946
+ : '';
947
+ const item = {
948
+ code: owner.code,
949
+ name: clip(owner.name),
950
+ market: input.market,
951
+ date,
952
+ published_at: publishedAt,
953
+ title: clip(stripHtml(typeof row.title === 'string' ? row.title : '')),
954
+ category: clip(category),
955
+ art_code: clip(artCode),
956
+ url: itemUrl(`https://data.eastmoney.com/notices/detail/${owner.code}/${artCode}.html`),
957
+ };
958
+ byArtCode.set(artCode, item);
959
+ }
960
+ if (reachedOlder || rows.length < EM_ANNOUNCEMENT_PAGE_SIZE || page * EM_ANNOUNCEMENT_PAGE_SIZE >= totalHits)
961
+ break;
962
+ }
963
+ const sorted = [...byArtCode.values()].sort((left, right) => {
964
+ const byTime = String(right.published_at ?? '').localeCompare(String(left.published_at ?? ''));
965
+ return byTime !== 0 ? byTime : String(right.date ?? '').localeCompare(String(left.date ?? ''));
966
+ });
967
+ const offset = Number.isSafeInteger(input.cursor) && input.cursor >= 0 ? input.cursor : 0;
968
+ const items = sorted.slice(offset, offset + input.pageSize);
969
+ const windowOutOfRange = !reachedOlder && oldestScanned !== undefined && oldestScanned > end;
970
+ const notes = ['东财公告是官方披露的转载;权威原文用巨潮 PDF / 发行人页面;正文用 eastmoney_announcement_body;窗口按 display_time(北京时间)过滤'];
971
+ if (sorted.length === 0 && !windowOutOfRange)
972
+ notes.push('窗口内没有公告(合法空结果;东财列表只覆盖最近若干页)');
973
+ if (windowOutOfRange) {
974
+ notes.push(`窗口未被覆盖:只扫描到最近 ${scannedPages * EM_ANNOUNCEMENT_PAGE_SIZE} 条(最早 ${oldestScanned}),所请求窗口早于此;请缩小日期/代码范围,或改用巨潮 PDF`);
975
+ }
976
+ if (offset + items.length < sorted.length) {
977
+ notes.push(`窗口内共 ${sorted.length} 条,本次返回第 ${offset + 1}–${offset + items.length} 条;继续翻页传 cursor=${offset + items.length}`);
978
+ }
979
+ if (!reachedOlder && !windowOutOfRange && totalHits > scannedPages * EM_ANNOUNCEMENT_PAGE_SIZE) {
980
+ notes.push(`本次只扫描前 ${scannedPages * EM_ANNOUNCEMENT_PAGE_SIZE} 条,窗口内可能仍有更早公告;请缩小日期窗或代码数`);
981
+ }
982
+ return {
983
+ source: 'eastmoney',
984
+ operation: 'announcements',
985
+ items: clipItems(items),
986
+ ...(offset + items.length < sorted.length ? { next_cursor: String(offset + items.length) } : {}),
987
+ note: notes.join(';'),
988
+ };
989
+ }
990
+ /**
991
+ * The detail API returns at most 5,000 characters per `page_index`; `page_size` is the
992
+ * total page count, so `done` is knowable without fetching a sentinel page. US filings
993
+ * often return an empty body while still carrying a PDF — that is a fact, not an error.
994
+ */
995
+ export async function eastmoneyAnnouncementBody(client, input) {
996
+ const artCode = input.artCode.trim().toUpperCase();
997
+ if (!/^AN\d{6,}$/u.test(artCode))
998
+ throw new Error('art_code must look like AN202611021831320924');
999
+ if (!Number.isSafeInteger(input.page) || input.page < 1)
1000
+ throw new Error('page must be a positive integer');
1001
+ if (!Number.isSafeInteger(input.offset) || input.offset < 0)
1002
+ throw new Error('offset must be a non-negative integer');
1003
+ if (!Number.isSafeInteger(input.limit) || input.limit < 1)
1004
+ throw new Error('limit must be a positive integer');
1005
+ const page = input.page;
1006
+ const params = new URLSearchParams({ art_code: artCode, client_source: 'web', page_index: String(page) });
1007
+ const outcome = await client.fetchText(`${EM_ANNOUNCEMENT_BODY_URL}?${params.toString()}`, {
1008
+ headers: { referer: EM_ANNOUNCEMENT_REFERER },
1009
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
1010
+ });
1011
+ const body = asRecord(parseJson(outcome.text, 'eastmoney', '东财公告正文响应'));
1012
+ const data = asRecord(body?.data);
1013
+ if (!data)
1014
+ throw new SourceError('eastmoney', 'NOT_FOUND', `东财公告正文没有 art_code=${artCode} 的数据(公告不存在或上游结构已变)`);
1015
+ const content = stripHtml(typeof data.notice_content === 'string' ? data.notice_content : '');
1016
+ const pageCount = Number(data.page_size);
1017
+ const totalPages = Number.isSafeInteger(pageCount) && pageCount > 0 ? pageCount : 1;
1018
+ const pdfUrl = itemUrl(data.attach_url_web ?? data.attach_url);
1019
+ const title = clip(data.notice_title);
1020
+ const noticeDate = clip(data.notice_date);
1021
+ const base = {
1022
+ source: 'eastmoney',
1023
+ operation: 'announcement_body',
1024
+ art_code: artCode,
1025
+ page,
1026
+ offset: input.offset,
1027
+ page_chars: content.length,
1028
+ total_pages: totalPages,
1029
+ ...(pdfUrl.length > 0 ? { pdf_url: pdfUrl } : {}),
1030
+ ...(title.length > 0 ? { title } : {}),
1031
+ ...(noticeDate.length > 0 ? { notice_date: noticeDate } : {}),
1032
+ };
1033
+ if (content.length === 0) {
1034
+ return {
1035
+ ...base,
1036
+ content: '',
1037
+ done: true,
1038
+ note: '该公告正文接口没有返回文本(美股公告常见;若为 A/H 公告,可能是上游结构变更);正文在 PDF/附件,用 ocr 解析,别把标题当正文',
1039
+ };
1040
+ }
1041
+ if (page > totalPages) {
1042
+ return { ...base, content: '', done: true, note: `page 超过总页数 ${totalPages}(该公告只有 ${totalPages} 页)` };
1043
+ }
1044
+ if (input.offset >= content.length) {
1045
+ const nextPage = page < totalPages ? page + 1 : undefined;
1046
+ return {
1047
+ ...base,
1048
+ content: '',
1049
+ ...(nextPage === undefined ? {} : { next_page: nextPage }),
1050
+ done: nextPage === undefined,
1051
+ note: nextPage === undefined ? '已到文末' : `本页已读完;下一页传 page=${nextPage}、offset=0`,
1052
+ };
1053
+ }
1054
+ const limit = Math.min(input.limit, 4_000);
1055
+ const slice = content.slice(input.offset, input.offset + limit);
1056
+ const nextOffset = input.offset + slice.length < content.length ? input.offset + slice.length : undefined;
1057
+ const nextPage = nextOffset === undefined && page < totalPages ? page + 1 : undefined;
1058
+ const done = nextOffset === undefined && nextPage === undefined;
1059
+ const notes = [];
1060
+ if (totalPages > 1)
1061
+ notes.push(`全文共 ${totalPages} 页(每页约 5,000 字符);用 next_offset / next_page 继续读`);
1062
+ if (pdfUrl.length > 0)
1063
+ notes.push('PDF 直链在 pdf_url;权威引用请用巨潮/发行人原文');
1064
+ return {
1065
+ ...base,
1066
+ content: slice,
1067
+ ...(nextOffset === undefined ? {} : { next_offset: nextOffset }),
1068
+ ...(nextPage === undefined ? {} : { next_page: nextPage }),
1069
+ done,
1070
+ ...(notes.length === 0 ? {} : { note: notes.join(';') }),
1071
+ };
1072
+ }
783
1073
  const SINA_REPORT_URL = 'https://vip.stock.finance.sina.com.cn/q/go.php/vReport_List/kind';
784
1074
  const SINA_REFERER = 'https://finance.sina.com.cn/';
785
1075
  /**