Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1a55d93
Implement priority-based automatic discovery of llms.txt and sitemap.…
leex279 Sep 8, 2025
43af7b7
fix: Update tests for single-file discovery and discovery stage integ…
leex279 Sep 8, 2025
d2adc15
fix: Address CodeRabbit critical issues for discovery service
leex279 Sep 8, 2025
77b0470
fix: Implement remaining CodeRabbit fixes for async and depth handling
leex279 Sep 8, 2025
8072066
Merge main into feature/automatic-discovery-llms-sitemap-430
leex279 Sep 20, 2025
2be44d1
fix: Resolve syntax error from merge conflict resolution
leex279 Sep 20, 2025
0a2c43f
fix: Update test assertions for proper rounding behavior in progress …
leex279 Sep 20, 2025
7f74aea
fix: Discovery now respects given URL path and fix method signature m…
leex279 Sep 20, 2025
c1677a9
fix: Skip discovery when user provides direct discovery file URLs
leex279 Sep 20, 2025
597fc86
fix: Skip link extraction for discovery targets (single-file mode)
leex279 Sep 20, 2025
d3cecd2
Merge branch 'main' into feature/automatic-discovery-llms-sitemap-430
leex279 Sep 22, 2025
d696918
Merge main into feature/automatic-discovery-llms-sitemap-430
leex279 Oct 11, 2025
968e5b7
Add SSL verification and response size limits to discovery service
leex279 Oct 14, 2025
e5160dd
fix: Address CodeRabbit feedback for discovery service
leex279 Oct 17, 2025
8777e94
feat: Prioritize same-directory discovery for llms.txt and sitemaps
leex279 Oct 17, 2025
a03ce1e
fix: Respect llms.txt priority over robots.txt sitemap declarations
leex279 Oct 17, 2025
cdf4323
feat: Implement llms.txt link following with discovery priority fix
leex279 Oct 17, 2025
8ab6c75
fix: Improve path detection and add progress validation
leex279 Oct 17, 2025
ddcd364
docs: Remove PRPs/llms-txt-link-following.md - not needed in repo
leex279 Oct 19, 2025
13796ab
feat: Improve discovery system with SSRF protection and optimize file…
leex279 Oct 19, 2025
957d8b9
fix: Update tests for requests.Session mock and cleanup URL validation
leex279 Oct 19, 2025
46ae553
fix: add tldextract to all dependency group
leex279 Oct 19, 2025
35c9ea9
fix: update test to use 'pages' terminology for llms.txt
leex279 Oct 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 173 additions & 17 deletions python/src/server/services/crawling/crawling_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

# Import strategies
# Import operations
from .discovery_service import DiscoveryService
from .document_storage_operations import DocumentStorageOperations
from .helpers.site_config import SiteConfig

Expand Down Expand Up @@ -83,6 +84,7 @@ def __init__(self, crawler=None, supabase_client=None, progress_id=None):

# Initialize operations
self.doc_storage_ops = DocumentStorageOperations(self.supabase_client)
self.discovery_service = DiscoveryService()

Comment on lines +133 to 135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Avoid blocking the event loop during discovery; wrap sync discovery or make it async.

If DiscoveryService.discover_files performs network I/O synchronously (e.g., via requests), calling it directly inside an async flow will block the event loop and throttle all concurrent crawls. Either expose an async variant or run the sync call in a thread.

Apply this diff to safely offload discovery to a thread without changing the service API:

-        self.discovery_service = DiscoveryService()
+        self.discovery_service = DiscoveryService()
-                    discovered_file = self.discovery_service.discover_files(url)
+                    # Offload potential sync I/O to a worker thread to avoid blocking the event loop.
+                    discovered_file = await asyncio.to_thread(self.discovery_service.discover_files, url)

Run this script to confirm whether DiscoveryService is sync and uses blocking I/O:

Also applies to: 344-355


🏁 Script executed:

#!/bin/bash
# Inspect discovery implementation for sync I/O usage
rg -nC2 -g '!**/dist/**' -g '!**/build/**' $'class\\s+DiscoveryService\\b|def\\s+discover_files\\b|import\\s+requests|import\\s+httpx|async\\s+def\\s+discover_files' python/src

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Locate DiscoveryService definition and inspect its discover_files method
rg -nC2 "class\s+DiscoveryService" python/src
rg -nC2 "def\s+discover_files" python/src

