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,538 @@
|
|
|
1
|
+
"""Listing finder (PLAN_V1 D10): the person points at their own listing, Free My ID records it.
|
|
2
|
+
|
|
3
|
+
Flow (only ever started by the person; scheduled runs never do this):
|
|
4
|
+
1. consent gate, broker lookup;
|
|
5
|
+
2. a VISIBLE browser window (system Edge on Windows) opens the site's search through
|
|
6
|
+
BrowserManager (NetPolicy allowlist, `bm.goto`, `check_escaped` every poll):
|
|
7
|
+
* `Broker.search_url_pattern` set -> the results URL, already filled in;
|
|
8
|
+
* otherwise the site's page (`Broker.url`), whose search boxes `filler.fill_page`
|
|
9
|
+
fills with name + city/state (nothing else; Search is NOT pressed);
|
|
10
|
+
3. a big banner says "Find the result that is you, open it, then click the green
|
|
11
|
+
'This is me' button", and a fixed green 'This is me' button (plus a smaller
|
|
12
|
+
'I can't find me') is added to every page of the window;
|
|
13
|
+
4. a click on 'This is me' records the URL of the page the click came from (taken from
|
|
14
|
+
Playwright's frame, never from page script) if it is https, on the broker's own
|
|
15
|
+
domains and matches `Broker.listing_url_pattern` (when set). Otherwise the banner says
|
|
16
|
+
why and Free My ID keeps waiting;
|
|
17
|
+
5. accepted: a CONFIRMED Listing (match_reasons user_confirmed + listing_finder), a
|
|
18
|
+
screenshot as evidence, the broker moves to FOUND (state.after_scan), an Event with
|
|
19
|
+
actor USER, open REVIEW_MATCH to-dos for the broker are closed. D9 fill_for_me then
|
|
20
|
+
uses the listing URL;
|
|
21
|
+
6. timeout / window closed / 'I can't find me' / off-allowlist navigation / any error:
|
|
22
|
+
the existing REVIEW_MATCH to-do (if there is one) or the Assisted to-do card is
|
|
23
|
+
returned (created if needed), so nothing is lost.
|
|
24
|
+
|
|
25
|
+
Safety:
|
|
26
|
+
* The banner and buttons are built with DOM APIs and `textContent` only (no innerHTML),
|
|
27
|
+
all strings are constants or passed as `evaluate` arguments.
|
|
28
|
+
* The click is reported through a binding with a random per-window name. The init script
|
|
29
|
+
takes the binding into a closure and deletes it from `window` before any page script
|
|
30
|
+
runs, and only forwards clicks with `event.isTrusted` (a real mouse click, not a script
|
|
31
|
+
calling `.click()`). The binding argument is only an action name; the URL comes from
|
|
32
|
+
the calling frame, which must be a page's main frame.
|
|
33
|
+
* No CAPTCHA solving, no stealth: a real person is at a real browser.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import re
|
|
41
|
+
import secrets
|
|
42
|
+
import threading
|
|
43
|
+
from collections.abc import Awaitable, Callable
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
46
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
47
|
+
|
|
48
|
+
from .. import config as cfgmod
|
|
49
|
+
from ..models import (
|
|
50
|
+
Actor,
|
|
51
|
+
Broker,
|
|
52
|
+
Event,
|
|
53
|
+
EvidenceKind,
|
|
54
|
+
Listing,
|
|
55
|
+
ListingState,
|
|
56
|
+
MatchLevel,
|
|
57
|
+
ProfileField,
|
|
58
|
+
TodoKind,
|
|
59
|
+
utcnow,
|
|
60
|
+
)
|
|
61
|
+
from . import filler
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
from playwright.async_api import BrowserContext, Page
|
|
65
|
+
|
|
66
|
+
from ..models import Profile, Registry
|
|
67
|
+
from ..storage import Store
|
|
68
|
+
from .browser import BrowserManager
|
|
69
|
+
|
|
70
|
+
log = logging.getLogger(__name__)
|
|
71
|
+
|
|
72
|
+
BANNER_ID = "freemyid-find-banner"
|
|
73
|
+
BUTTON_ID = "freemyid-this-is-me"
|
|
74
|
+
CANT_ID = "freemyid-cant-find"
|
|
75
|
+
_POLL_S = 0.5
|
|
76
|
+
_URL_MAX = 2048
|
|
77
|
+
|
|
78
|
+
MSG_FIND = ("Find the result that is you, open it, then click the green 'This is me' "
|
|
79
|
+
"button.")
|
|
80
|
+
MSG_SEARCH_FIRST = ("Free My ID typed your name and town into the search boxes. Press the "
|
|
81
|
+
"site's Search button first.")
|
|
82
|
+
MSG_HINT = "Can't find yourself? Click 'I can't find me'. To stop, just close this window."
|
|
83
|
+
MSG_REJECT = {
|
|
84
|
+
"pattern_mismatch": "This page is not a single person's listing. Open the result that "
|
|
85
|
+
"is you, then click 'This is me' again.",
|
|
86
|
+
"search_page": "This is still the search page. Open the result that is you, then click "
|
|
87
|
+
"'This is me' again.",
|
|
88
|
+
"off_host": "This page is not part of the site Free My ID is checking. Go back to it, open "
|
|
89
|
+
"your listing, then click 'This is me' again.",
|
|
90
|
+
}
|
|
91
|
+
MSG_REJECT_OTHER = "Free My ID could not use this page. Open your listing, then click 'This is " \
|
|
92
|
+
"me' again."
|
|
93
|
+
MSG_DONE = "Thank you! Free My ID saved this listing. You can close this window now."
|
|
94
|
+
BTN_TEXT = "This is me"
|
|
95
|
+
CANT_TEXT = "I can't find me"
|
|
96
|
+
|
|
97
|
+
# Fields the person searched with. The confirmed listing showed them (that is how it came
|
|
98
|
+
# up and how the person recognised it), so the opt-out form may repeat them (D9 minimization).
|
|
99
|
+
SEARCH_FIELDS = [ProfileField.LISTING_URL, ProfileField.FULL_NAME, ProfileField.FIRST_NAME,
|
|
100
|
+
ProfileField.LAST_NAME, ProfileField.CITY, ProfileField.STATE]
|
|
101
|
+
REASON_USER = "user_confirmed"
|
|
102
|
+
REASON_FINDER = "listing_finder"
|
|
103
|
+
|
|
104
|
+
Status = Literal["found", "fallback", "refused"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class FindResult:
|
|
109
|
+
"""Outcome of one listing-finder session. `listing_url` is for the caller's UI only;
|
|
110
|
+
never log it (it usually contains the person's name)."""
|
|
111
|
+
status: Status
|
|
112
|
+
reason: str # this_is_me | timeout | window_closed | user_cant_find | blocked |
|
|
113
|
+
# browser_unavailable | error:<Type> | consent | unknown_broker | ...
|
|
114
|
+
broker_id: str
|
|
115
|
+
listing_id: str | None = None
|
|
116
|
+
listing_url: str | None = None
|
|
117
|
+
rejected: int = 0 # 'This is me' clicks on unusable pages
|
|
118
|
+
last_rejection: str | None = None # off_host | pattern_mismatch | search_page | ...
|
|
119
|
+
searched_with: str | None = None # search_url | form | none
|
|
120
|
+
todo_id: str | None = None # fallback card (REVIEW_MATCH or ASSISTED_OPT_OUT)
|
|
121
|
+
evidence_ids: list[str] | None = None
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def ok(self) -> bool:
|
|
125
|
+
return self.status == "found"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------- pure helpers
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def check_listing_url(broker: Broker, url: str, *,
|
|
132
|
+
search_url: str | None = None) -> tuple[str | None, str]:
|
|
133
|
+
"""Is `url` usable as this broker's listing URL?
|
|
134
|
+
|
|
135
|
+
Returns (normalized_url, "") or (None, reason). Rules: https only, no credentials,
|
|
136
|
+
host on `broker.domains` (or a subdomain), not the search page itself, and the path
|
|
137
|
+
must `re.search`-match `broker.listing_url_pattern` when the registry has one (bounded
|
|
138
|
+
input). Without a pattern the site root is refused too. Fragments are dropped.
|
|
139
|
+
"""
|
|
140
|
+
from ..netpolicy import _host_ok, host_of
|
|
141
|
+
if not url or len(url) > _URL_MAX:
|
|
142
|
+
return None, "bad_url"
|
|
143
|
+
try:
|
|
144
|
+
u = urlsplit(url)
|
|
145
|
+
except ValueError:
|
|
146
|
+
return None, "bad_url"
|
|
147
|
+
if u.scheme != "https":
|
|
148
|
+
return None, "not_https"
|
|
149
|
+
host = host_of(url)
|
|
150
|
+
if not host or u.username or u.password:
|
|
151
|
+
return None, "bad_url"
|
|
152
|
+
if not _host_ok(host, set(broker.domains)):
|
|
153
|
+
return None, "off_host"
|
|
154
|
+
path = u.path or "/"
|
|
155
|
+
if search_url:
|
|
156
|
+
s = urlsplit(search_url)
|
|
157
|
+
if (s.hostname or "").lower() == host and (s.path or "/") == path:
|
|
158
|
+
return None, "search_page"
|
|
159
|
+
if broker.listing_url_pattern:
|
|
160
|
+
try:
|
|
161
|
+
if re.search(broker.listing_url_pattern, path[:_URL_MAX]) is None:
|
|
162
|
+
return None, "pattern_mismatch"
|
|
163
|
+
except re.error:
|
|
164
|
+
return None, "pattern_mismatch"
|
|
165
|
+
elif path == "/":
|
|
166
|
+
return None, "search_page"
|
|
167
|
+
port = f":{u.port}" if u.port and u.port != 443 else ""
|
|
168
|
+
return urlunsplit(("https", host + port, path, u.query, "")), ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def search_values(profile: Profile) -> dict[str, str]:
|
|
172
|
+
"""What the search boxes get: name and current city/state. Nothing else (no phone,
|
|
173
|
+
street, zip, DOB or email)."""
|
|
174
|
+
cur = next((a for a in profile.addresses if a.current), profile.addresses[0])
|
|
175
|
+
n = profile.name
|
|
176
|
+
vals = {"first_name": n.first, "last_name": n.last,
|
|
177
|
+
"full_name": f"{n.first} {n.last}".strip(), "city": cur.city or "",
|
|
178
|
+
"state": (cur.state or "").upper()}
|
|
179
|
+
return {k: v for k, v in vals.items() if v}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --------------------------------------------------------------------------- page script
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _init_js(binding: str) -> str:
|
|
186
|
+
"""Runs before page scripts in every document of the window (main frames only).
|
|
187
|
+
|
|
188
|
+
All text is constant (json-encoded here) and set with textContent. The binding is moved
|
|
189
|
+
into this closure and deleted from window, so page script cannot call it; only trusted
|
|
190
|
+
clicks are forwarded.
|
|
191
|
+
"""
|
|
192
|
+
cfg = {"binding": binding, "banner": BANNER_ID, "btn": BUTTON_ID, "cant": CANT_ID,
|
|
193
|
+
"lines": [MSG_FIND, MSG_HINT], "btnText": BTN_TEXT, "cantText": CANT_TEXT}
|
|
194
|
+
return _INIT_TEMPLATE.replace("__FREEMYID_CFG__", json.dumps(cfg))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_INIT_TEMPLATE = """
|
|
198
|
+
(() => {
|
|
199
|
+
if (window.top !== window) return;
|
|
200
|
+
const C = __FREEMYID_CFG__;
|
|
201
|
+
const report = window[C.binding];
|
|
202
|
+
try { delete window[C.binding]; } catch (e) {}
|
|
203
|
+
try { if (C.binding in window) window[C.binding] = undefined; } catch (e) {}
|
|
204
|
+
if (typeof report !== 'function') return;
|
|
205
|
+
const mk = (tag) => document.createElement(tag);
|
|
206
|
+
const send = (action) => (ev) => {
|
|
207
|
+
if (!ev || !ev.isTrusted) return;
|
|
208
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
209
|
+
try { report(action); } catch (e) {}
|
|
210
|
+
};
|
|
211
|
+
const button = (id, text, css, action) => {
|
|
212
|
+
const b = mk('button');
|
|
213
|
+
b.id = id; b.type = 'button'; b.textContent = text;
|
|
214
|
+
b.setAttribute('aria-label', text);
|
|
215
|
+
b.style.cssText = css;
|
|
216
|
+
b.addEventListener('click', send(action), true);
|
|
217
|
+
return b;
|
|
218
|
+
};
|
|
219
|
+
const ensure = () => {
|
|
220
|
+
const root = document.body || document.documentElement;
|
|
221
|
+
if (!root) return;
|
|
222
|
+
if (!document.getElementById(C.banner)) {
|
|
223
|
+
const bar = mk('div');
|
|
224
|
+
bar.id = C.banner;
|
|
225
|
+
bar.setAttribute('role', 'alert');
|
|
226
|
+
bar.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:2147483646;' +
|
|
227
|
+
'background:#ffe600;color:#000;font:bold 26px/1.35 "Segoe UI",Arial,sans-serif;' +
|
|
228
|
+
'padding:18px 28px;border-bottom:6px solid #000;box-shadow:0 4px 12px rgba(0,0,0,.4);' +
|
|
229
|
+
'text-align:left;pointer-events:none;';
|
|
230
|
+
C.lines.forEach((t, i) => {
|
|
231
|
+
const p = mk('div');
|
|
232
|
+
p.className = 'freemyid-line';
|
|
233
|
+
p.textContent = t;
|
|
234
|
+
if (i > 0) p.style.cssText = 'font-size:22px;font-weight:600;margin-top:8px;';
|
|
235
|
+
bar.appendChild(p);
|
|
236
|
+
});
|
|
237
|
+
root.appendChild(bar);
|
|
238
|
+
if (document.body) document.body.style.marginTop = (bar.offsetHeight + 8) + 'px';
|
|
239
|
+
}
|
|
240
|
+
if (!document.getElementById(C.btn)) {
|
|
241
|
+
root.appendChild(button(C.btn, C.btnText,
|
|
242
|
+
'position:fixed;right:28px;bottom:96px;z-index:2147483647;background:#0b6b2e;' +
|
|
243
|
+
'color:#fff;font:bold 30px/1.2 "Segoe UI",Arial,sans-serif;padding:20px 36px;' +
|
|
244
|
+
'border:5px solid #fff;outline:4px solid #000;border-radius:14px;cursor:pointer;' +
|
|
245
|
+
'box-shadow:0 6px 18px rgba(0,0,0,.5);', 'this_is_me'));
|
|
246
|
+
}
|
|
247
|
+
if (!document.getElementById(C.cant)) {
|
|
248
|
+
root.appendChild(button(C.cant, C.cantText,
|
|
249
|
+
'position:fixed;right:28px;bottom:28px;z-index:2147483647;background:#fff;' +
|
|
250
|
+
'color:#000;font:600 20px/1.2 "Segoe UI",Arial,sans-serif;padding:10px 20px;' +
|
|
251
|
+
'border:3px solid #000;border-radius:10px;cursor:pointer;', 'cant_find'));
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
if (document.readyState === 'loading')
|
|
255
|
+
document.addEventListener('DOMContentLoaded', ensure, {once: true});
|
|
256
|
+
else ensure();
|
|
257
|
+
setInterval(ensure, 800);
|
|
258
|
+
})();
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# Replace the banner text (argument, never interpolated). Background green when done.
|
|
263
|
+
_SET_MSG_JS = """
|
|
264
|
+
([id, lines, done]) => {
|
|
265
|
+
const bar = document.getElementById(id);
|
|
266
|
+
if (!bar) return false;
|
|
267
|
+
while (bar.firstChild) bar.removeChild(bar.firstChild);
|
|
268
|
+
lines.forEach((t, i) => {
|
|
269
|
+
const p = document.createElement('div');
|
|
270
|
+
p.className = 'freemyid-line';
|
|
271
|
+
p.textContent = String(t);
|
|
272
|
+
if (i > 0) p.style.cssText = 'font-size:22px;font-weight:600;margin-top:8px;';
|
|
273
|
+
bar.appendChild(p);
|
|
274
|
+
});
|
|
275
|
+
if (done) { bar.style.background = '#0b6b2e'; bar.style.color = '#fff'; }
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --------------------------------------------------------------------------- bookkeeping
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _event(store: Store, profile_id: str, broker_id: str, action: str,
|
|
285
|
+
actor: Actor = Actor.ENGINE, frm: ListingState | None = None,
|
|
286
|
+
to: ListingState | None = None, **detail: Any) -> None:
|
|
287
|
+
store.log(Event(profile_id=profile_id, broker_id=broker_id, actor=actor, action=action,
|
|
288
|
+
from_state=frm, to_state=to, detail=detail))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _fallback_todo(store: Store, profile: Profile, broker: Broker) -> str:
|
|
292
|
+
"""The existing match-review card if there is one, else the Assisted card."""
|
|
293
|
+
for t in store.list_todos(profile.id):
|
|
294
|
+
if t.broker_id == broker.id and t.kind == TodoKind.REVIEW_MATCH:
|
|
295
|
+
return t.id
|
|
296
|
+
from .fill_for_me import _fallback_todo as assisted_card
|
|
297
|
+
listings = [li for li in store.list_listings(profile.id, broker.id)
|
|
298
|
+
if li.gone_since is None and li.match_level != MatchLevel.NO_MATCH]
|
|
299
|
+
return assisted_card(store, profile, broker, listings, None, None)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def record_listing(store: Store, profile: Profile, broker: Broker, url: str,
|
|
303
|
+
screenshot: bytes | None = None) -> tuple[Listing, list[str]]:
|
|
304
|
+
"""Store `url` as a CONFIRMED listing chosen by the person (actor USER).
|
|
305
|
+
|
|
306
|
+
Same record the matcher/runner keeps for a confirmed match (a REVIEW_MATCH 'yes' adds
|
|
307
|
+
the same `user_confirmed` reason, which the runner keeps on re-scan). Moves the broker
|
|
308
|
+
to FOUND via state.after_scan and clears next_check_at so the next run acts on it.
|
|
309
|
+
"""
|
|
310
|
+
from ..state import after_scan, can_transition, transition
|
|
311
|
+
from ..submitters import base
|
|
312
|
+
now = utcnow()
|
|
313
|
+
prev = next((li for li in store.list_listings(profile.id, broker.id) if li.url == url),
|
|
314
|
+
None)
|
|
315
|
+
reasons = sorted({*(prev.match_reasons if prev and prev.match_level != MatchLevel.NO_MATCH
|
|
316
|
+
else []), REASON_USER, REASON_FINDER})
|
|
317
|
+
shown = sorted({*(prev.shown_fields if prev else []), *SEARCH_FIELDS},
|
|
318
|
+
key=lambda f: f.value)
|
|
319
|
+
if prev is not None:
|
|
320
|
+
li = prev.model_copy(update={"match_level": MatchLevel.CONFIRMED, "match_score": 100.0,
|
|
321
|
+
"match_reasons": reasons, "shown_fields": shown,
|
|
322
|
+
"last_seen": now, "gone_since": None})
|
|
323
|
+
else:
|
|
324
|
+
li = Listing(profile_id=profile.id, broker_id=broker.id, url=url, match_score=100.0,
|
|
325
|
+
match_level=MatchLevel.CONFIRMED, match_reasons=reasons,
|
|
326
|
+
shown_fields=shown, first_seen=now, last_seen=now)
|
|
327
|
+
store.save_listing(li)
|
|
328
|
+
ev_ids: list[str] = []
|
|
329
|
+
if screenshot:
|
|
330
|
+
ev = base.evidence(profile.id, broker.id, EvidenceKind.SCREENSHOT, "png",
|
|
331
|
+
listing_id=li.id, caption="listing chosen by the person")
|
|
332
|
+
ev_ids.append(store.save_evidence(ev, screenshot).id)
|
|
333
|
+
st = store.get_status(profile.id, broker.id)
|
|
334
|
+
frm = st.state
|
|
335
|
+
dst = after_scan(st, True, now)
|
|
336
|
+
if dst != st.state and can_transition(st.state, dst):
|
|
337
|
+
st = transition(st, dst, now)
|
|
338
|
+
store.save_status(st.model_copy(update={"next_check_at": None}))
|
|
339
|
+
_event(store, profile.id, broker.id, "listing_confirmed", Actor.USER, frm, st.state,
|
|
340
|
+
listing_id=li.id, via=REASON_FINDER, new=prev is None)
|
|
341
|
+
for t in store.list_todos(profile.id):
|
|
342
|
+
if t.broker_id == broker.id and t.kind == TodoKind.REVIEW_MATCH:
|
|
343
|
+
store.complete_todo(t.id)
|
|
344
|
+
return li, ev_ids
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# --------------------------------------------------------------------------- entry point
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def find_listing(store: Store, profile_id: str, broker_id: str, *, timeout_s: int = 600,
|
|
351
|
+
registry: Registry | None = None, browser: BrowserManager | None = None,
|
|
352
|
+
headed: bool = True, cfg: cfgmod.Config | None = None,
|
|
353
|
+
on_ready: Callable[[Page], Awaitable[Any]] | None = None,
|
|
354
|
+
close_delay_s: float = 3.0) -> FindResult:
|
|
355
|
+
"""Open the broker's search in a visible window and let the person pick their listing.
|
|
356
|
+
|
|
357
|
+
`broker_id` is the people-search site itself (not its covered_by parent: the listing
|
|
358
|
+
lives on this site). `browser` (an unstarted or started BrowserManager) is for tests;
|
|
359
|
+
by default one is created and closed here. `on_ready(page)` runs as a task once the
|
|
360
|
+
buttons are up; tests use it to play the person. Never raises for page/browser
|
|
361
|
+
problems: those return status "fallback".
|
|
362
|
+
"""
|
|
363
|
+
from ..consent import consent_ok
|
|
364
|
+
cfg = cfg or cfgmod.load()
|
|
365
|
+
profile = store.get_profile(profile_id)
|
|
366
|
+
if profile is None:
|
|
367
|
+
return FindResult("refused", "no_profile", broker_id)
|
|
368
|
+
ok, _why = consent_ok(profile, store)
|
|
369
|
+
if not ok:
|
|
370
|
+
return FindResult("refused", "consent", broker_id)
|
|
371
|
+
if registry is None:
|
|
372
|
+
from ..registry import load_registry
|
|
373
|
+
registry = load_registry()
|
|
374
|
+
try:
|
|
375
|
+
broker = registry.get(broker_id)
|
|
376
|
+
except KeyError:
|
|
377
|
+
return FindResult("refused", "unknown_broker", broker_id)
|
|
378
|
+
res = FindResult("fallback", "", broker.id)
|
|
379
|
+
|
|
380
|
+
def fallback(reason: str) -> FindResult:
|
|
381
|
+
res.status, res.reason = "fallback", reason
|
|
382
|
+
res.todo_id = _fallback_todo(store, profile, broker)
|
|
383
|
+
_event(store, profile.id, broker.id, "listing_finder_fallback", reason=reason[:40],
|
|
384
|
+
rejected=res.rejected)
|
|
385
|
+
return res
|
|
386
|
+
|
|
387
|
+
from ..netpolicy import NetPolicy
|
|
388
|
+
from .browser import BlockedByPolicy, BrowserManager
|
|
389
|
+
from .outbox import dev_fixture_base
|
|
390
|
+
own = browser is None
|
|
391
|
+
bm = browser or BrowserManager(NetPolicy(registry), headless=True, cfg=cfg,
|
|
392
|
+
fixture_base=dev_fixture_base())
|
|
393
|
+
bctx = None
|
|
394
|
+
try:
|
|
395
|
+
try:
|
|
396
|
+
await bm.start()
|
|
397
|
+
# SEC-36: the person may need the site's robot box; headed windows only.
|
|
398
|
+
bctx = await bm.new_context(broker.domains, headed=headed,
|
|
399
|
+
allow_captcha_frames=headed)
|
|
400
|
+
except Exception as e: # noqa: BLE001 - no browser: the card still works
|
|
401
|
+
log.warning("listing_finder: browser unavailable (%s)", type(e).__name__)
|
|
402
|
+
return fallback("browser_unavailable")
|
|
403
|
+
try:
|
|
404
|
+
return await _drive(store, profile, broker, bm, bctx, res, timeout_s, on_ready,
|
|
405
|
+
close_delay_s, fallback)
|
|
406
|
+
except BlockedByPolicy:
|
|
407
|
+
return fallback("blocked")
|
|
408
|
+
except Exception as e: # noqa: BLE001 - window closed mid-call, odd page, ...
|
|
409
|
+
if not [p for p in bctx.pages if not p.is_closed()]:
|
|
410
|
+
return fallback("window_closed")
|
|
411
|
+
log.warning("listing_finder failed: %s", type(e).__name__)
|
|
412
|
+
return fallback(f"error:{type(e).__name__}")
|
|
413
|
+
finally:
|
|
414
|
+
if bctx is not None:
|
|
415
|
+
try:
|
|
416
|
+
await bctx.close()
|
|
417
|
+
except Exception as e: # noqa: BLE001
|
|
418
|
+
log.debug("closing finder window failed: %s", type(e).__name__)
|
|
419
|
+
if own:
|
|
420
|
+
await bm.close()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def _drive(store: Store, profile: Profile, broker: Broker, bm: BrowserManager,
|
|
424
|
+
bctx: BrowserContext, res: FindResult, timeout_s: int,
|
|
425
|
+
on_ready: Callable[[Page], Awaitable[Any]] | None, close_delay_s: float,
|
|
426
|
+
fallback: Callable[[str], FindResult]) -> FindResult:
|
|
427
|
+
from ..submitters.web_form import search_url
|
|
428
|
+
clicks: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
|
|
429
|
+
binding = "__freemyid_" + secrets.token_hex(8)
|
|
430
|
+
|
|
431
|
+
async def _on_click(source: Any, action: Any = None) -> None:
|
|
432
|
+
if action not in ("this_is_me", "cant_find"):
|
|
433
|
+
return
|
|
434
|
+
frame = source.get("frame") if isinstance(source, dict) else None
|
|
435
|
+
pg = source.get("page") if isinstance(source, dict) else None
|
|
436
|
+
if frame is None or pg is None or frame != pg.main_frame:
|
|
437
|
+
return
|
|
438
|
+
await clicks.put((str(action), pg))
|
|
439
|
+
|
|
440
|
+
await bctx.expose_binding(binding, _on_click)
|
|
441
|
+
await bctx.add_init_script(_init_js(binding))
|
|
442
|
+
page = await bctx.new_page()
|
|
443
|
+
|
|
444
|
+
surl = search_url(broker, profile)
|
|
445
|
+
start = surl or str(broker.search_page_url or broker.url)
|
|
446
|
+
await bm.goto(page, start, check_challenge=False)
|
|
447
|
+
lines = [MSG_FIND]
|
|
448
|
+
if surl:
|
|
449
|
+
res.searched_with = "search_url"
|
|
450
|
+
else:
|
|
451
|
+
rep = await filler.fill_page(page, search_values(profile))
|
|
452
|
+
res.searched_with = "form" if rep.filled else "none"
|
|
453
|
+
if rep.filled:
|
|
454
|
+
lines.append(MSG_SEARCH_FIRST)
|
|
455
|
+
_event(store, profile.id, broker.id, "listing_finder_search", filled=sorted(
|
|
456
|
+
set(rep.filled)), searched_with=res.searched_with)
|
|
457
|
+
lines.append(MSG_HINT)
|
|
458
|
+
current = lines
|
|
459
|
+
|
|
460
|
+
async def _say(pg: Page, msg: list[str], done: bool = False) -> None:
|
|
461
|
+
try:
|
|
462
|
+
await pg.evaluate(_SET_MSG_JS, [BANNER_ID, msg, done])
|
|
463
|
+
except Exception: # noqa: BLE001, S110 - mid-navigation; the next poll retries
|
|
464
|
+
pass
|
|
465
|
+
|
|
466
|
+
await page.wait_for_selector(f"#{BUTTON_ID}", state="attached", timeout=10_000)
|
|
467
|
+
await _say(page, current)
|
|
468
|
+
task: asyncio.Task[Any] | None = (
|
|
469
|
+
asyncio.ensure_future(on_ready(page)) if on_ready else None)
|
|
470
|
+
|
|
471
|
+
loop = asyncio.get_running_loop()
|
|
472
|
+
deadline = loop.time() + max(1, timeout_s)
|
|
473
|
+
try:
|
|
474
|
+
while loop.time() < deadline:
|
|
475
|
+
open_pages = [p for p in bctx.pages if not p.is_closed()]
|
|
476
|
+
if not open_pages:
|
|
477
|
+
return fallback("window_closed")
|
|
478
|
+
for p in open_pages:
|
|
479
|
+
bm.check_escaped(p)
|
|
480
|
+
try:
|
|
481
|
+
action, pg = await asyncio.wait_for(clicks.get(), timeout=_POLL_S)
|
|
482
|
+
except TimeoutError:
|
|
483
|
+
for p in open_pages:
|
|
484
|
+
await _say(p, current) # keep the message after a reload
|
|
485
|
+
continue
|
|
486
|
+
if action == "cant_find":
|
|
487
|
+
_event(store, profile.id, broker.id, "listing_finder_cant_find", Actor.USER)
|
|
488
|
+
return fallback("user_cant_find")
|
|
489
|
+
if pg.is_closed():
|
|
490
|
+
continue
|
|
491
|
+
bm.check_escaped(pg)
|
|
492
|
+
url, why = check_listing_url(broker, pg.url, search_url=start)
|
|
493
|
+
if url is None:
|
|
494
|
+
res.rejected += 1
|
|
495
|
+
res.last_rejection = why
|
|
496
|
+
_event(store, profile.id, broker.id, "listing_finder_rejected", Actor.USER,
|
|
497
|
+
why=why)
|
|
498
|
+
current = [MSG_REJECT.get(why, MSG_REJECT_OTHER), MSG_HINT]
|
|
499
|
+
await _say(pg, current)
|
|
500
|
+
continue
|
|
501
|
+
shot: bytes | None
|
|
502
|
+
try:
|
|
503
|
+
shot = await pg.screenshot(full_page=True)
|
|
504
|
+
except Exception: # noqa: BLE001 - the listing still counts without a picture
|
|
505
|
+
shot = None
|
|
506
|
+
li, ev_ids = record_listing(store, profile, broker, url, shot)
|
|
507
|
+
res.status, res.reason = "found", "this_is_me"
|
|
508
|
+
res.listing_id, res.listing_url, res.evidence_ids = li.id, li.url, ev_ids
|
|
509
|
+
await _say(pg, [MSG_DONE], done=True)
|
|
510
|
+
if close_delay_s > 0:
|
|
511
|
+
await asyncio.sleep(close_delay_s)
|
|
512
|
+
return res
|
|
513
|
+
return fallback("timeout")
|
|
514
|
+
finally:
|
|
515
|
+
if task is not None and not task.done():
|
|
516
|
+
task.cancel()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def find_listing_sync(store: Store, profile_id: str, broker_id: str, **kw: Any) -> FindResult:
|
|
520
|
+
"""Blocking wrapper for the wizard/CLI (safe from a thread or with a loop running)."""
|
|
521
|
+
try:
|
|
522
|
+
asyncio.get_running_loop()
|
|
523
|
+
except RuntimeError:
|
|
524
|
+
return asyncio.run(find_listing(store, profile_id, broker_id, **kw))
|
|
525
|
+
box: dict[str, Any] = {}
|
|
526
|
+
|
|
527
|
+
def _t() -> None:
|
|
528
|
+
try:
|
|
529
|
+
box["r"] = asyncio.run(find_listing(store, profile_id, broker_id, **kw))
|
|
530
|
+
except BaseException as e: # noqa: BLE001
|
|
531
|
+
box["e"] = e
|
|
532
|
+
|
|
533
|
+
th = threading.Thread(target=_t, name="freemyid-find", daemon=True)
|
|
534
|
+
th.start()
|
|
535
|
+
th.join()
|
|
536
|
+
if "e" in box:
|
|
537
|
+
raise box["e"]
|
|
538
|
+
return box["r"]
|