catalejo 0.1.0__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.
catalejo/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """catalejo: Spanish CNMV public data, made usable."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from catalejo.documents import document_text
6
+ from catalejo.filings import filings
7
+ from catalejo.models import Filing, Security
8
+ from catalejo.securities import securities
9
+
10
+ __all__ = ["Filing", "Security", "document_text", "filings", "securities"]
catalejo/_http.py ADDED
@@ -0,0 +1,203 @@
1
+ """Polite HTTP client for cnmv.es.
2
+
3
+ Everything catalejo downloads goes through here, so the rules for being a
4
+ good citizen live in one place:
5
+
6
+ * identifiable User-Agent,
7
+ * at most one request per ``min_interval`` seconds,
8
+ * on-disk cache so repeated calls don't hit the CNMV again,
9
+ * a few retries with backoff on 429/5xx and network errors,
10
+ * only *.cnmv.es hosts are allowed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import time
19
+ from collections.abc import Callable, Mapping
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from urllib.parse import urlsplit
23
+
24
+ import httpx
25
+
26
+ from catalejo import __version__
27
+
28
+ USER_AGENT = f"catalejo/{__version__} (+https://github.com/polfananas/catalejo)"
29
+ ALLOWED_HOST_SUFFIX = "cnmv.es"
30
+ RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
31
+
32
+
33
+ class CNMVRequestError(RuntimeError):
34
+ """Raised when the CNMV can't be reached or keeps answering with errors."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Response:
39
+ url: str
40
+ status_code: int
41
+ content: bytes
42
+ content_type: str
43
+ from_cache: bool = False
44
+
45
+ @property
46
+ def text(self) -> str:
47
+ return self.content.decode("utf-8", errors="replace")
48
+
49
+
50
+ def default_cache_dir() -> Path:
51
+ env = os.environ.get("CATALEJO_CACHE_DIR")
52
+ if env:
53
+ return Path(env)
54
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
55
+ return Path(base) / "catalejo"
56
+
57
+
58
+ class HttpClient:
59
+ def __init__(
60
+ self,
61
+ *,
62
+ cache_dir: Path | None = None,
63
+ cache_ttl: float = 3600.0,
64
+ min_interval: float = 1.0,
65
+ max_retries: int = 3,
66
+ timeout: float = 30.0,
67
+ transport: httpx.BaseTransport | None = None,
68
+ sleep: Callable[[float], None] = time.sleep,
69
+ clock: Callable[[], float] = time.monotonic,
70
+ ) -> None:
71
+ self.cache_dir = cache_dir if cache_dir is not None else default_cache_dir()
72
+ self.cache_ttl = cache_ttl
73
+ self.min_interval = min_interval
74
+ self.max_retries = max_retries
75
+ self._sleep = sleep
76
+ self._clock = clock
77
+ self._last_request: float | None = None
78
+ self._client = httpx.Client(
79
+ headers={"User-Agent": USER_AGENT},
80
+ timeout=timeout,
81
+ follow_redirects=True,
82
+ transport=transport,
83
+ )
84
+
85
+ # -- public -----------------------------------------------------------
86
+
87
+ def get(
88
+ self,
89
+ url: str,
90
+ params: Mapping[str, str | int] | None = None,
91
+ *,
92
+ use_cache: bool = True,
93
+ ttl: float | None = None,
94
+ ) -> Response:
95
+ """GET a cnmv.es URL. `ttl` overrides the client's cache_ttl for this call
96
+ (use `math.inf` for things that never change, like filed documents)."""
97
+ _check_host(url)
98
+ key = _cache_key(url, params)
99
+ ttl = self.cache_ttl if ttl is None else ttl
100
+
101
+ if use_cache and ttl > 0:
102
+ cached = self._read_cache(key, ttl)
103
+ if cached is not None:
104
+ return cached
105
+
106
+ response = self._fetch(url, params)
107
+ if use_cache and ttl > 0:
108
+ self._write_cache(key, response)
109
+ return response
110
+
111
+ def close(self) -> None:
112
+ self._client.close()
113
+
114
+ def __enter__(self) -> HttpClient:
115
+ return self
116
+
117
+ def __exit__(self, *exc: object) -> None:
118
+ self.close()
119
+
120
+ # -- internals --------------------------------------------------------
121
+
122
+ def _throttle(self) -> None:
123
+ if self._last_request is not None:
124
+ wait = self.min_interval - (self._clock() - self._last_request)
125
+ if wait > 0:
126
+ self._sleep(wait)
127
+ self._last_request = self._clock()
128
+
129
+ def _fetch(self, url: str, params: Mapping[str, str | int] | None) -> Response:
130
+ last_error: str = ""
131
+ for attempt in range(self.max_retries + 1):
132
+ if attempt:
133
+ self._sleep(min(2**attempt, 30))
134
+ self._throttle()
135
+ try:
136
+ r = self._client.get(url, params=dict(params) if params else None)
137
+ except httpx.TransportError as e:
138
+ last_error = f"{type(e).__name__}: {e}"
139
+ continue
140
+
141
+ if r.status_code in RETRY_STATUSES:
142
+ last_error = f"HTTP {r.status_code}"
143
+ retry_after = r.headers.get("Retry-After", "")
144
+ if retry_after.isdigit():
145
+ self._sleep(min(int(retry_after), 60))
146
+ continue
147
+ if r.status_code >= 400:
148
+ raise CNMVRequestError(f"HTTP {r.status_code} for {r.url}")
149
+
150
+ return Response(
151
+ url=str(r.url),
152
+ status_code=r.status_code,
153
+ content=r.content,
154
+ content_type=r.headers.get("Content-Type", ""),
155
+ )
156
+
157
+ raise CNMVRequestError(
158
+ f"giving up on {url} after {self.max_retries + 1} attempts ({last_error})"
159
+ )
160
+
161
+ def _read_cache(self, key: str, ttl: float) -> Response | None:
162
+ meta_path = self.cache_dir / f"{key}.json"
163
+ body_path = self.cache_dir / f"{key}.bin"
164
+ try:
165
+ meta = json.loads(meta_path.read_text())
166
+ if time.time() - meta["fetched_at"] > ttl:
167
+ return None
168
+ content = body_path.read_bytes()
169
+ except (OSError, ValueError, KeyError):
170
+ return None
171
+ return Response(
172
+ url=meta["url"],
173
+ status_code=meta["status_code"],
174
+ content=content,
175
+ content_type=meta["content_type"],
176
+ from_cache=True,
177
+ )
178
+
179
+ def _write_cache(self, key: str, response: Response) -> None:
180
+ try:
181
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
182
+ (self.cache_dir / f"{key}.bin").write_bytes(response.content)
183
+ meta = {
184
+ "url": response.url,
185
+ "status_code": response.status_code,
186
+ "content_type": response.content_type,
187
+ "fetched_at": time.time(),
188
+ }
189
+ (self.cache_dir / f"{key}.json").write_text(json.dumps(meta))
190
+ except OSError:
191
+ pass # a broken cache shouldn't break the request
192
+
193
+
194
+ def _check_host(url: str) -> None:
195
+ host = (urlsplit(url).hostname or "").lower()
196
+ if host != ALLOWED_HOST_SUFFIX and not host.endswith("." + ALLOWED_HOST_SUFFIX):
197
+ raise ValueError(f"catalejo only talks to cnmv.es, got {host!r}")
198
+
199
+
200
+ def _cache_key(url: str, params: Mapping[str, str | int] | None) -> str:
201
+ items = sorted((k, str(v)) for k, v in (params or {}).items())
202
+ raw = url + "?" + "&".join(f"{k}={v}" for k, v in items)
203
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
catalejo/_parse.py ADDED
@@ -0,0 +1,162 @@
1
+ """HTML parsing for CNMV result listings (IP and OIR share the same markup)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import date, datetime
8
+ from urllib.parse import unquote
9
+ from zoneinfo import ZoneInfo
10
+
11
+ from selectolax.lexbor import LexborHTMLParser as HTMLParser
12
+ from selectolax.lexbor import LexborNode as Node
13
+
14
+ from catalejo.models import Filing, FilingKind, Security
15
+
16
+ MADRID = ZoneInfo("Europe/Madrid")
17
+
18
+ _PAGE_INFO = re.compile(r"(\d+)\D+(\d+)") # "Página 1 de 8" / "Page 1 out of 8"
19
+ _REGISTRY = re.compile(r"(\d+)\s*$")
20
+
21
+
22
+ class CNMVParseError(ValueError):
23
+ """The page didn't look like we expected. Probably the CNMV changed its HTML."""
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ResultsPage:
28
+ filings: list[Filing]
29
+ page: int
30
+ """1-based number of this page."""
31
+ total_pages: int
32
+
33
+ @property
34
+ def is_last(self) -> bool:
35
+ return self.page >= self.total_pages
36
+
37
+
38
+ def parse_results_page(html: str, kind: FilingKind) -> ResultsPage:
39
+ tree = HTMLParser(html)
40
+ filings = [_parse_entry(node, kind) for node in tree.css("#listaElementosPrimernivel > li")]
41
+ page, total = _parse_page_info(tree)
42
+ return ResultsPage(filings=filings, page=page, total_pages=total)
43
+
44
+
45
+ def normalize_nif(raw: str) -> str:
46
+ return re.sub(r"[\s\-.]", "", raw).upper()
47
+
48
+
49
+ def _parse_entry(node: Node, kind: FilingKind) -> Filing:
50
+ date = _text(node, "li.fecha-con-hora")
51
+ time = _text(node, "li.time")
52
+ issuer_link = _required(node, "a[id$=hlTituloCabecera]")
53
+ doc_link = _required(node, "a[id$=subtituloRegistroEnlace]")
54
+
55
+ registry_text = next(
56
+ (li.text(strip=True) for li in node.css("li.resumen") if "registro" in li.text().lower()),
57
+ "",
58
+ )
59
+ registry = _REGISTRY.search(registry_text)
60
+ if registry is None:
61
+ raise CNMVParseError(f"no registry number in entry: {node.text(strip=True)[:120]!r}")
62
+
63
+ try:
64
+ registered_at = datetime.strptime(f"{date} {time}", "%d/%m/%Y %H:%M").replace(tzinfo=MADRID)
65
+ except ValueError as e:
66
+ raise CNMVParseError(f"bad date/time {date!r} {time!r}") from e
67
+
68
+ raw_nif = _nif_from_href(issuer_link.attributes.get("href") or "")
69
+ return Filing(
70
+ kind=kind,
71
+ registered_at=registered_at,
72
+ issuer=issuer_link.text(strip=True),
73
+ issuer_nif=normalize_nif(raw_nif) if raw_nif else None,
74
+ category=_text(node, "span[id$=descripcionSubtituloCabecera]"),
75
+ title=doc_link.text(strip=True),
76
+ registry_number=int(registry.group(1)),
77
+ document_url=(doc_link.attributes.get("href") or "").strip(),
78
+ issuer_nif_raw=raw_nif,
79
+ )
80
+
81
+
82
+ def _parse_page_info(tree: HTMLParser) -> tuple[int, int]:
83
+ info = tree.css_first(".PagActivaTXT")
84
+ if info is None:
85
+ return 1, 1 # single page of results: the CNMV shows no paginator
86
+ match = _PAGE_INFO.search(info.text())
87
+ if match is None:
88
+ raise CNMVParseError(f"can't read pagination from {info.text()!r}")
89
+ return int(match.group(1)), int(match.group(2))
90
+
91
+
92
+ def _nif_from_href(href: str) -> str | None:
93
+ match = re.search(r"[?&]nif=([^&]+)", href, flags=re.IGNORECASE)
94
+ return unquote(match.group(1)).strip() if match else None
95
+
96
+
97
+ def _required(node: Node, selector: str) -> Node:
98
+ found = node.css_first(selector)
99
+ if found is None:
100
+ raise CNMVParseError(f"missing {selector!r} in entry: {node.text(strip=True)[:120]!r}")
101
+ return found
102
+
103
+
104
+ def _text(node: Node, selector: str) -> str:
105
+ return _required(node, selector).text(strip=True)
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class SecuritiesPage:
110
+ securities: list[Security]
111
+ page: int
112
+ total_pages: int
113
+
114
+ @property
115
+ def is_last(self) -> bool:
116
+ return self.page >= self.total_pages
117
+
118
+
119
+ def parse_securities_page(html: str) -> SecuritiesPage:
120
+ tree = HTMLParser(html)
121
+ securities = []
122
+ for row in tree.css("table#ctl00_ContentPrincipal_grdDatos tbody tr"):
123
+ cells = {
124
+ (td.attributes.get("data-th") or ""): td.text().strip() or None for td in row.css("td")
125
+ }
126
+ isin = cells.get("ISIN")
127
+ if not isin:
128
+ raise CNMVParseError(f"ISIN row without ISIN: {row.text(strip=True)[:120]!r}")
129
+ securities.append(
130
+ Security(
131
+ isin=isin,
132
+ fisn=cells.get("FISN"),
133
+ cfi=cells.get("CFI 2015"),
134
+ currency=cells.get("Moneda"),
135
+ issue_date=_es_date(cells.get("Fecha emisión")),
136
+ maturity_date=_es_date(cells.get("Fecha vencimiento")),
137
+ outstanding=_es_number(cells.get("Nº valores / Saldo emitido")),
138
+ nominal=_es_number(cells.get("Nominal / VMN / Precio")),
139
+ interest_rate=_es_number(cells.get("Interés")),
140
+ )
141
+ )
142
+ page, total = _parse_page_info(tree)
143
+ return SecuritiesPage(securities=securities, page=page, total_pages=total)
144
+
145
+
146
+ def _es_number(text: str | None) -> float | None:
147
+ """ "4.768.462.486,00" -> 4768462486.0"""
148
+ if not text:
149
+ return None
150
+ try:
151
+ return float(text.replace(".", "").replace(",", "."))
152
+ except ValueError as e:
153
+ raise CNMVParseError(f"bad number {text!r}") from e
154
+
155
+
156
+ def _es_date(text: str | None) -> date | None:
157
+ if not text:
158
+ return None
159
+ try:
160
+ return datetime.strptime(text, "%d/%m/%Y").date()
161
+ except ValueError as e:
162
+ raise CNMVParseError(f"bad date {text!r}") from e
catalejo/documents.py ADDED
@@ -0,0 +1,31 @@
1
+ """Text of the documents attached to filings (PDFs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import math
7
+
8
+ from catalejo._http import HttpClient
9
+ from catalejo.filings import _default_client
10
+ from catalejo.models import Filing
11
+
12
+
13
+ def document_text(filing: Filing | str, *, client: HttpClient | None = None) -> str:
14
+ """Plain text of a filing's PDF (or of a document URL).
15
+
16
+ Needs the optional dependency: ``pip install "catalejo[pdf]"``.
17
+ Documents never change, so they're cached forever after the first download.
18
+ Scanned PDFs have no text layer and return "".
19
+ """
20
+ try:
21
+ from pypdf import PdfReader
22
+ except ImportError:
23
+ raise ImportError('document_text() needs pypdf: pip install "catalejo[pdf]"') from None
24
+
25
+ url = filing.document_url if isinstance(filing, Filing) else filing
26
+ response = (client or _default_client()).get(url, ttl=math.inf)
27
+ if not response.content.startswith(b"%PDF"):
28
+ raise ValueError(f"not a PDF ({response.content_type or 'unknown type'}): {url}")
29
+
30
+ reader = PdfReader(io.BytesIO(response.content))
31
+ return "\n".join(page.extract_text() or "" for page in reader.pages).strip()
catalejo/filings.py ADDED
@@ -0,0 +1,138 @@
1
+ """Price-sensitive (IP) and other regulatory (OIR) announcements published by issuers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+ import warnings
8
+ from datetime import date, datetime, timedelta
9
+ from typing import Literal
10
+
11
+ from catalejo._http import HttpClient
12
+ from catalejo._parse import MADRID, normalize_nif, parse_results_page
13
+ from catalejo.models import Filing, FilingKind
14
+
15
+ BASE = "https://www.cnmv.es/portal"
16
+ RESULT_URLS: dict[FilingKind, str] = {
17
+ "IP": f"{BASE}/Informacion-Privilegiada/Resultado-IP",
18
+ "OIR": f"{BASE}/Otra-Informacion-Relevante/Resultado-OIR",
19
+ }
20
+
21
+ # Spanish NIF/CIF shapes: B12345678, A1234567B, 12345678Z (foreign issuers: N1234567X)
22
+ MAX_DAYS = 90 # the CNMV answers HTTP 400 above ~91; longer windows go as a date range
23
+
24
+ _NIF = re.compile(r"^(?:[A-Z]\d{7}[A-Z0-9]|\d{8}[A-Z])$")
25
+
26
+ _client: HttpClient | None = None
27
+
28
+
29
+ def _default_client() -> HttpClient:
30
+ global _client
31
+ if _client is None:
32
+ _client = HttpClient()
33
+ return _client
34
+
35
+
36
+ def filings(
37
+ days: int = 7,
38
+ kind: FilingKind | Literal["all"] = "all",
39
+ issuer: str | None = None,
40
+ *,
41
+ since: date | None = None,
42
+ until: date | None = None,
43
+ max_pages: int = 50,
44
+ client: HttpClient | None = None,
45
+ ) -> list[Filing]:
46
+ """Filings registered at the CNMV, newest first.
47
+
48
+ By default the last `days` days. Pass `since` (and optionally `until`) for a
49
+ fixed date range instead, e.g. to pull history.
50
+
51
+ >>> import catalejo
52
+ >>> catalejo.filings(days=3, kind="IP") # doctest: +SKIP
53
+ >>> catalejo.filings(since=date(2024, 1, 1), issuer="A64907306") # doctest: +SKIP
54
+ """
55
+ if days < 1:
56
+ raise ValueError("days must be >= 1")
57
+ window = _window(days, since, until)
58
+ client = client or _default_client()
59
+ kinds: list[FilingKind] = ["IP", "OIR"] if kind == "all" else [kind]
60
+
61
+ nif = _as_nif(issuer) if issuer is not None else None
62
+
63
+ result: list[Filing] = []
64
+ for k in kinds:
65
+ if nif:
66
+ # Ask the CNMV for just this issuer. It only matches the NIF in the exact
67
+ # form it stores it, and that can have a dash or not, so try both.
68
+ seen = set()
69
+ for variant in (nif, f"{nif[0]}-{nif[1:]}"):
70
+ for f in _fetch_all_pages(client, k, {**window, "nif": variant}, max_pages):
71
+ if f.registry_number not in seen:
72
+ seen.add(f.registry_number)
73
+ result.append(f)
74
+ else:
75
+ result.extend(_fetch_all_pages(client, k, window, max_pages))
76
+
77
+ if issuer is not None:
78
+ result = [f for f in result if matches_issuer(f, issuer)]
79
+
80
+ result.sort(key=lambda f: f.registered_at, reverse=True)
81
+ return result
82
+
83
+
84
+ def _fetch_all_pages(
85
+ client: HttpClient, kind: FilingKind, params: dict[str, str | int], max_pages: int
86
+ ) -> list[Filing]:
87
+ out: list[Filing] = []
88
+ for page_index in range(max_pages): # the CNMV's `page` param is zero-based
89
+ response = client.get(RESULT_URLS[kind], {**params, "page": page_index})
90
+ page = parse_results_page(response.text, kind)
91
+ out.extend(page.filings)
92
+ if page.is_last or not page.filings:
93
+ break
94
+ else:
95
+ warnings.warn(
96
+ f"stopped after max_pages={max_pages}; results are incomplete. "
97
+ "Narrow the date range or raise max_pages.",
98
+ stacklevel=3,
99
+ )
100
+ return out
101
+
102
+
103
+ def _window(days: int, since: date | None, until: date | None) -> dict[str, str | int]:
104
+ """Query params for the time window: `dias=N` when possible, a date range otherwise."""
105
+ if since is None and until is None and days <= MAX_DAYS:
106
+ return {"dias": days}
107
+ today = datetime.now(MADRID).date()
108
+ until = until or today
109
+ since = since or until - timedelta(days=days)
110
+ if since > until:
111
+ raise ValueError(f"since ({since}) is after until ({until})")
112
+ return {"fechaDesde": f"{since:%d/%m/%Y}", "fechaHasta": f"{until:%d/%m/%Y}"}
113
+
114
+
115
+ def _as_nif(query: str) -> str | None:
116
+ """The query normalized as a NIF, or None if it doesn't look like one."""
117
+ candidate = normalize_nif(query)
118
+ return candidate if _NIF.match(candidate) else None
119
+
120
+
121
+ def matches_issuer(filing: Filing, query: str) -> bool:
122
+ """True if `query` identifies the filing's issuer.
123
+
124
+ `query` can be a NIF ("A28004885", "A-28004885") or part of the name
125
+ ("cellnex", "Telefónica" == "telefonica").
126
+ """
127
+ q = query.strip()
128
+ if not q:
129
+ raise ValueError("issuer can't be empty")
130
+ if filing.issuer_nif and normalize_nif(q) == filing.issuer_nif:
131
+ return True
132
+ return _fold(q) in _fold(filing.issuer)
133
+
134
+
135
+ def _fold(text: str) -> str:
136
+ """Lowercase and strip accents, so "Telefónica" == "telefonica"."""
137
+ decomposed = unicodedata.normalize("NFKD", text)
138
+ return "".join(c for c in decomposed if not unicodedata.combining(c)).casefold()
catalejo/mcp_server.py ADDED
@@ -0,0 +1,63 @@
1
+ """MCP server exposing CNMV filings to AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import logging
7
+ from datetime import date
8
+ from typing import Any, Literal
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+
12
+ import catalejo
13
+
14
+ mcp = FastMCP("catalejo")
15
+
16
+
17
+ def _as_dict(f: catalejo.Filing) -> dict[str, Any]:
18
+ d = dataclasses.asdict(f)
19
+ d["registered_at"] = f.registered_at.isoformat() # datetime isn't JSON
20
+ return d
21
+
22
+
23
+ @mcp.tool()
24
+ def latest_filings(
25
+ days: int = 3, kind: Literal["IP", "OIR", "all"] = "all"
26
+ ) -> list[dict[str, Any]]:
27
+ """Latest filings published at the Spanish CNMV, newest first.
28
+
29
+ kind: "IP" = price-sensitive announcements, "OIR" = other regulatory announcements.
30
+ """
31
+ return [_as_dict(f) for f in catalejo.filings(days=days, kind=kind)]
32
+
33
+
34
+ @mcp.tool()
35
+ def issuer_filings(
36
+ issuer: str,
37
+ days: int = 7,
38
+ kind: Literal["IP", "OIR", "all"] = "all",
39
+ since: date | None = None,
40
+ until: date | None = None,
41
+ limit: int = 50,
42
+ ) -> list[dict[str, Any]]:
43
+ """Filings from one specific company at the Spanish CNMV, newest first.
44
+
45
+ issuer: its Spanish tax id / NIF ("A64907306") is best: the CNMV filters by it
46
+ directly, so even years of history come back in seconds. A company name or part
47
+ of it ("Cellnex", "telefonica") also works but scans every filing, so keep
48
+ `days` small (7-14) when searching by name.
49
+ since / until: optional date range (YYYY-MM-DD) for history; overrides `days`.
50
+ Price-sensitive announcements (IP) exist from Feb 2020 onwards.
51
+ limit: max filings returned (the newest ones).
52
+ """
53
+ found = catalejo.filings(days=days, kind=kind, issuer=issuer, since=since, until=until)
54
+ return [_as_dict(f) for f in found[:limit]]
55
+
56
+
57
+ def main() -> None:
58
+ logging.getLogger("httpx").setLevel(logging.WARNING) # one INFO line per request is noise
59
+ mcp.run() # stdio by default
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
catalejo/models.py ADDED
@@ -0,0 +1,61 @@
1
+ """Data models returned by catalejo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime
7
+ from typing import Literal
8
+ from urllib.parse import parse_qs, urlsplit
9
+
10
+ FilingKind = Literal["IP", "OIR"]
11
+ """``IP`` = información privilegiada (inside information, art. 17 MAR).
12
+ ``OIR`` = otra información relevante (other relevant information)."""
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Filing:
17
+ """One disclosure published by an issuer through the CNMV."""
18
+
19
+ kind: FilingKind
20
+ registered_at: datetime
21
+ """When the CNMV registered it (timezone-aware, Europe/Madrid)."""
22
+ issuer: str
23
+ issuer_nif: str | None
24
+ """Spanish tax id of the issuer, without dashes. Foreign issuers get an N-prefixed NIF."""
25
+ category: str
26
+ """Category as shown by the CNMV, in Spanish (e.g. "Sobre gobierno corporativo")."""
27
+ title: str
28
+ registry_number: int
29
+ """Official registry number. IP and OIR are numbered separately."""
30
+ document_url: str
31
+ """Link to the filed document (usually a PDF)."""
32
+ issuer_nif_raw: str | None = None
33
+ """NIF exactly as the CNMV writes it (sometimes "A-28004885"). Needed to query by issuer."""
34
+
35
+ @property
36
+ def document_id(self) -> str | None:
37
+ """The GUID the CNMV uses to identify the document, if present in the URL."""
38
+ values = parse_qs(urlsplit(self.document_url).query).get("t")
39
+ return values[0].strip("{}") if values else None
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class Security:
44
+ """A security registered under an issuer at the CNMV's ISIN agency (ANCV)."""
45
+
46
+ isin: str
47
+ fisn: str | None
48
+ """Short name, e.g. "CLNX/AC 0.25" (AC = acciones / shares, OB = bonds)."""
49
+ cfi: str | None
50
+ """ISO 10962 classification. Shares start with "ES". Empty for most foreign issuers."""
51
+ currency: str | None = None
52
+ issue_date: date | None = None
53
+ maturity_date: date | None = None
54
+ outstanding: float | None = None
55
+ """Number of securities (shares) or amount issued (debt)."""
56
+ nominal: float | None = None
57
+ interest_rate: float | None = None
58
+
59
+ @property
60
+ def is_share(self) -> bool:
61
+ return (self.cfi or "").startswith("ES")
catalejo/py.typed ADDED
File without changes
catalejo/securities.py ADDED
@@ -0,0 +1,43 @@
1
+ """Securities (ISINs) registered under an issuer, from the CNMV's ISIN agency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from catalejo._http import HttpClient
6
+ from catalejo._parse import normalize_nif, parse_securities_page
7
+ from catalejo.filings import _default_client
8
+ from catalejo.models import Security
9
+
10
+ ISIN_URL = "https://www.cnmv.es/portal/ancv/isin.aspx"
11
+
12
+
13
+ def securities(
14
+ nif: str, *, client: HttpClient | None = None, max_pages: int = 50
15
+ ) -> list[Security]:
16
+ """All ISINs the CNMV lists for an issuer: shares, bonds, notes...
17
+
18
+ `nif` can be written with or without dash. Use `[s for s in ... if s.is_share]`
19
+ to keep only shares.
20
+
21
+ >>> catalejo.securities("A64907306") # doctest: +SKIP
22
+ [Security(isin='ES0105066007', fisn='CLNX/AC 0.25', cfi='ESVUFB', ...)]
23
+ """
24
+ client = client or _default_client()
25
+ base = normalize_nif(nif)
26
+ if not base:
27
+ raise ValueError("nif can't be empty")
28
+ # Like the filings search, the CNMV only matches the NIF in the form it stores it.
29
+ for variant in (base, f"{base[0]}-{base[1:]}"):
30
+ found = _fetch(client, variant, max_pages)
31
+ if found:
32
+ return found
33
+ return []
34
+
35
+
36
+ def _fetch(client: HttpClient, nif: str, max_pages: int) -> list[Security]:
37
+ out: list[Security] = []
38
+ for page_index in range(max_pages):
39
+ page = parse_securities_page(client.get(ISIN_URL, {"nif": nif, "page": page_index}).text)
40
+ out.extend(page.securities)
41
+ if page.is_last or not page.securities:
42
+ break
43
+ return out
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: catalejo
3
+ Version: 0.1.0
4
+ Summary: Clean, structured access to Spanish securities regulator (CNMV) public data, as a Python library and MCP server.
5
+ Project-URL: Homepage, https://github.com/polfananas/catalejo
6
+ Project-URL: Issues, https://github.com/polfananas/catalejo/issues
7
+ Project-URL: Changelog, https://github.com/polfananas/catalejo/blob/main/CHANGELOG.md
8
+ Author: Pol Fañanás
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cnmv,corporate-disclosures,edgar,finance,financial-data,mcp,securities,spain
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Office/Business :: Financial :: Investment
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: selectolax>=1.0
21
+ Requires-Dist: tzdata; sys_platform == 'win32'
22
+ Provides-Extra: dev
23
+ Requires-Dist: mypy>=1.10; extra == 'dev'
24
+ Requires-Dist: pypdf>=5; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Provides-Extra: mcp
28
+ Requires-Dist: mcp<2,>=1.20; extra == 'mcp'
29
+ Provides-Extra: pdf
30
+ Requires-Dist: pypdf>=5; extra == 'pdf'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # catalejo
34
+
35
+ [![tests](https://github.com/polfananas/catalejo/actions/workflows/tests.yml/badge.svg)](https://github.com/polfananas/catalejo/actions/workflows/tests.yml)
36
+
37
+ > *catalejo* (Spanish): spyglass.
38
+
39
+ **Spain's securities regulator (CNMV) publishes everything listed companies have to disclose, but only through web forms and PDFs.** catalejo turns that into clean Python objects, and plugs it into AI agents through an MCP server.
40
+
41
+ Think of it as a small **SEC EDGAR for Spain**.
42
+
43
+ > *Having had perfect understanding of all things from the very first, to write unto thee in order, that thou mightest know the certainty of those things.* (Luke 1:3-4)
44
+
45
+ ![catalejo in a terminal: the latest price-sensitive announcements, 176 BBVA filings since 2020, the whole BBVA–Sabadell takeover bid in order, and its outcome read from the CNMV's own PDF. All live data.](https://raw.githubusercontent.com/polfananas/catalejo/main/docs/demo.gif)
46
+
47
+ <!-- TODO: second GIF, Claude Desktop using the MCP server -->
48
+
49
+ ```python
50
+ >>> import catalejo
51
+ >>> for f in catalejo.filings(days=7, kind="IP"):
52
+ ... print(f.registered_at, f.issuer, "|", f.title)
53
+ 2026-11-10 16:20:00+01:00 CONSTRUCCIONES Y AUXILIAR DE FERROCARRILES, S.A | El Consejo de Administración de la Sociedad ha aprobado el Plan Estratégico 2027-2030.
54
+ 2026-11-09 12:14:00+01:00 ELECNOR, S.A. | Elecnor comunica la propuesta del Consejo de Administración de distribución de un dividendo ...
55
+ ```
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install catalejo # library
61
+ pip install "catalejo[mcp]" # + MCP server for Claude and other agents
62
+ pip install "catalejo[pdf]" # + text extraction from filed PDFs
63
+ ```
64
+
65
+ Python 3.10+.
66
+
67
+ ## What you can get
68
+
69
+ **Price-sensitive announcements (`IP`) and other regulatory announcements (`OIR`)**, the two channels issuers use to disclose anything that can move the price: results, M&A, takeover bids, guidance, board changes, buybacks...
70
+
71
+ ```python
72
+ from datetime import date
73
+ import catalejo
74
+
75
+ catalejo.filings(days=3) # everything from the last 3 days, IP + OIR
76
+ catalejo.filings(days=30, kind="IP", issuer="cellnex")
77
+
78
+ # Full history for one company. Filtering by NIF (Spanish tax id) is done
79
+ # by the CNMV itself, so years of data come back in a few seconds.
80
+ catalejo.filings(since=date(2020, 1, 1), kind="IP", issuer="A64907306")
81
+ ```
82
+
83
+ Each `Filing` has `kind`, `registered_at` (timezone-aware, Madrid), `issuer`, `issuer_nif`, `category`, `title`, `registry_number` and `document_url`.
84
+
85
+ **The text of the filed document:**
86
+
87
+ ```python
88
+ f = catalejo.filings(days=10, kind="IP", issuer="A64907306")[0]
89
+ print(catalejo.document_text(f)) # needs catalejo[pdf]
90
+ ```
91
+
92
+ **The securities (ISINs) of an issuer**, shares and bonds:
93
+
94
+ ```python
95
+ >>> [s for s in catalejo.securities("A64907306") if s.is_share]
96
+ [Security(isin='ES0105066007', fisn='CLNX/AC 0.25', cfi='ESVUFB', ...)]
97
+ ```
98
+
99
+ ## Use it from Claude (MCP)
100
+
101
+ catalejo ships an MCP server with two tools, `latest_filings` and `issuer_filings`, so you can ask things like *"What price-sensitive news has Cellnex published since 2023? Group it by topic."*
102
+
103
+ **Claude Desktop:** add this to `claude_desktop_config.json` (Settings → Developer → Edit Config) and restart the app:
104
+
105
+ ```json
106
+ {
107
+ "mcpServers": {
108
+ "catalejo": { "command": "catalejo-mcp" }
109
+ }
110
+ }
111
+ ```
112
+
113
+ If Claude can't find the command, use the full path from `which catalejo-mcp`.
114
+
115
+ **Claude Code:** `claude mcp add catalejo -- catalejo-mcp`
116
+
117
+ ## Why not just ask an AI with web search?
118
+
119
+ Because the CNMV's search pages are forms that search engines barely index. An agent with web search ends up reading press articles: incomplete, late, and you can't tell what's missing. With catalejo it reads the official register directly: every filing, with its exact time, registry number and a link to the original document. And it can do what web search can't, like going through years of history or across every issuer.
120
+
121
+ ## Being a good citizen
122
+
123
+ catalejo talks only to `cnmv.es`, sends an identifiable User-Agent, makes **at most one request per second** and caches responses on disk (`~/.cache/catalejo`, or set `CATALEJO_CACHE_DIR`). Filed documents never change, so they are downloaded once. Please keep it that way if you fork it.
124
+
125
+ ## Good to know
126
+
127
+ - **Price-sensitive announcements (`IP`) have their own channel since 8 February 2020.** Before that, everything was published as *hechos relevantes*, which catalejo doesn't cover yet.
128
+ - Searching by **NIF** is fast (the CNMV filters). Searching by **name** works, but scans every filing in the window, so keep the window short.
129
+ - Times are the CNMV's registration time, to the minute.
130
+ - Filings come in the language the issuer used, which is almost always Spanish.
131
+ - Early days: the API may still change before 1.0.
132
+
133
+ ## Roadmap
134
+
135
+ - [x] Price-sensitive and other regulatory announcements, with full history
136
+ - [x] ISINs per issuer, text of filed PDFs
137
+ - [x] MCP server
138
+ - [ ] Directors' and managers' share dealings (art. 19 MAR)
139
+ - [ ] Net short positions
140
+ - [ ] Takeover bids (OPAs): status, price, spread
141
+ - [ ] Fund portfolios
142
+ - [ ] CLI
143
+
144
+ ## Data and license
145
+
146
+ The data comes from the [CNMV](https://www.cnmv.es) public registers and remains subject to the CNMV's [legal notice](https://www.cnmv.es/Portal/utilidades/NotaLegal): reproduce it faithfully, and if you ever sell it, tell your buyers it's free at cnmv.es. catalejo is not affiliated with or endorsed by the CNMV.
147
+
148
+ Code: MIT. See [LICENSE](https://github.com/polfananas/catalejo/blob/main/LICENSE).
@@ -0,0 +1,14 @@
1
+ catalejo/__init__.py,sha256=qsAtDLoXw2Dd7qcbUriAVETKXaZoc1dZ3pr9n-ssZ28,325
2
+ catalejo/_http.py,sha256=fP9bLKfR4SJSv_PEED6c53wQLXJgV_0vBKs_UThzZtg,6692
3
+ catalejo/_parse.py,sha256=pZqOnamHZxGyaefXc6cWlIWAv5qlBcZwWrBpst76mcs,5462
4
+ catalejo/documents.py,sha256=JWP-5GKSi_eh1RZmSG3Eqy5DyawAuTf2kWXVDz7z6sM,1169
5
+ catalejo/filings.py,sha256=jJghACO3bgSLZWM8PPbfIBBph6PCOMZsSELbgW9fD_8,4896
6
+ catalejo/mcp_server.py,sha256=kgBFtL70ZBVI2YjRvQU5COfaNqD2Y0oMhmJYWIvSv7U,1937
7
+ catalejo/models.py,sha256=vGI0nHL-Z7CrXfEObPF3xUH-VnvP39zPRqa78tLgWM4,2212
8
+ catalejo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ catalejo/securities.py,sha256=FuQbs0AvmWlTMYDimxdm5JRg4u5MbdY7M9IqOyueUEw,1539
10
+ catalejo-0.1.0.dist-info/METADATA,sha256=5fC6LjI8JCjdTvODK19g71NdzVJlCoIwEYTSIJvA478,6831
11
+ catalejo-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ catalejo-0.1.0.dist-info/entry_points.txt,sha256=rHRSXqxtYzYpLmRGqyGH_Ac859PpXzgAm5G1a432VbE,58
13
+ catalejo-0.1.0.dist-info/licenses/LICENSE,sha256=3Yga6SazeCMa1lYyNhcvRtg7-q3Nw2AFh-4Xk8RGfy8,1070
14
+ catalejo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ catalejo-mcp = catalejo.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pol Fañanás
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.