Length of output: 864


🏁 Script executed:

#!/bin/bash
# Search for sync HTTP calls within DiscoveryService
rg -nC3 -e "import requests" -e "requests\." -e "import httpx" -e "httpx\." -e "import aiohttp" -e "aiohttp\." python/src/server/services/crawling/discovery_service.py

Length of output: 4969


Offload blocking DiscoveryService.discover_files to a worker thread
DiscoveryService.discover_files makes multiple synchronous requests.get calls (e.g. URL checks, robots.txt, sitemaps, well-known files), which will block the async event loop when invoked directly; wrap each call site in await asyncio.to_thread(self.discovery_service.discover_files, url) to prevent throttling concurrent crawls (apply in python/src/server/services/crawling/crawling_service.py at lines 87–88 and 344–355).

🤖 Prompt for AI Agents
In python/src/server/services/crawling/crawling_service.py around lines 87-88
and 344-355, DiscoveryService.discover_files performs blocking synchronous
requests and must be offloaded to a worker thread; replace direct synchronous
calls to self.discovery_service.discover_files(...) with await
asyncio.to_thread(self.discovery_service.discover_files, url) (or pass the same
args/kwargs) at each call site and ensure asyncio is imported at the top of the
module so the event loop is not blocked during concurrent crawls.

# Track progress state across all stages to prevent UI resets
self.progress_state = {"progressId": self.progress_id} if self.progress_id else {}
Expand Down Expand Up @@ -179,13 +181,16 @@ async def crawl_single_page(self, url: str, retry_count: int = 3) -> dict[str, A
)

