"""Main content extraction for HTML pages. Uses trafilatura for fast, deterministic HTML-to-text extraction with BeautifulSoup4 as fallback. """ from __future__ import annotations import logging import os import re logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- _MAX_OUTPUT_LENGTH = int( os.environ.get("NSCT_CRAWLER_MAX_OUTPUT_LENGTH", "500000") ) try: import trafilatura _HAS_TRAFILATURA = True except ImportError: _HAS_TRAFILATURA = False try: from bs4 import BeautifulSoup _HAS_BS4 = True except ImportError: _HAS_BS4 = False def extract_main_content(html: str, url: str = "") -> str: """Extract main content from HTML. Priority: trafilatura -> BeautifulSoup4 -> raw text extraction. Args: html: HTML content as string. url: Source URL (used for metadata hints). Returns: Cleaned main content text (stripped, truncated to max length). """ if not html: return "" text = "" if _HAS_TRAFILATURA: try: text = trafilatura.extract( html, include_comments=False, include_tables=False, include_formatting=False, include_links=True, output_format="txt", ) if text is not None: text = text.strip() except Exception: logger.debug("trafilatura extraction failed, falling back to bs4") text = "" if not text and _HAS_BS4: try: text = _extract_with_bs4(html) except Exception: logger.debug("bs4 extraction failed, using minimal fallback") text = _minimal_extract(html) if not text: text = _minimal_extract(html) # Truncate to max output length if len(text) > _MAX_OUTPUT_LENGTH: text = text[:_MAX_OUTPUT_LENGTH] return text.strip() def _extract_with_bs4(html: str) -> str: """Extract main content using BeautifulSoup4. Heuristic: look for article, main, content, or body tags and extract text, stripping nav, header, footer, sidebar. """ soup = BeautifulSoup(html, "html.parser") # Remove script, style, noscript, iframe for tag in soup.find_all(["script", "style", "noscript", "iframe"]): tag.decompose() # Try specific content containers for selector in ["article", "main", "[class*=content]", "[id*=content]", "[class*=main]", "[id*=main]"]: tag = soup.select_one(selector) if tag: return tag.get_text(separator="\n", strip=True)[:_MAX_OUTPUT_LENGTH] # Fallback: body content, excluding nav body = soup.find("body") or soup for tag in body.find_all(["nav", "header", "footer", "aside", "sidebar"]): tag.decompose() text = body.get_text(separator="\n", strip=True) # Collapse excessive newlines text = re.sub(r"\n{3,}", "\n\n", text) return text[:_MAX_OUTPUT_LENGTH] def _minimal_extract(html: str) -> str: """Fallback: strip all tags and return plain text.""" # Remove HTML tags text = re.sub(r"<[^>]+>", " ", html) # Decode common entities text = text.replace(" ", " ") text = text.replace("&", "&") text = text.replace("<", "<") text = text.replace(">", ">") text = text.replace(""", '"') text = text.replace("'", "'") # Collapse whitespace text = re.sub(r"\s+", " ", text) return text[:_MAX_OUTPUT_LENGTH].strip()