"""Thin client for the Rajasthan Bhu-Naksha portal.
The portal is the map front-end to the state's land records. Its REST layer,
unlike the Apna Khata nakal page, asks for no login, no OTP and no captcha:
the "captcha" on the map's Nakal button is generated and checked in browser
JavaScript and is never sent to the server.
Two calls matter.
``ListsAfterLevel`` walks a six-level tree: district, tehsil, RI circle,
halka, village, sheet. Codes are passed back as a comma-separated string with
a trailing comma, exactly as the page builds it; without the trailing comma
the call returns empty lists.
``getPlotInfo`` takes a *giscode*, which is the six level codes concatenated
(``01`` + ``002`` + ``0745`` + ``02920`` + ``11035`` + ``001``), and a plot
number, and returns one JSON object whose ``info`` field holds the khata
number and one line per co-owner: name, father or husband, share, ``जाति-``
and residence. A plot that does not exist answers with an empty body and
HTTP 200 (occasionally 204), not an error, so "miss" is a first-class
outcome here.
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import requests
if TYPE_CHECKING:
from collections.abc import Callable
BASE = "https://bhunaksha.rajasthan.gov.in/"
STATE = "08"
LEVELS = 6
LEVEL_LABELS = ("district", "tehsil", "ri", "halka", "village", "sheet")
log = logging.getLogger(__name__)
# The portal's name stopped resolving for seven hours on 8 Sept 2026 while the
# host itself, once reached, answered normally. A crawl that gives up in
# fifteen seconds loses the whole window; one that waits it out loses nothing.
CONNECT_BACKOFF_START = 5.0
CONNECT_BACKOFF_CAP = 600.0
CONNECT_BACKOFF_TOTAL = 7200.0
[docs]
class PortalError(RuntimeError):
"""The portal answered, but not with what the call asked for."""
[docs]
@dataclass(frozen=True)
class Level:
"""One entry in the portal's location tree.
Attributes:
code: The portal's code for this entry, zero-padded as it sends it.
name: The Devanagari name shown in the map's drop-down.
has_data: Whether the portal reports a map (and so plot records)
beneath this entry.
"""
code: str
name: str
has_data: bool
[docs]
def with_connection_backoff[T](
call: Callable[[], T],
*,
what: str,
sleep: Callable[[float], None] = time.sleep,
total: float = CONNECT_BACKOFF_TOTAL,
) -> T:
"""Run ``call`` until it stops failing at the connection level.
Name resolution, connect and read failures are retried with a delay that
doubles from :data:`CONNECT_BACKOFF_START` to :data:`CONNECT_BACKOFF_CAP`
for up to ``total`` seconds. Anything the portal actually answers is not
a connection failure and is returned to the caller as is.
Args:
call: The request to make.
what: Label for the log.
sleep: Sleep function; tests pass a recorder.
total: Seconds of waiting after which to give up.
Returns:
Whatever ``call`` returns once it succeeds.
Raises:
PortalError: When ``total`` seconds of retrying did not get through.
"""
delay, waited, attempt = CONNECT_BACKOFF_START, 0.0, 0
while True:
try:
return call()
except (requests.ConnectionError, requests.Timeout) as exc:
attempt += 1
if waited >= total:
raise PortalError(
f"{what}: unreachable for {waited:.0f}s: {exc}"
) from exc
if attempt > 3:
log.warning(
"%s: attempt %d failed (%s); retrying in %.0fs",
what,
attempt,
exc,
delay,
)
sleep(delay)
waited += delay
delay = min(delay * 2, CONNECT_BACKOFF_CAP)
[docs]
class Session:
"""One HTTP session against the portal, with bounded retries.
The portal has shown no 429s, but latency swings from under a second to
several, and a crawl that hammers it is a crawl that gets blocked. A
connection-level failure is waited out (see
:func:`with_connection_backoff`); an answer the portal gives that is not
what was asked for is retried ``retries`` times and then raised.
"""
def __init__(
self, pause: float = 0.2, timeout: float = 60.0, retries: int = 4
) -> None:
"""Open the session and fetch the map page once to collect cookies.
Args:
pause: Minimum gap in seconds between two requests.
timeout: Per-request timeout in seconds.
retries: Attempts per call before giving up, with exponential
back-off between them.
"""
self.http = requests.Session()
self.http.headers.update(
{
"User-Agent": "Mozilla/5.0 (research crawler; contact in README)",
"Referer": BASE + "Viewmap/",
"X-Requested-With": "XMLHttpRequest",
}
)
self.pause = pause
self.timeout = timeout
self.retries = retries
self._last_request_at = 0.0
with_connection_backoff(
lambda: self.http.get(BASE + "Viewmap/", timeout=timeout),
what="GET Viewmap/",
)
def _post(self, path: str, **form: str | int) -> str:
"""POST a form to ``path`` and return the body, retrying on failure.
Args:
path: Path under :data:`BASE`.
**form: Form fields.
Returns:
The response body; empty for a 204.
Raises:
PortalError: After ``retries`` unexpected answers, or once the
connection back-off is exhausted.
"""
last_error: Exception | None = None
for attempt in range(self.retries):
wait = self.pause - (time.monotonic() - self._last_request_at)
if wait > 0:
time.sleep(wait)
response = with_connection_backoff(
lambda: self.http.post(BASE + path, data=form, timeout=self.timeout),
what=f"POST {path}",
)
self._last_request_at = time.monotonic()
if response.status_code in (200, 204):
return response.text
last_error = PortalError(f"{path}: HTTP {response.status_code}")
time.sleep(2**attempt)
raise PortalError(f"{path}: gave up after {self.retries} tries: {last_error}")
[docs]
def children(self, codes: list[str]) -> list[Level]:
"""List the entries one level below ``codes``.
Args:
codes: Level codes from the district down; empty for the
districts themselves.
Returns:
The child entries in the portal's order.
Raises:
ValueError: When ``codes`` already reaches the sheet level.
PortalError: When the portal's answer is not a non-empty list.
"""
level = len(codes)
if level >= LEVELS:
raise ValueError("already at sheet level")
joined = "".join(code + "," for code in codes)
text = self._post(
"rest/Levels/ListsAfterLevel",
state=STATE,
level=level,
codes=joined,
hasmap="true",
)
payload = json.loads(text)
if not isinstance(payload, list) or not payload:
raise PortalError(f"ListsAfterLevel({joined}): unexpected {text[:120]!r}")
children: list[Level] = []
for entry in payload[0]:
extra = {
item["key"]["value"]: item["value"]["value"]
for item in entry.get("extraParms", {}).get("entry", [])
}
children.append(
Level(
str(entry["code"]),
str(entry["value"]),
extra.get("hasData", "Y") == "Y",
)
)
return children
[docs]
def plot_info(self, giscode: str, plotno: str) -> dict[str, Any] | None:
"""Fetch the record for one plot.
Args:
giscode: The six level codes concatenated; see :func:`giscode`.
plotno: Plot (khasra) number as printed on the map.
Returns:
The portal's JSON object, or ``None`` when the portal says there
is no such plot.
Raises:
PortalError: When the body is neither empty nor JSON.
"""
text = self._post(
"rest/MapInfo/getPlotInfo",
state=STATE,
giscode=giscode,
plotno=plotno,
srcpage="viewmap",
)
if not text.strip():
return None
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise PortalError(
f"getPlotInfo({giscode},{plotno}): not JSON: {text[:120]!r}"
) from exc
[docs]
def extent(self, giscode: str) -> dict[str, Any]:
"""Fetch the georeferenced bounding box of one sheet.
Args:
giscode: The six level codes concatenated.
Returns:
The portal's JSON object describing the extent.
"""
text = self._post(
"rest/MapInfo/getVVVVExtentGeoref", state=STATE, giscode=giscode
)
return json.loads(text)
[docs]
def giscode(codes: list[str]) -> str:
"""Build the sheet key ``getPlotInfo`` wants from the six level codes.
Args:
codes: District, tehsil, RI circle, halka, village and sheet codes.
Returns:
The codes concatenated in that order.
Raises:
ValueError: When there are not exactly six codes.
"""
if len(codes) != LEVELS:
raise ValueError(f"need {LEVELS} codes, got {len(codes)}")
return "".join(codes)