pryscraper/enrichment.py
cryptorugmunch 0200bf3e16 refactor(exceptions): add ruff BLE001; convert 103 broad except Exception
Per CONVENTIONS.md Part 2 ("Never bare except") and CONVENTIONS.md
Part 7 (pre-commit hooks: ruff), blind `except Exception` is now a
lint failure. Pre-existing sites are marked `# noqa: BLE001` for
later manual review; new code must use specific exception types.

Changes:
- pyproject.toml: added "BLE" to ruff lint select. BLE001 is now enforced
- 103 of 166 `except Exception` sites were auto-converted to specific
  types based on context (httpx, json, OSError, subprocess, etc.)
- 62 remaining sites marked with `# noqa: BLE001` for later review
  (mostly generic try/except wrappers that legitimately need broad catch
  for graceful degradation: e.g. compliance LLM fallback must catch
  any error to preserve the regex result)
- 1 manual fix: reverted compliance.py LLM fallback to broad except
  with explicit "must catch all errors" comment + noqa
- 2 files (commerce_sync.py, crm_sync.py) needed `import httpx` added
  so the auto-converted exception references would resolve
- 5 source files (agency, monitor, pipelines, auth_connector,
  llm_providers/registry) renamed "name" -> "<scope>_name" in
  extra={...} dicts because "name" is a reserved LogRecord field

Test impact:
- 14 failing tests -> 1 (the SSE subprocess test is a sandbox limitation,
  pre-existing and unrelated)
- New `test_ble_temp.py` verifies BLE001 catches new violations

Follow-up:
- Each `# noqa: BLE001` site should be reviewed and replaced with a
  specific exception type where possible. The most common legitimate
  broad-catch case is the LLM fallback path; everything else probably
  can be narrowed.
2026-07-02 21:04:53 +02:00

212 lines
7.3 KiB
Python

