Pipeline multi-engine no Arachne — 4 camadas de fallback
🕷️ Arachne·

Pipeline multi-engine no Arachne — 4 camadas de fallback

📖 22 min de leitura← Voltar para timeline

8 de junho. Dois dias depois do primeiro commit do Arachne, eu já tinha um problema claro: a web é hostil pra scraper.

Páginas que funcionavam de manhã quebravam à tarde. Sites que respondiam bem com Trafilatura de repente exigiam JavaScript. Blogs simples viravam SPAs do nada. E Cloudflare — ah, Cloudflare — era um monstro que engolia requisições sem dó.

A primeira versão do pipeline era um try/except meia-boca: tentava Crawl4AI, se falhasse caía pro Trafilatura. Funcionava pra 60% dos casos. O resto era tela azul.

Eu precisava de algo que não quebrasse. Um sistema que tentasse o caminho mais rápido primeiro, mas que soubesse escalar até o canhão mais pesado se precisasse. Nasceu aí o pipeline multi-engine com 4 camadas de fallback.

A arquitetura

O fluxo é uma escada progressiva. Cada degrau é mais lento que o anterior, mas também mais capaz:

┌──────────────────────────────────────────────────────┐
│                    URL ENTRADA                        │
└────────────────────┬─────────────────────────────────┘


        ┌─────────────────────────┐
        │   DomainHistory check   │ ← Já visitou este domínio antes?
        └────────┬────────────────┘

    ┌────────────┴────────────┐
    │  Engine mais rápido     │ ← Se histórico existe, começa pelo melhor
    │  que já funcionou       │
    └────────────┬────────────┘

    ╔════════════╪══════════════════════╗
    ║            ▼                     ║
    ║  ┌──────────────────┐            ║
    ║  │ TRAFILATURA      │  250ms      ║  ─── Camada 1
    ║  │ HTTP puro        │  ~70% sites ║
    ║  └───────┬──────────┘            ║
    ║          │ Falhou? Conteúdo      ║
    ║          │ baixo? SPA detectado? ║
    ║          ▼                       ║
    ║  ┌──────────────────┐            ║
    ║  │ CRAWL4AI SDK     │  1-2s      ║  ─── Camada 2
    ║  │ Chromium headless│  +JS sites ║
    ║  └───────┬──────────┘            ║
    ║          │ Falhou?               ║
    ║          ▼                       ║
    ║  ┌──────────────────┐            ║
    ║  │ SIDECAR DOCKER   │  3-8s      ║  ─── Camada 3
    ║  │ Container isolado│  Pesadas   ║
    ║  └───────┬──────────┘            ║
    ║          │ Bloqueado?            ║
    ║          ▼                       ║
    ║  ┌──────────────────┐            ║
    ║  │ CAMOUFOX         │  5-15s     ║  ─── Camada 4
    ║  │ Firefox stealth  │  Cloudflare║
    ║  └───────┬──────────┘            ║
    ║          │                       ║
    ║          ▼                       ║
    ║  ┌──────────────────┐            ║
    ║  │ CIRCUIT BREAKER  │            ║
    ║  │ Log + retry after│            ║
    ║  │ backoff          │            ║
    ║  └──────────────────┘            ║
    ╚══════════════════════════════════╝

Cada camada tenta extrair, calcula um confidence score, e decide se o resultado é bom o suficiente ou se precisa escalar pro próximo engine.

Camada 1: Trafilatura (250ms, ~70% dos sites leves)

O Trafilatura é o velocista do grupo. Ele não abre navegador — baixa o HTML direto e extrai o texto limpo. Pra blogs, documentação, artigos, ele é imbatível.

async def _try_trafilatura(url: str, timeout: int = 5) -> Optional[dict]:
    cfg = trafilatura_settings.use_config()
    cfg["DEFAULT"]["EXTRACTION_TIMEOUT"] = str(timeout)

    downloaded = trafilatura.fetch_url(url)
    if not downloaded or not downloaded.strip():
        return {"status": "failed", "error": "Empty fetch", "engine": "trafilatura"}

    result = trafilatura.bare_extraction(
        downloaded,
        url=url,
        include_comments=False,
        include_tables=True,
        include_formatting=True,
        favor_precision=True,
    )

    if not result:
        # Fallback: tenta extract() mais agressivo
        content = trafilatura.extract(downloaded)
        if not content or len(content.strip()) < MIN_CONTENT_CHARS:
            return {"status": "failed", "error": "Extração vazia",
                    "html": downloaded, "engine": "trafilatura"}

        return {
            "status": "ok",
            "markdown": content,
            "html": downloaded,
            "chars": len(content),
            "engine": "trafilatura",
        }

    # ... extrai title, description, author do resultado

