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

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