@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 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+(Python 依赖会自动安装;需要 MCP Python SDK v2,旧环境会在下次启动时自动升级)
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
- * 自动检测 Python 并安装依赖,然后启动 Python MCP 服务器。
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 PYTHON_REQUIREMENTS = path.join(
16
+ const INSTALL_SCRIPT = path.join(
16
17
  __dirname,
17
18
  "..",
18
- "python",
19
- "requirements.txt",
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
- const VENV_DIR = path.join(os.homedir(), ".cninfo-mcp", "venv");
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
- // 查找可用的系统 Python 可执行文件(仅用于创建 venv)
34
- async function findPython() {
35
- const pythonCommands = [
36
- "python3",
37
- "python",
38
- "python3.12",
39
- "python3.11",
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 ensureVenv(systemPythonCmd) {
62
- const venvPython = getVenvPython();
63
- if (fs.existsSync(venvPython)) {
64
- return venvPython;
65
- }
66
-
67
- console.error("Creating Python virtual environment...");
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
- // 检查并安装 Python 依赖(使用 venv 中的 python)
80
- async function ensureDependencies(venvPython) {
81
- const requirementsPath = PYTHON_REQUIREMENTS;
82
-
83
- if (!fs.existsSync(requirementsPath)) {
84
- console.error("Error: requirements.txt not found at", requirementsPath);
85
- process.exit(1);
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 (error) {
92
- // 未安装,执行安装
93
- console.error("Installing Python dependencies...");
94
- try {
95
- await spawnAsync(
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: process.platform === "win32",
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 systemPython = await findPython();
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: process.platform === "win32",
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": "1.4.1",
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/test_spider.py -q"
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"
@@ -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
- @mcp.tool()
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: Boolean indicating if the query was successful
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": False,
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
- @mcp.tool()
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 to save files (e.g., '/Users/me/reports'). Defaults to pdf/ in package 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: Boolean indicating if download was successful
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 or saving_path
140
- os.makedirs(output_dir, exist_ok=True)
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
- "path": save_path or saving_path,
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
- reports = query_reports(stock_code, "annual")
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 = "http://www.cninfo.com.cn/new/information/topSearch/query"
31
+ TOP_SEARCH_URL = "https://www.cninfo.com.cn/new/information/topSearch/query"
29
32
  # 公告查询接口
30
- QUERY_URL = "http://www.cninfo.com.cn/new/hisAnnouncement/query"
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": "http://www.cninfo.com.cn",
148
- "Referer": "http://www.cninfo.com.cn/new/commonUrl?url=disclosure/list/notice",
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": download_path + adj if adj else "",
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 and "announcements" in result and result["announcements"]:
233
- return result["announcements"]
234
- return []
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
- try:
257
- hits = _post_json(TOP_SEARCH_URL, {"keyWord": stock_code, "maxNum": 10})
258
- except Exception as e:
259
- logger.warning("orgId 解析失败(%s): %s", stock_code, e)
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
- items = fetch_fn(page, stock)
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
- logger.warning("翻页达到上限 %s,结果可能被截断(%s)", MAX_PAGES, stock)
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
- matched = next((kw for kw in spec["keywords"] if kw in compact_title), None)
318
- if matched is None:
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
- # “说明”被 COMMON_EXCLUDE_KEYWORDS 误伤(参见 #2)。
322
- remainder = compact_title.replace(matched, "")
323
- return not any(kw in remainder for kw in COMMON_EXCLUDE_KEYWORDS)
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
- """下载公告列表中的 PDF 文件。"""
452
- if single_page is None:
453
- return 0
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
- for i in single_page:
460
- title = i.get("announcementTitle", "")
461
- if normalized_type:
462
- should_download = _is_report_title(
463
- title, normalized_type, year_filter=year_filter
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
- else:
466
- should_download = any(
467
- _is_report_title(title, candidate, year_filter=year_filter)
468
- for candidate in REPORT_TYPE_SPECS
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
- if not should_download:
472
- continue
473
-
474
- adjunct_url = i.get("adjunctUrl", "")
475
- if not adjunct_url:
476
- logger.warning("公告缺少 adjunctUrl,跳过:%s", title)
477
- continue
478
-
479
- download = download_path + adjunct_url
480
- name = _sanitize_filename(
481
- i.get("secCode", "") + "_" + i.get("secName", "") + "_" + title + ".pdf"
482
- )
483
- file_path = output_dir + name
484
-
485
- logger.info("↓ %s", name)
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
- requested_code = re.sub(r"\D", "", str(stock_code or ""))
506
- allowed_sec_codes = {requested_code} if requested_code else set()
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
- label = REPORT_TYPE_SPECS[normalized_type]["label"]
579
- announcements = query_reports(stock_code, normalized_type, year)
580
-
581
- if not announcements:
582
- return {
583
- "success": False,
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
- count = Download(
591
- announcements,
592
- report_type=normalized_type,
593
- year_filter=year,
594
- save_path=output_dir,
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
- downloaded = count or 0
598
- year_suffix = f"({year} 年)" if year else ""
599
- return {
600
- "success": downloaded > 0,
601
- "message": f"已下载 {stock_code} {label}{year_suffix},共 {downloaded} 个文件"
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
- * npm install 后自动安装 Python 依赖
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
- const VENV_DIR = path.join(os.homedir(), ".cninfo-mcp", "venv");
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
- try {
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
- shell: process.platform === "win32",
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
- console.log(
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
- const pythonCmd = await findPython();
83
- if (!pythonCmd) {
84
- console.warn(
85
- "⚠️ Python not found. Python dependencies will be installed on first run.",
86
- );
87
- console.warn(" Please install Python 3.10+ from https://python.org");
88
- return;
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
- try {
114
- await spawnCommand(
115
- venvPython,
116
- ["-m", "pip", "install", "-r", REQUIREMENTS_FILE],
117
- {
118
- stdio: "inherit",
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(console.error);
128
+ main().catch((error) => {
129
+ console.error(`❌ ${error.message}`);
130
+ process.exit(1);
131
+ });