@v587d/capital-generation 0.0.0-stage → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +794 -0
  2. package/LICENSE +21 -0
  3. package/README.md +116 -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 +76 -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,118 @@
1
+ /**
2
+ * 东财(eastmoney.com)**共享网络面**:全进程一个节流器、一条请求路径。
3
+ *
4
+ * ## 为什么需要它(2026-09-24 核实)
5
+ *
6
+ * 本仓的东财出口一度是**两份互不知情的实现**:`src/data-collector` 侧经由
7
+ * `src/sources/eastmoney-http.ts` 的裸 `fetch`(无 sleep、无队列),web_retriever 侧是我新加的
8
+ * 第二个客户端。两份独立节流 = 同一个出口 IP 上两个速率,等于没限。
9
+ *
10
+ * 社区实测的东财风控阈值是「每秒 >5 次 / 单 IP 并发 ≥10 / 1 分钟 ≥200 次 / 5 分钟 ≥300 次 →
11
+ * 临时封 IP」,参考实践因此要求 `em_get()` 串行 + 限流。本仓 `DataCollectorHub` 的 FIFO 只保证
12
+ * "同一时刻只跑一个请求",**不含最小间隔**——串行 ≠ 节流,连打十个请求仍是一秒十次。
13
+ *
14
+ * ## 分层(刻意如此,不要合并)
15
+ *
16
+ * - **数据面**按「可复用性」分两处,这是有意的:
17
+ * - 可复用的**数值/分页**端点(行情、财务、资金流)走 `DataCollectorHub`,落成不可变 Dataset ——
18
+ * 那里 Dataset 复用是**收益**;
19
+ * - 流式的**文本**来源(快讯、个股新闻、研报)走 web_retriever 的具名来源工具 ——
20
+ * 那里 Dataset 复用是**语义错误**("最新 20 条快讯"不是可复用快照)。
21
+ * - **网络面**只有这一份:两条数据面都从这里取请求路径与节流器,**共享同一个 process 级实例**。
22
+ * 这正是"切勿一刀切"的落点:切的判据是**可复用性**(数据面),不是 provider,也不是网络面。
23
+ *
24
+ * ## 边界
25
+ *
26
+ * 本模块**不**做 SSRF 出口校验,但**出口校验在哪一份实现里**必须说清楚:
27
+ * - web_retriever 侧的三个 `eastmoney_*` 工具通过 `createEastmoneyClient({ transport })`
28
+ * 注入 `createHttpRequester` 的闸门实现(DNS 公网 IP 校验 + 手动重定向重校验 + 大小/超时上限),
29
+ * 与其余六个来源共用同一份出口治理;
30
+ * - 数据面(Hub 侧能力)用默认 transport(裸 `fetch`):URL 全部是代码内常量 + 白名单参数,
31
+ * 模型不能投喂任意 URL,这是既有行为。
32
+ * 闸门只有 `createHttpRequester` 一份实现;本层只加节流与状态归类,不复制第二套校验
33
+ * (AGENTS.md §9.7)。
34
+ */
35
+ import { createRequestThrottle } from './throttle.js';
36
+ /**
37
+ * 东财系请求的**共享最小间隔**(毫秒)。
38
+ *
39
+ * 1000ms = 1 次/秒,低于「>5 次/秒」阈值,并同时满足「1 分钟 200 次 / 5 分钟 300 次」这类累计窗口口径。
40
+ */
41
+ export const EASTMONEY_MIN_INTERVAL_MS = 1000;
42
+ const DEFAULT_USER_AGENT = 'Mozilla/5.0';
43
+ /**: 传输层错误。`status` 只在"上游返回了 HTTP 错误状态"时给出。 */
44
+ export class EastmoneyTransportError extends Error {
45
+ status;
46
+ constructor(message, status) {
47
+ super(message);
48
+ this.status = status;
49
+ this.name = 'EastmoneyTransportError';
50
+ }
51
+ }
52
+ export function createEastmoneyClient(options = {}) {
53
+ const userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
54
+ const throttle = options.throttle ?? createRequestThrottle(options.minIntervalMs ?? EASTMONEY_MIN_INTERVAL_MS);
55
+ const transport = options.transport ?? createEastmoneyTransport();
56
+ /** 节流 + 发请求 + 状态判定:两条读取路径(text / json)共用这一份,避免状态判定被复制。 */
57
+ const send = (url, init) => throttle(async () => {
58
+ if (init.signal?.aborted) {
59
+ const error = new Error('eastmoney request aborted');
60
+ error.name = 'AbortError';
61
+ throw error;
62
+ }
63
+ let response;
64
+ try {
65
+ response = await transport(url, {
66
+ headers: { 'user-agent': userAgent, ...(init.headers ?? {}) },
67
+ ...(init.signal === undefined ? {} : { signal: init.signal }),
68
+ });
69
+ }
70
+ catch (error) {
71
+ // 取消原样抛出(`AbortError`),调用方据此区分"网络失败"与"调用方不要了"。
72
+ if (error instanceof Error && error.name === 'AbortError')
73
+ throw error;
74
+ // 已带分类 `code` 的错误(如闸门注入的 `LocalFetchError`:ABORTED/TIMEOUT/REDIRECT…)
75
+ // 原样抛出:再包一层会把"取消"降级成网络失败(docs/dev/web-retriever.md §4.2),也丢掉失败原因。
76
+ if (typeof error?.code === 'string')
77
+ throw error;
78
+ throw new EastmoneyTransportError(`eastmoney request failed: ${error instanceof Error ? error.message : String(error)}`);
79
+ }
80
+ // 按 `status` 判定而不是 `response.ok`:本仓多处测试的假 Response 只提供 status/text/json,
81
+ // 用 `ok` 会让它们在 200 上误判成失败(真实的 fetch Response 两者都有)。
82
+ if (response.status >= 400) {
83
+ throw new EastmoneyTransportError(`eastmoney returned HTTP ${response.status}`, response.status);
84
+ }
85
+ return response;
86
+ });
87
+ return {
88
+ async fetchText(url, init = {}) {
89
+ const response = await send(url, init);
90
+ return { status: response.status, headers: response.headers, text: await response.text() };
91
+ },
92
+ async fetchJson(url, init = {}) {
93
+ return await (await send(url, init)).json();
94
+ },
95
+ };
96
+ }
97
+ function createEastmoneyTransport() {
98
+ return (url, init) => globalThis.fetch(url, init);
99
+ }
100
+ let sharedThrottle;
101
+ /**
102
+ * **进程级共享节流器**:数据面与 web_retriever 侧可能各持一个客户端实例(transport 不同),
103
+ * 但东财风控按出口 IP 计,节流必须仍只有一份。两侧客户端都从这里取。
104
+ */
105
+ export function sharedEastmoneyThrottle() {
106
+ sharedThrottle ??= createRequestThrottle(EASTMONEY_MIN_INTERVAL_MS);
107
+ return sharedThrottle;
108
+ }
109
+ let shared;
110
+ /**
111
+ * **进程级单例**(数据面用,默认裸 fetch transport):所有默认客户端共享同一个节流器,
112
+ * 等于回到"两份独立限流"的老问题的解法。宿主进程内所有东财请求都应经这里或
113
+ * 显式注入 `sharedEastmoneyThrottle()` 的实例。
114
+ */
115
+ export function sharedEastmoneyClient() {
116
+ shared ??= createEastmoneyClient({ throttle: sharedEastmoneyThrottle() });
117
+ return shared;
118
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 通用请求节流器:把会打到**同一上游**的请求串成一条带最小间隔的链。
3
+ *
4
+ * ## 为什么是"链"而不是 `await sleep()`
5
+ *
6
+ * 多个工具调用可能**并发**进入(模型在同一条消息里发多个调用时,框架会把它们放进并发池)。
7
+ * 各自 sleep 之后仍会同时发出请求——间隔就白设了。串到一条 Promise 链上,才能保证
8
+ * "上一个请求**发出后**至少 N 毫秒,下一个才发出"。链本身不会因某个任务失败而断掉。
9
+ *
10
+ * ## 为什么是独立的中性模块
11
+ *
12
+ * 它纯属**网络面**关切,不属于任何一条数据面:东财 Hub 侧(`src/sources/eastmoney-http.ts`)
13
+ * 与 web_retriever 侧(`src/web-retriever/sources.ts`)都要用。若把它放在任一侧,另一侧就得
14
+ * 反向依赖对方——那正是"客户端被复制"的前身(见 `src/net/eastmoney-client.ts` 的分层说明)。
15
+ */
16
+ export declare function createRequestThrottle(minIntervalMs: number): <T>(task: () => Promise<T>) => Promise<T>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 通用请求节流器:把会打到**同一上游**的请求串成一条带最小间隔的链。
3
+ *
4
+ * ## 为什么是"链"而不是 `await sleep()`
5
+ *
6
+ * 多个工具调用可能**并发**进入(模型在同一条消息里发多个调用时,框架会把它们放进并发池)。
7
+ * 各自 sleep 之后仍会同时发出请求——间隔就白设了。串到一条 Promise 链上,才能保证
8
+ * "上一个请求**发出后**至少 N 毫秒,下一个才发出"。链本身不会因某个任务失败而断掉。
9
+ *
10
+ * ## 为什么是独立的中性模块
11
+ *
12
+ * 它纯属**网络面**关切,不属于任何一条数据面:东财 Hub 侧(`src/sources/eastmoney-http.ts`)
13
+ * 与 web_retriever 侧(`src/web-retriever/sources.ts`)都要用。若把它放在任一侧,另一侧就得
14
+ * 反向依赖对方——那正是"客户端被复制"的前身(见 `src/net/eastmoney-client.ts` 的分层说明)。
15
+ */
16
+ export function createRequestThrottle(minIntervalMs) {
17
+ if (!Number.isFinite(minIntervalMs) || minIntervalMs < 0) {
18
+ // 配错的节流器会静默变成"没有节流",而上游风控不会报"你太快了"——只会封 IP 或给空数据。
19
+ throw new Error(`createRequestThrottle: minIntervalMs must be a non-negative finite number, got ${String(minIntervalMs)}`);
20
+ }
21
+ let chain = Promise.resolve();
22
+ let lastStartedAt = 0;
23
+ return (task) => {
24
+ const result = chain.then(async () => {
25
+ const wait = minIntervalMs - (Date.now() - lastStartedAt);
26
+ if (wait > 0)
27
+ await new Promise((resolve) => setTimeout(resolve, wait));
28
+ lastStartedAt = Date.now();
29
+ return task();
30
+ });
31
+ chain = result.then(() => undefined, () => undefined);
32
+ return result;
33
+ };
34
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * PaddleOCR AIStudio **异步 job** 客户端(`ocr` 的上游实现,唯一一份出网实现)。
3
+ *
4
+ * 生命周期(2026-09-25 真报文实测,15 页贵州茅台研报,31 秒完成):
5
+ * `POST /api/v2/ocr/jobs` → `{code:0,msg:'Success',data:{jobId}}`
6
+ * → `GET /api/v2/ocr/jobs/{jobId}` 轮询 `data.state`:`pending` → `running`
7
+ * (带 `extractProgress.{totalPages,extractedPages}`)→ `done`(带 `resultUrl.jsonUrl`)
8
+ * → `GET jsonUrl` 拿 **JSONL**(每行一个批次,`result.layoutParsingResults[]` 一项一页)。
9
+ *
10
+ * ⛔ 两条只能从真报文里拿到的事实,决定了本文件的形状:
11
+ * 1. **上游把错误装在 HTTP 200 里**:模型名写错返回 `{code:10007,msg:'模型传参错误'}`
12
+ * 且没有 `data.jobId`。只判 `status === 200` 会把失败当成功、`jobId` 变 `undefined`,
13
+ * 然后拿 `undefined` 去轮询——所以每个响应都先看 `code`。
14
+ * 2. **`optionalPayload` 的未知键被静默忽略**(塞 `zzzBogusKey` 照样返回 Success)。
15
+ * 公开文档那页讲的其实是同步 `/layout-parsing`,`mergeTables` / `prettifyMarkdown`
16
+ * 在 jobs 上是否生效无法证明,因此**只传官方示例里出现过的三个键**,
17
+ * 不拿没验证过的开关换"看起来能配"的假象。表格噪音靠 `markdown.ts` 自己处理。
18
+ */
19
+ import type { HttpOutcome, HttpRequest } from '../web-retriever/local-fetch.js';
20
+ import { type OcrDocument } from './markdown.js';
21
+ export declare const PADDLE_OCR_DEFAULT_ENDPOINT = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs";
22
+ export declare const PADDLE_OCR_DEFAULT_MODEL = "PaddleOCR-VL-1.6";
23
+ /** 上游文档口径:PDF 默认最多 100 页。超页是**上游会失败**,提前拦比事后猜便宜。 */
24
+ export declare const PADDLE_OCR_MAX_PAGES = 100;
25
+ export type OcrErrorCode = 'NO_CREDENTIAL' | 'UPSTREAM' | 'JOB_FAILED' | 'RESULT_INVALID' | 'TOO_MANY_PAGES' | 'TIMEOUT';
26
+ export declare class OcrError extends Error {
27
+ readonly code: OcrErrorCode;
28
+ constructor(code: OcrErrorCode, message: string);
29
+ }
30
+ export interface OcrProgress {
31
+ extracted_pages: number;
32
+ total_pages: number;
33
+ }
34
+ /** 一次轮询看到的作业状态(只保留我们用得上的字段)。 */
35
+ export interface OcrJobState {
36
+ state: string;
37
+ progress?: OcrProgress;
38
+ jsonUrl?: string;
39
+ errorMsg?: string;
40
+ }
41
+ export interface OcrSubmitInput {
42
+ /** 公网可达的 `.pdf` 直链:由**上游**去取文件,本机不落任何字节。 */
43
+ fileUrl?: string;
44
+ /** 本地文档字节(`file` 形态):走 multipart 上传。 */
45
+ file?: {
46
+ bytes: Uint8Array;
47
+ filename: string;
48
+ };
49
+ charts: boolean;
50
+ signal: AbortSignal;
51
+ }
52
+ export interface OcrWaitOutcome {
53
+ status: 'done' | 'pending';
54
+ job_id: string;
55
+ progress?: OcrProgress;
56
+ /** `status === 'done'` 时一定有。 */
57
+ document?: OcrDocument;
58
+ json_url?: string;
59
+ elapsed_ms: number;
60
+ }
61
+ export interface PaddleOcrClientOptions {
62
+ endpoint?: string;
63
+ model?: string;
64
+ resolveToken: () => Promise<string | undefined>;
65
+ request: (request: HttpRequest, signal?: AbortSignal) => Promise<HttpOutcome>;
66
+ pollIntervalMs?: number;
67
+ waitBudgetMs?: number;
68
+ resultMaxBytes?: number;
69
+ /** 注入以便测试:睡眠必须可取消。 */
70
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
71
+ now?: () => number;
72
+ }
73
+ export interface PaddleOcrClient {
74
+ readonly model: string;
75
+ submit(input: OcrSubmitInput): Promise<string>;
76
+ poll(jobId: string, signal: AbortSignal): Promise<OcrJobState>;
77
+ wait(jobId: string, signal: AbortSignal, progress?: OcrProgress): Promise<OcrWaitOutcome>;
78
+ /** 提交并等到有结果(或自有预算耗尽回 pending)。 */
79
+ parse(input: OcrSubmitInput): Promise<OcrWaitOutcome>;
80
+ load(jsonUrl: string, signal: AbortSignal): Promise<OcrDocument>;
81
+ }
82
+ export declare function createPaddleOcrClient(options: PaddleOcrClientOptions): PaddleOcrClient;
@@ -0,0 +1,281 @@
1
+ /**
2
+ * PaddleOCR AIStudio **异步 job** 客户端(`ocr` 的上游实现,唯一一份出网实现)。
3
+ *
4
+ * 生命周期(2026-09-25 真报文实测,15 页贵州茅台研报,31 秒完成):
5
+ * `POST /api/v2/ocr/jobs` → `{code:0,msg:'Success',data:{jobId}}`
6
+ * → `GET /api/v2/ocr/jobs/{jobId}` 轮询 `data.state`:`pending` → `running`
7
+ * (带 `extractProgress.{totalPages,extractedPages}`)→ `done`(带 `resultUrl.jsonUrl`)
8
+ * → `GET jsonUrl` 拿 **JSONL**(每行一个批次,`result.layoutParsingResults[]` 一项一页)。
9
+ *
10
+ * ⛔ 两条只能从真报文里拿到的事实,决定了本文件的形状:
11
+ * 1. **上游把错误装在 HTTP 200 里**:模型名写错返回 `{code:10007,msg:'模型传参错误'}`
12
+ * 且没有 `data.jobId`。只判 `status === 200` 会把失败当成功、`jobId` 变 `undefined`,
13
+ * 然后拿 `undefined` 去轮询——所以每个响应都先看 `code`。
14
+ * 2. **`optionalPayload` 的未知键被静默忽略**(塞 `zzzBogusKey` 照样返回 Success)。
15
+ * 公开文档那页讲的其实是同步 `/layout-parsing`,`mergeTables` / `prettifyMarkdown`
16
+ * 在 jobs 上是否生效无法证明,因此**只传官方示例里出现过的三个键**,
17
+ * 不拿没验证过的开关换"看起来能配"的假象。表格噪音靠 `markdown.ts` 自己处理。
18
+ */
19
+ import { buildOcrDocument } from './markdown.js';
20
+ export const PADDLE_OCR_DEFAULT_ENDPOINT = 'https://paddleocr.aistudio-app.com/api/v2/ocr/jobs';
21
+ export const PADDLE_OCR_DEFAULT_MODEL = 'PaddleOCR-VL-1.6';
22
+ /** 上游文档口径:PDF 默认最多 100 页。超页是**上游会失败**,提前拦比事后猜便宜。 */
23
+ export const PADDLE_OCR_MAX_PAGES = 100;
24
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
25
+ /** 自有等待预算必须**小于**工具声明的 `timeoutMs`:留出不确定返回与写盘的余量。 */
26
+ const DEFAULT_WAIT_BUDGET_MS = 200_000;
27
+ /** 结果 JSONL 的体积上限(实测 15 页 258 KB;上限给到 8 MB 并在超限时响亮失败)。 */
28
+ const DEFAULT_RESULT_MAX_BYTES = 8 * 1024 * 1024;
29
+ export class OcrError extends Error {
30
+ code;
31
+ constructor(code, message) {
32
+ super(message);
33
+ this.code = code;
34
+ this.name = 'OcrError';
35
+ }
36
+ }
37
+ function asRecord(value) {
38
+ return typeof value === 'object' && value !== null ? value : undefined;
39
+ }
40
+ function sleepAbortable(ms, signal) {
41
+ return new Promise((resolve, reject) => {
42
+ if (signal.aborted) {
43
+ reject(signal.reason ?? new Error('aborted'));
44
+ return;
45
+ }
46
+ const timer = setTimeout(() => {
47
+ signal.removeEventListener('abort', onAbort);
48
+ resolve();
49
+ }, ms);
50
+ const onAbort = () => {
51
+ clearTimeout(timer);
52
+ reject(signal.reason ?? new Error('aborted'));
53
+ };
54
+ signal.addEventListener('abort', onAbort, { once: true });
55
+ });
56
+ }
57
+ /**
58
+ * 把传输层异常翻成 OCR 错误码。
59
+ *
60
+ * ⚠️ `createHttpRequester` 对 4xx/5xx 抛的是 `LocalFetchError('HTTP', '… returned HTTP 401')`,
61
+ * 状态码只在消息文本里。这里按文本识别鉴权类失败(401/403)**只为了把"该去卡片填 token"
62
+ * 这条最有用的话送到模型面前**;识别不到就退回 `UPSTREAM`,绝不假装知道上游说了什么。
63
+ */
64
+ function mapTransportFailure(error) {
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ if (/HTTP\s*(401|403)\b/u.test(message)) {
67
+ return new OcrError('NO_CREDENTIAL', `PaddleOCR 拒绝鉴权(${message}):请在 设置 → 插件 → Capital 模式 填写 PaddleOCR Token`);
68
+ }
69
+ return new OcrError('UPSTREAM', message);
70
+ }
71
+ export function createPaddleOcrClient(options) {
72
+ const endpoint = options.endpoint || PADDLE_OCR_DEFAULT_ENDPOINT;
73
+ const model = options.model || PADDLE_OCR_DEFAULT_MODEL;
74
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
75
+ const waitBudgetMs = options.waitBudgetMs ?? DEFAULT_WAIT_BUDGET_MS;
76
+ const resultMaxBytes = options.resultMaxBytes ?? DEFAULT_RESULT_MAX_BYTES;
77
+ const sleep = options.sleep ?? sleepAbortable;
78
+ const now = options.now ?? Date.now;
79
+ async function authorizedHeaders() {
80
+ const token = await options.resolveToken();
81
+ if (!token) {
82
+ throw new OcrError('NO_CREDENTIAL', 'PaddleOCR Token 未配置:在 设置 → 插件 → 插件配置 → Capital 模式 填写(credentials 引用 PADDLE_OCR_TOKEN,环境变量同名亦可)');
83
+ }
84
+ return { authorization: `bearer ${token}` };
85
+ }
86
+ /** 发一次请求并**校验 body.code**:上游用 HTTP 200 装业务错误(见文件头 ⛔ 1)。 */
87
+ async function call(request, signal) {
88
+ let outcome;
89
+ try {
90
+ outcome = await options.request(request, signal);
91
+ }
92
+ catch (error) {
93
+ const code = error?.code;
94
+ if (code === 'ABORTED' || (error instanceof Error && error.name === 'AbortError'))
95
+ throw error;
96
+ throw mapTransportFailure(error);
97
+ }
98
+ if (outcome.truncated) {
99
+ throw new OcrError('RESULT_INVALID', `上游响应被体积上限截断(${request.url.slice(0, 80)}):截断的 JSON 是解析失败,不是少读几条`);
100
+ }
101
+ let parsed;
102
+ try {
103
+ parsed = asRecord(JSON.parse(outcome.text));
104
+ }
105
+ catch {
106
+ throw new OcrError('RESULT_INVALID', `上游返回的不是 JSON(HTTP ${outcome.status}):${outcome.text.slice(0, 160)}`);
107
+ }
108
+ if (!parsed)
109
+ throw new OcrError('RESULT_INVALID', `上游返回的形状不认识(HTTP ${outcome.status})`);
110
+ const code = parsed.code;
111
+ if (code !== undefined && code !== 0) {
112
+ const message = typeof parsed.msg === 'string' ? parsed.msg : '(无 msg)';
113
+ if (code === 401)
114
+ throw new OcrError('NO_CREDENTIAL', `PaddleOCR 未鉴权(code 401 ${message}):请在 设置 → 插件 → Capital 模式 填写 Token`);
115
+ throw new OcrError('UPSTREAM', `PaddleOCR 返回业务错误 code=${String(code)}:${message}`);
116
+ }
117
+ return parsed;
118
+ }
119
+ function optionalPayload(charts) {
120
+ // 只带官方示例出现过的三个键(文件头 ⛔ 2)。
121
+ return { useDocOrientationClassify: false, useDocUnwarping: false, useChartRecognition: charts };
122
+ }
123
+ async function submit(input) {
124
+ const headers = await authorizedHeaders();
125
+ const payload = optionalPayload(input.charts);
126
+ let body;
127
+ let extraHeaders;
128
+ if (input.fileUrl !== undefined) {
129
+ extraHeaders = { 'content-type': 'application/json' };
130
+ body = JSON.stringify({ fileUrl: input.fileUrl, model, optionalPayload: payload });
131
+ }
132
+ else {
133
+ const file = input.file;
134
+ const form = new FormData();
135
+ form.append('model', model);
136
+ form.append('optionalPayload', JSON.stringify(payload));
137
+ // `fs.readBytes` 的返回类型是 `Uint8Array<ArrayBufferLike>`,而 `Blob` 只接受
138
+ // ArrayBuffer 背书的视图;字节确实是新分配的 ArrayBuffer,在边界上收敛一次类型。
139
+ form.append('file', new Blob([file.bytes], { type: 'application/octet-stream' }), file.filename);
140
+ extraHeaders = {};
141
+ body = form;
142
+ }
143
+ // ⛔ `headers`(bearer)与 `extraHeaders`(content-type)**都要**带上:只传后者就等于
144
+ // 把作业提交发成一个无鉴权请求(实测 401),而轮询那条路是带着 token 的——
145
+ // 两条出网路径形状不一致,恰是"只测接好的那个入口"能长期潜伏的形态。
146
+ const parsed = await call({ url: endpoint, method: 'POST', headers: { ...headers, ...extraHeaders }, body }, input.signal);
147
+ const jobId = asRecord(parsed.data)?.jobId;
148
+ if (typeof jobId !== 'string' || jobId.length === 0) {
149
+ throw new OcrError('RESULT_INVALID', `提交成功但响应里没有 data.jobId:${JSON.stringify(parsed).slice(0, 200)}`);
150
+ }
151
+ return jobId;
152
+ }
153
+ async function poll(jobId, signal) {
154
+ const parsed = await call({ url: `${endpoint}/${encodeURIComponent(jobId)}`, headers: await authorizedHeaders() }, signal);
155
+ const data = asRecord(parsed.data);
156
+ if (!data)
157
+ throw new OcrError('RESULT_INVALID', `轮询响应缺 data:${JSON.stringify(parsed).slice(0, 200)}`);
158
+ const state = typeof data.state === 'string' ? data.state : '';
159
+ if (state.length === 0)
160
+ throw new OcrError('RESULT_INVALID', `轮询响应缺 state:${JSON.stringify(data).slice(0, 200)}`);
161
+ const progress = asRecord(data.extractProgress);
162
+ const resultUrl = asRecord(data.resultUrl);
163
+ const outcome = { state };
164
+ if (progress && typeof progress.extractedPages === 'number' && typeof progress.totalPages === 'number') {
165
+ outcome.progress = { extracted_pages: progress.extractedPages, total_pages: progress.totalPages };
166
+ }
167
+ if (typeof resultUrl?.jsonUrl === 'string')
168
+ outcome.jsonUrl = resultUrl.jsonUrl;
169
+ if (typeof data.errorMsg === 'string')
170
+ outcome.errorMsg = data.errorMsg;
171
+ return outcome;
172
+ }
173
+ async function load(jsonUrl, signal) {
174
+ // ⛔ 结果地址是**预签名**的对象存储 URL(实测主机 `paddleocr-store-8.bj.bcebos.com`,与作业
175
+ // API 不同域)。带上我们的 bearer,BOS 会把它当作待验签请求并索要 `date` 头,直接
176
+ // HTTP 400 `MissingDateHeader`;不带才拿得下来(实测 200 / 308 KB)。而且把 token 发给
177
+ // 第三方存储域本身就是密钥外流——这条路**只**靠签名过的 URL 鉴权。
178
+ let outcome;
179
+ try {
180
+ outcome = await options.request({ url: jsonUrl, accept: 'application/json', maxBytes: resultMaxBytes, maxContentChars: resultMaxBytes }, signal);
181
+ }
182
+ catch (error) {
183
+ const code = error?.code;
184
+ if (code === 'ABORTED' || (error instanceof Error && error.name === 'AbortError'))
185
+ throw error;
186
+ throw mapTransportFailure(error);
187
+ }
188
+ // 结果 JSONL 单独定上限(15 页实测 258 KB),超限就是真超限,不能继续当完整文档解析。
189
+ if (outcome.truncated) {
190
+ throw new OcrError('RESULT_INVALID', `解析结果超出 ${resultMaxBytes} 字节上限并被截断:整份文档作废,不给半份 markdown`);
191
+ }
192
+ const sourcePages = [];
193
+ for (const line of outcome.text.split('\n')) {
194
+ const trimmed = line.trim();
195
+ if (trimmed.length === 0)
196
+ continue;
197
+ let record;
198
+ try {
199
+ record = asRecord(JSON.parse(trimmed));
200
+ }
201
+ catch {
202
+ throw new OcrError('RESULT_INVALID', `结果 JSONL 有一行不是 JSON(长度 ${trimmed.length}):整份文档作废`);
203
+ }
204
+ if (!record)
205
+ throw new OcrError('RESULT_INVALID', '结果 JSONL 的某一行不是对象');
206
+ // ⛔ 成功行**也带** `errorMsg`:2026-09-25 实测每行都是 `{errorCode: 0, errorMsg: "Success", …}`。
207
+ // 判据只能是 `errorCode !== 0`——把"有 errorMsg"当失败会把每一份成功解析的文档报成失败。
208
+ const errorCode = record.errorCode;
209
+ const failed = typeof errorCode === 'number' ? errorCode !== 0 : typeof errorCode === 'string' ? errorCode !== '0' && errorCode.length > 0 : false;
210
+ const result = asRecord(record.result);
211
+ const layouts = Array.isArray(result?.layoutParsingResults) ? result.layoutParsingResults : [];
212
+ if (failed) {
213
+ throw new OcrError('JOB_FAILED', `上游批次失败 errorCode=${String(errorCode)}:${typeof record.errorMsg === 'string' && record.errorMsg.length > 0 ? record.errorMsg : '(无 errorMsg)'}`);
214
+ }
215
+ for (const item of layouts) {
216
+ const layout = asRecord(item);
217
+ const markdown = asRecord(layout?.markdown);
218
+ if (typeof markdown?.text !== 'string') {
219
+ throw new OcrError('RESULT_INVALID', 'layoutParsingResults 的一项缺 markdown.text:不给静默空白页');
220
+ }
221
+ sourcePages.push({
222
+ page: sourcePages.length + 1,
223
+ markdown: markdown.text,
224
+ images: markdown.images && typeof markdown.images === 'object' ? Object.keys(markdown.images).length : 0,
225
+ });
226
+ }
227
+ }
228
+ if (sourcePages.length === 0)
229
+ throw new OcrError('RESULT_INVALID', '上游返回 0 页:空 markdown 会被当成"这篇文档没有内容",比报错危险');
230
+ if (sourcePages.length > PADDLE_OCR_MAX_PAGES) {
231
+ throw new OcrError('TOO_MANY_PAGES', `解析出 ${sourcePages.length} 页,超出上游 ${PADDLE_OCR_MAX_PAGES} 页口径`);
232
+ }
233
+ return buildOcrDocument(sourcePages);
234
+ }
235
+ async function wait(jobId, signal, prior) {
236
+ const started = now();
237
+ let last = prior;
238
+ let attempts = 0;
239
+ while (true) {
240
+ const elapsed = now() - started;
241
+ if (elapsed >= waitBudgetMs) {
242
+ return { status: 'pending', job_id: jobId, ...(last ? { progress: last } : {}), elapsed_ms: elapsed };
243
+ }
244
+ const state = await poll(jobId, signal);
245
+ attempts += 1;
246
+ if (state.progress)
247
+ last = state.progress;
248
+ if (state.state === 'done') {
249
+ if (!state.jsonUrl)
250
+ throw new OcrError('RESULT_INVALID', `作业标记 done 但没有 resultUrl.jsonUrl:${jobId}`);
251
+ const document = await load(state.jsonUrl, signal);
252
+ return {
253
+ status: 'done',
254
+ job_id: jobId,
255
+ document,
256
+ json_url: state.jsonUrl,
257
+ ...(last ? { progress: last } : {}),
258
+ elapsed_ms: now() - started,
259
+ };
260
+ }
261
+ if (state.state === 'failed') {
262
+ throw new OcrError('JOB_FAILED', `上游作业失败:${state.errorMsg ?? '(没有 errorMsg 字段)'}`);
263
+ }
264
+ try {
265
+ await sleep(Math.min(pollIntervalMs, Math.max(200, waitBudgetMs - (now() - started))), signal);
266
+ }
267
+ catch (error) {
268
+ const code = error?.code;
269
+ if (code === 'ABORTED' || (error instanceof Error && error.name === 'AbortError'))
270
+ throw error;
271
+ // 睡眠本身不会失败成别的错误;真出了就算作业状态未知,别硬编成上游错误。
272
+ throw new OcrError('RESULT_INVALID', `轮询等待异常(已轮询 ${attempts} 次):${error instanceof Error ? error.message : String(error)}`);
273
+ }
274
+ }
275
+ }
276
+ async function parse(input) {
277
+ const jobId = await submit(input);
278
+ return wait(jobId, input.signal);
279
+ }
280
+ return { model, submit, poll, wait, parse, load };
281
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * PaddleOCR 输出 → 模型可读 markdown 的归一化(`ocr` 的呈现层,唯一一份实现)。
3
+ *
4
+ * 为什么必须归一化(2026-09-25 拿真报文实测,贵州茅台 15 页研报):上游把**表格输出成内联
5
+ * HTML**,每个单元格都带 `style='text-align: center; word-wrap: break-word;'`——
6
+ * 105,639 个字符里 63,232 个(60%)是 `style='...'` 样板,去掉再转成 GFM 管道表只剩 27,156。
7
+ * 本仓的场景恰恰是"大量表格、图形",不处理就等于**预算的六成买的是 HTML 属性**,
8
+ * 而 `prettifyMarkdown` 这类 flag 在上游被静默忽略(未知键也返回 Success),帮不上。
9
+ *
10
+ * 归一化只做**无损排版**:不删正文、不改数字、不做摘要。数字一律照抄上游。
11
+ */
12
+ /** 上游一页的解析产物(我们只取 markdown 文本与图片计数)。 */
13
+ export interface OcrSourcePage {
14
+ page: number;
15
+ markdown: string;
16
+ images: number;
17
+ }
18
+ export interface OcrPageEntry {
19
+ page: number;
20
+ /** 该页正文在 `document` 里的起始码点下标(含)。 */
21
+ start: number;
22
+ /** 结束下标(不含)。 */
23
+ end: number;
24
+ chars: number;
25
+ /** 该页第一个标题行(索引用,没有则省略)。 */
26
+ heading?: string;
27
+ }
28
+ export interface OcrDocument {
29
+ /** 全篇:各页正文以 `<!-- page:N -->` 分隔(渲染不可见,页定位靠它)。 */
30
+ document: string;
31
+ pages: OcrPageEntry[];
32
+ chars: number;
33
+ images: number;
34
+ /** 转成 GFM 的 HTML 表数量。 */
35
+ tables: number;
36
+ /** 剥掉的 style 样板字符数(回执里用来证明"不是截断,是排版噪音")。 */
37
+ styleCharsRemoved: number;
38
+ }
39
+ export interface OcrHit {
40
+ page: number;
41
+ at: number;
42
+ excerpt: string;
43
+ }
44
+ declare const PAGE_MARKER_PREFIX = "<!-- page:";
45
+ export interface NormalizedMarkdown {
46
+ markdown: string;
47
+ tables: number;
48
+ styleCharsRemoved: number;
49
+ }
50
+ /** 剥样式 + HTML 表转 GFM + 收空行。对不含 HTML 的正文近似恒等。 */
51
+ export declare function normalizeOcrMarkdown(source: string): NormalizedMarkdown;
52
+ /** 把逐页 markdown 拼成一份文档,并算出**页偏移索引**(按页取回与关键词定位都靠它)。 */
53
+ export declare function buildOcrDocument(sourcePages: OcrSourcePage[]): OcrDocument;
54
+ /**
55
+ * 页区间解析:`"1-3"` / `"5"` / `"2-4,9"`(1 起始,闭区间)。
56
+ * 越界页码**响亮失败**而不是静默少给——"这页没有"和"我没给"在回执里必须能区分。
57
+ */
58
+ export declare function parsePageRange(spec: string, totalPages: number): number[];
59
+ export interface PageExcerpt {
60
+ page: number;
61
+ chars: number;
62
+ heading?: string;
63
+ text: string;
64
+ }
65
+ /** 按页切出正文(纯字符串切片,不重跑上游)。 */
66
+ export declare function selectPages(document: OcrDocument, pages: number[]): PageExcerpt[];
67
+ /**
68
+ * 关键词定位:在**已落盘的文档**里找命中,回带页号的上下文片段。
69
+ *
70
+ * 为什么不靠 `pages` 硬读:一份 15 页研报归一化后约 2.7 万字符,而工具输出预算 6,144——
71
+ * 想拿财务预测表就得能"跳着看"。上游页区间不影响算力(整篇都算过),所以定位必须在这份
72
+ * 归一化文本上做,不再出网。
73
+ */
74
+ export declare function locateQuery(document: OcrDocument, query: string, limit: number, windowChars: number): OcrHit[];
75
+ export { PAGE_MARKER_PREFIX };