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.
- edinet_util_mcp_server/__init__.py +12 -0
- edinet_util_mcp_server/__main__.py +7 -0
- edinet_util_mcp_server/api.py +178 -0
- edinet_util_mcp_server/codelist.py +99 -0
- edinet_util_mcp_server/convert.py +407 -0
- edinet_util_mcp_server/export.py +328 -0
- edinet_util_mcp_server/figures.py +416 -0
- edinet_util_mcp_server/guideline.py +67 -0
- edinet_util_mcp_server/server.py +301 -0
- edinet_util_mcp_server-0.1.0.dev0.dist-info/METADATA +106 -0
- edinet_util_mcp_server-0.1.0.dev0.dist-info/RECORD +14 -0
- edinet_util_mcp_server-0.1.0.dev0.dist-info/WHEEL +4 -0
- edinet_util_mcp_server-0.1.0.dev0.dist-info/entry_points.txt +5 -0
- edinet_util_mcp_server-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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()
|