cnreport-mcp 0.1.0__tar.gz
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.
- cnreport_mcp-0.1.0/PKG-INFO +14 -0
- cnreport_mcp-0.1.0/README.md +268 -0
- cnreport_mcp-0.1.0/cninfo_client.py +362 -0
- cnreport_mcp-0.1.0/cnreport_database.py +263 -0
- cnreport_mcp-0.1.0/cnreport_mcp.egg-info/PKG-INFO +14 -0
- cnreport_mcp-0.1.0/cnreport_mcp.egg-info/SOURCES.txt +20 -0
- cnreport_mcp-0.1.0/cnreport_mcp.egg-info/dependency_links.txt +1 -0
- cnreport_mcp-0.1.0/cnreport_mcp.egg-info/requires.txt +9 -0
- cnreport_mcp-0.1.0/cnreport_mcp.egg-info/top_level.txt +13 -0
- cnreport_mcp-0.1.0/cnreport_models.py +92 -0
- cnreport_mcp-0.1.0/cnreport_tools.py +926 -0
- cnreport_mcp-0.1.0/financials_client.py +92 -0
- cnreport_mcp-0.1.0/indicators_client.py +1352 -0
- cnreport_mcp-0.1.0/indicators_csv_migration.py +363 -0
- cnreport_mcp-0.1.0/indicators_extractors.py +174 -0
- cnreport_mcp-0.1.0/migrate.py +24 -0
- cnreport_mcp-0.1.0/pyproject.toml +37 -0
- cnreport_mcp-0.1.0/report_cache.py +257 -0
- cnreport_mcp-0.1.0/selfcheck.py +247 -0
- cnreport_mcp-0.1.0/selfcheck_cache.py +265 -0
- cnreport_mcp-0.1.0/server.py +681 -0
- cnreport_mcp-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cnreport-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chinese annual report MCP — outline extraction, AI structured extraction, Elasticsearch store + search
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: fastmcp>=2.0
|
|
7
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
8
|
+
Requires-Dist: python-dotenv>=1.0
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: pypdf>=4.0
|
|
11
|
+
Requires-Dist: jsonschema>=4.0
|
|
12
|
+
Requires-Dist: elasticsearch>=8.0
|
|
13
|
+
Requires-Dist: akshare>=1.13
|
|
14
|
+
Requires-Dist: pandas>=2.0
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# cnreport-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for Chinese A-share annual reports. Sixteen tools across three layers:
|
|
4
|
+
|
|
5
|
+
| Layer | Tool | What it does |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Company API (edgartools-style) | `get_company` | Resolve ticker / name → company entry |
|
|
8
|
+
| | `list_filings` | List CNINFO disclosures by form / category + year |
|
|
9
|
+
| | `get_filing` | One announcement's metadata + PDF URL |
|
|
10
|
+
| | `get_financials` | Income / balance / cashflow via akshare (structured numbers) |
|
|
11
|
+
| | `get_financial_statements` | 三大报表 (`合并利润表` / `合并资产负债表` / `合并现金流量表`) as **text** from the annual-report PDF via TOC |
|
|
12
|
+
| | `get_section` | `(ticker, year, section)` → section text |
|
|
13
|
+
| | `list_report_types` | Browse the CNINFO disclosure category catalog |
|
|
14
|
+
| | `get_special_report` | Retrieve a special-type report (招股说明书 / 收购报告书 / …) by category |
|
|
15
|
+
| PDF / AI / ES | `list_outline` | Parse 目录 from a report URL or PDF path |
|
|
16
|
+
| | `extract_section` | Body text by exact title / regex / ordinal |
|
|
17
|
+
| | `ai_extract` | LLM-structured extraction over section text |
|
|
18
|
+
| | `index_records` | Bulk index extracted records into ES |
|
|
19
|
+
| | `search_reports` | BM25 + filter search with highlights |
|
|
20
|
+
| | `delete_index` | Drop `cnreport-{year}` index |
|
|
21
|
+
| Report cache | `list_cache` | List cached reports (stock / year / form / size / cached_at) |
|
|
22
|
+
| | `clear_cache` | Evict cached reports (all / by company / by company+year) |
|
|
23
|
+
| Indicators | `list_indicators` | Browse the indicator rule set (by module / query / company applicability) |
|
|
24
|
+
| | `get_indicator` | One indicator's value for (indicator, company, period) — routes to akshare / PDF section / computed ratio |
|
|
25
|
+
| | `extract_indicators` | All applicable indicators for one company/year in one pass (one fetch, batched LLM, cached bundle) |
|
|
26
|
+
| | `extract_indicators_by_position` | Indicators named in a position CSV (`docs/indicators_position.csv`) for one company/year — external/realtime ones listed in `skipped` |
|
|
27
|
+
|
|
28
|
+
## Typical chain
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# 1. Resolve company → 2. find latest annual → 3. pull MD&A → 4. LLM-extract revenue table
|
|
32
|
+
|
|
33
|
+
co = get_company("600519")
|
|
34
|
+
# {"stock_code": "600519", "name": "贵州茅台", "org_id": "gssh0600519", "exchange": "sse", ...}
|
|
35
|
+
|
|
36
|
+
filings = list_filings("600519", form="年度报告", year=2023, limit=3)
|
|
37
|
+
# [{"announcement_id": "1219730876", "pdf_url": "http://static.cninfo.com.cn/.../*.PDF", ...}]
|
|
38
|
+
|
|
39
|
+
sec = get_section("600519", year=2023, section="管理层讨论与分析")
|
|
40
|
+
# {"text": "<full MD&A body>", "pdf_url": "...", "outline_entry": {...}, ...}
|
|
41
|
+
|
|
42
|
+
records = ai_extract(
|
|
43
|
+
text=sec["text"],
|
|
44
|
+
schema={"type": "object", "properties": {
|
|
45
|
+
"segment": {"type": "string"},
|
|
46
|
+
"revenue_2023": {"type": "string"},
|
|
47
|
+
}, "required": ["segment", "revenue_2023"]},
|
|
48
|
+
)
|
|
49
|
+
# {"records": [{"segment": "茅台酒", "revenue_2023": "139,989,000,000"}, ...]}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Special report types
|
|
53
|
+
|
|
54
|
+
CNINFO exposes dozens of disclosure categories beyond the four periodic reports
|
|
55
|
+
(招股说明书, 增发, 业绩预告, 收购报告书, 股权激励, …). Browse the catalog, then list or
|
|
56
|
+
retrieve by category:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# 1. Browse what's available → 2. list filings of a category → 3. pull a section
|
|
60
|
+
|
|
61
|
+
catalog = list_report_types()
|
|
62
|
+
# {"groups": [{"name": "定期报告", "categories": [...]}, {"name": "融资", ...}, ...], "count": 26}
|
|
63
|
+
|
|
64
|
+
list_report_types(group="融资")
|
|
65
|
+
# {"group": "融资", "categories": [{name: "首发", code: "category_sf_szsh", ...}, ...], "count": 6}
|
|
66
|
+
|
|
67
|
+
filings = list_filings("600519", category="首发", limit=3) # 首发 covers 招股说明书
|
|
68
|
+
# category accepts a catalog name OR a raw category_* code; mutually exclusive with form.
|
|
69
|
+
|
|
70
|
+
sec = get_special_report("600519", category="首发", section="募集资金运用")
|
|
71
|
+
# {"text": "<section body>", "pdf_url": "...", "outline_entry": {...}, ...}
|
|
72
|
+
|
|
73
|
+
# Without `section`, the PDF is NOT downloaded — only filing metadata + pdf_url:
|
|
74
|
+
meta = get_special_report("600519", category="业绩预告")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Three major financial statements (三大报表)
|
|
78
|
+
|
|
79
|
+
`get_financials` returns akshare's structured numeric tables. `get_financial_statements`
|
|
80
|
+
pulls the three major statement sections **as text straight from the annual-report
|
|
81
|
+
PDF** (via the table of contents), so you get the report's actual narrative + tables,
|
|
82
|
+
not just the numbers:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
stmts = get_financial_statements("600519", year=2023)
|
|
86
|
+
# {
|
|
87
|
+
# "stock_code": "600519", "company_name": "贵州茅台", "year": 2023,
|
|
88
|
+
# "form": "年度报告", "pdf_url": "...", "cached": False,
|
|
89
|
+
# "statements": {
|
|
90
|
+
# "income_statement": {"title": "2、 合并利润表", "outline_entry": {...}, "char_count": 4521, "text": "..."},
|
|
91
|
+
# "balance_sheet": {"title": "1、 合并资产负债表", ...},
|
|
92
|
+
# "cashflow": {"title": "3、 合并现金流量表", ...},
|
|
93
|
+
# },
|
|
94
|
+
# "missing": [],
|
|
95
|
+
# }
|
|
96
|
+
|
|
97
|
+
# Consolidated (合并) titles are preferred; the un-prefixed titles are the
|
|
98
|
+
# fallback. Any statement not located in the TOC is listed in `missing`, with
|
|
99
|
+
# the full `available` title list so you can fall back to get_section:
|
|
100
|
+
stmts = get_financial_statements("600519", year=2023)
|
|
101
|
+
# {"missing": ["cashflow"], "available": ["第一节 ...", ...], ...}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Report cache
|
|
105
|
+
|
|
106
|
+
Every report fetch (`list_outline`, `extract_section`, `get_section`,
|
|
107
|
+
`get_special_report`, `get_financial_statements`) goes through an on-disk cache:
|
|
108
|
+
the first fetch downloads the PDF + extracts text + outline and stores them
|
|
109
|
+
under `mcp/cnreport-mcp/.cache/reports/`; subsequent fetches of the **same**
|
|
110
|
+
report read from disk — no re-download, no re-`pypdf`-parse. Files are named
|
|
111
|
+
`{stock_code}_{year}_{form}_{announcement_id}.{pdf,txt,outline.json}` (or
|
|
112
|
+
`url_<hash>.*` for raw URL fetches without provenance), so the cache folder is
|
|
113
|
+
human-browseable.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
list_cache()
|
|
117
|
+
# {"cache_dir": ".../.cache/reports", "count": 2,
|
|
118
|
+
# "entries": [{"stock_code": "600519", "year": "2023", "form": "年度报告",
|
|
119
|
+
# "announcement_id": "1219730876", "cached_at": "...", "size": 123456}, ...]}
|
|
120
|
+
|
|
121
|
+
clear_cache() # evict everything
|
|
122
|
+
clear_cache(stock_code="600519") # evict one company
|
|
123
|
+
clear_cache(stock_code="600519", year=2023) # evict one company + year
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Override the cache directory with `CNREPORT_CACHE_DIR` (see Configuration).
|
|
127
|
+
CNINFO annual reports are immutable post-publication, so there is no TTL —
|
|
128
|
+
`clear_cache` is the manual eviction path.
|
|
129
|
+
|
|
130
|
+
The category catalog is the data-driven file `cninfo_categories.json` (sourced from
|
|
131
|
+
CNINFO's own `history-notice.js` via akshare). **Adding a report type = editing that
|
|
132
|
+
JSON** — no code change. Restart the server and the new type appears in
|
|
133
|
+
`list_report_types` and is accepted by `list_filings(category=…)` / `get_special_report(…)`.
|
|
134
|
+
|
|
135
|
+
Skip the company API and pass a PDF URL directly when you already have one:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
list_outline(source="https://example.com/600519_2023.pdf")
|
|
139
|
+
extract_section(source="...", selector="管理层讨论与分析", company="贵州茅台", year=2023)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Indicators
|
|
143
|
+
|
|
144
|
+
`indicator_rules.json` is a data-driven rule set mapping each indicator to
|
|
145
|
+
**where** its value comes from and **how** it is extracted. Each rule carries:
|
|
146
|
+
applicability (`applies_to`: industry / sub-type / explicit company list), a
|
|
147
|
+
section selector chain, an extractor (`"llm"` or `"python:<name>"`), and
|
|
148
|
+
unit/period. The rule set has two sources, both edited without a code change:
|
|
149
|
+
|
|
150
|
+
- **Banking rules** — hand-authored in `indicator_rules.json` (sourced from
|
|
151
|
+
`indicators.md`); carry rich, bank-specific selector chains + applicability.
|
|
152
|
+
- **Position-CSV rules** — migrated from `docs/indicators_position.csv` (319
|
|
153
|
+
indicators across the three statements, notes, risk management, shareholders,
|
|
154
|
+
HR, profit distribution, …) by `scripts/migrate_indicators_csv.py`. Edit the
|
|
155
|
+
CSV, re-run the migration, done. Indicators marked `report_type: 实时`
|
|
156
|
+
(PE-TTM, PB, 市值, …) are classified `source_type: "external"` — they are not
|
|
157
|
+
in the report PDF and are listed in `skipped` during extraction.
|
|
158
|
+
|
|
159
|
+
`docs/indicators-methodology.md` is the rendered source-and-process companion
|
|
160
|
+
(regenerate with `python indicators_client.py --render-methodology >
|
|
161
|
+
docs/indicators-methodology.md`; coverage summary with `--render-coverage`).
|
|
162
|
+
|
|
163
|
+
Different banks disclose the same indicator under different TOC titles, and some
|
|
164
|
+
indicators only appear for certain bank types — so the engine profiles each
|
|
165
|
+
company (国有大行 / 股份制 / 城商行 / 农商行; non-banks profile without a
|
|
166
|
+
sub_type and still receive universal `industry: "*"` rules), filters the rule
|
|
167
|
+
set per company, and walks the selector chain (company-specific override →
|
|
168
|
+
default → fallback, with normalized matching for descriptive section labels).
|
|
169
|
+
Derived ratios are computed locally, never by the LLM.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
# 1. Preview which indicators apply to a company → 2. pull one → 3. pull all → 4. by CSV
|
|
173
|
+
list_indicators(company="工商银行") # rules applicable to 工商银行 + its profile
|
|
174
|
+
get_indicator("资本充足率", "工商银行", 2023) # one value, routed per the rule
|
|
175
|
+
extract_indicators("工商银行", 2023) # all applicable indicators, one PDF fetch
|
|
176
|
+
extract_indicators("工商银行", 2023,
|
|
177
|
+
indicators=["资本充足率","不良率","资产负债率"]) # subset
|
|
178
|
+
extract_indicators("工商银行", 2023, extractor_mode="python") # LLM-free where possible
|
|
179
|
+
extract_indicators_by_position("工商银行", 2023) # all indicators named in the position CSV
|
|
180
|
+
extract_indicators_by_position("工商银行", 2023,
|
|
181
|
+
csv_path="docs/indicators_position.csv", # default
|
|
182
|
+
indicators=["资产总计","负债合计"], # subset (intersect CSV)
|
|
183
|
+
extractor="python") # LLM-free where possible
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Each periodic form is selectable via `form` (default `年度报告`):
|
|
187
|
+
`半年度报告` / `第一季度报告` / `第三季度报告`. Indicators whose `report_type`
|
|
188
|
+
doesn't include the form are placed in `skipped` (e.g. `分红金额` — annual only —
|
|
189
|
+
is skipped for `第一季度报告`), and quarterly reports fall back to a body-text
|
|
190
|
+
statement search when the outline lacks statement titles.
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
extract_indicators_by_position("工商银行", 2023, form="第一季度报告")
|
|
194
|
+
extract_indicators("贵州茅台", 2023, form="半年度报告")
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Standalone CLIs run the same pipelines offline — point `--rules` (or `--csv`)
|
|
198
|
+
at a different file per company batch to process different companies with
|
|
199
|
+
different rule sets:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
# hand-authored + CSV-merged rule set (full engine)
|
|
203
|
+
python scripts/extract_indicators.py 601398 --year 2023 \
|
|
204
|
+
[--rules indicator_rules.json] [--extractor auto|llm|python] \
|
|
205
|
+
[--indicators 资本充足率,不良率] [--out-dir ./out]
|
|
206
|
+
|
|
207
|
+
# position-CSV-driven: the indicators named in a CSV for one company/year/form
|
|
208
|
+
python scripts/extract_indicators_by_position.py 601398 --year 2023 \
|
|
209
|
+
[--csv docs/indicators_position.csv] [--extractor auto|llm|python] \
|
|
210
|
+
[--form 年度报告|半年度报告|第一季度报告|第三季度报告] \
|
|
211
|
+
[--indicators 资产总计,负债合计] [--out-dir ./out]
|
|
212
|
+
# non-default form is appended to the output stem (e.g. 601398_2023_第一季度报告.json)
|
|
213
|
+
|
|
214
|
+
# sync docs/indicators_position.csv → indicator_rules.json (idempotent)
|
|
215
|
+
python scripts/migrate_indicators_csv.py [--check]
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Adding a new Python extractor: write `(section_text, rule, period) -> {value, unit, note}`
|
|
219
|
+
in `indicators_extractors.py`, call `register("name", fn)`, then set
|
|
220
|
+
`"extractor": "python:name"` on the rule. See `docs/indicators-methodology.md`.
|
|
221
|
+
|
|
222
|
+
## Setup
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
uv sync # installs akshare, pypdf, fastmcp, ...
|
|
226
|
+
uv run python server.py # FastMCP over stdio
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Self-check (no network):
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
uv run python selfcheck.py # DB + outline + company API + special reports
|
|
233
|
+
uv run python selfcheck_cache.py # report cache + three-statements extraction
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Tests (offline; bypasses the user's broken logfire pytest plugin):
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
uv run --with pytest python -m pytest test_cnreport.py -v -p no:logfire
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Configuration
|
|
243
|
+
|
|
244
|
+
CNINFO and akshare are **keyless**. The other tools need env vars in root `.env`:
|
|
245
|
+
|
|
246
|
+
| Var | Used by | Required? |
|
|
247
|
+
|---|---|---|
|
|
248
|
+
| `LLM_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL` | `ai_extract` | Yes for AI |
|
|
249
|
+
| `ES_URL` (+ optional `ES_API_KEY` or `ES_USERNAME`/`ES_PASSWORD`) | `index_records`, `search_reports`, `delete_index` | Yes for ES |
|
|
250
|
+
| `DAAS_DATABASE_URL` | provenance writes for `extract_section` | Defaults to `mcp/daas.db` |
|
|
251
|
+
| `CNREPORT_CACHE_DIR` | report cache for all fetch paths | Defaults to `mcp/cnreport-mcp/.cache/reports/` |
|
|
252
|
+
|
|
253
|
+
## Architecture
|
|
254
|
+
|
|
255
|
+
- `cninfo_client.py` — single network entry point for CNINFO (`lookup_company`, `query_announcements`, `get_announcement`, `pdf_url`). Three keyless endpoints. Also loads the data-driven category registry (`load_categories`, `resolve_category`).
|
|
256
|
+
- `cninfo_categories.json` — CNINFO disclosure category catalog (name → code, grouped). Source of truth for `list_report_types` and the `category` parameter; extensible by JSON edit.
|
|
257
|
+
- `financials_client.py` — lazy-imports akshare; server boots even without it (`get_financials` returns an `{error}` instead).
|
|
258
|
+
- `cnreport_tools.py` — pure helpers + the company-API wrappers (`list_report_types`, `get_special_report`, `get_financial_statements`). Errors return `{"error": ...}`, never raise.
|
|
259
|
+
- `report_cache.py` — on-disk cache wrapping `fetch_source_with_bytes`; every fetch path (`list_outline`, `extract_section`, `get_section`, `get_special_report`, `get_financial_statements`) checks the cache before downloading and stores the PDF + extracted text + outline on a miss. `list_cache` / `clear_cache` manage it. Also persists the extracted indicator bundle (`{stem}.indicators.json`) used by `extract_indicators`.
|
|
260
|
+
- `indicators_client.py` — indicator rules engine: loads `indicator_rules.json`, profiles a company, filters applicable rules, routes each to akshare / report-section / computed / external, dispatches LLM or Python extractors, evaluates ratio formulas, and renders the methodology + coverage docs. Also exposes `extract_indicators_by_position` (CSV-driven).
|
|
261
|
+
- `indicators_extractors.py` — pluggable Python extractor registry (`register` / `get`) + starter extractors (`regex_amount`, `percent_value`, `table_row`, `headcount`). Add an extractor = add a function + `register`.
|
|
262
|
+
- `indicator_rules.json` — data-driven indicator rule set (name → applicability + section selector chain + extractor + unit + source_type). Hand-authored banking rules + CSV-migrated rules from `docs/indicators_position.csv`. Source of truth for `list_indicators` / `get_indicator` / `extract_indicators` / `extract_indicators_by_position`; extensible by JSON edit.
|
|
263
|
+
- `indicators_csv_migration.py` — converts `docs/indicators_position.csv` into `indicator_rules.json` rules (idempotent; appends CSV-only rules, annotates overlaps, preserves hand-authored rules). Run via `scripts/migrate_indicators_csv.py`.
|
|
264
|
+
- `docs/indicators_position.csv` — maintained human-editable catalog of indicators → section / report_type. The source for CSV-migrated rules.
|
|
265
|
+
- `scripts/extract_indicators.py` — standalone CLI that runs the engine for a company or list, with a selectable rule file (`--rules`) and extractor mode (`--extractor`), writing JSON + CSV.
|
|
266
|
+
- `scripts/extract_indicators_by_position.py` — standalone CLI that extracts the indicators named in a position CSV (`--csv`) for a company/year, writing JSON + CSV (with a `status` column and `skipped` rows for external indicators).
|
|
267
|
+
- `scripts/migrate_indicators_csv.py` — CLI wrapper around `indicators_csv_migration.migrate` (`--check` for a dry-run diff).
|
|
268
|
+
- `server.py` — `@app.tool` registrations; thin pass-through to `cnreport_tools` / `report_cache` / `indicators_client`.
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""CNINFO (巨潮资讯) HTTP JSON-API client.
|
|
2
|
+
|
|
3
|
+
Single network entry point for the cnreport-mcp company API. Wraps three
|
|
4
|
+
public, keyless endpoints CNINFO uses to power its own SPA:
|
|
5
|
+
|
|
6
|
+
/new/information/topSearch/query — company lookup by ticker or name
|
|
7
|
+
/new/hisAnnouncement/query — list announcements (filings)
|
|
8
|
+
static.cninfo.com.cn/<adjunctUrl> — PDF download URL
|
|
9
|
+
|
|
10
|
+
Form categories (CNINFO codes) map from user-facing Chinese form names:
|
|
11
|
+
年度报告 → category_ndbg_szsh
|
|
12
|
+
半年度报告 → category_bndbg_szsh
|
|
13
|
+
第一季度报告 → category_yjdbg_szsh
|
|
14
|
+
第三季度报告 → category_sjdbg_szsh
|
|
15
|
+
|
|
16
|
+
Form categories (CNINFO codes) live in the data-driven registry
|
|
17
|
+
`cninfo_categories.json` (loaded via `load_categories`); the four periodic
|
|
18
|
+
forms are exposed as the `_FORM_CATEGORIES` dict, derived from that registry so
|
|
19
|
+
the existing `form` path resolves to identical codes. `resolve_category`
|
|
20
|
+
accepts either a Chinese name from the catalog or a raw `category_*` code.
|
|
21
|
+
|
|
22
|
+
Used by cnreport_tools.{get_company,list_filings,get_filing,get_section,
|
|
23
|
+
list_report_types,get_special_report}. Mock-friendly: tests monkeypatch
|
|
24
|
+
`_post_json` and `_client`, not httpx itself.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Optional
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
_BASE = "http://www.cninfo.com.cn"
|
|
35
|
+
_PDF_CDN = "http://static.cninfo.com.cn/"
|
|
36
|
+
_TIMEOUT = 30.0
|
|
37
|
+
_UA = (
|
|
38
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
39
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# ── data-driven CNINFO category registry ───────────────────────────
|
|
43
|
+
# Source of truth: cninfo_categories.json (sourced from CNINFO's own
|
|
44
|
+
# history-notice.js via akshare). Adding a report type = editing the JSON.
|
|
45
|
+
_REGISTRY_PATH = Path(__file__).resolve().parent / "cninfo_categories.json"
|
|
46
|
+
_CATEGORIES_CACHE: Optional[dict] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_categories() -> dict:
|
|
50
|
+
"""Load and cache the CNINFO category registry (cninfo_categories.json).
|
|
51
|
+
|
|
52
|
+
Returns `{_source, _todo, groups: [{name, categories: [{name, code, description}]}]}`.
|
|
53
|
+
Raises `FileNotFoundError` if the registry file is missing, `json.JSONDecodeError`
|
|
54
|
+
if malformed — both naming the file, so a misconfigured package fails loudly at
|
|
55
|
+
boot rather than silently degrading to an empty catalog.
|
|
56
|
+
"""
|
|
57
|
+
global _CATEGORIES_CACHE
|
|
58
|
+
if _CATEGORIES_CACHE is not None:
|
|
59
|
+
return _CATEGORIES_CACHE
|
|
60
|
+
if not _REGISTRY_PATH.exists():
|
|
61
|
+
raise FileNotFoundError(f"CNINFO category registry not found: {_REGISTRY_PATH}")
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(_REGISTRY_PATH.read_text(encoding="utf-8"))
|
|
64
|
+
except json.JSONDecodeError as e:
|
|
65
|
+
raise json.JSONDecodeError(
|
|
66
|
+
f"malformed CNINFO category registry {_REGISTRY_PATH}: {e.msg}",
|
|
67
|
+
e.doc,
|
|
68
|
+
e.pos,
|
|
69
|
+
) from None
|
|
70
|
+
_CATEGORIES_CACHE = data
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_category(category: Optional[str]) -> Optional[str]:
|
|
75
|
+
"""Resolve a CNINFO category name or code to a canonical `category_*` code.
|
|
76
|
+
|
|
77
|
+
Accepts either a Chinese name from the catalog (年度报告, 招股说明书, …) or a
|
|
78
|
+
raw CNINFO code (category_ndbg_szsh). A raw `category_`-prefixed code is
|
|
79
|
+
passed through unchanged (escape hatch for codes not yet in the registry).
|
|
80
|
+
Returns `None` when the input is neither a known name nor a `category_` code.
|
|
81
|
+
"""
|
|
82
|
+
if not category:
|
|
83
|
+
return None
|
|
84
|
+
if category.startswith("category_"):
|
|
85
|
+
return category
|
|
86
|
+
for group in load_categories().get("groups", []):
|
|
87
|
+
for cat in group.get("categories", []):
|
|
88
|
+
if cat.get("name") == category:
|
|
89
|
+
return cat.get("code")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _build_form_categories() -> dict[str, str]:
|
|
94
|
+
"""Derive the periodic-form name→code map from the registry.
|
|
95
|
+
|
|
96
|
+
Covers the four periodic forms so the existing `form` path resolves to
|
|
97
|
+
identical codes without a hardcoded dict. If the registry's 定期报告 group is
|
|
98
|
+
ever renamed/missing, falls back to the historical four codes so the server
|
|
99
|
+
still boots and existing `form` calls keep working.
|
|
100
|
+
"""
|
|
101
|
+
fallback = {
|
|
102
|
+
"年度报告": "category_ndbg_szsh",
|
|
103
|
+
"半年度报告": "category_bndbg_szsh",
|
|
104
|
+
"第一季度报告": "category_yjdbg_szsh",
|
|
105
|
+
"第三季度报告": "category_sjdbg_szsh",
|
|
106
|
+
}
|
|
107
|
+
try:
|
|
108
|
+
for group in load_categories().get("groups", []):
|
|
109
|
+
if group.get("name") == "定期报告":
|
|
110
|
+
return {cat["name"]: cat["code"] for cat in group.get("categories", [])}
|
|
111
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
112
|
+
pass
|
|
113
|
+
return fallback
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Map user-facing Chinese form names → CNINFO category codes (registry-derived).
|
|
117
|
+
_FORM_CATEGORIES = _build_form_categories()
|
|
118
|
+
|
|
119
|
+
# Map CNINFO `category` strings (returned by topSearch) to exchange tag.
|
|
120
|
+
_EXCHANGE_BY_PREFIX = {"sh": "sse", "sz": "szse", "bj": "bse"}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _client() -> httpx.Client:
|
|
124
|
+
"""Build a per-call httpx client. Tests patch this to inject a mock."""
|
|
125
|
+
return httpx.Client(
|
|
126
|
+
base_url=_BASE,
|
|
127
|
+
timeout=_TIMEOUT,
|
|
128
|
+
headers={
|
|
129
|
+
"User-Agent": _UA,
|
|
130
|
+
"Accept": "application/json, text/plain, */*",
|
|
131
|
+
"Origin": _BASE,
|
|
132
|
+
"Referer": _BASE + "/",
|
|
133
|
+
},
|
|
134
|
+
follow_redirects=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _post_json(path: str, data: dict[str, Any]) -> Any:
|
|
139
|
+
"""POST form-encoded data and return parsed JSON. Errors raise."""
|
|
140
|
+
with _client() as c:
|
|
141
|
+
resp = c.post(path, data=data)
|
|
142
|
+
resp.raise_for_status()
|
|
143
|
+
return resp.json()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _exchange_from_org_id(org_id: str) -> str:
|
|
147
|
+
"""gssh0600519 → sse; gssz0000001 → szse; gsbj0830799 → bse."""
|
|
148
|
+
if not org_id or len(org_id) < 4:
|
|
149
|
+
return ""
|
|
150
|
+
tag = org_id[2:4] # 'sh' / 'sz' / 'bj'
|
|
151
|
+
return _EXCHANGE_BY_PREFIX.get(tag, "")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _column_from_org_id(org_id: str) -> str:
|
|
155
|
+
"""The `column` parameter hisAnnouncement expects: sse/szse/bj."""
|
|
156
|
+
ex = _exchange_from_org_id(org_id)
|
|
157
|
+
return "bj" if ex == "bse" else ex # bse → 'bj' for the API
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def pdf_url(adjunct_url: str) -> str:
|
|
161
|
+
"""Build the static-cdn PDF URL CNINFO uses for filing downloads.
|
|
162
|
+
|
|
163
|
+
`adjunct_url` is the path CNINFO returns in announcement rows
|
|
164
|
+
(e.g. `finalpage/2024-04-02/1219730876.PDF`).
|
|
165
|
+
"""
|
|
166
|
+
if not adjunct_url:
|
|
167
|
+
return ""
|
|
168
|
+
if adjunct_url.startswith("http"):
|
|
169
|
+
return adjunct_url
|
|
170
|
+
return _PDF_CDN + adjunct_url.lstrip("/")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def lookup_company(ticker_or_name: str) -> Optional[dict]:
|
|
174
|
+
"""Resolve a ticker or name fragment to a CNINFO company entry.
|
|
175
|
+
|
|
176
|
+
Returns `{stock_code, name, name_en, org_id, exchange, category}` or
|
|
177
|
+
`None` when no match is found. The match is the first row CNINFO's
|
|
178
|
+
own search returns — same ranking the cninfo.com.cn UI shows.
|
|
179
|
+
"""
|
|
180
|
+
if not ticker_or_name:
|
|
181
|
+
return None
|
|
182
|
+
payload = _post_json(
|
|
183
|
+
"/new/information/topSearch/query",
|
|
184
|
+
{"keyWord": ticker_or_name, "maxNum": "10"},
|
|
185
|
+
)
|
|
186
|
+
if not isinstance(payload, list) or not payload:
|
|
187
|
+
return None
|
|
188
|
+
# Prefer an exact stock_code match if the input is 6 digits.
|
|
189
|
+
if ticker_or_name.isdigit() and len(ticker_or_name) == 6:
|
|
190
|
+
for row in payload:
|
|
191
|
+
if str(row.get("code")) == ticker_or_name:
|
|
192
|
+
return _map_company_row(row)
|
|
193
|
+
return _map_company_row(payload[0])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _map_company_row(row: dict) -> dict:
|
|
197
|
+
org_id = row.get("orgId") or ""
|
|
198
|
+
return {
|
|
199
|
+
"stock_code": row.get("code") or "",
|
|
200
|
+
"name": row.get("zwjc") or row.get("pinyin") or "",
|
|
201
|
+
"name_en": row.get("yjc") or "",
|
|
202
|
+
"org_id": org_id,
|
|
203
|
+
"exchange": _exchange_from_org_id(org_id),
|
|
204
|
+
"category": row.get("category") or "",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def query_announcements(
|
|
209
|
+
stock_code: str,
|
|
210
|
+
org_id: str,
|
|
211
|
+
*,
|
|
212
|
+
form: Optional[str] = None,
|
|
213
|
+
category: Optional[str] = None,
|
|
214
|
+
year: Optional[int] = None,
|
|
215
|
+
limit: int = 20,
|
|
216
|
+
) -> list[dict]:
|
|
217
|
+
"""Query CNINFO disclosures for a company, filtered by form/category and year.
|
|
218
|
+
|
|
219
|
+
`form` accepts either a Chinese title (`年度报告`) or a raw category
|
|
220
|
+
code (`category_ndbg_szsh`); anything else is sent as a free-text
|
|
221
|
+
title filter (post-hoc, since CNINFO has no title-search field). The
|
|
222
|
+
server-side `category` filter is only applied for the four periodic forms.
|
|
223
|
+
`category` accepts any CNINFO category code or Chinese name from the
|
|
224
|
+
catalog (`招股说明书`, `增发`, …) and is sent verbatim as the server-side
|
|
225
|
+
`category` filter. `form` and `category` are mutually exclusive.
|
|
226
|
+
`year` filters by announcement publication year window — note CN
|
|
227
|
+
annual reports for FY{year} are typically published in {year+1}.
|
|
228
|
+
"""
|
|
229
|
+
if form and category:
|
|
230
|
+
raise ValueError("specify either form or category, not both")
|
|
231
|
+
|
|
232
|
+
page_size = max(1, min(int(limit) * 2 + 10, 50)) # over-fetch then post-filter
|
|
233
|
+
|
|
234
|
+
data: dict[str, Any] = {
|
|
235
|
+
"stock": f"{stock_code},{org_id}" if org_id else stock_code,
|
|
236
|
+
"tabName": "fulltext",
|
|
237
|
+
"pageSize": str(page_size),
|
|
238
|
+
"pageNum": "1",
|
|
239
|
+
"column": _column_from_org_id(org_id) or "sse",
|
|
240
|
+
"plate": "",
|
|
241
|
+
"searchkey": "",
|
|
242
|
+
"secid": "",
|
|
243
|
+
"sortName": "",
|
|
244
|
+
"sortType": "",
|
|
245
|
+
"isHLtitle": "true",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Resolve the server-side `category` filter.
|
|
249
|
+
if category is not None:
|
|
250
|
+
category_code = resolve_category(category)
|
|
251
|
+
if not category_code:
|
|
252
|
+
raise ValueError(f"unknown CNINFO category: {category!r}")
|
|
253
|
+
data["category"] = category_code
|
|
254
|
+
else:
|
|
255
|
+
category_code = _FORM_CATEGORIES.get(form or "", form or "")
|
|
256
|
+
if category_code and category_code in _FORM_CATEGORIES.values():
|
|
257
|
+
data["category"] = category_code
|
|
258
|
+
|
|
259
|
+
if year is not None:
|
|
260
|
+
# CN annual reports for FY{year} publish in {year+1}; widen window
|
|
261
|
+
# so 半年报/季报 also catch their natural publish windows.
|
|
262
|
+
data["seDate"] = f"{year}-01-01~{year + 1}-12-31"
|
|
263
|
+
|
|
264
|
+
payload = _post_json("/new/hisAnnouncement/query", data)
|
|
265
|
+
rows = payload.get("announcements") or [] if isinstance(payload, dict) else []
|
|
266
|
+
if rows is None:
|
|
267
|
+
rows = []
|
|
268
|
+
|
|
269
|
+
results: list[dict] = []
|
|
270
|
+
for row in rows:
|
|
271
|
+
mapped = _map_announcement_row(row)
|
|
272
|
+
# Post-hoc form filter (form path only): belt-and-braces over CNINFO's
|
|
273
|
+
# server-side `category` filter. Match by mapped["form"] when it's a
|
|
274
|
+
# known Chinese name; fall back to title substring otherwise.
|
|
275
|
+
if form:
|
|
276
|
+
row_form = mapped.get("form") or ""
|
|
277
|
+
row_title = mapped.get("title") or ""
|
|
278
|
+
if form in _FORM_CATEGORIES:
|
|
279
|
+
if row_form != form:
|
|
280
|
+
continue
|
|
281
|
+
else:
|
|
282
|
+
if form not in row_form and form not in row_title:
|
|
283
|
+
continue
|
|
284
|
+
# Post-hoc year filter: periodic-report titles embed the fiscal year
|
|
285
|
+
# (e.g. "2023年年度报告", "2023半年度报告"). Most titles use "{year}年",
|
|
286
|
+
# but some banks omit the 年 between year and form ("2023半年度报告"),
|
|
287
|
+
# so match the bare year string. The `category` path is skipped —
|
|
288
|
+
# special-report titles rarely embed the year; rely on seDate there.
|
|
289
|
+
if (
|
|
290
|
+
year is not None
|
|
291
|
+
and category is None
|
|
292
|
+
and str(year) not in (mapped.get("title") or "")
|
|
293
|
+
):
|
|
294
|
+
continue
|
|
295
|
+
results.append(mapped)
|
|
296
|
+
if len(results) >= limit:
|
|
297
|
+
break
|
|
298
|
+
return results
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _form_from_title(title: str) -> str:
|
|
302
|
+
"""Derive a human-readable Chinese form name from the announcement title.
|
|
303
|
+
|
|
304
|
+
CNINFO's `announcementType` field is an opaque, pipe-delimited code
|
|
305
|
+
string (e.g. `01010503||010113||010301`) — not directly usable. The
|
|
306
|
+
title, however, reliably contains the form name as a substring.
|
|
307
|
+
Checks longest forms first so "半年度报告" wins over "年度报告".
|
|
308
|
+
"""
|
|
309
|
+
if not title:
|
|
310
|
+
return ""
|
|
311
|
+
# Order matters: longer forms first.
|
|
312
|
+
for form in ("半年度报告", "第一季度报告", "第三季度报告", "年度报告"):
|
|
313
|
+
if form in title:
|
|
314
|
+
return form
|
|
315
|
+
return ""
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _map_announcement_row(row: dict) -> dict:
|
|
319
|
+
published_ms = row.get("announcementTime") or 0
|
|
320
|
+
try:
|
|
321
|
+
from datetime import datetime, timezone
|
|
322
|
+
|
|
323
|
+
published = (
|
|
324
|
+
datetime.fromtimestamp(int(published_ms) / 1000, tz=timezone.utc)
|
|
325
|
+
.strftime("%Y-%m-%d")
|
|
326
|
+
if published_ms
|
|
327
|
+
else ""
|
|
328
|
+
)
|
|
329
|
+
except Exception:
|
|
330
|
+
published = ""
|
|
331
|
+
title = row.get("announcementTitle") or ""
|
|
332
|
+
return {
|
|
333
|
+
"announcement_id": str(row.get("announcementId") or ""),
|
|
334
|
+
"title": title,
|
|
335
|
+
"form": _form_from_title(title),
|
|
336
|
+
"published": published,
|
|
337
|
+
"pdf_url": pdf_url(row.get("adjunctUrl") or ""),
|
|
338
|
+
"stock_code": row.get("secCode") or "",
|
|
339
|
+
"company_name": row.get("secName") or "",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def get_announcement(
|
|
344
|
+
announcement_id: str,
|
|
345
|
+
*,
|
|
346
|
+
stock_code: Optional[str] = None,
|
|
347
|
+
org_id: Optional[str] = None,
|
|
348
|
+
) -> Optional[dict]:
|
|
349
|
+
"""Fetch a single announcement by id, narrowed by stock when supplied.
|
|
350
|
+
|
|
351
|
+
CNINFO does not expose a `/announcement/<id>` endpoint, so this falls
|
|
352
|
+
back to the listing endpoint over the company's full disclosure
|
|
353
|
+
history and picks the matching row. With stock_code+org_id supplied,
|
|
354
|
+
this is cheap; without, we must enumerate.
|
|
355
|
+
"""
|
|
356
|
+
if stock_code and org_id:
|
|
357
|
+
rows = query_announcements(stock_code, org_id, limit=200)
|
|
358
|
+
for r in rows:
|
|
359
|
+
if r["announcement_id"] == announcement_id:
|
|
360
|
+
return r
|
|
361
|
+
# Fallback: nothing to narrow by → bail.
|
|
362
|
+
return None
|