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 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
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
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("&amp;", "&")
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("&amp;", "&"))
440
+ if details
441
+ else "",
442
+ status_url=urljoin(page_url, final.group(0).replace("&amp;", "&"))
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("&amp;", "&")))
588
+ elif "pdfDocument.php" in href:
589
+ documents.setdefault(
590
+ "request", urljoin(page_url, href.replace("&amp;", "&"))
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}