mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-16 14:32:34 +00:00
unbroker finds where a consenting person's info is exposed across data brokers and people-search sites and files the removals, running as far as each site allows and handing only genuinely human-only steps (hard CAPTCHA, gov-ID, phone, fax) back as an end-of-run digest. - Deterministic stdlib CLI (scripts/pdd.py) owns config, dossiers+consent, the broker DB, tier planning, the ledger, email, and the autonomous action queue; the agent scans/submits with native tools (web_extract, browser_*, delegate_task, cronjob, terminal). - Verify-before-disclose, least-disclosure (never volunteers SSN), consent gate, opaque ids, optional age-at-rest encryption, file-locked ledger. - Jurisdiction-aware (CCPA/CPRA, GDPR, generic); CA DROP one-shot covers the state registry (~545) in a single request; BADBOOL + curated people-search coverage; scheduled re-scan for re-listing. - No CAPTCHA-solving services or anti-bot bypass; browser email mode needs no stored password. - 85 hermetic tests (tests/skills/test_unbroker_skill.py; SMTP/IMAP via injected fakes, registry via CSV fixtures). Ships placeholder data only. Broker dataset adapted from BADBOOL (Yael Grauer, CC BY-NC-SA 4.0).
177 lines
6 KiB
Python
177 lines
6 KiB
Python
"""Pull and parse the Big-Ass Data Broker Opt-Out List (BADBOOL) into broker records.
|
|
|
|
BADBOOL (https://github.com/yaelwrites/Big-Ass-Data-Broker-Opt-Out-List) is a
|
|
maintained, frequently-updated markdown list. `refresh` fetches it and parses the
|
|
"People Search Sites" section into records that merge UNDER the curated DB (curated
|
|
records always win). Auto-parsed records carry source="BADBOOL-auto" and
|
|
confidence="auto" so the agent treats their URLs as best guesses to verify first.
|
|
|
|
`parse()` is pure (markdown in, records out) so it is tested offline; `fetch()` is
|
|
the only network call and can be bypassed by passing markdown directly to refresh().
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
import storage
|
|
|
|
DEFAULT_URL = (
|
|
"https://raw.githubusercontent.com/yaelwrites/"
|
|
"Big-Ass-Data-Broker-Opt-Out-List/master/README.md"
|
|
)
|
|
USER_AGENT = "Mozilla/5.0 (compatible; unbroker/1.0; data opt-out)"
|
|
|
|
# BADBOOL legend symbols.
|
|
SYMBOLS = {
|
|
"crucial": "\U0001F490", # 💐
|
|
"high": "\u2620", # ☠
|
|
"gov_id": "\U0001F3AB", # 🎫
|
|
"phone": "\U0001F4DE", # 📞
|
|
"payment": "\U0001F4B0", # 💰
|
|
}
|
|
|
|
_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
_OPTOUT_HINT = re.compile(
|
|
r"opt[\- ]?out|optout|removal|remove|suppress|control-privacy|delete", re.I
|
|
)
|
|
_FIND_HINT = re.compile(r"find|your information|search|look ?up|look for", re.I)
|
|
|
|
|
|
def slug(name: str) -> str:
|
|
# Drop a trailing .com/.org/.info on the displayed name so "FastPeopleSearch.com"
|
|
# matches the curated id "fastpeoplesearch"; keep .net/.id so distinct sites differ.
|
|
n = re.sub(r"\.(com|org|info)\b", "", name.strip(), flags=re.I)
|
|
return re.sub(r"[^a-z0-9]+", "", n.lower())
|
|
|
|
|
|
def _heading_flags(heading: str) -> tuple[str, dict]:
|
|
flags = {key: (sym in heading) for key, sym in SYMBOLS.items()}
|
|
name = heading
|
|
for sym in SYMBOLS.values():
|
|
name = name.replace(sym, "")
|
|
name = name.replace("\ufe0f", "").strip()
|
|
return name, flags
|
|
|
|
|
|
def _priority(flags: dict) -> str:
|
|
if flags["crucial"]:
|
|
return "crucial"
|
|
if flags["high"]:
|
|
return "high"
|
|
return "standard"
|
|
|
|
|
|
def _pick(links: list[tuple[str, str]], hint: re.Pattern) -> str | None:
|
|
for _text, url in links:
|
|
if hint.search(url):
|
|
return url
|
|
for text, url in links:
|
|
if hint.search(text):
|
|
return url
|
|
return None
|
|
|
|
|
|
def _clean(text: str) -> str:
|
|
return re.sub(r"\s+", " ", text).strip()[:600]
|
|
|
|
|
|
def _build(name: str, flags: dict, body: str) -> dict:
|
|
links = _LINK_RE.findall(body)
|
|
web = [(t, u) for t, u in links if u.lower().startswith("http")]
|
|
mailtos = [u[7:] for _t, u in links if u.lower().startswith("mailto:")]
|
|
optout_url = _pick(web, _OPTOUT_HINT)
|
|
search_url = _pick(web, _FIND_HINT) or (web[0][1] if web else None)
|
|
|
|
if flags["phone"]:
|
|
method = "phone"
|
|
elif optout_url:
|
|
method = "web_form"
|
|
elif mailtos:
|
|
method = "email"
|
|
else:
|
|
method = "manual"
|
|
|
|
return {
|
|
"id": slug(name),
|
|
"name": name,
|
|
"category": "people_search",
|
|
"priority": _priority(flags),
|
|
"jurisdictions": ["US"],
|
|
"search": {"method": "url_pattern", "url": search_url, "fetch": "browser",
|
|
"match_signal": "result", "by": ["name", "phone", "address"]},
|
|
"optout": {
|
|
"method": method,
|
|
"url": optout_url,
|
|
"email": mailtos[0] if mailtos else None,
|
|
"requires": {
|
|
"gov_id": flags["gov_id"],
|
|
"phone_voice": flags["phone"],
|
|
"payment": flags["payment"],
|
|
"email_verification": False,
|
|
"captcha": False,
|
|
"account": False,
|
|
"phone_callback": False,
|
|
},
|
|
"inputs": ["full_name", "contact_email"],
|
|
"notes": _clean(body),
|
|
"links": [{"text": t, "url": u} for t, u in links],
|
|
"est_processing_days": 14, # unknown for auto records; drives next_recheck_at
|
|
},
|
|
"source": "BADBOOL-auto",
|
|
"confidence": "auto",
|
|
"last_verified": None,
|
|
}
|
|
|
|
|
|
def parse(markdown: str) -> list[dict]:
|
|
"""Parse the 'People Search Sites' section of BADBOOL into broker records."""
|
|
records: list[dict] = []
|
|
in_people = False
|
|
heading: str | None = None
|
|
body: list[str] = []
|
|
|
|
def flush() -> None:
|
|
nonlocal heading, body
|
|
if heading is not None:
|
|
name, flags = _heading_flags(heading)
|
|
if name:
|
|
records.append(_build(name, flags, "\n".join(body).strip()))
|
|
heading, body = None, []
|
|
|
|
for line in markdown.splitlines():
|
|
if line.startswith("## "):
|
|
flush()
|
|
in_people = line[3:].strip().lower().startswith("people search")
|
|
continue
|
|
if not in_people:
|
|
continue
|
|
if line.startswith("### "):
|
|
flush()
|
|
heading = line[4:].strip()
|
|
elif heading is not None:
|
|
body.append(line)
|
|
flush()
|
|
return records
|
|
|
|
|
|
def fetch(url: str = DEFAULT_URL, timeout: int = 30) -> str:
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
return resp.read().decode("utf-8", errors="replace")
|
|
|
|
|
|
MIN_EXPECTED = 20 # BADBOOL's People Search section lists ~47; far fewer => upstream reorg, warn
|
|
|
|
|
|
def refresh(cache_path: Path, url: str = DEFAULT_URL, markdown: str | None = None) -> dict:
|
|
"""Fetch (or accept) BADBOOL markdown, parse it, and write the snapshot cache."""
|
|
md = markdown if markdown is not None else fetch(url)
|
|
records = parse(md)
|
|
storage.write_json(cache_path, records)
|
|
out = {"parsed": len(records), "cache_path": str(cache_path), "source_url": url}
|
|
if len(records) < MIN_EXPECTED:
|
|
out["warning"] = (f"only {len(records)} parsed (expected >{MIN_EXPECTED}); BADBOOL's "
|
|
"'People Search Sites' section may have moved/reorganized - check the parser")
|
|
return out
|