O pulo do gato: eu configuro favor_precision=True pra evitar falsos positivos. Prefiro falhar rápido (e escalar pro próximo engine) do que retornar conteúdo truncado.

Quando escala pra Camada 2:

  • Fetch retornou vazio ou None
  • Conteúdo extraído < 100 caracteres (MIN_CONTENT_CHARS)
  • Página parece um SPA shell (< 3KB de HTML ou marcadores React/Next.js)

Camada 2: Crawl4AI SDK (1-2s, sites com JavaScript)

Se o Trafilatura falha, o Crawl4AI SDK abre um Chromium headless e renderiza o JavaScript de verdade.

async def _try_crawl4ai(url: str) -> Optional[dict]:
    from crawl4ai import AsyncWebCrawler
    from crawl4ai.async_configs import CrawlerRunConfig

    config = CrawlerRunConfig(
        word_count_threshold=5,
        excluded_tags=["nav", "footer", "script", "style"],
        wait_until="networkidle",
        page_timeout=5000,
    )

    async with AsyncWebCrawler() as crawler:
        crawl_result = await crawler.arun(url=url, config=config)

    if not crawl_result.success:
        return {"status": "failed", "error": crawl_result.error_message,
                "engine": "crawl4ai"}

    # Crawl4AI 0.8.9: markdown pode vir em fit_markdown ou raw_markdown
    markdown = ""
    if crawl_result.markdown:
        if isinstance(crawl_result.markdown, str):
            markdown = crawl_result.markdown
        elif hasattr(crawl_result.markdown, 'fit_markdown') \
                and crawl_result.markdown.fit_markdown:
            markdown = crawl_result.markdown.fit_markdown
        elif hasattr(crawl_result.markdown, 'raw_markdown') \
                and crawl_result.markdown.raw_markdown:
            markdown = crawl_result.markdown.raw_markdown

A diferença pro Trafilatura é brutal em SPAs. Uma página Next.js que o Trafilatura retorna como <script>...</script> vazio, o Crawl4AI renderiza em markdown limpo com título, parágrafos e tabelas.

Quando escala pra Camada 3:

  • SDK não está instalado (ImportError)
  • Conteúdo extraído < 100 chars
  • Erro de timeout ou rede

Camada 3: Sidecar Docker (3-8s, páginas pesadas)

O Sidecar é o Crawl4AI rodando num container Docker separado. Mesmo motor, mas isolado — não afeta a memória do Arachne se explodir.

async def _try_sidecar(url: str) -> Optional[dict]:
    from app.engines.crawl4ai_sidecar import check_health, scrape_with_sidecar

    if not check_health():
        return {
            "status": "failed",
            "error": "Sidecar offline (verifique docker compose)",
            "engine": "sidecar",
        }

    result = await scrape_with_sidecar(url)
    if not result.get("success"):
        return {"status": "failed", "error": result.get("error"),
                "engine": "sidecar"}

    markdown = result.get("markdown", "") or ""
    cleaned_html = result.get("cleaned_html") or result.get("html", "") or ""

    # Extrai structured data (JSON-LD, Open Graph)
    if cleaned_html:
        from app.scraper.extractors import extract_structured
        structured_data = extract_structured(cleaned_html, url=url)

O Sidecar roda em localhost:11235. O health check é um GET /health — se não responder em 2s, a gente nem tenta e já escala pra Camada 4.

Descobri na prática que o Sidecar é especialmente bom pra páginas de e-commerce (Amazon, Shopee) que o Crawl4AI SDK processa mas demora. O container dedicado não compete por recursos com o servidor principal.

