Files
replypier/backend/app/knowledge/enhanced_website_reader.py
T
WangShen007 20fef2b9f3 chore: settle product naming across app, widget and deployments
One word everywhere: ReplyPier in UI copy, package and image names, the
replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the
--rp-* design tokens.

- public widget API is window.ReplyPier with replypierId / replypierBaseUrl /
  replypierConfig / replypierToken, loader at webclient/replypier.min.js;
  both bundles rebuilt from source
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime
  config rewritten; the demo config ships no third-party keys
- migration adds the ReplyPier managed model value to the AI model enum
- local compose project, database, role, volume and Docker network carry the
  same name
2026-10-26 00:57:22 +08:00

1333 lines
64 KiB
Python

"""
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, <h1> first and <title> second.
Must run BEFORE _clean_soup, which strips <head> and takes <title> with
it — a page with no <h1> would otherwise have no title to fall back to.
"""
heading = soup.find('h1')
if heading:
title = heading.get_text(" ", strip=True)
if title:
return title
tag = soup.find('title')
return tag.get_text(strip=True) if tag else ""
@staticmethod
def _prepend_title(title: str, content: str) -> str:
"""Keep the title when selection left it outside the content container.
Mintlify and similar docs themes put the <h1> in a page <header>, which
selection correctly treats as chrome — but the title is the strongest
retrieval signal a page has, so dropping it makes the page harder for an
agent to find rather than merely shorter.
"""
if title and title not in content:
return f"{title}\n\n{content}"
return content
def _extract_main_content(self, soup: BeautifulSoup) -> str:
"""
Extracts the main content from a BeautifulSoup object.
Selection is shared with the help-center importer (see main_content.py):
the deepest container that still holds essentially the whole page. If it
yields too little text the page is thin or JavaScript-rendered, and the
progressively looser fallbacks below get a turn — falling through here is
also what routes such a page to the browser fallback in _process_url.
:param soup: The BeautifulSoup object to extract the main content from.
:return: The main content as a string.
"""
# Read the title before cleaning: _clean_soup strips <head>.
page_title = self._page_title(soup)
# Remove undesirable elements first
self._clean_soup(soup)
main_node = select_main_node(soup, min_chars=self.min_content_length)
if main_node is None:
# Nothing but navigation chrome. The looser fallbacks below would
# happily return that chrome as the page's content — which is how
# www.solcontrol.ca came to store "Welcome, Guest - Login" and, worse,
# cleared the floor and so never reached the browser fallback that can
# actually render it. Report empty and let _process_url try Crawl4AI.
logger.warning("No readable content on the page (navigation chrome only)")
return ""
content = self._get_clean_text(main_node, include_links=True, base_url=self._current_url)
if len(content) >= self.min_content_length:
content = self._prepend_title(page_title, content)
logger.info(f"✓ Content extracted from <{main_node.name}> ({len(content)} chars)")
return content
logger.debug(f" Main content node too short: {len(content)} chars")
# Fallback 1: Density-based content extraction
logger.debug(f"Trying fallback: text density")
density_content = self._extract_by_text_density(soup)
if density_content and len(density_content) >= self.min_content_length:
logger.info(f"✓ Content extracted using density strategy ({len(density_content)} chars)")
return density_content
# Fallback 2: Collect all meaningful text elements (headings, paragraphs, lists, etc.)
logger.debug(f"Trying fallback: all meaningful content (tables, headings, paragraphs, lists)")
meaningful_content = self._extract_all_meaningful_content(soup)
if meaningful_content and len(meaningful_content) >= self.min_content_length:
logger.debug(f"✓ Content extracted using meaningful content strategy ({len(meaningful_content)} chars)")
return meaningful_content
else:
logger.debug(f" Meaningful content too short: {len(meaningful_content) if meaningful_content else 0} chars")
# Fallback 3: Entire body content with minimal cleaning
logger.debug(f"Trying fallback: body")
body = soup.find('body')
if body:
content = self._get_clean_text(body, include_links=True, base_url=self._current_url)
if content:
logger.info(f"✓ Content extracted using body fallback strategy (length: {len(content)})")
return content
else:
logger.warning(f"Body element found but no text extracted")
else:
logger.warning(f"No body element found in HTML")
# Last resort: just get all text from the document
logger.debug(f"Trying last resort: all text from document")
content = soup.get_text(strip=True, separator=" ")
if content:
logger.info(f"✓ Content extracted using last resort strategy (length: {len(content)})")
else:
logger.error(f"Failed to extract any text from HTML document")
return content
def _clean_soup(self, soup: BeautifulSoup) -> None:
"""
Removes undesirable elements from the soup.
IMPORTANT: Only remove elements that are truly undesirable (scripts, styles, etc.)
Keep structural elements like nav, footer, header, aside as they may contain useful content.
:param soup: The BeautifulSoup object to clean.
"""
removed_count = 0
# Remove comments
for comment in soup.find_all(string=lambda text: isinstance(text, str) and text.strip().startswith('<!--')):
comment.extract()
removed_count += 1
# Only remove truly problematic tags (scripts, styles, iframes)
# DO NOT remove structural tags like nav, footer, header, aside
minimal_blacklist = ['script', 'style', 'noscript', 'iframe', 'head']
for tag in minimal_blacklist:
elements = soup.find_all(tag)
removed_count += len(elements)
for element in elements:
element.extract()
# Remove hidden elements with style attribute (but be careful)
hidden_elements = soup.find_all(style=re.compile(r'display:\s*none|visibility:\s*hidden'))
removed_count += len(hidden_elements)
for element in hidden_elements:
element.extract()
# Remove elements with hidden or screen-reader-text class
for class_name in ['hidden', 'screen-reader-text']:
hidden_class_elements = soup.find_all(class_=class_name)
removed_count += len(hidden_class_elements)
for element in hidden_class_elements:
element.extract()
# Only remove obvious ad/tracking elements
for class_name in ['ads', 'advertisement', 'cookie']:
elements = soup.find_all(class_=re.compile(class_name, re.IGNORECASE))
removed_count += len(elements)
for element in elements:
element.extract()
# Remove comment sections - these often contain user comments that aren't main content
# Look for common comment container patterns
comment_selectors = [
{'id': re.compile(r'comment', re.IGNORECASE)},
{'class_': re.compile(r'comment', re.IGNORECASE)},
{'id': 'respond'},
{'class_': 'comment-respond'},
{'class_': 'comment-form'},
{'class_': 'comment-list'},
{'class_': 'comment-body'},
{'class_': 'comments-area'},
]
for selector in comment_selectors:
comment_elements = soup.find_all(**selector)
if comment_elements:
logger.debug(f" Removing {len(comment_elements)} comment elements matching {selector}")
removed_count += len(comment_elements)
for element in comment_elements:
element.extract()
# Remove navigation menus - these are not main content
nav_selectors = [
'nav',
{'role': 'navigation'},
{'class_': re.compile(r'navigation|menu|nav-', re.IGNORECASE)},
{'id': re.compile(r'navigation|menu|nav-', re.IGNORECASE)},
]
for selector in nav_selectors:
if isinstance(selector, str):
nav_elements = soup.find_all(selector)
else:
nav_elements = soup.find_all(**selector)
if nav_elements:
logger.debug(f" Removing {len(nav_elements)} navigation elements matching {selector}")
removed_count += len(nav_elements)
for element in nav_elements:
element.extract()
# Remove sidebars and widgets - often contain non-essential content
sidebar_selectors = [
{'id': re.compile(r'sidebar|widget', re.IGNORECASE)},
{'class_': re.compile(r'sidebar|widget', re.IGNORECASE)},
]
for selector in sidebar_selectors:
sidebar_elements = soup.find_all(**selector)
if sidebar_elements:
logger.debug(f" Removing {len(sidebar_elements)} sidebar/widget elements matching {selector}")
removed_count += len(sidebar_elements)
for element in sidebar_elements:
element.extract()
logger.debug(f"Cleaned soup: removed {removed_count} elements (minimal cleaning mode)")
def _get_clean_text(self, element: Tag, include_links: bool = True, base_url: str = None) -> str:
"""
Gets clean text from a BeautifulSoup element, optionally including URLs.
:param element: The BeautifulSoup element to get text from.
:param include_links: If True, appends URLs after link text in format [text](url)
:param base_url: Base URL for converting relative URLs to absolute URLs
:return: The clean text.
"""
if not element:
return ""
if include_links:
# Create a deep copy to avoid modifying the original
element_copy = deepcopy(element)
# Find all links and append their URLs to the text
for link in element_copy.find_all('a', href=True):
href = link.get('href', '')
# Skip anchor links and javascript links
if href and not href.startswith('#') and not href.startswith('javascript:'):
# Convert relative URLs to absolute URLs if base_url is provided
if base_url and not href.startswith(('http://', 'https://', 'mailto:')):
href = urljoin(base_url, href)
# Get the link text
link_text = link.get_text(strip=True)
if link_text: # Only add URL if there's text
# Create a new text node with the URL appended
# Format: "text (URL: https://...)"
new_text = f"{link_text} (URL: {href})"
link.string = new_text
# Get text with preserved whitespace
text = element_copy.get_text(separator=" ", strip=True)
else:
# Get text with preserved whitespace
text = element.get_text(separator=" ", strip=True)
# Normalize whitespace
text = re.sub(r'\s+', ' ', text)
# Remove excessive newlines
text = re.sub(r'\n{2,}', '\n', text)
return text.strip()
def _extract_all_meaningful_content(self, soup: BeautifulSoup) -> str:
"""
Extracts all meaningful content from the page (headings, paragraphs, lists, tables, etc.).
This is useful for pages that don't have clear content containers.
:param soup: The BeautifulSoup object to extract content from.
:return: The extracted meaningful content.
"""
# Focus on block-level content elements to avoid duplication from nested inline elements
meaningful_tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'blockquote', 'pre']
content_parts = []
tag_counts = {}
for tag in meaningful_tags:
elements = soup.find_all(tag)
count = 0
for element in elements:
text = element.get_text(strip=True)
# Only include text that's substantial (more than 10 characters)
if len(text) > 10:
content_parts.append(text)
count += 1
if count > 0:
tag_counts[tag] = count
logger.debug(f" Meaningful elements found: {tag_counts}")
logger.debug(f" Total text parts collected: {len(content_parts)}")
if not content_parts:
return ""
# Join all parts with space and clean up
full_content = " ".join(content_parts)
full_content = re.sub(r'\s+', ' ', full_content)
return full_content.strip()
def _extract_by_text_density(self, soup: BeautifulSoup) -> str:
"""
Extracts content based on text density (paragraphs with substantial text).
:param soup: The BeautifulSoup object to extract content from.
:return: The extracted content.
"""
paragraphs = soup.find_all('p')
if not paragraphs:
return ""
# Find paragraphs with substantial text
good_paragraphs = []
for p in paragraphs:
text = p.get_text(strip=True)
# Consider paragraphs with more than 20 characters
if len(text) > 20:
good_paragraphs.append(p)
if not good_paragraphs:
return ""
# Find the most common parent that contains a good number of paragraphs
parent_counts = {}
for p in good_paragraphs:
for parent in p.parents:
if parent.name not in ['html', 'body']:
parent_counts[parent] = parent_counts.get(parent, 0) + 1
# Get the parent with the most children
if parent_counts:
best_parent = max(parent_counts.items(), key=lambda x: x[1])[0]
return self._get_clean_text(best_parent, include_links=True, base_url=self._current_url)
# If no good parent found, just concatenate the good paragraphs
return " ".join([self._get_clean_text(p, include_links=True, base_url=self._current_url) for p in good_paragraphs])
def _process_url(self, url_info: Tuple[str, int], scope: CrawlScope) -> Optional[Tuple[str, str, List[str]]]:
"""
Process a single URL - fetch content and extract links.
This is used for parallel processing of URLs.
:param url_info: Tuple of (URL, depth)
:param scope: Crawl boundary the URL must fall inside
:return: Tuple of (URL, content, new_links) or None if failed
"""
current_url, current_depth = url_info
# Normalize URL to ensure it has a protocol
current_url = self._normalize_url(current_url)
# Skip if URL meets any skip conditions
if current_url in self._visited:
return None
# In-scope http(s) URLs only — the per-URL guard that also protects
# sitemap-seeded crawls and anything left over in an older queue.
if not scope.allows(current_url):
logger.debug(f"Skipping out-of-scope URL {current_url} ({scope.describe()})")
return None
if current_depth > self.max_depth:
return None
# Mark as visited before processing
self._visited.add(current_url)
self.delay()
# Increment crawled pages counter
self._crawled_pages_count += 1
page_start_time = time.time()
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Crawling page {self._crawled_pages_count}: {current_url} (depth: {current_depth})")
# Try to get content from URL with retries
content = None
new_links = []
retry_count = 0
last_error = None
last_cause = None
browser_can_help = True
is_javascript_heavy = False
soup = None
while retry_count < self.max_retries and content is None:
try:
logger.info(f"Attempt {retry_count + 1}/{self.max_retries} to fetch {current_url}")
# Warn if SSL verification is disabled
if not self.verify_ssl and retry_count == 0:
logger.warning(f"⚠️ SSL verification is disabled for {current_url} - This may pose security risks")
# Make the request
with httpx.Client(
timeout=self.timeout,
follow_redirects=True,
headers=self.headers,
verify=self.verify_ssl
) as client:
logger.debug(f"Making HTTP GET request to {current_url}")
# Follows redirects manually, re-validating each hop's host
# (SSRF), and reads the body only if it is a page.
html = fetch_page_text(client, current_url)
# Parse HTML with BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
logger.info(f"HTML length: {len(html)} characters")
# Check if the page is JavaScript-heavy
# Modern sites use many external scripts, so count both inline and external
script_tags = soup.find_all('script')
inline_script_length = sum(len(str(script)) for script in script_tags)
external_scripts = [script for script in script_tags if script.get('src')]
logger.info(f"Found {len(script_tags)} script tags ({len(external_scripts)} external, {len(script_tags) - len(external_scripts)} inline), inline content: {inline_script_length} characters")
# Log page structure for debugging
body = soup.find('body')
if body:
body_children = list(body.children)
logger.info(f"Body found with {len(body_children)} direct children")
# Log child element types
child_types = {}
for child in body_children:
if hasattr(child, 'name') and child.name:
child_types[child.name] = child_types.get(child.name, 0) + 1
logger.info(f"Body children types: {child_types}")
else:
logger.warning(f"No body tag found in HTML for {current_url}")
# Detect JavaScript-heavy pages using multiple criteria:
# 1. High ratio of inline script content (>30% of HTML)
# 2. Many external script files (>15 scripts typically indicates a JS framework)
# 3. High script tag density (>10 scripts per 10KB of HTML)
script_content_ratio = inline_script_length / len(html) if html else 0
html_size_kb = len(html) / 10000 # Convert to 10KB units
script_density = len(script_tags) / max(html_size_kb, 1) # Scripts per 10KB
is_javascript_heavy = (
script_content_ratio > 0.3 or # >30% inline scripts
len(external_scripts) >= 15 or # Many external scripts (React/Vue apps)
script_density > 10 # High density of scripts
)
if is_javascript_heavy:
detection_reason = []
if script_content_ratio > 0.3:
detection_reason.append(f"high inline script ratio ({script_content_ratio*100:.1f}%)")
if len(external_scripts) >= 15:
detection_reason.append(f"many external scripts ({len(external_scripts)})")
if script_density > 10:
detection_reason.append(f"high script density ({script_density:.1f} per 10KB)")
logger.warning(f"⚠️ JavaScript-heavy website detected: {', '.join(detection_reason)}")
# Set current URL for link resolution
self._current_url = current_url
content = self._extract_main_content(soup)
# Log extracted content length for debugging
logger.info(f"Extracted content length: {len(content) if content else 0} characters from {current_url}")
# A bot-challenge interstitial (wp.com / Cloudflare "Checking your
# browser…") is short enough to pass the length check, so detect it
# explicitly and route it through the JS browser, which can clear it.
is_challenge = self._looks_like_bot_challenge(content)
if is_challenge:
logger.warning(f"🛡️ Bot-challenge interstitial detected for {current_url} - routing through Crawl4AI")
# For JavaScript-heavy sites (or a detected challenge), try Crawl4AI
# for real content. Even static extraction may just be interstitials.
if is_javascript_heavy or is_challenge:
logger.info(f"🔄 Attempting Crawl4AI for better content extraction: {current_url}")
crawl4ai = get_crawl4ai_fallback(timeout=self.timeout, verify_ssl=self.verify_ssl)
if crawl4ai.is_available:
logger.info(f"🔄 Using Crawl4AI for JavaScript rendering: {current_url}")
# Note: fetch_with_browser returns (content, soup, screenshot)
crawl4ai_content, crawl4ai_soup, _ = crawl4ai.fetch_with_browser(current_url, take_screenshot=False)
if crawl4ai_content and len(crawl4ai_content) >= self.min_content_length:
logger.info(f"✓ Crawl4AI extracted {len(crawl4ai_content)} chars (vs {len(content) if content else 0} from BeautifulSoup)")
content = crawl4ai_content
soup = crawl4ai_soup if crawl4ai_soup else soup
elif crawl4ai_soup:
# Try to extract from crawl4ai soup
self._current_url = current_url
crawl4ai_extracted = self._extract_main_content(crawl4ai_soup)
if crawl4ai_extracted and len(crawl4ai_extracted) >= self.min_content_length:
logger.info(f"✓ Extracted {len(crawl4ai_extracted)} chars from Crawl4AI HTML")
content = crawl4ai_extracted
soup = crawl4ai_soup
else:
logger.warning(f"Crawl4AI didn't improve content extraction, keeping BeautifulSoup result")
else:
logger.warning(f"Crawl4AI extraction failed, keeping BeautifulSoup result")
else:
logger.warning(f"⚠️ Crawl4AI not available for JS-heavy site. Install with: pip install crawl4ai>=0.9")
logger.warning(f" Falling back to BeautifulSoup (may have incomplete content)")
# If the content is still a bot-challenge interstitial (the browser
# fallback couldn't clear it or wasn't available), skip the page
# rather than storing the "Checking your browser…" text as content.
if self._looks_like_bot_challenge(content):
logger.warning(f"⚠️ Skipping {current_url}: bot-challenge interstitial could not be cleared")
self._failed_crawls += 1
self._challenge_blocked += 1
self._record_failure(FailureCause.BOT_BLOCKED)
return None
# Check content quality
if not content or len(content) < self.min_content_length:
logger.warning(f"Content too short or empty ({len(content) if content else 0} chars) for {current_url}. Min required: {self.min_content_length}")
if content:
logger.warning(f"Content preview: {content[:200]}...")
else:
logger.warning(f"Content is None or empty. HTML preview: {html[:500]}...")
# Try to extract any text as last resort
fallback_content = soup.get_text(strip=True, separator=" ")
if fallback_content and len(fallback_content) >= self.min_content_length:
logger.info(f"Using fallback extraction: {len(fallback_content)} characters")
content = fallback_content
else:
logger.warning(f"Standard extraction failed. Fallback content: {len(fallback_content) if fallback_content else 0} chars")
# Try Crawl4AI fallback whenever content extraction fails
# (regardless of whether page is JavaScript-heavy or not)
crawl4ai = get_crawl4ai_fallback(timeout=self.timeout, verify_ssl=self.verify_ssl)
if crawl4ai.is_available:
# Log reason for using Crawl4AI
if is_javascript_heavy:
logger.info(f"🔄 Attempting Crawl4AI fallback - JavaScript-heavy page detected: {current_url}")
else:
logger.info(f"🔄 Attempting Crawl4AI fallback - Standard extraction failed: {current_url}")
# Note: fetch_with_browser returns (content, soup, screenshot)
# We don't need screenshot for backend crawling, so ignore it
crawl4ai_content, crawl4ai_soup, _ = crawl4ai.fetch_with_browser(current_url, take_screenshot=False)
if crawl4ai_content and len(crawl4ai_content) >= self.min_content_length:
logger.info(f"✓ Crawl4AI successfully extracted {len(crawl4ai_content)} chars")
content = crawl4ai_content
soup = crawl4ai_soup if crawl4ai_soup else soup
elif crawl4ai_soup:
# Try to extract from crawl4ai soup
self._current_url = current_url
content = self._extract_main_content(crawl4ai_soup)
if content and len(content) >= self.min_content_length:
logger.info(f"✓ Extracted {len(content)} chars from Crawl4AI HTML")
soup = crawl4ai_soup
else:
logger.error(f"Crawl4AI extraction also failed for {current_url}")
else:
logger.error(f"Crawl4AI fallback failed for {current_url}")
else:
logger.warning(f"⚠️ Crawl4AI not available. Install with: pip install crawl4ai>=0.9")
# Final check
if not content or len(content) < self.min_content_length:
logger.error(f"All extraction strategies failed for {current_url}. Final content: {len(content) if content else 0} chars")
self._failed_crawls += 1
self._record_failure(
FailureCause.EMPTY_RESPONSE if not html
else FailureCause.THIN_CONTENT
)
return None
self._successful_crawls += 1
logger.info(f"✓ Successfully extracted {len(content)} chars from {current_url}")
# Extract new links if not at max depth
if current_depth < self.max_depth:
links = self._extract_links(soup, current_url, scope)
next_depth = current_depth + 1
new_links = [(link, next_depth) for link in links
if link not in self._visited]
except NotAPage as e:
# A download the page linked to (an installer, an archive): not
# content, and not the site failing. Never worth the browser.
logger.info(f"Skipping {current_url}: {e}")
if e.download and current_depth == 1:
# The address the user gave (or a sitemap listed): if
# nothing else is read, this is why.
self._record_failure(FailureCause.NOT_A_PAGE)
return None
except BlockedHostError as e:
# A redirect pointed at an internal host — abort, don't retry or
# fall back to the browser (which would also reach it).
logger.warning(str(e))
self._failed_crawls += 1
self._record_failure(FailureCause.BLOCKED_HOST)
return None
except httpx.HTTPStatusError as e:
retry_count += 1
status_code = e.response.status_code
last_error = f"HTTP {status_code}: {e.response.reason_phrase}"
logger.warning(f"HTTP error on attempt {retry_count}/{self.max_retries} for {current_url}: {last_error}")
# 401/403/429 are the shapes bot protection takes; they need the
# "add it manually" advice rather than "check the URL".
last_cause = FailureCause.BOT_BLOCKED if status_code in (401, 403, 429) else FailureCause.HTTP_ERROR
# Special handling for 403 Forbidden (bot detection)
if status_code == 403:
logger.warning(f"⚠️ 403 Forbidden - Server may be blocking bot requests")
# Don't retry httpx for 403, go straight to Crawl4AI fallback
retry_count = self.max_retries
elif retry_count < self.max_retries:
# Exponential backoff for other errors
sleep_time = 2 ** retry_count
logger.info(f"Retrying in {sleep_time} seconds...")
time.sleep(sleep_time)
except Exception as e:
retry_count += 1
last_cause, retry_is_useful, browser_may_help = self._classify_transport_error(e)
browser_can_help = browser_may_help
if isinstance(e, httpx.TimeoutException):
last_error = f"Request timeout after {self.timeout}s"
logger.warning(f"Timeout on attempt {retry_count}/{self.max_retries} for {current_url}")
else:
last_error = f"{type(e).__name__}: {str(e)}"
logger.error(
f"Error on attempt {retry_count}/{self.max_retries} for {current_url}: {last_error}",
exc_info=True,
)
if not retry_is_useful:
logger.info(f"Not retrying {current_url}: {last_cause} fails the same way every attempt")
break
# Exponential backoff
if retry_count < self.max_retries:
sleep_time = 2 ** retry_count
logger.info(f"Retrying in {sleep_time} seconds...")
time.sleep(sleep_time)
# If all retries failed, try Crawl4AI fallback as last resort — but not
# when we never reached the host: Chromium shares our resolver and TCP
# stack, so it fails the same way after another browser launch.
if content is None and last_error and not browser_can_help:
self._failed_crawls += 1
self._record_failure(last_cause or FailureCause.CONNECTION)
logger.error(f"❌ {current_url} is unreachable ({last_error}); skipping the browser fallback")
return None
if content is None and last_error:
logger.warning(f"⚠️ All extraction attempts failed. Last error: {last_error}")
# Try Crawl4AI fallback for HTTP errors (403, 429, etc.) and other failures
crawl4ai = get_crawl4ai_fallback(timeout=self.timeout, verify_ssl=self.verify_ssl)
if crawl4ai.is_available:
logger.info(f"🔄 Attempting Crawl4AI fallback after HTTP/extraction failure: {current_url}")
logger.info(f" Reason: {last_error}")
try:
# Note: fetch_with_browser returns (content, soup, screenshot)
# We don't need screenshot for backend crawling, so ignore it
crawl4ai_content, crawl4ai_soup, _ = crawl4ai.fetch_with_browser(current_url, take_screenshot=False)
if crawl4ai_content and len(crawl4ai_content) >= self.min_content_length:
logger.info(f"✓ Crawl4AI successfully bypassed error and extracted {len(crawl4ai_content)} chars")
content = crawl4ai_content
soup = crawl4ai_soup if crawl4ai_soup else soup
# Extract links if we got content
if current_depth < self.max_depth and soup:
links = self._extract_links(soup, current_url, scope)
next_depth = current_depth + 1
new_links = [(link, next_depth) for link in links
if link not in self._visited]
self._successful_crawls += 1
page_end_time = time.time()
page_duration = page_end_time - page_start_time
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Completed crawling page {self._crawled_pages_count} with Crawl4AI (Time taken: {page_duration:.2f}s)")
return (current_url, content, new_links)
elif crawl4ai_soup:
# Try to extract from crawl4ai soup
self._current_url = current_url
content = self._extract_main_content(crawl4ai_soup)
if content and len(content) >= self.min_content_length:
logger.info(f"✓ Extracted {len(content)} chars from Crawl4AI HTML")
soup = crawl4ai_soup
# Extract links
if current_depth < self.max_depth:
links = self._extract_links(soup, current_url, scope)
next_depth = current_depth + 1
new_links = [(link, next_depth) for link in links
if link not in self._visited]
self._successful_crawls += 1
page_end_time = time.time()
page_duration = page_end_time - page_start_time
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Completed crawling page {self._crawled_pages_count} with Crawl4AI (Time taken: {page_duration:.2f}s)")
return (current_url, content, new_links)
logger.error(f"Crawl4AI also failed to extract content from {current_url}")
except Exception as crawl_error:
logger.error(f"Crawl4AI fallback error: {str(crawl_error)}", exc_info=True)
else:
logger.warning(f"⚠️ Crawl4AI not available to retry failed request. Install with: pip install crawl4ai>=0.9")
# Final failure
self._failed_crawls += 1
self._record_failure(last_cause or FailureCause.CONNECTION)
logger.error(f"❌ Failed to extract content from {current_url} after all attempts. Last error: {last_error}")
return None
page_end_time = time.time()
page_duration = page_end_time - page_start_time
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Completed crawling page {self._crawled_pages_count} (Time taken: {page_duration:.2f}s)")
return (current_url, content, new_links)
def crawl(self, url: str, starting_depth: int = 1, on_document_callback: Optional[Callable[[str, str], None]] = None, on_url_crawled_callback: Optional[Callable[[str], None]] = None) -> Dict[str, str]:
"""
Enhanced crawl method with parallel processing and immediate vector DB insertion.
:param url: The URL to crawl.
:param starting_depth: The starting depth level for the crawl.
:param on_document_callback: Callback function that receives (url, content) for immediate processing
:param on_url_crawled_callback: Callback function that receives (url) when a page is successfully crawled
:return: Dictionary of URLs and their corresponding content.
"""
# Normalize URL to ensure it has a protocol
url = self._normalize_url(url)
# A www host with no address of its own falls back to the apex.
url = self._seed_with_resolvable_host(url)
# Reset visited and urls_to_crawl for fresh crawl
self._visited = set()
self._urls_to_crawl = []
# Reset crawling statistics
self._crawled_pages_count = 0
self._successful_crawls = 0
self._failed_crawls = 0
self._challenge_blocked = 0
self._failure_causes = {}
crawl_start_time = time.time()
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Starting parallel crawl of {url} with max_depth={self.max_depth}, max_links={self.max_links}, and max_workers={self.max_workers}")
# Bound the crawl to the seed's own host by default — see crawl_scope.py.
scope = CrawlScope.for_seed(url, self.crawl_scope)
logger.info(f"Crawl scope for {url}: {scope.describe()}")
# Add starting URL with its depth to the queue
urls_to_process = [(url, starting_depth)]
logger.info(f"Added starting URL to crawl queue: {url} (depth: {starting_depth})")
crawler_result = self._run_crawl_queue(
urls_to_process, scope, on_document_callback, on_url_crawled_callback
)
# Log crawling summary
crawl_end_time = time.time()
crawl_duration = crawl_end_time - crawl_start_time
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Parallel crawl completed: {len(crawler_result)} pages crawled successfully")
logger.info(f"Crawling statistics: Total: {self._crawled_pages_count}, Successful: {self._successful_crawls}, Failed: {self._failed_crawls}")
logger.info(f"Total crawling time: {crawl_duration:.2f}s, Average time per page: {crawl_duration/max(1, self._crawled_pages_count):.2f}s")
return crawler_result
def _run_crawl_queue(
self,
urls_to_process: List[Tuple[str, int]],
scope: CrawlScope,
on_document_callback: Optional[Callable[[str, str], None]] = None,
on_url_crawled_callback: Optional[Callable[[str], None]] = None,
) -> Dict[str, str]:
"""Process a seed queue of (url, depth) in parallel until it drains or the
``max_links`` cap is hit, extracting content per URL and enqueuing any new
links a page yields. Shared by website crawling (seeds one URL, follows
links) and sitemap crawling (seeds the listed pages, which yield no links).
"""
crawler_result: Dict[str, str] = {}
urls_to_process = list(urls_to_process)
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Process URLs in batches until no more URLs or max_links reached
while urls_to_process and len(crawler_result) < self.max_links:
if self.out_of_time:
logger.warning(f"Out of time: stopping with {len(urls_to_process)} link(s) not read")
break
# Take a batch of URLs to process
batch_size = min(self.max_workers, len(urls_to_process))
current_batch = urls_to_process[:batch_size]
urls_to_process = urls_to_process[batch_size:]
# Submit all URLs in the current batch for parallel processing
future_to_url = {
executor.submit(self._process_url, url_info, scope): url_info[0]
for url_info in current_batch
}
# Process completed futures as they finish
for future in as_completed(future_to_url):
url = future_to_url[future]
try:
result = future.result()
if result:
processed_url, content, new_links = result
# Add the content to our results
crawler_result[processed_url] = content
# Call the URL crawled callback first (for progress tracking)
if on_url_crawled_callback:
logger.debug(f"📞 Calling URL crawled callback for: {processed_url}")
on_url_crawled_callback(processed_url)
# Call the callback for immediate processing if provided
if on_document_callback:
on_document_callback(processed_url, content)
# Add new links to the processing queue
for new_link, depth in new_links:
if new_link not in self._visited and (new_link, depth) not in urls_to_process:
urls_to_process.append((new_link, depth))
# If we've reached max_links, break early
if len(crawler_result) >= self.max_links:
logger.info(f"Reached maximum number of links ({self.max_links}), stopping further crawling")
break
except Exception as exc:
logger.error(f"URL {url} generated an exception: {exc}")
self._failed_crawls += 1
return crawler_result
def _extract_links(
self, soup: BeautifulSoup, base_url: str, scope: Optional[CrawlScope] = None
) -> List[str]:
"""
Extract links from a BeautifulSoup object.
:param soup: The BeautifulSoup object.
:param base_url: The base URL to resolve relative URLs.
:param scope: Crawl boundary links must fall inside; defaults to this
reader's configured scope seeded at ``base_url``.
:return: A list of absolute URLs.
"""
links = []
seen = set()
if scope is None:
scope = CrawlScope.for_seed(base_url, self.crawl_scope)
base_canonical = scope.canonical(self._canonical_url(base_url))
all_links = soup.find_all("a", href=True)
for link in all_links:
if not isinstance(link, Tag):
continue
href_str = str(link["href"])
# Drop mailto:/tel:/sms:/javascript:/data: hrefs before urljoin —
# it passes them through untouched, and they are not pages.
if not is_crawlable_url(href_str):
continue
full_url = urljoin(base_url, href_str)
if not isinstance(full_url, str):
continue
# Skip query-string URLs before canonicalizing (kept for simplicity).
if "?" in full_url:
continue
# Canonicalize (drop #fragment / trailing slash, put the seed's own
# www/bare spelling on the host) so page variants collapse to a
# single link and don't get crawled/stored twice.
full_url = scope.canonical(self._canonical_url(full_url))
# Filter out unwanted URLs
parsed_url = urlparse(full_url)
# Ignore self-links and already-collected duplicates
if full_url == base_canonical or full_url in seen:
continue
if (
scope.allows(full_url)
and not any(parsed_url.path.endswith(ext) for ext in [
".pdf", ".jpg", ".png", ".gif", ".zip", ".mp3", ".mp4", ".exe", ".dll"
])
and not parsed_url.path.startswith("#") # Skip anchors
):
seen.add(full_url)
links.append(full_url)
logger.info(f"Extracted {len(links)} valid links from {base_url}")
return links
def _create_document_from_content(self, page_url: str, content: str, source_url: str, index: int) -> Document:
"""
Create a Document object from page content with proper metadata.
Optionally summarizes content to reduce token usage.
:param page_url: The URL of the page
:param content: The page content
:param source_url: Original source URL
:param index: The document index
:return: Document object
"""
# Extract page identifier without protocol and domain for ID
parsed_url = urlparse(page_url)
path = parsed_url.path
# Handle fragment identifiers (#section)
fragment = parsed_url.fragment
if fragment:
path = f"{path}#{fragment}"
# Clean up path for ID generation
if not path or path == "/":
path = "index"
else:
# Remove leading/trailing slashes and replace special chars
path = path.strip("/")
path = re.sub(r'[^\w\-]', '_', path)
# Summarize content if enabled to reduce token usage
original_length = len(content)
try:
summarizer = get_content_summarizer()
content = summarizer.summarize(content, page_url)
if len(content) < original_length:
logger.info(f"Content summarized: {original_length} -> {len(content)} chars ({100*len(content)/original_length:.1f}%)")
except Exception as e:
logger.error(f"Error during content summarization: {str(e)}")
# Continue with original content if summarization fails
# Create metadata with relevant information
metadata = {
"url": page_url,
"chunk": index,
"chunk_size": len(content),
"original_size": original_length
}
# Create document with content and metadata
document = Document(
content=content,
meta_data=metadata
)
# Set document ID and name
document.id = page_url
# Set name to full page URL for matching with knowledge_queue
document.name = source_url
return document
def read(self, url: str, vector_db_callback: Optional[Callable[[Document], None]] = None, url_crawled_callback: Optional[Callable[[str], None]] = None) -> List[Document]:
"""
Read content from a URL, crawl related pages, and convert the content into Documents.
Optionally sends documents to vector DB as they are created.
:param url: The URL to read from.
:param vector_db_callback: Optional callback to send documents to vector DB as they're created
:param url_crawled_callback: Optional callback called when each URL is successfully crawled
:return: A list of Document objects.
"""
# Normalize URL to ensure it has a protocol
url = self._normalize_url(url)
# Get timestamp for tracking
start_time = time.time()
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Starting to read from {url} with parallel processing")
documents = []
# Create a callback for immediate document processing. A page becomes
# one document per chunk (see app.knowledge.chunking); each is queued
# for embedding on its own so the whole page is searchable.
def on_document_created(page_url: str, content: str):
index = len(documents) + 1
page = self._create_document_from_content(page_url, content, url, index)
for document in chunk_page(page):
documents.append(document)
# Call vector DB callback if provided
if vector_db_callback:
try:
vector_db_callback(document)
logger.info(f"✓ Document {document.id} successfully sent to vector DB")
except Exception as e:
logger.error(f"Error sending document {document.id} to vector DB: {str(e)}")
# Crawl website with the callback for immediate document processing
self.crawl(url, on_document_callback=on_document_created, on_url_crawled_callback=url_crawled_callback)
end_time = time.time()
duration = end_time - start_time
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Completed reading from {url} - Created {len(documents)} documents (Total time: {duration:.2f}s)")
return documents