acis-browser 1.0.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.
@@ -0,0 +1,34 @@
1
+ """Automated browser to perform ACIS lookups."""
2
+
3
+ from acis_browser.a_number import normalize_a_number, redact
4
+ from acis_browser.browser import AcisBrowser, Outcome, raise_for_outcome
5
+ from acis_browser.exceptions import (
6
+ AcisError,
7
+ CaptchaError,
8
+ CaseNotFoundError,
9
+ CaseUnavailableError,
10
+ InvalidANumberError,
11
+ InvalidNationalityError,
12
+ UnknownNationalityError,
13
+ UpstreamError,
14
+ )
15
+ from acis_browser.nationalities import Nationality, get_by_code, resolve
16
+
17
+ __all__ = [
18
+ "AcisBrowser",
19
+ "AcisError",
20
+ "CaptchaError",
21
+ "CaseNotFoundError",
22
+ "CaseUnavailableError",
23
+ "InvalidANumberError",
24
+ "InvalidNationalityError",
25
+ "Nationality",
26
+ "Outcome",
27
+ "UnknownNationalityError",
28
+ "UpstreamError",
29
+ "get_by_code",
30
+ "normalize_a_number",
31
+ "raise_for_outcome",
32
+ "redact",
33
+ "resolve",
34
+ ]
@@ -0,0 +1,21 @@
1
+ """Utilities for working with A-Numbers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from acis_browser.exceptions import InvalidANumberError
8
+
9
+ _NON_DIGITS = re.compile(r"[\s\-]")
10
+
11
+
12
+ def normalize_a_number(value: str) -> str:
13
+ candidate = _NON_DIGITS.sub("", value or "")
14
+ candidate = candidate.removeprefix("A").removeprefix("a")
15
+ if not candidate.isdigit() or len(candidate) != 9:
16
+ raise InvalidANumberError(f"A-Number must be 9 digits, got {value!r}")
17
+ return candidate
18
+
19
+
20
+ def redact(a_number: str) -> str:
21
+ return f"***{a_number[-4:]}" if len(a_number) >= 4 else "***"
@@ -0,0 +1,357 @@
1
+ """Wrapper around patchright to drive headed Chrome to do ACIS lookups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import re
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from enum import StrEnum, auto
12
+ from typing import TYPE_CHECKING, Any, Literal, Self
13
+
14
+ import structlog
15
+ from patchright.async_api import Error as PlaywrightError
16
+ from patchright.async_api import TimeoutError as PlaywrightTimeoutError
17
+ from patchright.async_api import async_playwright
18
+
19
+ from acis_browser.a_number import redact
20
+ from acis_browser.exceptions import (
21
+ CaptchaError,
22
+ CaseNotFoundError,
23
+ CaseUnavailableError,
24
+ InvalidNationalityError,
25
+ UpstreamError,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from pathlib import Path
30
+ from types import TracebackType
31
+
32
+ from patchright.async_api import (
33
+ BrowserContext,
34
+ Page,
35
+ Playwright,
36
+ Request,
37
+ Response,
38
+ )
39
+
40
+ logger = structlog.get_logger()
41
+
42
+ ACIS_URL = "https://acis.eoir.justice.gov/en/"
43
+
44
+
45
+ def option_label(nat_name: str, nat_code: str) -> re.Pattern[str]:
46
+ return re.compile(rf"^{re.escape(f'{nat_name} ({nat_code})')}$", re.IGNORECASE)
47
+
48
+
49
+ ##### Outcomes #####
50
+
51
+
52
+ class Outcome(StrEnum):
53
+ """Classification of an ACIS payload by its pinned ``message`` fragments."""
54
+
55
+ OK = auto()
56
+ CAPTCHA_REJECTED = auto()
57
+ UNAVAILABLE = auto()
58
+ NOT_FOUND = auto()
59
+ INVALID_NATIONALITY = auto()
60
+ OTHER = auto()
61
+
62
+ @classmethod
63
+ def from_payload(cls, payload: dict[str, Any]) -> Outcome:
64
+ if isinstance(payload.get("Data"), dict):
65
+ return cls.OK
66
+ message = payload.get("message") or ""
67
+ for fragment, outcome in _MESSAGES:
68
+ if fragment in message:
69
+ return outcome
70
+ return cls.OTHER
71
+
72
+
73
+ _MESSAGES = (
74
+ ("Invalid Captcha Provided", Outcome.CAPTCHA_REJECTED),
75
+ ("Case information is unavailable", Outcome.UNAVAILABLE),
76
+ ("No case info found", Outcome.NOT_FOUND),
77
+ ("Invalid nationality code", Outcome.INVALID_NATIONALITY),
78
+ )
79
+
80
+
81
+ def raise_for_outcome(payload: dict[str, Any]) -> None:
82
+ """Raise the exception matching the payload's outcome; return for a success."""
83
+ outcome = Outcome.from_payload(payload)
84
+ if outcome is Outcome.OK:
85
+ return
86
+ message = payload.get("message") or ""
87
+ if outcome is Outcome.CAPTCHA_REJECTED:
88
+ raise CaptchaError(
89
+ message, reason=CaptchaError.Reason.REJECTED, payload=payload
90
+ )
91
+ if outcome is Outcome.NOT_FOUND:
92
+ raise CaseNotFoundError(message, payload=payload)
93
+ if outcome is Outcome.INVALID_NATIONALITY:
94
+ raise InvalidNationalityError(message, payload=payload)
95
+ if outcome is Outcome.UNAVAILABLE:
96
+ raise CaseUnavailableError(message, payload=payload)
97
+ raise UpstreamError(
98
+ message or "ACIS returned an unexpected payload", payload=payload
99
+ )
100
+
101
+
102
+ ##### Browser #####
103
+
104
+
105
+ @dataclass
106
+ class _Capture:
107
+ requested: bool = False
108
+ status: int = 0
109
+ body: str | None = None
110
+ received: asyncio.Event = field(default_factory=asyncio.Event)
111
+
112
+
113
+ @dataclass(kw_only=True)
114
+ class AcisBrowser:
115
+ """A Playwright/Patchright wrapper to do ACIS lookups."""
116
+
117
+ profile_dir: Path
118
+ lookup_timeout: float = 20
119
+ lookup_attempts: int = 2
120
+ idle_timeout: float = 900
121
+
122
+ # Lock to ensure only one lookup at a time
123
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
124
+ _playwright: Playwright | None = field(default=None, init=False, repr=False)
125
+ _context: BrowserContext | None = field(default=None, init=False, repr=False)
126
+ _last_used: float = field(default=0.0, init=False, repr=False)
127
+
128
+ ##### Lifecycle #####
129
+
130
+ async def start(self) -> None:
131
+ """Launch Chrome. Safe to call repeatedly."""
132
+ if self._context is not None:
133
+ return
134
+ self.profile_dir.mkdir(parents=True, exist_ok=True)
135
+ logger.info("browser starting", profile=str(self.profile_dir))
136
+ self._playwright = await async_playwright().start()
137
+ self._context = await self._playwright.chromium.launch_persistent_context(
138
+ str(self.profile_dir),
139
+ channel="chrome",
140
+ headless=False,
141
+ no_viewport=True,
142
+ )
143
+ self._last_used = time.monotonic()
144
+
145
+ async def close(self) -> None:
146
+ if self._context is not None:
147
+ logger.info("browser stopping")
148
+ try:
149
+ await self._context.close()
150
+ finally:
151
+ self._context = None
152
+ if self._playwright is not None:
153
+ try:
154
+ await self._playwright.stop()
155
+ finally:
156
+ self._playwright = None
157
+
158
+ async def close_if_idle(self) -> bool:
159
+ if self._context is None:
160
+ return False
161
+ idle = time.monotonic() - self._last_used
162
+ if idle < self.idle_timeout:
163
+ return False
164
+ async with self._lock:
165
+ # Re-check under the lock: a lookup may have started meanwhile.
166
+ if self._context is None:
167
+ return False
168
+ if time.monotonic() - self._last_used < self.idle_timeout:
169
+ return False
170
+ logger.info("browser idle, closing", idle_seconds=round(idle))
171
+ await self.close()
172
+ return True
173
+
174
+ async def __aenter__(self) -> Self:
175
+ await self.start()
176
+ return self
177
+
178
+ async def __aexit__(
179
+ self,
180
+ exc_type: type[BaseException] | None,
181
+ exc: BaseException | None,
182
+ tb: TracebackType | None,
183
+ ) -> None:
184
+ await self.close()
185
+
186
+ ##### Lookup #####
187
+
188
+ async def lookup(
189
+ self, a_number: str, nat_code: str, nat_name: str
190
+ ) -> dict[str, Any]:
191
+ """Return the raw ACIS JSON payload for a case, whatever the outcome.
192
+
193
+ Retries transient captcha failures. The form takes the nationality
194
+ *name*; the code only disambiguates the option label, since names are
195
+ substrings of one another.
196
+ """
197
+ attempts = self.lookup_attempts
198
+ async with self._lock:
199
+ await self.start()
200
+ self._last_used = time.monotonic()
201
+ try:
202
+ attempt = 0
203
+ while True:
204
+ attempt += 1
205
+ try:
206
+ payload = await self._lookup_once(a_number, nat_code, nat_name)
207
+ except CaptchaError as exc:
208
+ logger.warning(
209
+ "lookup.captcha_failed",
210
+ attempt=attempt,
211
+ attempts=attempts,
212
+ a_number=redact(a_number),
213
+ nat_code=nat_code,
214
+ reason=exc.reason,
215
+ error=str(exc),
216
+ )
217
+ if attempt >= attempts:
218
+ raise
219
+ await asyncio.sleep(2**attempt)
220
+ except PlaywrightError as exc:
221
+ logger.warning("lookup.browser_lost", error=str(exc))
222
+ with contextlib.suppress(PlaywrightError):
223
+ await self.close()
224
+ raise UpstreamError("Chrome is no longer available") from exc
225
+ else:
226
+ return payload
227
+ finally:
228
+ self._last_used = time.monotonic()
229
+
230
+ async def _lookup_once(
231
+ self, a_number: str, nat_code: str, nat_name: str
232
+ ) -> dict[str, Any]:
233
+ """Fill the form and return the parsed backend response."""
234
+ if self._context is None: # pragma: no cover
235
+ raise UpstreamError("Browser is not running")
236
+ page = await self._context.new_page()
237
+ captured = _Capture()
238
+ case_info_path = "/api/Case/GetCaseInfo"
239
+
240
+ async def on_request(request: Request) -> None:
241
+ if case_info_path in request.url:
242
+ captured.requested = True
243
+
244
+ async def on_response(response: Response) -> None:
245
+ if case_info_path not in response.url:
246
+ return
247
+ captured.status = response.status
248
+ try:
249
+ captured.body = await response.text()
250
+ except Exception as exc: # noqa: BLE001
251
+ logger.warning("could not read ACIS response body", error=str(exc))
252
+ finally:
253
+ captured.received.set()
254
+
255
+ page.on("request", on_request)
256
+ page.on("response", on_response)
257
+
258
+ try:
259
+ await page.goto(ACIS_URL, wait_until="domcontentloaded", timeout=60_000)
260
+ await self._wait_for(
261
+ page,
262
+ '.ReactModal__Overlay, input[inputmode="numeric"]',
263
+ "app",
264
+ timeout_ms=30_000,
265
+ )
266
+ await self._dismiss_modal(page)
267
+ await self._fill_form(page, a_number, nat_code, nat_name)
268
+ await page.locator("#btn_submit").click()
269
+ with contextlib.suppress(TimeoutError):
270
+ async with asyncio.timeout(self.lookup_timeout):
271
+ await captured.received.wait()
272
+ except PlaywrightError as exc:
273
+ raise UpstreamError("Could not lookup case") from exc
274
+ finally:
275
+ try:
276
+ await page.close()
277
+ except PlaywrightError:
278
+ logger.warning("could not close page")
279
+
280
+ return self._parse(captured)
281
+
282
+ async def _wait_for(
283
+ self,
284
+ page: Page,
285
+ selector: str,
286
+ description: str,
287
+ *,
288
+ state: Literal["attached", "detached", "hidden", "visible"] = "visible",
289
+ timeout_ms: int = 5_000,
290
+ ) -> bool:
291
+ try:
292
+ await page.wait_for_selector(selector, state=state, timeout=timeout_ms)
293
+ except PlaywrightTimeoutError:
294
+ logger.warning("lookup.wait_timeout", condition=description, state=state)
295
+ return False
296
+ return True
297
+
298
+ async def _dismiss_modal(self, page: Page) -> None:
299
+ try:
300
+ await page.wait_for_selector(".ReactModal__Overlay", timeout=500)
301
+ except PlaywrightTimeoutError:
302
+ return
303
+ await page.locator(".ReactModalPortal button").last.click()
304
+ await self._wait_for(
305
+ page, ".ReactModal__Overlay", "consent modal to close", state="detached"
306
+ )
307
+
308
+ async def _fill_form(
309
+ self, page: Page, a_number: str, nat_code: str, nat_name: str
310
+ ) -> None:
311
+ type_delay = 70 # milliseconds
312
+ await page.locator('input[inputmode="numeric"]').first.click()
313
+ await page.keyboard.type(a_number, delay=type_delay)
314
+
315
+ await page.locator("#react-select-3-input").click()
316
+ await page.keyboard.type(nat_name, delay=type_delay)
317
+ option = page.get_by_role("option", name=option_label(nat_name, nat_code))
318
+ await option.first.click(timeout=5000)
319
+ await self._wait_for(
320
+ page, '[role="option"]', "nationality menu to close", state="detached"
321
+ )
322
+
323
+ def _parse(self, captured: _Capture) -> dict[str, Any]:
324
+ if not captured.received.is_set():
325
+ if not captured.requested:
326
+ raise CaptchaError(
327
+ "No captcha token obtained: the form was submitted but no case "
328
+ "request followed",
329
+ reason=CaptchaError.Reason.NO_REQUEST,
330
+ )
331
+ raise CaptchaError(
332
+ f"No response to the case request within {self.lookup_timeout}s",
333
+ reason=CaptchaError.Reason.NO_RESPONSE,
334
+ )
335
+ if captured.body is None:
336
+ raise UpstreamError(
337
+ f"Could not read ACIS response body (HTTP {captured.status})"
338
+ )
339
+ status, body = captured.status, captured.body
340
+ try:
341
+ payload = json.loads(body)
342
+ except json.JSONDecodeError as exc:
343
+ raise UpstreamError(f"ACIS returned non-JSON (HTTP {status})") from exc
344
+ if not isinstance(payload, dict):
345
+ raise UpstreamError("ACIS returned an unexpected payload")
346
+
347
+ outcome = Outcome.from_payload(payload)
348
+ if outcome is Outcome.CAPTCHA_REJECTED:
349
+ raise CaptchaError(
350
+ payload["message"], reason=CaptchaError.Reason.REJECTED, payload=payload
351
+ )
352
+ if outcome not in (Outcome.OK, Outcome.OTHER):
353
+ return payload
354
+
355
+ if status != 200:
356
+ raise UpstreamError(f"ACIS returned HTTP {status}")
357
+ return payload
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum, auto
4
+ from typing import Any
5
+
6
+
7
+ class AcisError(Exception):
8
+ """Base class for failures talking to ACIS."""
9
+
10
+ def __init__(self, *args: object, payload: dict[str, Any] | None = None) -> None:
11
+ super().__init__(*args)
12
+ self.payload = payload
13
+
14
+
15
+ class InvalidANumberError(AcisError):
16
+ """Raised when an A-Number isn't valid."""
17
+
18
+
19
+ class UnknownNationalityError(AcisError):
20
+ """Raised when a nationality code or name can't be resolved."""
21
+
22
+
23
+ class CaptchaError(AcisError):
24
+ """Raised when an hCaptcha token can't be obtained or the token is refused."""
25
+
26
+ class Reason(StrEnum):
27
+ NO_REQUEST = auto()
28
+ """Form was submitted but no case request followed."""
29
+ NO_RESPONSE = auto()
30
+ """Case request went out but nothing came back."""
31
+ REJECTED = auto()
32
+ """Token was submitted and rejected."""
33
+
34
+ def __init__(
35
+ self, message: str, *, reason: Reason, payload: dict[str, Any] | None = None
36
+ ) -> None:
37
+ super().__init__(message, payload=payload)
38
+ self.reason = reason
39
+
40
+
41
+ class CaseNotFoundError(AcisError):
42
+ """Raised when ACIS has no case for a given A-Number."""
43
+
44
+
45
+ class InvalidNationalityError(AcisError):
46
+ """Raised when ACIS rejects the nationality code."""
47
+
48
+
49
+ class CaseUnavailableError(AcisError):
50
+ """Raised when the case exists but ACIS won't release information for it."""
51
+
52
+
53
+ class UpstreamError(AcisError):
54
+ """Raised when ACIS returns an unexpected response."""
@@ -0,0 +1,60 @@
1
+ """Utilities for working with EOIR nationality codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from functools import cache
8
+ from pathlib import Path
9
+
10
+ from acis_browser.exceptions import UnknownNationalityError
11
+
12
+ HERE = Path(__file__).parent
13
+ REFERENCE_FILE = HERE / "reference" / "nationality-codes.json"
14
+
15
+ # Placeholder entry in the upstream table; never a valid lookup value.
16
+ UNKNOWN_CODE = "??"
17
+
18
+
19
+ @dataclass(frozen=True, kw_only=True)
20
+ class Nationality:
21
+ code: str
22
+ name: str
23
+
24
+
25
+ @cache
26
+ def _tables() -> tuple[dict[str, Nationality], dict[str, Nationality]]:
27
+ """Return (by_code, by_name) lookup tables of active nationalities."""
28
+ raw = json.loads(REFERENCE_FILE.read_text(encoding="utf-8"))
29
+ by_code: dict[str, Nationality] = {}
30
+ by_name: dict[str, Nationality] = {}
31
+ for entry in raw:
32
+ code = entry["Code"].strip().upper()
33
+ if not entry.get("IsActive") or code == UNKNOWN_CODE:
34
+ continue
35
+ nationality = Nationality(code=code, name=entry["Name"].strip().upper())
36
+ by_code[nationality.code] = nationality
37
+ by_name[nationality.name] = nationality
38
+ return by_code, by_name
39
+
40
+
41
+ def get_by_code(code: str) -> Nationality:
42
+ """Look up strictly by code."""
43
+ by_code, _ = _tables()
44
+ try:
45
+ return by_code[code.strip().upper()]
46
+ except KeyError:
47
+ raise UnknownNationalityError(f"Unknown nationality code: {code!r}") from None
48
+
49
+
50
+ def resolve(value: str) -> Nationality:
51
+ """Resolve a code (``MX``) or a name (``mexico``) to a Nationality."""
52
+ candidate = (value or "").strip().upper()
53
+ if not candidate:
54
+ raise UnknownNationalityError("Nationality is required")
55
+ by_code, by_name = _tables()
56
+ if candidate in by_code:
57
+ return by_code[candidate]
58
+ if candidate in by_name:
59
+ return by_name[candidate]
60
+ raise UnknownNationalityError(f"Unknown nationality: {value!r}")
acis_browser/py.typed ADDED
File without changes