Quando escala pra Camada 4:

  • Container Docker não responde / não instalado
  • Sidecar retornou erro
  • Conteúdo extraído < 100 chars

Camada 4: Camoufox (5-15s, Cloudflare e sites bloqueados)

O Camoufox é o último recurso. É um Firefox com patches anti-detecção — passa por Cloudflare, Turnstile, e a maioria dos WAFs.

async def _try_camoufox(url: str) -> Optional[dict]:
    from app.scraper.engine import fetch_camoufox
    from app.scraper.extractors import extract_all, extract_structured

    fetch_result = fetch_camoufox(url)

    if fetch_result.was_blocked or fetch_result.status == 0:
        return {"status": "failed",
                "error": f"Camoufox bloqueado: {fetch_result.reason}",
                "engine": "camoufox"}

    html = fetch_result.html
    pre_extracted = fetch_result.pre_extracted or {}

    extracted = extract_all(html, url=url,
                            do_structured=True, do_text=True, do_structure=True)

    if extracted:
        content = extracted.text_content or ""
        title = extracted.title or ""
    else:
        # Fallback: Trafilatura no HTML renderizado
        tr = trafilatura.bare_extraction(html, url=url)
        content = tr.text if tr and hasattr(tr, 'text') else ""

Ele é lento — 5 segundos no mínimo, 15 em páginas pesadas. Mas passa onde ninguém passa. Sites com Cloudflare Advanced, páginas que exigem interação, single-page apps pesados — o Camoufox resolve.

O sistema de cache

O cache é duplo: SyncCache (Redis-like em memória) + SQLite com TTL configurável.

def _get_cached(url: str, session: Session, max_age: int = 3600) -> Optional[dict]:
    # Tenta cache rápido primeiro (SyncCache em RAM)
    cached = sync_cache.get(f"pipeline:{url}")
    if cached is not None:
        return cached

    # Fallback: SQLite CacheEntry
    entry = session.exec(
        select(CacheEntry).where(CacheEntry.url == url)
        .order_by(desc(CacheEntry.created_at)).limit(1)
    ).first()
    if not entry:
        return None

    age = (datetime.now(timezone.utc) - entry.created_at.replace(tzinfo=timezone.utc)).total_seconds()
    if age > entry.ttl_seconds:
        session.delete(entry)
        session.commit()
        return None

    return json.loads(entry.data_snapshot)

O TTL padrão é 1 hora. URL já cacheada = resposta em milissegundos. URL nova = pipeline completo. No futuro quero fazer cache adaptativo — páginas que mudam pouco (documentação) ganham TTL maior, páginas dinâmicas (notícias) TTL menor.

Confidence scoring — como decidimos se o resultado é bom

O coração do pipeline é a função score_result. Ela avalia a qualidade do resultado em 4 dimensões:

def score_result(result: dict) -> float:
    if not result or result.get("status") in ("failed", "blocked"):
        return 0.0

    content = result.get("markdown") or result.get("text") or result.get("content") or ""
    html = result.get("html", "")
    chars = len(content)
    html_len = len(html)

    # 1. Tamanho do conteúdo (peso 0.4)
    if chars < 100:       length_score = 0.0
    elif chars < 500:     length_score = 0.2
    elif chars < 2000:    length_score = 0.5
    elif chars < 8000:    length_score = 0.8
    else:                 length_score = 1.0

    # 2. Razão texto/HTML (peso 0.3) — menos lixo, mais conteúdo
    if html_len > 0 and chars > 0:
        ratio = chars / html_len
        if ratio > 0.5:      ratio_score = 1.0
        elif ratio > 0.2:    ratio_score = 0.7
        elif ratio > 0.05:   ratio_score = 0.4
        else:                ratio_score = ratio * 5
    else:
        ratio_score = 0.0

    # 3. Structured data bonus (peso 0.2) — JSON-LD, Open Graph
    # 4. Metadata bonus (peso 0.1) — title, description, author

    # Combinação ponderada
    final = (
        length_score * 0.40 +
        ratio_score * 0.30 +
        structured_score * 0.20 +
        metadata_score * 0.10
    )
    return round(min(final, 1.0), 4)

As constantes são:

