Skip to content

Repository files navigation

Sapu

Sapu — sweep articles off the web. Stealth Puppeteer scraper with URL classification, multi-site scheduling, and RabbitMQ workers.

License: MIT Status: production TypeScript Node.js pnpm Postgres RabbitMQ Docker

A configuration-driven news scraper. The complex stuff lives in the database as JSON; the codebase stays small.


Why Sapu

Most scrapers accumulate abstractions: workers, queues, classifiers, schedulers, each their own service. Sapu collapses that. One TypeScript service, one Postgres database, one RabbitMQ exchange, and a small Vite admin panel.

Three things drove the design:

  1. Configuration over code — site rules, processor definitions, URL patterns live in the database as JSON. Add a new site by adding a JSON file to config/sites/.
  2. Stealth by default — puppeteer-extra with the stealth plugin, automatic resource blocking (images, CSS, fonts), and per-site timeouts.
  3. Schedule-aware — every site gets its own cron. The scheduler re-reads its own config at runtime.

Quick start

Docker (recommended)

git clone https://github.com/ahsanatha/sapu.git
cd sapu
cp .env.example .env
docker compose up -d

The API comes up on :3000, the admin panel on :4321. Bring Postgres and RabbitMQ up via the included docker-compose.yml.

Local development

pnpm install
pnpm db:init          # create schema + seed reference data
pnpm dev              # API server (tsx watch)
pnpm worker           # scraper worker process
pnpm web:dev          # admin panel (Vite + React)

Requires Node 22+, pnpm 10+, Postgres 15+, RabbitMQ 3.13+.

Architecture

┌─────────────────────┐
│  Scheduler (cron)   │   per-site cron, re-reads config at runtime
└──────────┬──────────┘
           ↓ publishes to sapu.jobs
┌─────────────────────┐
│  RabbitMQ exchange  │   routing keys: scraping, url_collection
└──────────┬──────────┘
           ↓
┌─────────────────────┐         ┌──────────────────┐
│  Worker pool        │  ←────  │  Auto-scaler     │  queue-depth aware
│  (stealth puppeteer)│         └──────────────────┘
└──────────┬──────────┘
           ↓
┌─────────────────────┐         ┌──────────────────┐
│  Postgres           │  ←────  │  Admin panel     │  React + Vite
│  - articles         │         │  (port 4321)     │
│  - processors       │         └──────────────────┘
│  - url_patterns     │
│  - articles         │
└─────────────────────┘

Features

🕵️ Stealth mode puppeteer-extra + stealth plugin; auto-blocks images/CSS/fonts
🔍 URL classification regex patterns in DB with confidence + domain filters
Multi-scheduler per-site cron, hot-reloadable, validated
📈 Auto-scaling workers queue-depth + utilization based, cooldown-bounded
🐰 RabbitMQ durable exchange, per-queue prefetch, DLQ
⚙️ Site configs JSON files in config/sites/; add sites by dropping a file
🎯 Smart discovery classification-filtered URL discovery
🔔 Telegram notifications per-event fanout (optional)

API

x402-ready stateless extraction

Public endpoints that can be placed behind x402 middleware and priced per route:

Method Path Purpose Suggested x402 price
GET /x402/health Public product/route metadata free
GET /x402/cost/estimate Unit economics estimate free
POST /x402/classify-url CPU-only URL classification $0.001
POST /x402/fetch/http Bounded HTTP fetch $0.003
POST /x402/extract/links Extract links from fetched HTML $0.003
POST /x402/extract/article HTTP-only title/text/link extraction $0.005
POST /x402/extract/article/browser Cheap Cloudflare Browser Run article extraction $0.018
POST /x402/extract/article/browser/long Long-running local browser article extraction $0.075
POST /x402/fetch/browser Cheap Cloudflare Browser Run fetch $0.015
POST /x402/fetch/browser/long Long-running local browser fetch $0.060

Example:

curl -s http://localhost:3000/x402/health

curl -s http://localhost:3000/x402/classify-url \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com/news/article"}'

curl -s http://localhost:3000/x402/extract/article \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com","includeText":true}'

curl -s http://localhost:3000/x402/extract/article/browser \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com","includeText":true}'

curl -s http://localhost:3000/x402/extract/article/browser/long \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com","timeoutMs":90000,"includeText":true}'

curl -s 'http://localhost:3000/x402/cost/estimate?endpoint=fetch-browser&seconds=10'

