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
187 lines
6.6 KiB
Python
187 lines
6.6 KiB
Python
"""Pry — markdown generation strategies with content filtering.
|
|
Raw, Fit (pruning), and Fit+BM25 (query-relevant) markdown generators."""
|
|
|
|
# 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 math
|
|
import re
|
|
from collections.abc import Sequence
|
|
from typing import Any, ClassVar
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MarkdownGenerator:
|
|
"""Base markdown generator. Produces raw markdown from content."""
|
|
|
|
def generate(self, content: str, url: str = "") -> dict[str, Any]:
|
|
"""Generate markdown. Returns dict with raw_markdown and metadata."""
|
|
return {"raw_markdown": content, "url": url}
|
|
|
|
|
|
class PruningContentFilter:
|
|
"""Remove noise from content: nav bars, ads, sidebars, footers.
|
|
Uses heuristics to identify and prune boilerplate sections."""
|
|
|
|
BOILERPLATE_PATTERNS: ClassVar[list[str]] = [
|
|
r"nav|navbar|navigation|menu",
|
|
r"footer|copyright",
|
|
r"sidebar|aside",
|
|
r"advertisement|sponsored|promoted",
|
|
r"cookie|consent|gdpr",
|
|
r"social.*share|share.*buttons",
|
|
r"newsletter|subscribe|sign.?up",
|
|
r"comments?.*section",
|
|
]
|
|
|
|
def __init__(self, threshold: float = 0.3, min_word_threshold: int = 50) -> None:
|
|
self.threshold = threshold
|
|
self.min_word_threshold = min_word_threshold
|
|
|
|
def filter(self, content: str) -> str:
|
|
"""Remove boilerplate sections from content."""
|
|
lines = content.split("\n")
|
|
kept: list[str] = []
|
|
for line in lines:
|
|
if self._is_boilerplate(line):
|
|
continue
|
|
if len(line.strip()) < 3:
|
|
continue
|
|
kept.append(line)
|
|
return "\n".join(kept)
|
|
|
|
def _is_boilerplate(self, line: str) -> bool:
|
|
lower = line.lower().strip()
|
|
return any(re.search(p, lower) for p in self.BOILERPLATE_PATTERNS)
|
|
|
|
def score(self, content: str) -> dict[str, Any]:
|
|
"""Score content quality. Higher = better."""
|
|
lines = content.split("\n")
|
|
total = len(lines)
|
|
boilerplate = sum(1 for line in lines if self._is_boilerplate(line))
|
|
header_score = sum(1 for line in lines if line.startswith("#"))
|
|
link_score = sum(1 for line in lines if "http" in line.lower())
|
|
return {
|
|
"total_lines": total,
|
|
"boilerplate_lines": boilerplate,
|
|
"boilerplate_ratio": round(boilerplate / total, 2) if total > 0 else 0,
|
|
"headers": header_score,
|
|
"links": link_score,
|
|
"quality": "high" if boilerplate / total < self.threshold else "low",
|
|
}
|
|
|
|
|
|
class BM25ContentFilter:
|
|
"""BM25-based content filtering for query-relevant extraction.
|
|
Scores each section by relevance to a user query."""
|
|
|
|
def __init__(self, k1: float = 1.5, b: float = 0.75, threshold: float = 1.0) -> None:
|
|
self.k1 = k1
|
|
self.b = b
|
|
self.threshold = threshold
|
|
|
|
def filter(self, content: str, query: str) -> str:
|
|
"""Filter content to keep only query-relevant sections."""
|
|
sections = self._split_sections(content)
|
|
if not sections:
|
|
return content
|
|
|
|
scores = self._bm25_scores(sections, query)
|
|
|
|
kept: list[str] = []
|
|
for section, score in zip(sections, scores, strict=False):
|
|
if score >= self.threshold:
|
|
kept.append(section)
|
|
|
|
return "\n\n".join(kept) if kept else content
|
|
|
|
def _split_sections(self, content: str) -> list[str]:
|
|
"""Split content into sections by headings."""
|
|
sections = re.split(r"\n(?=#+\s)", content)
|
|
return [s.strip() for s in sections if s.strip()]
|
|
|
|
def _bm25_scores(self, sections: Sequence[str], query: str) -> list[float]:
|
|
"""Compute BM25 score for each section against query."""
|
|
query_terms = query.lower().split()
|
|
if not query_terms:
|
|
return [1.0] * len(sections)
|
|
|
|
tokenized = [self._tokenize(s) for s in sections]
|
|
avg_len = sum(len(t) for t in tokenized) / max(len(tokenized), 1)
|
|
|
|
df: dict[str, int] = {}
|
|
for tokens in tokenized:
|
|
for term in set(tokens):
|
|
df[term] = df.get(term, 0) + 1
|
|
|
|
n = len(sections)
|
|
scores: list[float] = []
|
|
for tokens in tokenized:
|
|
score = 0.0
|
|
doc_len = len(tokens)
|
|
for term in query_terms:
|
|
if term not in df:
|
|
continue
|
|
tf = tokens.count(term)
|
|
idf = math.log((n - df[term] + 0.5) / (df[term] + 0.5) + 1.0)
|
|
numerator = tf * (self.k1 + 1)
|
|
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / avg_len)
|
|
score += idf * (numerator / denominator)
|
|
scores.append(score)
|
|
|
|
return scores
|
|
|
|
def _tokenize(self, text: str) -> list[str]:
|
|
"""Simple word tokenizer."""
|
|
return re.findall(r"\w+", text.lower())
|
|
|
|
|
|
class DefaultMarkdownGenerator(MarkdownGenerator):
|
|
"""Markdown generator with configurable content filtering.
|
|
|
|
Usage:
|
|
gen = DefaultMarkdownGenerator(
|
|
content_filter=PruningContentFilter(threshold=0.3)
|
|
)
|
|
result = gen.generate(content, url="https://example.com")
|
|
print(result["raw_markdown"])
|
|
if "fit_markdown" in result:
|
|
print(result["fit_markdown"])
|
|
"""
|
|
|
|
def __init__(
|
|
self, content_filter: PruningContentFilter | BM25ContentFilter | None = None
|
|
) -> None:
|
|
self.content_filter = content_filter
|
|
|
|
def generate(self, content: str, url: str = "", query: str = "") -> dict[str, Any]:
|
|
"""Generate markdown with optional filtering.
|
|
|
|
Returns:
|
|
raw_markdown: Original content as markdown
|
|
fit_markdown: Pruned content (if PruningContentFilter)
|
|
fit_markdown_bm25: BM25-filtered content (if BM25ContentFilter + query)
|
|
metadata: Content quality scores
|
|
"""
|
|
result: dict[str, Any] = {
|
|
"raw_markdown": content,
|
|
"url": url,
|
|
}
|
|
|
|
if self.content_filter:
|
|
if isinstance(self.content_filter, BM25ContentFilter) and query:
|
|
result["fit_markdown_bm25"] = self.content_filter.filter(content, query)
|
|
result["filter"] = "bm25"
|
|
elif isinstance(self.content_filter, PruningContentFilter):
|
|
result["fit_markdown"] = self.content_filter.filter(content)
|
|
result["metadata"] = self.content_filter.score(content)
|
|
result["filter"] = "pruning"
|
|
else:
|
|
result["fit_markdown"] = content
|
|
|
|
return result
|