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,523 @@
1
+ """Deterministic form filler for "Fill it in for me" (PLAN_V1 D9).
2
+
3
+ No AI and no per-broker code. One JS pass lists the page's form controls (label, aria-label,
4
+ name, id, placeholder, autocomplete); `classify()` maps each one to a canonical field with a
5
+ fixed synonym table; `fill_page()` fills only the values `plan_values()` allows.
6
+
7
+ Never touched, whatever the labels say:
8
+ * anything inside a CAPTCHA / challenge widget (recaptcha, hcaptcha, turnstile, ...);
9
+ * password, file, hidden, checkbox, radio, button-like inputs (nothing gets ticked,
10
+ except `tick_safe_checkboxes` for the registry's `Broker.safe_checkboxes`);
11
+ * invisible / off-screen / aria-hidden controls (bot "honeypots": filling them marks the
12
+ request as a bot) and disabled / read-only ones;
13
+ * phone, birth date, age, middle name, company, search boxes and anything unrecognised.
14
+ Text boxes that already have a value are left alone.
15
+
16
+ Data minimization: the values come from `submitters.base.form_vars`, the same minimized set
17
+ the Automatic tier uses (fields the listing shows that the broker needs, the removal mailbox,
18
+ never DOB / relatives / personal emails / phone).
19
+
20
+ The report holds field keys and counts only (no values), so it is safe to log.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ from .matcher import STATE_NAMES
30
+
31
+ if TYPE_CHECKING:
32
+ from playwright.async_api import Page
33
+
34
+ from ..models import Broker, Listing, Profile
35
+
36
+ # Canonical keys, in classification precedence order (email before street because of
37
+ # "email address", listing_url before street because of "web address", first/last before
38
+ # full name because they contain "name").
39
+ KEYS = ("email", "listing_url", "first_name", "last_name", "full_name", "zip", "state",
40
+ "city", "street")
41
+
42
+ _NEGATIVE = re.compile(
43
+ r"\b(middle|maiden|nick ?name|user ?name|username|login|company|business|organi[sz]ation|"
44
+ r"employer|card|cvv|cvc|search|query|coupon|promo|phone|tel|telephone|mobile|cell|fax|"
45
+ r"password|passcode|captcha|recaptcha|robot|human|comment|comments|message|subject|"
46
+ r"reason|age|birth|birthday|dob|ssn|social security|country|line 2|address 2|apt|"
47
+ r"apartment|suite|unit|honeypot|leave (this )?blank|confirm(ation)? code|otp|verification)\b")
48
+
49
+ # A box whose whole label / name is one of these is a typical honeypot ("leave this empty")
50
+ # and is skipped even when visible (a "Website" box would otherwise get the listing URL).
51
+ _HONEYPOT = re.compile(r"(your )?(website|web site|homepage|home page|hp|trap|honey ?pot)( url)?")
52
+
53
+ _PATTERNS: dict[str, re.Pattern[str]] = {
54
+ "email": re.compile(r"\b(e ?mail|email address|e mail address)\b"),
55
+ "listing_url": re.compile(
56
+ r"\b(url|uri|link|web ?address|web ?page|profile (address|page)|listing "
57
+ r"(address|page)|record (address|page))\b"),
58
+ "first_name": re.compile(r"\b((first|given|fore) ?name|fname|first|given)\b"),
59
+ "last_name": re.compile(r"\b((last|family|sur) ?name|surname|lname|last|family)\b"),
60
+ "full_name": re.compile(r"\b(full ?name|your name|name)\b"),
61
+ "zip": re.compile(r"\b(zip|zipcode|zip ?code|postal|post ?code|postcode)\b"),
62
+ "state": re.compile(r"\b(state|province)\b"),
63
+ "city": re.compile(r"\b(city|town|locality)\b"),
64
+ "street": re.compile(r"\b(street|address ?line ?1?|address 1|addr|address|street address)\b"),
65
+ }
66
+
67
+ _AUTOCOMPLETE = {
68
+ "email": "email", "url": "listing_url", "given-name": "first_name",
69
+ "family-name": "last_name", "name": "full_name", "postal-code": "zip",
70
+ "address-level1": "state", "address-level2": "city", "street-address": "street",
71
+ "address-line1": "street",
72
+ }
73
+
74
+ # Input types we may type into. Everything else (password, file, checkbox, radio, hidden,
75
+ # submit, button, image, reset, range, color, date, tel, number, search) is never touched.
76
+ _TEXTLIKE = frozenset({"text", "email", "url", ""})
77
+ _PROTECTED_TYPES = frozenset({"password", "file", "hidden", "checkbox", "radio", "submit",
78
+ "button", "image", "reset"})
79
+
80
+
81
+ def _norm(s: str | None) -> str:
82
+ if not s:
83
+ return ""
84
+ s = re.sub(r"([a-z])([A-Z])", r"\1 \2", s)
85
+ s = re.sub(r"[^a-z0-9]+", " ", s.lower())
86
+ return " ".join(s.split())
87
+
88
+
89
+ @dataclass
90
+ class Control:
91
+ """One form control as seen by the page scan (no values, just descriptors)."""
92
+ key: str # data-freemyid-field marker
93
+ tag: str = "input"
94
+ type: str = "text"
95
+ name: str = ""
96
+ id: str = ""
97
+ label: str = ""
98
+ labelledby: str = ""
99
+ aria: str = ""
100
+ placeholder: str = ""
101
+ autocomplete: str = ""
102
+ title: str = ""
103
+ visible: bool = True
104
+ in_captcha: bool = False
105
+ disabled: bool = False
106
+ has_value: bool = False
107
+ maxlength: int = -1
108
+ options: list[dict[str, str]] = field(default_factory=list)
109
+
110
+ @classmethod
111
+ def from_js(cls, d: dict[str, Any]) -> Control:
112
+ names = cls.__dataclass_fields__.keys()
113
+ return cls(**{k: v for k, v in d.items() if k in names})
114
+
115
+ def protected(self) -> bool:
116
+ """Must never be touched (CAPTCHA, password, file, checkbox, honeypot, ...)."""
117
+ if self.in_captcha or self.disabled or not self.visible:
118
+ return True
119
+ if self.tag == "input" and self.type in _PROTECTED_TYPES:
120
+ return True
121
+ blob = _norm(f"{self.name} {self.id}")
122
+ return bool(re.search(r"\b(g recaptcha|h captcha|cf turnstile|captcha)\b", blob))
123
+
124
+ def fillable(self) -> bool:
125
+ if self.protected():
126
+ return False
127
+ if self.tag == "select" or self.tag == "textarea":
128
+ return True
129
+ return self.tag == "input" and self.type in _TEXTLIKE
130
+
131
+ def sources(self) -> list[str]:
132
+ """Descriptor texts in the order we trust them (label first)."""
133
+ return [self.label, self.labelledby, self.aria, self.name, self.id, self.placeholder,
134
+ self.title]
135
+
136
+
137
+ def classify(c: Control) -> str | None:
138
+ """Canonical key for a control, or None (unknown / deliberately skipped)."""
139
+ if not c.fillable():
140
+ return None
141
+ for src in c.sources():
142
+ t = _norm(src)
143
+ if not t:
144
+ continue
145
+ if _NEGATIVE.search(t) or _HONEYPOT.fullmatch(t):
146
+ return None
147
+ for k in KEYS:
148
+ if _PATTERNS[k].search(t):
149
+ return k
150
+ ac = (c.autocomplete or "").lower().split()
151
+ for tok in ac:
152
+ if tok in _AUTOCOMPLETE:
153
+ return _AUTOCOMPLETE[tok]
154
+ if c.tag == "input" and c.type == "email":
155
+ return "email"
156
+ if c.tag == "input" and c.type == "url":
157
+ return "listing_url"
158
+ return None
159
+
160
+
161
+ def choose_state_option(options: list[dict[str, str]], code: str) -> str | None:
162
+ """The <option> value for a 2-letter state code. Accepts "NC", "North Carolina",
163
+ "NC - North Carolina" style options (case-insensitive)."""
164
+ code = code.upper()
165
+ full = STATE_NAMES.get(code, "").lower()
166
+ for o in options:
167
+ v, t = (o.get("value") or "").strip(), (o.get("text") or "").strip()
168
+ if v.upper() == code or t.upper() == code or (full and (v.lower() == full or
169
+ t.lower() == full)):
170
+ return o.get("value") or t
171
+ for o in options: # "NC - North Carolina", "North Carolina (NC)"
172
+ t = _norm(o.get("text"))
173
+ if full and full in t and re.search(rf"\b{code.lower()}\b", t):
174
+ return o.get("value") or o.get("text")
175
+ return None
176
+
177
+
178
+ def plan_values(profile: Profile, broker: Broker, listing: Listing | None) -> dict[str, str]:
179
+ """Canonical key -> value, data-minimized exactly like the Automatic tier."""
180
+ from ..submitters.base import form_vars
181
+ v = form_vars(profile, broker, listing)
182
+ p = vars(v["profile"])
183
+ out: dict[str, str] = {}
184
+ for k, src in (("first_name", "first"), ("last_name", "last"), ("full_name", "full_name"),
185
+ ("city", "city"), ("state", "state"), ("zip", "zip"), ("street", "street")):
186
+ if p.get(src):
187
+ out[k] = str(p[src])
188
+ if v["listing"] is not None:
189
+ out["listing_url"] = v["listing"].url
190
+ if v["mailbox"] is not None:
191
+ out["email"] = v["mailbox"].address
192
+ return out
193
+
194
+
195
+ @dataclass
196
+ class FillReport:
197
+ """What happened. Keys and counts only (PII-free)."""
198
+ filled: list[str] = field(default_factory=list)
199
+ not_found: list[str] = field(default_factory=list) # had a value, no matching box
200
+ withheld: list[str] = field(default_factory=list) # box found, Free My ID may not send it
201
+ already_filled: list[str] = field(default_factory=list)
202
+ protected: int = 0 # captcha/password/file/checkbox...
203
+ unknown: int = 0 # visible boxes we did not recognise
204
+ errors: int = 0
205
+ ticked: int = 0 # Broker.safe_checkboxes ticked
206
+ # (name, id) attributes of the boxes we filled: lets fill_for_me tell whether the filled
207
+ # form is still on the page (SEC-32). Attribute names only, never values.
208
+ controls: list[tuple[str, str]] = field(default_factory=list)
209
+
210
+ def summary(self) -> dict[str, Any]:
211
+ return {"filled": sorted(set(self.filled)), "not_found": sorted(self.not_found),
212
+ "withheld": sorted(set(self.withheld)), "protected": self.protected,
213
+ "unknown": self.unknown, "errors": self.errors, "ticked": self.ticked}
214
+
215
+
216
+ # Shared page-side helpers (SEC-30 / SEC-33). `parentOf` walks up through shadow roots;
217
+ # `trulyVisible` is the honeypot test: checkVisibility(), own size and style, not pushed off
218
+ # the page, no clipping ancestor (overflow != visible with ~zero size or not overlapping the
219
+ # element), and a hit test at the element's centre after scrolling it into view.
220
+ _JS_HELPERS = r"""
221
+ const CAP = /(g-recaptcha|h-captcha|hcaptcha|recaptcha|cf-turnstile|turnstile|captcha|arkose|funcaptcha|px-captcha|cf-challenge|challenge-form)/i;
222
+ const parentOf = (a) => {
223
+ if (a.parentElement) return a.parentElement;
224
+ const r = a.getRootNode ? a.getRootNode() : null;
225
+ return (r && r !== a && r.host) ? r.host : null;
226
+ };
227
+ const captchaOrHidden = (el) => {
228
+ let inCaptcha = false, hidden = false;
229
+ for (let a = el; a && a.nodeType === 1; a = parentOf(a)) {
230
+ const cls = typeof a.className === 'string' ? a.className : '';
231
+ if (CAP.test((a.id || '') + ' ' + cls) || a.hasAttribute('data-sitekey')) inCaptcha = true;
232
+ if (a.getAttribute('aria-hidden') === 'true' || a.hidden) hidden = true;
233
+ }
234
+ return [inCaptcha, hidden];
235
+ };
236
+ const labelOf = (el) => {
237
+ let lab = el.closest('label');
238
+ if (!lab && el.id) {
239
+ try {
240
+ lab = el.getRootNode().querySelector('label[for="' + CSS.escape(el.id) + '"]');
241
+ } catch (e) { lab = null; }
242
+ }
243
+ return lab;
244
+ };
245
+ const trulyVisible = (el) => {
246
+ if (typeof el.checkVisibility === 'function' &&
247
+ !el.checkVisibility({checkOpacity: true, checkVisibilityCSS: true,
248
+ opacityProperty: true, visibilityProperty: true})) return false;
249
+ const cs = getComputedStyle(el);
250
+ if (cs.visibility !== 'visible' || cs.display === 'none' ||
251
+ parseFloat(cs.opacity) <= 0.05 || el.getAttribute('tabindex') === '-1') return false;
252
+ const r0 = el.getBoundingClientRect();
253
+ if (r0.width <= 2 || r0.height <= 2) return false;
254
+ // pushed off the page: left/top of the document, or only reachable by scrolling sideways
255
+ const sx = window.scrollX, sy = window.scrollY;
256
+ if (r0.right + sx <= 0 || r0.bottom + sy <= 0) return false;
257
+ if (r0.left + sx >= window.innerWidth) return false;
258
+ const de = document.documentElement;
259
+ if (r0.top + sy >= Math.max(de.scrollHeight, de.clientHeight)) return false;
260
+ try { el.scrollIntoView({block: 'center', inline: 'nearest', behavior: 'instant'}); }
261
+ catch (e) { /* keep going with the current position */ }
262
+ const r = el.getBoundingClientRect();
263
+ for (let a = parentOf(el); a && a.nodeType === 1; a = parentOf(a)) {
264
+ if (a === document.body || a === de) continue; // their overflow is the viewport's
265
+ const s = getComputedStyle(a);
266
+ if (parseFloat(s.opacity) <= 0.05) return false;
267
+ if (s.overflowX !== 'visible' || s.overflowY !== 'visible') {
268
+ const ar = a.getBoundingClientRect();
269
+ if (ar.width <= 2 || ar.height <= 2) return false;
270
+ if (ar.right <= r.left || ar.left >= r.right || ar.bottom <= r.top || ar.top >= r.bottom)
271
+ return false;
272
+ }
273
+ }
274
+ const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
275
+ if (cx < 0 || cy < 0 || cx >= window.innerWidth || cy >= window.innerHeight) return false;
276
+ const root = el.getRootNode && el.getRootNode().elementFromPoint ? el.getRootNode() : document;
277
+ const hit = root.elementFromPoint(cx, cy);
278
+ if (!hit) return false;
279
+ if (hit === el || el.contains(hit)) return true;
280
+ const lab = labelOf(el);
281
+ return !!(lab && (lab === hit || lab.contains(hit)));
282
+ };
283
+ """
284
+
285
+ SCAN_JS = r"""
286
+ () => {
287
+ """ + _JS_HELPERS + r"""
288
+ const textOf = (node) => {
289
+ if (!node) return '';
290
+ const c = node.cloneNode(true);
291
+ c.querySelectorAll('input,select,textarea,option,script,style').forEach(e => e.remove());
292
+ return (c.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 200);
293
+ };
294
+ const sx0 = window.scrollX, sy0 = window.scrollY;
295
+ const out = [];
296
+ let n = 0;
297
+ for (const el of document.querySelectorAll('input, select, textarea')) {
298
+ const tag = el.tagName.toLowerCase();
299
+ const type = tag === 'input' ? (el.getAttribute('type') || 'text').toLowerCase() : tag;
300
+ const [inCaptcha, hidden] = captchaOrHidden(el);
301
+ let visible = false;
302
+ if (!hidden && type !== 'hidden') {
303
+ try { visible = trulyVisible(el); } catch (e) { visible = false; }
304
+ }
305
+ let label = '';
306
+ if (el.id) {
307
+ try { label = textOf(document.querySelector('label[for="' + CSS.escape(el.id) + '"]')); }
308
+ catch (e) { label = ''; }
309
+ }
310
+ if (!label) label = textOf(el.closest('label'));
311
+ let lb = '';
312
+ const ids = el.getAttribute('aria-labelledby');
313
+ if (ids) lb = ids.split(/\s+/).map(i => textOf(document.getElementById(i))).join(' ');
314
+ const key = 'f' + (n++);
315
+ el.setAttribute('data-freemyid-field', key);
316
+ out.push({
317
+ key, tag, type, name: el.getAttribute('name') || '', id: el.id || '', label,
318
+ labelledby: lb, aria: el.getAttribute('aria-label') || '',
319
+ placeholder: el.getAttribute('placeholder') || '',
320
+ autocomplete: el.getAttribute('autocomplete') || '', title: el.getAttribute('title') || '',
321
+ visible, in_captcha: inCaptcha, disabled: !!(el.disabled || el.readOnly),
322
+ has_value: tag === 'select' ? false : !!(el.value && el.value.trim()),
323
+ maxlength: el.maxLength || -1,
324
+ options: tag === 'select' ? Array.from(el.options).slice(0, 120)
325
+ .map(o => ({value: o.value, text: (o.text || '').trim()})) : [],
326
+ });
327
+ }
328
+ try { window.scrollTo(sx0, sy0); } catch (e) { /* ignore */ }
329
+ return out;
330
+ }
331
+ """
332
+
333
+
334
+ async def scan(page: Page) -> list[Control]:
335
+ """Controls of the page's main frame (CAPTCHA iframes are other frames: never seen)."""
336
+ return [Control.from_js(d) for d in await page.evaluate(SCAN_JS)]
337
+
338
+
339
+ _KEY_RE = re.compile(r"f\d{1,6}")
340
+ _FILL_TAGS = frozenset({"input", "select", "textarea"})
341
+ # Ancestor-or-self CAPTCHA containers, evaluated by Playwright's own selector engine (its
342
+ # isolated world), not by page script.
343
+ _LOWER = "translate(concat(@class,' ',@id),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')"
344
+ _CAPTCHA_ANCESTOR_XPATH = (
345
+ "xpath=ancestor-or-self::*[@data-sitekey or "
346
+ + " or ".join(f"contains({_LOWER},'{w}')" for w in (
347
+ "captcha", "turnstile", "arkose", "cf-challenge", "challenge-form"))
348
+ + "]")
349
+
350
+
351
+ def _locator_for(page: Page, c: Control) -> Any | None:
352
+ """Playwright locator for a scanned control, or None if the scan output looks forged
353
+ (SEC-34: the key and tag come from page-world JS and go into a selector)."""
354
+ if not isinstance(c.key, str) or not _KEY_RE.fullmatch(c.key) or c.tag not in _FILL_TAGS:
355
+ return None
356
+ return page.locator(f'{c.tag}[data-freemyid-field="{c.key}"]')
357
+
358
+
359
+ async def _still_safe(loc: Any, c: Control, timeout_ms: int) -> bool:
360
+ """Re-check through Playwright (not page JS) right before typing: exactly one element,
361
+ a text-like type, visible, editable, not named like or inside a CAPTCHA (SEC-34)."""
362
+ if await loc.count() != 1:
363
+ return False
364
+ if c.tag == "input":
365
+ t = ((await loc.get_attribute("type", timeout=timeout_ms)) or "text").strip().lower()
366
+ if t not in _TEXTLIKE:
367
+ return False
368
+ name = _norm(f"{await loc.get_attribute('name', timeout=timeout_ms) or ''} "
369
+ f"{await loc.get_attribute('id', timeout=timeout_ms) or ''}")
370
+ if re.search(r"\b(g recaptcha|h captcha|cf turnstile|captcha)\b", name):
371
+ return False
372
+ if not await loc.is_visible() or not await loc.is_editable(timeout=timeout_ms):
373
+ return False
374
+ return await loc.locator(_CAPTCHA_ANCESTOR_XPATH).count() == 0
375
+
376
+
377
+ async def fill_page(page: Page, values: dict[str, str], *, timeout_ms: int = 5000) -> FillReport:
378
+ """Fill every recognised box we have an allowed value for. Never submits, never ticks."""
379
+ rep = FillReport()
380
+ seen: set[str] = set()
381
+ for c in await scan(page):
382
+ if c.protected():
383
+ rep.protected += 1
384
+ continue
385
+ k = classify(c)
386
+ if k is None:
387
+ if c.fillable():
388
+ rep.unknown += 1
389
+ continue
390
+ seen.add(k)
391
+ val = values.get(k)
392
+ if not val:
393
+ rep.withheld.append(k)
394
+ continue
395
+ loc = _locator_for(page, c)
396
+ if loc is None:
397
+ rep.errors += 1
398
+ continue
399
+ try:
400
+ if c.tag == "select":
401
+ if k != "state":
402
+ rep.unknown += 1
403
+ continue
404
+ opt = choose_state_option(c.options, val)
405
+ if opt is None:
406
+ rep.errors += 1
407
+ continue
408
+ if not await _still_safe(loc, c, timeout_ms):
409
+ rep.protected += 1
410
+ continue
411
+ await loc.select_option(value=opt, timeout=timeout_ms)
412
+ elif c.has_value:
413
+ rep.already_filled.append(k)
414
+ continue
415
+ else:
416
+ if not await _still_safe(loc, c, timeout_ms):
417
+ rep.protected += 1
418
+ continue
419
+ await loc.fill(val, timeout=timeout_ms)
420
+ rep.filled.append(k)
421
+ rep.controls.append((c.name[:100], c.id[:100]))
422
+ except Exception: # noqa: BLE001 - one stubborn box never stops the rest
423
+ rep.errors += 1
424
+ rep.not_found = [k for k in values if k not in seen and not _covered(k, seen)]
425
+ return rep
426
+
427
+
428
+ # The element must be a real, visible, enabled, unticked checkbox input that is not inside
429
+ # a CAPTCHA / challenge container (also across shadow roots, SEC-33), and whose wording is
430
+ # not a robot / human check (SEC-31) or a marketing opt-in. Returns '' when it is ok to tick.
431
+ _ROBOT_WORDS = r"robot|human|\bbot\b|captcha|verify|verification|not a machine|security check"
432
+ _MARKETING_WORDS = (r"newsletter|offers?\b|marketing|partners?\b|subscribe|promotion|"
433
+ r"promotional|special deals|third.?part")
434
+ _ROBOT_RE = re.compile(_ROBOT_WORDS, re.IGNORECASE)
435
+ _MARKETING_RE = re.compile(_MARKETING_WORDS, re.IGNORECASE)
436
+
437
+ CHECKBOX_OK_JS = r"""
438
+ (el) => {
439
+ """ + _JS_HELPERS + r"""
440
+ const ROBOT = new RegExp(""" + json.dumps(_ROBOT_WORDS) + r""", 'i');
441
+ const MKT = new RegExp(""" + json.dumps(_MARKETING_WORDS) + r""", 'i');
442
+ if (!el || el.tagName !== 'INPUT' || (el.getAttribute('type') || '').toLowerCase() !== 'checkbox')
443
+ return 'not_checkbox';
444
+ if (el.disabled || el.readOnly) return 'disabled';
445
+ if (el.checked) return 'already';
446
+ const [inCaptcha, hidden] = captchaOrHidden(el);
447
+ if (inCaptcha) return 'captcha';
448
+ if (hidden) return 'hidden';
449
+ const blob = (el.name || '') + ' ' + (el.id || '');
450
+ if (CAP.test(blob)) return 'captcha';
451
+ const parts = [el.getAttribute('name'), el.id, el.getAttribute('aria-label'),
452
+ el.getAttribute('title'), el.getAttribute('value')];
453
+ const lab = labelOf(el);
454
+ if (lab) parts.push(lab.textContent);
455
+ const lb = el.getAttribute('aria-labelledby');
456
+ if (lb) lb.split(/\s+/).forEach(i => {
457
+ const n = document.getElementById(i); if (n) parts.push(n.textContent);
458
+ });
459
+ const words = parts.filter(Boolean).join(' ').replace(/[_-]+/g, ' ');
460
+ if (ROBOT.test(words)) return 'robot';
461
+ if (MKT.test(words)) return 'marketing';
462
+ const sx0 = window.scrollX, sy0 = window.scrollY;
463
+ let vis = false;
464
+ try { vis = trulyVisible(el); } catch (e) { vis = false; }
465
+ try { window.scrollTo(sx0, sy0); } catch (e) { /* ignore */ }
466
+ return vis ? '' : 'hidden';
467
+ }
468
+ """
469
+
470
+ _MAX_SAFE_SELECTORS = 3 # models.Broker allows at most 3
471
+ _MAX_PER_SELECTOR = 5
472
+
473
+
474
+ async def tick_safe_checkboxes(page: Page, selectors: list[str], *,
475
+ timeout_ms: int = 5000) -> int:
476
+ """Tick the registry's `Broker.safe_checkboxes` (plain consent boxes) and nothing else.
477
+
478
+ Main frame only (`page.query_selector_all` never looks into iframes, where CAPTCHA
479
+ widgets live); every element must pass CHECKBOX_OK_JS. Returns how many were ticked.
480
+ """
481
+ ticked = 0
482
+ for sel in selectors[:_MAX_SAFE_SELECTORS]:
483
+ if not sel or len(sel) > 300:
484
+ continue
485
+ try:
486
+ els = await page.query_selector_all(sel)
487
+ except Exception: # noqa: BLE001, S112 - malformed selector from the registry
488
+ continue
489
+ for el in els[:_MAX_PER_SELECTOR]:
490
+ try:
491
+ if await el.evaluate(CHECKBOX_OK_JS) != "":
492
+ continue
493
+ if not await _checkbox_still_safe(el):
494
+ continue
495
+ await el.check(timeout=timeout_ms)
496
+ ticked += 1
497
+ except Exception: # noqa: BLE001, S112 - detached / covered element
498
+ continue
499
+ return ticked
500
+
501
+
502
+ async def _checkbox_still_safe(el: Any) -> bool:
503
+ """Second opinion through Playwright's handle API (not page JS): a visible, enabled
504
+ checkbox whose name / id / aria-label / title has no robot or marketing words and that
505
+ is not inside a CAPTCHA container (SEC-31 / SEC-34)."""
506
+ if ((await el.get_attribute("type")) or "").strip().lower() != "checkbox":
507
+ return False
508
+ words = " ".join([(await el.get_attribute(a)) or "" for a in
509
+ ("name", "id", "aria-label", "title")]).replace("_", " ").replace("-", " ")
510
+ if _ROBOT_RE.search(words) or _MARKETING_RE.search(words):
511
+ return False
512
+ if not await el.is_visible() or not await el.is_enabled():
513
+ return False
514
+ return not await el.query_selector(_CAPTCHA_ANCESTOR_XPATH)
515
+
516
+
517
+ def _covered(k: str, seen: set[str]) -> bool:
518
+ """A form with a single 'Full name' box does not need first/last (and vice versa)."""
519
+ if k in ("first_name", "last_name"):
520
+ return "full_name" in seen
521
+ if k == "full_name":
522
+ return {"first_name", "last_name"} <= seen
523
+ return False