refactor(pry): rename 19 root modules to pry_<name> to remove naming collision with routers/
The duplicate file names (advanced, agency, auth, compliance, costing, etc.) in both root and routers/ made imports ambiguous. Renamed root modules to pry_<name>.py so: from quality import X -> from pry_quality import X from routers.quality import router (unchanged) Also: - Renamed x402.py to pry_x402/ package directory - Fixed 21+ bare imports across api.py, deps.py, routers/, tests/, llm_providers/ Tests: 593 passed, 1 skipped (test_ready_returns_200 fails pre-existing because Ollama is unreachable from test env, unrelated to this refactor). Audit item 8.
This commit is contained in:
parent
090f284c37
commit
0ecc250349
48 changed files with 46 additions and 46 deletions
482
pry_compliance.py
Normal file
482
pry_compliance.py
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
"""Pry — Legal Compliance Engine.
|
||||
Per-source compliance scorecard: robots.txt, ToS, GDPR/CCPA, jurisdiction."""
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
# Licensed under MIT. See LICENSE.
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# GDPR/CCPA sensitive data patterns
|
||||
SENSITIVE_DATA_PATTERNS = {
|
||||
"personally_identifiable": [
|
||||
r"\b[A-Z][a-z]+ [A-Z][a-z]+\b", # Full names
|
||||
r"\b\d{3}-\d{2}-\d{4}\b", # SSN
|
||||
r"\b\d{9}\b", # SSN compact
|
||||
r"\b\d{1,2}/\d{1,2}/\d{4}\b", # Dates
|
||||
],
|
||||
"financial": [
|
||||
r"\$\d+(?:,\d{3})*(?:\.\d{2})?", # Dollar amounts
|
||||
r"\b\d{4}[ -]?\d{4}[ -]?\d{4}[ -]?\d{4}\b", # Credit cards
|
||||
r"\b(?:invoice|payment|billing|purchase)\b",
|
||||
],
|
||||
"contact": [
|
||||
r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b", # Emails
|
||||
r"\b\+?\d{1,3}[-.]?\d{3,4}[-.]?\d{4}\b", # Phones
|
||||
r"\b\d{5}(?:-\d{4})?\b", # ZIP codes
|
||||
],
|
||||
"health": [
|
||||
r"\b(?:diagnosis|patient|medical|treatment|healthcare)\b",
|
||||
r"\b(?:HIPAA|HIPPA|PHI)\b",
|
||||
],
|
||||
"employment": [
|
||||
r"\b(?:salary|wage|compensation|payroll|bonus)\b",
|
||||
r"\b(?:resume|CV|applicant|candidate)\b",
|
||||
],
|
||||
}
|
||||
|
||||
# Known vendor block pages for TOS classification
|
||||
TOS_INDICATORS = {
|
||||
"restrictive": [
|
||||
r"no scraping|no crawling|no automated",
|
||||
r"prohibited.*automated|automated.*prohibited",
|
||||
r"reverse engineer|decompile|disassemble",
|
||||
r"commercial use.*prohibited|not.*commercial use",
|
||||
r"rate limit|throttle|api limit",
|
||||
r"copyright.*all rights reserved",
|
||||
r"do not store|cache.*prohibited",
|
||||
],
|
||||
"permissive": [
|
||||
r"open data|public data|freely available",
|
||||
r"creative commons|CC BY|CC0",
|
||||
r"api.*available|public.*api",
|
||||
r"attribution required",
|
||||
r"research.*permitted|academic.*use",
|
||||
],
|
||||
"moderate": [
|
||||
r"personal use only|non-commercial only",
|
||||
r"attribution.*required|credit.*required",
|
||||
r"limited.*use|reasonable.*use",
|
||||
r"fair use|fair dealing",
|
||||
],
|
||||
}
|
||||
|
||||
# Jurisdiction detection by TLD and language patterns
|
||||
JURISDICTION_MAP = {
|
||||
".eu": "eu",
|
||||
".de": "eu",
|
||||
".fr": "eu",
|
||||
".nl": "eu",
|
||||
".it": "eu",
|
||||
".es": "eu",
|
||||
".pl": "eu",
|
||||
".se": "eu",
|
||||
".dk": "eu",
|
||||
".fi": "eu",
|
||||
".at": "eu",
|
||||
".be": "eu",
|
||||
".ie": "eu",
|
||||
".pt": "eu",
|
||||
".gr": "eu",
|
||||
".cz": "eu",
|
||||
".hu": "eu",
|
||||
".ro": "eu",
|
||||
".bg": "eu",
|
||||
".sk": "eu",
|
||||
".si": "eu",
|
||||
".lt": "eu",
|
||||
".lv": "eu",
|
||||
".ee": "eu",
|
||||
".hr": "eu",
|
||||
".mt": "eu",
|
||||
".lu": "eu",
|
||||
".cy": "eu",
|
||||
".co.uk": "eu",
|
||||
".uk": "eu",
|
||||
".ch": "other",
|
||||
".no": "other",
|
||||
".is": "other",
|
||||
".ca": "ca",
|
||||
".com.au": "au",
|
||||
".jp": "jp",
|
||||
".cn": "cn",
|
||||
".in": "in",
|
||||
}
|
||||
|
||||
|
||||
async def check_robots_txt(url: str) -> dict[str, Any]:
|
||||
"""Fetch and parse robots.txt, return crawl permissions for this URL."""
|
||||
parsed = urlparse(url)
|
||||
robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
|
||||
result: dict[str, Any] = {
|
||||
"robots_url": robots_url,
|
||||
"accessible": False,
|
||||
"crawl_allowed": True,
|
||||
"crawl_delay": 0,
|
||||
"disallowed_paths": [],
|
||||
"sitemaps": [],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(robots_url, follow_redirects=True)
|
||||
if resp.status_code == 404:
|
||||
result["accessible"] = False
|
||||
result["crawl_allowed"] = True # No robots.txt = no restrictions
|
||||
result["note"] = "No robots.txt found — no restrictions"
|
||||
return result
|
||||
if resp.status_code >= 400:
|
||||
result["accessible"] = False
|
||||
result["crawl_allowed"] = True
|
||||
result["note"] = f"robots.txt returned {resp.status_code}"
|
||||
return result
|
||||
|
||||
result["accessible"] = True
|
||||
text = resp.text
|
||||
path = parsed.path or "/"
|
||||
|
||||
# Parse disallowed paths
|
||||
current_agent = "*"
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("User-agent:"):
|
||||
current_agent = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Disallow:"):
|
||||
disallowed = line.split(":", 1)[1].strip()
|
||||
if current_agent == "*" and disallowed:
|
||||
result["disallowed_paths"].append(disallowed)
|
||||
elif line.startswith("Crawl-delay:"):
|
||||
delay = line.split(":", 1)[1].strip()
|
||||
if current_agent == "*" and delay.isdigit():
|
||||
result["crawl_delay"] = int(delay)
|
||||
elif line.startswith("Sitemap:"):
|
||||
sitemap = line.split(":", 1)[1].strip()
|
||||
result["sitemaps"].append(sitemap)
|
||||
|
||||
# Check if URL path is disallowed
|
||||
for disallowed in result["disallowed_paths"]:
|
||||
if path.startswith(disallowed):
|
||||
result["crawl_allowed"] = False
|
||||
result["matched_disallow"] = disallowed
|
||||
break
|
||||
|
||||
except (httpx.HTTPError, httpx.RequestError) as e:
|
||||
result["error"] = str(e)
|
||||
result["crawl_allowed"] = True # Fail open: assume allowed if can't check
|
||||
result["note"] = f"Could not fetch robots.txt: {str(e)[:100]}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def detect_jurisdiction(url: str, html: str = "") -> dict[str, Any]:
|
||||
"""Detect likely legal jurisdiction based on TLD and content signals."""
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc.lower()
|
||||
tld_found = "unknown"
|
||||
|
||||
# Check TLD map
|
||||
for tld, jurisdiction in sorted(JURISDICTION_MAP.items(), key=lambda x: -len(x[0])):
|
||||
if domain.endswith(tld):
|
||||
tld_found = jurisdiction
|
||||
break
|
||||
if domain.endswith(".com"):
|
||||
tld_found = "us"
|
||||
if domain.endswith(".org") or domain.endswith(".net"):
|
||||
tld_found = "unknown"
|
||||
|
||||
# Check HTML for GDPR/CCPA signals
|
||||
signals = {"gdpr": False, "ccpa": False, "lgpd": False}
|
||||
if html:
|
||||
lower = html.lower()
|
||||
signals["gdpr"] = bool(
|
||||
re.search(r"gdpr|general data protection|data protection regulation", lower)
|
||||
)
|
||||
signals["ccpa"] = bool(
|
||||
re.search(r"ccpa|california consumer privacy|california privacy rights", lower)
|
||||
)
|
||||
signals["lgpd"] = bool(re.search(r"lgpd|lei geral de prote", lower))
|
||||
|
||||
return {
|
||||
"tld": domain.split(".")[-1] if "." in domain else "unknown",
|
||||
"jurisdiction": tld_found,
|
||||
"gdpr_signals": signals["gdpr"],
|
||||
"ccpa_signals": signals["ccpa"],
|
||||
"lgpd_signals": signals["lgpd"],
|
||||
}
|
||||
|
||||
|
||||
def classify_tos(text: str) -> dict[str, Any]:
|
||||
"""Classify Terms of Service as restrictive/permissive/moderate."""
|
||||
lower = text.lower()
|
||||
matches: dict[str, list[str]] = {"restrictive": [], "permissive": [], "moderate": []}
|
||||
|
||||
for category, patterns in TOS_INDICATORS.items():
|
||||
for p in patterns:
|
||||
if re.search(p, lower):
|
||||
matches[category].append(p)
|
||||
|
||||
# Determine overall classification
|
||||
restrictive_score = len(matches["restrictive"])
|
||||
permissive_score = len(matches["permissive"])
|
||||
moderate_score = len(matches["moderate"])
|
||||
|
||||
if restrictive_score > permissive_score and restrictive_score > moderate_score:
|
||||
classification = "restrictive"
|
||||
elif permissive_score > restrictive_score and permissive_score >= moderate_score:
|
||||
classification = "permissive"
|
||||
else:
|
||||
classification = "moderate"
|
||||
|
||||
return {
|
||||
"classification": classification,
|
||||
"confidence": "high"
|
||||
if (restrictive_score + permissive_score + moderate_score) >= 3
|
||||
else "medium",
|
||||
"matches": {k: len(v) for k, v in matches.items()},
|
||||
"note": _tos_note(classification),
|
||||
}
|
||||
|
||||
|
||||
def _tos_note(classification: str) -> str:
|
||||
notes = {
|
||||
"restrictive": "Terms prohibit scraping or automated access. Legal review recommended.",
|
||||
"permissive": "Terms appear to allow data access. Verify specific clauses.",
|
||||
"moderate": "Terms have mixed signals. May allow limited non-commercial use.",
|
||||
}
|
||||
return notes.get(classification, "Unable to classify terms.")
|
||||
|
||||
|
||||
def tag_sensitive_data(html: str) -> dict[str, Any]:
|
||||
"""Tag GDPR/CCPA sensitive data categories present in content."""
|
||||
found: dict[str, list[str]] = {}
|
||||
for category, patterns in SENSITIVE_DATA_PATTERNS.items():
|
||||
matches = []
|
||||
for p in patterns:
|
||||
m = re.findall(p, html)
|
||||
if m:
|
||||
matches.extend(m[:5]) # Limit to 5 samples per pattern
|
||||
if matches:
|
||||
found[category] = matches
|
||||
|
||||
return {
|
||||
"has_pii": "personally_identifiable" in found,
|
||||
"has_financial": "financial" in found,
|
||||
"has_contact": "contact" in found,
|
||||
"has_health": "health" in found,
|
||||
"has_employment": "employment" in found,
|
||||
"categories_present": list(found.keys()),
|
||||
"samples": {k: v[:3] for k, v in found.items()},
|
||||
"gdpr_relevance": "high"
|
||||
if any(c in found for c in ["personally_identifiable", "financial", "health"])
|
||||
else "medium"
|
||||
if "contact" in found
|
||||
else "low",
|
||||
}
|
||||
|
||||
|
||||
async def run_compliance_check(url: str) -> dict[str, Any]:
|
||||
"""Run full compliance check on a URL: robots.txt + jurisdiction + ToS + sensitive data."""
|
||||
# Fetch robots.txt
|
||||
robots = await check_robots_txt(url)
|
||||
|
||||
# Fetch page content for ToS + sensitive data analysis
|
||||
html = ""
|
||||
tos_text = ""
|
||||
tos_url = ""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={"User-Agent": "PryCompliance/1.0 (compliance check)"},
|
||||
)
|
||||
if resp.is_success:
|
||||
html = resp.text
|
||||
except (httpx.HTTPError, httpx.RequestError):
|
||||
pass
|
||||
|
||||
# Try to find and fetch ToS page
|
||||
parsed = urlparse(url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
tos_paths = ["/terms", "/terms-of-service", "/tos", "/legal/terms", "/terms.html"]
|
||||
for path in tos_paths:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(f"{base}{path}")
|
||||
if resp.is_success and len(resp.text) > 200:
|
||||
tos_text = resp.text
|
||||
tos_url = f"{base}{path}"
|
||||
break
|
||||
except (httpx.HTTPError, httpx.RequestError):
|
||||
continue
|
||||
|
||||
# Run all checks
|
||||
jurisdiction = detect_jurisdiction(url, html)
|
||||
tos_result = (
|
||||
classify_tos(tos_text)
|
||||
if tos_text
|
||||
else {
|
||||
"classification": "unknown",
|
||||
"confidence": "low",
|
||||
"matches": {},
|
||||
"note": "Could not locate Terms of Service page.",
|
||||
}
|
||||
)
|
||||
sensitive = (
|
||||
tag_sensitive_data(html)
|
||||
if html
|
||||
else {
|
||||
"has_pii": False,
|
||||
"has_financial": False,
|
||||
"has_contact": False,
|
||||
"has_health": False,
|
||||
"has_employment": False,
|
||||
"categories_present": [],
|
||||
"samples": {},
|
||||
"gdpr_relevance": "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
# LLM fallback for ToS classification when the regex pass is low-confidence
|
||||
# or no ToS page was found. The LLM gets the ToS text (or page HTML if no
|
||||
# ToS) and returns a richer risk classification. Best-effort: if the
|
||||
# LLM call fails or no provider is configured, we keep the regex result.
|
||||
if tos_result.get("confidence") == "low" or not tos_text:
|
||||
try:
|
||||
from llm_features import llm_compliance_analyze
|
||||
|
||||
llm_input = tos_text if tos_text else html[:8000]
|
||||
llm_result = await llm_compliance_analyze(llm_input, url=url)
|
||||
if llm_result and llm_result.get("risk_level"):
|
||||
tos_result = {
|
||||
**tos_result,
|
||||
"classification": llm_result.get("risk_level", tos_result["classification"]),
|
||||
"confidence": llm_result.get("confidence", "medium"),
|
||||
"matches": tos_result.get("matches", {}),
|
||||
"note": (tos_result.get("note", "") + " | LLM-enhanced").strip(" |"),
|
||||
"llm_enhanced": True,
|
||||
"llm_risk_summary": llm_result.get("risk_summary", ""),
|
||||
"llm_recommendation": llm_result.get("recommendation", ""),
|
||||
"llm_key_restrictions": llm_result.get("key_restrictions", []),
|
||||
"llm_provider": llm_result.get("llm_provider", ""),
|
||||
"llm_cost_usd": llm_result.get("llm_cost_usd", 0.0),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 - LLM fallback must catch all errors
|
||||
logger.debug("llm_compliance_fallback_failed", extra={"url": url, "error": str(e)[:80]})
|
||||
|
||||
# Compute overall risk score
|
||||
risk_factors = 0
|
||||
risk_notes = []
|
||||
|
||||
if robots.get("crawl_allowed") is False:
|
||||
risk_factors += 3
|
||||
risk_notes.append("robots.txt disallows crawling")
|
||||
|
||||
if tos_result["classification"] == "restrictive":
|
||||
risk_factors += 3
|
||||
risk_notes.append("Terms of Service prohibit scraping")
|
||||
|
||||
if jurisdiction.get("jurisdiction") == "eu" and sensitive.get("has_pii"):
|
||||
risk_factors += 2
|
||||
risk_notes.append("GDPR applies to personal data")
|
||||
|
||||
if jurisdiction.get("jurisdiction") == "ca" and sensitive.get("has_pii"):
|
||||
risk_factors += 2
|
||||
risk_notes.append("CCPA applies to personal data")
|
||||
|
||||
if sensitive.get("has_health"):
|
||||
risk_factors += 2
|
||||
risk_notes.append("HIPAA-protected health data detected")
|
||||
|
||||
if sensitive.get("has_financial"):
|
||||
risk_factors += 1
|
||||
risk_notes.append("Financial data — additional compliance may apply")
|
||||
|
||||
if risk_factors >= 5:
|
||||
risk_level = "red"
|
||||
elif risk_factors >= 2:
|
||||
risk_level = "yellow"
|
||||
else:
|
||||
risk_level = "green"
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"risk_level": risk_level,
|
||||
"risk_score": risk_factors,
|
||||
"risk_notes": risk_notes,
|
||||
"checked_at": datetime.now(UTC).isoformat(),
|
||||
"robots_txt": {
|
||||
"accessible": robots["accessible"],
|
||||
"crawl_allowed": robots["crawl_allowed"],
|
||||
"crawl_delay": robots["crawl_delay"],
|
||||
"disallowed_paths": robots["disallowed_paths"],
|
||||
"sitemaps": robots["sitemaps"],
|
||||
"note": robots.get("note", ""),
|
||||
},
|
||||
"terms_of_service": {
|
||||
"found": bool(tos_url),
|
||||
"url": tos_url or "",
|
||||
"classification": tos_result["classification"],
|
||||
"confidence": tos_result["confidence"],
|
||||
"note": tos_result["note"],
|
||||
"llm_enhanced": tos_result.get("llm_enhanced", False),
|
||||
"llm_provider": tos_result.get("llm_provider", ""),
|
||||
"llm_cost_usd": tos_result.get("llm_cost_usd", 0.0),
|
||||
"llm_risk_summary": tos_result.get("llm_risk_summary", ""),
|
||||
"llm_recommendation": tos_result.get("llm_recommendation", ""),
|
||||
"llm_key_restrictions": tos_result.get("llm_key_restrictions", []),
|
||||
},
|
||||
"jurisdiction": {
|
||||
"tld": jurisdiction["tld"],
|
||||
"region": jurisdiction["jurisdiction"],
|
||||
"gdpr_signals": jurisdiction["gdpr_signals"],
|
||||
"ccpa_signals": jurisdiction["ccpa_signals"],
|
||||
},
|
||||
"sensitive_data": {
|
||||
"has_pii": sensitive["has_pii"],
|
||||
"has_financial": sensitive["has_financial"],
|
||||
"has_contact": sensitive["has_contact"],
|
||||
"has_health": sensitive["has_health"],
|
||||
"categories": sensitive["categories_present"],
|
||||
"gdpr_relevance": sensitive["gdpr_relevance"],
|
||||
},
|
||||
"recommendations": _generate_recommendations(risk_level, risk_notes, jurisdiction),
|
||||
}
|
||||
|
||||
|
||||
def _generate_recommendations(
|
||||
risk_level: str, risk_notes: list[str], jurisdiction: dict[str, Any]
|
||||
) -> list[str]:
|
||||
recs = []
|
||||
if risk_level == "red":
|
||||
recs.append("LEGAL REVIEW REQUIRED: Multiple high-risk factors detected.")
|
||||
recs.append("Do not scrape without written legal approval.")
|
||||
elif risk_level == "yellow":
|
||||
recs.append("Proceed with caution. Consider:")
|
||||
recs.append("- Rate-limit requests to respect robots.txt")
|
||||
recs.append("- Anonymize any PII before storage")
|
||||
recs.append("- Review Terms of Service for scraping clauses")
|
||||
|
||||
if "GDPR" in str(risk_notes) or jurisdiction.get("jurisdiction") == "eu":
|
||||
recs.append(
|
||||
"GDPR compliance required: ensure lawful basis, data minimization, right to erasure."
|
||||
)
|
||||
|
||||
if "CCPA" in str(risk_notes) or jurisdiction.get("jurisdiction") == "ca":
|
||||
recs.append("CCPA compliance required: allow opt-out, disclose data collection.")
|
||||
|
||||
if not recs:
|
||||
recs.append("Low risk — proceed with standard scraping practices.")
|
||||
recs.append("Monitor for changes to robots.txt and Terms of Service.")
|
||||
|
||||
return recs
|
||||
Loading…
Add table
Add a link
Reference in a new issue