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

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])