pryscraper/advanced.py
cryptorugmunch a7c30b12cd
Some checks failed
CI / lint (push) Failing after 2s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 2s
CI / Secret scan (gitleaks) (push) Failing after 1s
CI / Security audit (bandit) (push) Failing after 2s
chore(lint): auto-fix 253 of 283 ruff issues (F401, I001, E402, RUF100, UP037, SIM105)
Mass ruff auto-fix:
  - ruff check --fix: 109 issues fixed (F401 unused imports,
    I001 unsorted imports, UP037 quoted annotations, SIM105
    suppressible exception, RUF100 unused-noqa)
  - ruff check --fix --unsafe-fixes: 22 additional issues
  - ruff format: 70 files reformatted
  - Manual pass: fix 16 misplaced import httpx lines
  - Manual pass: fix remaining E402 (import-after-docstring)

Result: 283 errors -> 30 errors.

The remaining 30 are real issues that need manual review:
  5 F401 unused-import (likely auto-generated stubs)
  5 F821 undefined-name (real bugs in code that references
    redis/pydantic/LLMRegistry without imports)
  3 BLE001 (the compliance LLM fallback is intentional; the
    other two are real)
  3 RUF012 mutable-class-default
  3 SIM105, 3 SIM117, 2 E722, 2 E741
  1 B007, 1 B025, 1 E402, 1 RUF200 (pyproject.toml issue)

Tests: 436/437 pass (1 pre-existing SSE sandbox failure).
format check + import sort: now clean.
make ci: still gated on the 30 remaining real issues.
Follow-up: triage the 30 issues file-by-file.
2026-07-02 21:51:25 +02:00

253 lines
9.2 KiB
Python

