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.
@@ -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