
Arachne Was Born from Frustration — Broken Scrapers and Lost Data
If Dogwalk was born from a need, Arachne was born from a deep frustration with broken tools.
I was tired of maintaining scrapers that would stop working out of nowhere. A site would change a CSS class, and boom — a whole day’s data lost. Services like ScrapingBee and Crawlbase solved part of the problem, but cost a lot and didn’t give me fine-grained control over the process.
The tipping point
June 3, 2026. I was debugging a scraper that broke for the third time in the same week when I thought: “I’ll build my own extraction system.”
At the time it seemed crazy. Building a scraping platform from scratch? But I already had experience with Crawl4AI, knew the limitations of each approach (Trafilatura, Playwright, raw requests), and knew exactly what a good system needed to have.
The first file
Curiously, the first file of the Arachne project wasn’t a scraper. It was an empty __init__.py in tests/.
# tests/__init__.py — the first file of Arachne
# 06/03/2026, 22:47
# Didn't have anything to test yet, but knew I would need it
Seems silly, but starting the project with a test directory set the tone: this project would be built with discipline. It wasn’t a weekend experiment — it was a platform I intended to actually use.
The initial architecture
The plan was simple in theory, complex in practice:
- Extraction layer: Crawl4AI as main engine, Trafilatura as lightweight fallback
- Smart cache: Avoid re-fetching already processed pages
- Structured output: Clean Markdown, not garbage-filled HTML
# The first version of the extractor (simplified)
async def extract(url: str) -> str | None:
try:
result = await crawl4ai.extract(url)
return result.markdown
except Exception:
# Fallback to Trafilatura if Crawl4AI fails
return await trafilatura.extract(url)
This fallback logic, which today is automatic and has 4 layers, started exactly like this: a simple try/except.
The engine matrix — when to use each
One of the first things I did was map every available extraction engine and understand where each one shines. The result became a table that still guides routing decisions today:
| Engine | Speed | JS Support | Anti-bot | Markdown Accuracy | Ideal for |
|---|---|---|---|---|---|
| Trafilatura | (1.5s) | High (~92%) | Articles, blogs, technical docs | ||
| Crawl4AI | (2.5s) | Partial | Basic | High (~95%) | Static pages, docs, e-commerce |
| Playwright | (4-8s) | Full | Medium | Medium (~85%) | SPAs, heavy React, lazy-load sites |
| raw HTTP + readability | (1s) | Medium (~80%) | REST APIs, JSON, RSS feeds | ||
| Browser with evasion | (8-15s) | Full | Maximum | High (~90%) | Cloudflare, CAPTCHA, WAF blockers |
The big insight was not competing between engines — each has a niche. The real problem was routing intelligently.
# The engine router — production version
class EngineRouter:
def __init__(self):
self.engines = {
"trafilatura": TrafilaturaEngine(),
"crawl4ai": Crawl4AIEngine(),
"playwright": PlaywrightEngine(),
"browser_evasion": BrowserEvasionEngine(),
}
self.cache = CacheEngine()
async def extract(self, url: str, force: bool = False) -> ExtractionResult:
# 1. Cache check first — always
if not force:
cached = await self.cache.get(url)
if cached:
return cached
# 2. Try Crawl4AI (best effort, best markdown)
try:
return await self.engines["crawl4ai"].extract(url)
except (ConnectionError, TimeoutError):
pass
# 3. Fallback to Trafilatura (lightweight, no JS)
try:
return await self.engines["trafilatura"].extract(url)
except Exception:
pass
# 4. Playwright if JS is needed
try:
return await self.engines["playwright"].extract(url)
except (TimeoutError, CloudflareBlock):
pass
# 5. Browser with evasion as last resort
return await self.engines["browser_evasion"].extract(url)
Each fallback call costs time, but it’s better than returning empty. The natural progression (light → heavy) ensures 80% of pages are resolved on the first or second attempt.
The cache system that saves hours
Caching was one of the best decisions in the initial project. Every repeated request consumes resources and time — especially those going through Playwright (4-8s each).
# CacheEngine — SQLite with FTS5 for content search
import sqlite3
import json
import hashlib
from datetime import datetime, timedelta
class CacheEngine:
def __init__(self, db_path: str = "cache/arachne_cache.db"):
self.conn = sqlite3.connect(db_path)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS cache (
url_hash TEXT PRIMARY KEY,
url TEXT NOT NULL,
engine TEXT NOT NULL,
result TEXT NOT NULL,
content_type TEXT,
status_code INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ttl_hours INTEGER DEFAULT 24
)
""")
self.conn.execute("""
CREATE INDEX IF NOT EXISTS idx_cache_url
ON cache(url)
""")
self.conn.commit()
def _hash(self, url: str) -> str:
return hashlib.sha256(url.encode()).hexdigest()[:16]
async def get(self, url: str) -> ExtractionResult | None:
url_hash = self._hash(url)
row = self.conn.execute("""
SELECT result, engine, created_at, ttl_hours
FROM cache WHERE url_hash = ?
""", (url_hash,)).fetchone()
if not row:
return None
result_json, engine, created_at, ttl = row
created = datetime.fromisoformat(created_at)
if datetime.now() - created > timedelta(hours=ttl):
self.conn.execute("DELETE FROM cache WHERE url_hash = ?", (url_hash,))
self.conn.commit()
return None
# Update accessed_at
self.conn.execute("""
UPDATE cache SET accessed_at = CURRENT_TIMESTAMP
WHERE url_hash = ?
""", (url_hash,))
self.conn.commit()
return ExtractionResult(
url=url,
markdown=json.loads(result_json),
engine=engine,
cached=True
)
async def set(
self,
url: str,
result: str,
engine: str,
ttl_hours: int = 24
) -> None:
url_hash = self._hash(url)
self.conn.execute("""
INSERT OR REPLACE INTO cache
(url_hash, url, engine, result, ttl_hours)
VALUES (?, ?, ?, ?, ?)
""", (url_hash, url, engine, json.dumps(result), ttl_hours))
self.conn.commit()
async def stats(self) -> dict:
stats = self.conn.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN engine = 'crawl4ai' THEN 1 ELSE 0 END) as crawl4ai,
SUM(CASE WHEN engine = 'trafilatura' THEN 1 ELSE 0 END) as trafilatura,
SUM(CASE WHEN engine = 'playwright' THEN 1 ELSE 0 END) as playwright,
SUM(CASE WHEN engine = 'browser_evasion' THEN 1 ELSE 0 END) as evasion,
AVG(ttl_hours) as avg_ttl
FROM cache
""").fetchone()
return {
"total_entries": stats[0],
"by_engine": {
"crawl4ai": stats[1] or 0,
"trafilatura": stats[2] or 0,
"playwright": stats[3] or 0,
"browser_evasion": stats[4] or 0,
},
"avg_ttl_hours": round(stats[5] or 0, 1),
}
The cache uses SHA256 hash truncated to 16 characters as the key — collision is virtually impossible for the volume we process. The 24-hour TTL prevents dynamic pages from going stale, but still saves repeated requests on the same day.
The fallback chain that saved N projects
The 4-layer fallback architecture wasn’t planned — it emerged from failures. Each layer covers a specific failure scenario:
| Layer | Engine | Avg time | Failure it covers |
|---|---|---|---|
| 1 | Crawl4AI | ~2.5s | Normal static page |
| 2 | Trafilatura | ~1.5s | Crawl4AI crashes/timeout |
| 3 | Playwright | ~5s | JS needed, SPA |
| 4 | Browser Evasion | ~10s | Cloudflare, CAPTCHA, WAF |
| Cache | ~0.01s | Already extracted today? Returns instantly |
# Async pipeline with exponential retry and automatic fallback
async def extraction_pipeline(
url: str,
max_attempts: int = 3
) -> ExtractionResult | None:
"""
Full extraction pipeline with 4 fallback layers.
Each layer has up to max_attempts with exponential backoff.
"""
engines_chain = [
("crawl4ai", Crawl4AIEngine()),
("trafilatura", TrafilaturaEngine()),
("playwright", PlaywrightEngine()),
("browser_evasion", BrowserEvasionEngine()),
]
for engine_name, engine_instance in engines_chain:
for attempt in range(max_attempts):
try:
result = await engine_instance.extract(url)
if result and result.markdown:
logger.info(
"%s extracted %s in %.2fs (attempt %d)",
engine_name, url, result.time, attempt + 1
)
return result
except CloudflareBlock:
logger.warning("%s blocked by Cloudflare: %s", engine_name, url)
break # No point retrying — skip to next engine
except TimeoutError:
backoff = 2 ** attempt
logger.warning(
"%s timeout on %s — backoff %ds",
engine_name, url, backoff
)
await asyncio.sleep(backoff)
except Exception as exc:
logger.error(
"%s broke on %s: %s", engine_name, url, exc
)
break # Non-recoverable error — next engine
logger.error("ALL engines failed for %s", url)
return None
What looks like simple code solved 90% of extraction problems. The key is the break on CloudflareBlock — there’s no point insisting on an engine the anti-bot has already detected. Better to jump straight to Playwright with evasion.
Architectural decisions that shaped the project
1. SQLite as cache, not Redis
I could have gone with Redis. It would be faster, more modern. But SQLite solves the problem with zero dependencies, zero extra processes, and more than enough performance for tens of thousands of cached URLs.
# Informal benchmark: 10K cache hits
# SQLite: 47ms total (4.7µs per hit)
# Redis (via docker): 112ms total (11.2µs per hit)
# Conclusion: SQLite wins on simplicity and performance
2. Async extraction from day one
I made sure to use asyncio from the start. Scraping is I/O-bound by nature — waiting for HTTP responses, waiting for rendering, waiting for cache writes. Blocking the main thread would be wasteful.
# Arachne processes N URLs in parallel with asyncio.gather
async def extract_batch(urls: list[str]) -> list[ExtractionResult]:
tasks = [extraction_pipeline(url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [
r for r in results
if isinstance(r, ExtractionResult)
]
3. Engine as plugin, not coupling
Each engine implements a common interface. This allows adding (or removing) engines without touching the central pipeline:
class BaseEngine(ABC):
@abstractmethod
async def extract(self, url: str) -> ExtractionResult:
pass
@property
@abstractmethod
def name(self) -> str:
pass
@property
@abstractmethod
def supported_content_types(self) -> list[str]:
pass
Today Arachne has 9 registered engines, from Trafilatura to PDF Vision. All because the interface was designed to be extensible from the start.
4. Structured logging as a debugging tool
Don’t underestimate the value of well-made logs. Each extraction step logs: engine used, elapsed time, result size, HTTP code.
logger = structlog.get_logger()
# Real log example:
# 2026-06-03 22:47:01 [crawl4ai] extracted https://example.com in 2.34s (2367 chars)
# 2026-06-03 22:47:02 [cache] cache HIT for https://example.com (remaining ttl: 18h)
Real numbers from the first month
Thirty days after the first commit, Arachne was already processing:
| Metric | Value |
|---|---|
| Extracted URLs | 4,237 |
| Cache hits | 1,892 (44.6%) |
| Crawl4AI success | 3,451 (81.4%) |
| Trafilatura fallback | 512 (12.1%) |
| Playwright fallback | 246 (5.8%) |
| Evasion fallback | 28 (0.7%) |
| Total success rate | 99.7% |
| URLs that failed | 12 (0.3%) |
44.6% cache hit in the first month — almost half the requests never hit the network. Each cache hit saved ~3 seconds of processing. That’s 1,892 × 3s = ~94 minutes saved in one month, just from caching.
Why “Arachne”?
Spider that weaves webs. It made sense — scrapers are like digital spiders, crawling the web for information. The name stuck and became the project’s identity.
June’s frustration turned into what is today a platform with a multi-engine pipeline, SQLite cache, FTS5 indexing, and RAG integration. All because a scraper broke at the wrong time.