@youhaozhao/cninfo-mcp 1.0.7 → 1.1.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@youhaozhao/cninfo-mcp)](https://www.npmjs.com/package/@youhaozhao/cninfo-mcp)
4
4
 
5
- 通过 MCP 协议查询和下载巨潮资讯网上市公司年报的工具,适用于 Claude Desktop。
5
+ 通过 MCP 协议查询和下载巨潮资讯网上市公司年报 PDF 的工具,适用于 Claude Desktop。
6
6
 
7
7
  ## 使用方法
8
8
 
@@ -50,3 +50,4 @@
50
50
 
51
51
  爬虫逻辑基于 [gaodechen/cninfo_process](https://github.com/gaodechen/cninfo_process)。
52
52
 
53
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youhaozhao/cninfo-mcp",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
4
4
  "description": "MCP Server for querying and downloading Chinese listed companies' annual reports from CNINFO (巨潮资讯网)",
5
5
  "keywords": [
6
6
  "mcp",
@@ -50,4 +50,4 @@
50
50
  "README.md",
51
51
  "LICENSE"
52
52
  ]
53
- }
53
+ }
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """CLI bridge that exposes cninfo spider functions as JSON in/out for Node.js."""
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ import traceback
8
+ from typing import Any, Optional
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from spider import ( # noqa: E402
13
+ download_annual_reports,
14
+ download_prospectus,
15
+ query_annual_reports,
16
+ query_prospectus,
17
+ saving_path,
18
+ )
19
+
20
+
21
+ BASE_URL = "https://static.cninfo.com.cn/"
22
+
23
+
24
+ def _format_reports(reports: list[dict]) -> list[dict]:
25
+ formatted = []
26
+ for report in reports:
27
+ adj = report.get("adjunctUrl", "")
28
+ formatted.append(
29
+ {
30
+ "announcementTitle": report.get("announcementTitle", ""),
31
+ "announcementTime": report.get("announcementTime", ""),
32
+ "secCode": report.get("secCode", ""),
33
+ "secName": report.get("secName", ""),
34
+ "adjunctUrl": BASE_URL + adj if adj else "",
35
+ }
36
+ )
37
+ return formatted
38
+
39
+
40
+ def _require_stock_code(payload: dict) -> str:
41
+ stock_code = (payload.get("stock_code") or "").strip()
42
+ if not stock_code:
43
+ raise ValueError("stock_code is required")
44
+ return stock_code
45
+
46
+
47
+ def _optional_year(payload: dict) -> Optional[int]:
48
+ year = payload.get("year")
49
+ if year is None:
50
+ return None
51
+ return int(year)
52
+
53
+
54
+ def _resolve_save_path(payload: dict) -> str:
55
+ save_path = payload.get("save_path")
56
+ return save_path if save_path else saving_path
57
+
58
+
59
+ def action_query_annual_reports(payload: dict) -> dict:
60
+ stock_code = _require_stock_code(payload)
61
+ year = _optional_year(payload)
62
+ reports = query_annual_reports(stock_code, year)
63
+ suffix = f" for year {year}" if year else ""
64
+ if not reports:
65
+ return {
66
+ "success": False,
67
+ "stock_code": stock_code,
68
+ "year": year,
69
+ "count": 0,
70
+ "reports": [],
71
+ "message": f"No annual reports found for stock {stock_code}{suffix}",
72
+ }
73
+ return {
74
+ "success": True,
75
+ "stock_code": stock_code,
76
+ "year": year,
77
+ "count": len(reports),
78
+ "reports": _format_reports(reports),
79
+ "message": f"Found {len(reports)} annual report(s){suffix}",
80
+ }
81
+
82
+
83
+ def action_download_annual_reports(payload: dict) -> dict:
84
+ stock_code = _require_stock_code(payload)
85
+ year = _optional_year(payload)
86
+ output_dir = _resolve_save_path(payload)
87
+ os.makedirs(output_dir, exist_ok=True)
88
+ result = download_annual_reports(stock_code, year, save_path=output_dir)
89
+ result["stock_code"] = stock_code
90
+ result["year"] = year
91
+ return result
92
+
93
+
94
+ def action_query_prospectus(payload: dict) -> dict:
95
+ stock_code = _require_stock_code(payload)
96
+ reports = query_prospectus(stock_code)
97
+ if not reports:
98
+ return {
99
+ "success": False,
100
+ "stock_code": stock_code,
101
+ "count": 0,
102
+ "reports": [],
103
+ "message": f"No prospectus found for stock {stock_code}",
104
+ }
105
+ return {
106
+ "success": True,
107
+ "stock_code": stock_code,
108
+ "count": len(reports),
109
+ "reports": _format_reports(reports),
110
+ "message": f"Found {len(reports)} prospectus document(s)",
111
+ }
112
+
113
+
114
+ def action_download_prospectus(payload: dict) -> dict:
115
+ stock_code = _require_stock_code(payload)
116
+ output_dir = _resolve_save_path(payload)
117
+ os.makedirs(output_dir, exist_ok=True)
118
+ result = download_prospectus(stock_code, save_path=output_dir)
119
+ result["stock_code"] = stock_code
120
+ return result
121
+
122
+
123
+ ACTIONS = {
124
+ "query_annual_reports": action_query_annual_reports,
125
+ "download_annual_reports": action_download_annual_reports,
126
+ "query_prospectus": action_query_prospectus,
127
+ "download_prospectus": action_download_prospectus,
128
+ }
129
+
130
+
131
+ def main() -> int:
132
+ if len(sys.argv) < 3:
133
+ sys.stderr.write("usage: bridge.py <action> <json-payload>\n")
134
+ return 2
135
+
136
+ action = sys.argv[1]
137
+ raw_payload = sys.argv[2]
138
+
139
+ handler = ACTIONS.get(action)
140
+ if handler is None:
141
+ sys.stdout.write(json.dumps({"ok": False, "error": f"Unknown action: {action}"}))
142
+ return 1
143
+
144
+ try:
145
+ payload = json.loads(raw_payload) if raw_payload else {}
146
+ except json.JSONDecodeError as exc:
147
+ sys.stdout.write(json.dumps({"ok": False, "error": f"Invalid JSON payload: {exc}"}))
148
+ return 1
149
+
150
+ try:
151
+ result: Any = handler(payload)
152
+ sys.stdout.write(json.dumps({"ok": True, "payload": result}, ensure_ascii=False))
153
+ return 0
154
+ except Exception as exc:
155
+ sys.stderr.write(traceback.format_exc())
156
+ sys.stdout.write(json.dumps({"ok": False, "error": str(exc)}))
157
+ return 1
158
+
159
+
160
+ if __name__ == "__main__":
161
+ raise SystemExit(main())
package/python/spider.py CHANGED
@@ -18,6 +18,13 @@ _saving_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pdf")
18
18
  saving_path = _saving_path + "/"