"""Pry — Data Enrichment Pipeline.
Enrich scraped data with company info, social profiles, tech stack detection."""
# 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 typing import Any
logger = logging.getLogger(__name__)
# ── Tech Stack Detection ──
TECH_PATTERNS: dict[str, list[str]] = {
"wordpress": [r"wp-content", r"wp-includes", r"/wp-json/", r"wordpress"],
"shopify": [r"shopify\.com", r"myshopify\.com", r"Shopify", r"cdn\.shopify"],
"woocommerce": [r"woocommerce", r"wc-", r"add-to-cart"],
"wix": [r"wix\.com", r"Wix\.com", r"wixstatic\.com"],
"squarespace": [r"squarespace\.com", r"Squarespace"],
"webflow": [r"webflow\.com", r"Webflow"],
"magento": [r"magento", r"Magento", r"mage\-"],
"laravel": [r"laravel", r"Laravel"],
"django": [r"django", r"Django", r"csrfmiddlewaretoken", r"csrftoken"],
"rails": [r"rails", r"Ruby on Rails", r"_rails"],
"nextjs": [r"_next/static", r"__next_data__", r"next\.js"],
"nuxt": [r"__NUXT__", r"nuxt"],
"gatsby": [r"gatsby", r"Gatsby"],
"react": [r"react\.js", r"react-dom", r"React", r"create-react-app"],
"vue": [r"vue\.js", r"Vue", r"vue-router"],
"angular": [r"angular\.js", r"Angular", r"ng-"],
"cloudflare": [r"cloudflare", r"cf-ray", r"__cfduid"],
"fastly": [r"fastly", r"Fastly"],
"cloudfront": [r"cloudfront\.net", r"CloudFront"],
"google_analytics": [r"google-analytics\.com", r"gtag", r"ga\.js"],
"facebook_pixel": [r"facebook\.com/tr", r"fbq\("],
"hotjar": [r"hotjar", r"Hotjar"],
"intercom": [r"intercom\.io", r"Intercom"],
"hubspot": [r"hubspot\.com", r"HubSpot", r"hs-scripts"],
"stripe": [r"stripe\.com", r"Stripe", r"pk_live"],
"paypal": [r"paypal\.com", r"PayPal"],
}
def detect_tech_stack(html: str, headers: dict[str, str] | None = None) -> dict[str, Any]:
"""Detect technologies used on a website from HTML and headers."""
detected: dict[str, bool] = {}
lower_html = html.lower()
for tech, patterns in TECH_PATTERNS.items():
for p in patterns:
if re.search(p, lower_html) or (
headers and any(re.search(p, str(v).lower()) for v in headers.values())
):
detected[tech] = True
break
# Categorize
categories: dict[str, list[str]] = {
"cms": [
t
for t in [
"wordpress",
"shopify",
"woocommerce",
"wix",
"squarespace",
"webflow",
"magento",
]
if detected.get(t)
],
"framework": [
t for t in ["laravel", "django", "rails", "nextjs", "nuxt", "gatsby"] if detected.get(t)
],
"frontend": [t for t in ["react", "vue", "angular"] if detected.get(t)],
"hosting_cdn": [t for t in ["cloudflare", "fastly", "cloudfront"] if detected.get(t)],
"analytics": [
t
for t in ["google_analytics", "facebook_pixel", "hotjar", "intercom", "hubspot"]
if detected.get(t)
],
"payments": [t for t in ["stripe", "paypal"] if detected.get(t)],
}
return {
"detected": list(detected.keys()),
"count": len(detected),
"categories": categories,
}
# ── Social Profile Extraction ──
SOCIAL_PATTERNS: dict[str, str] = {
"twitter": r"(?:twitter\.com|x\.com)/([A-Za-z0-9_]{1,30})/?",
"linkedin": r"linkedin\.com/(?:company|in)/([A-Za-z0-9\-]+)/?",
"facebook": r"facebook\.com/([A-Za-z0-9\.\-]+)/?",
"instagram": r"instagram\.com/([A-Za-z0-9_\.]+)/?",
"youtube": r"youtube\.com/@?([A-Za-z0-9_\-]+)/?",
"github": r"github\.com/([A-Za-z0-9\-]+)/?",
"crunchbase": r"crunchbase\.com/(?:organization|person)/([A-Za-z0-9\-]+)/?",
"angellist": r"angel\.co/([A-Za-z0-9\-]+)/?",
"producthunt": r"producthunt\.com/@?([A-Za-z0-9_\-]+)/?",
}
def extract_social_profiles(html: str, url: str = "") -> dict[str, Any]:
"""Extract social media profile links from HTML."""
profiles: dict[str, list[str]] = {}
lower_html = html.lower()
for platform, pattern in SOCIAL_PATTERNS.items():
matches = re.findall(pattern, lower_html)
if matches:
profiles[platform] = list(set(matches[:3]))
# Also check URL itself
if url:
lower_url = url.lower()
for platform, pattern in SOCIAL_PATTERNS.items():
if platform not in profiles:
m = re.search(pattern, lower_url)
if m:
profiles[platform] = [m.group(1)]
return {
"profiles": profiles,
"platforms_found": list(profiles.keys()),
"total": sum(len(v) for v in profiles.values()),
}
# ── Company Info Extraction ──
def extract_company_info(html: str) -> dict[str, Any]:
"""Extract company information from website content."""
lower = html.lower()
info: dict[str, Any] = {}
# Extract email
emails = re.findall(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b", html)
info["emails"] = list({e for e in emails if not e.endswith(".png") and not e.endswith(".jpg")})[
:5
]
# Extract phone
phones = re.findall(r"[\+\(]?\d{1,3}[\)\s.-]?\d{3,4}[\s.-]?\d{4}", html)
info["phones"] = list(set(phones))[:3]
# Extract address
address_patterns = [
r"\d{1,5}\s+[A-Za-z0-9\s,]+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way)[,\s]+[A-Za-z\s]+,\s*[A-Z]{2}\s*\d{5}",
r"\d{1,5}\s+[A-Za-z0-9\s,]+(?:Street|St|Avenue|Ave|Road|Rd)[,\s]+[A-Za-z\s]+,[,\s]*[A-Z]{2}",
]
addresses = []
for pat in address_patterns:
matches = re.findall(pat, html)
addresses.extend(matches[:2])
info["addresses"] = addresses
# Extract founded year
years = re.findall(
r"(?:founded|established|since|incorporated)\s*(?:\w+\s+)?(?::)?\s*(\d{4})", lower
)
info["founded_year"] = years[0] if years else None
# Extract team size
team = re.findall(r"(\d+[\+]?)\s*(?:employees|team members|people)", lower)
info["team_size"] = team[0] if team else None
return info
# ── Full Enrichment Pipeline ──
async def enrich_url(
url: str, html: str = "", headers: dict[str, str] | None = None
) -> dict[str, Any]:
"""Run full enrichment pipeline on a URL/content.
Returns: tech stack, social profiles, company info, domain age, security.
"""
from client import get_client
if not html:
try:
client = await get_client()
resp = await client.get(url, timeout=20, follow_redirects=True)
if resp.is_success:
html = resp.text
headers = dict(resp.headers)
except (httpx.HTTPError, httpx.RequestError) as e:
logger.warning("enrichment_fetch_failed", extra={"url": url, "error": str(e)})
result: dict[str, Any] = {
"url": url,
"tech_stack": detect_tech_stack(html, headers)
if html
else {"detected": [], "count": 0, "categories": {}},
"social_profiles": extract_social_profiles(html, url)
if html
else {"profiles": {}, "platforms_found": [], "total": 0},
"company_info": extract_company_info(html) if html else {},
}
return result