catalejo 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.
- catalejo-0.1.0/.gitignore +20 -0
- catalejo-0.1.0/CHANGELOG.md +11 -0
- catalejo-0.1.0/LICENSE +21 -0
- catalejo-0.1.0/PKG-INFO +148 -0
- catalejo-0.1.0/README.md +116 -0
- catalejo-0.1.0/pyproject.toml +59 -0
- catalejo-0.1.0/src/catalejo/__init__.py +10 -0
- catalejo-0.1.0/src/catalejo/_http.py +203 -0
- catalejo-0.1.0/src/catalejo/_parse.py +162 -0
- catalejo-0.1.0/src/catalejo/documents.py +31 -0
- catalejo-0.1.0/src/catalejo/filings.py +138 -0
- catalejo-0.1.0/src/catalejo/mcp_server.py +63 -0
- catalejo-0.1.0/src/catalejo/models.py +61 -0
- catalejo-0.1.0/src/catalejo/py.typed +0 -0
- catalejo-0.1.0/src/catalejo/securities.py +43 -0
- catalejo-0.1.0/tests/fixtures/README.md +17 -0
- catalejo-0.1.0/tests/fixtures/ip_dias7_page1.html +70 -0
- catalejo-0.1.0/tests/fixtures/isin_hbx_foreign.html +28 -0
- catalejo-0.1.0/tests/fixtures/isin_sabadell_page1.html +50 -0
- catalejo-0.1.0/tests/fixtures/oir_dias3_page1.html +75 -0
- catalejo-0.1.0/tests/fixtures/oir_dias3_page8_last.html +53 -0
- catalejo-0.1.0/tests/test_documents.py +78 -0
- catalejo-0.1.0/tests/test_filings.py +168 -0
- catalejo-0.1.0/tests/test_http.py +127 -0
- catalejo-0.1.0/tests/test_mcp_server.py +55 -0
- catalejo-0.1.0/tests/test_package.py +5 -0
- catalejo-0.1.0/tests/test_parse.py +91 -0
- catalejo-0.1.0/tests/test_securities.py +60 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
venv/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
.DS_Store
|
|
14
|
+
|
|
15
|
+
# local HTTP cache for CNMV requests
|
|
16
|
+
.catalejo_cache/
|
|
17
|
+
|
|
18
|
+
# raw screen recordings (only the converted GIFs go in the repo)
|
|
19
|
+
docs/*.mov
|
|
20
|
+
docs/Screen*
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- `catalejo.filings()`: price-sensitive (IP) and other regulatory (OIR) announcements from the CNMV register, with full history since 2020, date ranges and server-side filtering by NIF.
|
|
8
|
+
- `catalejo.securities()`: ISINs registered under an issuer (shares, bonds...).
|
|
9
|
+
- `catalejo.document_text()`: text of the PDF attached to a filing (`catalejo[pdf]`).
|
|
10
|
+
- MCP server (`catalejo-mcp`, `catalejo[mcp]`) with `latest_filings` and `issuer_filings` tools.
|
|
11
|
+
- Polite by default: identifiable User-Agent, 1 request per second, on-disk cache.
|
catalejo-0.1.0/LICENSE
ADDED
|
@@ -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.
|
catalejo-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](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
|
+

|
|
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).
|
catalejo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# catalejo
|
|
2
|
+
|
|
3
|
+
[](https://github.com/polfananas/catalejo/actions/workflows/tests.yml)
|
|
4
|
+
|
|
5
|
+
> *catalejo* (Spanish): spyglass.
|
|
6
|
+
|
|
7
|
+
**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.
|
|
8
|
+
|
|
9
|
+
Think of it as a small **SEC EDGAR for Spain**.
|
|
10
|
+
|
|
11
|
+
> *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)
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
<!-- TODO: second GIF, Claude Desktop using the MCP server -->
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
>>> import catalejo
|
|
19
|
+
>>> for f in catalejo.filings(days=7, kind="IP"):
|
|
20
|
+
... print(f.registered_at, f.issuer, "|", f.title)
|
|
21
|
+
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.
|
|
22
|
+
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 ...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install catalejo # library
|
|
29
|
+
pip install "catalejo[mcp]" # + MCP server for Claude and other agents
|
|
30
|
+
pip install "catalejo[pdf]" # + text extraction from filed PDFs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Python 3.10+.
|
|
34
|
+
|
|
35
|
+
## What you can get
|
|
36
|
+
|
|
37
|
+
**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...
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from datetime import date
|
|
41
|
+
import catalejo
|
|
42
|
+
|
|
43
|
+
catalejo.filings(days=3) # everything from the last 3 days, IP + OIR
|
|
44
|
+
catalejo.filings(days=30, kind="IP", issuer="cellnex")
|
|
45
|
+
|
|
46
|
+
# Full history for one company. Filtering by NIF (Spanish tax id) is done
|
|
47
|
+
# by the CNMV itself, so years of data come back in a few seconds.
|
|
48
|
+
catalejo.filings(since=date(2020, 1, 1), kind="IP", issuer="A64907306")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Each `Filing` has `kind`, `registered_at` (timezone-aware, Madrid), `issuer`, `issuer_nif`, `category`, `title`, `registry_number` and `document_url`.
|
|
52
|
+
|
|
53
|
+
**The text of the filed document:**
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
f = catalejo.filings(days=10, kind="IP", issuer="A64907306")[0]
|
|
57
|
+
print(catalejo.document_text(f)) # needs catalejo[pdf]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**The securities (ISINs) of an issuer**, shares and bonds:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
>>> [s for s in catalejo.securities("A64907306") if s.is_share]
|
|
64
|
+
[Security(isin='ES0105066007', fisn='CLNX/AC 0.25', cfi='ESVUFB', ...)]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Use it from Claude (MCP)
|
|
68
|
+
|
|
69
|
+
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."*
|
|
70
|
+
|
|
71
|
+
**Claude Desktop:** add this to `claude_desktop_config.json` (Settings → Developer → Edit Config) and restart the app:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"mcpServers": {
|
|
76
|
+
"catalejo": { "command": "catalejo-mcp" }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If Claude can't find the command, use the full path from `which catalejo-mcp`.
|
|
82
|
+
|
|
83
|
+
**Claude Code:** `claude mcp add catalejo -- catalejo-mcp`
|
|
84
|
+
|
|
85
|
+
## Why not just ask an AI with web search?
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
|
|
89
|
+
## Being a good citizen
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
## Good to know
|
|
94
|
+
|
|
95
|
+
- **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.
|
|
96
|
+
- Searching by **NIF** is fast (the CNMV filters). Searching by **name** works, but scans every filing in the window, so keep the window short.
|
|
97
|
+
- Times are the CNMV's registration time, to the minute.
|
|
98
|
+
- Filings come in the language the issuer used, which is almost always Spanish.
|
|
99
|
+
- Early days: the API may still change before 1.0.
|
|
100
|
+
|
|
101
|
+
## Roadmap
|
|
102
|
+
|
|
103
|
+
- [x] Price-sensitive and other regulatory announcements, with full history
|
|
104
|
+
- [x] ISINs per issuer, text of filed PDFs
|
|
105
|
+
- [x] MCP server
|
|
106
|
+
- [ ] Directors' and managers' share dealings (art. 19 MAR)
|
|
107
|
+
- [ ] Net short positions
|
|
108
|
+
- [ ] Takeover bids (OPAs): status, price, spread
|
|
109
|
+
- [ ] Fund portfolios
|
|
110
|
+
- [ ] CLI
|
|
111
|
+
|
|
112
|
+
## Data and license
|
|
113
|
+
|
|
114
|
+
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.
|
|
115
|
+
|
|
116
|
+
Code: MIT. See [LICENSE](https://github.com/polfananas/catalejo/blob/main/LICENSE).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "catalejo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Clean, structured access to Spanish securities regulator (CNMV) public data, as a Python library and MCP server."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Pol Fañanás" }]
|
|
14
|
+
keywords = ["cnmv", "spain", "finance", "securities", "financial-data", "corporate-disclosures", "mcp", "edgar"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.27",
|
|
25
|
+
"selectolax>=1.0",
|
|
26
|
+
"tzdata; sys_platform == 'win32'",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/polfananas/catalejo"
|
|
31
|
+
Issues = "https://github.com/polfananas/catalejo/issues"
|
|
32
|
+
Changelog = "https://github.com/polfananas/catalejo/blob/main/CHANGELOG.md"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.10", "pypdf>=5"]
|
|
36
|
+
pdf = ["pypdf>=5"]
|
|
37
|
+
mcp = ["mcp>=1.20,<2"]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
catalejo-mcp = "catalejo.mcp_server:main"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
exclude = ["docs", ".github"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/catalejo"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
target-version = "py310"
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
strict = true
|
|
@@ -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"]
|
|
@@ -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]
|