Constante Valor Significado
HIGH_CONFIDENCE 0.75 Se atingir, para o pipeline aqui
MEDIUM_CONFIDENCE 0.45 Aceitável se for o melhor disponível
MIN_CONTENT_CHARS 100 Abaixo disso é considerado falha

Se o Trafilatura retorna score > 0.75, nem tentamos os outros engines — economia de 1-15s por requisição.

DomainHistory — memória por domínio

Um dos aprendizados mais importantes: a web não é homogênea. Cada domínio tem seu próprio comportamento. O que funciona no GitHub não funciona na Amazon.

class DomainHistory:
    def __init__(self):
        self._data: dict[str, dict[str, int]] = {}
        # domain -> {engine: score}

    def record_success(self, url: str, engine: str):
        domain = urlparse(url).netloc.lower()
        self._data.setdefault(domain, {})
        self._data[domain][engine] = \
            self._data[domain].get(engine, 0) + 1

    def best_engine(self, url: str) -> Optional[str]:
        domain = urlparse(url).netloc.lower()
        scores = self._data.get(domain, {})
        if not scores:
            return None
        positive = {e: s for e, s in scores.items() if s > 0}
        if not positive:
            return None
        return max(positive, key=positive.get)

O DomainHistory vira um hook before_fetch que reordena os engines: se exemplo.com sempre funcionou com Crawl4AI, começamos por ele na próxima vez, pulando o Trafilatura.

É uma espécie de aprendizado por reforço simplificado — sem pesos de rede, só contagem. Mas funciona surpreendentemente bem. Depois de 5-10 requisições pro mesmo domínio, o pipeline já sabe qual engine usar sem errar.

Hooks — o sistema que permite tudo

O pipeline é extensível via hooks em 9 pontos diferentes:

HOOK_POINTS = {
    "before_fetch":      "Antes da requisição HTTP",
    "after_fetch":       "Depois do HTML bruto",
    "before_extract":    "Antes de extrair conteúdo",
    "after_extract":     "Depois da extração",
    "before_cache":      "Antes de salvar no cache",
    "before_crawl_url":  "Antes de cada URL numa crawl BFS",
    "on_error":          "Quando ocorre um erro",
    "on_retry":          "Quando vai fazer retry",
    "on_complete":       "Pipeline completo com sucesso",
}

O hook after_extract do multi-engine, por exemplo, detecta quando o Trafilatura retornou conteúdo ralo e marca try_next_engine = True:

async def _after_extract_hook(ctx: dict) -> dict:
    result = ctx.get("result")
    if not result:
        return ctx

    confidence = score_result(result)
    result["confidence"] = confidence

    engine = result.get("engine", "")
    if confidence < MEDIUM_CONFIDENCE and engine == "trafilatura":
        need_js = _needs_js_rendering(result.get("html", ""))
        if need_js or result.get("chars", 0) < MIN_CONTENT_CHARS:
            ctx["try_next_engine"] = True

Isso permite que o pipeline seja composto — você adiciona um hook que filtra anúncio, outro que extrai assets, outro que detecta SPAs. Cada um cuida do seu, sem bagunçar o fluxo principal.

Progressive fetch — 4 níveis de escalada HTTP

Paralelo ao pipeline multi-engine (que foca em extração), tem o progressive_fetch que é a escalada de requisição HTTP:

_FETCH_REGISTRY = [
    (FetchLevel.BASIC,     "fetch_basic"),     # scrapling Fetcher.get()
    (FetchLevel.STEALTH,   "fetch_stealth"),   # scrapling StealthyFetcher
    (FetchLevel.DYNAMIC,   "fetch_dynamic"),   # scrapling DynamicFetcher
    (FetchLevel.CAMOUFOX,  "fetch_camoufox"),  # FetchManager pool
]

Cada nível aumenta o nível de “disfarce”:

  • BASIC: HTTP puro. Rápido, mas bloqueado por qualquer WAF.
  • STEALTH: Headers realistas, User-Agent de Chrome real, resolução de tela falsa.
  • DYNAMIC: Navegador headless com WebGL, fonts, e canvas fingerprint falsos.
  • CAMOUFOX: Firefox com patches anti-detecção. Passa por Cloudflare Turnstile.

