Web crawling service for extracting grant-related documents from URLs. The service listens to Pub/Sub messages, crawls web pages recursively with depth limiting and relevance filtering, extracts content and metadata using AI-powered extraction, and publishes indexed content for downstream RAG processing.
For prerequisites, environment setup, and general development workflow, see the Contributing Guide.
This README covers crawler service-specific architecture and development details.
services/crawler/
├── src/
│ ├── __init__.py
│ ├── main.py # Litestar app + Pub/Sub handler
│ ├── extraction.py # Core crawling logic
│ ├── utils.py # HTTP utilities + message decoding
│ ├── constants.py # Configuration constants
│ └── dev_indexing_bypass.py # Dev environment helpers
└── tests/
├── conftest.py
├── extraction_test.py
├── utils_test.py
├── url_deduplication_test.py
├── dev_indexing_bypass_test.py
└── e2e/
├── crawler_pipeline_test.py
└── url_extraction_test.py
sequenceDiagram
participant PubSub as Pub/Sub (url-crawling)
participant Crawler as Crawler Service
participant HTTP as External Websites
participant BeautifulSoup as Link Extraction
participant Trafilatura as HTML→Markdown
participant Kreuzberg as Content Processing
participant GCS as Google Cloud Storage
participant DB as PostgreSQL
participant IndexTopic as Pub/Sub (file-indexing)
PubSub->>Crawler: CrawlingRequest message
Note over Crawler: Decode message & claim source
Crawler->>DB: Update RagSource to INDEXING
Crawler->>HTTP: Download page HTML
HTTP-->>Crawler: Raw HTML content
Crawler->>BeautifulSoup: Extract links from HTML
BeautifulSoup-->>Crawler: Document links + Normal links
Crawler->>Trafilatura: Convert HTML to text/markdown
Trafilatura-->>Crawler: Text content + Markdown
Crawler->>Kreuzberg: Extract entities, keywords, chunks
Kreuzberg-->>Crawler: Structured metadata + chunks
Note over Crawler: Generate embeddings & calculate similarity
loop For each relevant link (depth < MAX_DEPTH)
Crawler->>HTTP: Download linked page
HTTP-->>Crawler: HTML content
Crawler->>Trafilatura: Convert to markdown
Note over Crawler: Recursive crawl if similarity >= 0.58
end
loop For each document link (.pdf, .docx, etc.)
Crawler->>HTTP: Download document
HTTP-->>Crawler: File content
Crawler->>GCS: Upload file
GCS-->>Crawler: Upload confirmed
Crawler->>DB: Create RagFile entry
Crawler->>IndexTopic: Trigger file-indexing
end
Crawler->>DB: Save vectors + text content
Crawler->>DB: Update RagSource to FINISHED
- Receives
CrawlingRequestfromurl-crawlingPub/Sub topic - Contains:
source_id,entity_type,entity_id,url,trace_id - Claims source by updating
RagSource.indexing_statustoINDEXING(prevents duplicate processing) - If source already being processed or finished, exits early
- Handles stuck jobs (>10 minutes in INDEXING state)
- Downloads HTML content using
httpx(15s timeout) - Parses with
BeautifulSoupand sanitizes HTML - Extracts all
<a>tags and converts relative URLs to absolute - Classifies links using regex pattern
FILE_RX:- Document links: Match
\.(pdf|docx?|xlsx?|pptx?|txt|md|rtf)(?=$|[/?#]) - Normal links: All other HTTP/HTTPS links
- Document links: Match
- Filters out social media domains (x.com, twitter.com, facebook.com)
- Uses
trafilaturato convert HTML to clean markdown and text - Generates embeddings using Vertex AI text-embedding-004 model
- Processes with Kreuzberg for:
- Entity extraction (organizations, locations, people, grants)
- Keyword extraction
- Document classification
- Content chunking (when enabled)
- Stores metadata including entities, keywords, and document classification
- Maximum depth controlled by
MAX_DEPTHconstant (default: 0 = no recursion) - For each normal link at depth < MAX_DEPTH:
- Downloads and extracts text content
- Generates embeddings for the linked page
- Calculates cosine similarity between main page and linked page embeddings
- Recursively crawls if similarity >= 0.58 (relevant content)
- Uses in-memory session store to track visited URLs (prevents loops)
- URLs normalized before deduplication (trailing slashes, query params, fragments)
- Parallel processing of relevant links using
asyncio.gather()
- Controlled by
DOWNLOAD_FILESenvironment variable (default: false) - Filters document links by supported extensions:
- PDF, DOCX, DOC, XLSX, XLS, PPTX, PPT, TXT, MD, RTF
- Downloads documents in parallel using
asyncio.gather() - Stores files in temporary directory during processing
- Each downloaded file creates:
- New
RagSourcerecord (parent_id = original source) RagFilerecord with GCS object path- Entity association (GrantingInstitutionSource/GrantApplicationSource/GrantTemplateSource)
- New
- Constructs object path:
{entity_type}/{entity_id}/sources/{source_id}/{filename} - Uploads file bytes to Google Cloud Storage
- Creates
RagFiledatabase entry with object path and metadata - In development: triggers file-indexing directly via internal function
- In production: file-indexing triggered by GCS object finalize event
- Combines markdown content from all crawled pages
- Re-processes combined content with Kreuzberg for final chunking
- Generates embeddings for each chunk using Vertex AI
- Creates
TextVectorrecords linked to source - Stores enriched metadata (entities, keywords, classification)
- Updates
RagSource.indexing_statustoFINISHEDon success - Updates to
FAILEDon error with error_type and error_message - Retriable errors (network, timeout) keep status as FAILED for retry
- Non-retriable errors (validation, parsing) marked as FAILED permanently
- Tracks indexing duration via
indexing_started_attimestamp
-
Input:
url-crawling- Message format:
CrawlingRequestTypedDict - Contains source_id, entity_type, entity_id, url, trace_id
- Triggered by backend when user adds URL source
- Message format:
-
Output:
file-indexing(implicit via GCS events in production)- Triggered when file uploaded to GCS
- Processed by Indexer service for content extraction
- Bucket structure:
{entity_type}/{entity_id}/sources/{source_id}/{filename} - Stores downloaded documents (PDF, DOCX, etc.)
- Object finalize events trigger downstream indexing
- Files linked via
RagFile.object_path
- RagSource: Main source record with indexing status and text content
- RagUrl: Stores URL metadata (not directly modified by crawler)
- RagFile: File metadata and GCS object path
- TextVector: pgvector embeddings for semantic search
- Entity Junction Tables: GrantingInstitutionSource, GrantApplicationSource, GrantTemplateSource
- HTTP: Downloads HTML and files from target websites
- Vertex AI Embeddings: text-embedding-004 model for semantic similarity
- Trafilatura: Open-source HTML→text/markdown extraction
- BeautifulSoup: HTML parsing and link extraction
- Kreuzberg: AI-powered content processing (entities, keywords, chunking)
MAX_DEPTHenvironment variable controls crawl depth (default: 0)- Depth 0: Only process initial URL, no following links
- Depth 1: Process initial URL + directly linked relevant pages
- Depth 2: Process initial URL + 2 levels of relevant links
- Prevents infinite crawling and excessive processing time
- Memory store tracks visited URLs to prevent cycles
- Only processes files matching
FILE_RXpattern - Pattern:
\.(pdf|docx?|xlsx?|pptx?|txt|md|rtf)(?=$|[/?#]) - Case-insensitive matching
- Validates extension exists in
SUPPORTED_FILE_EXTENSIONSbefore upload - Unsupported files silently skipped (logged at debug level)
- Currently not implemented
- Service does not check robots.txt before crawling
- Uses 15-second timeout to avoid hanging on slow sites
- Rate limiting via sequential processing (0.5s delay between requests implied by async operations)
- URL normalization before deduplication:
- Removes trailing slashes
- Normalizes query parameter order
- Removes URL fragments (#section)
- In-memory session store with 1-hour expiration
- Per-source session key:
visited_urls:{source_id} - Database-level deduplication via
RagSourceclaiming pattern - Parallel container safety via optimistic locking (UPDATE WHERE status IN [CREATED, FAILED])
- Cosine similarity threshold: 0.58
- Compares embeddings between main page and linked pages
- Links with similarity >= 0.58 considered relevant and crawled
- Helps filter out navigation links, footers, unrelated content
- Threshold tuned empirically for grant-related content
- Retriable errors: Network failures, timeouts, transient GCS errors
- Status set to FAILED
- Pub/Sub message acknowledged (will retry via exponential backoff)
- Non-retriable errors: Validation errors, parsing failures, invalid URLs
- Status set to FAILED permanently
- Error logged as warning (not exception)
- Pub/Sub message acknowledged (no retry)
- Error details stored in
RagSource.error_typeanderror_message - Stuck job detection: Sources in INDEXING state for >10 minutes are reclaimed