"""Pry — Exclusive features Firecrawl doesn't have.
Diff tracking, LLM summarization, RSS generation, change monitoring,
schema.org extraction, email finder, and more.
All powered by local Ollama — no API keys needed."""
# 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 difflib
import hashlib
import json
import re
from datetime import datetime
import httpx
OLLAMA_BASE = "http://100.100.18.18:11434"
class PryAdvanced:
"""Features Firecrawl charges extra for — or doesn't have at all."""
def __init__(self, cache=None):
self.cache = cache
self._diffs: dict[str, list] = {}
# ── 1. LLM Page Summary ──
async def summarize(self, content: str, max_words: int = 100) -> dict:
"""Summarize scraped content using local Ollama. Free, private, no data leakage."""
if not content or len(content) < 100:
return {"summary": content, "model": "none"}
prompt = (
f"Summarize the following content in {max_words} words or fewer. "
f"Focus on key facts, data, and actionable information:\n\n{content[:6000]}"
)
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{OLLAMA_BASE}/api/generate",
json={
"model": "qwen2.5-coder:3b",
"prompt": prompt,
"stream": False,
"options": {"num_ctx": 8192, "temperature": 0.2},
},
)
return {"summary": resp.json().get("response", ""), "model": "qwen2.5-coder:3b"}
except (httpx.HTTPError, httpx.RequestError) as e:
return {"summary": content[:500], "error": str(e)}
# ── 2. Diff Tracking — Compare page versions ──
async def track_diff(self, url: str, new_content: str) -> dict:
"""Compare current content against previous scrape. Returns unified diff.
First scrape returns 'initial', subsequent returns changes."""
content_hash = hashlib.md5(new_content.encode()).hexdigest()
if url not in self._diffs:
self._diffs[url] = [
{
"hash": content_hash,
"content": new_content,
"timestamp": datetime.utcnow().isoformat(),
}
]
return {"status": "initial", "url": url, "changes": None, "version": 1}
prev = self._diffs[url][-1]
if prev["hash"] == content_hash:
return {
"status": "unchanged",
"url": url,
"changes": [],
"version": len(self._diffs[url]) + 1,
}
diff = list(
difflib.unified_diff(
prev["content"].splitlines(keepends=True),
new_content.splitlines(keepends=True),
fromfile=f"v{len(self._diffs[url])}",
tofile=f"v{len(self._diffs[url]) + 1}",
n=3,
)
)
self._diffs[url].append(
{
"hash": content_hash,
"content": new_content,
"timestamp": datetime.utcnow().isoformat(),
}
)
return {
"status": "changed",
"url": url,
"changes": diff[:100],
"version": len(self._diffs[url]),
}
# ── 3. Schema.org / JSON-LD extraction ──
def extract_schema(self, html: str) -> list[dict]:
"""Extract structured data (JSON-LD, microdata) from HTML.
Many sites embed Schema.org data that's richer than visible content."""
results = []
# JSON-LD in <script type="application/ld+json">
for m in re.finditer(
r'<script\s+type="application/ld\+json"[^>]*>(.*?)</script>', html, re.I | re.S
):
try:
data = json.loads(m.group(1))
results.append(data)
except json.JSONDecodeError:
continue
# Open Graph / Twitter Card meta tags
og_data = {}
for m in re.finditer(
r'<meta\s+(?:property|name)=["\'](og:|twitter:)([^"\']+)["\']\s+content=["\']([^"\']*)["\']',
html,
re.I,
):
key = m.group(1) + m.group(2)
og_data[key] = m.group(3)
if og_data:
results.append({"@type": "OpenGraph", **og_data})
return results
# ── 4. Email finder ──
def find_emails(self, content: str) -> list[str]:
"""Extract all email addresses from content.
Useful for lead generation and contact discovery."""
emails = set(re.findall(r"[\w.+-]+@[\w-]+\.[\w.-]+", content))
# Filter out common false positives
return sorted(
[e for e in emails if not e.endswith((".png", ".jpg", ".css", ".js", ".svg"))]
)
# ── 5. Social media link finder ──
def find_social_links(self, html: str) -> dict[str, str]:
"""Find social media profile links in HTML."""
patterns = {
"twitter": r"https?://(?:www\.)?(?:twitter\.com|x\.com)/[A-Za-z0-9_]+",
"github": r"https?://(?:www\.)?github\.com/[A-Za-z0-9_-]+",
"linkedin": r"https?://(?:www\.)?linkedin\.com/(?:company|in)/[A-Za-z0-9_-]+",
"youtube": r"https?://(?:www\.)?youtube\.com/(?:@|c/|channel/|user/)[A-Za-z0-9_-]+",
"telegram": r"https?://(?:t\.me|telegram\.me)/[A-Za-z0-9_]+",
"discord": r"https?://(?:www\.)?discord\.(?:gg|com)/[A-Za-z0-9_]+",
"reddit": r"https?://(?:www\.)?reddit\.com/r/[A-Za-z0-9_]+",
}
found = {}
for platform, pattern in patterns.items():
m = re.search(pattern, html, re.I)
if m:
found[platform] = m.group(0)
return found
# ── 6. AI Categorization ──
async def categorize(self, content: str) -> list[str]:
"""Use local AI to categorize scraped content into topics.
Returns tags like 'technology', 'crypto', 'news', 'tutorial', etc."""
prompt = (
"Categorize the following content. Return ONLY a JSON array of 2-5 category tags. "
f'Example: ["technology", "crypto", "analysis"]\n\nContent:\n{content[:4000]}'
)
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{OLLAMA_BASE}/api/generate",
json={
"model": "qwen2.5-coder:3b",
"prompt": prompt,
"stream": False,
"options": {"num_ctx": 4096, "temperature": 0.1},
},
)
raw = resp.json().get("response", "")
arr_match = re.search(r"\[.*?\]", raw, re.S)
return json.loads(arr_match.group(0)) if arr_match else ["uncategorized"]
except (json.JSONDecodeError, ValueError):
return ["uncategorized"]
# ── 7. Keyword density analysis ──
def keyword_density(self, content: str, top_n: int = 20) -> list[dict]:
"""Analyze word frequency in content. Useful for SEO and content analysis."""
words = re.findall(r"\b[a-zA-Z]{3,}\b", content.lower())
stop_words = {
"the",
"and",
"for",
"are",
"but",
"not",
"you",
"all",
"can",
"was",
"has",
"had",
"its",
"that",
"this",
"with",
"from",
"they",
}
freq = {}
for w in words:
if w not in stop_words:
freq[w] = freq.get(w, 0) + 1
sorted_words = sorted(freq.items(), key=lambda x: -x[1])[:top_n]
total = len(words)
return [
{"word": w, "count": c, "density": f"{c / total * 100:.2f}%"} for w, c in sorted_words
]
# ── 8. Readability score ──
def readability(self, content: str) -> dict:
"""Calculate Flesch Reading Ease score for content."""
if not content:
return {"score": 0, "level": "empty"}
sentences = len(re.findall(r"[.!?]+", content))
words = len(re.findall(r"\b\w+\b", content))
syllables = len(re.findall(r"[aeiouy]+", content.lower()))
if sentences == 0 or words == 0:
return {"score": 0, "level": "empty"}
score = 206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words)
score = max(0, min(100, score))
if score >= 90:
level = "very easy"
elif score >= 80:
level = "easy"
elif score >= 70:
level = "fairly easy"
elif score >= 60:
level = "standard"
elif score >= 50:
level = "fairly difficult"
elif score >= 30:
level = "difficult"
else:
level = "very difficult"
return {"score": round(score, 1), "level": level, "words": words, "sentences": sentences}