pryscraper/llm_features.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

183 lines
6.9 KiB
Python

"""Pry — Real LLM-powered features. Replaces regex stubs with actual LLM calls.
Used by compliance, SEO, entity reconciliation, PII redaction, and other AI features."""
# 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 json
import logging
from typing import Any
from llm_providers.registry import get_registry
logger = logging.getLogger(__name__)
def _strip_fence(text: str) -> str:
"""Strip markdown code fences that LLMs commonly wrap JSON in."""
t = text.strip()
if t.startswith("```json"):
t = t[len("```json"):]
elif t.startswith("```"):
t = t[len("```"):]
if t.endswith("```"):
t = t[: -len("```")]
return t.strip()
async def llm_compliance_analyze(text: str, url: str = "") -> dict[str, Any]:
"""Use LLM to actually analyze Terms of Service for compliance risk."""
if not text:
return {"risk_level": "unknown", "reason": "No ToS text provided"}
prompt = f"""Analyze the following Terms of Service for legal compliance risks when scraping the associated website.
URL: {url}
Terms of Service (truncated to 4000 chars):
{text[:4000]}
Return JSON with these fields:
- risk_level: "green" (no restrictions), "yellow" (some restrictions), "red" (prohibits scraping)
- confidence: "high" / "medium" / "low"
- key_restrictions: list of strings describing scraping-related restrictions
- risk_summary: 1-2 sentence summary
- recommendation: what to do before scraping
Respond ONLY with valid JSON, no markdown formatting."""
try:
reg = get_registry()
resp = await reg.complete(
prompt,
system=(
"You are a legal compliance analyst specializing in web scraping. "
"Be concise and accurate."
),
max_tokens=800,
temperature=0.3,
)
result = json.loads(_strip_fence(resp.text))
result["llm_provider"] = resp.provider
result["llm_cost_usd"] = round(resp.cost_usd, 6)
return result
except Exception as e:
logger.warning("llm_compliance_failed", extra={"error": str(e)[:80]})
return {"risk_level": "unknown", "error": str(e)[:200]}
async def llm_seo_analyze(
url: str, content: str, target_keywords: list[str] | None = None
) -> dict[str, Any]:
"""Use LLM to analyze SEO quality of a page and identify optimization opportunities."""
if not content:
return {"score": 0, "recommendations": []}
keywords = ", ".join(target_keywords) if target_keywords else "general relevance"
prompt = f"""Analyze the SEO quality of this page for target keywords: {keywords}
URL: {url}
Page content (truncated):
{content[:3000]}
Return JSON with:
- overall_score: 0-100
- title_quality: "good" / "fair" / "poor"
- content_depth: "comprehensive" / "adequate" / "shallow"
- keyword_presence: {{keyword: "well_optimized" / "under_optimized" / "missing"}}
- recommendations: list of 3-5 specific actionable improvements
- issues: list of SEO problems found
Respond ONLY with valid JSON."""
try:
reg = get_registry()
resp = await reg.complete(prompt, max_tokens=1000, temperature=0.3)
return json.loads(_strip_fence(resp.text))
except Exception as e:
logger.warning("llm_seo_failed", extra={"error": str(e)[:80]})
return {"score": 0, "error": str(e)[:200]}
async def llm_entity_reconcile(records: list[dict], vertical: str = "product") -> dict[str, Any]:
"""Use LLM to semantically match and merge records from different sources."""
if not records or len(records) < 2:
return {"entities": records, "matches": []}
sample = records[:50]
prompt = f"""You are a data reconciliation expert. Given records from multiple sources for the same {vertical} vertical, identify which records refer to the same real-world entity.
Records (JSON):
{json.dumps(sample, indent=2, default=str)[:8000]}
Return JSON with:
- groups: list of groups, each with a "canonical_id" (string) and "record_indices" (list of integers referring to the input records)
- unmatched: list of record indices that don't match any other record
- reasoning: brief explanation of how you matched
Respond ONLY with valid JSON."""
try:
reg = get_registry()
resp = await reg.complete(prompt, max_tokens=2000, temperature=0.2)
return json.loads(_strip_fence(resp.text))
except Exception as e:
logger.warning("llm_reconcile_failed", extra={"error": str(e)[:80]})
return {"entities": records, "matches": [], "error": str(e)[:200]}
async def llm_pii_detect(text: str) -> dict[str, Any]:
"""Use LLM to detect PII that regex might miss (context-aware)."""
if not text or len(text) < 50:
return {"pii_found": [], "redacted_text": text}
prompt = f"""Find all personally identifiable information (PII) in this text that should be redacted for safe AI training data use.
Text:
{text[:4000]}
Return JSON with:
- pii_items: list of {{"text": "the PII", "type": "name/email/phone/ssn/address/other", "start": character_index, "end": character_index}}
- redacted_text: the original text with PII replaced by [REDACTED:TYPE]
Respond ONLY with valid JSON. Use character indices relative to the original text (truncated to 4000 chars)."""
try:
reg = get_registry()
resp = await reg.complete(prompt, max_tokens=2000, temperature=0.1)
return json.loads(_strip_fence(resp.text))
except Exception as e:
logger.warning("llm_pii_failed", extra={"error": str(e)[:80]})
return {"pii_items": [], "redacted_text": text, "error": str(e)[:200]}
async def llm_anomaly_detect(
historical_data: list[dict], current: dict, field: str = "price"
) -> dict[str, Any]:
"""Use LLM to detect anomalies that statistical methods miss (context-aware).
E.g., Black Friday prices dropping 50% is expected, but a 50% drop on a random Tuesday is suspicious."""
if not historical_data or not current:
return {"anomaly": False, "reason": "Insufficient data"}
prompt = f"""Analyze whether this change in '{field}' is a true anomaly or an expected pattern.
Historical data (last 10):
{json.dumps(historical_data[-10:], default=str)}
Current value:
{json.dumps(current, default=str)}
Consider:
- Day of week / seasonal patterns
- Promotional events (Black Friday, holidays)
- Market conditions
- Whether the change is in the expected direction
Return JSON with:
- is_anomaly: bool
- confidence: 0.0-1.0
- reasoning: 1-2 sentences
- context_factors: list of relevant context that explain the change
Respond ONLY with valid JSON."""
try:
reg = get_registry()
resp = await reg.complete(prompt, max_tokens=500, temperature=0.3)
return json.loads(_strip_fence(resp.text))
except Exception as e:
logger.warning("llm_anomaly_failed", extra={"error": str(e)[:80]})
return {"is_anomaly": False, "reason": str(e)[:200]}