edinet-util-mcp-server 0.1.0.dev0__py3-none-any.whl

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,12 @@
1
+ """EDINET API v2 access for agents: list filings and read them as Markdown."""
2
+
3
+
4
+ def main() -> None:
5
+ # Imported here, not at the top: the snapshot builder imports this package's
6
+ # API client and figures without needing the MCP server.
7
+ from .server import main as run
8
+
9
+ run()
10
+
11
+
12
+ __all__ = ["main"]
@@ -0,0 +1,7 @@
1
+ """`python -m edinet_util_mcp_server`, which is how a client that installed the
2
+ wheel starts the server: no console script on PATH, no absolute path to bake into
3
+ a configuration file."""
4
+
5
+ from .server import main
6
+
7
+ main()
@@ -0,0 +1,178 @@
1
+ """The EDINET API v2, as this package uses it.
2
+
3
+ Two endpoints, both GETs with the key in the `Subscription-Key` parameter:
4
+ - `GET /documents.json?date=YYYY-MM-DD&type=2` lists every filing made that day.
5
+ There is no search by company or keyword.
6
+ - `GET /documents/{docID}?type=N` downloads one filing: 1 = body and XBRL (ZIP),
7
+ 2 = PDF, 3 = attachments (ZIP), 4 = English (ZIP), 5 = XBRL converted to CSV (ZIP).
8
+
9
+ Errors often come back as JSON with HTTP 200, so a download is checked by its
10
+ Content-Type, not only its status.
11
+
12
+ There is no published rate limit, but the terms forbid heavy access in a short
13
+ time. Requests go one at a time, 1 second apart, widening to 2 seconds after a
14
+ 429 or server error, with exponential back-off while 429s repeat (design D16).
15
+
16
+ Source: EDINET API仕様書 Version 2 (updated 2026-06-03).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import time
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any, Self
27
+
28
+ import httpx
29
+
30
+ BASE_URL = "https://api.edinet-fsa.go.jp/api/v2"
31
+ CODELIST_URL = "https://disclosure2dl.edinet-fsa.go.jp/searchdocument/codelist/Edinetcode.zip"
32
+ DOCUMENT_PAGE_URL = "https://disclosure2.edinet-fsa.go.jp/WZEK0040.aspx?{doc_id}"
33
+
34
+ DOC_TYPE_ZIP = 1
35
+ DOC_TYPE_PDF = 2
36
+ DOC_TYPE_ATTACHMENTS = 3
37
+ DOC_TYPE_ENGLISH = 4
38
+ DOC_TYPE_CSV = 5
39
+
40
+ NORMAL_SPACING = 1.0
41
+ WIDE_SPACING = 2.0
42
+ MAX_BACKOFF = 120.0
43
+ MAX_ATTEMPTS = 6
44
+
45
+
46
+ class EdinetError(RuntimeError):
47
+ """The API answered, but not with what was asked for."""
48
+
49
+
50
+ class DocumentUnavailable(EdinetError):
51
+ """The document is not (or no longer) available, e.g. withdrawn or not disclosed."""
52
+
53
+
54
+ def load_api_key(env_file: Path | None = None) -> str:
55
+ """`EDINET_API_KEY` from the environment, else from a dotenv-style file.
56
+
57
+ The key is never printed or logged.
58
+ """
59
+ key = os.environ.get("EDINET_API_KEY", "").strip()
60
+ if key:
61
+ return key
62
+ if env_file and env_file.is_file():
63
+ for line in env_file.read_text(encoding="utf-8").splitlines():
64
+ name, sep, value = line.partition("=")
65
+ if sep and name.strip() == "EDINET_API_KEY":
66
+ key = value.strip().strip("'\"")
67
+ if key:
68
+ return key
69
+ raise EdinetError("EDINET_API_KEY is not set.")
70
+
71
+
72
+ @dataclass
73
+ class _Pacer:
74
+ spacing: float = NORMAL_SPACING
75
+ last: float = 0.0
76
+
77
+ def wait(self) -> None:
78
+ delay = self.last + self.spacing - time.monotonic()
79
+ if delay > 0:
80
+ time.sleep(delay)
81
+ self.last = time.monotonic()
82
+
83
+
84
+ class EdinetClient:
85
+ """A paced, sequential client. Not thread-safe by design: one request at a time."""
86
+
87
+ def __init__(self, api_key: str, *, timeout: float = 120.0,
88
+ transport: httpx.BaseTransport | None = None,
89
+ spacing: float = NORMAL_SPACING) -> None:
90
+ self._key = api_key
91
+ self._http = httpx.Client(timeout=timeout, transport=transport,
92
+ headers={"User-Agent": "edinet-util-mcp-server"})
93
+ self._pacer = _Pacer(spacing=spacing)
94
+ self.requests = 0
95
+
96
+ def close(self) -> None:
97
+ self._http.close()
98
+
99
+ def __enter__(self) -> Self:
100
+ return self
101
+
102
+ def __exit__(self, *exc: object) -> None:
103
+ self.close()
104
+
105
+ def _get(self, path: str, params: dict[str, Any]) -> httpx.Response:
106
+ params = {**params, "Subscription-Key": self._key}
107
+ backoff = WIDE_SPACING
108
+ for attempt in range(1, MAX_ATTEMPTS + 1):
109
+ self._pacer.wait()
110
+ self.requests += 1
111
+ try:
112
+ response = self._http.get(f"{BASE_URL}{path}", params=params)
113
+ except httpx.TransportError:
114
+ if attempt == MAX_ATTEMPTS:
115
+ raise
116
+ self._pacer.spacing = WIDE_SPACING
117
+ time.sleep(backoff)
118
+ backoff = min(backoff * 2, MAX_BACKOFF)
119
+ continue
120
+ if response.status_code == 429 or response.status_code >= 500:
121
+ self._pacer.spacing = WIDE_SPACING
122
+ if attempt == MAX_ATTEMPTS:
123
+ break
124
+ time.sleep(backoff)
125
+ backoff = min(backoff * 2, MAX_BACKOFF)
126
+ continue
127
+ return response
128
+ raise EdinetError(f"EDINET kept refusing {path} (HTTP {response.status_code}).")
129
+
130
+ def daily_list(self, date: str) -> dict[str, Any]:
131
+ """The filing list for one day (`type=2`, with document details)."""
132
+ response = self._get("/documents.json", {"date": date, "type": 2})
133
+ data = _json_or_error(response)
134
+ status = str(data.get("metadata", {}).get("status", ""))
135
+ if status != "200":
136
+ raise EdinetError(f"Daily list {date}: {_error_message(data)}")
137
+ return data
138
+
139
+ def document(self, doc_id: str, doc_type: int) -> bytes:
140
+ """One document's file. Raises DocumentUnavailable when EDINET has none."""
141
+ response = self._get(f"/documents/{doc_id}", {"type": doc_type})
142
+ content_type = response.headers.get("Content-Type", "")
143
+ if response.status_code == 200 and "json" not in content_type:
144
+ return response.content
145
+ data = _json_or_error(response)
146
+ status = str(data.get("metadata", {}).get("status") or data.get("StatusCode") or "")
147
+ message = _error_message(data)
148
+ if status == "404" or response.status_code == 404:
149
+ raise DocumentUnavailable(f"{doc_id} type {doc_type}: {message}")
150
+ raise EdinetError(f"{doc_id} type {doc_type}: {message}")
151
+
152
+
153
+ def download_codelist(timeout: float = 120.0) -> bytes:
154
+ """The EDINET code list ZIP. Public, no key, updated daily."""
155
+ response = httpx.get(CODELIST_URL, timeout=timeout,
156
+ headers={"User-Agent": "edinet-util-mcp-server"})
157
+ response.raise_for_status()
158
+ if not response.content.startswith(b"PK"):
159
+ raise EdinetError("The EDINET code list download is not a ZIP file.")
160
+ return response.content
161
+
162
+
163
+ def _json_or_error(response: httpx.Response) -> dict[str, Any]:
164
+ try:
165
+ data = response.json()
166
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
167
+ raise EdinetError(f"Unexpected answer (HTTP {response.status_code}, "
168
+ f"{response.headers.get('Content-Type', '')}).") from exc
169
+ if not isinstance(data, dict):
170
+ raise EdinetError(f"Unexpected answer (HTTP {response.status_code}).")
171
+ return data
172
+
173
+
174
+ def _error_message(data: dict[str, Any]) -> str:
175
+ metadata = data.get("metadata", {})
176
+ message = metadata.get("message") or data.get("message") or "unknown error"
177
+ status = metadata.get("status") or data.get("StatusCode") or ""
178
+ return f"{status} {message}".strip()
@@ -0,0 +1,99 @@
1
+ """The EDINET code list (`Edinetcode.zip`): every filer, with its listing status.
2
+
3
+ The ZIP holds one CP932 CSV. Its first line is metadata (download date, count),
4
+ the second the header, then one quoted row per filer:
5
+
6
+ EDINETコード, 提出者種別, 上場区分, 連結の有無, 資本金, 決算日, 提出者名,
7
+ 提出者名(英字), 提出者名(ヨミ), 所在地, 提出者業種, 証券コード, 提出者法人番号
8
+
9
+ 資本金 is in millions of yen; 決算日 reads like "3月31日" or "3月末日" (month end).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import calendar
15
+ import csv
16
+ import io
17
+ import re
18
+ import zipfile
19
+ from dataclasses import dataclass
20
+
21
+ _FISCAL_YEAR_END = re.compile(r"^(\d{1,2})月(\d{1,2}|末)日$")
22
+ _AS_OF = re.compile(r"(\d{4})年(\d{2})月(\d{2})日")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Filer:
27
+ edinet_code: str
28
+ filer_type: str # 内国法人・組合, 外国法人・組合, 個人, ...
29
+ listing: str # 上場, 非上場, or blank
30
+ consolidated: str # 有, 無, or blank
31
+ capital_million_yen: int | None
32
+ fiscal_year_end: str | None # MM-DD
33
+ name: str
34
+ name_en: str
35
+ name_kana: str
36
+ address: str
37
+ industry: str # TSE 33-industry name
38
+ sec_code: str
39
+ jcn: str
40
+
41
+ @property
42
+ def listed(self) -> bool:
43
+ return self.listing == "上場"
44
+
45
+ @property
46
+ def fiscal_year_end_month(self) -> int | None:
47
+ return int(self.fiscal_year_end[:2]) if self.fiscal_year_end else None
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class CodeList:
52
+ as_of: str | None # ISO date of the download, from the file's first line
53
+ filers: list[Filer]
54
+
55
+
56
+ def parse_codelist(zip_bytes: bytes) -> CodeList:
57
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
58
+ name = next(n for n in archive.namelist() if n.lower().endswith(".csv"))
59
+ text = archive.read(name).decode("cp932")
60
+ lines = text.splitlines()
61
+ as_of = None
62
+ if lines and (match := _AS_OF.search(lines[0])):
63
+ as_of = "-".join(match.groups())
64
+ rows = list(csv.reader(lines[2:]))
65
+ return CodeList(as_of=as_of, filers=[_filer(row) for row in rows if len(row) >= 13])
66
+
67
+
68
+ def _filer(row: list[str]) -> Filer:
69
+ cells = [cell.strip() for cell in row]
70
+ return Filer(
71
+ edinet_code=cells[0],
72
+ filer_type=cells[1],
73
+ listing=cells[2],
74
+ consolidated=cells[3],
75
+ capital_million_yen=int(cells[4]) if cells[4].isdigit() else None,
76
+ fiscal_year_end=_fiscal_year_end(cells[5]),
77
+ name=_normalise_name(cells[6]),
78
+ name_en=cells[7],
79
+ name_kana=cells[8],
80
+ address=cells[9],
81
+ industry=cells[10],
82
+ sec_code=cells[11],
83
+ jcn=cells[12],
84
+ )
85
+
86
+
87
+ def _fiscal_year_end(value: str) -> str | None:
88
+ match = _FISCAL_YEAR_END.match(value)
89
+ if not match:
90
+ return None
91
+ month = int(match[1])
92
+ # 末日 is the month's last day; February is given as the 28th, its usual end.
93
+ day = calendar.monthrange(2026, month)[1] if match[2] == "末" else int(match[2])
94
+ return f"{month:02d}-{day:02d}"
95
+
96
+
97
+ def _normalise_name(name: str) -> str:
98
+ # The list pads some names with a full-width space ("株式会社 サカタのタネ").
99
+ return name.replace(" ", " ").strip()