optout 0.1.0__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.
- optout/__init__.py +3 -0
- optout/brokers/__init__.py +0 -0
- optout/brokers/loader.py +36 -0
- optout/brokers/registry.py +56 -0
- optout/brokers/schema.py +196 -0
- optout/cli.py +1185 -0
- optout/config.py +137 -0
- optout/data/__init__.py +0 -0
- optout/data/brokers/__init__.py +0 -0
- optout/data/brokers/beenverified.yml +197 -0
- optout/data/brokers/mylife.yml +122 -0
- optout/data/brokers/radaris.yml +127 -0
- optout/data/brokers/spokeo.yml +99 -0
- optout/data/brokers/whitepages.yml +149 -0
- optout/db.py +265 -0
- optout/engine/__init__.py +24 -0
- optout/engine/browser.py +45 -0
- optout/engine/dispatcher.py +73 -0
- optout/engine/escalation.py +1 -0
- optout/engine/methods/__init__.py +0 -0
- optout/engine/methods/email.py +158 -0
- optout/engine/methods/manual.py +16 -0
- optout/engine/methods/postal.py +18 -0
- optout/engine/methods/web_form.py +728 -0
- optout/logging.py +85 -0
- optout/monitoring.py +172 -0
- optout/scan/__init__.py +0 -0
- optout/scan/scanner.py +111 -0
- optout/utils/__init__.py +0 -0
- optout/utils/playwright_helpers.py +42 -0
- optout/utils/statutes.py +124 -0
- optout/verify.py +146 -0
- optout/wizard.py +365 -0
- optout-0.1.0.dist-info/METADATA +227 -0
- optout-0.1.0.dist-info/RECORD +38 -0
- optout-0.1.0.dist-info/WHEEL +5 -0
- optout-0.1.0.dist-info/entry_points.txt +2 -0
- optout-0.1.0.dist-info/top_level.txt +1 -0
optout/__init__.py
ADDED
|
File without changes
|
optout/brokers/loader.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Loads and validates broker YAML files against BrokerDef."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from .schema import BrokerDef
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_broker(path: Path) -> BrokerDef:
|
|
14
|
+
"""Parse and validate a single broker YAML file."""
|
|
15
|
+
data = yaml.safe_load(path.read_text())
|
|
16
|
+
try:
|
|
17
|
+
return BrokerDef.model_validate(data)
|
|
18
|
+
except ValidationError as exc:
|
|
19
|
+
raise ValueError(f"Invalid broker definition at {path}:\n{exc}") from exc
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_all_brokers(brokers_dir: Path) -> list[BrokerDef]:
|
|
23
|
+
"""Load every *.yml file in brokers_dir, sorted by slug."""
|
|
24
|
+
results: list[BrokerDef] = []
|
|
25
|
+
errors: list[str] = []
|
|
26
|
+
|
|
27
|
+
for yml in sorted(brokers_dir.glob("*.yml")):
|
|
28
|
+
try:
|
|
29
|
+
results.append(load_broker(yml))
|
|
30
|
+
except (ValueError, yaml.YAMLError) as exc:
|
|
31
|
+
errors.append(str(exc))
|
|
32
|
+
|
|
33
|
+
if errors:
|
|
34
|
+
raise ValueError(f"{len(errors)} broker(s) failed validation:\n" + "\n\n".join(errors))
|
|
35
|
+
|
|
36
|
+
return results
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""In-memory broker catalog, populated from the brokers/ YAML directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .loader import load_all_brokers
|
|
10
|
+
from .schema import BrokerDef
|
|
11
|
+
|
|
12
|
+
_registry: dict[str, BrokerDef] = {}
|
|
13
|
+
_loaded = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _brokers_dir() -> Path:
|
|
17
|
+
"""Return the directory containing broker YAML files.
|
|
18
|
+
|
|
19
|
+
Priority:
|
|
20
|
+
1. OPTOUT_BROKERS_DIR env var — lets users/tests override with a custom set.
|
|
21
|
+
2. importlib.resources — finds the YAMLs bundled inside the installed package,
|
|
22
|
+
works for both editable installs (real filesystem path) and wheel installs.
|
|
23
|
+
"""
|
|
24
|
+
env = os.environ.get("OPTOUT_BROKERS_DIR")
|
|
25
|
+
if env:
|
|
26
|
+
return Path(env)
|
|
27
|
+
return Path(str(files("optout").joinpath("data/brokers")))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_registry(brokers_dir: Path | None = None) -> None:
|
|
31
|
+
global _registry, _loaded
|
|
32
|
+
directory = brokers_dir or _brokers_dir()
|
|
33
|
+
if not directory.exists():
|
|
34
|
+
_registry = {}
|
|
35
|
+
_loaded = True
|
|
36
|
+
return
|
|
37
|
+
_registry = {b.slug: b for b in load_all_brokers(directory)}
|
|
38
|
+
_loaded = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ensure_loaded() -> None:
|
|
42
|
+
if not _loaded:
|
|
43
|
+
load_registry()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_broker(slug: str) -> BrokerDef:
|
|
47
|
+
_ensure_loaded()
|
|
48
|
+
try:
|
|
49
|
+
return _registry[slug]
|
|
50
|
+
except KeyError:
|
|
51
|
+
raise KeyError(f"Unknown broker slug: '{slug}'. Run `optout brokers list` to see options.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def all_brokers() -> list[BrokerDef]:
|
|
55
|
+
_ensure_loaded()
|
|
56
|
+
return sorted(_registry.values(), key=lambda b: b.name)
|
optout/brokers/schema.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Pydantic models for broker YAML definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, model_validator
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Resilience primitives
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RetryConfig(BaseModel):
|
|
16
|
+
attempts: int = 3
|
|
17
|
+
backoff_seconds: float = 1.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StepBase(BaseModel):
|
|
21
|
+
id: str
|
|
22
|
+
label: str | None = None
|
|
23
|
+
retry: RetryConfig | None = None
|
|
24
|
+
optional: bool = False
|
|
25
|
+
on_failure: Literal["abort", "skip", "prompt"] = "abort"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Step types (discriminated union on `type`)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SearchStep(StepBase):
|
|
34
|
+
type: Literal["search"]
|
|
35
|
+
search_url_template: str
|
|
36
|
+
result_selector: str
|
|
37
|
+
pick_strategy: str = "prompt_user"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NavigateStep(StepBase):
|
|
41
|
+
type: Literal["navigate"]
|
|
42
|
+
url: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FormField(BaseModel):
|
|
46
|
+
selector: str
|
|
47
|
+
value: str
|
|
48
|
+
use_type: bool = False # type char-by-char; needed for React forms that ignore fill()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FormFillStep(StepBase):
|
|
52
|
+
type: Literal["form_fill"]
|
|
53
|
+
fields: dict[str, FormField]
|
|
54
|
+
frame: str | None = None # CSS selector for an iframe to fill inside
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PromptUserIfPresentStep(StepBase):
|
|
58
|
+
type: Literal["prompt_user_if_present"]
|
|
59
|
+
selector: str
|
|
60
|
+
message: str
|
|
61
|
+
frame: str | None = None # CSS selector for an iframe to probe
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ClickStep(StepBase):
|
|
65
|
+
type: Literal["click"]
|
|
66
|
+
selector: str
|
|
67
|
+
frame: str | None = None # CSS selector for an iframe to click inside
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SmsVerificationStep(StepBase):
|
|
71
|
+
type: Literal["sms_verification_prompt"]
|
|
72
|
+
code_selector: str
|
|
73
|
+
message: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CaptureStep(StepBase):
|
|
77
|
+
type: Literal["capture"]
|
|
78
|
+
method: str # "screenshot" is the only current value
|
|
79
|
+
save_as: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WaitForStep(StepBase):
|
|
83
|
+
type: Literal["wait_for"]
|
|
84
|
+
selector: str | None = None
|
|
85
|
+
js_condition: str | None = None # JS predicate: () => bool; polled until truthy
|
|
86
|
+
timeout_ms: int = 120_000
|
|
87
|
+
message: str | None = None
|
|
88
|
+
frame: str | None = None # CSS selector for an iframe to probe
|
|
89
|
+
|
|
90
|
+
@model_validator(mode="after")
|
|
91
|
+
def _requires_selector_or_condition(self) -> WaitForStep:
|
|
92
|
+
if not self.selector and not self.js_condition:
|
|
93
|
+
raise ValueError("wait_for step requires 'selector' or 'js_condition'")
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class OpenInBrowserStep(StepBase):
|
|
98
|
+
type: Literal["open_in_browser"]
|
|
99
|
+
url: str
|
|
100
|
+
instructions: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CollectInputStep(StepBase):
|
|
104
|
+
type: Literal["collect_input"]
|
|
105
|
+
message: str
|
|
106
|
+
store_as: str # key written into StepState, e.g. "found_listing_url"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AssertStep(StepBase):
|
|
110
|
+
type: Literal["assert"]
|
|
111
|
+
selector: str | None = None
|
|
112
|
+
text_contains: str | None = None
|
|
113
|
+
url_matches: str | None = None
|
|
114
|
+
js_condition: str | None = None
|
|
115
|
+
|
|
116
|
+
@model_validator(mode="after")
|
|
117
|
+
def _requires_at_least_one_condition(self) -> AssertStep:
|
|
118
|
+
if not any([self.selector, self.text_contains, self.url_matches, self.js_condition]):
|
|
119
|
+
raise ValueError("assert step requires at least one condition field")
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
BrokerStep = Annotated[
|
|
124
|
+
SearchStep
|
|
125
|
+
| NavigateStep
|
|
126
|
+
| FormFillStep
|
|
127
|
+
| PromptUserIfPresentStep
|
|
128
|
+
| ClickStep
|
|
129
|
+
| SmsVerificationStep
|
|
130
|
+
| CaptureStep
|
|
131
|
+
| WaitForStep
|
|
132
|
+
| OpenInBrowserStep
|
|
133
|
+
| CollectInputStep
|
|
134
|
+
| AssertStep,
|
|
135
|
+
Field(discriminator="type"),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Scan config (how to check if the user appears on a broker)
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class BrokerScanConfig(BaseModel):
|
|
145
|
+
search_url_template: str
|
|
146
|
+
result_selector: str
|
|
147
|
+
match_fields: list[str] = []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Top-level BrokerDef
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class BrokerDef(BaseModel):
|
|
156
|
+
slug: str
|
|
157
|
+
name: str
|
|
158
|
+
domain: str
|
|
159
|
+
category: str
|
|
160
|
+
parent_company: str | None = None
|
|
161
|
+
|
|
162
|
+
method: Literal["web_form", "email", "postal", "manual"]
|
|
163
|
+
opt_out_url: str | None = None
|
|
164
|
+
opt_out_email: str | None = None
|
|
165
|
+
opt_out_postal_address: str | None = None
|
|
166
|
+
|
|
167
|
+
required_fields: list[str] = []
|
|
168
|
+
optional_fields: list[str] = []
|
|
169
|
+
|
|
170
|
+
requires_listing_url: bool = False
|
|
171
|
+
requires_phone_verification: bool = False
|
|
172
|
+
requires_email_verification: bool = False
|
|
173
|
+
requires_id_upload: bool = False
|
|
174
|
+
requires_notarization: bool = False
|
|
175
|
+
|
|
176
|
+
legal_basis: list[str]
|
|
177
|
+
statutory_response_days: int
|
|
178
|
+
re_add_window_days: int | None = None
|
|
179
|
+
|
|
180
|
+
last_verified_at: date | None = None
|
|
181
|
+
notes: str | None = None
|
|
182
|
+
|
|
183
|
+
scan: BrokerScanConfig | None = None
|
|
184
|
+
steps: list[BrokerStep] = []
|
|
185
|
+
|
|
186
|
+
@model_validator(mode="after")
|
|
187
|
+
def _method_fields_consistent(self) -> BrokerDef:
|
|
188
|
+
if self.method == "email" and not self.opt_out_email:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"Broker '{self.slug}': opt_out_email is required when method is 'email'"
|
|
191
|
+
)
|
|
192
|
+
if self.method == "web_form" and not self.opt_out_url:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Broker '{self.slug}': opt_out_url is required when method is 'web_form'"
|
|
195
|
+
)
|
|
196
|
+
return self
|