freemyid 0.2.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,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"]