Operational guardrail: cheap HTTP extraction routes must not launch browser rendering. Cheap browser routes use Cloudflare Browser Run. Long-running browser routes use local Puppeteer and are priced separately.

x402 payment gate

The server includes x402 v2 Express middleware. It is disabled by default for local development and turns on only when both conditions are true:

X402_ENABLED=true
X402_PAY_TO=0xYourSellerWalletAddress

Default seller config:

X402_NETWORK=eip155:84532
X402_FACILITATOR_URL=https://x402.org/facilitator
X402_RESOURCE_BASE_URL=https://sapu.rekursa.id

GET /x402/health and GET /x402/cost/estimate stay free. The paid routes return x402 v2 payment requirements and expect the PAYMENT-SIGNATURE header. Successful paid responses include the x402 PAYMENT-RESPONSE header.

For deployment, prefer the stateless x402 entrypoint:

pnpm build
pnpm start:x402

This starts apis/x402-server.ts, which does not import Postgres, RabbitMQ, or the admin UI. Use Dockerfile.x402 for Railway.

Cloudflare Browser Run backend

Cheap browser-priced routes use Cloudflare Browser Run instead of local Chromium:

CLOUDFLARE_ACCOUNT_ID=...
CLOUDFLARE_BROWSER_API_TOKEN=...
CLOUDFLARE_BROWSER_WAIT_UNTIL=domcontentloaded
X402_CLOUDFLARE_BROWSER_MAX_TIMEOUT_MS=45000
X402_LOCAL_BROWSER_MAX_TIMEOUT_MS=120000

The implementation calls Cloudflare's Browser Run REST /content Quick Action, which captures fully rendered HTML after JavaScript execution. Use an API token with Browser Rendering - Edit permission. The public Sapu response contract does not change: browser routes still return html, title, contentHash, elapsedMs, and retrievedAt. When Cloudflare returns it, Sapu also includes cloudflareBrowserMsUsed for unit-cost tracking.

Use /x402/fetch/browser/long or /x402/extract/article/browser/long for slow pages that need a local long-running browser task.

Core

Method Path Purpose
GET /health Health check + feature status
GET /api/articles List articles with classification filter
GET /api/scrape Queue a single URL
GET /api/stories Story clusters via pgvector KNN

URL classification

Method Path Purpose
GET /patterns List all classification patterns
POST /patterns Add a new pattern
POST /classify Classify a URL

Operations

Method Path Purpose
GET /api/status System status + queue depth
GET /api/events/stream Real-time SSE monitoring

Database schema

The schema (schema.sql) is configuration-as-data:

Table Role
configurations Generic key/value JSON (env-driven config)
processors Worker, notifier definitions
url_classification_patterns Regex → classification, with confidence
articles Scraped articles with classification + scoring
worker_stats Heartbeat + throughput per worker

Run pnpm db:init to apply schema and seed reference data.

Configuration

All runtime config via .env:

# Database
DATABASE_URL=postgresql://postgres@localhost:5432/sapu
RABBITMQ_URL=amqp://sapu:sapu123@localhost:5672/sapu

# Embeddings (via OpenRouter)
OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-large

# Admin auth (HMAC signature)
ADMIN_PASSWORD=sapu123

# Telegram (optional)
TELEGRAM_ENABLED=false

The stack

Layer Technology
API Node.js 22, TypeScript 5, Express 4
Workers Node.js 22, puppeteer-extra + stealth, tsx
Database Postgres 15, pgvector
Queue RabbitMQ 3.13
Admin React 19, Vite 5, Tailwind CSS
Cache Postgres (no Redis — single-source-of-truth principle)
Deploy Docker Compose (split-VM: control plane + workers)

Honest limits

This was built for a specific workload and shaped by it:

  • Single-region, single-process — fine for hundreds of sites, not designed for thousands.
  • Postgres-only persistence — no Redis; the queue is the only ephemeral state.
  • Stealth ≠ magic — puppeteer-extra mitigates, but dedicated anti-bot services can still detect it.

If you need horizontal scaling or stronger anti-detection, swap RabbitMQ for Kafka/NATS and look at puppeteer-extra + residential proxy rotation.

License

MIT — see LICENSE.

About

Sapu — sweep articles off the web. Stealth Puppeteer scraper with URL classification, multi-site scheduler, RabbitMQ workers.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages