codal-mcp 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.
- codal_mcp/__init__.py +5 -0
- codal_mcp/codal.py +538 -0
- codal_mcp/http.py +256 -0
- codal_mcp/registry.py +16 -0
- codal_mcp/server.py +88 -0
- codal_mcp-0.1.0.dist-info/METADATA +405 -0
- codal_mcp-0.1.0.dist-info/RECORD +10 -0
- codal_mcp-0.1.0.dist-info/WHEEL +4 -0
- codal_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- codal_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
codal_mcp/__init__.py
ADDED
codal_mcp/codal.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Codal tools: search disclosures, read one letter, look up companies, letter types and fiscal years."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from html import unescape
|
|
8
|
+
from html.parser import HTMLParser
|
|
9
|
+
from typing import Annotated, Any
|
|
10
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from .http import SEARCH, WWW, ApiError, fetch
|
|
15
|
+
from .registry import tool
|
|
16
|
+
|
|
17
|
+
LOOKUP_TTL = 12 * 3600 # companies, industry groups, categories: change rarely
|
|
18
|
+
LETTER_TTL = 3600 # a published letter does not change; keep it an hour
|
|
19
|
+
MAX_TEXT = 16_000 # characters of letter text or tables returned at once
|
|
20
|
+
|
|
21
|
+
_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "0123456789" * 2)
|
|
22
|
+
_LETTERS = str.maketrans({"ي": "ی", "ى": "ی", "ك": "ک", "\u200c": " ", "\u200f": None, "\u200e": None})
|
|
23
|
+
|
|
24
|
+
# v1/companies field `st`, as the search page's PublisherStatus list names them
|
|
25
|
+
MARKETS = {
|
|
26
|
+
0: "Tehran Stock Exchange (بورس تهران)",
|
|
27
|
+
1: "Iran Fara Bourse (فرابورس)",
|
|
28
|
+
2: "registered, not listed (ثبت شده پذیرفته نشده)",
|
|
29
|
+
3: "not registered with SEO (ثبت نشده نزد سازمان)",
|
|
30
|
+
4: "Iran Mercantile Exchange (بورس کالا)",
|
|
31
|
+
5: "Iran Energy Exchange (بورس انرژی)",
|
|
32
|
+
}
|
|
33
|
+
# v1/companies field `t`, the publisher types of v1/categories
|
|
34
|
+
KINDS = {1: "publisher", 2: "broker", 3: "financial institution or fund", 4: "public entity", 5: "state company"}
|
|
35
|
+
# v1/companies field `RT`, the search page's ReportingType list
|
|
36
|
+
REPORTING = {
|
|
37
|
+
1000000: "manufacturing",
|
|
38
|
+
1000001: "construction",
|
|
39
|
+
1000002: "investment",
|
|
40
|
+
1000003: "bank",
|
|
41
|
+
1000004: "leasing",
|
|
42
|
+
1000005: "services",
|
|
43
|
+
1000006: "insurance",
|
|
44
|
+
1000007: "sea transport",
|
|
45
|
+
1000008: "agriculture",
|
|
46
|
+
1000009: "investment bank (تامین سرمایه)",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ascii(v: Any) -> str:
|
|
51
|
+
"""Persian/Arabic digits to ASCII and runs of spaces collapsed, as the site renders them."""
|
|
52
|
+
return re.sub(r"\s+", " ", str(v or "").translate(_DIGITS)).strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _key(v: str) -> str:
|
|
56
|
+
"""Matching form of a symbol or name: ASCII digits, Persian ی/ک, no ZWNJ, single spaces."""
|
|
57
|
+
return re.sub(r"\s+", " ", v.translate(_DIGITS).translate(_LETTERS)).strip().lower()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _jalali(v: str | None, name: str) -> str | None:
|
|
61
|
+
if not v:
|
|
62
|
+
return None
|
|
63
|
+
s = _ascii(v).replace("-", "/")
|
|
64
|
+
m = re.fullmatch(r"(\d{4})/(\d{1,2})/(\d{1,2})", s)
|
|
65
|
+
if not m or not 1300 <= int(m[1]) <= 1500 or not 1 <= int(m[2]) <= 12 or not 1 <= int(m[3]) <= 31:
|
|
66
|
+
raise ApiError(f"{name} must be a Jalali (Persian calendar) date like 1405/07/01, got {v!r}.")
|
|
67
|
+
return f"{m[1]}/{int(m[2]):02d}/{int(m[3]):02d}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _symbol(v: str | None) -> str | None:
|
|
71
|
+
return _ascii(v) or None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _search(params: dict[str, Any], ttl: float) -> dict[str, Any]:
|
|
75
|
+
# The flags codal's own search page sends (setDefaultSearchModel); -1 means any.
|
|
76
|
+
query = {
|
|
77
|
+
"Audited": "true",
|
|
78
|
+
"AuditorRef": -1,
|
|
79
|
+
"Category": -1,
|
|
80
|
+
"Childs": "true",
|
|
81
|
+
"CompanyState": -1,
|
|
82
|
+
"CompanyType": -1,
|
|
83
|
+
"Consolidatable": "true",
|
|
84
|
+
"IsNotAudited": "false",
|
|
85
|
+
"Length": -1,
|
|
86
|
+
"LetterType": -1,
|
|
87
|
+
"Mains": "true",
|
|
88
|
+
"NotAudited": "true",
|
|
89
|
+
"NotConsolidatable": "true",
|
|
90
|
+
"PageNumber": 1,
|
|
91
|
+
"Publisher": "false",
|
|
92
|
+
"ReportingType": -1,
|
|
93
|
+
"TracingNo": -1,
|
|
94
|
+
"search": "true",
|
|
95
|
+
**{k: v for k, v in params.items() if v is not None},
|
|
96
|
+
}
|
|
97
|
+
body = await fetch(f"{SEARCH}/v2/q", query, ttl=ttl)
|
|
98
|
+
if not isinstance(body, dict) or not isinstance(body.get("Letters"), list):
|
|
99
|
+
raise ApiError("codal.ir returned no search result.")
|
|
100
|
+
return body
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _row(x: dict[str, Any]) -> dict[str, Any]:
|
|
104
|
+
files = [
|
|
105
|
+
name
|
|
106
|
+
for name, flag in (
|
|
107
|
+
("html", "HasHtml"),
|
|
108
|
+
("pdf", "HasPdf"),
|
|
109
|
+
("excel", "HasExcel"),
|
|
110
|
+
("attachments", "HasAttachment"),
|
|
111
|
+
)
|
|
112
|
+
if x.get(flag)
|
|
113
|
+
]
|
|
114
|
+
row = {
|
|
115
|
+
"tracing_no": x.get("TracingNo"),
|
|
116
|
+
"symbol": _ascii(x.get("Symbol")),
|
|
117
|
+
"company": _ascii(x.get("CompanyName")),
|
|
118
|
+
"title": _ascii(x.get("Title")),
|
|
119
|
+
"sent": _ascii(x.get("SentDateTime")),
|
|
120
|
+
"code": _ascii(x.get("LetterCode")),
|
|
121
|
+
"files": files,
|
|
122
|
+
}
|
|
123
|
+
if x.get("PublishDateTime") and x.get("PublishDateTime") != x.get("SentDateTime"):
|
|
124
|
+
row["published"] = _ascii(x["PublishDateTime"])
|
|
125
|
+
if x.get("UnderSupervision"):
|
|
126
|
+
row["under_supervision"] = True
|
|
127
|
+
return row
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@tool("Search Codal disclosures")
|
|
131
|
+
async def cd_search_letters(
|
|
132
|
+
symbol: Annotated[
|
|
133
|
+
str | None,
|
|
134
|
+
Field(description="Exact trading symbol in Persian, e.g. فولاد or خودرو. Use cd_company to find it."),
|
|
135
|
+
] = None,
|
|
136
|
+
category: Annotated[
|
|
137
|
+
int | None,
|
|
138
|
+
Field(
|
|
139
|
+
description="Letter group: 1 financial statements, board reports and dividend schedules; 2 material information, clarifications and EPS forecasts; 3 monthly activity and portfolio reports; 4 articles of association and prospectuses; 5 board and audit committee; 6 shareholder meetings (invitations, decisions); 7 capital increase; 8 exchange notices; 9 SEO (regulator) notices; 10 other; 11 debt securities.",
|
|
140
|
+
ge=1,
|
|
141
|
+
le=11,
|
|
142
|
+
),
|
|
143
|
+
] = None,
|
|
144
|
+
letter_type: Annotated[
|
|
145
|
+
int | None,
|
|
146
|
+
Field(
|
|
147
|
+
description="One letter type id from cd_letter_types, e.g. 6 interim/annual financial statements, 58 monthly activity report, 20 annual general meeting decisions, 11 material information, 30 dividend payment schedule.",
|
|
148
|
+
ge=1,
|
|
149
|
+
),
|
|
150
|
+
] = None,
|
|
151
|
+
title: Annotated[
|
|
152
|
+
str | None,
|
|
153
|
+
Field(description="Words that must appear in the letter title, in Persian, e.g. سود or افزایش سرمایه."),
|
|
154
|
+
] = None,
|
|
155
|
+
from_date: Annotated[
|
|
156
|
+
str | None, Field(description="Sent on or after this Jalali date, YYYY/MM/DD, e.g. 1405/07/01.")
|
|
157
|
+
] = None,
|
|
158
|
+
to_date: Annotated[str | None, Field(description="Sent on or before this Jalali date, YYYY/MM/DD.")] = None,
|
|
159
|
+
period_months: Annotated[
|
|
160
|
+
int | None,
|
|
161
|
+
Field(
|
|
162
|
+
description="Only reports covering this many months, e.g. 12 annual, 6 half-year, 3 quarterly, 1 monthly.",
|
|
163
|
+
ge=1,
|
|
164
|
+
le=12,
|
|
165
|
+
),
|
|
166
|
+
] = None,
|
|
167
|
+
tracing_no: Annotated[
|
|
168
|
+
int | None, Field(description="Codal tracing number (شماره پیگیری) of one letter, e.g. 1612323.", ge=1)
|
|
169
|
+
] = None,
|
|
170
|
+
page: Annotated[int, Field(description="Result page, 20 letters each, newest first.", ge=1)] = 1,
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
"""Search codal.ir disclosures (letters) of Iranian listed companies, newest first, 20 per page.
|
|
173
|
+
|
|
174
|
+
Use it for "latest letters of a company", financial statements, monthly reports, meeting decisions, dividend
|
|
175
|
+
schedules or capital increases. With no filters it lists the newest letters of all companies. Combine filters
|
|
176
|
+
to narrow down; `total` is the number of matching letters and `pages` the number of pages (pass `page` for more).
|
|
177
|
+
Each letter has its `tracing_no`: pass it to cd_letter to read the letter's text and tables and get its PDF,
|
|
178
|
+
Excel and attachment links. `files` lists what the letter has. Dates and times are Jalali (Tehran time).
|
|
179
|
+
No results: check the symbol with cd_company, widen the dates or drop a filter.
|
|
180
|
+
"""
|
|
181
|
+
body = await _search(
|
|
182
|
+
{
|
|
183
|
+
"Symbol": _symbol(symbol),
|
|
184
|
+
"Category": category,
|
|
185
|
+
"LetterType": letter_type,
|
|
186
|
+
"Subject": _ascii(title) or None,
|
|
187
|
+
"FromDate": _jalali(from_date, "from_date"),
|
|
188
|
+
"ToDate": _jalali(to_date, "to_date"),
|
|
189
|
+
"Length": period_months,
|
|
190
|
+
"TracingNo": tracing_no,
|
|
191
|
+
"PageNumber": page,
|
|
192
|
+
},
|
|
193
|
+
ttl=120,
|
|
194
|
+
)
|
|
195
|
+
return {
|
|
196
|
+
"total": body.get("Total"),
|
|
197
|
+
"page": page,
|
|
198
|
+
"pages": body.get("Page"),
|
|
199
|
+
"letters": [_row(x) for x in body["Letters"] if isinstance(x, dict)],
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class _Text(HTMLParser):
|
|
204
|
+
"""Visible text of a letter page: drops scripts, styles and anything inside a display:none element.
|
|
205
|
+
|
|
206
|
+
Table cells are joined with ' | ', one table row per line.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
VOID = {"br", "img", "input", "meta", "link", "hr", "col", "area", "base", "wbr", "source"}
|
|
210
|
+
BLOCK = {"div", "p", "tr", "table", "li", "h1", "h2", "h3", "h4", "h5", "h6", "br", "ul", "ol", "form"}
|
|
211
|
+
|
|
212
|
+
def __init__(self) -> None:
|
|
213
|
+
super().__init__(convert_charrefs=True)
|
|
214
|
+
self.out: list[str] = []
|
|
215
|
+
self.stack: list[tuple[str, bool]] = [] # (tag, hidden)
|
|
216
|
+
|
|
217
|
+
def _hidden(self) -> bool:
|
|
218
|
+
return bool(self.stack) and self.stack[-1][1]
|
|
219
|
+
|
|
220
|
+
def handle_starttag(self, tag, attrs):
|
|
221
|
+
a = dict(attrs)
|
|
222
|
+
style = re.sub(r"\s", "", (a.get("style") or "").lower())
|
|
223
|
+
hidden = (
|
|
224
|
+
self._hidden()
|
|
225
|
+
or tag in ("script", "style", "head", "select", "option", "noscript")
|
|
226
|
+
or "display:none" in style
|
|
227
|
+
or a.get("type") == "hidden"
|
|
228
|
+
)
|
|
229
|
+
if tag in self.VOID:
|
|
230
|
+
if tag == "br" and not hidden:
|
|
231
|
+
self.out.append("\n")
|
|
232
|
+
return
|
|
233
|
+
self.stack.append((tag, hidden))
|
|
234
|
+
if not hidden and tag in self.BLOCK:
|
|
235
|
+
self.out.append("\n")
|
|
236
|
+
|
|
237
|
+
def handle_endtag(self, tag):
|
|
238
|
+
if tag in self.VOID:
|
|
239
|
+
return
|
|
240
|
+
# Pop up to the matching tag; letter pages are not always well nested.
|
|
241
|
+
for i in range(len(self.stack) - 1, -1, -1):
|
|
242
|
+
if self.stack[i][0] == tag:
|
|
243
|
+
hidden = self.stack[i][1]
|
|
244
|
+
del self.stack[i:]
|
|
245
|
+
if not hidden:
|
|
246
|
+
self.out.append(" | " if tag in ("td", "th") else "\n" if tag in self.BLOCK else "")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
def handle_data(self, data):
|
|
250
|
+
if not self._hidden():
|
|
251
|
+
self.out.append(re.sub(r"\s+", " ", data)) # source line breaks are not visible breaks
|
|
252
|
+
|
|
253
|
+
def text(self) -> str:
|
|
254
|
+
lines = []
|
|
255
|
+
for line in "".join(self.out).split("\n"):
|
|
256
|
+
line = _ascii(line).strip(" |")
|
|
257
|
+
line = re.sub(r"(\s*\|\s*){2,}", " | ", line)
|
|
258
|
+
if line and line != "|":
|
|
259
|
+
lines.append(line)
|
|
260
|
+
return "\n".join(lines)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _visible_text(html: str) -> str:
|
|
264
|
+
p = _Text()
|
|
265
|
+
body = html.find("<body")
|
|
266
|
+
p.feed(html[body:] if body >= 0 else html)
|
|
267
|
+
p.close()
|
|
268
|
+
return p.text()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _datasource(html: str) -> dict[str, Any] | None:
|
|
272
|
+
i = html.find("var datasource = ")
|
|
273
|
+
if i < 0:
|
|
274
|
+
return None
|
|
275
|
+
try:
|
|
276
|
+
d, _ = json.JSONDecoder().raw_decode(html, i + len("var datasource = "))
|
|
277
|
+
except ValueError:
|
|
278
|
+
return None
|
|
279
|
+
return d if isinstance(d, dict) else None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _tables(ds: dict[str, Any]) -> list[dict[str, Any]]:
|
|
283
|
+
"""Each table of the shown sheet as {title, rows}; a row is its cell values joined by ' | '.
|
|
284
|
+
|
|
285
|
+
Rows whose cells are all flagged invisible are hidden helper rows and are dropped. A single invisible cell is
|
|
286
|
+
kept: the site still shows columns such as the second 'درصد تغيير' (change vs. the last full year) that the
|
|
287
|
+
datasource flags isVisible=false (checked in Chrome on tracing no. 1612323).
|
|
288
|
+
"""
|
|
289
|
+
out = []
|
|
290
|
+
for sheet in ds.get("sheets") or []:
|
|
291
|
+
for t in sheet.get("tables") or []:
|
|
292
|
+
rows: dict[int, list[dict[str, Any]]] = {}
|
|
293
|
+
for c in t.get("cells") or []:
|
|
294
|
+
if isinstance(c, dict):
|
|
295
|
+
rows.setdefault(c.get("rowSequence") or 0, []).append(c)
|
|
296
|
+
lines = []
|
|
297
|
+
for k in sorted(rows):
|
|
298
|
+
if not any(c.get("isVisible", True) for c in rows[k]):
|
|
299
|
+
continue
|
|
300
|
+
vals = [_ascii(c.get("value")) for c in sorted(rows[k], key=lambda c: c.get("columnSequence") or 0)]
|
|
301
|
+
while vals and not vals[-1]:
|
|
302
|
+
vals.pop()
|
|
303
|
+
if vals:
|
|
304
|
+
lines.append(" | ".join(vals))
|
|
305
|
+
if lines:
|
|
306
|
+
out.append({"title": _ascii(t.get("title_Fa") or sheet.get("title_Fa")), "rows": lines})
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _sheets(html: str) -> list[dict[str, Any]]:
|
|
311
|
+
m = re.search(r'<select[^>]*name="ctl00\$ddlTable".*?</select>', html, re.S)
|
|
312
|
+
if not m:
|
|
313
|
+
return []
|
|
314
|
+
return [
|
|
315
|
+
{"sheet": int(v), "name": _ascii(unescape(n))}
|
|
316
|
+
for v, n in re.findall(r'<option[^>]*value="(-?\d+)"[^>]*>([^<]*)', m[0])
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _label(html: str, id_: str) -> str:
|
|
321
|
+
m = re.search(rf'id="{id_}"[^>]*>([^<]*)<', html)
|
|
322
|
+
return _ascii(unescape(m[1])) if m else ""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _clip(text: str) -> tuple[str, bool]:
|
|
326
|
+
return (text, False) if len(text) <= MAX_TEXT else (text[:MAX_TEXT], True)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _attachments(html: str) -> list[dict[str, str]]:
|
|
330
|
+
out = []
|
|
331
|
+
# ASP.NET renders the grid with inline styles for Chrome and without for plain curl; match both.
|
|
332
|
+
rows = re.findall(r"window\.open\('(DownloadFile\.aspx\?id=[^']+)'\);\"[^>]*>(.*?)</tr>", unescape(html), re.S)
|
|
333
|
+
for link, cells in rows:
|
|
334
|
+
tds = [_ascii(re.sub(r"<[^>]+>", " ", td)) for td in re.findall(r"<td[^>]*>(.*?)</td>", cells, re.S)]
|
|
335
|
+
tds = [t for t in tds if t]
|
|
336
|
+
out.append(
|
|
337
|
+
{
|
|
338
|
+
"name": tds[0] if tds else "",
|
|
339
|
+
"added": tds[1] if len(tds) > 1 else "",
|
|
340
|
+
"url": f"{WWW}/Reports/{link}",
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
return out
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _split(path: str) -> tuple[str, dict[str, str]]:
|
|
347
|
+
"""A site-relative link from the search API -> (absolute URL without query, query params)."""
|
|
348
|
+
u = urlsplit(path)
|
|
349
|
+
p = u.path if u.path.startswith("/") else f"/{u.path}"
|
|
350
|
+
return f"{WWW}{p}", dict(parse_qsl(u.query))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _link(path: str) -> str:
|
|
354
|
+
if not path:
|
|
355
|
+
return ""
|
|
356
|
+
return path if path.startswith("http") else f"{WWW}{path if path.startswith('/') else '/' + path}"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@tool("Read one Codal letter")
|
|
360
|
+
async def cd_letter(
|
|
361
|
+
tracing_no: Annotated[
|
|
362
|
+
int, Field(description="Tracing number (شماره پیگیری) from cd_search_letters, e.g. 1612323.", ge=1)
|
|
363
|
+
],
|
|
364
|
+
sheet: Annotated[
|
|
365
|
+
int | None,
|
|
366
|
+
Field(
|
|
367
|
+
description="For financial statements with several sheets: the `sheet` id from the `sheets` list of a previous call, e.g. 0 for the balance sheet. Omit for the first sheet."
|
|
368
|
+
),
|
|
369
|
+
] = None,
|
|
370
|
+
) -> dict[str, Any]:
|
|
371
|
+
"""Read one codal.ir letter: its text or tables, plus links to its page, PDF, Excel and attachments.
|
|
372
|
+
|
|
373
|
+
Use it after cd_search_letters to see what a letter says. Structured reports (financial statements, monthly
|
|
374
|
+
activity reports) come back as `tables`, one line per row with cells separated by ' | ' (amounts as on the
|
|
375
|
+
site, usually million Rial; see `unit_note`). Financial statements show one sheet at a time: `sheets` lists
|
|
376
|
+
them (income statement, balance sheet, cash flows ...); call again with `sheet` to read another. Other letters
|
|
377
|
+
come back as `text`. `attachments` are files the company attached (often the real content of an explanation
|
|
378
|
+
letter). Long letters are cut at 16,000 characters (`truncated`); the PDF link has the full letter.
|
|
379
|
+
"""
|
|
380
|
+
body = await _search({"TracingNo": tracing_no}, ttl=LETTER_TTL)
|
|
381
|
+
found = [x for x in body["Letters"] if isinstance(x, dict) and x.get("TracingNo") == tracing_no]
|
|
382
|
+
if not found:
|
|
383
|
+
raise ApiError(f"No codal letter has tracing number {tracing_no}. Check the number with cd_search_letters.")
|
|
384
|
+
x = found[0]
|
|
385
|
+
out: dict[str, Any] = _row(x)
|
|
386
|
+
url = x.get("Url") or ""
|
|
387
|
+
out["url"] = _link(url)
|
|
388
|
+
if x.get("HasPdf") and x.get("PdfUrl"):
|
|
389
|
+
out["pdf"] = _link(x["PdfUrl"]) # relative to the site root (checked: /DownloadFile.aspx?hs=... is the PDF)
|
|
390
|
+
if x.get("HasExcel") and x.get("ExcelUrl"):
|
|
391
|
+
out["excel"] = _link(x["ExcelUrl"])
|
|
392
|
+
|
|
393
|
+
if x.get("HasHtml") and "Decision.aspx" in url:
|
|
394
|
+
page_url, params = _split(url)
|
|
395
|
+
if sheet is not None:
|
|
396
|
+
params["sheetId"] = str(sheet)
|
|
397
|
+
html = await fetch(page_url, params, text=True, ttl=LETTER_TTL)
|
|
398
|
+
ds = _datasource(html)
|
|
399
|
+
if ds:
|
|
400
|
+
out["report"] = _ascii(ds.get("title_Fa"))
|
|
401
|
+
out["period_months"] = ds.get("period")
|
|
402
|
+
out["period_end"] = _ascii(ds.get("periodEndToDate"))
|
|
403
|
+
out["year_end"] = _ascii(ds.get("yearEndToDate"))
|
|
404
|
+
out["audited"] = ds.get("isAudited")
|
|
405
|
+
out["consolidated"] = ds.get("isConsolidated")
|
|
406
|
+
out["unit_note"] = _label(html, "ctl00_pPriceNote")
|
|
407
|
+
out["listed_capital"] = _label(html, "ctl00_lblListedCapital")
|
|
408
|
+
sheets = _sheets(html)
|
|
409
|
+
if sheets:
|
|
410
|
+
out["sheets"] = sheets
|
|
411
|
+
shown = re.search(r'<option selected="selected" value="(-?\d+)"', html)
|
|
412
|
+
out["sheet"] = int(shown[1]) if shown else sheet
|
|
413
|
+
tables, size = [], 0
|
|
414
|
+
for t in _tables(ds):
|
|
415
|
+
size += sum(len(r) + 1 for r in t["rows"])
|
|
416
|
+
if size > MAX_TEXT and tables:
|
|
417
|
+
out["truncated"] = True
|
|
418
|
+
break
|
|
419
|
+
tables.append(t)
|
|
420
|
+
out["tables"] = tables
|
|
421
|
+
else:
|
|
422
|
+
out["text"], truncated = _clip(_visible_text(html))
|
|
423
|
+
if truncated:
|
|
424
|
+
out["truncated"] = True
|
|
425
|
+
if x.get("HasAttachment") and x.get("AttachmentUrl"):
|
|
426
|
+
page_url, params = _split(x["AttachmentUrl"])
|
|
427
|
+
out["attachments"] = _attachments(await fetch(page_url, params, text=True, ttl=LETTER_TTL))
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def _companies() -> list[dict[str, Any]]:
|
|
432
|
+
body = await fetch(f"{SEARCH}/v1/companies", ttl=LOOKUP_TTL)
|
|
433
|
+
if not isinstance(body, list) or not body:
|
|
434
|
+
raise ApiError("codal.ir returned no company list.")
|
|
435
|
+
return [c for c in body if isinstance(c, dict)]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@tool("Find a company on Codal")
|
|
439
|
+
async def cd_company(
|
|
440
|
+
query: Annotated[
|
|
441
|
+
str,
|
|
442
|
+
Field(description="Trading symbol or part of the company name, in Persian, e.g. فولاد or فولاد مبارکه."),
|
|
443
|
+
],
|
|
444
|
+
limit: Annotated[int, Field(description="Most companies to return.", ge=1, le=50)] = 10,
|
|
445
|
+
) -> dict[str, Any]:
|
|
446
|
+
"""Find companies and funds on codal.ir by symbol or name, with their market, industry and type.
|
|
447
|
+
|
|
448
|
+
Use it to get the exact `symbol` for cd_search_letters and cd_financial_years, or to answer what a symbol is.
|
|
449
|
+
Exact symbol or name matches come first, then symbols starting with the query, then names containing it;
|
|
450
|
+
within each, exchange-listed companies before unlisted ones. Covers the
|
|
451
|
+
~5,400 publishers on codal.ir, including unlisted companies and funds. `matched` is the total number of hits.
|
|
452
|
+
"""
|
|
453
|
+
q = _key(query)
|
|
454
|
+
if not q:
|
|
455
|
+
raise ApiError("query must not be empty.")
|
|
456
|
+
companies = await _companies()
|
|
457
|
+
groups = await fetch(f"{SEARCH}/v1/IndustryGroup", ttl=LOOKUP_TTL)
|
|
458
|
+
industry = {g.get("Id"): g.get("Name") for g in groups if isinstance(g, dict)} if isinstance(groups, list) else {}
|
|
459
|
+
ranked = []
|
|
460
|
+
for c in companies:
|
|
461
|
+
sy, name = _key(str(c.get("sy") or "")), _key(str(c.get("n") or ""))
|
|
462
|
+
rank = 0 if q in (sy, name) else 1 if sy.startswith(q) else 2 if q in name else 3 if q in sy else None
|
|
463
|
+
if rank is not None:
|
|
464
|
+
ranked.append((rank, c.get("st") not in (0, 1, 4, 5), len(sy), c)) # exchange-listed first
|
|
465
|
+
ranked.sort(key=lambda r: r[:3])
|
|
466
|
+
rows = []
|
|
467
|
+
for *_, c in ranked[:limit]:
|
|
468
|
+
row = {
|
|
469
|
+
"symbol": _ascii(c.get("sy")),
|
|
470
|
+
"name": _ascii(c.get("n")),
|
|
471
|
+
"isic": _ascii(c.get("i")),
|
|
472
|
+
"market": MARKETS.get(c.get("st")),
|
|
473
|
+
"kind": KINDS.get(c.get("t")),
|
|
474
|
+
"industry": _ascii(industry.get(c.get("IG"))) or None,
|
|
475
|
+
"reporting_type": REPORTING.get(c.get("RT")),
|
|
476
|
+
}
|
|
477
|
+
rows.append(row)
|
|
478
|
+
return {"matched": len(ranked), "companies": rows}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@tool("List Codal letter types")
|
|
482
|
+
async def cd_letter_types(
|
|
483
|
+
category: Annotated[
|
|
484
|
+
int | None,
|
|
485
|
+
Field(description="Letter group code (1-11) to list its letter types; omit to list the groups.", ge=1, le=11),
|
|
486
|
+
] = None,
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
"""List codal.ir letter groups, or the letter types of one group with the ids cd_search_letters takes.
|
|
489
|
+
|
|
490
|
+
Call it without arguments for the 11 groups, then with `category` for that group's letter types. Use the
|
|
491
|
+
`id` of a type as `letter_type` in cd_search_letters (with or without the same `category`).
|
|
492
|
+
"""
|
|
493
|
+
body = await fetch(f"{SEARCH}/v1/categories", ttl=LOOKUP_TTL)
|
|
494
|
+
if not isinstance(body, list):
|
|
495
|
+
raise ApiError("codal.ir returned no category list.")
|
|
496
|
+
cats = [c for c in body if isinstance(c, dict) and isinstance(c.get("Code"), int) and c["Code"] > 0]
|
|
497
|
+
|
|
498
|
+
def types(c: dict[str, Any]) -> dict[int, str]:
|
|
499
|
+
seen: dict[int, str] = {}
|
|
500
|
+
for p in c.get("PublisherTypes") or []:
|
|
501
|
+
for lt in p.get("LetterTypes") or []:
|
|
502
|
+
if isinstance(lt, dict) and lt.get("Id") not in seen:
|
|
503
|
+
seen[lt.get("Id")] = _ascii(lt.get("Name"))
|
|
504
|
+
return seen
|
|
505
|
+
|
|
506
|
+
if category is None:
|
|
507
|
+
return {
|
|
508
|
+
"categories": [
|
|
509
|
+
{"category": c["Code"], "name": _ascii(c.get("Name")), "letter_types": len(types(c))} for c in cats
|
|
510
|
+
]
|
|
511
|
+
}
|
|
512
|
+
match = [c for c in cats if c["Code"] == category]
|
|
513
|
+
if not match:
|
|
514
|
+
raise ApiError(f"Unknown category {category}; call cd_letter_types without arguments for the list.")
|
|
515
|
+
return {
|
|
516
|
+
"category": category,
|
|
517
|
+
"name": _ascii(match[0].get("Name")),
|
|
518
|
+
"letter_types": [{"id": k, "name": v} for k, v in sorted(types(match[0]).items())],
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@tool("Fiscal years of a company")
|
|
523
|
+
async def cd_financial_years(
|
|
524
|
+
symbol: Annotated[str, Field(description="Exact trading symbol in Persian, e.g. فولاد.")],
|
|
525
|
+
) -> dict[str, Any]:
|
|
526
|
+
"""List the fiscal year-end dates codal.ir knows for one company, newest first (Jalali).
|
|
527
|
+
|
|
528
|
+
Use it to learn when a company's fiscal year ends (e.g. 12/29 or 03/31) before searching for its annual
|
|
529
|
+
statements with cd_search_letters (from_date after the year end, period_months 12). An empty list usually
|
|
530
|
+
means the symbol is wrong: check it with cd_company.
|
|
531
|
+
"""
|
|
532
|
+
sy = _symbol(symbol)
|
|
533
|
+
if not sy:
|
|
534
|
+
raise ApiError("symbol must not be empty.")
|
|
535
|
+
body = await fetch(f"{SEARCH}/v1/financialYears", {"Symbol": sy}, ttl=LOOKUP_TTL)
|
|
536
|
+
if not isinstance(body, list):
|
|
537
|
+
raise ApiError("codal.ir returned no fiscal years.")
|
|
538
|
+
return {"symbol": sy, "year_ends": [_ascii(y) for y in body]}
|