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
152 lines
4.6 KiB
Python
152 lines
4.6 KiB
Python
"""Pry — network capture and lazy load handling.
|
|
Captures XHR/fetch requests from JS-heavy SPAs and handles lazy content."""
|
|
|
|
# SPDX-License-Identifier: BSL-1.1
|
|
# Copyright (c) 2026 Rug Munch Media LLC
|
|
#
|
|
# Part of Pry — Stealth / Anti-Detection Module
|
|
# Licensed under Business Source License 1.1 — see LICENSE-BSL-STEALTH.
|
|
# Change Date: 2029-01-01 (converts to MIT).
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def extract_api_calls_from_html(html: str) -> list[dict[str, Any]]:
|
|
"""Extract API call patterns from HTML/JS.
|
|
|
|
Finds fetch(), XMLHttpRequest, axios, $.ajax patterns in inline scripts.
|
|
"""
|
|
api_calls = []
|
|
|
|
# Pattern 1: fetch() calls
|
|
fetch_patterns = re.finditer(
|
|
r'fetch\s*\(\s*["\']([^"\']+)["\']',
|
|
html,
|
|
)
|
|
for m in fetch_patterns:
|
|
api_calls.append({"method": "fetch", "url": m.group(1)})
|
|
|
|
# Pattern 2: XMLHttpRequest
|
|
xhr_patterns = re.finditer(
|
|
r'XMLHttpRequest\.open\s*\(\s*["\'](GET|POST|PUT|DELETE)["\']\s*,\s*["\']([^"\']+)["\']',
|
|
html,
|
|
)
|
|
for m in xhr_patterns:
|
|
api_calls.append({"method": m.group(1), "url": m.group(2)})
|
|
|
|
# Pattern 3: axios calls (axios.get(), axios.post(), axios('url'))
|
|
axios_patterns = re.finditer(
|
|
r'axios(?:\.\w+)?\s*\(\s*["\']([^"\']+)["\']',
|
|
html,
|
|
)
|
|
for m in axios_patterns:
|
|
api_calls.append({"method": "axios", "url": m.group(1)})
|
|
|
|
# Pattern 4: $.ajax / $.get / $.post
|
|
jquery_patterns = re.finditer(
|
|
r'\$\.(?:ajax|get|post)\s*\(\s*["\']([^"\']+)["\']',
|
|
html,
|
|
)
|
|
for m in jquery_patterns:
|
|
api_calls.append({"method": "jquery", "url": m.group(1)})
|
|
|
|
# Pattern 5: JSON API endpoints in script data-* attrs or config objects
|
|
api_url_patterns = re.finditer(
|
|
r'(?:apiUrl|api_url|endpoint|apiEndpoint|baseUrl)\s*[:=]\s*["\']([^"\']+)["\']',
|
|
html,
|
|
re.IGNORECASE,
|
|
)
|
|
for m in api_url_patterns:
|
|
api_calls.append({"method": "config", "url": m.group(1)})
|
|
|
|
# De-duplicate by URL
|
|
seen = set()
|
|
unique = []
|
|
for call in api_calls:
|
|
if call["url"] not in seen:
|
|
seen.add(call["url"])
|
|
unique.append(call)
|
|
|
|
return unique
|
|
|
|
|
|
def extract_graphql_queries(html: str) -> list[dict[str, Any]]:
|
|
"""Extract GraphQL query patterns from HTML/JS."""
|
|
queries = []
|
|
|
|
# Pattern: gql`...` or graphql(`...`)
|
|
gql_patterns = re.finditer(
|
|
r"(?:gql|graphql)\s*`([^`]+)`",
|
|
html,
|
|
)
|
|
for m in gql_patterns:
|
|
queries.append({"type": "gql_tagged", "query": m.group(1).strip()[:200]})
|
|
|
|
# Pattern: "query" or "mutation" in JSON configs
|
|
query_patterns = re.finditer(
|
|
r'["\'](?:query|mutation|subscription)["\']\s*:\s*["\']([^"\']+)["\']',
|
|
html,
|
|
)
|
|
for m in query_patterns:
|
|
queries.append({"type": "inline", "query": m.group(1)[:200]})
|
|
|
|
return queries
|
|
|
|
|
|
def extract_json_ld(html: str) -> list[dict[str, Any]]:
|
|
"""Extract JSON-LD structured data from <script type="application/ld+json">."""
|
|
ld_patterns = re.finditer(
|
|
r'<script\s+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
|
html,
|
|
re.DOTALL,
|
|
)
|
|
results = []
|
|
for m in ld_patterns:
|
|
try:
|
|
data = json.loads(m.group(1).strip())
|
|
results.append(data)
|
|
except (json.JSONDecodeError, ValueError):
|
|
logger.warning("json_ld_parse_failed")
|
|
return results
|
|
|
|
|
|
def _parse_braced_json(text: str, start: int) -> dict[str, Any] | None:
|
|
"""Parse a JSON object starting at text[start] using brace counting."""
|
|
if start < 0 or text[start] != "{":
|
|
return None
|
|
depth = 0
|
|
for i in range(start, len(text)):
|
|
if text[i] == "{":
|
|
depth += 1
|
|
elif text[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
try:
|
|
parsed: Any = json.loads(text[start : i + 1])
|
|
if isinstance(parsed, dict):
|
|
return parsed
|
|
return None
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def extract_nextjs_props(html: str) -> dict[str, Any] | None:
|
|
"""Extract Next.js __NEXT_DATA__ props."""
|
|
m = re.search(r"window\.__NEXT_DATA__\s*=\s*(\{)", html, re.DOTALL)
|
|
if m:
|
|
return _parse_braced_json(html, m.start(1))
|
|
return None
|
|
|
|
|
|
def extract_nuxt_state(html: str) -> dict[str, Any] | None:
|
|
"""Extract Nuxt/Vue __NUXT__ state."""
|
|
m = re.search(r"window\.__NUXT__\s*=\s*(\{)", html, re.DOTALL)
|
|
if m:
|
|
return _parse_braced_json(html, m.start(1))
|
|
return None
|