rti-mcp 0.3.2__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.
- rti_mcp/__init__.py +18 -0
- rti_mcp/__main__.py +3 -0
- rti_mcp/_version.py +24 -0
- rti_mcp/client.py +636 -0
- rti_mcp/config.py +49 -0
- rti_mcp/server.py +420 -0
- rti_mcp-0.3.2.dist-info/METADATA +373 -0
- rti_mcp-0.3.2.dist-info/RECORD +12 -0
- rti_mcp-0.3.2.dist-info/WHEEL +5 -0
- rti_mcp-0.3.2.dist-info/entry_points.txt +2 -0
- rti_mcp-0.3.2.dist-info/licenses/LICENSE +21 -0
- rti_mcp-0.3.2.dist-info/top_level.txt +1 -0
rti_mcp/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""MCP server for querying the RTI Online portal (rtionline.gov.in) via a
|
|
2
|
+
long-lived "View History" session URL."""
|
|
3
|
+
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version as _dist_version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
# Installed (including `pip install -e .`): the built metadata is the
|
|
9
|
+
# authority, so this reports exactly what pip resolved.
|
|
10
|
+
__version__ = _dist_version("rti-mcp")
|
|
11
|
+
except PackageNotFoundError: # pragma: no cover - running from a bare checkout
|
|
12
|
+
try:
|
|
13
|
+
# Generated by setuptools-scm at build time; absent until then.
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
except ImportError:
|
|
16
|
+
__version__ = "0+unknown"
|
|
17
|
+
|
|
18
|
+
__all__ = ["__version__"]
|
rti_mcp/__main__.py
ADDED
rti_mcp/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.3.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 2)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'gbf61f29c6'
|
rti_mcp/client.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""Scraper for the RTI Online citizen "View History" area.
|
|
2
|
+
|
|
3
|
+
Everything hangs off one seed URL -- the tokenised `citizen_view_history.php`
|
|
4
|
+
link the portal hands out after an OTP + captcha login. That URL authenticates
|
|
5
|
+
itself (`emailchk`/`cellchk`/`urletoken` are server-side encrypted blobs), so
|
|
6
|
+
no cookie jar has to be carried over from the browser.
|
|
7
|
+
|
|
8
|
+
Page graph
|
|
9
|
+
----------
|
|
10
|
+
citizen_view_history.php dashboard: 6 counts + 6 drill-down links
|
|
11
|
+
-> list_action_status_new.php every application in a (category, status)
|
|
12
|
+
bucket, in one table, no paging
|
|
13
|
+
-> regdetails.php full application: PA, applicant, text
|
|
14
|
+
-> finalstatus_viewhistory.php
|
|
15
|
+
current status, remarks, reply document
|
|
16
|
+
-> viewPDF.php reply PDF
|
|
17
|
+
-> pdfDocument.php document attached to the request
|
|
18
|
+
|
|
19
|
+
Navigation rules (established empirically -- the portal enforces them with
|
|
20
|
+
bare 403s, not error pages):
|
|
21
|
+
|
|
22
|
+
1. A list fetch mints `regId`/`token` params for that page's rows only.
|
|
23
|
+
Fetching any other list invalidates the previous page's links, so detail
|
|
24
|
+
links cannot be cached across runs.
|
|
25
|
+
2. Several detail pages may be read off one list fetch, but only while that
|
|
26
|
+
list fetch is the most recent *successful* navigation.
|
|
27
|
+
3. Any 403 poisons the session: the next request fails too, whatever it is.
|
|
28
|
+
Re-walking seed -> list clears it.
|
|
29
|
+
|
|
30
|
+
`_walk_to` and `_fetch_detail` encode those rules; callers just ask for an
|
|
31
|
+
application and get its details.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import re
|
|
38
|
+
import time
|
|
39
|
+
from collections.abc import Iterable
|
|
40
|
+
from dataclasses import asdict, dataclass, field
|
|
41
|
+
from typing import Any
|
|
42
|
+
from urllib.parse import parse_qs, urljoin, urlparse
|
|
43
|
+
|
|
44
|
+
import requests
|
|
45
|
+
from bs4 import BeautifulSoup
|
|
46
|
+
|
|
47
|
+
from . import config
|
|
48
|
+
|
|
49
|
+
CATEGORIES = ("request", "appeal")
|
|
50
|
+
STATUSES = ("registered", "disposed", "pending")
|
|
51
|
+
|
|
52
|
+
# Labels as the portal renders them, normalised to our status keys.
|
|
53
|
+
_STATUS_LABELS = {
|
|
54
|
+
"registered": "registered",
|
|
55
|
+
"disposed of": "disposed",
|
|
56
|
+
"disposedof": "disposed",
|
|
57
|
+
"pending": "pending",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# e.g. QCCBN/R/E/26/00003; transferred requests carry hop suffixes:
|
|
61
|
+
# BSNKN/R/E/26/00071/23. Authority codes are not plain alphanumerics -- some
|
|
62
|
+
# carry punctuation from the department's initials (DOP&T, DOA&C), so those
|
|
63
|
+
# characters have to be allowed or the rows are dropped as unrecognised.
|
|
64
|
+
_REG_NO_RE = re.compile(r"^[A-Z0-9&.\-]{3,12}/[A-Z]/[A-Z]/\d{2,4}/\d+(?:/\d+)*$")
|
|
65
|
+
_STATUS_DATE_RE = re.compile(r"^(.*?)\s*\((\d{2}/\d{2}/\d{4})\)\s*$", re.S)
|
|
66
|
+
|
|
67
|
+
MIN_REQUEST_GAP = 0.6 # be polite to a government server
|
|
68
|
+
NETWORK_RETRIES = 3
|
|
69
|
+
RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RTIError(RuntimeError):
|
|
73
|
+
"""Any failure talking to the portal."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Blocked(RTIError):
|
|
77
|
+
"""A 403 -- the navigation token was stale, or the session is poisoned."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SessionExpired(RTIError):
|
|
81
|
+
"""The seed URL no longer authenticates."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, detail: str = "") -> None:
|
|
84
|
+
super().__init__(
|
|
85
|
+
"The RTI Online session URL is no longer valid"
|
|
86
|
+
+ (f" ({detail})" if detail else "")
|
|
87
|
+
+ ". Log in at https://rtionline.gov.in with OTP + captcha, click "
|
|
88
|
+
"'View History', copy the whole URL from the address bar, and pass "
|
|
89
|
+
"it to the `rti_set_session_url` tool."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _clean(text: str) -> str:
|
|
94
|
+
return re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cell_text(cell) -> str:
|
|
98
|
+
return _clean(cell.get_text(" ", strip=True))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Application:
|
|
103
|
+
"""One row of an application list, plus the links needed to drill in.
|
|
104
|
+
|
|
105
|
+
`details_url`/`status_url` are only usable in the session that fetched
|
|
106
|
+
them, and only until the next list fetch -- see the module docstring.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
registration_number: str
|
|
110
|
+
applicant: str
|
|
111
|
+
date_of_receipt: str # ISO, e.g. 2026-08-06
|
|
112
|
+
status: str
|
|
113
|
+
status_date: str # dd/mm/yyyy
|
|
114
|
+
category: str # request | appeal
|
|
115
|
+
bucket: str # registered | disposed | pending
|
|
116
|
+
details_url: str = ""
|
|
117
|
+
status_url: str = ""
|
|
118
|
+
|
|
119
|
+
def public(self) -> dict[str, Any]:
|
|
120
|
+
d = asdict(self)
|
|
121
|
+
d.pop("details_url", None)
|
|
122
|
+
d.pop("status_url", None)
|
|
123
|
+
return d
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class Dashboard:
|
|
128
|
+
user_name: str = ""
|
|
129
|
+
email: str = ""
|
|
130
|
+
as_on: str = ""
|
|
131
|
+
counts: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
132
|
+
list_urls: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
133
|
+
|
|
134
|
+
def public(self) -> dict[str, Any]:
|
|
135
|
+
return {
|
|
136
|
+
"user_name": self.user_name,
|
|
137
|
+
"email": self.email,
|
|
138
|
+
"as_on": self.as_on,
|
|
139
|
+
"counts": self.counts,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class RTIClient:
|
|
144
|
+
def __init__(self, session_url: str | None = None, ttl: int | None = None) -> None:
|
|
145
|
+
self.session_url = (session_url or config.get_session_url() or "").strip()
|
|
146
|
+
if not self.session_url:
|
|
147
|
+
raise RTIError(
|
|
148
|
+
"No RTI session URL configured. Set the RTI_HISTORY_URL environment "
|
|
149
|
+
"variable, or call the `rti_set_session_url` tool with the "
|
|
150
|
+
"citizen_view_history.php URL from your logged-in browser."
|
|
151
|
+
)
|
|
152
|
+
self.ttl = config.DEFAULT_TTL if ttl is None else ttl
|
|
153
|
+
self.http = requests.Session()
|
|
154
|
+
self.http.headers.update(
|
|
155
|
+
{
|
|
156
|
+
"User-Agent": config.USER_AGENT,
|
|
157
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
158
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
self._dashboard: Dashboard | None = None
|
|
162
|
+
self._last_request = 0.0
|
|
163
|
+
# Which list the session's live tokens currently belong to, and the
|
|
164
|
+
# rows parsed from that fetch.
|
|
165
|
+
self._live_key: tuple[str, str] | None = None
|
|
166
|
+
self._live_rows: list[Application] = []
|
|
167
|
+
# Table rows whose registration number did not parse, per (category,
|
|
168
|
+
# bucket). A silently-dropped row is indistinguishable from an
|
|
169
|
+
# application that does not exist, so they are kept and reported.
|
|
170
|
+
self._unparsed: dict[tuple[str, str], list[str]] = {}
|
|
171
|
+
|
|
172
|
+
# ----------------------------------------------------------------- http
|
|
173
|
+
|
|
174
|
+
def _get(self, url: str, *, timeout: int = 120) -> requests.Response:
|
|
175
|
+
gap = time.monotonic() - self._last_request
|
|
176
|
+
if gap < MIN_REQUEST_GAP:
|
|
177
|
+
time.sleep(MIN_REQUEST_GAP - gap)
|
|
178
|
+
|
|
179
|
+
last = ""
|
|
180
|
+
for attempt in range(NETWORK_RETRIES):
|
|
181
|
+
try:
|
|
182
|
+
resp = self.http.get(url, timeout=timeout)
|
|
183
|
+
except requests.RequestException as exc:
|
|
184
|
+
last = f"network error: {exc}"
|
|
185
|
+
else:
|
|
186
|
+
self._last_request = time.monotonic()
|
|
187
|
+
if resp.status_code == 200:
|
|
188
|
+
return resp
|
|
189
|
+
if resp.status_code == 403:
|
|
190
|
+
# Token/navigation problem, not something a plain retry
|
|
191
|
+
# fixes -- the caller has to re-walk.
|
|
192
|
+
self._live_key = None
|
|
193
|
+
raise Blocked(f"HTTP 403 for {url}")
|
|
194
|
+
last = f"HTTP {resp.status_code}"
|
|
195
|
+
if resp.status_code not in RETRY_STATUS:
|
|
196
|
+
break
|
|
197
|
+
self._last_request = time.monotonic()
|
|
198
|
+
if attempt < NETWORK_RETRIES - 1:
|
|
199
|
+
time.sleep(2.0 * (2**attempt))
|
|
200
|
+
raise RTIError(f"RTI Online request failed ({last}): {url}")
|
|
201
|
+
|
|
202
|
+
def _get_html(self, url: str, *, timeout: int = 120) -> tuple[str, str]:
|
|
203
|
+
"""Return (html, final_url). Raises SessionExpired on a bounce to login."""
|
|
204
|
+
resp = self._get(url, timeout=timeout)
|
|
205
|
+
html = resp.text
|
|
206
|
+
if ("login.php" in resp.url or "captcha.php" in html.lower()) and (
|
|
207
|
+
"UserName" not in html
|
|
208
|
+
):
|
|
209
|
+
raise SessionExpired("redirected to the login page")
|
|
210
|
+
return html, resp.url
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------ dashboard
|
|
213
|
+
|
|
214
|
+
def dashboard(self, refresh: bool = False) -> Dashboard:
|
|
215
|
+
"""Fetch the seed page. Also resets the portal's navigation state."""
|
|
216
|
+
if self._dashboard is not None and not refresh:
|
|
217
|
+
return self._dashboard
|
|
218
|
+
|
|
219
|
+
html, page_url = self._get_html(self.session_url)
|
|
220
|
+
self._live_key = None
|
|
221
|
+
self._live_rows = []
|
|
222
|
+
|
|
223
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
224
|
+
text = _clean(soup.get_text(" ", strip=True))
|
|
225
|
+
|
|
226
|
+
dash = Dashboard()
|
|
227
|
+
if m := re.search(r"UserName\s*:-\s*(.*?)\s*Email\s*:-\s*(\S+@\S+)", text):
|
|
228
|
+
dash.user_name, dash.email = m.group(1), m.group(2)
|
|
229
|
+
if m := re.search(r"Status as on\s*(\d{2}-\d{2}-\d{4})", text):
|
|
230
|
+
dash.as_on = m.group(1)
|
|
231
|
+
|
|
232
|
+
# Drill-down links sit in href, or inside redirectUrl('...') on an
|
|
233
|
+
# onclick. Group by the `action` param: first distinct value is
|
|
234
|
+
# Requests, second is Appeals.
|
|
235
|
+
anchors: list[tuple[str, str]] = []
|
|
236
|
+
for a in soup.find_all("a"):
|
|
237
|
+
raw = a.get("href") or a.get("onclick") or ""
|
|
238
|
+
if m := re.search(r"(list_action_status_new\.php\?[^'\"()]+)", raw):
|
|
239
|
+
anchors.append((_clean(a.get_text(" ", strip=True)), m.group(1)))
|
|
240
|
+
|
|
241
|
+
if not anchors or not dash.user_name:
|
|
242
|
+
raise SessionExpired("the dashboard did not render an application summary")
|
|
243
|
+
|
|
244
|
+
order: list[str] = []
|
|
245
|
+
for _, href in anchors:
|
|
246
|
+
action = parse_qs(urlparse(href).query).get("action", [""])[0]
|
|
247
|
+
if action not in order:
|
|
248
|
+
order.append(action)
|
|
249
|
+
|
|
250
|
+
for label, href in anchors:
|
|
251
|
+
action = parse_qs(urlparse(href).query).get("action", [""])[0]
|
|
252
|
+
idx = order.index(action)
|
|
253
|
+
if idx > 1:
|
|
254
|
+
continue
|
|
255
|
+
category = CATEGORIES[idx]
|
|
256
|
+
count = None
|
|
257
|
+
key = _STATUS_LABELS.get(label.lower().strip())
|
|
258
|
+
if key is None:
|
|
259
|
+
if cm := re.fullmatch(r"\[(\d+)\]", label):
|
|
260
|
+
# A count anchor shares its href with its label anchor.
|
|
261
|
+
count = int(cm.group(1))
|
|
262
|
+
key = self._status_for_href(anchors, href)
|
|
263
|
+
if key is None:
|
|
264
|
+
continue
|
|
265
|
+
dash.list_urls.setdefault(category, {})[key] = urljoin(
|
|
266
|
+
page_url, href.replace("&", "&")
|
|
267
|
+
)
|
|
268
|
+
if count is not None:
|
|
269
|
+
dash.counts.setdefault(category, {})[key] = count
|
|
270
|
+
|
|
271
|
+
for category in CATEGORIES:
|
|
272
|
+
dash.counts.setdefault(category, {})
|
|
273
|
+
dash.list_urls.setdefault(category, {})
|
|
274
|
+
|
|
275
|
+
self._dashboard = dash
|
|
276
|
+
return dash
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def _status_for_href(anchors: Iterable[tuple[str, str]], href: str) -> str | None:
|
|
280
|
+
for label, other in anchors:
|
|
281
|
+
if other == href:
|
|
282
|
+
if key := _STATUS_LABELS.get(label.lower().strip()):
|
|
283
|
+
return key
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------- cache
|
|
287
|
+
|
|
288
|
+
def _cache_path(self, category: str, bucket: str):
|
|
289
|
+
return config.CACHE_DIR / f"{category}_{bucket}.json"
|
|
290
|
+
|
|
291
|
+
def _read_cache(self, category: str, bucket: str) -> list[Application] | None:
|
|
292
|
+
try:
|
|
293
|
+
blob = json.loads(
|
|
294
|
+
self._cache_path(category, bucket).read_text(encoding="utf-8")
|
|
295
|
+
)
|
|
296
|
+
except (OSError, ValueError):
|
|
297
|
+
return None
|
|
298
|
+
if blob.get("session_url") != self.session_url:
|
|
299
|
+
return None
|
|
300
|
+
if time.time() - blob.get("fetched_at", 0) > self.ttl:
|
|
301
|
+
return None
|
|
302
|
+
rows = []
|
|
303
|
+
for row in blob.get("rows", []):
|
|
304
|
+
app = Application(**row)
|
|
305
|
+
# Cached tokens are dead by definition; drop them so nothing tries
|
|
306
|
+
# to reuse them.
|
|
307
|
+
app.details_url = app.status_url = ""
|
|
308
|
+
rows.append(app)
|
|
309
|
+
self._unparsed[(category, bucket)] = blob.get("unparsed", [])
|
|
310
|
+
return rows
|
|
311
|
+
|
|
312
|
+
def _write_cache(self, category: str, bucket: str, rows: list[Application]) -> None:
|
|
313
|
+
config.CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
self._cache_path(category, bucket).write_text(
|
|
315
|
+
json.dumps(
|
|
316
|
+
{
|
|
317
|
+
"session_url": self.session_url,
|
|
318
|
+
"fetched_at": time.time(),
|
|
319
|
+
"rows": [asdict(r) for r in rows],
|
|
320
|
+
"unparsed": self._unparsed.get((category, bucket), []),
|
|
321
|
+
},
|
|
322
|
+
indent=1,
|
|
323
|
+
),
|
|
324
|
+
encoding="utf-8",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def cache_age(self, category: str, bucket: str) -> float | None:
|
|
328
|
+
try:
|
|
329
|
+
blob = json.loads(
|
|
330
|
+
self._cache_path(category, bucket).read_text(encoding="utf-8")
|
|
331
|
+
)
|
|
332
|
+
except (OSError, ValueError):
|
|
333
|
+
return None
|
|
334
|
+
return time.time() - blob.get("fetched_at", 0)
|
|
335
|
+
|
|
336
|
+
def unparsed_rows(self) -> dict[str, list[str]]:
|
|
337
|
+
"""Rows seen in a list table but not recognised as applications.
|
|
338
|
+
|
|
339
|
+
Should always be empty. Anything here means the portal changed its
|
|
340
|
+
registration-number format and those applications are missing from
|
|
341
|
+
every result the server returns.
|
|
342
|
+
"""
|
|
343
|
+
found = {
|
|
344
|
+
f"{category}/{bucket}": regs
|
|
345
|
+
for (category, bucket), regs in self._unparsed.items()
|
|
346
|
+
if regs
|
|
347
|
+
}
|
|
348
|
+
# Also read what earlier runs persisted, so this reports honestly even
|
|
349
|
+
# when no list has been fetched in this process.
|
|
350
|
+
for category in CATEGORIES:
|
|
351
|
+
for bucket in STATUSES:
|
|
352
|
+
key = f"{category}/{bucket}"
|
|
353
|
+
if key in found:
|
|
354
|
+
continue
|
|
355
|
+
try:
|
|
356
|
+
blob = json.loads(
|
|
357
|
+
self._cache_path(category, bucket).read_text(encoding="utf-8")
|
|
358
|
+
)
|
|
359
|
+
except (OSError, ValueError):
|
|
360
|
+
continue
|
|
361
|
+
if regs := blob.get("unparsed"):
|
|
362
|
+
found[key] = regs
|
|
363
|
+
return found
|
|
364
|
+
|
|
365
|
+
def clear_cache(self) -> int:
|
|
366
|
+
removed = 0
|
|
367
|
+
if config.CACHE_DIR.exists():
|
|
368
|
+
for path in config.CACHE_DIR.glob("*.json"):
|
|
369
|
+
path.unlink()
|
|
370
|
+
removed += 1
|
|
371
|
+
self._dashboard = None
|
|
372
|
+
self._live_key = None
|
|
373
|
+
self._live_rows = []
|
|
374
|
+
self._unparsed = {}
|
|
375
|
+
return removed
|
|
376
|
+
|
|
377
|
+
# ----------------------------------------------------------------- list
|
|
378
|
+
|
|
379
|
+
def _fetch_list(self, category: str, bucket: str) -> list[Application]:
|
|
380
|
+
"""Fetch a bucket's list page, making its row links the live ones."""
|
|
381
|
+
dash = self.dashboard()
|
|
382
|
+
url = dash.list_urls.get(category, {}).get(bucket)
|
|
383
|
+
if not url:
|
|
384
|
+
raise RTIError(f"No {bucket} {category} list link on the dashboard")
|
|
385
|
+
html, page_url = self._get_html(url, timeout=240)
|
|
386
|
+
rows = self._parse_list(html, page_url, category, bucket)
|
|
387
|
+
self._live_key = (category, bucket)
|
|
388
|
+
self._live_rows = rows
|
|
389
|
+
self._write_cache(category, bucket, rows)
|
|
390
|
+
return rows
|
|
391
|
+
|
|
392
|
+
def applications(
|
|
393
|
+
self, category: str, bucket: str, refresh: bool = False
|
|
394
|
+
) -> list[Application]:
|
|
395
|
+
category, bucket = category.lower(), bucket.lower()
|
|
396
|
+
if category not in CATEGORIES:
|
|
397
|
+
raise RTIError(f"category must be one of {CATEGORIES}, got {category!r}")
|
|
398
|
+
if bucket not in STATUSES:
|
|
399
|
+
raise RTIError(f"status must be one of {STATUSES}, got {bucket!r}")
|
|
400
|
+
if not refresh:
|
|
401
|
+
if cached := self._read_cache(category, bucket):
|
|
402
|
+
return cached
|
|
403
|
+
return self._fetch_list(category, bucket)
|
|
404
|
+
|
|
405
|
+
def _parse_list(
|
|
406
|
+
self, html: str, page_url: str, category: str, bucket: str
|
|
407
|
+
) -> list[Application]:
|
|
408
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
409
|
+
out: list[Application] = []
|
|
410
|
+
unparsed: list[str] = []
|
|
411
|
+
for tr in soup.find_all("tr"):
|
|
412
|
+
cells = tr.find_all("td")
|
|
413
|
+
if len(cells) < 5:
|
|
414
|
+
continue
|
|
415
|
+
reg_no = _cell_text(cells[1])
|
|
416
|
+
if not _REG_NO_RE.match(reg_no):
|
|
417
|
+
unparsed.append(reg_no)
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
raw_date = _cell_text(cells[3]) # "2026-08-06 06/08/2026"
|
|
421
|
+
status_raw = _cell_text(cells[4])
|
|
422
|
+
status, status_date = status_raw, ""
|
|
423
|
+
if m := _STATUS_DATE_RE.match(status_raw):
|
|
424
|
+
status, status_date = _clean(m.group(1)), m.group(2)
|
|
425
|
+
|
|
426
|
+
row_html = str(tr)
|
|
427
|
+
details = re.search(r"regdetails\.php\?[^'\"\s]+", row_html)
|
|
428
|
+
final = re.search(r"finalstatus_viewhistory\.php\?[^'\"\s]+", row_html)
|
|
429
|
+
|
|
430
|
+
out.append(
|
|
431
|
+
Application(
|
|
432
|
+
registration_number=reg_no,
|
|
433
|
+
applicant=_cell_text(cells[2]),
|
|
434
|
+
date_of_receipt=raw_date.split(" ")[0] if raw_date else "",
|
|
435
|
+
status=status,
|
|
436
|
+
status_date=status_date,
|
|
437
|
+
category=category,
|
|
438
|
+
bucket=bucket,
|
|
439
|
+
details_url=urljoin(page_url, details.group(0).replace("&", "&"))
|
|
440
|
+
if details
|
|
441
|
+
else "",
|
|
442
|
+
status_url=urljoin(page_url, final.group(0).replace("&", "&"))
|
|
443
|
+
if final
|
|
444
|
+
else "",
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
self._unparsed[(category, bucket)] = unparsed
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
def all_applications(self, refresh: bool = False) -> list[Application]:
|
|
451
|
+
"""Every application, both categories.
|
|
452
|
+
|
|
453
|
+
The 'registered' bucket is the superset -- its count equals disposed +
|
|
454
|
+
pending -- so one fetch per category covers everything.
|
|
455
|
+
"""
|
|
456
|
+
out: list[Application] = []
|
|
457
|
+
for category in CATEGORIES:
|
|
458
|
+
out.extend(self.applications(category, "registered", refresh=refresh))
|
|
459
|
+
return out
|
|
460
|
+
|
|
461
|
+
def find(self, registration_number: str, refresh: bool = False) -> Application:
|
|
462
|
+
wanted = registration_number.strip().upper()
|
|
463
|
+
for row in self.all_applications(refresh=refresh):
|
|
464
|
+
if row.registration_number.upper() == wanted:
|
|
465
|
+
return row
|
|
466
|
+
if not refresh:
|
|
467
|
+
return self.find(registration_number, refresh=True)
|
|
468
|
+
raise RTIError(
|
|
469
|
+
f"No application found with registration number {registration_number!r}. "
|
|
470
|
+
"Use `rti_search` to look it up by public authority or partial number."
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# --------------------------------------------------------------- detail
|
|
474
|
+
|
|
475
|
+
def _live_lookup(self, registration_number: str) -> Application | None:
|
|
476
|
+
"""The row from the currently-live list, if it holds this application."""
|
|
477
|
+
if not self._live_rows:
|
|
478
|
+
return None
|
|
479
|
+
wanted = registration_number.upper()
|
|
480
|
+
return next(
|
|
481
|
+
(r for r in self._live_rows if r.registration_number.upper() == wanted),
|
|
482
|
+
None,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def _walk_to(self, category: str, bucket: str) -> list[Application]:
|
|
486
|
+
"""Make `bucket`'s row links live, re-walking from the seed if needed."""
|
|
487
|
+
if self._live_key == (category, bucket) and self._live_rows:
|
|
488
|
+
return self._live_rows
|
|
489
|
+
try:
|
|
490
|
+
return self._fetch_list(category, bucket)
|
|
491
|
+
except Blocked:
|
|
492
|
+
self.dashboard(refresh=True) # clears a poisoned session
|
|
493
|
+
return self._fetch_list(category, bucket)
|
|
494
|
+
|
|
495
|
+
def _fetch_detail(self, app: Application, attr: str) -> dict[str, Any]:
|
|
496
|
+
"""Read one detail page for `app`, honouring the navigation rules."""
|
|
497
|
+
category, bucket = app.category, app.bucket
|
|
498
|
+
|
|
499
|
+
def attempt() -> dict[str, Any]:
|
|
500
|
+
# Any list already live in this session works if it holds the row,
|
|
501
|
+
# so a run of status checks costs one list fetch, not one each.
|
|
502
|
+
live = self._live_lookup(app.registration_number)
|
|
503
|
+
if live is None or not getattr(live, attr):
|
|
504
|
+
rows = self._walk_to(category, bucket)
|
|
505
|
+
live = next(
|
|
506
|
+
(
|
|
507
|
+
r
|
|
508
|
+
for r in rows
|
|
509
|
+
if r.registration_number.upper()
|
|
510
|
+
== app.registration_number.upper()
|
|
511
|
+
),
|
|
512
|
+
None,
|
|
513
|
+
)
|
|
514
|
+
if live is None:
|
|
515
|
+
raise RTIError(
|
|
516
|
+
f"{app.registration_number} is no longer in the {bucket} "
|
|
517
|
+
f"{category} list -- its status may have changed. "
|
|
518
|
+
"Re-run with refresh=true."
|
|
519
|
+
)
|
|
520
|
+
url = getattr(live, attr)
|
|
521
|
+
if not url:
|
|
522
|
+
raise RTIError(
|
|
523
|
+
f"The portal offers no detail link for {app.registration_number}."
|
|
524
|
+
)
|
|
525
|
+
html, page_url = self._get_html(url)
|
|
526
|
+
return self._parse_fields(html, page_url)
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
parsed = attempt()
|
|
530
|
+
except Blocked:
|
|
531
|
+
# Poisoned session: reset through the seed and walk again.
|
|
532
|
+
self.dashboard(refresh=True)
|
|
533
|
+
parsed = attempt()
|
|
534
|
+
|
|
535
|
+
if not parsed.get("fields"):
|
|
536
|
+
raise RTIError(
|
|
537
|
+
f"The portal returned no details for {app.registration_number}."
|
|
538
|
+
)
|
|
539
|
+
return parsed
|
|
540
|
+
|
|
541
|
+
@staticmethod
|
|
542
|
+
def _parse_fields(html: str, page_url: str) -> dict[str, Any]:
|
|
543
|
+
"""Pull label/value pairs and document links off a detail page.
|
|
544
|
+
|
|
545
|
+
Detail pages are nested label/value tables, so two-cell rows carry the
|
|
546
|
+
data. The application text is the exception: it sits alone in a
|
|
547
|
+
single-cell row just after a 'Description of Information Sought'
|
|
548
|
+
heading.
|
|
549
|
+
"""
|
|
550
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
551
|
+
fields: dict[str, str] = {}
|
|
552
|
+
documents: dict[str, str] = {}
|
|
553
|
+
expecting_text = False
|
|
554
|
+
app_text = ""
|
|
555
|
+
|
|
556
|
+
for tr in soup.find_all("tr"):
|
|
557
|
+
cells = tr.find_all(["td", "th"])
|
|
558
|
+
texts = [_cell_text(c) for c in cells]
|
|
559
|
+
|
|
560
|
+
if len(cells) == 1:
|
|
561
|
+
label = texts[0]
|
|
562
|
+
if not label:
|
|
563
|
+
continue
|
|
564
|
+
# The portal prints the heading twice ("(Description of
|
|
565
|
+
# Information sought (upto 500 characters)" then "Description
|
|
566
|
+
# of Information Sought") before the body, so keep waiting
|
|
567
|
+
# until a row that is not itself a heading turns up.
|
|
568
|
+
if re.search(r"Description of Information sought", label, re.I):
|
|
569
|
+
expecting_text = True
|
|
570
|
+
elif expecting_text and not app_text:
|
|
571
|
+
app_text = label
|
|
572
|
+
expecting_text = False
|
|
573
|
+
continue
|
|
574
|
+
|
|
575
|
+
if len(cells) != 2:
|
|
576
|
+
continue
|
|
577
|
+
label, value = texts[0].rstrip(":").strip(), texts[1]
|
|
578
|
+
if not label or label.lower() == "s.no.":
|
|
579
|
+
continue
|
|
580
|
+
fields.setdefault(label, value)
|
|
581
|
+
|
|
582
|
+
for a in soup.find_all("a", href=True):
|
|
583
|
+
href = a["href"]
|
|
584
|
+
if "regId=" not in href:
|
|
585
|
+
continue
|
|
586
|
+
if "viewPDF.php" in href:
|
|
587
|
+
documents.setdefault("reply", urljoin(page_url, href.replace("&", "&")))
|
|
588
|
+
elif "pdfDocument.php" in href:
|
|
589
|
+
documents.setdefault(
|
|
590
|
+
"request", urljoin(page_url, href.replace("&", "&"))
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
if app_text:
|
|
594
|
+
fields.setdefault("Description of Information Sought", app_text)
|
|
595
|
+
return {"fields": fields, "documents": documents}
|
|
596
|
+
|
|
597
|
+
def status_detail(self, app: Application) -> dict[str, Any]:
|
|
598
|
+
return self._fetch_detail(app, "status_url")
|
|
599
|
+
|
|
600
|
+
def request_detail(self, app: Application) -> dict[str, Any]:
|
|
601
|
+
return self._fetch_detail(app, "details_url")
|
|
602
|
+
|
|
603
|
+
# ------------------------------------------------------------ documents
|
|
604
|
+
|
|
605
|
+
def download(self, app: Application, kind: str, dest) -> dict[str, Any]:
|
|
606
|
+
"""Save a request/reply PDF. The link has to be taken live, like details."""
|
|
607
|
+
kind = kind.lower()
|
|
608
|
+
if kind not in ("reply", "request"):
|
|
609
|
+
raise RTIError("kind must be 'reply' or 'request'")
|
|
610
|
+
|
|
611
|
+
attr = "status_url" if kind == "reply" else "details_url"
|
|
612
|
+
parsed = self._fetch_detail(app, attr)
|
|
613
|
+
url = parsed["documents"].get(kind)
|
|
614
|
+
if not url:
|
|
615
|
+
# The reply PDF is sometimes linked from the details page instead.
|
|
616
|
+
other = self._fetch_detail(
|
|
617
|
+
app, "details_url" if attr == "status_url" else "status_url"
|
|
618
|
+
)
|
|
619
|
+
url = other["documents"].get(kind)
|
|
620
|
+
if not url:
|
|
621
|
+
raise RTIError(
|
|
622
|
+
f"No {kind} document is attached to {app.registration_number}."
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
resp = self._get(url, timeout=240)
|
|
626
|
+
body = resp.content
|
|
627
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
628
|
+
if b"%PDF" not in body[:1024] and "pdf" not in content_type.lower():
|
|
629
|
+
snippet = _clean(BeautifulSoup(resp.text[:4000], "html.parser").get_text(" "))
|
|
630
|
+
raise RTIError(
|
|
631
|
+
f"The portal did not return a PDF for the {kind} document "
|
|
632
|
+
f"(Content-Type: {content_type or 'unknown'}). It said: {snippet[:200]}"
|
|
633
|
+
)
|
|
634
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
635
|
+
dest.write_bytes(body)
|
|
636
|
+
return {"path": str(dest), "bytes": len(body), "content_type": content_type}
|