async def crawl_markdown_file(
self, url: str, progress_callback: Callable[[str, int, str], Awaitable[None]] | None = None
self, url: str, progress_callback: Callable[[str, int, str], Awaitable[None]] | None = None,
start_progress: int = 10, end_progress: int = 20
) -> list[dict[str, Any]]:
"""Crawl a .txt or markdown file."""
return await self.single_page_strategy.crawl_markdown_file(
url,
self.url_handler.transform_github_url,
progress_callback,
start_progress,
end_progress,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def parse_sitemap(self, sitemap_url: str) -> list[str]:
Expand Down Expand Up @@ -332,15 +337,87 @@ async def update_mapped_progress(
# Check for cancellation before proceeding
self._check_cancellation()

# Analyzing stage - report initial page count (at least 1)
await update_mapped_progress(
"analyzing", 50, f"Analyzing URL type for {url}",
total_pages=1, # We know we have at least the start URL
processed_pages=0
# Discovery phase - find the single best related file
discovered_urls = []
# Skip discovery if the URL itself is already a discovery target (sitemap, llms file, etc.)
is_already_discovery_target = (
self.url_handler.is_sitemap(url) or
self.url_handler.is_llms_variant(url) or
self.url_handler.is_robots_txt(url) or
self.url_handler.is_well_known_file(url) or
self.url_handler.is_txt(url) # Also skip for any .txt file that user provides directly
)

# Detect URL type and perform crawl
crawl_results, crawl_type = await self._crawl_by_url_type(url, request)
if is_already_discovery_target:
safe_logfire_info(f"Skipping discovery - URL is already a discovery target file: {url}")

if request.get("auto_discovery", True) and not is_already_discovery_target: # Default enabled, but skip if already a discovery file
await update_mapped_progress(
"discovery", 25, f"Discovering best related file for {url}", current_url=url
)
try:
# Offload potential sync I/O to avoid blocking the event loop
discovered_file = await asyncio.to_thread(self.discovery_service.discover_files, url)

# Add the single best discovered file to crawl list
if discovered_file:
safe_logfire_info(f"Discovery found file: {discovered_file}")
# Filter through is_binary_file() check like existing code
if not self.url_handler.is_binary_file(discovered_file):
discovered_urls.append(discovered_file)
safe_logfire_info(f"Adding discovered file to crawl: {discovered_file}")
else:
safe_logfire_info(f"Skipping binary file: {discovered_file}")
else:
safe_logfire_info(f"Discovery found no files for {url}")

file_count = len(discovered_urls)
safe_logfire_info(f"Discovery selected {file_count} best file to crawl")

await update_mapped_progress(
"discovery", 100, f"Discovery completed: selected {file_count} best file", current_url=url
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
except Exception as e:
safe_logfire_error(f"Discovery phase failed: {e}")
# Continue with regular crawl even if discovery fails
await update_mapped_progress(
"discovery", 100, "Discovery phase failed, continuing with regular crawl", current_url=url
)

# Analyzing stage - determine what to crawl
if discovered_urls:
# Discovery found a file - crawl ONLY the discovered file, not the main URL
total_urls_to_crawl = len(discovered_urls)
await update_mapped_progress(
"analyzing", 50, f"Analyzing discovered file: {discovered_urls[0]}",
total_pages=total_urls_to_crawl,
processed_pages=0
)

# Crawl only the discovered file with discovery context
discovered_url = discovered_urls[0]
safe_logfire_info(f"Crawling discovered file instead of main URL: {discovered_url}")

# Mark this as a discovery target for domain filtering
discovery_request = request.copy()
discovery_request["is_discovery_target"] = True
discovery_request["original_domain"] = self.url_handler.get_base_url(url)

crawl_results, crawl_type = await self._crawl_by_url_type(discovered_url, discovery_request)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
else:
# No discovery - crawl the main URL normally
total_urls_to_crawl = 1
await update_mapped_progress(
"analyzing", 50, f"Analyzing URL type for {url}",
total_pages=total_urls_to_crawl,
processed_pages=0
)

# Crawl the main URL
safe_logfire_info(f"No discovery file found, crawling main URL: {url}")
crawl_results, crawl_type = await self._crawl_by_url_type(url, request)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Update progress tracker with crawl type
if self.progress_tracker and crawl_type:
Expand Down Expand Up @@ -583,6 +660,27 @@ async def code_progress_callback(data: dict):
f"Unregistered orchestration service on error | progress_id={self.progress_id}"
)

def _is_same_domain(self, url: str, base_domain: str) -> bool:
"""
Check if a URL belongs to the same domain as the base domain.

Args:
url: URL to check
base_domain: Base domain URL to compare against

Returns:
True if the URL is from the same domain
"""
try:
from urllib.parse import urlparse
u, b = urlparse(url), urlparse(base_domain)
url_host = (u.hostname or "").lower()
base_host = (b.hostname or "").lower()
return bool(url_host) and url_host == base_host
except Exception:
# If parsing fails, be conservative and exclude the URL
return False

def _is_self_link(self, link: str, base_url: str) -> bool:
"""
Check if a link is a self-referential link to the base URL.
Expand Down Expand Up @@ -655,7 +753,14 @@ async def update_crawl_progress(stage_progress: int, message: str, **kwargs):
if crawl_results and len(crawl_results) > 0:
content = crawl_results[0].get('markdown', '')
if self.url_handler.is_link_collection_file(url, content):
# Extract links from the content
# If this file was selected by discovery, skip link extraction (single-file mode)
if request.get("is_discovery_target"):
logger.info(f"Discovery single-file mode: skipping link extraction for {url}")
crawl_type = "discovery_single_file"
logger.info(f"Discovery file crawling completed: {len(crawl_results)} result")
return crawl_results, crawl_type

# Extract links from the content for non-discovery files
extracted_links = self.url_handler.extract_markdown_links(content, url)

# Filter out self-referential links to avoid redundant crawling
Expand All @@ -669,6 +774,19 @@ async def update_crawl_progress(stage_progress: int, message: str, **kwargs):
if self_filtered_count > 0:
logger.info(f"Filtered out {self_filtered_count} self-referential links from {original_count} extracted links")

# For discovery targets, only follow same-domain links
if extracted_links and request.get("is_discovery_target"):
original_domain = request.get("original_domain")
if original_domain:
original_count = len(extracted_links)
extracted_links = [
link for link in extracted_links
if self._is_same_domain(link, original_domain)
]
domain_filtered_count = original_count - len(extracted_links)
if domain_filtered_count > 0:
safe_logfire_info(f"Discovery mode: filtered out {domain_filtered_count} external links, keeping {len(extracted_links)} same-domain links")

Comment on lines +956 to +968

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Honor “crawl only the discovered file” policy by default; gate link following behind an explicit flag.
Current logic still follows same-domain links from discovery targets, which conflicts with the PR objective (“crawls only that file”). Make following links opt-in.

Minimal change to disable follow by default:

-                    if extracted_links and request.get("is_discovery_target"):
+                    if extracted_links and request.get("is_discovery_target"):
                         original_domain = request.get("original_domain")
                         if original_domain:
                             original_count = len(extracted_links)
                             extracted_links = [
                                 link for link in extracted_links 
                                 if self._is_same_domain(link, original_domain)
                             ]
                             domain_filtered_count = original_count - len(extracted_links)
                             if domain_filtered_count > 0:
                                 safe_logfire_info(f"Discovery mode: filtered out {domain_filtered_count} external links, keeping {len(extracted_links)} same-domain links")
+                        # Do not follow links from discovery files unless explicitly allowed
+                        if not request.get("follow_links_from_discovery", False):
+                            logger.info("Discovery mode: not following links from discovery file (set follow_links_from_discovery=True to enable).")
+                            extracted_links = []

🏁 Script executed:

#!/bin/bash
set -euxo pipefail

# Print context around the discovery snippet
sed -n '650,840p' python/src/server/services/crawling/crawling_service.py

# Search for extracted_links usage
rg -n 'extracted_links' -C3 python/src/server/services/crawling/crawling_service.py

# Check for existing flags controlling link following
rg -n 'follow_links' -C3 python/src/server/services/crawling/crawling_service.py || echo "No follow_links flag found"

# Locate all usage of is_discovery_target
rg -n 'is_discovery_target' -C3 python/src/server/services/crawling/crawling_service.py

Length of output: 14145


Disable link crawling by default for discovery files
Insert a guard after the same-domain filter in crawling_service.py so that links are only followed when follow_links_from_discovery=True:

                     # For discovery targets, only follow same-domain links
                     if extracted_links and request.get("is_discovery_target"):
                         original_domain = request.get("original_domain")
                         if original_domain:
                             original_count = len(extracted_links)
                             extracted_links = [
                                 link for link in extracted_links 
                                 if self._is_same_domain(link, original_domain)
                             ]
                             domain_filtered_count = original_count - len(extracted_links)
                             if domain_filtered_count > 0:
                                 safe_logfire_info(f"Discovery mode: filtered out {domain_filtered_count} external links, keeping {len(extracted_links)} same-domain links")
+                        # Do not follow links from discovery files unless explicitly enabled
+                        if not request.get("follow_links_from_discovery", False):
+                            logger.info(
+                                "Discovery mode: not following links from discovery file "
+                                "(set follow_links_from_discovery=True to enable)."
+                            )
+                            extracted_links = []
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# For discovery targets, only follow same-domain links
if extracted_links and request.get("is_discovery_target"):
original_domain = request.get("original_domain")
if original_domain:
original_count = len(extracted_links)
extracted_links = [
link for link in extracted_links
if self._is_same_domain(link, original_domain)
]
domain_filtered_count = original_count - len(extracted_links)
if domain_filtered_count > 0:
safe_logfire_info(f"Discovery mode: filtered out {domain_filtered_count} external links, keeping {len(extracted_links)} same-domain links")
# For discovery targets, only follow same-domain links
if extracted_links and request.get("is_discovery_target"):
original_domain = request.get("original_domain")
if original_domain:
original_count = len(extracted_links)
extracted_links = [
link for link in extracted_links
if self._is_same_domain(link, original_domain)
]
domain_filtered_count = original_count - len(extracted_links)
if domain_filtered_count > 0:
safe_logfire_info(
f"Discovery mode: filtered out {domain_filtered_count} external links, "
f"keeping {len(extracted_links)} same-domain links"
)
# Do not follow links from discovery files unless explicitly enabled
if not request.get("follow_links_from_discovery", False):
logger.info(
"Discovery mode: not following links from discovery file "
"(set follow_links_from_discovery=True to enable)."
)
extracted_links = []
🤖 Prompt for AI Agents
In python/src/server/services/crawling/crawling_service.py around lines 692 to
704, after the same-domain filtering for discovery targets, add a guard that
disables following links unless request.get("follow_links_from_discovery") is
True; if the flag is missing or False, set extracted_links to an empty list (and
log that link crawling was skipped for discovery files) so downstream logic
won't follow any discovery links, otherwise leave extracted_links as filtered
and continue normally.

# Filter out binary files (PDFs, images, archives, etc.) to avoid wasteful crawling
if extracted_links:
original_count = len(extracted_links)
Expand All @@ -677,23 +795,47 @@ async def update_crawl_progress(stage_progress: int, message: str, **kwargs):
if filtered_count > 0:
logger.info(f"Filtered out {filtered_count} binary files from {original_count} extracted links")

# Deduplicate to reduce redundant work
extracted_links = list(dict.fromkeys(extracted_links))

if extracted_links:
# Crawl the extracted links using batch crawling
# For discovery targets, respect max_depth for same-domain links
max_depth = request.get('max_depth', 2) if request.get("is_discovery_target") else request.get('max_depth', 1)

if max_depth > 1 and request.get("is_discovery_target"):
# Use recursive crawling to respect depth limit for same-domain links
logger.info(f"Crawling {len(extracted_links)} same-domain links with max_depth={max_depth-1}")
batch_results = await self.crawl_recursive_with_progress(
extracted_links,
max_depth=max_depth - 1, # Reduce depth since we're already 1 level deep
max_concurrent=request.get('max_concurrent'),
progress_callback=await self._create_crawl_progress_callback("crawling"),
)
else:
# Depth limit reached, just crawl the immediate links without following further
logger.info(f"Max depth reached, crawling {len(extracted_links)} links without further recursion")
batch_results = await self.crawl_batch_with_progress(
extracted_links,
max_concurrent=request.get('max_concurrent'),
progress_callback=await self._create_crawl_progress_callback("crawling"),
)
else:
# Use normal batch crawling for non-discovery targets
logger.info(f"Crawling {len(extracted_links)} extracted links from {url}")
batch_results = await self.crawl_batch_with_progress(
extracted_links,
max_concurrent=request.get('max_concurrent'), # None -> use DB settings
progress_callback=await self._create_crawl_progress_callback("crawling"),
)

# Combine original text file results with batch results
crawl_results.extend(batch_results)
crawl_type = "link_collection_with_crawled_links"
# Combine original text file results with batch results
crawl_results.extend(batch_results)
crawl_type = "link_collection_with_crawled_links"

logger.info(f"Link collection crawling completed: {len(crawl_results)} total results (1 text file + {len(batch_results)} extracted links)")
else:
logger.info(f"No valid links found in link collection file: {url}")
logger.info(f"Text file crawling completed: {len(crawl_results)} results")
logger.info(f"Link collection crawling completed: {len(crawl_results)} total results (1 text file + {len(batch_results)} extracted links)")
else:
logger.info(f"No valid links found in link collection file: {url}")
logger.info(f"Text file crawling completed: {len(crawl_results)} results")

elif self.url_handler.is_sitemap(url):
# Handle sitemaps
Expand All @@ -703,6 +845,20 @@ async def update_crawl_progress(stage_progress: int, message: str, **kwargs):
"Detected sitemap, parsing URLs...",
crawl_type=crawl_type
)

# If this sitemap was selected by discovery, just return the sitemap itself (single-file mode)
if request.get("is_discovery_target"):
logger.info(f"Discovery single-file mode: returning sitemap itself without crawling URLs from {url}")
crawl_type = "discovery_sitemap"
# Return the sitemap file as the result
crawl_results = [{
'url': url,
'markdown': f"# Sitemap: {url}\n\nThis is a sitemap file discovered and returned in single-file mode.",
'title': f"Sitemap - {self.url_handler.extract_display_name(url)}",
'crawl_type': crawl_type
}]
return crawl_results, crawl_type

sitemap_urls = self.parse_sitemap(url)

if sitemap_urls:
Expand Down
Loading