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,630 @@
|
|
|
1
|
+
"""'Fill it in for me' (PLAN_V1 D9): Free My ID fills the opt-out form, the person submits it.
|
|
2
|
+
|
|
3
|
+
Flow (only ever started by the person, from the dashboard; scheduled runs never do this):
|
|
4
|
+
1. consent gate, broker checks (web form with an opt-out page; covered_by -> parent);
|
|
5
|
+
2. a VISIBLE browser window (system Edge on Windows) opens the opt-out page through
|
|
6
|
+
BrowserManager (NetPolicy allowlist, `bm.goto`, `check_escaped`);
|
|
7
|
+
3. `filler.fill_page` fills the minimized fields; only registry safe_checkboxes are ticked, CAPTCHAs are not
|
|
8
|
+
touched, Submit is NOT pressed;
|
|
9
|
+
4. a big plain-English banner asks the person to tick the robot box and press Submit;
|
|
10
|
+
5. Free My ID watches for the registry's success signal (the `expect_text` / `wait_for` steps
|
|
11
|
+
after the form's submit step, same as web_form, plus `success_text` /
|
|
12
|
+
`success_url_pattern`). SEC-32: success needs BOTH a submit-like event after the fill
|
|
13
|
+
(a main-frame navigation, or an XHR/fetch POST to an allowed host) AND a signal that
|
|
14
|
+
is new compared with the baseline taken after filling (kept up to date until the
|
|
15
|
+
first submit event): a success phrase that appears more often than before, a success
|
|
16
|
+
selector that was not there, a URL that changed to match `success_url_pattern`, or the
|
|
17
|
+
filled form gone while a success phrase/selector is on the page. A POST that re-shows
|
|
18
|
+
the same form never counts. On success: screenshots saved as evidence (the form as it
|
|
19
|
+
was when the person pressed Submit, SEC-37, and the confirmation without our banner),
|
|
20
|
+
a SENT web-form Request, the broker moves to SUBMITTED (or AWAITING_VERIFICATION)
|
|
21
|
+
exactly like a successful web_form submission, and the Assisted to-do is closed;
|
|
22
|
+
6. timeout / window closed / no success signal / any error: the Assisted to-do card
|
|
23
|
+
stays (or is created), so nothing is lost.
|
|
24
|
+
|
|
25
|
+
Practice mode (`real_submissions_enabled: false`) still fills: the person, not Free My ID,
|
|
26
|
+
presses Submit. The banner then says plainly that Submit sends a real request.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import logging
|
|
32
|
+
import re
|
|
33
|
+
import threading
|
|
34
|
+
from collections.abc import Awaitable, Callable
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
37
|
+
|
|
38
|
+
from .. import config as cfgmod
|
|
39
|
+
from ..models import (
|
|
40
|
+
Actor,
|
|
41
|
+
Broker,
|
|
42
|
+
Event,
|
|
43
|
+
EvidenceKind,
|
|
44
|
+
Listing,
|
|
45
|
+
ListingState,
|
|
46
|
+
MatchLevel,
|
|
47
|
+
Method,
|
|
48
|
+
Request,
|
|
49
|
+
RequestKind,
|
|
50
|
+
RequestStatus,
|
|
51
|
+
TodoKind,
|
|
52
|
+
utcnow,
|
|
53
|
+
)
|
|
54
|
+
from . import filler
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from playwright.async_api import Page
|
|
58
|
+
|
|
59
|
+
from ..models import Profile, Registry, StateLaw
|
|
60
|
+
from ..storage import Store
|
|
61
|
+
from .browser import BrowserManager
|
|
62
|
+
|
|
63
|
+
log = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
BANNER_ID = "freemyid-fill-banner"
|
|
66
|
+
_POLL_S = 0.5
|
|
67
|
+
|
|
68
|
+
MSG_FILLED = ("Free My ID filled this in for you. Please check it, tick the box that says "
|
|
69
|
+
"you're not a robot (if there is one), then press Submit.")
|
|
70
|
+
MSG_MISSING = "A few boxes are still empty. Please fill those in yourself."
|
|
71
|
+
MSG_PRACTICE = ("Practice mode is on, but this is the real website: pressing Submit "
|
|
72
|
+
"really sends your removal request.")
|
|
73
|
+
MSG_HINT = "Free My ID will notice when you're done. To stop, just close this window."
|
|
74
|
+
MSG_DONE = "Done! Free My ID saved proof that you sent it. You can close this window now."
|
|
75
|
+
|
|
76
|
+
Status = Literal["submitted", "fallback", "refused"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class FillResult:
|
|
81
|
+
"""Outcome of one 'Fill it in for me' attempt. PII-free (keys, ids, labels)."""
|
|
82
|
+
status: Status
|
|
83
|
+
reason: str # success_text | success_selector | timeout |
|
|
84
|
+
# window_closed | unverified | nothing_filled | blocked |
|
|
85
|
+
# browser_unavailable | error:<Type> | consent | ...
|
|
86
|
+
broker_id: str
|
|
87
|
+
filled: list[str] = field(default_factory=list)
|
|
88
|
+
not_found: list[str] = field(default_factory=list)
|
|
89
|
+
withheld: list[str] = field(default_factory=list)
|
|
90
|
+
submit_seen: bool = False # a navigation / POST happened after filling
|
|
91
|
+
practice_mode: bool = False
|
|
92
|
+
todo_id: str | None = None # Assisted card to fall back to (fallback)
|
|
93
|
+
request_id: str | None = None # SENT request (submitted)
|
|
94
|
+
evidence_ids: list[str] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def ok(self) -> bool:
|
|
98
|
+
return self.status == "submitted"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------------------- helpers
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def success_signals(broker: Broker, vars: dict | None = None) -> tuple[list[str], list[str]]:
|
|
105
|
+
"""(texts, selectors) that mean "it worked": the `expect_text` values and `wait_for`
|
|
106
|
+
selectors after the last click/submit step of `Broker.form` (what web_form checks),
|
|
107
|
+
plus `Broker.success_text`."""
|
|
108
|
+
from ..submitters.web_form import render_value
|
|
109
|
+
steps = broker.form
|
|
110
|
+
last = max((i for i, s in enumerate(steps) if s.action in ("click", "submit")), default=-1)
|
|
111
|
+
texts: list[str] = []
|
|
112
|
+
sels: list[str] = []
|
|
113
|
+
for s in steps[last + 1:]:
|
|
114
|
+
if s.action == "expect_text" and s.value:
|
|
115
|
+
try:
|
|
116
|
+
t = render_value(s.value, vars or {}).strip()
|
|
117
|
+
except Exception: # noqa: BLE001, S112 - template needs a var we don't have
|
|
118
|
+
continue
|
|
119
|
+
if t:
|
|
120
|
+
texts.append(t.lower())
|
|
121
|
+
elif s.action == "wait_for" and s.selector and last >= 0:
|
|
122
|
+
sels.append(s.selector)
|
|
123
|
+
for t in broker.success_text:
|
|
124
|
+
t = (t or "").strip().lower()
|
|
125
|
+
if t and t not in texts:
|
|
126
|
+
texts.append(t)
|
|
127
|
+
return texts, sels
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_URL_MATCH_MAX = 2048
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def url_matches(pattern: str | None, url: str) -> bool:
|
|
134
|
+
"""re.search of a registry regex against the URL's path+query (bounded length)."""
|
|
135
|
+
if not pattern or not url:
|
|
136
|
+
return False
|
|
137
|
+
from urllib.parse import urlsplit
|
|
138
|
+
try:
|
|
139
|
+
u = urlsplit(url)
|
|
140
|
+
except ValueError:
|
|
141
|
+
return False
|
|
142
|
+
target = (u.path or "/") + (f"?{u.query}" if u.query else "")
|
|
143
|
+
try:
|
|
144
|
+
return re.search(pattern, target[:_URL_MATCH_MAX]) is not None
|
|
145
|
+
except re.error:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _banner_js(lines: list[str], done: bool) -> str:
|
|
150
|
+
import json
|
|
151
|
+
bg, fg = ("#0b6b2e", "#ffffff") if done else ("#ffe600", "#000000")
|
|
152
|
+
return f"""
|
|
153
|
+
(() => {{
|
|
154
|
+
const id = {json.dumps(BANNER_ID)};
|
|
155
|
+
let b = document.getElementById(id);
|
|
156
|
+
if (b) b.remove();
|
|
157
|
+
b = document.createElement('div');
|
|
158
|
+
b.id = id;
|
|
159
|
+
b.setAttribute('role', 'alert');
|
|
160
|
+
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:2147483647;' +
|
|
161
|
+
'background:{bg};color:{fg};font:bold 26px/1.35 "Segoe UI",Arial,sans-serif;' +
|
|
162
|
+
'padding:18px 28px;border-bottom:6px solid {fg};box-shadow:0 4px 12px rgba(0,0,0,.4);' +
|
|
163
|
+
'text-align:left;pointer-events:none;';
|
|
164
|
+
for (const [i, t] of {json.dumps(lines)}.entries()) {{
|
|
165
|
+
const p = document.createElement('div');
|
|
166
|
+
p.textContent = t;
|
|
167
|
+
if (i > 0) p.style.cssText = 'font-size:22px;font-weight:600;margin-top:8px;';
|
|
168
|
+
b.appendChild(p);
|
|
169
|
+
}}
|
|
170
|
+
(document.body || document.documentElement).appendChild(b);
|
|
171
|
+
if (document.body) document.body.style.marginTop = (b.offsetHeight + 8) + 'px';
|
|
172
|
+
return true;
|
|
173
|
+
}})()
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_PAGE_TEXT_JS = f"""
|
|
178
|
+
() => {{
|
|
179
|
+
const b = document.getElementById({BANNER_ID!r});
|
|
180
|
+
const t = document.body ? document.body.innerText : '';
|
|
181
|
+
return (b ? t.replace(b.innerText, '') : t).toLowerCase();
|
|
182
|
+
}}
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Banner gone but a form is still there (reload after a validation error): show it again.
|
|
187
|
+
# A page without a form (e.g. the site's own thank-you page) gets no "press Submit" banner.
|
|
188
|
+
_NEEDS_BANNER_JS = f"""
|
|
189
|
+
() => !document.getElementById({BANNER_ID!r}) &&
|
|
190
|
+
!!document.querySelector('form input:not([type=hidden]), form textarea, form select')
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _matched(page: Page, texts: list[str], sels: list[str],
|
|
195
|
+
url_pattern: str | None = None) -> str | None:
|
|
196
|
+
if url_matches(url_pattern, page.url):
|
|
197
|
+
return "success_url"
|
|
198
|
+
if texts:
|
|
199
|
+
body = await page.evaluate(_PAGE_TEXT_JS)
|
|
200
|
+
if any(t in body for t in texts):
|
|
201
|
+
return "success_text"
|
|
202
|
+
for s in sels:
|
|
203
|
+
if await page.query_selector(s) is not None:
|
|
204
|
+
return "success_selector"
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# One look at the page for SEC-32: how often each success phrase occurs (our banner's words
|
|
209
|
+
# subtracted), which success selectors exist, how many of the boxes we filled are still
|
|
210
|
+
# there and visible, and the URL. All inputs are passed as arguments, never interpolated.
|
|
211
|
+
_SNAPSHOT_JS = """
|
|
212
|
+
([banner, texts, sels, controls]) => {
|
|
213
|
+
const b = document.getElementById(banner);
|
|
214
|
+
let t = document.body ? document.body.innerText : '';
|
|
215
|
+
if (b) t = t.replace(b.innerText, '');
|
|
216
|
+
t = t.toLowerCase();
|
|
217
|
+
const counts = texts.map((x) => {
|
|
218
|
+
let n = 0, i = 0;
|
|
219
|
+
while (x && (i = t.indexOf(x, i)) !== -1) { n++; i += x.length; }
|
|
220
|
+
return n;
|
|
221
|
+
});
|
|
222
|
+
const present = sels.map((s) => { try { return !!document.querySelector(s); }
|
|
223
|
+
catch (e) { return false; } });
|
|
224
|
+
const vis = (el) => {
|
|
225
|
+
const r = el.getBoundingClientRect(); const cs = getComputedStyle(el);
|
|
226
|
+
return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none';
|
|
227
|
+
};
|
|
228
|
+
let formLeft = 0;
|
|
229
|
+
for (const [name, id] of controls) {
|
|
230
|
+
let el = null;
|
|
231
|
+
try {
|
|
232
|
+
if (id) el = document.getElementById(id);
|
|
233
|
+
if (!el && name) el = document.querySelector('[name="' + CSS.escape(name) + '"]');
|
|
234
|
+
} catch (e) { el = null; }
|
|
235
|
+
if (el && vis(el)) formLeft++;
|
|
236
|
+
}
|
|
237
|
+
return {counts, present, formLeft, url: location.href};
|
|
238
|
+
}
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
_REMOVE_BANNER_JS = f"""
|
|
242
|
+
() => {{
|
|
243
|
+
const b = document.getElementById({BANNER_ID!r});
|
|
244
|
+
if (b) {{ b.remove(); if (document.body) document.body.style.marginTop = ''; }}
|
|
245
|
+
return true;
|
|
246
|
+
}}
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass
|
|
251
|
+
class _Snap:
|
|
252
|
+
counts: list[int]
|
|
253
|
+
present: list[bool]
|
|
254
|
+
form_left: int
|
|
255
|
+
url: str
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def _snapshot(page: Page, texts: list[str], sels: list[str],
|
|
259
|
+
controls: list[tuple[str, str]]) -> _Snap:
|
|
260
|
+
d = await page.evaluate(_SNAPSHOT_JS, [BANNER_ID, texts, sels,
|
|
261
|
+
[list(c) for c in controls]])
|
|
262
|
+
counts = [int(x) for x in (d.get("counts") or [])][:len(texts)]
|
|
263
|
+
present = [bool(x) for x in (d.get("present") or [])][:len(sels)]
|
|
264
|
+
counts += [0] * (len(texts) - len(counts))
|
|
265
|
+
present += [False] * (len(sels) - len(present))
|
|
266
|
+
return _Snap(counts, present, int(d.get("formLeft") or 0), page.url)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# SEC-39: no single page call may outlive the fill_for_me deadline (an evaluate can hang
|
|
270
|
+
# forever while a navigation is paused in a route handler).
|
|
271
|
+
_CALL_TIMEOUT_S = 5.0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def _eval_t(page: Page, js: str, arg: Any = None) -> Any:
|
|
275
|
+
return await asyncio.wait_for(page.evaluate(js, arg), _CALL_TIMEOUT_S)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def _snap_t(page: Page, texts: list[str], sels: list[str],
|
|
279
|
+
controls: list[tuple[str, str]]) -> _Snap:
|
|
280
|
+
return await asyncio.wait_for(_snapshot(page, texts, sels, controls), _CALL_TIMEOUT_S)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _merge_baseline(base: _Snap, now: _Snap) -> _Snap:
|
|
284
|
+
"""Before the first submit event, anything that shows up (late-rendered FAQ text, ...)
|
|
285
|
+
becomes part of the baseline: it cannot be the result of submitting."""
|
|
286
|
+
return _Snap([max(a, b) for a, b in zip(base.counts, now.counts, strict=True)],
|
|
287
|
+
[a or b for a, b in zip(base.present, now.present, strict=True)],
|
|
288
|
+
max(base.form_left, now.form_left), base.url)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _path_of(url: str) -> str:
|
|
292
|
+
from urllib.parse import urlsplit
|
|
293
|
+
try:
|
|
294
|
+
u = urlsplit(url)
|
|
295
|
+
except ValueError:
|
|
296
|
+
return url
|
|
297
|
+
return (u.path or "/") + (f"?{u.query}" if u.query else "")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def new_signal(base: _Snap, now: _Snap, url_pattern: str | None) -> str | None:
|
|
301
|
+
"""A success signal that was NOT there at baseline (SEC-32), or None."""
|
|
302
|
+
if url_pattern and url_matches(url_pattern, now.url) and \
|
|
303
|
+
not url_matches(url_pattern, base.url) and _path_of(now.url) != _path_of(base.url):
|
|
304
|
+
return "success_url"
|
|
305
|
+
if any(n > b for n, b in zip(now.counts, base.counts, strict=True)):
|
|
306
|
+
return "success_text"
|
|
307
|
+
if any(n and not b for n, b in zip(now.present, base.present, strict=True)):
|
|
308
|
+
return "success_selector"
|
|
309
|
+
# The filled form is gone (e.g. replaced by a thank-you page that repeats a phrase the
|
|
310
|
+
# form page already had in its instructions) and a success phrase / selector is shown.
|
|
311
|
+
if base.form_left > 0 and now.form_left == 0:
|
|
312
|
+
if any(now.counts):
|
|
313
|
+
return "success_text"
|
|
314
|
+
if any(now.present):
|
|
315
|
+
return "success_selector"
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _confirmed_listing(store: Store, profile_id: str, broker_id: str) -> tuple[
|
|
320
|
+
Listing | None, list[Listing]]:
|
|
321
|
+
lis = [li for li in store.list_listings(profile_id, broker_id) if li.gone_since is None]
|
|
322
|
+
conf = [li for li in lis if li.match_level == MatchLevel.CONFIRMED]
|
|
323
|
+
return (conf[0] if conf else None), lis
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _event(store: Store, profile_id: str, broker_id: str, action: str, actor: Actor = Actor.ENGINE,
|
|
327
|
+
frm: ListingState | None = None, to: ListingState | None = None, **detail: Any) -> None:
|
|
328
|
+
store.log(Event(profile_id=profile_id, broker_id=broker_id, actor=actor, action=action,
|
|
329
|
+
from_state=frm, to_state=to, detail=detail))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _fallback_todo(store: Store, profile: Profile, broker: Broker, listings: list[Listing],
|
|
333
|
+
law: StateLaw | None, reason: str | None) -> str:
|
|
334
|
+
"""Make sure the Assisted card exists (deduplicated) and return its id."""
|
|
335
|
+
from ..submitters import assisted
|
|
336
|
+
from .handoff import persist_todo
|
|
337
|
+
for t in store.list_todos(profile.id):
|
|
338
|
+
if t.broker_id == broker.id and t.kind == TodoKind.ASSISTED_OPT_OUT:
|
|
339
|
+
return t.id
|
|
340
|
+
todo = assisted.assisted_todo(profile, broker, listings, reason=reason)
|
|
341
|
+
return persist_todo(store, todo).id
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _record_success(store: Store, profile: Profile, broker: Broker, listing: Listing | None,
|
|
345
|
+
shots: list[tuple[bytes, str]], how: str) -> tuple[str, list[str]]:
|
|
346
|
+
"""Same bookkeeping as a successful web_form submission (runner._apply_outcome)."""
|
|
347
|
+
from ..state import can_transition, mark_submitted, next_check_at, transition
|
|
348
|
+
from ..submitters import base
|
|
349
|
+
now = utcnow()
|
|
350
|
+
conf = [listing] if listing else []
|
|
351
|
+
req = Request(profile_id=profile.id, broker_id=broker.id, kind=RequestKind.OPT_OUT,
|
|
352
|
+
method=Method.WEB_FORM, listing_ids=[li.id for li in conf],
|
|
353
|
+
status=RequestStatus.SENT, created_at=now, sent_at=now)
|
|
354
|
+
store.save_request(req)
|
|
355
|
+
ev_ids: list[str] = []
|
|
356
|
+
for png, caption in shots:
|
|
357
|
+
ev = base.evidence(profile.id, broker.id, EvidenceKind.SCREENSHOT, "png",
|
|
358
|
+
listing_id=listing.id if listing else None, request_id=req.id,
|
|
359
|
+
caption=caption)
|
|
360
|
+
ev_ids.append(store.save_evidence(ev, png).id)
|
|
361
|
+
st = store.get_status(profile.id, broker.id)
|
|
362
|
+
frm = st.state
|
|
363
|
+
if st.state in (ListingState.UNKNOWN, ListingState.NOT_FOUND):
|
|
364
|
+
st = transition(st, ListingState.FOUND, now) # the person found it worth removing
|
|
365
|
+
if can_transition(st.state, ListingState.SUBMITTED):
|
|
366
|
+
st = mark_submitted(st, now)
|
|
367
|
+
if broker.needs_email_verification:
|
|
368
|
+
st = transition(st, ListingState.AWAITING_VERIFICATION, now)
|
|
369
|
+
st = st.model_copy(update={"next_check_at": next_check_at(broker, st.state, now)})
|
|
370
|
+
store.save_status(st)
|
|
371
|
+
_event(store, profile.id, broker.id, "submitted", Actor.USER, frm, st.state,
|
|
372
|
+
request_id=req.id, method=Method.WEB_FORM.value, via="fill_for_me", signal=how)
|
|
373
|
+
for t in store.list_todos(profile.id):
|
|
374
|
+
if t.broker_id == broker.id and t.kind in (TodoKind.ASSISTED_OPT_OUT,
|
|
375
|
+
TodoKind.CAPTCHA):
|
|
376
|
+
store.complete_todo(t.id)
|
|
377
|
+
return req.id, ev_ids
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --------------------------------------------------------------------------- entry point
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def fill_for_me(store: Store, profile_id: str, broker_id: str, *, timeout_s: int = 600,
|
|
384
|
+
registry: Registry | None = None, browser: BrowserManager | None = None,
|
|
385
|
+
headed: bool = True, cfg: cfgmod.Config | None = None,
|
|
386
|
+
state_laws: dict[str, StateLaw] | None = None,
|
|
387
|
+
on_ready: Callable[[Page], Awaitable[Any]] | None = None,
|
|
388
|
+
close_delay_s: float = 4.0) -> FillResult:
|
|
389
|
+
"""Open the broker's opt-out page in a visible window, fill it, let the person submit.
|
|
390
|
+
|
|
391
|
+
`broker_id` may be a covered_by child; the parent's form is used. `browser` (an
|
|
392
|
+
unstarted or started BrowserManager) is for tests; by default one is created and closed
|
|
393
|
+
here. `on_ready(page)` runs (as a task) once the banner is up; tests use it to play the
|
|
394
|
+
person. Never raises for page/browser problems: those return status "fallback".
|
|
395
|
+
"""
|
|
396
|
+
from ..consent import consent_ok
|
|
397
|
+
from . import tiers
|
|
398
|
+
cfg = cfg or cfgmod.load()
|
|
399
|
+
profile = store.get_profile(profile_id)
|
|
400
|
+
if profile is None:
|
|
401
|
+
return FillResult("refused", "no_profile", broker_id)
|
|
402
|
+
ok, _why = consent_ok(profile, store)
|
|
403
|
+
if not ok:
|
|
404
|
+
return FillResult("refused", "consent", broker_id)
|
|
405
|
+
if registry is None:
|
|
406
|
+
from ..registry import load_registry
|
|
407
|
+
registry = load_registry()
|
|
408
|
+
try:
|
|
409
|
+
broker = tiers.submit_target(registry.get(broker_id), registry)
|
|
410
|
+
except KeyError:
|
|
411
|
+
return FillResult("refused", "unknown_broker", broker_id)
|
|
412
|
+
if state_laws is None:
|
|
413
|
+
try:
|
|
414
|
+
from ..registry import load_state_laws
|
|
415
|
+
state_laws = load_state_laws()
|
|
416
|
+
except Exception: # noqa: BLE001
|
|
417
|
+
state_laws = {}
|
|
418
|
+
law = state_laws.get(profile.state_of_residence)
|
|
419
|
+
practice = not cfg.real_submissions_enabled
|
|
420
|
+
listing, listings = _confirmed_listing(store, profile.id, broker.id)
|
|
421
|
+
if broker.id != broker_id:
|
|
422
|
+
# covered_by child: the listing finder (D10) records the listing on the child site
|
|
423
|
+
c_listing, c_listings = _confirmed_listing(store, profile.id, broker_id)
|
|
424
|
+
listing = listing or c_listing
|
|
425
|
+
listings = listings + [li for li in c_listings if li not in listings]
|
|
426
|
+
res = FillResult("fallback", "", broker.id, practice_mode=practice)
|
|
427
|
+
|
|
428
|
+
def fallback(reason: str) -> FillResult:
|
|
429
|
+
res.status, res.reason = "fallback", reason
|
|
430
|
+
res.todo_id = _fallback_todo(store, profile, broker, listings, law,
|
|
431
|
+
"error" if reason.startswith("error") else None)
|
|
432
|
+
_event(store, profile.id, broker.id, "fill_for_me_fallback", reason=reason[:40],
|
|
433
|
+
submit_seen=res.submit_seen)
|
|
434
|
+
return res
|
|
435
|
+
|
|
436
|
+
if broker.method != Method.WEB_FORM or not broker.opt_out_url:
|
|
437
|
+
res.status, res.reason = "refused", "not_a_web_form"
|
|
438
|
+
return res
|
|
439
|
+
if broker.needs_id_upload and not profile.permissions.allow_id_upload:
|
|
440
|
+
return fallback("id_upload")
|
|
441
|
+
|
|
442
|
+
from ..netpolicy import NetPolicy
|
|
443
|
+
from .browser import BlockedByPolicy, BrowserManager
|
|
444
|
+
from .outbox import dev_fixture_base
|
|
445
|
+
own = browser is None
|
|
446
|
+
bm = browser or BrowserManager(NetPolicy(registry), headless=True, cfg=cfg,
|
|
447
|
+
fixture_base=dev_fixture_base())
|
|
448
|
+
bctx = None
|
|
449
|
+
try:
|
|
450
|
+
try:
|
|
451
|
+
await bm.start()
|
|
452
|
+
# SEC-36: the person may need the site's robot box; headed windows only.
|
|
453
|
+
bctx = await bm.new_context(broker.domains, headed=headed,
|
|
454
|
+
allow_captcha_frames=headed)
|
|
455
|
+
page = await bctx.new_page()
|
|
456
|
+
except Exception as e: # noqa: BLE001 - no browser: the card still works
|
|
457
|
+
log.warning("fill_for_me: browser unavailable (%s)", type(e).__name__)
|
|
458
|
+
return fallback("browser_unavailable")
|
|
459
|
+
try:
|
|
460
|
+
return await _drive(store, profile, broker, listing, bm, page, res, timeout_s,
|
|
461
|
+
practice, on_ready, close_delay_s, fallback)
|
|
462
|
+
except BlockedByPolicy:
|
|
463
|
+
return fallback("blocked")
|
|
464
|
+
except Exception as e: # noqa: BLE001 - window closed mid-call, odd page, ...
|
|
465
|
+
if page.is_closed():
|
|
466
|
+
return fallback("window_closed")
|
|
467
|
+
log.warning("fill_for_me failed: %s", type(e).__name__)
|
|
468
|
+
return fallback(f"error:{type(e).__name__}")
|
|
469
|
+
finally:
|
|
470
|
+
if bctx is not None:
|
|
471
|
+
try:
|
|
472
|
+
await bctx.close()
|
|
473
|
+
except Exception as e: # noqa: BLE001
|
|
474
|
+
log.debug("closing fill window failed: %s", type(e).__name__)
|
|
475
|
+
if own:
|
|
476
|
+
await bm.close()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
async def _drive(store: Store, profile: Profile, broker: Broker, listing: Listing | None,
|
|
480
|
+
bm: BrowserManager, page: Page, res: FillResult, timeout_s: int,
|
|
481
|
+
practice: bool, on_ready: Callable[[Page], Awaitable[Any]] | None,
|
|
482
|
+
close_delay_s: float, fallback: Callable[[str], FillResult]) -> FillResult:
|
|
483
|
+
from ..submitters.base import form_vars
|
|
484
|
+
closed = {"v": False}
|
|
485
|
+
page.on("close", lambda _p: closed.__setitem__("v", True))
|
|
486
|
+
|
|
487
|
+
await bm.goto(page, str(broker.opt_out_url), check_challenge=False)
|
|
488
|
+
values = filler.plan_values(profile, broker, listing)
|
|
489
|
+
rep = await filler.fill_page(page, values)
|
|
490
|
+
if broker.safe_checkboxes:
|
|
491
|
+
rep.ticked = await filler.tick_safe_checkboxes(page, broker.safe_checkboxes)
|
|
492
|
+
res.filled, res.not_found, res.withheld = (sorted(set(rep.filled)), rep.not_found,
|
|
493
|
+
sorted(set(rep.withheld)))
|
|
494
|
+
_event(store, profile.id, broker.id, "fill_for_me_filled", **rep.summary())
|
|
495
|
+
if not rep.filled:
|
|
496
|
+
return fallback("nothing_filled")
|
|
497
|
+
# Kept only as a fallback: the evidence shot is taken when the person submits (SEC-37).
|
|
498
|
+
filled_png = await page.screenshot(full_page=True)
|
|
499
|
+
|
|
500
|
+
texts, sels = success_signals(broker, form_vars(profile, broker, listing))
|
|
501
|
+
url_re = broker.success_url_pattern
|
|
502
|
+
lines = [MSG_FILLED]
|
|
503
|
+
if rep.not_found or rep.withheld or rep.unknown:
|
|
504
|
+
lines.append(MSG_MISSING)
|
|
505
|
+
if practice:
|
|
506
|
+
lines.append(MSG_PRACTICE)
|
|
507
|
+
lines.append(MSG_HINT)
|
|
508
|
+
banner = _banner_js(lines, done=False)
|
|
509
|
+
await page.evaluate(banner)
|
|
510
|
+
controls = rep.controls
|
|
511
|
+
hosts: set[str] = getattr(page.context, "freemyid_hosts", set())
|
|
512
|
+
# "base" = the page as it was before the person submitted: taken now, merged with every
|
|
513
|
+
# poll until the first submit event, and with a last look taken at that event itself.
|
|
514
|
+
submit: dict[str, Any] = {"n": 0, "shot": None,
|
|
515
|
+
"base": await _snapshot(page, texts, sels, controls)}
|
|
516
|
+
|
|
517
|
+
async def _watch(route: Any) -> None:
|
|
518
|
+
"""Submit-like events after the fill (SEC-32): a main-frame navigation, or an
|
|
519
|
+
XHR/fetch POST to an allowed host. The page is captured while the request waits,
|
|
520
|
+
i.e. exactly as the person sent it (SEC-37). Then the context route decides.
|
|
521
|
+
|
|
522
|
+
SEC-39: while a main-frame navigation is paused in this handler, Playwright never
|
|
523
|
+
answers a page.evaluate, so for navigations the baseline is the one from the last
|
|
524
|
+
poll (no snapshot here), and the request is always released (finally)."""
|
|
525
|
+
try:
|
|
526
|
+
req = route.request
|
|
527
|
+
try:
|
|
528
|
+
nav = req.is_navigation_request() and req.frame == page.main_frame
|
|
529
|
+
post = (req.resource_type in ("xhr", "fetch") and
|
|
530
|
+
req.method not in ("GET", "HEAD", "OPTIONS") and
|
|
531
|
+
bm.url_allowed(req.url, hosts))
|
|
532
|
+
except Exception: # noqa: BLE001 - no frame (worker): not a submit
|
|
533
|
+
nav = post = False
|
|
534
|
+
if nav or post:
|
|
535
|
+
first = submit["n"] == 0
|
|
536
|
+
submit["n"] += 1
|
|
537
|
+
if first and post and not nav: # the page before submitting
|
|
538
|
+
try:
|
|
539
|
+
submit["base"] = _merge_baseline(
|
|
540
|
+
submit["base"], await _snap_t(page, texts, sels, controls))
|
|
541
|
+
except Exception as e: # noqa: BLE001 - keep the last poll's baseline
|
|
542
|
+
log.debug("submit-time baseline failed: %s", type(e).__name__)
|
|
543
|
+
try:
|
|
544
|
+
submit["shot"] = await asyncio.wait_for(
|
|
545
|
+
page.screenshot(full_page=True), _CALL_TIMEOUT_S)
|
|
546
|
+
except Exception as e: # noqa: BLE001 - the fill-time shot is the fallback
|
|
547
|
+
log.debug("submit-time screenshot failed: %s", type(e).__name__)
|
|
548
|
+
finally:
|
|
549
|
+
try:
|
|
550
|
+
await route.fallback()
|
|
551
|
+
except Exception as e: # noqa: BLE001 - route already handled / page closed
|
|
552
|
+
log.debug("route fallback failed: %s", type(e).__name__)
|
|
553
|
+
|
|
554
|
+
await page.route("**/*", _watch)
|
|
555
|
+
task: asyncio.Task[Any] | None = (
|
|
556
|
+
asyncio.ensure_future(on_ready(page)) if on_ready else None)
|
|
557
|
+
|
|
558
|
+
loop = asyncio.get_running_loop()
|
|
559
|
+
deadline = loop.time() + max(1, timeout_s)
|
|
560
|
+
how: str | None = None
|
|
561
|
+
try:
|
|
562
|
+
while loop.time() < deadline:
|
|
563
|
+
if closed["v"] or page.is_closed():
|
|
564
|
+
return fallback("window_closed")
|
|
565
|
+
bm.check_escaped(page)
|
|
566
|
+
submitted_before = submit["n"] > 0
|
|
567
|
+
res.submit_seen = res.submit_seen or submitted_before
|
|
568
|
+
try:
|
|
569
|
+
now = await _snap_t(page, texts, sels, controls)
|
|
570
|
+
if not submitted_before:
|
|
571
|
+
if submit["n"] == 0: # nothing was submitted while we looked
|
|
572
|
+
submit["base"] = _merge_baseline(submit["base"], now)
|
|
573
|
+
else:
|
|
574
|
+
how = new_signal(submit["base"], now, url_re)
|
|
575
|
+
if how:
|
|
576
|
+
break
|
|
577
|
+
if await _eval_t(page, _NEEDS_BANNER_JS):
|
|
578
|
+
await _eval_t(page, banner) # page reloaded (e.g. a validation error)
|
|
579
|
+
except Exception: # noqa: BLE001 - mid-navigation; try again next tick
|
|
580
|
+
if closed["v"] or page.is_closed():
|
|
581
|
+
return fallback("window_closed")
|
|
582
|
+
await asyncio.sleep(_POLL_S)
|
|
583
|
+
if how is None:
|
|
584
|
+
if not (texts or sels or url_re) and res.submit_seen:
|
|
585
|
+
return fallback("unverified")
|
|
586
|
+
return fallback("timeout")
|
|
587
|
+
bm.check_escaped(page)
|
|
588
|
+
try:
|
|
589
|
+
await _eval_t(page, _REMOVE_BANNER_JS) # SEC-37: no banner in the proof
|
|
590
|
+
except Exception: # noqa: BLE001, S110 - mid-navigation: shoot what is there
|
|
591
|
+
pass
|
|
592
|
+
done_png = await page.screenshot(full_page=True)
|
|
593
|
+
sent_png = submit["shot"]
|
|
594
|
+
shots = [(sent_png, "form as sent") if sent_png else
|
|
595
|
+
(filled_png, "form filled by Free My ID"), (done_png, "confirmation page")]
|
|
596
|
+
res.request_id, res.evidence_ids = _record_success(store, profile, broker, listing,
|
|
597
|
+
shots, how)
|
|
598
|
+
res.status, res.reason = "submitted", how
|
|
599
|
+
try:
|
|
600
|
+
await _eval_t(page, _banner_js([MSG_DONE], done=True))
|
|
601
|
+
if close_delay_s > 0:
|
|
602
|
+
await asyncio.sleep(close_delay_s)
|
|
603
|
+
except Exception: # noqa: BLE001, S110 - person closed the window already
|
|
604
|
+
pass
|
|
605
|
+
return res
|
|
606
|
+
finally:
|
|
607
|
+
if task is not None and not task.done():
|
|
608
|
+
task.cancel()
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def fill_for_me_sync(store: Store, profile_id: str, broker_id: str, **kw: Any) -> FillResult:
|
|
612
|
+
"""Blocking wrapper for the wizard (safe to call from a thread or with a loop running)."""
|
|
613
|
+
try:
|
|
614
|
+
asyncio.get_running_loop()
|
|
615
|
+
except RuntimeError:
|
|
616
|
+
return asyncio.run(fill_for_me(store, profile_id, broker_id, **kw))
|
|
617
|
+
box: dict[str, Any] = {}
|
|
618
|
+
|
|
619
|
+
def _t() -> None:
|
|
620
|
+
try:
|
|
621
|
+
box["r"] = asyncio.run(fill_for_me(store, profile_id, broker_id, **kw))
|
|
622
|
+
except BaseException as e: # noqa: BLE001
|
|
623
|
+
box["e"] = e
|
|
624
|
+
|
|
625
|
+
th = threading.Thread(target=_t, name="freemyid-fill", daemon=True)
|
|
626
|
+
th.start()
|
|
627
|
+
th.join()
|
|
628
|
+
if "e" in box:
|
|
629
|
+
raise box["e"]
|
|
630
|
+
return box["r"]
|