High-Throughput Streaming ETL Engine for Web Content Intelligence
An enterprise-grade, real-time web crawling, SEO analysis, and content extraction platform β
built with .NET 9, orchestrated by .NET Aspire, and designed for production reliability.
- Overview
- Tech Stack
- System Architecture
- Streaming ETL Pipeline
- Concurrency & Resource Governance
- DevOps & CI/CD
- Project Structure
- Getting Started
- Observability & Telemetry
ContentPulse is not a simple web scraper. It is a distributed, streaming ETL system that:
- Extracts web content at scale via dual-engine ingestion (static HTTP + headless Playwright)
- Transforms raw HTML through a 3-stage in-memory pipeline (Parse β SEO Analyze β Content Extract)
- Loads structured results into PostgreSQL with full relational integrity
- Broadcasts live progress to a real-time dashboard via SignalR WebSockets
The system is designed around the same principles used in production data platforms: backpressure-aware channels, idempotent deduplication, bounded concurrency, and scoped dependency lifetimes to prevent resource leaks.
graph TB
subgraph "Ingress"
User([fa:fa-user User / Browser])
end
subgraph "Orchestration Layer (Aspire AppHost)"
Gateway[fa:fa-network-wired Service Discovery & Health Checks]
end
subgraph "Application Layer"
Dashboard["fa:fa-desktop Dashboard<br/>(Blazor Server)"]
Api["fa:fa-server API<br/>(REST + SignalR Hub)"]
Crawler["fa:fa-spider Crawler Worker<br/>(BackgroundService Γ 5)"]
end
subgraph "Data Layer"
Redis[("fa:fa-bolt Redis<br/>URL Dedup Cache")]
Postgres[("fa:fa-database PostgreSQL<br/>Jobs Β· Pages Β· SEO Reports")]
end
Internet((fa:fa-globe Target Websites))
User -->|HTTPS| Dashboard
User -->|REST API| Api
Gateway -.->|Orchestrates & Monitors| Dashboard
Gateway -.->|Orchestrates & Monitors| Api
Gateway -.->|Orchestrates & Monitors| Crawler
Dashboard <-->|"SignalR (WebSocket)"| Api
Api <-->|EF Core| Postgres
Crawler -->|"Poll Pending Jobs (5s)"| Postgres
Crawler -->|"O(1) Dedup Check"| Redis
Crawler -->|"Mark Visited (TTL 24h)"| Redis
Crawler <-->|"HTTP GET / Playwright"| Internet
Crawler -->|"Persist CrawledPage"| Postgres
Crawler -->|"POST /notify-update"| Api
The heart of ContentPulse is a 3-stage streaming ETL pipeline built on System.Threading.Channels. Unlike batch processing, data flows continuously through bounded channels with built-in backpressure β if a downstream stage is slow, upstream stages block automatically rather than buffering unbounded data into memory.
graph LR
subgraph "Extract"
CW["CrawlerWorker<br/>(5 parallel consumers)"]
end
subgraph "Transform"
S1["Stage 1: HTML Parsing<br/>(AngleSharp DOM)"]
S2["Stage 2: SEO Analysis<br/>(15-signal scoring)"]
S3["Stage 3: Content Extraction<br/>(Readability + NLP)"]
end
subgraph "Load"
PG[("PostgreSQL<br/>Structured Results")]
WS["SignalR<br/>Live Dashboard"]
end
CW -->|"Channel<CrawledPage><br/>capacity: 50"| S1
S1 -->|"Channel<ParsedDocument><br/>capacity: 30"| S2
S2 -->|"Channel<AnalyzedPage><br/>capacity: 30"| S3
S3 -->|"Channel<ProcessedContent><br/>unbounded"| PG
CW -->|"HTTP POST"| WS
| Pattern | Implementation | Why It Matters |
|---|---|---|
| Backpressure | BoundedChannelOptions with FullMode.Wait |
Prevents OOM by blocking producers when consumers are slow |
| Idempotent Dedup | Redis SET with SetContainsAsync + 24h TTL |
Guarantees at-most-once processing per URL per crawl job |
| Scoped DB Lifetimes | IServiceScopeFactory per operation |
Prevents captive dependency (stale DbContext in Singletons) |
| Fan-Out Parallelism | 5 ProcessQueueAsync workers consuming from single ChannelReader |
Thread-safe work-stealing pattern with zero lock contention |
| Graceful Degradation | TryComplete() on channel writers in finally blocks |
Ensures downstream stages terminate cleanly on upstream failure |
| Atomic Counters | Interlocked.Increment for pipeline metrics |
Lock-free thread-safe counting across parallel workers |
Each crawled page is evaluated against 15 independent SEO signals across 6 categories, producing a score out of 100:
| Category | Signals | Max Points |
|---|---|---|
| Meta | Title Tag, Meta Description | 20 |
| Content | H1 Tag, Heading Hierarchy | 10 |
| Accessibility | Image Alt Text, Language Attr | 15 |
| Links | Internal Link Count | 10 |
| Technical | Canonical, Viewport, Favicon, HTTPS | 20 |
| Structured Data | Schema.org (JSON-LD), Open Graph, Twitter Card | 20 |
| Total | 95 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CrawlerWorker (BackgroundService) β
β β
β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ
β β Worker 1 β β Worker 2 β β Worker 3 β β Worker 4 β β Worker 5 β
β ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ
β β β β β β
β ββββββββββββββββ΄βββββββ¬ββββββββ΄βββββββββββββββ΄βββββββββββββββ
β β
β ChannelReader<CrawlTask> (shared, thread-safe)
β β
β βββββββββββββββββββββββ΄ββββββββββββββββββββββ
β β Mode Router β
β β β
β β Static βββΊ HttpClient (unlimited) β
β β Dynamic βββΊ Playwright (SemaphoreSlim=3) β
β ββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- 5 parallel workers consume from a single bounded
Channel<CrawlTask>(capacity: 1000) - Static mode uses pooled
HttpClientinstances with Polly resilience (retries + circuit breaker) - Dynamic mode is gated by
SemaphoreSlim(3, 3)β only 3 Chromium contexts can exist simultaneously, preventing the ~250MB-per-instance memory overhead from causing OOM - Polling loop checks for new pending jobs every 5 seconds via a separate
Task.Runbackground thread
All cloud resources are declaratively defined in infra/main.bicep:
infra/
βββ main.bicep # Root orchestration module
βββ resources/
β βββ containerApp.bicep # Azure Container Apps definitions
β βββ postgres.bicep # Azure Database for PostgreSQL Flexible Server
β βββ redis.bicep # Azure Cache for Redis
graph LR
subgraph "CI (Pull Request)"
A[Checkout] --> B[Restore]
B --> C["Build (dotnet build)"]
C --> D["Test (dotnet test)"]
end
subgraph "CD (Main Branch)"
D --> E["Docker Build<br/>(multi-stage)"]
E --> F["Push to ACR<br/>(Azure Container Registry)"]
F --> G["Deploy to ACA<br/>(Azure Container Apps)"]
G --> H["Health Check<br/>(/health, /alive)"]
end
Each microservice has its own multi-stage Dockerfile:
# Example: ContentPulse.Crawler
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build # Build stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final # Runtime stage (~80MB)| Service | Port | Description |
|---|---|---|
ContentPulse.Api |
8080 | REST API + SignalR Hub |
ContentPulse.Crawler |
β | Headless worker (no HTTP) |
ContentPulse.Dashboard |
8080 | Blazor Server UI |
ContentPulse/
βββ src/
β βββ ContentPulse.AppHost/ # .NET Aspire orchestrator
β β βββ Program.cs # Wires Postgres, Redis, API, Crawler, Dashboard
β β
β βββ ContentPulse.Core/ # Domain layer (zero dependencies)
β β βββ Models/ # CrawlJob, CrawledPage, SeoReport, ProcessedContent
β β βββ Events/ # PageCrawledEvent, ContentProcessedEvent
β β βββ Interfaces/ # Repository contracts, IPipelineStage<TIn,TOut>
β β
β βββ ContentPulse.Infrastructure/ # Data access layer
β β βββ Data/AppDbContext.cs # EF Core context with full Fluent API config
β β βββ Repositories/ # CrawlJob, CrawledPage, SeoReport repositories
β β βββ Caching/ # Redis URL deduplication service
β β βββ DependencyInjection.cs # Service registration extension method
β β
β βββ ContentPulse.Pipeline/ # Streaming ETL engine
β β βββ PipelineOrchestrator.cs # BackgroundService: 3-stage channel orchestration
β β βββ Stages/
β β βββ HtmlParsingStage.cs # AngleSharp DOM parsing
β β βββ SeoAnalysisStage.cs # 15-signal SEO scoring engine
β β βββ ContentExtractionStage.cs # Readability + keyword extraction
β β
β βββ ContentPulse.Crawler/ # Ingestion worker
β β βββ Services/
β β βββ CrawlerWorker.cs # BackgroundService: 5 parallel consumers + poller
β β βββ CrawlScheduler.cs # Job lifecycle, dedup, enqueue, scoped DB access
β β βββ HttpCrawlService.cs # Static HTTP ingestion
β β βββ PlaywrightService.cs # Dynamic JS rendering (SemaphoreSlim-bounded)
β β
β βββ ContentPulse.Api/ # REST API + real-time hub
β β βββ Controllers/ # CrawlController, AnalyticsController
β β βββ Hubs/DashboardHub.cs # SignalR hub for live push
β β
β βββ ContentPulse.Dashboard/ # Blazor Server frontend
β β βββ Components/Pages/ # Home.razor (real-time UI)
β β βββ Services/ApiClient.cs # Typed HTTP client for API
β β βββ wwwroot/app.css # Glassmorphism design system
β β
β βββ ContentPulse.ServiceDefaults/ # Shared Aspire defaults (health, telemetry)
β
βββ infra/ # Azure Bicep IaC templates
βββ .github/workflows/ # CI/CD pipeline definitions
βββ docker-compose.yml # Local multi-container dev environment
βββ README.md
| Tool | Version | Purpose |
|---|---|---|
| .NET 9.0 SDK | 9.0+ | Build & run |
| Docker Desktop | Latest | Aspire provisions Postgres & Redis containers |
# 1. Clone the repository
git clone https://github.com/FelixMatrixar/contentpulse.git
cd contentpulse
# 2. Trust the ASP.NET Core dev certificate (first time only)
dotnet dev-certs https --trust
# 3. Run the Aspire AppHost (spins up everything)
dotnet run --project src/ContentPulse.AppHost/ContentPulse.AppHost.csprojAspire will automatically:
- Pull and start PostgreSQL and Redis Docker containers
- Launch the API, Crawler, and Dashboard services
- Open the Aspire Dashboard for distributed tracing and log aggregation
Every layer is instrumented with structured logging at the Trace level in Development:
| Component | What's Logged |
|---|---|
| CrawlerWorker | Job discovery, task dispatch, mode routing |
| CrawlScheduler | URL enqueue/dedup decisions, scoped DB writes, job lifecycle transitions |
| PlaywrightService | Semaphore acquire/release, render time, page size |
| HttpCrawlService | Request initiation, TTFB, response status/size |
| PipelineOrchestrator | Stage init, stage completion time, fatal errors |
| HtmlParsingStage | Parse duration, document size, skip reasons |
| SeoAnalysisStage | Score breakdown, DB persistence |
| ContentExtractionStage | Word count, readability grade, estimated read time |
| Repositories | Every CREATE, UPDATE, DELETE with entity IDs |
| UrlDeduplicationService | Redis hit/miss per URL, TTL management |
| API Controllers | Request received, response status, SignalR broadcasts |
| Dashboard ApiClient | Outbound HTTP calls, deserialization results |
All logs use structured logging with named parameters (e.g., {JobId}, {Url}, {StatusCode}) for seamless integration with log aggregation tools (Seq, Elasticsearch, Azure Monitor).
FelixMatrixar β Architecture, Data Engineering, DevOps & Implementation
Contributions, issues, and feature requests are welcome! Feel free to check the issues page.
This project is licensed under the MIT License β see the LICENSE file for details.
MIT License
Copyright (c) 2026 FelixMatrixar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.