19
19
  logger = logging.getLogger(__name__)
20
20
 
21
+ # 巨潮资讯历史公告的实际下限约为 2001 会计年度,再往前查询无数据返回。
22
+ EARLIEST_DATE = "2001-01-01"
23
+ # 接口单页最大返回条数
24
+ PAGE_SIZE = 30
25
+ # 翻页安全上限,防止异常情况下无限循环
26
+ MAX_PAGES = 100
27
+
21
28
  User_Agent = [
22
29
  "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET CLR 2.0.50727; Media Center PC 6.0)",
23
30
  "Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0; WOW64; Trident/4.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET CLR 1.0.3705; .NET CLR 1.1.4322)",
@@ -55,6 +62,26 @@ def _date_range(start_date: str) -> str:
55
62
  return f"{start_date}~{today}"
56
63
 
57
64
 
65
+ def _paginate(fetch_fn, stock):
66
+ """
67
+ 对单页查询函数翻页,汇总所有页的公告。
68
+
69
+ 巨潮接口单页最多返回 PAGE_SIZE 条,放开时间区间后历史年报会跨越多页,
70
+ 必须翻页才能取全。以“返回数量不足一页”作为终止条件,并设安全上限。
71
+ """
72
+ all_items = []
73
+ for page in range(1, MAX_PAGES + 1):
74
+ items = fetch_fn(page, stock)
75
+ if not items:
76
+ break
77
+ all_items.extend(items)
78
+ if len(items) < PAGE_SIZE: # 不足一页说明已到最后一页
79
+ break
80
+ else:
81
+ logger.warning("翻页达到上限 %s,结果可能被截断(%s)", MAX_PAGES, stock)
82
+ return all_items
83
+
84
+
58
85
  def _is_annual_report_title(
59
86
  title: str, year_filter: Optional[Union[int, str]] = None
60
87
  ) -> bool:
@@ -101,7 +128,7 @@ def szseAnnual(page, stock):
101
128
  query_path = "http://www.cninfo.com.cn/new/hisAnnouncement/query"
102
129
  query = {
103
130
  "pageNum": page, # 页码
104
- "pageSize": 30,
131
+ "pageSize": PAGE_SIZE,
105
132
  "tabName": "fulltext",
106
133
  "column": "szse", # 深交所
107
134
  "stock": "",
@@ -110,7 +137,7 @@ def szseAnnual(page, stock):
110
137
  "plate": "sz",
111
138
  "category": "category_ndbg_szsh", # 年度报告
112
139
  "trade": "",
113
- "seDate": _date_range("2020-01-01"), # 时间区间
140
+ "seDate": _date_range(EARLIEST_DATE), # 时间区间
114
141
  }
115
142
 
116
143
  namelist = requests.post(
@@ -127,7 +154,7 @@ def sseAnnual(page, stock):
127
154
  query_path = "http://www.cninfo.com.cn/new/hisAnnouncement/query"
128
155
  query = {
129
156
  "pageNum": page, # 页码
130
- "pageSize": 30,
157
+ "pageSize": PAGE_SIZE,
131
158
  "tabName": "fulltext",
132
159
  "column": "sse",
133
160
  "stock": "",
@@ -136,7 +163,7 @@ def sseAnnual(page, stock):
136
163
  "plate": "sh",
137
164
  "category": "category_ndbg_szsh", # 年度报告
138
165
  "trade": "",
139
- "seDate": _date_range("2020-01-01"), # 时间区间
166
+ "seDate": _date_range(EARLIEST_DATE), # 时间区间
140
167
  }
141
168
 
142
169
  namelist = requests.post(
@@ -153,7 +180,7 @@ def szseStock(page, stock):
153
180
  query_path = "http://www.cninfo.com.cn/new/hisAnnouncement/query"
154
181
  query = {
155
182
  "pageNum": page, # 页码
156
- "pageSize": 30,
183
+ "pageSize": PAGE_SIZE,
157
184
  "tabName": "fulltext",
158
185
  "column": "szse",
159
186
  "stock": "",
@@ -162,7 +189,7 @@ def szseStock(page, stock):
162
189
  "plate": "sz",
163
190
  "category": "",
164
191
  "trade": "",
165
- "seDate": _date_range("2015-01-01"), # 时间区间
192
+ "seDate": _date_range(EARLIEST_DATE), # 时间区间
166
193
  }
167
194
 
168
195
  namelist = requests.post(
@@ -179,7 +206,7 @@ def sseStock(page, stock):
179
206
  query_path = "http://www.cninfo.com.cn/new/hisAnnouncement/query"
180
207
  query = {
181
208
  "pageNum": page, # 页码
182
- "pageSize": 30,
209
+ "pageSize": PAGE_SIZE,
183
210
  "tabName": "fulltext",
184
211
  "column": "sse",
185
212
  "stock": "",
@@ -188,7 +215,7 @@ def sseStock(page, stock):
188
215
  "plate": "sh",
189
216
  "category": "",
190
217
  "trade": "",
191
- "seDate": _date_range("2015-01-01"), # 时间区间
218
+ "seDate": _date_range(EARLIEST_DATE), # 时间区间
192
219
  }
193
220
 
194
221
  namelist = requests.post(
@@ -271,13 +298,13 @@ def query_prospectus(stock_code):
271
298
  all_announcements = []
272
299
 
273
300
  try:
274
- announcements_sse = sseStock(1, stock_code)
301
+ announcements_sse = _paginate(sseStock, stock_code)
275
302
  all_announcements.extend(announcements_sse)
276
303
  except Exception as e:
277
304
  logger.warning("沪市招股书查询失败: %s", e)
278
305
 
279
306
  try:
280
- announcements_szse = szseStock(1, stock_code)
307
+ announcements_szse = _paginate(szseStock, stock_code)
281
308
  all_announcements.extend(announcements_szse)
282
309
  except Exception as e:
283
310
  logger.warning("深市招股书查询失败: %s", e)
@@ -323,14 +350,14 @@ def query_annual_reports(stock_code, year=None):
323
350
 
324
351
  # 查询沪市
325
352
  try:
326
- announcements_sse = sseAnnual(1, stock_code)
353
+ announcements_sse = _paginate(sseAnnual, stock_code)
327
354
  all_announcements.extend(announcements_sse)
328
355
  except Exception as e:
329
356
  logger.warning("沪市年报查询失败: %s", e)
330
357
 
331
358
  # 查询深市
332
359
  try:
333
- announcements_szse = szseAnnual(1, stock_code)
360
+ announcements_szse = _paginate(szseAnnual, stock_code)
334
361
  all_announcements.extend(announcements_szse)
335
362
  except Exception as e:
336
363
  logger.warning("深市年报查询失败: %s", e)