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
146 lines
4.6 KiB
Python
146 lines
4.6 KiB
Python
"""Pry — PDF Table Extraction using multiple methods.
|
|
Extracts structured tables from PDF documents (financial reports, invoices, etc.)
|
|
Uses pdfplumber, camelot, and pdfminer as fallback methods."""
|
|
|
|
# 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 io
|
|
import logging
|
|
import os
|
|
from contextlib import suppress
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Try different PDF libraries
|
|
_pdfplumber: bool = False
|
|
_camelot: bool = False
|
|
_pdfminer: bool = False
|
|
|
|
try:
|
|
import pdfplumber # noqa: F401
|
|
|
|
_pdfplumber = True
|
|
except ImportError:
|
|
pass
|
|
|
|
try:
|
|
import camelot # noqa: F401
|
|
|
|
_camelot = True
|
|
except ImportError:
|
|
pass
|
|
|
|
try:
|
|
from pdfminer.high_level import extract_text # noqa: F401
|
|
|
|
_pdfminer = True
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
class PDFTableExtractor:
|
|
"""Extract tables from PDF documents using multiple methods."""
|
|
|
|
def __init__(self, prefer_method: str = "pdfplumber"):
|
|
self.prefer_method = prefer_method
|
|
|
|
def extract(self, pdf_bytes: bytes, method: str = "") -> dict[str, Any]:
|
|
"""Extract tables from a PDF document.
|
|
|
|
Returns: {tables: [...], text: "...", page_count: N, method_used: "..."}
|
|
"""
|
|
method = method or self.prefer_method
|
|
|
|
# Try preferred method first
|
|
if method == "pdfplumber" and _pdfplumber:
|
|
return self._extract_pdfplumber(pdf_bytes)
|
|
if method == "camelot" and _camelot:
|
|
return self._extract_camelot(pdf_bytes)
|
|
if method == "pdfminer" and _pdfminer:
|
|
return self._extract_pdfminer(pdf_bytes)
|
|
|
|
# Fallback chain
|
|
for fallback in ["pdfplumber", "camelot", "pdfminer"]:
|
|
if fallback == "pdfplumber" and _pdfplumber:
|
|
return self._extract_pdfplumber(pdf_bytes)
|
|
if fallback == "camelot" and _camelot:
|
|
return self._extract_camelot(pdf_bytes)
|
|
if fallback == "pdfminer" and _pdfminer:
|
|
return self._extract_pdfminer(pdf_bytes)
|
|
|
|
return {
|
|
"error": "No PDF library available. Install pdfplumber, camelot, or pdfminer.six.",
|
|
"tables": [],
|
|
"text": "",
|
|
}
|
|
|
|
def _extract_pdfplumber(self, pdf_bytes: bytes) -> dict[str, Any]:
|
|
import pdfplumber
|
|
|
|
tables: list[dict[str, Any]] = []
|
|
text = ""
|
|
page_count = 0
|
|
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
|
|
for page in pdf.pages:
|
|
page_count += 1
|
|
text += (page.extract_text() or "") + "\n"
|
|
for table in page.extract_tables():
|
|
if table:
|
|
tables.append(
|
|
{
|
|
"page": page.page_number,
|
|
"rows": table,
|
|
"row_count": len(table),
|
|
"col_count": len(table[0]) if table else 0,
|
|
}
|
|
)
|
|
return {
|
|
"tables": tables,
|
|
"text": text,
|
|
"page_count": page_count,
|
|
"table_count": len(tables),
|
|
"method_used": "pdfplumber",
|
|
}
|
|
|
|
def _extract_camelot(self, pdf_bytes: bytes) -> dict[str, Any]:
|
|
import camelot
|
|
|
|
tables: list[dict[str, Any]] = []
|
|
tmp_path = "/tmp/_pry_pdf.pdf"
|
|
try:
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(pdf_bytes)
|
|
camelot_tables = camelot.read_pdf(tmp_path, pages="all")
|
|
for i, table in enumerate(camelot_tables):
|
|
tables.append(
|
|
{
|
|
"page": i + 1,
|
|
"rows": table.df.values.tolist(),
|
|
"row_count": len(table.df),
|
|
"col_count": len(table.df.columns),
|
|
"accuracy": table.accuracy,
|
|
"whitespace": table.whitespace,
|
|
}
|
|
)
|
|
finally:
|
|
with suppress(OSError):
|
|
os.unlink(tmp_path)
|
|
return {
|
|
"tables": tables,
|
|
"table_count": len(tables),
|
|
"method_used": "camelot",
|
|
}
|
|
|
|
def _extract_pdfminer(self, pdf_bytes: bytes) -> dict[str, Any]:
|
|
from pdfminer.high_level import extract_text
|
|
|
|
text = extract_text(io.BytesIO(pdf_bytes))
|
|
return {"tables": [], "text": text, "method_used": "pdfminer"}
|
|
|
|
def is_available(self) -> bool:
|
|
return any([_pdfplumber, _camelot, _pdfminer])
|