freemyid 0.2.0.dev1__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.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,430 @@
1
+ """Playwright browser wrapper: allowlisted, polite, honest. Never bypasses bot protection.
2
+
3
+ * System browser first: on Windows Microsoft Edge (`channel="msedge"`, on every Windows
4
+ 10/11 machine) then `config.browser_channel`; elsewhere `config.browser_channel`; then
5
+ Playwright's bundled Chromium. FREEMYID_BROWSER_EXECUTABLE overrides (dev machines).
6
+ * Dev fixture mode (`fixture_base`): every allowlisted request is answered by the local
7
+ fixture server (http://127.0.0.1:<port>/<host><path>); nothing reaches the internet.
8
+ * Every request goes through a route handler: anything whose host is not in the broker's
9
+ narrow allowlist (and the global NetPolicy) is aborted.
10
+ * SEC-16: Playwright routes only see the first URL of a redirect chain, and service workers
11
+ bypass routes. So: service workers are blocked; every launch gets a Chromium
12
+ `--host-resolver-rules` floor (off-list DNS names never resolve, even on a redirect hop);
13
+ a request listener records off-list redirect hops / main-frame navigations and `goto` /
14
+ `check_escaped` raise BlockedByPolicy (never retried). Residual gap: IP-literal and
15
+ loopback redirect targets bypass the resolver floor; they are detected (BlockedByPolicy)
16
+ but the request itself has already been sent. Full fix = allowlisting proxy (SEC-16 A).
17
+ * SEC-36: the headed human-in-the-loop windows (D9 fill_for_me, D10 listing finder) may
18
+ opt in with `new_context(..., headed=True, allow_captcha_frames=True)`. Only then the
19
+ CAPTCHA widget hosts (`netpolicy.captcha_subresource_allowed` / `captcha_hosts`) load as
20
+ sub-frames / sub-resources (never a main-frame navigation, never headless, never in dev
21
+ fixture mode), so the person can tick the robot box themselves. Free My ID never touches it.
22
+ * Honest user agent: the browser's real UA + `config.user_agent_suffix`.
23
+ * Random delay min_delay_s..max_delay_s before each navigation, per-domain rate limit,
24
+ retries with exponential backoff.
25
+ * CAPTCHA / challenge pages are DETECTED (`detect_challenge`) and handed to a human.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import glob
31
+ import ipaddress
32
+ import logging
33
+ import os
34
+ import random
35
+ import re
36
+ import sys
37
+ import time
38
+ from collections.abc import Awaitable, Callable
39
+ from typing import TYPE_CHECKING, Any
40
+ from urllib.parse import urlsplit
41
+
42
+ from .. import config as cfgmod
43
+ from ..netpolicy import (
44
+ LOOPBACK_HOSTS,
45
+ NetPolicy,
46
+ _host_ok,
47
+ captcha_hosts,
48
+ captcha_subresource_allowed,
49
+ host_of,
50
+ )
51
+
52
+ if TYPE_CHECKING:
53
+ from playwright.async_api import Browser, BrowserContext, Page, Playwright, Response
54
+
55
+ log = logging.getLogger(__name__)
56
+
57
+ _LOCAL_SCHEMES = ("data:", "about:", "blob:")
58
+
59
+ _CHALLENGE_FRAME = re.compile(
60
+ r"(google\.com/recaptcha|recaptcha\.net|hcaptcha\.com|challenges\.cloudflare\.com|"
61
+ r"turnstile|arkoselabs|funcaptcha|geo\.captcha-delivery\.com|perimeterx|px-captcha)", re.IGNORECASE)
62
+ _CHALLENGE_MARKUP = re.compile(
63
+ r"(class=[\"'][^\"']*(g-recaptcha|h-captcha|cf-turnstile)|id=[\"'](challenge-form|"
64
+ r"cf-challenge-running|px-captcha)[\"']|cf-chl-|data-sitekey=)", re.IGNORECASE)
65
+ _CHALLENGE_TEXT = re.compile(
66
+ r"(verify (that )?you are (a )?human|are you a robot|checking your browser|"
67
+ r"please complete the security check|press (and|&) hold|unusual traffic|"
68
+ r"access denied|attention required)", re.IGNORECASE)
69
+
70
+
71
+ class ChallengeEncountered(Exception):
72
+ """A CAPTCHA / bot challenge / block page. `kind` is a short PII-free label."""
73
+
74
+ def __init__(self, kind: str, url: str = "") -> None:
75
+ super().__init__(kind)
76
+ self.kind = kind
77
+ self.url = url
78
+
79
+
80
+ class BlockedByPolicy(Exception):
81
+ """Navigation target is not on the allowlist."""
82
+
83
+
84
+ def detect_challenge(html: str, frame_urls: list[str] | None = None,
85
+ status: int | None = None) -> str | None:
86
+ """Pure function: return a challenge label or None. Tested against fixtures."""
87
+ for u in frame_urls or []:
88
+ if _CHALLENGE_FRAME.search(u):
89
+ return "captcha_frame"
90
+ if _CHALLENGE_FRAME.search(html) and re.search(r"<iframe|<script", html, re.IGNORECASE):
91
+ return "captcha_frame"
92
+ if _CHALLENGE_MARKUP.search(html):
93
+ return "captcha_widget"
94
+ # body text check (strip tags crudely; keep it cheap)
95
+ text = re.sub(r"<script.*?</script>|<style.*?</style>|<[^>]+>", " ", html, flags=re.DOTALL | re.IGNORECASE)
96
+ if _CHALLENGE_TEXT.search(text):
97
+ return "challenge_text"
98
+ if status in (403, 429, 503):
99
+ return f"http_{status}"
100
+ return None
101
+
102
+
103
+ def channel_candidates(configured: str | None, platform: str | None = None) -> list[str]:
104
+ """Browser channels to try, in order (PLAN_V1 D1: Edge on Windows, no download)."""
105
+ platform = platform or sys.platform
106
+ out: list[str] = []
107
+ if platform == "win32":
108
+ out.append("msedge")
109
+ if configured and configured not in out:
110
+ out.append(configured)
111
+ return out
112
+
113
+
114
+ def _cached_headless_shell() -> str | None:
115
+ """Newest Playwright headless shell in the user cache (dev fixture runs only)."""
116
+ pat = os.path.expanduser(
117
+ "~/.cache/ms-playwright/chromium_headless_shell-*/*/chrome-headless-shell")
118
+ found = sorted(glob.glob(pat))
119
+ return found[-1] if found else None
120
+
121
+
122
+ def fixture_url(base: str, url: str) -> str:
123
+ """https://host/path?q -> <base>/host/path?q (dev fixture server)."""
124
+ sp = urlsplit(url)
125
+ path = sp.path or "/"
126
+ return f"{base.rstrip('/')}/{sp.hostname}{path}" + (f"?{sp.query}" if sp.query else "")
127
+
128
+
129
+ def _is_ip(host: str) -> bool:
130
+ try:
131
+ ipaddress.ip_address(host.strip("[]"))
132
+ return True
133
+ except ValueError:
134
+ return False
135
+
136
+
137
+ def host_resolver_rules(allowed: set[str], fixture_mode: bool = False) -> str:
138
+ """Chromium `--host-resolver-rules` value: every DNS name fails unless allowlisted.
139
+
140
+ Fixture mode only needs the local fixture server (broker hosts are answered by routes).
141
+ The rules also apply to IP literals (verified: an unlisted 127.0.0.1 fails with
142
+ ERR_NAME_NOT_RESOLVED), so allowlisted IPs are excluded without a `*.` wildcard.
143
+ """
144
+ hosts = set(LOOPBACK_HOSTS) if fixture_mode else set(allowed)
145
+ rules = ["MAP * ~NOTFOUND"]
146
+ for h in sorted(hosts):
147
+ if not h or any(c in h for c in ", "):
148
+ continue
149
+ if _is_ip(h):
150
+ rules.append(f"EXCLUDE [{h}]" if ":" in h else f"EXCLUDE {h}")
151
+ else:
152
+ rules += [f"EXCLUDE {h}", f"EXCLUDE *.{h}"]
153
+ return ", ".join(rules)
154
+
155
+
156
+ class RateLimiter:
157
+ """Minimum spacing between requests to the same domain (per process)."""
158
+
159
+ def __init__(self, min_interval_s: float) -> None:
160
+ self.min_interval_s = min_interval_s
161
+ self._last: dict[str, float] = {}
162
+ self._lock = asyncio.Lock()
163
+
164
+ async def wait(self, host: str) -> None:
165
+ async with self._lock:
166
+ now = time.monotonic()
167
+ nxt = self._last.get(host, 0.0) + self.min_interval_s
168
+ delay = max(0.0, nxt - now)
169
+ self._last[host] = now + delay
170
+ if delay:
171
+ await asyncio.sleep(delay)
172
+
173
+
174
+ class BrowserManager:
175
+ """Lazily launched, shared by all brokers in one run. One context per broker."""
176
+
177
+ def __init__(self, netpolicy: NetPolicy, *, headless: bool | None = None,
178
+ cfg: cfgmod.Config | None = None, fixture_base: str | None = None) -> None:
179
+ self.cfg = cfg or cfgmod.load()
180
+ self.fixture_base = fixture_base
181
+ self.netpolicy = netpolicy
182
+ self.headless = self.cfg.headless if headless is None else headless
183
+ self._pw: Playwright | None = None
184
+ self._browser: Browser | None = None
185
+ self._ua: str | None = None
186
+ self._headed: list[Browser] = []
187
+ self.rate = RateLimiter(self.cfg.min_delay_s)
188
+ self.fast = self.cfg.max_delay_s <= 0 or bool(fixture_base) # dev/test: no sleeps
189
+
190
+ async def start(self) -> None:
191
+ """Launch now (so a missing browser is detected up front).
192
+
193
+ On failure the Playwright driver is stopped too (SEC-26: otherwise its subprocess
194
+ transport outlives the event loop -> "Event loop is closed" warnings).
195
+ """
196
+ try:
197
+ await self._ensure()
198
+ except BaseException:
199
+ await self.close()
200
+ raise
201
+
202
+ async def _ensure(self) -> Browser:
203
+ if self._browser:
204
+ return self._browser
205
+ from playwright.async_api import async_playwright
206
+ self._pw = await async_playwright().start()
207
+ self._browser = await self._launch(self.headless)
208
+ return self._browser
209
+
210
+ def launch_args(self, extra_hosts: set[str] | None = None) -> list[str]:
211
+ """Browser-wide network floor (SEC-16 b). `extra_hosts` (the CAPTCHA widget hosts,
212
+ SEC-36) are only ever passed for a headed fill/finder window; never in fixture mode."""
213
+ allowed = set(self.netpolicy.allowed)
214
+ if extra_hosts and not self.fixture_base:
215
+ allowed |= extra_hosts
216
+ rules = host_resolver_rules(allowed, bool(self.fixture_base))
217
+ return [f"--host-resolver-rules={rules}"]
218
+
219
+ async def _launch(self, headless: bool, extra_hosts: set[str] | None = None) -> Browser:
220
+ assert self._pw
221
+ args = self.launch_args(extra_hosts)
222
+ exe = os.environ.get("FREEMYID_BROWSER_EXECUTABLE")
223
+ if exe:
224
+ return await self._pw.chromium.launch(executable_path=exe, headless=headless,
225
+ args=args)
226
+ for chan in channel_candidates(self.cfg.browser_channel):
227
+ try:
228
+ return await self._pw.chromium.launch(channel=chan, headless=headless,
229
+ args=args)
230
+ except Exception as e: # noqa: BLE001 - try the next one
231
+ log.info("browser channel %s unavailable (%s)", chan, type(e).__name__)
232
+ try:
233
+ return await self._pw.chromium.launch(headless=headless, args=args)
234
+ except Exception:
235
+ shell = _cached_headless_shell() if self.fixture_base else None
236
+ if shell is None:
237
+ raise
238
+ return await self._pw.chromium.launch(executable_path=shell, headless=headless,
239
+ args=args)
240
+
241
+ async def user_agent(self) -> str:
242
+ if self._ua is None:
243
+ b = await self._ensure()
244
+ ctx = await b.new_context(service_workers="block")
245
+ pg = await ctx.new_page()
246
+ real = await pg.evaluate("navigator.userAgent")
247
+ await ctx.close()
248
+ self._ua = f"{real} {self.cfg.user_agent_suffix}".strip()
249
+ return self._ua
250
+
251
+ def allowed_hosts(self, domains: list[str], extra: list[str] | None = None) -> set[str]:
252
+ return self.netpolicy.for_broker(domains, extra)
253
+
254
+ def url_allowed(self, url: str, hosts: set[str]) -> bool:
255
+ if url.startswith(_LOCAL_SCHEMES):
256
+ return True
257
+ host = host_of(url)
258
+ if not host: # unparseable / hostless URL -> blocked
259
+ return False
260
+ return _host_ok(host, hosts) and self.netpolicy.is_allowed(host)
261
+
262
+ @staticmethod
263
+ def _is_main_frame_navigation(req: Any) -> bool:
264
+ """True for a top-level document load (any page, popups included). Anything we
265
+ cannot judge (no frame, e.g. a worker) counts as one: the strict side."""
266
+ try:
267
+ return bool(req.is_navigation_request()) and req.frame.parent_frame is None
268
+ except Exception: # noqa: BLE001
269
+ return True
270
+
271
+ def route_decision(self, url: str, *, is_main_frame_navigation: bool, hosts: set[str],
272
+ allow_captcha_frames: bool = False) -> str:
273
+ """What the route handler does with a request: "fixture" (answered by the local dev
274
+ server), "continue", "captcha" (a CAPTCHA widget sub-resource in an opted-in headed
275
+ window, SEC-36; continued) or "block". Pure; unit-tested with fake requests."""
276
+ if self.url_allowed(url, hosts):
277
+ if self.fixture_base and not url.startswith(_LOCAL_SCHEMES):
278
+ return "fixture"
279
+ return "continue"
280
+ if allow_captcha_frames and not self.fixture_base and captcha_subresource_allowed(
281
+ url, is_main_frame_navigation=is_main_frame_navigation):
282
+ return "captcha"
283
+ return "block"
284
+
285
+ def is_escape(self, req: Any, hosts: set[str], allow_captcha_frames: bool = False) -> bool:
286
+ """Off-list redirect hop (any frame) or off-list main-frame navigation (SEC-16).
287
+ In an opted-in headed window a CAPTCHA sub-frame / sub-resource is not an escape,
288
+ but a main-frame navigation to a CAPTCHA host still is (SEC-36)."""
289
+ try:
290
+ hop = req.redirected_from is not None
291
+ except Exception: # noqa: BLE001
292
+ hop = True
293
+ nav = self._is_main_frame_navigation(req)
294
+ if not (hop or nav) or self.url_allowed(req.url, hosts):
295
+ return False
296
+ return not (allow_captcha_frames and not nav and not self.fixture_base and
297
+ captcha_subresource_allowed(req.url, is_main_frame_navigation=False))
298
+
299
+ def make_route_handler(self, hosts: set[str], blocked: list[str],
300
+ allow_captcha_frames: bool = False) -> Callable[[Any], Awaitable[None]]:
301
+ async def _route(route: Any) -> None:
302
+ req = route.request
303
+ url = req.url
304
+ what = self.route_decision(
305
+ url, is_main_frame_navigation=self._is_main_frame_navigation(req), hosts=hosts,
306
+ allow_captcha_frames=allow_captcha_frames)
307
+ if what == "fixture":
308
+ try:
309
+ resp = await route.fetch(url=fixture_url(str(self.fixture_base), url),
310
+ max_redirects=0)
311
+ await route.fulfill(response=resp)
312
+ except Exception: # noqa: BLE001
313
+ await route.abort("failed")
314
+ elif what in ("continue", "captcha"):
315
+ await route.continue_()
316
+ else:
317
+ blocked.append(host_of(url) or "?")
318
+ await route.abort("blockedbyclient")
319
+ return _route
320
+
321
+ async def new_context(self, domains: list[str], extra: list[str] | None = None,
322
+ storage_state: Any = None, headed: bool = False,
323
+ allow_captcha_frames: bool = False) -> BrowserContext:
324
+ """One context per broker. `allow_captcha_frames` (default False) is honoured only
325
+ for a headed window (the person is there to tick the box); ignored otherwise."""
326
+ hosts = self.allowed_hosts(domains, extra)
327
+ captcha_ok = bool(allow_captcha_frames and headed and not self.fixture_base)
328
+ if allow_captcha_frames and not captcha_ok:
329
+ log.info("allow_captcha_frames ignored (headless or fixture mode)")
330
+ if headed:
331
+ await self._ensure()
332
+ b = await self._launch(False, captcha_hosts() if captcha_ok else None)
333
+ self._headed.append(b)
334
+ else:
335
+ b = await self._ensure()
336
+ ctx = await b.new_context(user_agent=await self.user_agent(),
337
+ storage_state=storage_state, locale="en-US",
338
+ service_workers="block")
339
+ blocked: list[str] = []
340
+ escaped: list[str] = []
341
+
342
+ def _on_request(req: Any) -> None:
343
+ # Redirect hops never reach the route handler (SEC-16): record off-list ones.
344
+ if self.is_escape(req, hosts, captcha_ok):
345
+ escaped.append(host_of(req.url) or "?")
346
+
347
+ ctx.on("request", _on_request)
348
+ await ctx.route("**/*", self.make_route_handler(hosts, blocked, captcha_ok))
349
+ ctx.freemyid_blocked = blocked # type: ignore[attr-defined]
350
+ ctx.freemyid_escaped = escaped # type: ignore[attr-defined]
351
+ ctx.freemyid_hosts = hosts # type: ignore[attr-defined]
352
+ ctx.freemyid_captcha_frames = captcha_ok # type: ignore[attr-defined]
353
+ return ctx
354
+
355
+ async def polite_delay(self, host: str) -> None:
356
+ if self.fast:
357
+ return
358
+ await self.rate.wait(host)
359
+ await asyncio.sleep(random.uniform(self.cfg.min_delay_s, self.cfg.max_delay_s))
360
+
361
+ def escaped(self, page: Page) -> str | None:
362
+ """Off-list host this page reached or tried to reach (redirect hop / navigation)."""
363
+ hosts: set[str] = getattr(page.context, "freemyid_hosts", set())
364
+ esc: list[str] = getattr(page.context, "freemyid_escaped", [])
365
+ if esc:
366
+ return esc[0]
367
+ if not self.url_allowed(page.url, hosts):
368
+ return host_of(page.url) or "?"
369
+ return None
370
+
371
+ def check_escaped(self, page: Page) -> None:
372
+ """Raise BlockedByPolicy if the page left the allowlist. Call after every
373
+ navigation, click or submit."""
374
+ h = self.escaped(page)
375
+ if h is not None:
376
+ raise BlockedByPolicy(h)
377
+
378
+ async def goto(self, page: Page, url: str, *, check_challenge: bool = True,
379
+ timeout_ms: int = 30000) -> Response | None:
380
+ hosts: set[str] = getattr(page.context, "freemyid_hosts", set())
381
+ if not self.url_allowed(url, hosts):
382
+ raise BlockedByPolicy(host_of(url) or "?")
383
+ host = host_of(url) or ""
384
+ last: Exception | None = None
385
+ for attempt in range(max(1, self.cfg.max_retries)):
386
+ await self.polite_delay(host)
387
+ try:
388
+ resp = await page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
389
+ self.check_escaped(page)
390
+ if resp is not None and resp.status in (500, 502, 504) and \
391
+ attempt + 1 < self.cfg.max_retries:
392
+ raise RuntimeError(f"http_{resp.status}")
393
+ if check_challenge:
394
+ await self.raise_if_challenge(page, resp.status if resp else None)
395
+ return resp
396
+ except (ChallengeEncountered, BlockedByPolicy):
397
+ raise
398
+ except Exception as e:
399
+ esc: list[str] = getattr(page.context, "freemyid_escaped", [])
400
+ if esc:
401
+ raise BlockedByPolicy(esc[0]) from e
402
+ last = e
403
+ if not self.fast:
404
+ await asyncio.sleep(min(60.0, 2.0 ** (attempt + 1)))
405
+ assert last is not None
406
+ raise last
407
+
408
+ async def raise_if_challenge(self, page: Page, status: int | None = None) -> None:
409
+ kind = await page_challenge(page, status)
410
+ if kind:
411
+ raise ChallengeEncountered(kind, page.url)
412
+
413
+ async def close(self) -> None:
414
+ try:
415
+ for hb in self._headed:
416
+ await hb.close()
417
+ self._headed.clear()
418
+ if self._browser:
419
+ await self._browser.close()
420
+ if self._pw:
421
+ await self._pw.stop()
422
+ finally:
423
+ self._browser = None
424
+ self._pw = None
425
+
426
+
427
+ async def page_challenge(page: Page, status: int | None = None) -> str | None:
428
+ html = await page.content()
429
+ frames = [f.url for f in page.frames]
430
+ return detect_challenge(html, frames, status)