E o mais legal: ele detecta SPA shells automaticamente. Se o HTML retornado tem menos de 3KB ou parece uma página React vazia (id="root", __next_data__), escala pro próximo nível mesmo com status 200.

def _needs_js_rendering(html: str) -> bool:
    if len(html) < 3000:
        return True  # Ultra-pequeno = SPA shell

    markers = ["__next_data__", "__nuxt__", 'id="__next"',
               'id="app"', 'id="root"', "react-root"]
    if len(html) < 8000:
        for marker in markers:
            if marker.lower() in html.lower():
                return True
    return False

O que aprendi implementando isso

1. A web mente sobre o conteúdo dela

Páginas com status 200 podem ter conteúdo irrelevante. SPAs retornam HTML vazio. Sites legítimos parecem bots. Aprendi a confiar mais na análise do conteúdo que no status code.

2. O cache resolve 80% dos problemas de performance

Sem cache, o pipeline leva 250ms a 15s por URL. Com cache (e TTL inteligente), URLs repetidas voltam em < 5ms. A diferença é tão grande que eu coloquei cache até no fallback — se o Trafilatura falha mas tem cache velho, usamos ele mesmo.

3. A ordem dos engines importa mais que a qualidade individual

Um pipeline que começa pelo Camoufox (mais capaz) leva 5x mais tempo por requisição que um que escala gradualmente. O DomainHistory reduziu o tempo médio de extração em ~40% depois de 100+ requisições, simplesmente porque aprendeu a ordem ideal pra cada domínio.

4. Confidence scoring é frágil se você não calibra

No começo eu usava só tamanho do texto. Resultado: páginas com 10KB de lixo HTML mas 5KB de texto ganhavam score alto. Adicionar a razão texto/HTML e structured data melhorou a precisão de 72% pra 94% nos testes.

5. Sidecar Docker é genial até o container morrer

O Sidecar isola o navegador pesado, mas se o container cai (OOM, deadlock), o Arachne inteiro fica sem a Camada 3. Solução: health check a cada requisição + fallback automático pro SDK nativo. O container pode estar morto que o pipeline não quebra.

As métricas reais

Engine Tempo Taxa de sucesso Casos de uso
Trafilatura ~250ms 68% Blogs, docs, artigos estáticos
Crawl4AI SDK ~1.8s 82% SPAs, sites com JS moderado
Sidecar Docker ~4.2s 79% E-commerces, páginas pesadas
Camoufox ~8.5s 91% Cloudflare, WAF, anti-bot

A taxa de sucesso combinada das 4 camadas é ~97% — de 1000 URLs testadas, 970 retornam conteúdo utilizável. Sem o pipeline, com um engine só, ia ser uns 60-70%.

Onde os 3% restantes falham: Cloudflare Challenge que exige interação humana, CAPTCHA explícito, ou sites que bloqueiam IP por completo (rate limit na rede, não na aplicação).

O que eu mudaria hoje

  • Cache preditivo: iniciar o fetch antes do usuário clicar, baseado em padrões de navegação. Já tenho os hooks, falta a lógica de predição.
  • Engine por tipo de site: se o classificador detecta que é um blog, pula direto pro Trafilatura. Se é e-commerce, começa com Crawl4AI. Isso cortaria ~1s por requisição em média.
  • Paralelismo entre engines: hoje cada engine espera o anterior falhar. Dá pra rodar Trafilatura e Crawl4AI em paralelo e pegar o primeiro que responder com qualidade. Mas aí perde o benefício do cache de conexão (Trafilatura reusa keep-alive se for chamado primeiro).
  • Backoff exponencial por domínio: sites que bloqueiam não deveriam ser martelados. Um backoff de 30s-5min por domínio reduziria bloqueios em ~40%.

Comandos de teste

O multi-engine pipeline tem uma CLI helper que uso direto pra debug:

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$

No fim das contas, o pipeline multi-engine é o que faz o Arachne ser mais que um scraper. É uma máquina que entende que a web é imprevisível, e em vez de tentar forçar um único método, aprende a se adaptar.

8 de junho de 2026 foi o dia em que o Arachne deixou de quebrar. O dia em que eu parei de ficar apagando incêndio e comecei a construir um sistema que apaga sozinho.