"""
Content extraction: multi-strategy with CSS scoring, text density, readability fallback.
"""
from __future__ import annotations
import logging
import re
from typing import Optional
from bs4 import BeautifulSoup, NavigableString, Tag
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Noise patterns
# ---------------------------------------------------------------------------
_NOISE_CLASSES = re.compile(
r"ad|ads|advert|advertisement|banner|popup|modal|cookie|gdpr|"
r"nav|navigation|header|footer|sidebar|widget|comment|disqus|"
r"share|social|related|recommend|newsletter|subscribe|signup|"
r"author-bio|breadcrumb|pagination|next-chapter|prev-chapter|"
r"chapter-nav|bookmark|rating|review|buy|purchase|unlock",
re.IGNORECASE,
)
_NOISE_IDS = _NOISE_CLASSES # same pattern
_NOISE_TEXT_PATTERNS = [
re.compile(p, re.IGNORECASE) for p in [
r"if you (?:find|enjoy|like) this (novel|book|story)",
r"please (support|visit|check out|subscribe)",
r"read this (novel|chapter|story) at",
r"this chapter was (stolen|taken|scrapped)",
r"visit\s+\w+\.(com|net|org)\s+for",
r"translator'?s?\s+note",
r"\[TL[:\s]",
r"^\s*\*+\s*$",
r"chapter\s+\d+\s+has\s+been\s+stolen",
r"this\s+content\s+is\s+taken\s+from",
r"you\s+can\s+find\s+the\s+latest\s+chapters",
r"join\s+our\s+discord",
r"patreon\.com",
r"ko-fi\.com",
r"support\s+the\s+author",
]
]
_LOCKED_PATTERNS = [
re.compile(p, re.IGNORECASE) for p in [
r"this chapter is locked",
r"premium chapter",
r"vip (chapter|content)",
r"unlock (with|using) coins?",
r"purchase to (read|unlock)",
r"subscribe to (read|access)",
]
]
# ---------------------------------------------------------------------------
# Content container candidates
# ---------------------------------------------------------------------------
_CONTENT_SELECTORS = [
# Novelhall.com specific
"div#htmlContent",
# High-confidence specific selectors
"div.chapter-content",
"div.chapter_content",
"div#chapter-content",
"div#chapter_content",
"div.text-chapter",
"div.reading-content",
"div#reading-content",
"article.chapter",
"div.content-text",
"div.novel-content",
"div.post-content",
"article.post-content",
"div.entry-content",
"div#content",
"article",
"main",
"div.content",
"div.text",
]
_TITLE_SELECTORS = [
# Novelhall.com specific
"div.single-header h1",
"h1.chapter-title",
"h2.chapter-title",
"h1.entry-title",
"h1",
"h2",
".chapter-title",
"#chapter-title",
"title",
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _remove_noise_elements(soup: BeautifulSoup) -> None:
"""In-place removal of known noisy elements."""
# Remove by tag
for tag in soup.find_all(["script", "style", "noscript", "iframe", "form",
"button", "input", "select", "textarea", "canvas"]):
tag.decompose()
# Remove by class/id
for element in soup.find_all(True):
cls = " ".join(element.get("class", []))
id_ = element.get("id", "")
if _NOISE_CLASSES.search(cls) or _NOISE_IDS.search(id_):
element.decompose()
def _is_noise_paragraph(text: str) -> bool:
"""Return True if paragraph is spam/noise."""
text = text.strip()
if not text or len(text) < 5:
return True
for pat in _NOISE_TEXT_PATTERNS:
if pat.search(text):
return True
return False
def _text_density(element: Tag) -> float:
"""Ratio of text length to tag count."""
text_len = len(element.get_text())
tags = len(element.find_all(True))
if tags == 0:
return float(text_len)
return text_len / (tags + 1)
def _extract_paragraphs(element: Tag) -> list[str]:
"""Extract clean paragraphs from a container element."""
paragraphs: list[str] = []
# Replace
tags with newlines (v1 approach — many novel sites use
not
) for br in element.find_all("br"): br.replace_with("\n") # Prefer
tags p_tags = element.find_all("p") if p_tags: for p in p_tags: text = p.get_text(separator=" ", strip=True) if not _is_noise_paragraph(text): paragraphs.append(text) else: # Fall back to br-delimited text for line in element.get_text(separator="\n").splitlines(): line = line.strip() if line and not _is_noise_paragraph(line) and len(line) > 20: paragraphs.append(line) # Deduplicate consecutive identical paragraphs deduped: list[str] = [] prev = "" for p in paragraphs: if p != prev: deduped.append(p) prev = p return deduped # --------------------------------------------------------------------------- # Strategy 1: CSS selector scoring # --------------------------------------------------------------------------- def _strategy_selectors(soup: BeautifulSoup) -> Optional[list[str]]: for sel in _CONTENT_SELECTORS: try: el = soup.select_one(sel) if el: paras = _extract_paragraphs(el) if len(paras) >= 1 and sum(len(p) for p in paras) > 50: return paras except Exception: continue return None # --------------------------------------------------------------------------- # Strategy 2: Text density analysis # --------------------------------------------------------------------------- def _strategy_density(soup: BeautifulSoup) -> Optional[list[str]]: candidates: list[tuple[float, Tag]] = [] for tag in soup.find_all(["div", "article", "section", "main"]): d = _text_density(tag) if d > 50: candidates.append((d, tag)) candidates.sort(reverse=True, key=lambda x: x[0]) for _, el in candidates[:3]: paras = _extract_paragraphs(el) if len(paras) >= 3 and sum(len(p) for p in paras) > 300: return paras return None # --------------------------------------------------------------------------- # Strategy 3: Readability / trafilatura # --------------------------------------------------------------------------- def _strategy_readability(html: str) -> Optional[list[str]]: # Try trafilatura first try: import trafilatura extracted = trafilatura.extract(html, include_comments=False, include_tables=False) if extracted and len(extracted) > 300: lines = [l.strip() for l in extracted.splitlines() if l.strip() and len(l.strip()) > 20] if lines: return lines except ImportError: pass # Try readability-lxml try: from readability import Document doc = Document(html) content_html = doc.summary() sub_soup = BeautifulSoup(content_html, "lxml") paras = _extract_paragraphs(sub_soup) if paras and sum(len(p) for p in paras) > 200: return paras except ImportError: pass return None # --------------------------------------------------------------------------- # Strategy 4: Largest text block heuristic # --------------------------------------------------------------------------- def _strategy_largest_block(soup: BeautifulSoup) -> Optional[list[str]]: best: tuple[int, Optional[list[str]]] = (0, None) for tag in soup.find_all(["div", "p", "article", "section"]): text = tag.get_text(separator="\n") paras = [l.strip() for l in text.splitlines() if len(l.strip()) > 30] total = sum(len(p) for p in paras) if total > best[0]: best = (total, paras) if best[1] and best[0] > 500: # Clean noise return [p for p in best[1] if not _is_noise_paragraph(p)] return None # --------------------------------------------------------------------------- # Title extraction # --------------------------------------------------------------------------- def extract_title(soup: BeautifulSoup, fallback: str = "") -> str: for sel in _TITLE_SELECTORS: try: el = soup.select_one(sel) if el: text = el.get_text(strip=True) if text and len(text) < 200: return text except Exception: continue return fallback # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def is_locked_chapter(html: str) -> bool: for pat in _LOCKED_PATTERNS: if pat.search(html): return True return False def extract_chapter(html: str, fallback_title: str = "") -> tuple[str, str, list[str]]: """ Extract chapter from raw HTML. Returns (title, content_text, paragraphs). """ soup = BeautifulSoup(html, "lxml") title = extract_title(soup, fallback=fallback_title) # Remove noise before content extraction _remove_noise_elements(soup) # Try strategies in order paragraphs: Optional[list[str]] = None paragraphs = _strategy_selectors(soup) if not paragraphs: logger.debug("CSS selectors found nothing, trying density...") paragraphs = _strategy_density(soup) if not paragraphs: logger.debug("Density failed, trying readability...") paragraphs = _strategy_readability(html) if not paragraphs: logger.debug("Readability failed, trying largest block...") paragraphs = _strategy_largest_block(soup) if not paragraphs: paragraphs = ["[Content extraction failed for this chapter]"] content_text = "\n\n".join(paragraphs) return title, content_text, paragraphs