""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import random import re import socket import threading import time from copy import deepcopy from datetime import datetime from dataclasses import dataclass, field from typing import Dict, List, Set, Tuple, Optional, Callable from urllib.parse import urljoin, urlparse, urldefrag, urlunparse from app.knowledge.crawler_identity import CRAWLER_USER_AGENT from app.knowledge.url_safety import BlockedHostError from app.knowledge.deadline import Deadline from app.knowledge.page_fetch import NotAPage, fetch_page_text import concurrent.futures from concurrent.futures import ThreadPoolExecutor, as_completed from bs4 import BeautifulSoup, Tag import httpx from agno.document.base import Document from agno.document.reader.website_reader import WebsiteReader from app.core.logger import get_logger from app.knowledge.chunking import chunk_page from app.knowledge.crawl4ai_fallback import get_crawl4ai_fallback from app.knowledge.content_summarizer import get_content_summarizer from app.knowledge.crawl_scope import ( DEFAULT_CRAWL_SCOPE, CrawlScope, is_crawlable_url, url_scheme, ) from app.knowledge.domains import registrable_domain from app.knowledge.failure_advice import FAILURE_ADVICE, FailureCause from app.knowledge.main_content import select_main_node # Initialize logger for this module logger = get_logger(__name__) class BotProtectionError(Exception): """Raised when a crawl produced no content because the site's bot protection (Cloudflare / WordPress.com "Checking your browser…" interstitial) blocked the automated browser. Signals the user should add the content manually.""" class EmptyCrawlError(Exception): """Raised when a crawl stored no pages for a reason other than bot protection. A run that indexes nothing is a failure, not a success: without this the queue item completes and the source appears in the dashboard as a healthy source with zero pages, so nobody learns the agent has no content to answer from.""" def _is_name_resolution_error(error: Exception) -> bool: """True when a connection failed because the name has no address. Walks the cause chain for socket.gaierror rather than matching on the message, which would also catch any other error whose text happens to mention a "name" and wrongly skip the browser fallback for it. """ seen = set() current = error while current is not None and id(current) not in seen: seen.add(id(current)) if isinstance(current, socket.gaierror): return True current = current.__cause__ or current.__context__ text = str(error).lower() return 'nodename' in text or 'name or service not known' in text or 'name resolution' in text @dataclass class EnhancedWebsiteReader(WebsiteReader): """Enhanced Reader for Websites with more robust content extraction""" debug_on = True # Additional configuration options min_content_length: int = 100 # Minimum length of text to be considered meaningful content # Only blacklist truly problematic tags - keep structural elements that may have useful content blacklist_tags: List[str] = field(default_factory=lambda: [ 'script', 'style', 'noscript', 'iframe', 'head' ]) user_agent: str = CRAWLER_USER_AGENT headers: Dict[str, str] = field(default_factory=lambda: { 'User-Agent': CRAWLER_USER_AGENT, 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', 'DNT': '1', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1', 'Cache-Control': 'max-age=0' }) timeout: int = 30 # Request timeout in seconds max_retries: int = 3 # Maximum number of retries for failed requests respect_robots_txt: bool = True # Whether to respect robots.txt max_workers: int = 10 # Maximum number of parallel workers for crawling # How far the crawl may wander from its seed URL: 'host' (default), 'path' # (seed host + seed path prefix) or 'domain' (every host on the registrable # domain). See app/knowledge/crawl_scope.py. crawl_scope: str = DEFAULT_CRAWL_SCOPE verify_ssl: bool = True # Whether to verify SSL certificates (set to False for self-signed certs) # Track crawling statistics _crawled_pages_count: int = 0 _successful_crawls: int = 0 _failed_crawls: int = 0 _challenge_blocked: int = 0 # Pages skipped because a bot-check couldn't be cleared # Why pages failed, as {FailureCause: count}. One string covering "empty, # JavaScript, or unreachable" told the user nothing and told us nothing; # _process_url already knows which of those it hit, so keep it. _failure_causes: Dict[str, int] = field(default_factory=dict) # Pages are crawled on up to max_workers threads, and "read, add one, write # back" loses increments when two of them fail at once. _failure_lock: threading.Lock = field(default_factory=threading.Lock, repr=False) _current_url: str = None # Track current URL being processed for link resolution # Set by the knowledge base for the source being read: no new pages are # fetched once it passes (see app/knowledge/deadline.py). deadline: Optional[Deadline] = None @staticmethod def _classify_transport_error(error: Exception) -> Tuple[str, bool, bool]: """Map a transport failure to (cause, retry_is_useful, browser_may_help). Retrying only pays when the failure could plausibly differ next time. A domain with no address and a host that drops the SYN both fail the same way every attempt, so the old ladder of three tries with backoff plus a headless browser spent 130 seconds on mateusoliveira.com and ~10 on each dead domain, holding one of only two queue slots the whole time. The browser shares our DNS and TCP stack, so it cannot reach what we could not connect to — it is worth a turn only when the connection itself was refused, which can be a TLS quirk rather than a dead host. """ if isinstance(error, httpx.ConnectTimeout): # No SYN-ACK. Waiting longer only waits longer. return FailureCause.TIMEOUT, False, False if isinstance(error, httpx.ConnectError): if _is_name_resolution_error(error): # Chromium reports ERR_NAME_NOT_RESOLVED for exactly these. return FailureCause.DNS, False, False return FailureCause.CONNECTION, False, True if isinstance(error, httpx.PoolTimeout): # Our own connection pool was saturated; the site never heard from # us. Retry, but do not report it as the site being slow. return FailureCause.UNKNOWN, True, True if isinstance(error, httpx.TimeoutException): # Connected, then went quiet: a slow page really may load next time. return FailureCause.TIMEOUT, True, True if isinstance(error, httpx.RequestError): return FailureCause.CONNECTION, True, True # Not a transport failure: a bug in our own parsing, most likely. Retry # and let the browser try, but do not tell the user their site is down. return FailureCause.UNKNOWN, True, True @property def out_of_time(self) -> bool: return self.deadline is not None and self.deadline.expired def _record_failure(self, cause: str) -> None: """Remember why a page failed so the queue item can be specific.""" with self._failure_lock: self._failure_causes[cause] = self._failure_causes.get(cause, 0) + 1 def dominant_failure_cause(self) -> Optional[str]: """The cause that accounts for the most failed pages, if any.""" if not self._failure_causes: return None return max(self._failure_causes.items(), key=lambda item: item[1])[0] def _normalize_url(self, url: str) -> str: """ Normalize URL by ensuring it has a proper protocol (http:// or https://). If no protocol is present, https:// is added by default. A URL that already carries a scheme is returned untouched — including a non-HTTP one such as ``mailto:``. Prefixing those produced ``https://mailto:a@example.com``, which parses as host ``example.com`` with ``mailto:a`` as userinfo and so was crawled as a real page. :param url: The URL to normalize. :return: The normalized URL with protocol. """ if not url: return url url = url.strip() # Check if URL already has a protocol if url_scheme(url): return url # Add https:// by default if no protocol is present logger.debug(f"URL '{url}' is missing protocol, adding 'https://'") return f"https://{url}" @staticmethod def _host_resolves(host: str) -> bool: """Whether a hostname has an address. False for any failure to find one. Strict on purpose: the two callers want opposite defaults from a lookup that errors oddly (a malformed IDN host, say), and treating "could not tell" as success would let the apex check pass a host it never verified. Failing closed means an unverifiable pair is simply left alone. """ try: socket.getaddrinfo(host, None) return True except Exception: return False def _seed_with_resolvable_host(self, url: str) -> str: """Swap a dead ``www`` seed for its apex when only the apex resolves. utsabzone.store serves its site on the apex but has a broken CNAME on ``www``, so a customer who pasted the ``www`` address was told the domain does not resolve — true of the name they gave us, useless as advice, and the site was up the whole time. Only the seed is rewritten. CrawlScope already treats ``www.x`` and ``x`` as one host, so the crawl stays inside the same boundary, and the fetch still runs the SSRF guard on whichever host we end up asking for. """ parsed = urlparse(url) host = (parsed.hostname or "").lower() if not host.startswith("www."): return url # Only pay for a lookup once we know there is an alternative to try. if self._host_resolves(host): return url apex = host[4:] if not apex or not self._host_resolves(apex): return url netloc = parsed.netloc if "@" in netloc or not netloc.lower().startswith(host): # Credentials or an unexpected shape: do not rewrite it. return url swapped = parsed._replace(netloc=apex + netloc[len(host):]).geturl() logger.warning( f"{host} has no address; crawling {apex} instead (the apex resolves)" ) return swapped def _canonical_url(self, url: str) -> str: """Canonicalize a URL so variants of the same page collapse to one id. Drops the ``#fragment`` and any query string, and strips a trailing slash (the root path stays as-is). Without this the same page linked as ``/``, ``/#features`` and ``/#pricing`` would be crawled and stored three times under distinct ids, producing duplicate sub-pages. """ if not url: return url url = urldefrag(url).url # remove #fragment parsed = urlparse(url) path = parsed.path.rstrip('/') or ('/' if not parsed.netloc else '') return urlunparse((parsed.scheme, parsed.netloc, path, '', '', '')) # Unambiguous bot-challenge / interstitial signatures. _STRONG_CHALLENGE_MARKERS = ( "secured by wp.com", "cf-browser-verification", "cf_chl_", "ddos protection by cloudflare", "verifying you are human", "attention required! | cloudflare", ) # Weaker signatures — only treated as a challenge on a short page, since these # phrases can legitimately appear inside real content. _WEAK_CHALLENGE_MARKERS = ( "checking your browser", "just a moment", "please enable javascript", "please turn javascript on", "enable cookies and reload", "please stand by, while we are checking", ) def _looks_like_bot_challenge(self, text: str) -> bool: """True if the extracted text is a bot-check/interstitial, not real content. Sites behind Cloudflare / WordPress.com serve a short "Checking your browser…" page to non-browser clients. Its text is just long enough to pass the min-length check, so without this it would be stored as the page's content. """ if not text: return False lowered = text.lower() if any(marker in lowered for marker in self._STRONG_CHALLENGE_MARKERS): return True if len(text) < 500 and any(marker in lowered for marker in self._WEAK_CHALLENGE_MARKERS): return True return False def _get_primary_domain(self, url: str) -> str: """ Extract primary domain from the given URL. Overrides the parent method to handle domains more effectively. :param url: The URL to extract the primary domain from. :return: The primary domain. """ # Delegate to the shared registrable-domain helper (ccTLD-aware, # port/userinfo stripped via hostname) so every same-domain check in # the codebase agrees. parsed_url = urlparse(self._normalize_url(url)) return registrable_domain(parsed_url.hostname or parsed_url.netloc or "") @staticmethod def _page_title(soup: BeautifulSoup) -> str: """The page's own title,