""" Unified document extraction pipeline for URLs / HTML / PDF bytes. Cascade (ethical, rate-friendly): 1. PDF URL → download + PyMuPDF/pypdf 2. HTML URL → fetch (httpx/requests) → Trafilatura main content 3. Optional Crawl4AI markdown if HTTP body is thin (caller may pass prefetched html) Always attaches legal citation extraction for regulation grounding. """ from __future__ import annotations import logging import os import re import tempfile from typing import Any, Dict, Optional from urllib.parse import urlparse from core.document_intel.html_extract import html_to_clean_text from core.document_intel.legal_citations import extract_legal_citations from core.document_intel.pdf_extract import extract_pdf_text logger = logging.getLogger(__name__) _PDF_EXT = re.compile(r"\.pdf($|\?)", re.I) def extract_from_html( html: str, *, url: str = "", content_type: str = "", ) -> Dict[str, Any]: """Clean HTML + legal citations. Binary/ZIP URLs short-circuit without Trafilatura.""" try: from core.document_intel.fetch_resilience import should_skip_html_extract if should_skip_html_extract(url, content_type=content_type or None): return { "text": "", "extractor": "skipped_binary", "chars": 0, "legal": extract_legal_citations(""), "content_type": "binary", "url": url, "ok": False, "skipped": True, "reason": "binary_or_archive", } except Exception: pass cleaned = html_to_clean_text(html or "", url=url, content_type=content_type) if cleaned.get("skipped"): return { "text": "", "extractor": cleaned.get("extractor") or "skipped_binary", "chars": 0, "legal": extract_legal_citations(""), "content_type": "binary", "url": url, "ok": False, "skipped": True, "reason": "binary_or_archive", } text = cleaned.get("text") or "" cites = extract_legal_citations(text) return { "text": text, "extractor": cleaned.get("extractor"), "chars": cleaned.get("chars") or len(text), "legal": cites, "content_type": "html", "url": url, "ok": bool(text and len(text) >= 40), } def _looks_like_pdf_url(url: str) -> bool: if not url: return False path = urlparse(url).path or "" return bool(_PDF_EXT.search(path) or path.lower().endswith(".pdf")) def _fetch_bytes(url: str, *, timeout: float = 45.0) -> Dict[str, Any]: """HTTP GET — prefer curl_cffi stealth on WAF domains, else requests.""" try: from core.document_intel.stealth_fetch import should_use_stealth, stealth_get if should_use_stealth(url): stealth = stealth_get(url, timeout=timeout) if stealth.get("ok"): return { "status_code": stealth.get("status_code") or 200, "content": stealth.get("content") or b"", "text": stealth.get("text") or "", "headers": stealth.get("headers") or {}, "final_url": stealth.get("final_url") or url, "via": f"stealth:{stealth.get('impersonate')}", } # fall through to plain requests except Exception as e: logger.debug("[DocIntel] stealth path skip: %s", e) import requests headers = { "User-Agent": ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml,application/pdf,*/*;q=0.8", "Accept-Language": "pl-PL,pl;q=0.9,en;q=0.8", } resp = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True) return { "status_code": resp.status_code, "content": resp.content, "text": resp.text if resp.encoding or resp.apparent_encoding else "", "headers": {k.lower(): v for k, v in resp.headers.items()}, "final_url": str(resp.url), "via": "requests", } def _process_fetched( url: str, fetched: Dict[str, Any], *, is_pdf: bool, ) -> Dict[str, Any]: status = int(fetched.get("status_code") or 0) if status >= 400 or status == 0: return { "ok": False, "reason": f"http_{status}", "text": "", "url": url, "status_code": status, } ctype = (fetched.get("headers") or {}).get("content-type", "") content: bytes = fetched.get("content") or b"" final_url = fetched.get("final_url") or url if is_pdf or "application/pdf" in ctype or content[:4] == b"%PDF": fd, path = tempfile.mkstemp(suffix=".pdf") try: with os.fdopen(fd, "wb") as f: f.write(content) pdf = extract_pdf_text(path) text = pdf.get("text") or "" cites = extract_legal_citations(text) return { "ok": bool(text and len(text) >= 40), "text": text, "extractor": pdf.get("parser"), "chars": pdf.get("chars") or len(text), "legal": cites, "content_type": "pdf", "url": final_url, "status_code": status, "source": "pdf_bytes", } finally: try: os.unlink(path) except Exception: pass html = fetched.get("text") or "" if not html and content: try: html = content.decode("utf-8", errors="replace") except Exception: html = "" out = extract_from_html(html, url=final_url) out["status_code"] = status out["url"] = final_url out["source"] = "http_html" return out def extract_document_from_url( url: str, *, prefer_pdf: Optional[bool] = None, html_hint: Optional[str] = None, timeout: float = 45.0, ) -> Dict[str, Any]: """ Fetch URL and extract clean text + legal citations (sync). Soft-fails with ok=False (never raises for network issues). """ if not url or not str(url).startswith(("http://", "https://")): return {"ok": False, "reason": "invalid_url", "text": "", "url": url} try: from core.document_intel.fetch_resilience import ( is_binary_or_archive_url, path_extension, ) # ZIP/archives: never fetch into Trafilatura (P2) if is_binary_or_archive_url(url) and path_extension(url) != ".pdf": return { "ok": False, "reason": "skipped_binary_archive", "text": "", "url": url, "skipped": True, "extractor": "skipped_binary", } except Exception: pass if html_hint: out = extract_from_html(html_hint, url=url) out["source"] = "html_hint" return out is_pdf = prefer_pdf if prefer_pdf is not None else _looks_like_pdf_url(url) try: fetched = _fetch_bytes(url, timeout=timeout) except Exception as e: logger.warning("[DocIntel] fetch failed %s: %s", url[:80], e) return {"ok": False, "reason": f"fetch_error:{e}"[:120], "text": "", "url": url} return _process_fetched(url, fetched, is_pdf=is_pdf) async def extract_document_from_url_async( url: str, *, prefer_pdf: Optional[bool] = None, html_hint: Optional[str] = None, timeout: float = 45.0, use_crawl4ai_fallback: bool = True, ) -> Dict[str, Any]: """ Async variant: same cascade + optional Crawl4AI when HTML is thin. """ import asyncio if not url or not str(url).startswith(("http://", "https://")): return {"ok": False, "reason": "invalid_url", "text": "", "url": url} try: from core.document_intel.fetch_resilience import ( is_binary_or_archive_url, path_extension, ) if is_binary_or_archive_url(url) and path_extension(url) != ".pdf": return { "ok": False, "reason": "skipped_binary_archive", "text": "", "url": url, "skipped": True, "extractor": "skipped_binary", } except Exception: pass if html_hint: out = extract_from_html(html_hint, url=url) out["source"] = "html_hint" return out is_pdf = prefer_pdf if prefer_pdf is not None else _looks_like_pdf_url(url) try: fetched = await asyncio.to_thread(_fetch_bytes, url, timeout=timeout) except Exception as e: logger.warning("[DocIntel] async fetch failed %s: %s", url[:80], e) return {"ok": False, "reason": f"fetch_error:{e}"[:120], "text": "", "url": url} out = _process_fetched(url, fetched, is_pdf=is_pdf) min_chars = int(os.environ.get("DOC_INTEL_MIN_CHARS", "120")) if ( use_crawl4ai_fallback and out.get("content_type") != "pdf" and (not out.get("ok") or (out.get("chars") or 0) < min_chars) ): try: from core.crawl4ai_client import scrape_url_to_markdown md = await scrape_url_to_markdown(out.get("url") or url) if md and len(md.strip()) > (out.get("chars") or 0): cites = extract_legal_citations(md) return { "ok": True, "text": md.strip(), "extractor": "crawl4ai", "chars": len(md.strip()), "legal": cites, "content_type": "markdown", "url": out.get("url") or url, "status_code": out.get("status_code"), "source": "crawl4ai_fallback", } except Exception as e: logger.debug("[DocIntel] crawl4ai fallback skip: %s", e) return out