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,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
|