| """ |
| Single Page Crawling Strategy |
| |
| Handles crawling of individual web pages. |
| """ |
|
|
| import asyncio |
| import traceback |
| from collections.abc import Awaitable, Callable |
| from typing import Any |
|
|
| from crawl4ai import CacheMode, CrawlerRunConfig |
|
|
| from ....config.logfire_config import get_logger |
|
|
| logger = get_logger(__name__) |
|
|
|
|
| class SinglePageCrawlStrategy: |
| """Strategy for crawling a single web page.""" |
|
|
| def __init__(self, crawler, markdown_generator): |
| """ |
| Initialize single page crawl strategy. |
| |
| Args: |
| crawler (AsyncWebCrawler): The Crawl4AI crawler instance for web crawling operations |
| markdown_generator (DefaultMarkdownGenerator): The markdown generator instance for converting HTML to markdown |
| """ |
| self.crawler = crawler |
| self.markdown_generator = markdown_generator |
|
|
| def _get_wait_selector_for_docs(self, url: str) -> str: |
| """Get appropriate wait selector based on documentation framework.""" |
| url_lower = url.lower() |
|
|
| |
| if "docusaurus" in url_lower: |
| return ".markdown, .theme-doc-markdown, article" |
| elif "vitepress" in url_lower: |
| return ".VPDoc, .vp-doc, .content" |
| elif "gitbook" in url_lower: |
| return ".markdown-section, .page-wrapper" |
| elif "mkdocs" in url_lower: |
| return ".md-content, article" |
| elif "docsify" in url_lower: |
| return "#main, .markdown-section" |
| elif "copilotkit" in url_lower: |
| |
| return 'div[class*="content"], div[class*="doc"], #__next' |
| elif "milkdown" in url_lower: |
| |
| return 'main, article, .prose, [class*="content"]' |
| else: |
| |
| return "body" |
|
|
| async def crawl_single_page( |
| self, |
| url: str, |
| transform_url_func: Callable[[str], str], |
| is_documentation_site_func: Callable[[str], bool], |
| retry_count: int = 3, |
| ) -> dict[str, Any]: |
| """ |
| Crawl a single web page and return the result with retry logic. |
| |
| Args: |
| url: URL of the web page to crawl |
| transform_url_func: Function to transform URLs (e.g., GitHub URLs) |
| is_documentation_site_func: Function to check if URL is a documentation site |
| retry_count: Number of retry attempts |
| |
| Returns: |
| Dict with success status, content, and metadata |
| """ |
| |
| original_url = url |
| url = transform_url_func(url) |
|
|
| last_error = None |
|
|
| for attempt in range(retry_count): |
| try: |
| if not self.crawler: |
| logger.error(f"No crawler instance available for URL: {url}") |
| return { |
| "success": False, |
| "error": "No crawler instance available - crawler initialization may have failed", |
| } |
|
|
| |
| cache_mode = CacheMode.BYPASS if attempt > 0 else CacheMode.ENABLED |
|
|
| |
| is_doc_site = is_documentation_site_func(url) |
|
|
| |
| if is_doc_site: |
| wait_selector = self._get_wait_selector_for_docs(url) |
| logger.info(f"Detected documentation site, using wait selector: {wait_selector}") |
|
|
| crawl_config = CrawlerRunConfig( |
| cache_mode=cache_mode, |
| stream=True, |
| markdown_generator=self.markdown_generator, |
| |
| wait_for=wait_selector, |
| |
| wait_until="domcontentloaded", |
| |
| page_timeout=30000, |
| |
| delay_before_return_html=0.5, |
| |
| wait_for_images=False, |
| |
| scan_full_page=True, |
| |
| exclude_all_images=False, |
| |
| remove_overlay_elements=True, |
| |
| process_iframes=True, |
| ) |
| else: |
| |
| crawl_config = CrawlerRunConfig( |
| cache_mode=cache_mode, |
| stream=True, |
| markdown_generator=self.markdown_generator, |
| wait_until="domcontentloaded", |
| page_timeout=45000, |
| delay_before_return_html=0.3, |
| scan_full_page=True, |
| ) |
|
|
| logger.info(f"Crawling {url} (attempt {attempt + 1}/{retry_count})") |
| logger.info(f"Using wait_until: {crawl_config.wait_until}, page_timeout: {crawl_config.page_timeout}") |
|
|
| try: |
| result = await self.crawler.arun(url=url, config=crawl_config) |
| except Exception as e: |
| last_error = f"Crawler exception for {url}: {str(e)}" |
| logger.error(last_error) |
| if attempt < retry_count - 1: |
| await asyncio.sleep(2**attempt) |
| continue |
|
|
| if not result.success: |
| last_error = f"Failed to crawl {url}: {result.error_message}" |
| logger.warning(f"Crawl attempt {attempt + 1} failed: {last_error}") |
|
|
| |
| if attempt < retry_count - 1: |
| await asyncio.sleep(2**attempt) |
| continue |
|
|
| |
| if not result.markdown or len(result.markdown.strip()) < 50: |
| last_error = f"Insufficient content from {url}" |
| logger.warning(f"Crawl attempt {attempt + 1}: {last_error}") |
|
|
| if attempt < retry_count - 1: |
| await asyncio.sleep(2**attempt) |
| continue |
|
|
| |
| |
| markdown_sample = result.markdown[:1000] if result.markdown else "NO MARKDOWN" |
| has_triple_backticks = "```" in result.markdown if result.markdown else False |
| backtick_count = result.markdown.count("```") if result.markdown else 0 |
|
|
| logger.info( |
| f"Crawl result for {url} | has_markdown={bool(result.markdown)} | markdown_length={len(result.markdown) if result.markdown else 0} | has_triple_backticks={has_triple_backticks} | backtick_count={backtick_count}" |
| ) |
|
|
| |
| if backtick_count > 0: |
| logger.info(f"Markdown has {backtick_count} code blocks for {url}") |
|
|
| if "getting-started" in url: |
| logger.info(f"Markdown sample for getting-started: {markdown_sample}") |
|
|
| return { |
| "success": True, |
| "url": original_url, |
| "markdown": result.markdown, |
| "html": result.html, |
| "title": getattr(result, "metadata", {}).get("title") or "Untitled", |
| "links": result.links, |
| "content_length": len(result.markdown), |
| } |
|
|
| except TimeoutError: |
| last_error = f"Timeout crawling {url}" |
| logger.warning(f"Crawl attempt {attempt + 1} timed out") |
| except Exception as e: |
| last_error = f"Error crawling page: {str(e)}" |
| logger.error(f"Error on attempt {attempt + 1} crawling {url}: {e}") |
| logger.error(traceback.format_exc()) |
|
|
| |
| if attempt < retry_count - 1: |
| await asyncio.sleep(2**attempt) |
|
|
| |
| return {"success": False, "error": last_error or f"Failed to crawl {url} after {retry_count} attempts"} |
|
|
| async def crawl_markdown_file( |
| self, |
| url: str, |
| transform_url_func: Callable[[str], str], |
| progress_callback: Callable[..., Awaitable[None]] | None = None, |
| start_progress: int = 10, |
| end_progress: int = 20, |
| ) -> list[dict[str, Any]]: |
| """ |
| Crawl a .txt or markdown file with comprehensive error handling and progress reporting. |
| |
| Args: |
| url: URL of the text/markdown file |
| transform_url_func: Function to transform URLs (e.g., GitHub URLs) |
| progress_callback: Optional callback for progress updates |
| start_progress: Starting progress percentage |
| end_progress: Ending progress percentage |
| |
| Returns: |
| List containing the crawled document |
| """ |
| try: |
| |
| original_url = url |
| url = transform_url_func(url) |
| logger.info(f"Crawling markdown file: {url}") |
|
|
| |
| async def report_progress(progress: int, message: str, **kwargs): |
| """Helper to report progress if callback is available""" |
| if progress_callback: |
| await progress_callback("crawling", progress, message, **kwargs) |
|
|
| |
| await report_progress(start_progress, f"Fetching text file: {url}", total_pages=1, processed_pages=0) |
|
|
| |
| crawl_config = CrawlerRunConfig(cache_mode=CacheMode.ENABLED, stream=False) |
|
|
| result = await self.crawler.arun(url=url, config=crawl_config) |
| if result.success and result.markdown: |
| logger.info(f"Successfully crawled markdown file: {url}") |
|
|
| |
| await report_progress( |
| end_progress, f"Text file crawled successfully: {original_url}", total_pages=1, processed_pages=1 |
| ) |
|
|
| return [{"url": original_url, "markdown": result.markdown, "html": result.html}] |
| else: |
| logger.error(f"Failed to crawl {url}: {result.error_message}") |
| return [] |
| except Exception as e: |
| logger.error(f"Exception while crawling markdown file {url}: {e}") |
| logger.error(traceback.format_exc()) |
| return [] |
|
|