@youhaozhao/cninfo-mcp 1.4.1 → 2.0.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.
- package/README.md +69 -2
- package/bin/cninfo-mcp.js +61 -74
- package/package.json +3 -3
- package/python/mcp_server.py +103 -12
- package/python/spider.py +256 -99
- package/scripts/install-python-deps.js +52 -52
package/README.md
CHANGED
|
@@ -4,11 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
通过 MCP 协议查询和下载巨潮资讯网上市公司定期报告及招股书 PDF 的工具,适用于 Claude Desktop / Claude Code。
|
|
6
6
|
|
|
7
|
+
> 本项目是社区维护的开源实现,**不是巨潮资讯网的官方产品**,详见[免责声明与数据使用](#免责声明与数据使用)。
|
|
8
|
+
|
|
7
9
|
## 并发限制
|
|
8
10
|
巨潮资讯网后端禁止大量并发,推荐将并发数设置为 4 以防止后端返回大量 403 导致 IP 短暂被封
|
|
9
11
|
|
|
10
12
|
## 使用方法
|
|
11
13
|
|
|
14
|
+
### 1. 安装 Python 依赖(只需一次)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx -y @youhaozhao/cninfo-mcp install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
这条命令会在 `~/.cninfo-mcp/venv` 创建 Python 虚拟环境,并用 pip 从 PyPI 安装 [`python/requirements.txt`](python/requirements.txt) 里的依赖。`npm install` 和服务器启动都不会创建环境或执行 pip;环境缺失或依赖过期时,服务器会直接报错并提示重新运行这条命令。
|
|
21
|
+
|
|
22
|
+
### 2. 添加到 MCP 客户端
|
|
23
|
+
|
|
12
24
|
在 Claude Desktop / Claude Code 配置文件中添加:
|
|
13
25
|
|
|
14
26
|
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
@@ -20,12 +32,17 @@
|
|
|
20
32
|
"mcpServers": {
|
|
21
33
|
"cninfo": {
|
|
22
34
|
"command": "npx",
|
|
23
|
-
"args": ["-y", "@youhaozhao/cninfo-mcp"]
|
|
35
|
+
"args": ["-y", "@youhaozhao/cninfo-mcp"],
|
|
36
|
+
"env": {
|
|
37
|
+
"CNINFO_MCP_DOWNLOAD_DIR": "/Users/me/reports"
|
|
38
|
+
}
|
|
24
39
|
}
|
|
25
40
|
}
|
|
26
41
|
}
|
|
27
42
|
```
|
|
28
43
|
|
|
44
|
+
`env` 可以省略。`CNINFO_MCP_DOWNLOAD_DIR` 是下载根目录,不设置时为 `~/Downloads/cninfo-mcp`。
|
|
45
|
+
|
|
29
46
|
重启 Claude Desktop 后即可使用。
|
|
30
47
|
|
|
31
48
|
## 可用工具
|
|
@@ -33,6 +50,15 @@
|
|
|
33
50
|
- **`query_annual_reports_tool`** — 查询报告列表,参数:股票代码(必填)、年份(可选)、报告类型(可选,默认 `annual`)
|
|
34
51
|
- **`download_annual_reports_tool`** — 下载报告 PDF,参数:股票代码(必填)、年份(可选)、保存路径(可选)、报告类型(可选,默认 `annual`)
|
|
35
52
|
|
|
53
|
+
保存路径只能是下载根目录内的位置:相对路径(如 `annual/2024`)按根目录下的子目录解析,落在根目录之外的路径(包括 `..` 和指向外部的符号链接)会被拒绝,不会写入任何文件。
|
|
54
|
+
|
|
55
|
+
两个工具都声明了 MCP 工具注解:
|
|
56
|
+
|
|
57
|
+
| 工具 | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` |
|
|
58
|
+
| --- | --- | --- | --- | --- |
|
|
59
|
+
| `query_annual_reports_tool` | `true` | `false` | `true` | `true` |
|
|
60
|
+
| `download_annual_reports_tool` | `false` | `false` | `true` | `true` |
|
|
61
|
+
|
|
36
62
|
支持的 `report_type`:
|
|
37
63
|
|
|
38
64
|
- `annual` — 年度报告 / 年报
|
|
@@ -56,12 +82,53 @@
|
|
|
56
82
|
## 系统要求
|
|
57
83
|
|
|
58
84
|
- Node.js 18+
|
|
59
|
-
- Python 3.10
|
|
85
|
+
- Python 3.10+(需要 MCP Python SDK v2;依赖通过上面的 `install` 命令安装,升级后如提示依赖过期,重新运行一次即可)
|
|
86
|
+
|
|
87
|
+
## 安全边界
|
|
88
|
+
|
|
89
|
+
- **网络**:只访问 `https://www.cninfo.com.cn`(查询)和 `https://static.cninfo.com.cn`(PDF 下载),全程 HTTPS。
|
|
90
|
+
- **文件**:只有下载工具会写盘,并且只写入下载根目录(`CNINFO_MCP_DOWNLOAD_DIR`,默认 `~/Downloads/cninfo-mcp`)。根目录由部署方在客户端配置里指定,工具调用方无法更改。
|
|
91
|
+
- **写入确认**:服务器自身不弹确认框。下载工具声明了 `readOnlyHint: false`,是否在调用前向用户确认由 MCP 客户端决定。
|
|
92
|
+
- **安装**:创建虚拟环境和 pip 安装只发生在显式的 `install` 命令里,位置固定为 `~/.cninfo-mcp/venv`。
|
|
93
|
+
- **凭据**:不需要、也不读取任何账号、Cookie 或密钥。
|
|
60
94
|
|
|
61
95
|
## 数据来源
|
|
62
96
|
|
|
63
97
|
[巨潮资讯网](https://www.cninfo.com.cn) — 支持沪深两市(主板、创业板、科创板)及北京证券交易所(北交所)
|
|
64
98
|
|
|
99
|
+
## 免责声明与数据使用
|
|
100
|
+
|
|
101
|
+
- **非官方**:本项目是社区维护的开源工具,不是巨潮资讯网的官方产品,与其运营方深圳证券信息有限公司没有隶属、合作或授权关系。
|
|
102
|
+
- **非官方接口**:工具调用的是巨潮资讯网网页自身使用的查询接口,不是官方数据服务接口,可能随时变更、限流或停用。
|
|
103
|
+
- **内容权利**:本项目不托管、不转发任何报告内容,所有数据都由你的机器直接从巨潮资讯网获取。网站页脚标注“深圳证券信息有限公司 版权所有”,报告文件由各上市公司披露;下载后的使用方式(尤其是再分发和商业用途)请自行确认是否合规。
|
|
104
|
+
- **上游声明**:巨潮资讯网在[首页](https://www.cninfo.com.cn)页脚公布了免责声明,写明网站“竭力保证所提供的证券市场信息准确可靠,但并不担保(无论提示、默示、法定或其他形式)其准确性和完整性”,并且进入网站即视为接受该声明。通过本工具访问同样受其约束,请以网站公布的最新内容为准。
|
|
105
|
+
- **合理使用**:请遵守上面的[并发限制](#并发限制),不要用本工具做高频或大批量抓取。
|
|
106
|
+
- **不构成投资建议**:工具只负责检索和下载公开披露文件,不对内容的准确性、完整性和时效性作任何保证。
|
|
107
|
+
|
|
108
|
+
## 从 1.4.x 及更早版本升级
|
|
109
|
+
|
|
110
|
+
- 启动器不再自动创建虚拟环境或安装依赖。已有的 `~/.cninfo-mcp/venv` 满足依赖时无需操作,否则先运行一次 `npx -y @youhaozhao/cninfo-mcp install`。
|
|
111
|
+
- 下载位置改为受限的下载根目录,默认从包目录下的 `pdf/` 变为 `~/Downloads/cninfo-mcp`。以前传给保存路径的任意绝对路径会被拒绝,需要改用 `CNINFO_MCP_DOWNLOAD_DIR` 指定根目录。
|
|
112
|
+
- 查询接口改为通过 HTTPS 访问。
|
|
113
|
+
|
|
65
114
|
## Credits
|
|
66
115
|
|
|
67
116
|
爬虫逻辑基于 [gaodechen/cninfo_process](https://github.com/gaodechen/cninfo_process)。
|
|
117
|
+
|
|
118
|
+
## 开发测试
|
|
119
|
+
|
|
120
|
+
在独立环境中安装运行依赖和 pytest 后执行全部 Python 与 Node 回归测试:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python3 -m venv .venv
|
|
124
|
+
# Windows: .venv\Scripts\activate
|
|
125
|
+
source .venv/bin/activate
|
|
126
|
+
python -m pip install -r python/requirements.txt pytest
|
|
127
|
+
npm test
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
也可使用 uv 临时环境:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv run --no-project --with pytest --with requests --with 'mcp~=2.1.1' npm test
|
|
134
|
+
```
|
package/bin/cninfo-mcp.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 巨潮资讯 MCP 服务器启动器
|
|
5
|
-
*
|
|
5
|
+
* 只校验已有的 Python 环境并启动 Python MCP 服务器:不联网,不写用户目录。
|
|
6
|
+
* 环境由显式命令 `cninfo-mcp install` 准备(scripts/install-python-deps.js)。
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
const { spawn } = require("child_process");
|
|
@@ -12,15 +13,16 @@ const os = require("os");
|
|
|
12
13
|
|
|
13
14
|
// 配置路径
|
|
14
15
|
const PYTHON_SCRIPT = path.join(__dirname, "..", "python", "mcp_server.py");
|
|
15
|
-
const
|
|
16
|
+
const INSTALL_SCRIPT = path.join(
|
|
16
17
|
__dirname,
|
|
17
18
|
"..",
|
|
18
|
-
"
|
|
19
|
-
"
|
|
19
|
+
"scripts",
|
|
20
|
+
"install-python-deps.js",
|
|
20
21
|
);
|
|
22
|
+
const INSTALL_COMMAND = "npx -y @youhaozhao/cninfo-mcp install";
|
|
21
23
|
|
|
22
24
|
// 虚拟环境目录,放在用户目录下保证跨 npx 调用持久化
|
|
23
|
-
|
|
25
|
+
let VENV_DIR = path.join(os.homedir(), ".cninfo-mcp", "venv");
|
|
24
26
|
|
|
25
27
|
// 获取虚拟环境中的 Python 可执行文件路径
|
|
26
28
|
function getVenvPython() {
|
|
@@ -30,83 +32,65 @@ function getVenvPython() {
|
|
|
30
32
|
return path.join(VENV_DIR, "bin", "python3");
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"python3.10",
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
for (const cmd of pythonCommands) {
|
|
44
|
-
try {
|
|
45
|
-
const result = await spawnAsync(cmd, ["--version"]);
|
|
46
|
-
if (result.stdout && result.stdout.includes("Python")) {
|
|
47
|
-
return cmd;
|
|
48
|
-
}
|
|
49
|
-
} catch (error) {
|
|
50
|
-
// 继续尝试下一个命令
|
|
51
|
-
}
|
|
35
|
+
async function isSupportedPython(cmd) {
|
|
36
|
+
try {
|
|
37
|
+
const result = await spawnAsync(cmd, ["--version"]);
|
|
38
|
+
const version = `${result.stdout || ""} ${result.stderr || ""}`.match(/\bPython (\d+)\.(\d+)\.(\d+)\b/);
|
|
39
|
+
return Boolean(version && Number(version[1]) === 3 && Number(version[2]) >= 10);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
52
42
|
}
|
|
53
|
-
|
|
54
|
-
throw new Error(
|
|
55
|
-
"Python not found. Please install Python 3.10+ from https://python.org\n" +
|
|
56
|
-
"After installation, restart your terminal and try again.",
|
|
57
|
-
);
|
|
58
43
|
}
|
|
59
44
|
|
|
60
|
-
//
|
|
61
|
-
async function
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
fs.mkdirSync(path.dirname(VENV_DIR), { recursive: true });
|
|
69
|
-
await spawnAsync(systemPythonCmd, ["-m", "venv", VENV_DIR], {
|
|
70
|
-
stdio: "inherit",
|
|
71
|
-
});
|
|
72
|
-
console.error("Virtual environment created\n");
|
|
73
|
-
return venvPython;
|
|
45
|
+
// An obsolete environment is left intact; its compatible sibling is used instead.
|
|
46
|
+
async function reusableVenv() {
|
|
47
|
+
if (!fs.existsSync(getVenvPython())) return null;
|
|
48
|
+
if (await isSupportedPython(getVenvPython())) return getVenvPython();
|
|
49
|
+
VENV_DIR += "-py310";
|
|
50
|
+
if (!fs.existsSync(getVenvPython())) return null;
|
|
51
|
+
if (await isSupportedPython(getVenvPython())) return getVenvPython();
|
|
52
|
+
throw new Error(`Unsupported or broken Python environment at ${VENV_DIR}. Recreate it with Python 3.10+.`);
|
|
74
53
|
}
|
|
75
54
|
|
|
76
55
|
// 依赖探针:校验 venv 是否满足 requirements.txt 的全部约束
|
|
77
56
|
const DEPS_CHECK = path.join(__dirname, "..", "python", "check_deps.py");
|
|
78
57
|
|
|
79
|
-
//
|
|
80
|
-
async function
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
58
|
+
// 只读校验:环境缺失或依赖不满足时报错并给出安装命令,不在启动阶段安装
|
|
59
|
+
async function requireEnvironment() {
|
|
60
|
+
const venvPython = await reusableVenv();
|
|
61
|
+
if (!venvPython) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"Python environment for cninfo-mcp is not installed.\n" +
|
|
64
|
+
`Run this once, then restart the MCP client: ${INSTALL_COMMAND}`,
|
|
65
|
+
);
|
|
86
66
|
}
|
|
87
67
|
|
|
88
68
|
try {
|
|
89
|
-
// 校验依赖是否满足约束(不满足时抛错,转入下方安装流程)
|
|
90
69
|
await spawnAsync(venvPython, [DEPS_CHECK]);
|
|
91
|
-
} catch
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
venvPython,
|
|
97
|
-
["-m", "pip", "install", "-r", requirementsPath],
|
|
98
|
-
{
|
|
99
|
-
stdio: "inherit",
|
|
100
|
-
},
|
|
101
|
-
);
|
|
102
|
-
console.error("Python dependencies installed successfully\n");
|
|
103
|
-
} catch (installError) {
|
|
104
|
-
console.error("\n❌ Failed to install Python dependencies");
|
|
105
|
-
console.error("Please run manually:");
|
|
106
|
-
console.error(` ${venvPython} -m pip install -r ${requirementsPath}`);
|
|
107
|
-
process.exit(1);
|
|
108
|
-
}
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Python dependencies in ${VENV_DIR} are missing or out of date.\n` +
|
|
73
|
+
`Run this, then restart the MCP client: ${INSTALL_COMMAND}`,
|
|
74
|
+
);
|
|
109
75
|
}
|
|
76
|
+
return venvPython;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 显式安装入口:唯一会联网并写用户目录的路径
|
|
80
|
+
function runInstaller() {
|
|
81
|
+
const child = spawn(process.execPath, [INSTALL_SCRIPT], {
|
|
82
|
+
stdio: "inherit",
|
|
83
|
+
shell: false,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
child.on("error", (error) => {
|
|
87
|
+
console.error("Failed to start installer:", error.message);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
child.on("exit", (code) => {
|
|
92
|
+
process.exit(code ?? 1);
|
|
93
|
+
});
|
|
110
94
|
}
|
|
111
95
|
|
|
112
96
|
// 启动子进程并返回结果
|
|
@@ -114,7 +98,7 @@ function spawnAsync(command, args, options = {}) {
|
|
|
114
98
|
return new Promise((resolve, reject) => {
|
|
115
99
|
const child = spawn(command, args, {
|
|
116
100
|
stdio: options.stdio || "pipe",
|
|
117
|
-
shell:
|
|
101
|
+
shell: false,
|
|
118
102
|
...options,
|
|
119
103
|
});
|
|
120
104
|
|
|
@@ -155,21 +139,24 @@ function spawnAsync(command, args, options = {}) {
|
|
|
155
139
|
|
|
156
140
|
async function main() {
|
|
157
141
|
try {
|
|
142
|
+
if (process.argv[2] === "install") {
|
|
143
|
+
runInstaller();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
158
147
|
// 检查 Python 脚本是否存在
|
|
159
148
|
if (!fs.existsSync(PYTHON_SCRIPT)) {
|
|
160
149
|
console.error("Error: mcp_server.py not found at", PYTHON_SCRIPT);
|
|
161
150
|
process.exit(1);
|
|
162
151
|
}
|
|
163
152
|
|
|
164
|
-
const
|
|
165
|
-
const venvPython = await ensureVenv(systemPython);
|
|
166
|
-
await ensureDependencies(venvPython);
|
|
153
|
+
const venvPython = await requireEnvironment();
|
|
167
154
|
|
|
168
155
|
// 启动 MCP 服务器
|
|
169
156
|
console.error("巨潮资讯 MCP 服务器已启动,等待连接...");
|
|
170
157
|
const child = spawn(venvPython, [PYTHON_SCRIPT], {
|
|
171
158
|
stdio: "inherit",
|
|
172
|
-
shell:
|
|
159
|
+
shell: false,
|
|
173
160
|
env: {
|
|
174
161
|
...process.env,
|
|
175
162
|
PYTHONPATH: path.join(__dirname, "..", "python"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@youhaozhao/cninfo-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"mcpName": "io.github.youhaozhao/cninfo-mcp",
|
|
4
5
|
"description": "MCP Server for querying and downloading Chinese listed companies' periodic reports from CNINFO (巨潮资讯网)",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"mcp",
|
|
@@ -37,10 +38,9 @@
|
|
|
37
38
|
"cninfo-mcp": "bin/cninfo-mcp.js"
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|
|
40
|
-
"postinstall": "node scripts/install-python-deps.js",
|
|
41
41
|
"start": "node bin/cninfo-mcp.js",
|
|
42
42
|
"dev": "node bin/cninfo-mcp.js",
|
|
43
|
-
"test": "python3 -m pytest python/
|
|
43
|
+
"test": "python3 -m pytest python -q && node --test scripts/test-launcher.js scripts/test-manifest.js"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"spawn-please": "^2.0.2"
|
package/python/mcp_server.py
CHANGED
|
@@ -13,14 +13,22 @@ from typing import Optional
|
|
|
13
13
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
14
|
|
|
15
15
|
from mcp.server.mcpserver import MCPServer
|
|
16
|
+
from mcp.types import ToolAnnotations
|
|
16
17
|
from spider import (
|
|
17
18
|
query_reports,
|
|
19
|
+
QueryError,
|
|
20
|
+
normalize_stock_code,
|
|
18
21
|
download_reports,
|
|
19
22
|
format_reports,
|
|
20
|
-
saving_path,
|
|
21
23
|
supported_report_types,
|
|
22
24
|
)
|
|
23
25
|
|
|
26
|
+
# 下载根目录由部署方通过环境变量指定,调用方(模型)无法用工具参数改写,
|
|
27
|
+
# 因此所有写盘都落在这一个可审计的目录内。
|
|
28
|
+
DOWNLOAD_DIR_ENV = "CNINFO_MCP_DOWNLOAD_DIR"
|
|
29
|
+
DEFAULT_DOWNLOAD_DIR = os.path.join("~", "Downloads", "cninfo-mcp")
|
|
30
|
+
|
|
31
|
+
|
|
24
32
|
def _package_version(default: str = "0.0.0") -> str:
|
|
25
33
|
"""从 package.json 读取版本,避免与 npm 包版本各写一份而漂移。
|
|
26
34
|
|
|
@@ -49,7 +57,43 @@ def _supported_report_types_text() -> str:
|
|
|
49
57
|
return ", ".join(supported_report_types().keys())
|
|
50
58
|
|
|
51
59
|
|
|
52
|
-
|
|
60
|
+
def _download_root() -> str:
|
|
61
|
+
configured = os.environ.get(DOWNLOAD_DIR_ENV, "").strip()
|
|
62
|
+
return os.path.realpath(os.path.expanduser(configured or DEFAULT_DOWNLOAD_DIR))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_save_path(save_path: Optional[str]) -> str:
|
|
66
|
+
"""把调用方给的 save_path 限制在下载根目录内,返回最终写入目录。
|
|
67
|
+
|
|
68
|
+
相对路径按根目录下的子目录解析;绝对路径只有落在根目录内才接受。两侧都
|
|
69
|
+
先 realpath,因此 `..` 和指向根目录之外的符号链接都会被拒绝。
|
|
70
|
+
"""
|
|
71
|
+
root = _download_root()
|
|
72
|
+
requested = (save_path or "").strip()
|
|
73
|
+
if not requested:
|
|
74
|
+
return root
|
|
75
|
+
candidate = os.path.realpath(os.path.join(root, os.path.expanduser(requested)))
|
|
76
|
+
try:
|
|
77
|
+
inside = os.path.commonpath([root, candidate]) == root
|
|
78
|
+
except ValueError:
|
|
79
|
+
# Windows 上不同盘符之间没有公共路径
|
|
80
|
+
inside = False
|
|
81
|
+
if not inside:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"save_path must be inside the download directory {root}. "
|
|
84
|
+
f"Set {DOWNLOAD_DIR_ENV} in the MCP server configuration to change it"
|
|
85
|
+
)
|
|
86
|
+
return candidate
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@mcp.tool(
|
|
90
|
+
annotations=ToolAnnotations(
|
|
91
|
+
read_only_hint=True,
|
|
92
|
+
destructive_hint=False,
|
|
93
|
+
idempotent_hint=True,
|
|
94
|
+
open_world_hint=True,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
53
97
|
def query_annual_reports_tool(
|
|
54
98
|
stock_code: str, year: Optional[int] = None, report_type: str = "annual"
|
|
55
99
|
) -> dict:
|
|
@@ -63,7 +107,8 @@ def query_annual_reports_tool(
|
|
|
63
107
|
|
|
64
108
|
Returns:
|
|
65
109
|
Dictionary containing:
|
|
66
|
-
- success:
|
|
110
|
+
- success: True only for complete queries, including empty results
|
|
111
|
+
- status: complete, partial, or error; incomplete queries include error details
|
|
67
112
|
- stock_code: The queried stock code
|
|
68
113
|
- report_type: The requested report type
|
|
69
114
|
- year: The filtered year (if any)
|
|
@@ -71,11 +116,13 @@ def query_annual_reports_tool(
|
|
|
71
116
|
- reports: List of report details (announcementTitle, announcementTime, secCode, secName, adjunctUrl)
|
|
72
117
|
"""
|
|
73
118
|
try:
|
|
119
|
+
stock_code = normalize_stock_code(stock_code)
|
|
74
120
|
reports = query_reports(stock_code, report_type, year)
|
|
75
121
|
|
|
76
122
|
if not reports:
|
|
77
123
|
return {
|
|
78
|
-
"success":
|
|
124
|
+
"success": True,
|
|
125
|
+
"status": "complete",
|
|
79
126
|
"stock_code": stock_code,
|
|
80
127
|
"report_type": report_type,
|
|
81
128
|
"year": year,
|
|
@@ -87,6 +134,7 @@ def query_annual_reports_tool(
|
|
|
87
134
|
|
|
88
135
|
return {
|
|
89
136
|
"success": True,
|
|
137
|
+
"status": "complete",
|
|
90
138
|
"stock_code": stock_code,
|
|
91
139
|
"report_type": report_type,
|
|
92
140
|
"year": year,
|
|
@@ -96,9 +144,24 @@ def query_annual_reports_tool(
|
|
|
96
144
|
+ (f" for year {year}" if year else ""),
|
|
97
145
|
}
|
|
98
146
|
|
|
147
|
+
except QueryError as e:
|
|
148
|
+
return {
|
|
149
|
+
"success": False,
|
|
150
|
+
"status": e.status,
|
|
151
|
+
"stock_code": stock_code,
|
|
152
|
+
"report_type": report_type,
|
|
153
|
+
"year": year,
|
|
154
|
+
"count": len(e.reports),
|
|
155
|
+
"reports": format_reports(e.reports),
|
|
156
|
+
"errors": e.errors,
|
|
157
|
+
"error": str(e),
|
|
158
|
+
"message": f"Query incomplete: {str(e)}",
|
|
159
|
+
}
|
|
160
|
+
|
|
99
161
|
except Exception as e:
|
|
100
162
|
return {
|
|
101
163
|
"success": False,
|
|
164
|
+
"status": "error",
|
|
102
165
|
"stock_code": stock_code,
|
|
103
166
|
"report_type": report_type,
|
|
104
167
|
"year": year,
|
|
@@ -109,7 +172,16 @@ def query_annual_reports_tool(
|
|
|
109
172
|
}
|
|
110
173
|
|
|
111
174
|
|
|
112
|
-
|
|
175
|
+
# 非只读:会在下载根目录内写文件。文件名带附件 URL 的 SHA-256,重复调用只会
|
|
176
|
+
# 原子替换同一附件的旧副本,不会改动其他文件,因此不是破坏性操作且幂等。
|
|
177
|
+
@mcp.tool(
|
|
178
|
+
annotations=ToolAnnotations(
|
|
179
|
+
read_only_hint=False,
|
|
180
|
+
destructive_hint=False,
|
|
181
|
+
idempotent_hint=True,
|
|
182
|
+
open_world_hint=True,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
113
185
|
def download_annual_reports_tool(
|
|
114
186
|
stock_code: str,
|
|
115
187
|
year: Optional[int] = None,
|
|
@@ -122,12 +194,16 @@ def download_annual_reports_tool(
|
|
|
122
194
|
Args:
|
|
123
195
|
stock_code: Stock code (e.g., '000888' for 峨眉山, '688777' for 中科德芯)
|
|
124
196
|
year: Optional year to filter (e.g., 2024). If not provided, downloads all available years
|
|
125
|
-
save_path: Optional directory
|
|
197
|
+
save_path: Optional sub-directory for the files (e.g., 'annual/2024'). It must resolve inside the server's download directory, which is set by the CNINFO_MCP_DOWNLOAD_DIR environment variable (default ~/Downloads/cninfo-mcp); relative paths are taken relative to that directory and anything outside it is rejected. Omit to save directly in the download directory.
|
|
126
198
|
report_type: Optional report type. Supported values: annual, semiannual, q1, q3, prospectus. Defaults to annual for backward compatibility.
|
|
127
199
|
|
|
128
200
|
Returns:
|
|
129
201
|
Dictionary containing:
|
|
130
|
-
- success:
|
|
202
|
+
- success: True only when the query and all downloads completed
|
|
203
|
+
- status: complete, partial, or error
|
|
204
|
+
- query_status: Whether the source query completed
|
|
205
|
+
- files / failures: Successful paths and per-attachment errors
|
|
206
|
+
- failed: Number of failed attachments
|
|
131
207
|
- stock_code: The stock code
|
|
132
208
|
- report_type: The requested report type
|
|
133
209
|
- year: The filtered year (if any)
|
|
@@ -135,9 +211,10 @@ def download_annual_reports_tool(
|
|
|
135
211
|
- path: Directory where files were saved
|
|
136
212
|
- message: Status message
|
|
137
213
|
"""
|
|
214
|
+
output_dir = _download_root()
|
|
138
215
|
try:
|
|
139
|
-
output_dir = save_path
|
|
140
|
-
|
|
216
|
+
output_dir = _resolve_save_path(save_path)
|
|
217
|
+
stock_code = normalize_stock_code(stock_code)
|
|
141
218
|
|
|
142
219
|
result = download_reports(
|
|
143
220
|
stock_code, report_type, year=year, save_path=output_dir
|
|
@@ -151,11 +228,16 @@ def download_annual_reports_tool(
|
|
|
151
228
|
except Exception as e:
|
|
152
229
|
return {
|
|
153
230
|
"success": False,
|
|
231
|
+
"status": "error",
|
|
154
232
|
"stock_code": stock_code,
|
|
155
233
|
"report_type": report_type,
|
|
156
234
|
"year": year,
|
|
157
235
|
"downloaded": 0,
|
|
158
|
-
"
|
|
236
|
+
"files": [],
|
|
237
|
+
"failed": 0,
|
|
238
|
+
"failures": [],
|
|
239
|
+
"query_status": "error",
|
|
240
|
+
"path": output_dir,
|
|
159
241
|
"error": str(e),
|
|
160
242
|
"message": f"Error downloading reports: {str(e)}. Supported report_type values: {_supported_report_types_text()}",
|
|
161
243
|
}
|
|
@@ -165,12 +247,21 @@ def download_annual_reports_tool(
|
|
|
165
247
|
def get_annual_reports_list(stock_code: str) -> str:
|
|
166
248
|
"""返回指定股票代码的年度报告格式化列表"""
|
|
167
249
|
try:
|
|
168
|
-
|
|
169
|
-
|
|
250
|
+
warning = ""
|
|
251
|
+
try:
|
|
252
|
+
reports = query_reports(stock_code, "annual")
|
|
253
|
+
except QueryError as exc:
|
|
254
|
+
reports = exc.reports
|
|
255
|
+
warning = f"Query incomplete ({exc.status}): {exc}"
|
|
256
|
+
|
|
257
|
+
if not reports and warning:
|
|
258
|
+
return warning
|
|
170
259
|
if not reports:
|
|
171
260
|
return f"No annual reports found for stock {stock_code}"
|
|
172
261
|
|
|
173
262
|
output = [f"Annual Reports for {stock_code}:", "=" * 60]
|
|
263
|
+
if warning:
|
|
264
|
+
output.append(warning)
|
|
174
265
|
|
|
175
266
|
for report in reports:
|
|
176
267
|
title = report.get("announcementTitle", "N/A")
|
package/python/spider.py
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import datetime
|
|
6
|
+
import hashlib
|
|
6
7
|
import logging
|
|
7
8
|
import os
|
|
8
9
|
import random
|
|
9
10
|
import re
|
|
11
|
+
import tempfile
|
|
10
12
|
import time
|
|
11
13
|
from typing import Optional, Union
|
|
14
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
12
15
|
|
|
13
16
|
import requests
|
|
14
17
|
|
|
@@ -25,9 +28,9 @@ PAGE_SIZE = 30
|
|
|
25
28
|
# 翻页安全上限,防止异常情况下无限循环
|
|
26
29
|
MAX_PAGES = 100
|
|
27
30
|
# 巨潮搜索联想接口:把股票代码/简称解析为 orgId(北交所查询必需)
|
|
28
|
-
TOP_SEARCH_URL = "
|
|
31
|
+
TOP_SEARCH_URL = "https://www.cninfo.com.cn/new/information/topSearch/query"
|
|
29
32
|
# 公告查询接口
|
|
30
|
-
QUERY_URL = "
|
|
33
|
+
QUERY_URL = "https://www.cninfo.com.cn/new/hisAnnouncement/query"
|
|
31
34
|
# 瞬时失败(网络抖动/限流)的重试次数与退避基数(秒)
|
|
32
35
|
MAX_RETRIES = 3
|
|
33
36
|
RETRY_BACKOFF = 1.0
|
|
@@ -144,8 +147,8 @@ BASE_HEADERS = {
|
|
|
144
147
|
"Accept-Encoding": "gzip, deflate",
|
|
145
148
|
"Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-HK;q=0.6,zh-TW;q=0.5",
|
|
146
149
|
"Host": "www.cninfo.com.cn",
|
|
147
|
-
"Origin": "
|
|
148
|
-
"Referer": "
|
|
150
|
+
"Origin": "https://www.cninfo.com.cn",
|
|
151
|
+
"Referer": "https://www.cninfo.com.cn/new/commonUrl?url=disclosure/list/notice",
|
|
149
152
|
"X-Requested-With": "XMLHttpRequest",
|
|
150
153
|
}
|
|
151
154
|
|
|
@@ -167,6 +170,46 @@ def normalize_report_type(report_type: Optional[str]) -> str:
|
|
|
167
170
|
return normalized
|
|
168
171
|
|
|
169
172
|
|
|
173
|
+
def normalize_stock_code(stock_code) -> str:
|
|
174
|
+
"""Require a six-digit security code; preserve leading zeroes."""
|
|
175
|
+
code = str(stock_code).strip()
|
|
176
|
+
if not re.fullmatch(r"[0-9]{6}", code):
|
|
177
|
+
raise ValueError("stock_code must contain exactly six ASCII digits")
|
|
178
|
+
return code
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class QueryError(RuntimeError):
|
|
182
|
+
"""An incomplete query, optionally carrying already fetched reports."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, errors, reports=None, partial=False):
|
|
185
|
+
self.errors = errors
|
|
186
|
+
self.reports = reports or []
|
|
187
|
+
self.status = "partial" if partial else "error"
|
|
188
|
+
super().__init__("; ".join(errors))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def resolve_attachment_url(value: str, base: str = download_path) -> str:
|
|
192
|
+
"""Resolve attachment paths, allowing only HTTPS on CNINFO's static host."""
|
|
193
|
+
if (
|
|
194
|
+
not isinstance(value, str)
|
|
195
|
+
or not value
|
|
196
|
+
or re.search(r"[\s\\\x00-\x1f\x7f]", value)
|
|
197
|
+
):
|
|
198
|
+
raise ValueError("Invalid attachment URL")
|
|
199
|
+
parsed = urlsplit(urljoin(base, value))
|
|
200
|
+
if (
|
|
201
|
+
parsed.scheme != "https"
|
|
202
|
+
or parsed.hostname != "static.cninfo.com.cn"
|
|
203
|
+
or parsed.username is not None
|
|
204
|
+
or parsed.password is not None
|
|
205
|
+
or parsed.port not in (None, 443)
|
|
206
|
+
):
|
|
207
|
+
raise ValueError("Attachment URLs must use https://static.cninfo.com.cn")
|
|
208
|
+
return urlunsplit(
|
|
209
|
+
("https", "static.cninfo.com.cn", parsed.path or "/", parsed.query, "")
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
170
213
|
def format_reports(reports: list) -> list:
|
|
171
214
|
"""提取公告中稳定、有用的字段,并把附件路径补全为可访问的绝对 URL。
|
|
172
215
|
|
|
@@ -176,15 +219,23 @@ def format_reports(reports: list) -> list:
|
|
|
176
219
|
formatted = []
|
|
177
220
|
for report in reports:
|
|
178
221
|
adj = report.get("adjunctUrl", "")
|
|
222
|
+
attachment_error = None
|
|
223
|
+
try:
|
|
224
|
+
url = resolve_attachment_url(adj) if adj else ""
|
|
225
|
+
except ValueError as exc:
|
|
226
|
+
url = ""
|
|
227
|
+
attachment_error = str(exc)
|
|
179
228
|
formatted.append(
|
|
180
229
|
{
|
|
181
230
|
"announcementTitle": report.get("announcementTitle", ""),
|
|
182
231
|
"announcementTime": report.get("announcementTime", ""),
|
|
183
232
|
"secCode": report.get("secCode", ""),
|
|
184
233
|
"secName": report.get("secName", ""),
|
|
185
|
-
"adjunctUrl":
|
|
234
|
+
"adjunctUrl": url,
|
|
186
235
|
}
|
|
187
236
|
)
|
|
237
|
+
if attachment_error:
|
|
238
|
+
formatted[-1]["attachmentError"] = attachment_error
|
|
188
239
|
return formatted
|
|
189
240
|
|
|
190
241
|
|
|
@@ -229,9 +280,12 @@ def _post_json(url: str, data: dict) -> dict:
|
|
|
229
280
|
def _query_announcements(query: dict) -> list:
|
|
230
281
|
"""调用公告查询接口并返回 announcements 列表(带重试)。"""
|
|
231
282
|
result = _post_json(QUERY_URL, query)
|
|
232
|
-
if result
|
|
233
|
-
|
|
234
|
-
|
|
283
|
+
if not isinstance(result, dict) or "announcements" not in result:
|
|
284
|
+
raise ValueError("Invalid announcement response")
|
|
285
|
+
items = result["announcements"]
|
|
286
|
+
if items is not None and not isinstance(items, list):
|
|
287
|
+
raise ValueError("Invalid announcements list")
|
|
288
|
+
return items or []
|
|
235
289
|
|
|
236
290
|
|
|
237
291
|
def _is_bse_code(stock_code) -> bool:
|
|
@@ -253,13 +307,10 @@ def _resolve_org_id(stock_code) -> Optional[tuple]:
|
|
|
253
307
|
优先返回 code 完全等于输入的条目;找不到精确匹配则取第一条
|
|
254
308
|
(同一公司新旧代码共用同一 orgId)。无结果返回 None。
|
|
255
309
|
"""
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
return None
|
|
261
|
-
|
|
262
|
-
if not isinstance(hits, list) or not hits:
|
|
310
|
+
hits = _post_json(TOP_SEARCH_URL, {"keyWord": stock_code, "maxNum": 10})
|
|
311
|
+
if not isinstance(hits, list):
|
|
312
|
+
raise ValueError("Invalid orgId response")
|
|
313
|
+
if not hits:
|
|
263
314
|
return None
|
|
264
315
|
|
|
265
316
|
target = re.sub(r"\D", "", str(stock_code or ""))
|
|
@@ -288,14 +339,17 @@ def _paginate(fetch_fn, stock):
|
|
|
288
339
|
"""
|
|
289
340
|
all_items = []
|
|
290
341
|
for page in range(1, MAX_PAGES + 1):
|
|
291
|
-
|
|
342
|
+
try:
|
|
343
|
+
items = fetch_fn(page, stock)
|
|
344
|
+
except Exception as exc:
|
|
345
|
+
raise QueryError([f"page {page}: {exc}"], all_items, page > 1) from exc
|
|
292
346
|
if not items:
|
|
293
347
|
break
|
|
294
348
|
all_items.extend(items)
|
|
295
349
|
if len(items) < PAGE_SIZE:
|
|
296
350
|
break
|
|
297
351
|
else:
|
|
298
|
-
|
|
352
|
+
raise QueryError([f"Pagination limit reached ({MAX_PAGES})"], all_items, True)
|
|
299
353
|
return all_items
|
|
300
354
|
|
|
301
355
|
|
|
@@ -314,13 +368,28 @@ def _is_report_title(
|
|
|
314
368
|
spec = REPORT_TYPE_SPECS[normalized_type]
|
|
315
369
|
|
|
316
370
|
if normalized_type == "prospectus":
|
|
317
|
-
|
|
318
|
-
|
|
371
|
+
# Historical version labels vary; filter document kind below instead
|
|
372
|
+
# of restricting prospectus versions to a fixed suffix vocabulary.
|
|
373
|
+
match = re.fullmatch(
|
|
374
|
+
r".*?(?:招股说明书|招股意向书|招股书)(?:\([^()()]+\)|([^()()]+))*",
|
|
375
|
+
compact_title,
|
|
376
|
+
)
|
|
377
|
+
if not match:
|
|
319
378
|
return False
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
379
|
+
remainder = re.sub(r"招股说明书|招股意向书|招股书", "", compact_title)
|
|
380
|
+
excluded = COMMON_EXCLUDE_KEYWORDS + [
|
|
381
|
+
"关于",
|
|
382
|
+
"意见",
|
|
383
|
+
"核查",
|
|
384
|
+
"验证",
|
|
385
|
+
"问询",
|
|
386
|
+
"回复",
|
|
387
|
+
"公告",
|
|
388
|
+
"审计报告",
|
|
389
|
+
"附件",
|
|
390
|
+
"附录",
|
|
391
|
+
]
|
|
392
|
+
return not any(kw in remainder for kw in excluded)
|
|
324
393
|
|
|
325
394
|
# 摘要/更正/修订等非正文变体应排除
|
|
326
395
|
if any(keyword in compact_title for keyword in COMMON_EXCLUDE_KEYWORDS):
|
|
@@ -353,7 +422,8 @@ def _matches_year(
|
|
|
353
422
|
# announcementTime 通常是 "YYYY-MM-DD" 字符串;个别接口可能返回 epoch 毫秒
|
|
354
423
|
if isinstance(announcement_time, (int, float)):
|
|
355
424
|
announcement_time = datetime.datetime.fromtimestamp(
|
|
356
|
-
announcement_time / 1000
|
|
425
|
+
announcement_time / 1000,
|
|
426
|
+
tz=datetime.timezone(datetime.timedelta(hours=8)),
|
|
357
427
|
).strftime("%Y-%m-%d")
|
|
358
428
|
return str(announcement_time).startswith(str(year))
|
|
359
429
|
return _is_report_title(
|
|
@@ -442,68 +512,141 @@ def sseStock(page, stock):
|
|
|
442
512
|
return _query_exchange_report(page, stock, "prospectus", "sse", "sh")
|
|
443
513
|
|
|
444
514
|
|
|
515
|
+
def _get_attachment(url):
|
|
516
|
+
"""Follow at most five redirects, validating before every network request."""
|
|
517
|
+
url = resolve_attachment_url(url)
|
|
518
|
+
for redirects in range(6):
|
|
519
|
+
response = requests.get(
|
|
520
|
+
url,
|
|
521
|
+
headers={"User-Agent": random.choice(User_Agent)},
|
|
522
|
+
timeout=30,
|
|
523
|
+
allow_redirects=False,
|
|
524
|
+
)
|
|
525
|
+
if response.status_code not in (301, 302, 303, 307, 308):
|
|
526
|
+
return response
|
|
527
|
+
try:
|
|
528
|
+
if redirects == 5:
|
|
529
|
+
raise ValueError("Too many attachment redirects")
|
|
530
|
+
url = resolve_attachment_url(response.headers.get("Location", ""), base=url)
|
|
531
|
+
finally:
|
|
532
|
+
response.close()
|
|
533
|
+
raise RuntimeError("Attachment redirect handling failed")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _fetch_pdf(url):
|
|
537
|
+
for attempt in range(MAX_RETRIES):
|
|
538
|
+
try:
|
|
539
|
+
response = _get_attachment(url)
|
|
540
|
+
try:
|
|
541
|
+
response.raise_for_status()
|
|
542
|
+
content = response.content
|
|
543
|
+
content_type = (
|
|
544
|
+
response.headers.get("Content-Type", "")
|
|
545
|
+
.lower()
|
|
546
|
+
.split(";", 1)[0]
|
|
547
|
+
.strip()
|
|
548
|
+
)
|
|
549
|
+
if content_type and content_type not in (
|
|
550
|
+
"application/pdf",
|
|
551
|
+
"application/octet-stream",
|
|
552
|
+
"binary/octet-stream",
|
|
553
|
+
"application/x-pdf",
|
|
554
|
+
):
|
|
555
|
+
raise ValueError(f"Unexpected PDF content type: {content_type}")
|
|
556
|
+
if not content.startswith(b"%PDF-"):
|
|
557
|
+
raise ValueError("Empty or non-PDF response")
|
|
558
|
+
return content
|
|
559
|
+
finally:
|
|
560
|
+
response.close()
|
|
561
|
+
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
|
|
562
|
+
if attempt == MAX_RETRIES - 1:
|
|
563
|
+
raise
|
|
564
|
+
except requests.exceptions.HTTPError as exc:
|
|
565
|
+
status = exc.response.status_code if exc.response is not None else None
|
|
566
|
+
if status != 429 and (status is None or status < 500):
|
|
567
|
+
raise
|
|
568
|
+
if attempt == MAX_RETRIES - 1:
|
|
569
|
+
raise
|
|
570
|
+
time.sleep(RETRY_BACKOFF * (2**attempt) + random.random())
|
|
571
|
+
raise RuntimeError("No download attempts configured")
|
|
572
|
+
|
|
573
|
+
|
|
445
574
|
def Download(
|
|
446
575
|
single_page,
|
|
447
576
|
report_type: Optional[str] = None,
|
|
448
577
|
year_filter: Optional[Union[int, str]] = None,
|
|
449
578
|
save_path: Optional[str] = None,
|
|
579
|
+
*,
|
|
580
|
+
details=False,
|
|
450
581
|
):
|
|
451
|
-
"""
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
output_dir = (save_path or saving_path).rstrip("/\\") + "/"
|
|
456
|
-
downloaded_count = 0
|
|
582
|
+
"""Download independently and atomically; legacy callers receive a count."""
|
|
583
|
+
output_dir = save_path or saving_path
|
|
584
|
+
files, failures = [], []
|
|
457
585
|
normalized_type = normalize_report_type(report_type) if report_type else None
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if
|
|
462
|
-
|
|
463
|
-
|
|
586
|
+
for report in single_page or []:
|
|
587
|
+
title = report.get("announcementTitle", "")
|
|
588
|
+
types = [normalized_type] if normalized_type else REPORT_TYPE_SPECS
|
|
589
|
+
if not any(_is_report_title(title, kind, year_filter) for kind in types):
|
|
590
|
+
continue
|
|
591
|
+
adjunct_url = report.get("adjunctUrl", "")
|
|
592
|
+
temporary_path = None
|
|
593
|
+
try:
|
|
594
|
+
if not adjunct_url:
|
|
595
|
+
raise ValueError("Missing adjunctUrl")
|
|
596
|
+
url = resolve_attachment_url(adjunct_url)
|
|
597
|
+
# Full attachment digest keeps distinct URLs distinct, even with identical titles.
|
|
598
|
+
attachment_id = hashlib.sha256(adjunct_url.encode("utf-8")).hexdigest()
|
|
599
|
+
stem = _sanitize_filename(
|
|
600
|
+
f"{report.get('secCode', '')}_{report.get('secName', '')}_{title}"
|
|
464
601
|
)
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
602
|
+
# Leave room for the digest on filesystems with a 255-byte name limit.
|
|
603
|
+
stem = stem.encode("utf-8")[:160].decode("utf-8", errors="ignore")
|
|
604
|
+
file_path = os.path.join(output_dir, f"{stem}_{attachment_id}.pdf")
|
|
605
|
+
time.sleep(random.random() * 2)
|
|
606
|
+
content = _fetch_pdf(url)
|
|
607
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
608
|
+
with tempfile.NamedTemporaryFile(
|
|
609
|
+
dir=output_dir, suffix=".tmp", delete=False
|
|
610
|
+
) as fh:
|
|
611
|
+
temporary_path = fh.name
|
|
612
|
+
fh.write(content)
|
|
613
|
+
os.replace(temporary_path, file_path)
|
|
614
|
+
temporary_path = None
|
|
615
|
+
files.append({"adjunctUrl": adjunct_url, "path": file_path})
|
|
616
|
+
except Exception as exc:
|
|
617
|
+
failures.append(
|
|
618
|
+
{
|
|
619
|
+
"adjunctUrl": adjunct_url,
|
|
620
|
+
"announcementTitle": title,
|
|
621
|
+
"error": str(exc),
|
|
622
|
+
}
|
|
469
623
|
)
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
)
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
os.makedirs(output_dir, exist_ok=True)
|
|
487
|
-
|
|
488
|
-
time.sleep(random.random() * 2)
|
|
489
|
-
|
|
490
|
-
r = requests.get(
|
|
491
|
-
download, headers={"User-Agent": random.choice(User_Agent)}, timeout=30
|
|
492
|
-
)
|
|
493
|
-
r.raise_for_status()
|
|
494
|
-
with open(file_path, "wb") as f:
|
|
495
|
-
f.write(r.content)
|
|
496
|
-
downloaded_count += 1
|
|
497
|
-
|
|
498
|
-
return downloaded_count
|
|
624
|
+
logger.warning("Download failed (%s): %s", title, exc)
|
|
625
|
+
finally:
|
|
626
|
+
if temporary_path is not None:
|
|
627
|
+
try:
|
|
628
|
+
os.unlink(temporary_path)
|
|
629
|
+
except OSError as exc:
|
|
630
|
+
logger.warning(
|
|
631
|
+
"Could not remove temporary file %s: %s", temporary_path, exc
|
|
632
|
+
)
|
|
633
|
+
result = {
|
|
634
|
+
"downloaded": len(files),
|
|
635
|
+
"files": files,
|
|
636
|
+
"failed": len(failures),
|
|
637
|
+
"failures": failures,
|
|
638
|
+
}
|
|
639
|
+
return result if details else len(files)
|
|
499
640
|
|
|
500
641
|
|
|
501
642
|
def query_reports(stock_code, report_type="annual", year=None):
|
|
502
|
-
"""
|
|
643
|
+
"""Return complete results or raise QueryError carrying partial reports."""
|
|
644
|
+
stock_code = normalize_stock_code(stock_code)
|
|
503
645
|
normalized_type = normalize_report_type(report_type)
|
|
504
646
|
all_announcements = []
|
|
505
|
-
|
|
506
|
-
|
|
647
|
+
allowed_sec_codes = {stock_code}
|
|
648
|
+
errors = []
|
|
649
|
+
partial = False
|
|
507
650
|
|
|
508
651
|
exchanges = [
|
|
509
652
|
("sse", "sh", "沪市"),
|
|
@@ -515,7 +658,12 @@ def query_reports(stock_code, report_type="annual", year=None):
|
|
|
515
658
|
page, stock_code, normalized_type, c, p
|
|
516
659
|
)
|
|
517
660
|
all_announcements.extend(_paginate(fetch_fn, stock_code))
|
|
661
|
+
partial = True
|
|
518
662
|
except Exception as e:
|
|
663
|
+
errors.append(f"{label}: {e}")
|
|
664
|
+
if isinstance(e, QueryError):
|
|
665
|
+
all_announcements.extend(e.reports)
|
|
666
|
+
partial = partial or e.status == "partial"
|
|
519
667
|
logger.warning(
|
|
520
668
|
"%s%s查询失败: %s",
|
|
521
669
|
label,
|
|
@@ -539,7 +687,14 @@ def query_reports(stock_code, report_type="annual", year=None):
|
|
|
539
687
|
stock_value=stock_value,
|
|
540
688
|
)
|
|
541
689
|
all_announcements.extend(_paginate(fetch_fn, stock_value))
|
|
690
|
+
partial = True
|
|
691
|
+
else:
|
|
692
|
+
raise ValueError(f"Could not resolve orgId for {stock_code}")
|
|
542
693
|
except Exception as e:
|
|
694
|
+
errors.append(f"北交所: {e}")
|
|
695
|
+
if isinstance(e, QueryError):
|
|
696
|
+
all_announcements.extend(e.reports)
|
|
697
|
+
partial = partial or e.status == "partial"
|
|
543
698
|
logger.warning(
|
|
544
699
|
"北交所%s查询失败: %s",
|
|
545
700
|
REPORT_TYPE_SPECS[normalized_type]["label"],
|
|
@@ -569,41 +724,43 @@ def query_reports(stock_code, report_type="annual", year=None):
|
|
|
569
724
|
continue
|
|
570
725
|
filtered.append(announcement)
|
|
571
726
|
|
|
727
|
+
if errors:
|
|
728
|
+
raise QueryError(errors, filtered, partial)
|
|
572
729
|
return filtered
|
|
573
730
|
|
|
574
731
|
|
|
575
732
|
def download_reports(stock_code, report_type="annual", year=None, save_path=None):
|
|
576
733
|
"""下载指定股票和报告类型的 PDF。"""
|
|
734
|
+
stock_code = normalize_stock_code(stock_code)
|
|
577
735
|
normalized_type = normalize_report_type(report_type)
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
"message": f"未找到股票 {stock_code} 的{label}"
|
|
585
|
-
+ (f"({year} 年)" if year else ""),
|
|
586
|
-
"downloaded": 0,
|
|
587
|
-
}
|
|
588
|
-
|
|
736
|
+
query_error = None
|
|
737
|
+
try:
|
|
738
|
+
announcements = query_reports(stock_code, normalized_type, year)
|
|
739
|
+
except QueryError as exc:
|
|
740
|
+
query_error = exc
|
|
741
|
+
announcements = exc.reports
|
|
589
742
|
output_dir = save_path or saving_path
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
743
|
+
result = Download(announcements, normalized_type, year, output_dir, details=True)
|
|
744
|
+
status = "complete"
|
|
745
|
+
if query_error or result["failed"]:
|
|
746
|
+
status = (
|
|
747
|
+
"partial"
|
|
748
|
+
if result["downloaded"] or (query_error and query_error.status == "partial")
|
|
749
|
+
else "error"
|
|
750
|
+
)
|
|
751
|
+
result.update(
|
|
752
|
+
success=status == "complete",
|
|
753
|
+
status=status,
|
|
754
|
+
query_status=query_error.status if query_error else "complete",
|
|
755
|
+
path=output_dir,
|
|
756
|
+
message=f"Downloaded {result['downloaded']} file(s); {result['failed']} failed. Status: {status}.",
|
|
595
757
|
)
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
"
|
|
601
|
-
|
|
602
|
-
if downloaded > 0
|
|
603
|
-
else f"未下载任何文件({stock_code} {label}{year_suffix})",
|
|
604
|
-
"downloaded": downloaded,
|
|
605
|
-
"path": output_dir,
|
|
606
|
-
}
|
|
758
|
+
if query_error:
|
|
759
|
+
result["query_errors"] = query_error.errors
|
|
760
|
+
result["error"] = str(query_error)
|
|
761
|
+
elif result["failed"]:
|
|
762
|
+
result["error"] = f"{result['failed']} download(s) failed"
|
|
763
|
+
return result
|
|
607
764
|
|
|
608
765
|
|
|
609
766
|
def query_annual_reports(stock_code, year=None):
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 显式安装 Python 依赖:在用户目录创建 venv 并执行 pip install。
|
|
5
|
+
* 只由 `cninfo-mcp install` 触发;npm install 和服务器启动都不会运行它。
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
const { spawn } = require("child_process");
|
|
@@ -16,7 +17,7 @@ const REQUIREMENTS_FILE = path.join(
|
|
|
16
17
|
"requirements.txt",
|
|
17
18
|
);
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
let VENV_DIR = path.join(os.homedir(), ".cninfo-mcp", "venv");
|
|
20
21
|
|
|
21
22
|
// 依赖探针:校验 venv 是否满足 requirements.txt 的全部约束
|
|
22
23
|
const DEPS_CHECK = path.join(__dirname, "..", "python", "check_deps.py");
|
|
@@ -28,6 +29,26 @@ function getVenvPython() {
|
|
|
28
29
|
return path.join(VENV_DIR, "bin", "python3");
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
async function isSupportedPython(cmd) {
|
|
33
|
+
try {
|
|
34
|
+
const result = await spawnCommand(cmd, ["--version"]);
|
|
35
|
+
const version = `${result.stdout || ""} ${result.stderr || ""}`.match(/\bPython (\d+)\.(\d+)\.(\d+)\b/);
|
|
36
|
+
return Boolean(version && Number(version[1]) === 3 && Number(version[2]) >= 10);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Preserve an obsolete environment and create a compatible sibling if needed.
|
|
43
|
+
async function reusableVenv() {
|
|
44
|
+
if (!fs.existsSync(getVenvPython())) return null;
|
|
45
|
+
if (await isSupportedPython(getVenvPython())) return getVenvPython();
|
|
46
|
+
VENV_DIR += "-py310";
|
|
47
|
+
if (!fs.existsSync(getVenvPython())) return null;
|
|
48
|
+
if (await isSupportedPython(getVenvPython())) return getVenvPython();
|
|
49
|
+
throw new Error(`Unsupported or broken Python environment at ${VENV_DIR}. Recreate it with Python 3.10+.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
async function findPython() {
|
|
32
53
|
const pythonCommands = [
|
|
33
54
|
"python3",
|
|
@@ -38,22 +59,18 @@ async function findPython() {
|
|
|
38
59
|
];
|
|
39
60
|
|
|
40
61
|
for (const cmd of pythonCommands) {
|
|
41
|
-
|
|
42
|
-
const result = await spawnCommand(cmd, ["--version"]);
|
|
43
|
-
if (result.stdout && result.stdout.includes("Python")) {
|
|
44
|
-
return cmd;
|
|
45
|
-
}
|
|
46
|
-
} catch (error) {}
|
|
62
|
+
if (await isSupportedPython(cmd)) return cmd;
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
return null;
|
|
50
66
|
}
|
|
51
67
|
|
|
52
|
-
function spawnCommand(cmd, args) {
|
|
68
|
+
function spawnCommand(cmd, args, options = {}) {
|
|
53
69
|
return new Promise((resolve, reject) => {
|
|
54
70
|
const child = spawn(cmd, args, {
|
|
55
71
|
stdio: "pipe",
|
|
56
|
-
|
|
72
|
+
...options,
|
|
73
|
+
shell: false,
|
|
57
74
|
});
|
|
58
75
|
let stdout = "";
|
|
59
76
|
let stderr = "";
|
|
@@ -71,36 +88,23 @@ function spawnCommand(cmd, args) {
|
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
async function main() {
|
|
74
|
-
// requirements.txt 不存在则跳过
|
|
75
91
|
if (!fs.existsSync(REQUIREMENTS_FILE)) {
|
|
76
|
-
|
|
77
|
-
"⚠️ requirements.txt not found, skipping Python dependencies installation",
|
|
78
|
-
);
|
|
79
|
-
return;
|
|
92
|
+
throw new Error(`requirements.txt not found at ${REQUIREMENTS_FILE}`);
|
|
80
93
|
}
|
|
81
94
|
|
|
82
|
-
|
|
83
|
-
if (!
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// 创建虚拟环境(如果不存在)
|
|
92
|
-
const venvPython = getVenvPython();
|
|
93
|
-
if (!fs.existsSync(venvPython)) {
|
|
94
|
-
console.log("Creating Python virtual environment...");
|
|
95
|
-
try {
|
|
96
|
-
fs.mkdirSync(path.dirname(VENV_DIR), { recursive: true });
|
|
97
|
-
await spawnCommand(pythonCmd, ["-m", "venv", VENV_DIR]);
|
|
98
|
-
console.log("Virtual environment created");
|
|
99
|
-
} catch (venvError) {
|
|
100
|
-
console.warn(" Failed to create virtual environment during npm install");
|
|
101
|
-
console.warn(" It will be created automatically on first run");
|
|
102
|
-
return;
|
|
95
|
+
let venvPython = await reusableVenv();
|
|
96
|
+
if (!venvPython) {
|
|
97
|
+
const pythonCmd = await findPython();
|
|
98
|
+
if (!pythonCmd) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"Python 3.10+ not found. Install it from https://python.org, then run this command again.",
|
|
101
|
+
);
|
|
103
102
|
}
|
|
103
|
+
venvPython = getVenvPython();
|
|
104
|
+
console.log(`Creating Python virtual environment at ${VENV_DIR}...`);
|
|
105
|
+
fs.mkdirSync(path.dirname(VENV_DIR), { recursive: true });
|
|
106
|
+
await spawnCommand(pythonCmd, ["-m", "venv", VENV_DIR]);
|
|
107
|
+
console.log("Virtual environment created");
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
try {
|
|
@@ -110,22 +114,18 @@ async function main() {
|
|
|
110
114
|
} catch (error) {
|
|
111
115
|
// 执行安装(用 venv 的 pip)
|
|
112
116
|
console.log("📦 Installing Python dependencies...");
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
console.log("✅ Python dependencies installed successfully");
|
|
122
|
-
} catch (installError) {
|
|
123
|
-
console.warn(
|
|
124
|
-
"⚠️ Failed to install Python dependencies during npm install",
|
|
125
|
-
);
|
|
126
|
-
console.warn(" They will be installed automatically on first run");
|
|
127
|
-
}
|
|
117
|
+
await spawnCommand(
|
|
118
|
+
venvPython,
|
|
119
|
+
["-m", "pip", "install", "-r", REQUIREMENTS_FILE],
|
|
120
|
+
{
|
|
121
|
+
stdio: "inherit",
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
console.log("✅ Python dependencies installed successfully");
|
|
128
125
|
}
|
|
129
126
|
}
|
|
130
127
|
|
|
131
|
-
main().catch(
|
|
128
|
+
main().catch((error) => {
|
|
129
|
+
console.error(`❌ ${error.message}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
});
|