"""Webpage content fetching with caching, PDF extraction, and summarization helpers.""" import copy import io import json import os import re import logging from datetime import datetime, timedelta from typing import List import httpx from bs4 import BeautifulSoup from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT from src import outbound_fetch as _outbound_fetch from .analytics import RateLimitError, error_logger from .cache import ( CONTENT_CACHE_DIR, content_cache_index, generate_cache_key, cleanup_cache, ) logger = logging.getLogger(__name__) def _is_private_address(addr): return _outbound_fetch._is_private_address(addr) def _resolve_hostname_ips(hostname): return _outbound_fetch._resolve_hostname_ips(hostname) def _public_http_url(url): return _outbound_fetch._public_http_url(url, resolver=_resolve_hostname_ips) def _resolve_public_ips(url): return _outbound_fetch._resolve_public_ips(url, resolver=_resolve_hostname_ips) _PinnedBackend = _outbound_fetch._PinnedBackend _PinnedTransport = _outbound_fetch._PinnedTransport BodyTooLargeError = _outbound_fetch.BodyTooLargeError _CappedFetch = _outbound_fetch._CappedFetch def _get_public_url(url, headers, timeout, max_redirects=5, max_bytes=None): return _outbound_fetch._get_public_url( url, headers=headers, timeout=timeout, max_redirects=max_redirects, max_bytes=max_bytes, resolve_public_ips=_resolve_public_ips, transport_factory=_PinnedTransport, ) # PDF extraction (optional dependency) try: from pdfminer.high_level import extract_text as pdf_extract_text except ImportError: pdf_extract_text = None # type: ignore # ---------------------------------------------------------------------- # HTML extraction helpers # ---------------------------------------------------------------------- def _extract_meta(soup: BeautifulSoup) -> dict: """Pull meta description and keywords if present.""" description = "" keywords = "" desc_tag = soup.find("meta", attrs={"name": re.compile("description", re.I)}) if desc_tag and desc_tag.get("content"): description = desc_tag["content"].strip() kw_tag = soup.find("meta", attrs={"name": re.compile("keywords", re.I)}) if kw_tag and kw_tag.get("content"): keywords = kw_tag["content"].strip() return {"description": description, "keywords": keywords} def _extract_og_image(soup: BeautifulSoup) -> str: """Extract the best representative image URL from meta tags. Only returns absolute http(s) URLs -- skips relative paths and data URIs. """ candidates = [] for prop in ("og:image", "og:image:url", "og:image:secure_url"): tag = soup.find("meta", attrs={"property": prop}) if tag and tag.get("content", "").strip(): candidates.append(tag["content"].strip()) tag = soup.find("meta", attrs={"name": "twitter:image"}) if tag and tag.get("content", "").strip(): candidates.append(tag["content"].strip()) tag = soup.find("meta", attrs={"name": "thumbnail"}) if tag and tag.get("content", "").strip(): candidates.append(tag["content"].strip()) for url in candidates: if url.startswith(("https://", "http://")) and not url.endswith((".svg", ".ico")): return url return "" def _extract_lists(soup: BeautifulSoup) -> List[List[str]]: """Return a list of lists, each inner list representing a