# ============================================================ # Project: EDGAR Research Database # Description: SEC EDGAR Research Infrastructure # # Authors: # - Manel Kammoun — manel.kammoun@uqo.ca # - Djerrycharli Tandjambianda — djerrycharli.tandjambianda@uqo.ca # - Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca # # Institution: # Université du Québec en Outaouais (UQO) # # License: # Research / Academic Use # ============================================================ from __future__ import annotations import re from pathlib import Path import chardet from bs4 import BeautifulSoup, Tag from src.utils.config import get_parsing_config from src.utils.logging import get_logger logger = get_logger("parsing.html") SGML_HEADER_PATTERN = re.compile( r"^.*?<(?:DOCUMENT|document)>.*?<(?:TEXT|text)>", re.DOTALL, ) SGML_HEADER_TAG_PATTERN = re.compile( r"^.*?(?=<(?:html|HTML|body|BODY|div|DIV|p|P|table|TABLE)\b)", re.DOTALL, ) class HTMLCleaner: def __init__(self, config: dict | None = None) -> None: cfg = config or get_parsing_config() html_cfg = cfg.get("html", {}) self._parser: str = html_cfg.get("parser", "lxml") self._remove_tags: list[str] = html_cfg.get( "remove_tags", ["script", "style", "meta", "link"] ) self._preserve_tables: bool = html_cfg.get("preserve_tables", True) self._preserve_lists: bool = html_cfg.get("preserve_lists", True) self._normalize_ws: bool = html_cfg.get("normalize_whitespace", True) self._max_file_size: int = html_cfg.get("max_file_size_mb", 50) * 1024 * 1024 def clean(self, html_content: str) -> str: text = self._strip_sgml_header(html_content) soup = BeautifulSoup(text, self._parser) for tag_name in self._remove_tags: for tag in soup.find_all(tag_name): tag.decompose() if self._preserve_tables: self._convert_tables(soup) if self._preserve_lists: self._convert_lists(soup) raw_text = soup.get_text(separator="\n") if self._normalize_ws: raw_text = self._normalize_whitespace(raw_text) return raw_text.strip() def clean_file(self, file_path: Path) -> str: file_path = Path(file_path) if not file_path.exists(): raise FileNotFoundError(f"File not found: {file_path}") file_size = file_path.stat().st_size if file_size > self._max_file_size: logger.warning( "Skipping file exceeding size limit: %s (%d MB)", file_path, file_size // (1024 * 1024), ) return "" raw_bytes = file_path.read_bytes() encoding = self._detect_encoding(raw_bytes) html_content = raw_bytes.decode(encoding, errors="replace") return self.clean(html_content) def _detect_encoding(self, raw_bytes: bytes) -> str: for enc in ("utf-8", "latin-1", "windows-1252"): try: raw_bytes.decode(enc) return enc except (UnicodeDecodeError, LookupError): continue result = chardet.detect(raw_bytes) return result.get("encoding") or "utf-8" def _strip_sgml_header(self, text: str) -> str: match = SGML_HEADER_PATTERN.search(text) if match: text = text[match.end():] match = SGML_HEADER_TAG_PATTERN.search(text) if match and match.end() > 0: text = text[match.end():] return text def _convert_tables(self, soup: BeautifulSoup) -> None: for table in soup.find_all("table"): rows: list[str] = [] for tr in table.find_all("tr"): cells = [] for cell in tr.find_all(["td", "th"]): cell_text = cell.get_text(strip=True) cells.append(cell_text) if any(cells): rows.append("\t".join(cells)) table_text = "\n".join(rows) table.replace_with(BeautifulSoup(table_text, self._parser)) def _convert_lists(self, soup: BeautifulSoup) -> None: for li in soup.find_all("li"): li_text = li.get_text(strip=True) li.replace_with(f"- {li_text}\n") def _normalize_whitespace(self, text: str) -> str: text = re.sub(r"[^\S\n]+", " ", text) text = re.sub(r" *\n *", "\n", text) text = re.sub(r"\n{3,}", "\n\n", text) lines = text.split("\n") cleaned = [line.strip() for line in lines] return "\n".join(cleaned)