""" HTML extraction with BeautifulSoup4+lxml. Extracts: visible text, links (all types), metadata, hidden-content indicators, inline script patterns, JSON-LD, and links buried in HTML comments. """ import json import re from urllib.parse import urlencode, urljoin, urlparse, parse_qsl, urlunparse from bs4 import BeautifulSoup # Query-param keys that are pure cache-busters on asset URLs (CSS/JS/img). # They change on every request and must be stripped before storing/diffing. CACHE_PARAM_KEYS = {"v", "ver", "version", "cache", "cb", "_", "_t"} def strip_cache_params(url: str) -> str: """Remove cache-buster query params (e.g. style.css?v=12345) from a URL.""" p = urlparse(url) if not p.query: return url kept = [(k, v) for k, v in parse_qsl(p.query) if k.lower() not in CACHE_PARAM_KEYS] return urlunparse(p._replace(query=urlencode(kept))) # Inline-script patterns that warrant flagging _SUSPICIOUS_INLINE = [ (r"eval\s*\(", "eval()"), (r"atob\s*\(", "atob()"), (r"fromCharCode", "fromCharCode"), (r"document\.createElement\s*\(\s*[\"']script[\"']", "createElement(script)"), (r"\bfetch\s*\(", "fetch()"), (r"XMLHttpRequest", "XMLHttpRequest"), (r"base64_decode", "base64_decode"), ] # CSS-based hiding patterns (applied to inline style attributes) _HIDDEN_STYLE_CHECKS = [ (r"display\s*:\s*none", "display:none"), (r"visibility\s*:\s*hidden", "visibility:hidden"), (r"opacity\s*:\s*0(?:\.0+)?(?!\d)", "opacity:0"), (r"font-size\s*:\s*0", "font-size:0"), (r"color\s*:\s*(?:white|#fff(?:fff)?|rgba?\(\s*255\s*,\s*255\s*,\s*255)", "white-text"), (r"width\s*:\s*0(?:px)?[;\s]", "width:0"), (r"height\s*:\s*0(?:px)?[;\s]", "height:0"), (r"position\s*:\s*absolute", "position:absolute"), # combined with off-screen check below (r"(?:left|top)\s*:\s*-\d{3,}", "off-screen-position"), (r"clip\s*:\s*rect\s*\(\s*0\s*,?\s*0\s*,?\s*0\s*,?\s*0\s*\)", "clip:rect(0)"), (r"text-indent\s*:\s*-\d{3,}", "text-indent-offscreen"), ] def extract_page(html: str, url: str, cfg: dict | None = None) -> dict: """ Full extraction from page HTML. Returns a structured dict with all data needed for diffing and checking. """ cfg = cfg or {} soup = BeautifulSoup(html, "lxml") # Remove ignored selectors before text extraction ignore_selectors = cfg.get("normalization", {}).get("ignore_selectors") or [] for sel in ignore_selectors: for el in soup.select(sel): el.decompose() return { "url": url, "text": _visible_text(soup), "links": _extract_links(soup, url), "metadata": _extract_metadata(soup), "hidden_content": _find_hidden_content(soup), "inline_scripts": _check_inline_scripts(soup), "jsonld": _extract_jsonld(soup), "comment_links": _find_comment_links(html, url), } # --------------------------------------------------------------------------- # Text # --------------------------------------------------------------------------- def _visible_text(soup: BeautifulSoup) -> str: """Extract visible text — strips scripts, styles, noscript, head.""" work = soup.__copy__() for tag in work(["script", "style", "noscript", "head", "meta", "link"]): tag.decompose() text = work.get_text(separator=" ") return re.sub(r"\s+", " ", text).strip() # --------------------------------------------------------------------------- # Links # --------------------------------------------------------------------------- def _extract_links(soup: BeautifulSoup, base_url: str) -> dict: """ Extract all link categories: a, link, script[src], img[src], iframe[src], form[action], meta_refresh, canonical Each entry: {url, class: 'internal'|'external', ...extra} """ base_netloc = urlparse(base_url).netloc def classify(u: str) -> str: return "internal" if urlparse(u).netloc == base_netloc else "external" def resolve(href: str | None) -> str | None: if not href: return None href = href.strip() if href.startswith(("javascript:", "mailto:", "tel:", "data:", "#")): return None abs_url = urljoin(base_url, href) p = urlparse(abs_url) if p.scheme not in ("http", "https"): return None # Drop fragment return abs_url.split("#")[0] result: dict = { "a": [], "link_rel": [], "script": [], "img": [], "audio": [], "video": [], "iframe": [], "form": [], "meta_refresh": [], "canonical": None, } for tag in soup.find_all("a", href=True): u = resolve(tag["href"]) if u: result["a"].append({ "url": u, "text": tag.get_text(" ", strip=True)[:200], "class": classify(u), "rel": " ".join(tag.get("rel", [])), }) for tag in soup.find_all("link", href=True): u = resolve(tag["href"]) if not u: continue rel = " ".join(tag.get("rel", [])) if "canonical" in rel: result["canonical"] = u u = strip_cache_params(u) # assets carry cache-busters → normalise away result["link_rel"].append({"url": u, "rel": rel, "class": classify(u)}) for tag in soup.find_all("script", src=True): u = resolve(tag["src"]) if u: u = strip_cache_params(u) result["script"].append({"url": u, "class": classify(u)}) for tag in soup.find_all("img", src=True): u = resolve(tag["src"]) if u: u = strip_cache_params(u) result["img"].append({"url": u, "class": classify(u)}) for tag in soup.find_all(["audio", "video"]): media_type = tag.name # "audio" or "video" if tag.get("src"): u = resolve(tag["src"]) if u: u = strip_cache_params(u) result[media_type].append({"url": u, "class": classify(u)}) for src_tag in tag.find_all("source", src=True): u = resolve(src_tag["src"]) if u: u = strip_cache_params(u) result[media_type].append({"url": u, "class": classify(u)}) for tag in soup.find_all("iframe", src=True): u = resolve(tag["src"]) if u: result["iframe"].append({"url": u, "class": classify(u)}) for tag in soup.find_all("form", action=True): u = resolve(tag["action"]) if u: result["form"].append({"url": u, "class": classify(u)}) for tag in soup.find_all("meta"): if tag.get("http-equiv", "").lower() == "refresh": content = tag.get("content", "") m = re.search(r"url\s*=\s*(.+)", content, re.I) if m: u = resolve(m.group(1).strip().strip("\"'")) if u: result["meta_refresh"].append(u) return result # --------------------------------------------------------------------------- # Metadata # --------------------------------------------------------------------------- def _extract_metadata(soup: BeautifulSoup) -> dict: """Extract title, description, canonical, og-tags, robots, hreflang, H1/H2.""" meta: dict = {} t = soup.find("title") meta["title"] = t.get_text(strip=True) if t else None canonical_tag = soup.find("link", rel="canonical") if canonical_tag: meta["canonical"] = canonical_tag.get("href", "").strip() for tag in soup.find_all("meta"): name = (tag.get("name") or "").lower().strip() prop = (tag.get("property") or "").lower().strip() content = tag.get("content", "") if name == "description": meta["description"] = content elif name == "robots": meta["robots"] = content elif name == "keywords": meta["keywords"] = content elif prop.startswith("og:"): meta[prop] = content elif prop.startswith("twitter:"): meta[prop] = content hreflang = [] for tag in soup.find_all("link", rel="alternate"): hl = tag.get("hreflang") if hl: hreflang.append({"lang": hl, "href": tag.get("href", "")}) meta["hreflang"] = hreflang meta["h1"] = [h.get_text(" ", strip=True) for h in soup.find_all("h1")] meta["h2"] = [h.get_text(" ", strip=True) for h in soup.find_all("h2")] return meta # --------------------------------------------------------------------------- # Hidden content # --------------------------------------------------------------------------- def _find_hidden_content(soup: BeautifulSoup) -> list[dict]: """ Detect elements that are visually hidden but contain text or links. Catches common SEO-spam hiding techniques. """ findings: list[dict] = [] for tag in soup.find_all(style=True): style = tag.get("style", "") matched_indicators = [] for pattern, label in _HIDDEN_STYLE_CHECKS: if re.search(pattern, style, re.I): matched_indicators.append(label) if not matched_indicators: continue text = tag.get_text(" ", strip=True)[:300] links = [a.get("href", "") for a in tag.find_all("a", href=True)] if text or links: findings.append({ "type": "inline_style_hidden", "indicators": matched_indicators, "tag": tag.name, "text_preview": text, "links": links, }) #