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.
- freemyid/__init__.py +2 -0
- freemyid/_data/brokers/LICENSE-DATA.md +28 -0
- freemyid/_data/brokers/registry.sample.yaml +674 -0
- freemyid/_data/brokers/state_laws.yaml +448 -0
- freemyid/_data/schema/registry.schema.json +668 -0
- freemyid/_data/schema/setup-config.schema.json +538 -0
- freemyid/_data/schema/state_laws.schema.json +222 -0
- freemyid/cli.py +725 -0
- freemyid/cli_json.py +198 -0
- freemyid/config.py +43 -0
- freemyid/consent.py +82 -0
- freemyid/crypto.py +197 -0
- freemyid/doctor.py +374 -0
- freemyid/engine/__init__.py +0 -0
- freemyid/engine/_dev/__init__.py +101 -0
- freemyid/engine/_dev/devrun.py +101 -0
- freemyid/engine/_dev/inbox/devauto.eml +7 -0
- freemyid/engine/_dev/registry.yaml +106 -0
- freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
- freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
- freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
- freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
- freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
- freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
- freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
- freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
- freemyid/engine/_dev/site/devfill.example/done.html +3 -0
- freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
- freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
- freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
- freemyid/engine/_dev/site/devfill.example/late.html +22 -0
- freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/results.html +11 -0
- freemyid/engine/_dev/site/devfind.example/search.html +16 -0
- freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
- freemyid/engine/browser.py +430 -0
- freemyid/engine/fill_for_me.py +630 -0
- freemyid/engine/filler.py +523 -0
- freemyid/engine/handoff.py +237 -0
- freemyid/engine/letters.py +125 -0
- freemyid/engine/listing_finder.py +538 -0
- freemyid/engine/mail.py +406 -0
- freemyid/engine/mail_tls.py +198 -0
- freemyid/engine/matcher.py +208 -0
- freemyid/engine/outbox.py +421 -0
- freemyid/engine/plugins.py +68 -0
- freemyid/engine/runner.py +791 -0
- freemyid/engine/tiers.py +107 -0
- freemyid/interfaces.py +164 -0
- freemyid/logging_setup.py +152 -0
- freemyid/models.py +700 -0
- freemyid/netpolicy.py +145 -0
- freemyid/notify.py +58 -0
- freemyid/paths.py +83 -0
- freemyid/registry.py +275 -0
- freemyid/scheduler/__init__.py +203 -0
- freemyid/scheduler/render.py +91 -0
- freemyid/secret_backends.py +562 -0
- freemyid/secrets.py +25 -0
- freemyid/setup_config.py +311 -0
- freemyid/state.py +151 -0
- freemyid/storage/__init__.py +3 -0
- freemyid/storage/db.py +394 -0
- freemyid/submitters/__init__.py +0 -0
- freemyid/submitters/assisted.py +171 -0
- freemyid/submitters/base.py +119 -0
- freemyid/submitters/email.py +40 -0
- freemyid/submitters/web_form.py +276 -0
- freemyid/templates/_identity.txt.j2 +19 -0
- freemyid/templates/_signature.txt.j2 +7 -0
- freemyid/templates/ccpa.txt.j2 +16 -0
- freemyid/templates/follow_up.txt.j2 +15 -0
- freemyid/templates/generic_removal.txt.j2 +12 -0
- freemyid/templates/legal_demand.txt.j2 +20 -0
- freemyid/templates/phone_script.txt.j2 +25 -0
- freemyid/templates/postal_letter.txt.j2 +20 -0
- freemyid/templates/state_privacy.txt.j2 +17 -0
- freemyid/wizard/__init__.py +0 -0
- freemyid/wizard/dashboard.py +401 -0
- freemyid/wizard/desktop.py +188 -0
- freemyid/wizard/draft.py +627 -0
- freemyid/wizard/firstrun.py +42 -0
- freemyid/wizard/flows.py +283 -0
- freemyid/wizard/report.py +86 -0
- freemyid/wizard/server.py +834 -0
- freemyid/wizard/static/__init__.py +0 -0
- freemyid/wizard/static/app.js +98 -0
- freemyid/wizard/static/freemyid.png +0 -0
- freemyid/wizard/static/style.css +85 -0
- freemyid/wizard/ui.py +343 -0
- freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
- freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
- freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
- freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
- 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)
|