pryscraper/enrichment.py
cryptorugmunch bb77eb5f35 chore(license): re-license to dual MIT (core) + BSL 1.1 (stealth)
Re-license Pry from full Proprietary to a dual-license model:

- Core engine, extraction, templates (80+), MCP server, x402 payment rail,
  CLI, SDK, browser extension, WordPress plugin, Shopify app, and
  llm_providers: MIT (see LICENSE)
- Anti-detection / stealth subset (15 files): BSL 1.1 with Change Date
  2029-01-01 (see LICENSE-BSL-STEALTH)

BSL files (anti-detection moat):
  ultimate_scraper.py, stealth_engine.py, stealth_scripts/*.js (6),
  camoufox_integration.py, tls_fingerprint.py, cookie_warmer.py,
  behavioral_biometrics.py, adaptive.py, browser_pool.py, network.py,
  captcha_solver.py, shadow_dom.py, lazy_load.py, signup_automator.py,
  auth_connector.py

This enables community contributions to the core engine (templates,
integrations, MCP tools) while protecting the anti-detection techniques
that constitute the actual competitive moat. BSL Additional Use Grant
permits free non-production use; production deployment requires a
commercial license from enterprise@rugmunch.io.

Changes:
- Replace proprietary LICENSE with MIT LICENSE + new LICENSE-BSL-STEALTH
- Add SPDX-License-Identifier headers to 300+ source files
- Add docs/adr/0002-dual-licensing.md (ADR documenting the decision)
- Update README.md: new License section with BSL Additional Use Grant
- Update LICENSING_PRICING_STRATEGY.md: Section 3 (PryScraper) for dual license
- Update AGENTS.md: license line in header + new rule 8 (PRs touching BSL rejected)
- Update pyproject.toml: license = "MIT AND BSL-1.1" + classifiers + license-files
- Update DECISIONS.md index with ADR-0002
- Update STATUS.md (2026-07-03) and PLAN.md sprint goals

Refs: ADR-0002
2026-07-02 19:49:21 +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 Exception 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