Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- Dockerfile (5).txt +67 -0
- app-4.py +577 -0
- models-3.py +284 -0
- requirements-4.txt +57 -0
- scraper-3.py +268 -0
- utils-4.py +395 -0
Dockerfile (5).txt
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# Stage 1 : builder — compile les wheels
|
| 3 |
+
# ============================================================
|
| 4 |
+
FROM python:3.12-slim AS builder
|
| 5 |
+
|
| 6 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 7 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 8 |
+
PIP_NO_CACHE_DIR=1 \
|
| 9 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1
|
| 10 |
+
|
| 11 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 12 |
+
gcc g++ git curl \
|
| 13 |
+
libxml2-dev libxslt1-dev zlib1g-dev \
|
| 14 |
+
libffi-dev libssl-dev \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
WORKDIR /build
|
| 18 |
+
COPY requirements.txt .
|
| 19 |
+
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# ============================================================
|
| 22 |
+
# Stage 2 : production — image minimale
|
| 23 |
+
# ============================================================
|
| 24 |
+
FROM python:3.12-slim
|
| 25 |
+
|
| 26 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 27 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 28 |
+
PORT=7860 \
|
| 29 |
+
PYTHONPATH=/app
|
| 30 |
+
|
| 31 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 32 |
+
curl \
|
| 33 |
+
libxml2 libxslt1.1 \
|
| 34 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 35 |
+
&& apt-get clean
|
| 36 |
+
|
| 37 |
+
# Utilisateur non-root
|
| 38 |
+
RUN useradd -m -u 1000 -s /bin/bash scraper \
|
| 39 |
+
&& mkdir -p /app/cache /app/logs \
|
| 40 |
+
&& chown -R scraper:scraper /app
|
| 41 |
+
|
| 42 |
+
WORKDIR /app
|
| 43 |
+
|
| 44 |
+
# Installer les wheels compilés
|
| 45 |
+
COPY --from=builder /wheels /wheels
|
| 46 |
+
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* \
|
| 47 |
+
&& rm -rf /wheels
|
| 48 |
+
|
| 49 |
+
# Copier le code applicatif
|
| 50 |
+
COPY --chown=scraper:scraper app.py models.py scraper.py utils.py ./
|
| 51 |
+
|
| 52 |
+
USER scraper
|
| 53 |
+
|
| 54 |
+
ENV HF_HOME=/app/cache \
|
| 55 |
+
NUMEXPR_MAX_THREADS=4 \
|
| 56 |
+
OMP_NUM_THREADS=4 \
|
| 57 |
+
# Scrapy asyncio reactor — doit être défini avant tout import
|
| 58 |
+
SCRAPY_SETTINGS_MODULE=""
|
| 59 |
+
|
| 60 |
+
HEALTHCHECK --interval=20s --timeout=5s --start-period=40s --retries=2 \
|
| 61 |
+
CMD curl -f -m 4 http://localhost:${PORT}/health || exit 1
|
| 62 |
+
|
| 63 |
+
EXPOSE ${PORT}
|
| 64 |
+
|
| 65 |
+
# Scrapy install_reactor doit être importé AVANT Twisted/Scrapy au démarrage.
|
| 66 |
+
# On passe par un petit script d'amorçage pour garantir l'ordre des imports.
|
| 67 |
+
CMD ["sh", "-c", "python -c 'import scrapy.utils.reactor; scrapy.utils.reactor.install_reactor(\"twisted.internet.asyncioreactor.AsyncioSelectorReactor\")' && uvicorn app:app --host 0.0.0.0 --port ${PORT} --workers 1 --loop uvloop --http httptools --log-level warning --no-access-log --limit-concurrency 100 --timeout-keep-alive 30"]
|
app-4.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Scraper SOTA v3 — FastAPI + Scrapy + Pydantic v2.
|
| 3 |
+
|
| 4 |
+
Architecture :
|
| 5 |
+
- FastAPI gère les routes HTTP et la validation (Pydantic v2)
|
| 6 |
+
- Scrapy est le moteur de crawl primaire (retry, throttle, middlewares intégrés)
|
| 7 |
+
- curl_cffi / cloudscraper / httpx sont des fallbacks pour les sites protégés
|
| 8 |
+
- structlog + prometheus pour l'observabilité
|
| 9 |
+
- Cache LRU en mémoire (remplace le dict manuel)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import logging
|
| 16 |
+
import time
|
| 17 |
+
from collections import OrderedDict
|
| 18 |
+
from contextlib import asynccontextmanager
|
| 19 |
+
from datetime import datetime, timezone
|
| 20 |
+
from typing import Any, Optional
|
| 21 |
+
from urllib.parse import urlparse
|
| 22 |
+
|
| 23 |
+
import httpx
|
| 24 |
+
import orjson
|
| 25 |
+
import structlog
|
| 26 |
+
from curl_cffi import requests as curl_requests
|
| 27 |
+
import cloudscraper
|
| 28 |
+
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
| 29 |
+
from fastapi.responses import ORJSONResponse, Response
|
| 30 |
+
from prometheus_client import (
|
| 31 |
+
CONTENT_TYPE_LATEST,
|
| 32 |
+
Counter,
|
| 33 |
+
Gauge,
|
| 34 |
+
Histogram,
|
| 35 |
+
generate_latest,
|
| 36 |
+
)
|
| 37 |
+
from tenacity import (
|
| 38 |
+
retry,
|
| 39 |
+
retry_if_exception_type,
|
| 40 |
+
stop_after_attempt,
|
| 41 |
+
wait_exponential,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
from models import (
|
| 45 |
+
ContentData,
|
| 46 |
+
ExtractionConfig,
|
| 47 |
+
ExtractionMode,
|
| 48 |
+
HealthResponse,
|
| 49 |
+
ImagesData,
|
| 50 |
+
ImageItem,
|
| 51 |
+
LinksData,
|
| 52 |
+
LinkItem,
|
| 53 |
+
MetadataData,
|
| 54 |
+
PerformanceMetrics,
|
| 55 |
+
ScrapingMethod,
|
| 56 |
+
ScrapeOptions,
|
| 57 |
+
ScrapeRequest,
|
| 58 |
+
ScrapeResponse,
|
| 59 |
+
settings,
|
| 60 |
+
)
|
| 61 |
+
from scraper import PageItem, ScrapyRunner, scrapy_runner
|
| 62 |
+
from utils import ContentCleaner, URLValidator
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# Logging structuré
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
structlog.configure(
|
| 69 |
+
processors=[
|
| 70 |
+
structlog.stdlib.filter_by_level,
|
| 71 |
+
structlog.processors.TimeStamper(fmt="iso"),
|
| 72 |
+
structlog.stdlib.add_logger_name,
|
| 73 |
+
structlog.stdlib.add_log_level,
|
| 74 |
+
structlog.processors.StackInfoRenderer(),
|
| 75 |
+
structlog.processors.format_exc_info,
|
| 76 |
+
structlog.processors.JSONRenderer(serializer=orjson.dumps),
|
| 77 |
+
],
|
| 78 |
+
wrapper_class=structlog.stdlib.BoundLogger,
|
| 79 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 80 |
+
cache_logger_on_first_use=True,
|
| 81 |
+
)
|
| 82 |
+
log = structlog.get_logger()
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# Métriques Prometheus
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
|
| 88 |
+
REQUESTS_TOTAL = Counter("scraper_requests_total", "Total requests", ["method", "status"])
|
| 89 |
+
REQUESTS_DURATION = Histogram("scraper_duration_seconds", "Request duration", ["method"])
|
| 90 |
+
ACTIVE_REQUESTS = Gauge("scraper_active_requests", "Active requests")
|
| 91 |
+
CACHE_HITS = Counter("scraper_cache_hits_total", "Cache hits")
|
| 92 |
+
CACHE_MISSES = Counter("scraper_cache_misses_total", "Cache misses")
|
| 93 |
+
ERRORS_TOTAL = Counter("scraper_errors_total", "Errors", ["error_type"])
|
| 94 |
+
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
# Cache LRU en mémoire
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class LRUCache:
|
| 101 |
+
"""Cache LRU thread-safe (asyncio) avec TTL par entrée."""
|
| 102 |
+
|
| 103 |
+
def __init__(self, max_size: int, default_ttl: int) -> None:
|
| 104 |
+
self._store: OrderedDict[str, tuple[Any, float]] = OrderedDict()
|
| 105 |
+
self.max_size = max_size
|
| 106 |
+
self.default_ttl = default_ttl
|
| 107 |
+
self._hits = 0
|
| 108 |
+
self._misses = 0
|
| 109 |
+
|
| 110 |
+
def get(self, key: str) -> Optional[Any]:
|
| 111 |
+
if key not in self._store:
|
| 112 |
+
self._misses += 1
|
| 113 |
+
CACHE_MISSES.inc()
|
| 114 |
+
return None
|
| 115 |
+
value, expires_at = self._store[key]
|
| 116 |
+
if time.monotonic() > expires_at:
|
| 117 |
+
del self._store[key]
|
| 118 |
+
self._misses += 1
|
| 119 |
+
CACHE_MISSES.inc()
|
| 120 |
+
return None
|
| 121 |
+
self._store.move_to_end(key)
|
| 122 |
+
self._hits += 1
|
| 123 |
+
CACHE_HITS.inc()
|
| 124 |
+
return value
|
| 125 |
+
|
| 126 |
+
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
|
| 127 |
+
effective_ttl = ttl if ttl is not None else self.default_ttl
|
| 128 |
+
if key in self._store:
|
| 129 |
+
self._store.move_to_end(key)
|
| 130 |
+
elif len(self._store) >= self.max_size:
|
| 131 |
+
self._store.popitem(last=False) # évicte le plus ancien
|
| 132 |
+
self._store[key] = (value, time.monotonic() + effective_ttl)
|
| 133 |
+
|
| 134 |
+
@property
|
| 135 |
+
def size(self) -> int:
|
| 136 |
+
return len(self._store)
|
| 137 |
+
|
| 138 |
+
@property
|
| 139 |
+
def hit_rate(self) -> float:
|
| 140 |
+
total = self._hits + self._misses
|
| 141 |
+
return self._hits / total if total else 0.0
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ---------------------------------------------------------------------------
|
| 145 |
+
# État global du worker
|
| 146 |
+
# ---------------------------------------------------------------------------
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class WorkerState:
|
| 150 |
+
def __init__(self) -> None:
|
| 151 |
+
self.start_time = time.monotonic()
|
| 152 |
+
self.total_requests = 0
|
| 153 |
+
self.active_requests = 0
|
| 154 |
+
self.total_errors = 0
|
| 155 |
+
|
| 156 |
+
self.cache = LRUCache(
|
| 157 |
+
max_size=settings.cache_max_size,
|
| 158 |
+
default_ttl=settings.cache_ttl,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
self._response_times: list[float] = []
|
| 162 |
+
self._rt_max = 1000 # fenêtre glissante
|
| 163 |
+
|
| 164 |
+
# HTTP clients alternatifs (fallback)
|
| 165 |
+
self._cloudscraper = cloudscraper.create_scraper(
|
| 166 |
+
browser={"browser": "chrome", "platform": "windows", "mobile": False},
|
| 167 |
+
delay=10,
|
| 168 |
+
)
|
| 169 |
+
self._httpx_client: Optional[httpx.AsyncClient] = None
|
| 170 |
+
|
| 171 |
+
async def get_httpx_client(self) -> httpx.AsyncClient:
|
| 172 |
+
if self._httpx_client is None:
|
| 173 |
+
limits = httpx.Limits(
|
| 174 |
+
max_connections=settings.max_concurrent_requests,
|
| 175 |
+
max_keepalive_connections=settings.max_concurrent_requests // 2,
|
| 176 |
+
keepalive_expiry=30,
|
| 177 |
+
)
|
| 178 |
+
self._httpx_client = httpx.AsyncClient(
|
| 179 |
+
timeout=httpx.Timeout(settings.request_timeout),
|
| 180 |
+
limits=limits,
|
| 181 |
+
follow_redirects=settings.follow_redirects,
|
| 182 |
+
http2=True,
|
| 183 |
+
verify=settings.verify_ssl,
|
| 184 |
+
)
|
| 185 |
+
return self._httpx_client
|
| 186 |
+
|
| 187 |
+
def record_response_time(self, duration: float) -> None:
|
| 188 |
+
self._response_times.append(duration)
|
| 189 |
+
if len(self._response_times) > self._rt_max:
|
| 190 |
+
self._response_times.pop(0)
|
| 191 |
+
|
| 192 |
+
@property
|
| 193 |
+
def avg_response_time(self) -> float:
|
| 194 |
+
if not self._response_times:
|
| 195 |
+
return 0.0
|
| 196 |
+
return sum(self._response_times) / len(self._response_times)
|
| 197 |
+
|
| 198 |
+
@property
|
| 199 |
+
def error_rate(self) -> float:
|
| 200 |
+
if self.total_requests == 0:
|
| 201 |
+
return 0.0
|
| 202 |
+
return self.total_errors / self.total_requests
|
| 203 |
+
|
| 204 |
+
async def close(self) -> None:
|
| 205 |
+
if self._httpx_client:
|
| 206 |
+
await self._httpx_client.aclose()
|
| 207 |
+
await scrapy_runner.shutdown()
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
state = WorkerState()
|
| 211 |
+
|
| 212 |
+
# ---------------------------------------------------------------------------
|
| 213 |
+
# Lifecycle FastAPI
|
| 214 |
+
# ---------------------------------------------------------------------------
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@asynccontextmanager
|
| 218 |
+
async def lifespan(app: FastAPI):
|
| 219 |
+
log.info(
|
| 220 |
+
"worker_startup",
|
| 221 |
+
worker_id=settings.worker_id,
|
| 222 |
+
environment=settings.environment,
|
| 223 |
+
)
|
| 224 |
+
yield
|
| 225 |
+
log.info("worker_shutdown", worker_id=settings.worker_id)
|
| 226 |
+
await state.close()
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# ---------------------------------------------------------------------------
|
| 230 |
+
# Application
|
| 231 |
+
# ---------------------------------------------------------------------------
|
| 232 |
+
|
| 233 |
+
app = FastAPI(
|
| 234 |
+
title="Scraper SOTA v3",
|
| 235 |
+
description="Worker de scraping haute performance — FastAPI + Scrapy + Pydantic v2",
|
| 236 |
+
version="3.0.0",
|
| 237 |
+
lifespan=lifespan,
|
| 238 |
+
default_response_class=ORJSONResponse,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# ---------------------------------------------------------------------------
|
| 242 |
+
# Middleware
|
| 243 |
+
# ---------------------------------------------------------------------------
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@app.middleware("http")
|
| 247 |
+
async def timing_middleware(request: Request, call_next):
|
| 248 |
+
t0 = time.perf_counter()
|
| 249 |
+
state.active_requests += 1
|
| 250 |
+
ACTIVE_REQUESTS.set(state.active_requests)
|
| 251 |
+
try:
|
| 252 |
+
response = await call_next(request)
|
| 253 |
+
elapsed = time.perf_counter() - t0
|
| 254 |
+
response.headers["X-Process-Time"] = f"{elapsed:.4f}"
|
| 255 |
+
response.headers["X-Worker-ID"] = settings.worker_id
|
| 256 |
+
state.record_response_time(elapsed)
|
| 257 |
+
return response
|
| 258 |
+
finally:
|
| 259 |
+
state.active_requests -= 1
|
| 260 |
+
ACTIVE_REQUESTS.set(state.active_requests)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# ---------------------------------------------------------------------------
|
| 264 |
+
# Routes
|
| 265 |
+
# ---------------------------------------------------------------------------
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@app.get("/")
|
| 269 |
+
async def root():
|
| 270 |
+
return {
|
| 271 |
+
"service": "Scraper SOTA",
|
| 272 |
+
"version": "3.0.0",
|
| 273 |
+
"worker_id": settings.worker_id,
|
| 274 |
+
"status": "operational",
|
| 275 |
+
"engine": "Scrapy + FastAPI + Pydantic v2",
|
| 276 |
+
"features": [
|
| 277 |
+
"scrapy-primary-crawler",
|
| 278 |
+
"curl_cffi / cloudscraper fallback",
|
| 279 |
+
"pydantic-v2-strict-models",
|
| 280 |
+
"lru-cache",
|
| 281 |
+
"prometheus-metrics",
|
| 282 |
+
"structured-logging",
|
| 283 |
+
"multi-method-extraction",
|
| 284 |
+
],
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
@app.get("/health", response_model=HealthResponse)
|
| 289 |
+
async def health() -> HealthResponse:
|
| 290 |
+
return HealthResponse(
|
| 291 |
+
status="healthy" if state.error_rate < 0.3 else "degraded",
|
| 292 |
+
worker_id=settings.worker_id,
|
| 293 |
+
uptime_seconds=time.monotonic() - state.start_time,
|
| 294 |
+
total_requests=state.total_requests,
|
| 295 |
+
active_requests=state.active_requests,
|
| 296 |
+
cache_size=state.cache.size,
|
| 297 |
+
cache_hit_rate=state.cache.hit_rate,
|
| 298 |
+
avg_response_time=state.avg_response_time,
|
| 299 |
+
error_rate=state.error_rate,
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@app.get("/metrics")
|
| 304 |
+
async def metrics():
|
| 305 |
+
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
@app.post("/scrape", response_model=ScrapeResponse)
|
| 309 |
+
async def scrape_url(
|
| 310 |
+
request: ScrapeRequest,
|
| 311 |
+
background_tasks: BackgroundTasks,
|
| 312 |
+
) -> ScrapeResponse:
|
| 313 |
+
t0 = time.perf_counter()
|
| 314 |
+
url_str = str(request.url)
|
| 315 |
+
state.total_requests += 1
|
| 316 |
+
|
| 317 |
+
log.info("scrape_start", url=url_str, method=request.options.method.value)
|
| 318 |
+
|
| 319 |
+
# --- Cache ---
|
| 320 |
+
if request.options.cache.enabled and not request.options.cache.force_refresh:
|
| 321 |
+
cache_key = ContentCleaner.compute_content_hash(url_str)
|
| 322 |
+
cached = state.cache.get(cache_key)
|
| 323 |
+
if cached is not None:
|
| 324 |
+
log.info("cache_hit", url=url_str)
|
| 325 |
+
cached["performance"]["cache_hit"] = True
|
| 326 |
+
cached["performance"]["total_time"] = time.perf_counter() - t0
|
| 327 |
+
return ScrapeResponse(**cached)
|
| 328 |
+
|
| 329 |
+
try:
|
| 330 |
+
# --- Sélection de méthode ---
|
| 331 |
+
method = _select_method(request)
|
| 332 |
+
|
| 333 |
+
# --- Téléchargement ---
|
| 334 |
+
dl_start = time.perf_counter()
|
| 335 |
+
html, status_code, final_url = await _download(url_str, method, request.options)
|
| 336 |
+
dl_time = time.perf_counter() - dl_start
|
| 337 |
+
|
| 338 |
+
REQUESTS_DURATION.labels(method=method.value).observe(dl_time)
|
| 339 |
+
|
| 340 |
+
# --- Extraction ---
|
| 341 |
+
parse_start = time.perf_counter()
|
| 342 |
+
content_data = _extract_content(html, final_url, request.options.extraction)
|
| 343 |
+
parse_time = time.perf_counter() - parse_start
|
| 344 |
+
|
| 345 |
+
ex_start = time.perf_counter()
|
| 346 |
+
metadata_data = (
|
| 347 |
+
_extract_metadata(html) if request.options.extraction.include_metadata else None
|
| 348 |
+
)
|
| 349 |
+
links_data = (
|
| 350 |
+
_extract_links(html, final_url) if request.options.extraction.include_links else None
|
| 351 |
+
)
|
| 352 |
+
images_data = (
|
| 353 |
+
_extract_images(html, final_url) if request.options.extraction.include_images else None
|
| 354 |
+
)
|
| 355 |
+
ex_time = time.perf_counter() - ex_start
|
| 356 |
+
|
| 357 |
+
total_time = time.perf_counter() - t0
|
| 358 |
+
|
| 359 |
+
performance = PerformanceMetrics(
|
| 360 |
+
total_time=round(total_time, 4),
|
| 361 |
+
download_time=round(dl_time, 4),
|
| 362 |
+
parsing_time=round(parse_time, 4),
|
| 363 |
+
extraction_time=round(ex_time, 4),
|
| 364 |
+
content_size=len(html.encode("utf-8", errors="replace")),
|
| 365 |
+
cache_hit=False,
|
| 366 |
+
scrapy_used=(method == ScrapingMethod.SCRAPY),
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
response_dict: dict[str, Any] = {
|
| 370 |
+
"success": True,
|
| 371 |
+
"worker_id": settings.worker_id,
|
| 372 |
+
"url": url_str,
|
| 373 |
+
"final_url": final_url,
|
| 374 |
+
"status_code": status_code,
|
| 375 |
+
"method_used": method,
|
| 376 |
+
"content": content_data.model_dump(),
|
| 377 |
+
"metadata": metadata_data.model_dump() if metadata_data else None,
|
| 378 |
+
"links": links_data.model_dump() if links_data else None,
|
| 379 |
+
"images": images_data.model_dump() if images_data else None,
|
| 380 |
+
"performance": performance.model_dump(),
|
| 381 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
# --- Mise en cache ---
|
| 385 |
+
if request.options.cache.enabled:
|
| 386 |
+
cache_key = ContentCleaner.compute_content_hash(url_str)
|
| 387 |
+
state.cache.set(response_dict, cache_key, request.options.cache.ttl)
|
| 388 |
+
|
| 389 |
+
REQUESTS_TOTAL.labels(method=method.value, status="success").inc()
|
| 390 |
+
log.info("scrape_success", url=url_str, duration=total_time, method=method.value)
|
| 391 |
+
|
| 392 |
+
return ScrapeResponse(**response_dict)
|
| 393 |
+
|
| 394 |
+
except Exception as exc:
|
| 395 |
+
state.total_errors += 1
|
| 396 |
+
ERRORS_TOTAL.labels(error_type=type(exc).__name__).inc()
|
| 397 |
+
REQUESTS_TOTAL.labels(method="unknown", status="error").inc()
|
| 398 |
+
log.error("scrape_error", url=url_str, error=str(exc))
|
| 399 |
+
|
| 400 |
+
return ScrapeResponse(
|
| 401 |
+
success=False,
|
| 402 |
+
worker_id=settings.worker_id,
|
| 403 |
+
url=url_str,
|
| 404 |
+
error=str(exc),
|
| 405 |
+
performance=PerformanceMetrics(
|
| 406 |
+
total_time=round(time.perf_counter() - t0, 4),
|
| 407 |
+
download_time=0.0,
|
| 408 |
+
parsing_time=0.0,
|
| 409 |
+
extraction_time=0.0,
|
| 410 |
+
content_size=0,
|
| 411 |
+
cache_hit=False,
|
| 412 |
+
),
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
# ---------------------------------------------------------------------------
|
| 417 |
+
# Logique métier
|
| 418 |
+
# ---------------------------------------------------------------------------
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _select_method(request: ScrapeRequest) -> ScrapingMethod:
|
| 422 |
+
if request.options.method != ScrapingMethod.AUTO:
|
| 423 |
+
return request.options.method
|
| 424 |
+
|
| 425 |
+
url_lower = str(request.url).lower()
|
| 426 |
+
if any(x in url_lower for x in ("cloudflare", "cf-", "captcha")):
|
| 427 |
+
return ScrapingMethod.CLOUDSCRAPER
|
| 428 |
+
|
| 429 |
+
# Scrapy est le moteur par défaut
|
| 430 |
+
return ScrapingMethod.SCRAPY
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
async def _download(
|
| 434 |
+
url: str,
|
| 435 |
+
method: ScrapingMethod,
|
| 436 |
+
options: ScrapeOptions,
|
| 437 |
+
) -> tuple[str, Optional[int], str]:
|
| 438 |
+
"""Délègue le téléchargement à Scrapy ou aux clients HTTP alternatifs."""
|
| 439 |
+
timeout = options.timeout or settings.request_timeout
|
| 440 |
+
verify = options.verify_ssl if options.verify_ssl is not None else settings.verify_ssl
|
| 441 |
+
headers = dict(options.headers or {})
|
| 442 |
+
|
| 443 |
+
if method == ScrapingMethod.SCRAPY:
|
| 444 |
+
item: PageItem = await scrapy_runner.fetch(
|
| 445 |
+
url,
|
| 446 |
+
timeout=timeout,
|
| 447 |
+
verify_ssl=verify,
|
| 448 |
+
custom_headers=headers,
|
| 449 |
+
)
|
| 450 |
+
if item.get("error"):
|
| 451 |
+
raise RuntimeError(f"Scrapy error: {item['error']}")
|
| 452 |
+
return item["html"], item.get("status_code"), item.get("final_url", url)
|
| 453 |
+
|
| 454 |
+
# Fallbacks
|
| 455 |
+
if "User-Agent" not in headers:
|
| 456 |
+
headers["User-Agent"] = settings.user_agent
|
| 457 |
+
headers.update(
|
| 458 |
+
{
|
| 459 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 460 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 461 |
+
"Accept-Encoding": "gzip, deflate, br",
|
| 462 |
+
}
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
if method == ScrapingMethod.CURL_CFFI:
|
| 466 |
+
loop = asyncio.get_running_loop()
|
| 467 |
+
resp = await loop.run_in_executor(
|
| 468 |
+
None,
|
| 469 |
+
lambda: curl_requests.get(
|
| 470 |
+
url,
|
| 471 |
+
headers=headers,
|
| 472 |
+
timeout=timeout,
|
| 473 |
+
impersonate="chrome120",
|
| 474 |
+
verify=verify,
|
| 475 |
+
allow_redirects=options.follow_redirects if options.follow_redirects is not None else settings.follow_redirects,
|
| 476 |
+
),
|
| 477 |
+
)
|
| 478 |
+
return resp.text, resp.status_code, str(resp.url)
|
| 479 |
+
|
| 480 |
+
if method == ScrapingMethod.CLOUDSCRAPER:
|
| 481 |
+
loop = asyncio.get_running_loop()
|
| 482 |
+
resp = await loop.run_in_executor(
|
| 483 |
+
None,
|
| 484 |
+
lambda: state._cloudscraper.get(
|
| 485 |
+
url,
|
| 486 |
+
headers=headers,
|
| 487 |
+
timeout=timeout,
|
| 488 |
+
verify=verify,
|
| 489 |
+
),
|
| 490 |
+
)
|
| 491 |
+
resp.raise_for_status()
|
| 492 |
+
return resp.text, resp.status_code, resp.url
|
| 493 |
+
|
| 494 |
+
if method == ScrapingMethod.HTTPX:
|
| 495 |
+
client = await state.get_httpx_client()
|
| 496 |
+
resp = await client.get(url, headers=headers, timeout=timeout)
|
| 497 |
+
resp.raise_for_status()
|
| 498 |
+
return resp.text, resp.status_code, str(resp.url)
|
| 499 |
+
|
| 500 |
+
raise ValueError(f"Méthode non supportée : {method}")
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def _extract_content(html: str, url: str, config: ExtractionConfig) -> ContentData:
|
| 504 |
+
if config.mode == ExtractionMode.RAW:
|
| 505 |
+
return ContentData(raw_html=html[: settings.max_content_size])
|
| 506 |
+
|
| 507 |
+
raw_extracted: dict[str, Any] = {}
|
| 508 |
+
|
| 509 |
+
if config.mode in (ExtractionMode.CLEAN, ExtractionMode.FULL):
|
| 510 |
+
clean_html = ContentCleaner.clean_html_fast(html)
|
| 511 |
+
|
| 512 |
+
if config.mode in (ExtractionMode.MAIN_CONTENT, ExtractionMode.FULL):
|
| 513 |
+
raw_extracted = ContentCleaner.extract_main_content(html, url)
|
| 514 |
+
text = raw_extracted.get("text", "")
|
| 515 |
+
if config.normalize_text:
|
| 516 |
+
text = ContentCleaner.normalize_text(text)
|
| 517 |
+
return ContentData(
|
| 518 |
+
clean_html=(ContentCleaner.clean_html_fast(html)[: settings.max_content_size]
|
| 519 |
+
if config.mode == ExtractionMode.FULL else None),
|
| 520 |
+
text=text[: settings.max_content_size],
|
| 521 |
+
title=raw_extracted.get("title"),
|
| 522 |
+
author=raw_extracted.get("author"),
|
| 523 |
+
date=raw_extracted.get("date"),
|
| 524 |
+
description=raw_extracted.get("description"),
|
| 525 |
+
language=raw_extracted.get("language"),
|
| 526 |
+
word_count=len(text.split()),
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
# CLEAN only
|
| 530 |
+
return ContentData(clean_html=ContentCleaner.clean_html_fast(html)[: settings.max_content_size])
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def _extract_metadata(html: str) -> MetadataData:
|
| 534 |
+
md = ContentCleaner.extract_metadata(html)
|
| 535 |
+
return MetadataData(
|
| 536 |
+
og_data={k.replace("og_", ""): v for k, v in md.items() if k.startswith("og_")} or None,
|
| 537 |
+
twitter_data={k.replace("twitter_", ""): v for k, v in md.items() if k.startswith("twitter_")} or None,
|
| 538 |
+
meta_tags={k: v for k, v in md.items() if not k.startswith(("og_", "twitter_"))} or None,
|
| 539 |
+
canonical_url=md.get("canonical"),
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def _extract_links(html: str, base_url: str) -> LinksData:
|
| 544 |
+
base_domain = urlparse(base_url).netloc
|
| 545 |
+
all_links = ContentCleaner.extract_links(html, base_url)[: settings.max_links]
|
| 546 |
+
internal, external = [], []
|
| 547 |
+
for lk in all_links:
|
| 548 |
+
item = LinkItem(**lk)
|
| 549 |
+
if urlparse(lk["url"]).netloc == base_domain:
|
| 550 |
+
internal.append(item)
|
| 551 |
+
else:
|
| 552 |
+
external.append(item)
|
| 553 |
+
return LinksData(internal=internal, external=external)
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def _extract_images(html: str, base_url: str) -> ImagesData:
|
| 557 |
+
imgs = ContentCleaner.extract_images(html, base_url)[: settings.max_images]
|
| 558 |
+
return ImagesData(images=[ImageItem(**i) for i in imgs])
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
# ---------------------------------------------------------------------------
|
| 562 |
+
# Entrypoint
|
| 563 |
+
# ---------------------------------------------------------------------------
|
| 564 |
+
|
| 565 |
+
if __name__ == "__main__":
|
| 566 |
+
import uvicorn
|
| 567 |
+
|
| 568 |
+
uvicorn.run(
|
| 569 |
+
"app:app",
|
| 570 |
+
host="0.0.0.0",
|
| 571 |
+
port=settings.port,
|
| 572 |
+
log_level="warning",
|
| 573 |
+
access_log=False,
|
| 574 |
+
loop="uvloop",
|
| 575 |
+
http="httptools",
|
| 576 |
+
limit_concurrency=settings.max_concurrent_requests,
|
| 577 |
+
)
|
models-3.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modèles Pydantic v2 - typage strict, computed fields, validators.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import hashlib
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from typing import Annotated, Any, Optional
|
| 11 |
+
from urllib.parse import urlparse
|
| 12 |
+
|
| 13 |
+
from pydantic import (
|
| 14 |
+
AnyHttpUrl,
|
| 15 |
+
BaseModel,
|
| 16 |
+
ConfigDict,
|
| 17 |
+
Field,
|
| 18 |
+
computed_field,
|
| 19 |
+
field_validator,
|
| 20 |
+
model_validator,
|
| 21 |
+
)
|
| 22 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# Enums
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ScrapingMethod(str, Enum):
|
| 30 |
+
AUTO = "auto"
|
| 31 |
+
SCRAPY = "scrapy"
|
| 32 |
+
CURL_CFFI = "curl_cffi"
|
| 33 |
+
CLOUDSCRAPER = "cloudscraper"
|
| 34 |
+
HTTPX = "httpx"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ExtractionMode(str, Enum):
|
| 38 |
+
RAW = "raw"
|
| 39 |
+
CLEAN = "clean"
|
| 40 |
+
MAIN_CONTENT = "main_content"
|
| 41 |
+
FULL = "full"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# Settings (pydantic-settings v2)
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Settings(BaseSettings):
|
| 50 |
+
model_config = SettingsConfigDict(
|
| 51 |
+
env_file=".env",
|
| 52 |
+
env_file_encoding="utf-8",
|
| 53 |
+
case_sensitive=False,
|
| 54 |
+
extra="ignore",
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
port: int = 7860
|
| 58 |
+
worker_id: str = "scraper-1"
|
| 59 |
+
environment: str = "production"
|
| 60 |
+
|
| 61 |
+
# Concurrency
|
| 62 |
+
max_concurrent_requests: Annotated[int, Field(ge=1, le=500)] = 100
|
| 63 |
+
scrapy_concurrent_requests: Annotated[int, Field(ge=1, le=64)] = 16
|
| 64 |
+
scrapy_concurrent_per_domain: Annotated[int, Field(ge=1, le=32)] = 8
|
| 65 |
+
scrapy_download_delay: float = 0.0 # seconds between requests per domain
|
| 66 |
+
|
| 67 |
+
# Timeouts
|
| 68 |
+
request_timeout: Annotated[int, Field(ge=5, le=120)] = 30
|
| 69 |
+
|
| 70 |
+
# HTTP
|
| 71 |
+
user_agent: str = (
|
| 72 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 73 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 74 |
+
"Chrome/121.0.0.0 Safari/537.36"
|
| 75 |
+
)
|
| 76 |
+
max_retries: Annotated[int, Field(ge=1, le=5)] = 3
|
| 77 |
+
retry_backoff: Annotated[float, Field(ge=0.1, le=5.0)] = 1.0
|
| 78 |
+
follow_redirects: bool = True
|
| 79 |
+
verify_ssl: bool = True
|
| 80 |
+
|
| 81 |
+
# Content
|
| 82 |
+
max_content_size: int = 50_000_000
|
| 83 |
+
|
| 84 |
+
# Cache
|
| 85 |
+
enable_cache: bool = True
|
| 86 |
+
cache_ttl: int = 3600
|
| 87 |
+
cache_max_size: int = 1000
|
| 88 |
+
|
| 89 |
+
# Extraction
|
| 90 |
+
max_links: int = 500
|
| 91 |
+
max_images: int = 100
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Request models
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
PositiveInt = Annotated[int, Field(gt=0)]
|
| 99 |
+
TimeoutInt = Annotated[int, Field(ge=5, le=120)]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class CacheConfig(BaseModel):
|
| 103 |
+
model_config = ConfigDict(frozen=True)
|
| 104 |
+
|
| 105 |
+
enabled: bool = True
|
| 106 |
+
ttl: Optional[PositiveInt] = None
|
| 107 |
+
force_refresh: bool = False
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class ExtractionConfig(BaseModel):
|
| 111 |
+
model_config = ConfigDict(frozen=True)
|
| 112 |
+
|
| 113 |
+
mode: ExtractionMode = ExtractionMode.MAIN_CONTENT
|
| 114 |
+
include_metadata: bool = True
|
| 115 |
+
include_links: bool = False
|
| 116 |
+
include_images: bool = False
|
| 117 |
+
normalize_text: bool = True
|
| 118 |
+
detect_language: bool = True
|
| 119 |
+
css_selectors: Optional[list[str]] = None
|
| 120 |
+
xpath_selectors: Optional[list[str]] = None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class ScrapeOptions(BaseModel):
|
| 124 |
+
model_config = ConfigDict(frozen=True)
|
| 125 |
+
|
| 126 |
+
method: ScrapingMethod = ScrapingMethod.AUTO
|
| 127 |
+
headers: Optional[dict[str, str]] = None
|
| 128 |
+
timeout: Optional[TimeoutInt] = None
|
| 129 |
+
verify_ssl: Optional[bool] = None
|
| 130 |
+
follow_redirects: Optional[bool] = None
|
| 131 |
+
extraction: ExtractionConfig = Field(default_factory=ExtractionConfig)
|
| 132 |
+
cache: CacheConfig = Field(default_factory=CacheConfig)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class ScrapeRequest(BaseModel):
|
| 136 |
+
model_config = ConfigDict(frozen=True)
|
| 137 |
+
|
| 138 |
+
url: AnyHttpUrl
|
| 139 |
+
options: ScrapeOptions = Field(default_factory=ScrapeOptions)
|
| 140 |
+
|
| 141 |
+
@field_validator("url", mode="before")
|
| 142 |
+
@classmethod
|
| 143 |
+
def reject_non_http_files(cls, v: Any) -> Any:
|
| 144 |
+
url_str = str(v)
|
| 145 |
+
blocked = {
|
| 146 |
+
".pdf", ".zip", ".exe", ".dmg", ".pkg",
|
| 147 |
+
".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp",
|
| 148 |
+
".mp4", ".avi", ".mov", ".mp3", ".wav",
|
| 149 |
+
".css", ".woff", ".woff2", ".ttf",
|
| 150 |
+
}
|
| 151 |
+
path = urlparse(url_str).path.lower()
|
| 152 |
+
if any(path.endswith(ext) for ext in blocked):
|
| 153 |
+
raise ValueError(f"Extension non scrapable : {url_str}")
|
| 154 |
+
return v
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
# Response models
|
| 159 |
+
# ---------------------------------------------------------------------------
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class ContentData(BaseModel):
|
| 163 |
+
raw_html: Optional[str] = None
|
| 164 |
+
clean_html: Optional[str] = None
|
| 165 |
+
text: Optional[str] = None
|
| 166 |
+
title: Optional[str] = None
|
| 167 |
+
author: Optional[str] = None
|
| 168 |
+
date: Optional[str] = None
|
| 169 |
+
description: Optional[str] = None
|
| 170 |
+
language: Optional[str] = None
|
| 171 |
+
word_count: Optional[int] = None
|
| 172 |
+
|
| 173 |
+
@computed_field # type: ignore[misc]
|
| 174 |
+
@property
|
| 175 |
+
def content_hash(self) -> Optional[str]:
|
| 176 |
+
if self.text:
|
| 177 |
+
return hashlib.sha256(self.text.encode()).hexdigest()
|
| 178 |
+
return None
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class MetadataData(BaseModel):
|
| 182 |
+
og_data: Optional[dict[str, str]] = None
|
| 183 |
+
twitter_data: Optional[dict[str, str]] = None
|
| 184 |
+
meta_tags: Optional[dict[str, str]] = None
|
| 185 |
+
canonical_url: Optional[str] = None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class LinkItem(BaseModel):
|
| 189 |
+
url: str
|
| 190 |
+
text: str = ""
|
| 191 |
+
rel: str = ""
|
| 192 |
+
title: str = ""
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class ImageItem(BaseModel):
|
| 196 |
+
url: str
|
| 197 |
+
alt: str = ""
|
| 198 |
+
title: str = ""
|
| 199 |
+
width: str = ""
|
| 200 |
+
height: str = ""
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class LinksData(BaseModel):
|
| 204 |
+
internal: list[LinkItem] = Field(default_factory=list)
|
| 205 |
+
external: list[LinkItem] = Field(default_factory=list)
|
| 206 |
+
|
| 207 |
+
@computed_field # type: ignore[misc]
|
| 208 |
+
@property
|
| 209 |
+
def total_count(self) -> int:
|
| 210 |
+
return len(self.internal) + len(self.external)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class ImagesData(BaseModel):
|
| 214 |
+
images: list[ImageItem] = Field(default_factory=list)
|
| 215 |
+
|
| 216 |
+
@computed_field # type: ignore[misc]
|
| 217 |
+
@property
|
| 218 |
+
def total_count(self) -> int:
|
| 219 |
+
return len(self.images)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class PerformanceMetrics(BaseModel):
|
| 223 |
+
total_time: float
|
| 224 |
+
download_time: float
|
| 225 |
+
parsing_time: float
|
| 226 |
+
extraction_time: float
|
| 227 |
+
content_size: int
|
| 228 |
+
cache_hit: bool = False
|
| 229 |
+
scrapy_used: bool = False
|
| 230 |
+
|
| 231 |
+
@computed_field # type: ignore[misc]
|
| 232 |
+
@property
|
| 233 |
+
def throughput_kbps(self) -> float:
|
| 234 |
+
if self.download_time > 0:
|
| 235 |
+
return round(self.content_size / 1024 / self.download_time, 2)
|
| 236 |
+
return 0.0
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
class ScrapeResponse(BaseModel):
|
| 240 |
+
success: bool
|
| 241 |
+
worker_id: str
|
| 242 |
+
url: str
|
| 243 |
+
final_url: Optional[str] = None
|
| 244 |
+
status_code: Optional[int] = None
|
| 245 |
+
method_used: Optional[ScrapingMethod] = None
|
| 246 |
+
content: Optional[ContentData] = None
|
| 247 |
+
metadata: Optional[MetadataData] = None
|
| 248 |
+
links: Optional[LinksData] = None
|
| 249 |
+
images: Optional[ImagesData] = None
|
| 250 |
+
performance: PerformanceMetrics
|
| 251 |
+
error: Optional[str] = None
|
| 252 |
+
timestamp: str = Field(
|
| 253 |
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
@model_validator(mode="after")
|
| 257 |
+
def error_requires_failure(self) -> "ScrapeResponse":
|
| 258 |
+
if self.error and self.success:
|
| 259 |
+
raise ValueError("Un ScrapeResponse avec error doit avoir success=False")
|
| 260 |
+
return self
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
class HealthResponse(BaseModel):
|
| 264 |
+
status: str = "healthy"
|
| 265 |
+
worker_id: str
|
| 266 |
+
uptime_seconds: float
|
| 267 |
+
total_requests: int
|
| 268 |
+
active_requests: int
|
| 269 |
+
cache_size: int
|
| 270 |
+
cache_hit_rate: float
|
| 271 |
+
avg_response_time: float
|
| 272 |
+
error_rate: float
|
| 273 |
+
methods_available: list[ScrapingMethod] = Field(
|
| 274 |
+
default_factory=lambda: list(ScrapingMethod)
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
@computed_field # type: ignore[misc]
|
| 278 |
+
@property
|
| 279 |
+
def is_degraded(self) -> bool:
|
| 280 |
+
return self.error_rate > 0.3 or self.status != "healthy"
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
# Singleton settings
|
| 284 |
+
settings = Settings()
|
requirements-4.txt
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Framework web
|
| 2 |
+
fastapi==0.115.0
|
| 3 |
+
uvicorn[standard]==0.30.6
|
| 4 |
+
uvloop==0.20.0
|
| 5 |
+
httptools==0.6.4
|
| 6 |
+
|
| 7 |
+
# Validation
|
| 8 |
+
pydantic==2.9.2
|
| 9 |
+
pydantic-settings==2.5.2
|
| 10 |
+
orjson==3.10.7
|
| 11 |
+
|
| 12 |
+
# === SCRAPY (moteur de crawl principal) ===
|
| 13 |
+
scrapy==2.11.2
|
| 14 |
+
twisted==24.7.0 # reactor asyncio intégré dans Scrapy 2.11+
|
| 15 |
+
pyopenssl==24.2.1 # HTTPS via Twisted
|
| 16 |
+
service-identity==24.2.0 # validation des certificats
|
| 17 |
+
|
| 18 |
+
# Scraping fallback
|
| 19 |
+
curl-cffi==0.7.3
|
| 20 |
+
httpx[http2]==0.27.2
|
| 21 |
+
cloudscraper==1.2.71
|
| 22 |
+
aiohttp==3.10.5
|
| 23 |
+
|
| 24 |
+
# Parsing
|
| 25 |
+
selectolax==0.3.21
|
| 26 |
+
lxml==5.3.0
|
| 27 |
+
beautifulsoup4==4.12.3
|
| 28 |
+
cssselect==1.2.0
|
| 29 |
+
|
| 30 |
+
# Extraction de contenu
|
| 31 |
+
trafilatura==1.12.0
|
| 32 |
+
readability-lxml==0.8.1
|
| 33 |
+
justext==3.0.1
|
| 34 |
+
|
| 35 |
+
# Nettoyage / détection
|
| 36 |
+
charset-normalizer==3.4.0
|
| 37 |
+
ftfy==6.2.3
|
| 38 |
+
langdetect==1.0.9
|
| 39 |
+
w3lib==2.2.1
|
| 40 |
+
|
| 41 |
+
# Async
|
| 42 |
+
aiofiles==24.1.0
|
| 43 |
+
|
| 44 |
+
# Monitoring
|
| 45 |
+
prometheus-client==0.21.0
|
| 46 |
+
structlog==24.4.0
|
| 47 |
+
|
| 48 |
+
# Retry
|
| 49 |
+
tenacity==9.0.0
|
| 50 |
+
|
| 51 |
+
# Compression
|
| 52 |
+
brotli==1.1.0
|
| 53 |
+
zstandard==0.23.0
|
| 54 |
+
|
| 55 |
+
# Utilitaires
|
| 56 |
+
python-dotenv==1.0.1
|
| 57 |
+
python-multipart==0.0.12
|
scraper-3.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Moteur Scrapy - spider, middlewares, pipelines, runner asyncio-compatible.
|
| 3 |
+
|
| 4 |
+
L'astuce clé : Scrapy tourne sur Twisted, mais on l'intègre dans FastAPI
|
| 5 |
+
(asyncio) via scrapy.utils.reactor.install_reactor('twisted.internet.asyncioreactor')
|
| 6 |
+
appelé AVANT tout import de Scrapy. Le runner est lancé dans un thread dédié
|
| 7 |
+
pour ne pas bloquer la boucle asyncio principale.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import logging
|
| 14 |
+
import time
|
| 15 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 16 |
+
from typing import Any, Optional
|
| 17 |
+
from urllib.parse import urlparse
|
| 18 |
+
|
| 19 |
+
# Doit être le tout premier import Scrapy pour choisir l'asyncio reactor
|
| 20 |
+
import scrapy.utils.reactor # noqa: F401 (side-effect import order matters)
|
| 21 |
+
from scrapy import Spider, signals
|
| 22 |
+
from scrapy.crawler import CrawlerRunner
|
| 23 |
+
from scrapy.http import Response
|
| 24 |
+
from scrapy.item import Field, Item
|
| 25 |
+
from scrapy.utils.project import get_project_settings
|
| 26 |
+
from twisted.internet import defer
|
| 27 |
+
|
| 28 |
+
from models import settings
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Scrapy Items
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class PageItem(Item):
|
| 38 |
+
"""Item Scrapy transportant toutes les données d'une page scrappée."""
|
| 39 |
+
|
| 40 |
+
url = Field()
|
| 41 |
+
final_url = Field()
|
| 42 |
+
status_code = Field()
|
| 43 |
+
html = Field()
|
| 44 |
+
download_time = Field()
|
| 45 |
+
error = Field()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Scrapy Settings factory
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def make_scrapy_settings(request_timeout: int, verify_ssl: bool) -> dict[str, Any]:
|
| 54 |
+
"""Construit les settings Scrapy depuis notre config Pydantic."""
|
| 55 |
+
return {
|
| 56 |
+
# Identification
|
| 57 |
+
"BOT_NAME": "sota-scraper",
|
| 58 |
+
"USER_AGENT": settings.user_agent,
|
| 59 |
+
# Concurrence et politesse
|
| 60 |
+
"CONCURRENT_REQUESTS": settings.scrapy_concurrent_requests,
|
| 61 |
+
"CONCURRENT_REQUESTS_PER_DOMAIN": settings.scrapy_concurrent_per_domain,
|
| 62 |
+
"DOWNLOAD_DELAY": settings.scrapy_download_delay,
|
| 63 |
+
"RANDOMIZE_DOWNLOAD_DELAY": True, # ±50% du DOWNLOAD_DELAY
|
| 64 |
+
# Timeouts
|
| 65 |
+
"DOWNLOAD_TIMEOUT": request_timeout,
|
| 66 |
+
# Redirections
|
| 67 |
+
"REDIRECT_ENABLED": settings.follow_redirects,
|
| 68 |
+
"REDIRECT_MAX_TIMES": 10,
|
| 69 |
+
# SSL
|
| 70 |
+
"VERIFY_SSL": verify_ssl,
|
| 71 |
+
# Retry middleware intégré
|
| 72 |
+
"RETRY_ENABLED": True,
|
| 73 |
+
"RETRY_TIMES": settings.max_retries,
|
| 74 |
+
"RETRY_HTTP_CODES": [429, 500, 502, 503, 504, 522, 524, 408],
|
| 75 |
+
"RETRY_BACKOFF_BASE": settings.retry_backoff,
|
| 76 |
+
# Compression automatique
|
| 77 |
+
"COMPRESSION_ENABLED": True,
|
| 78 |
+
# Désactiver robots.txt (scraper général)
|
| 79 |
+
"ROBOTSTXT_OBEY": False,
|
| 80 |
+
# Cookies désactivés par défaut (moins de fingerprinting)
|
| 81 |
+
"COOKIES_ENABLED": False,
|
| 82 |
+
# Logging minimal (structlog gère le reste)
|
| 83 |
+
"LOG_ENABLED": False,
|
| 84 |
+
# Telnet console désactivée
|
| 85 |
+
"TELNETCONSOLE_ENABLED": False,
|
| 86 |
+
# Reactor asyncio
|
| 87 |
+
"TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor",
|
| 88 |
+
# Middlewares downloader (ordre : 100 = premier)
|
| 89 |
+
"DOWNLOADER_MIDDLEWARES": {
|
| 90 |
+
"scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": None,
|
| 91 |
+
"scrapy.downloadermiddlewares.retry.RetryMiddleware": 550,
|
| 92 |
+
"scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600,
|
| 93 |
+
"scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 810,
|
| 94 |
+
},
|
| 95 |
+
# Pas de pipeline ici — on récupère l'item via signal dans le runner
|
| 96 |
+
"ITEM_PIPELINES": {},
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
# Spider générique
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class SinglePageSpider(Spider):
|
| 106 |
+
"""
|
| 107 |
+
Spider minimaliste : une URL → un PageItem.
|
| 108 |
+
|
| 109 |
+
Utilisé par ScrapyRunner pour chaque requête individuelle.
|
| 110 |
+
On lui passe les headers custom via `custom_headers`.
|
| 111 |
+
"""
|
| 112 |
+
|
| 113 |
+
name = "single_page"
|
| 114 |
+
# custom_settings est surchargé à l'instanciation via kwargs
|
| 115 |
+
|
| 116 |
+
def __init__(
|
| 117 |
+
self,
|
| 118 |
+
url: str,
|
| 119 |
+
custom_headers: Optional[dict[str, str]] = None,
|
| 120 |
+
*args: Any,
|
| 121 |
+
**kwargs: Any,
|
| 122 |
+
):
|
| 123 |
+
super().__init__(*args, **kwargs)
|
| 124 |
+
self.start_urls = [url]
|
| 125 |
+
self.custom_headers = custom_headers or {}
|
| 126 |
+
self._download_start: float = 0.0
|
| 127 |
+
|
| 128 |
+
def start_requests(self):
|
| 129 |
+
headers = {
|
| 130 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 131 |
+
"Accept-Language": "en-US,en;q=0.9,fr;q=0.8",
|
| 132 |
+
"Accept-Encoding": "gzip, deflate, br",
|
| 133 |
+
"DNT": "1",
|
| 134 |
+
"Connection": "keep-alive",
|
| 135 |
+
"Upgrade-Insecure-Requests": "1",
|
| 136 |
+
**self.custom_headers,
|
| 137 |
+
}
|
| 138 |
+
self._download_start = time.perf_counter()
|
| 139 |
+
yield scrapy.Request(
|
| 140 |
+
url=self.start_urls[0],
|
| 141 |
+
headers=headers,
|
| 142 |
+
callback=self.parse,
|
| 143 |
+
errback=self.errback,
|
| 144 |
+
dont_filter=True,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
def parse(self, response: Response):
|
| 148 |
+
download_time = time.perf_counter() - self._download_start
|
| 149 |
+
yield PageItem(
|
| 150 |
+
url=self.start_urls[0],
|
| 151 |
+
final_url=str(response.url),
|
| 152 |
+
status_code=response.status,
|
| 153 |
+
html=response.text,
|
| 154 |
+
download_time=round(download_time, 4),
|
| 155 |
+
error=None,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
def errback(self, failure):
|
| 159 |
+
download_time = time.perf_counter() - self._download_start
|
| 160 |
+
logger.warning("scrapy_error url=%s err=%s", self.start_urls[0], repr(failure))
|
| 161 |
+
yield PageItem(
|
| 162 |
+
url=self.start_urls[0],
|
| 163 |
+
final_url=self.start_urls[0],
|
| 164 |
+
status_code=None,
|
| 165 |
+
html="",
|
| 166 |
+
download_time=round(download_time, 4),
|
| 167 |
+
error=str(failure.value),
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# Runner asyncio-compatible
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class ScrapyRunner:
|
| 177 |
+
"""
|
| 178 |
+
Exécute un crawl Scrapy et retourne le premier PageItem via un Future asyncio.
|
| 179 |
+
|
| 180 |
+
Architecture :
|
| 181 |
+
- Scrapy / Twisted tourne dans un ThreadPoolExecutor dédié (1 thread).
|
| 182 |
+
- La boucle asyncio principale attend via asyncio.Future.
|
| 183 |
+
- Les résultats sont transmis via call_soon_threadsafe pour rester thread-safe.
|
| 184 |
+
"""
|
| 185 |
+
|
| 186 |
+
def __init__(self) -> None:
|
| 187 |
+
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scrapy")
|
| 188 |
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
| 189 |
+
|
| 190 |
+
def _get_loop(self) -> asyncio.AbstractEventLoop:
|
| 191 |
+
if self._loop is None:
|
| 192 |
+
self._loop = asyncio.get_event_loop()
|
| 193 |
+
return self._loop
|
| 194 |
+
|
| 195 |
+
async def fetch(
|
| 196 |
+
self,
|
| 197 |
+
url: str,
|
| 198 |
+
*,
|
| 199 |
+
timeout: int = 30,
|
| 200 |
+
verify_ssl: bool = True,
|
| 201 |
+
custom_headers: Optional[dict[str, str]] = None,
|
| 202 |
+
) -> PageItem:
|
| 203 |
+
"""
|
| 204 |
+
Lance un crawl Scrapy pour `url` et retourne le PageItem résultant.
|
| 205 |
+
Lève une exception si le crawl échoue complètement.
|
| 206 |
+
"""
|
| 207 |
+
loop = asyncio.get_running_loop()
|
| 208 |
+
future: asyncio.Future[PageItem] = loop.create_future()
|
| 209 |
+
|
| 210 |
+
def _run_in_thread() -> None:
|
| 211 |
+
"""Exécution bloquante dans le thread Scrapy/Twisted."""
|
| 212 |
+
try:
|
| 213 |
+
from twisted.internet import reactor # type: ignore
|
| 214 |
+
|
| 215 |
+
scrapy_cfg = get_project_settings()
|
| 216 |
+
scrapy_cfg.update(make_scrapy_settings(timeout, verify_ssl))
|
| 217 |
+
|
| 218 |
+
runner = CrawlerRunner(scrapy_cfg)
|
| 219 |
+
collected: list[PageItem] = []
|
| 220 |
+
|
| 221 |
+
crawler = runner.create_crawler(SinglePageSpider)
|
| 222 |
+
|
| 223 |
+
def _on_item(item: PageItem, response: Any, spider: Any) -> None:
|
| 224 |
+
collected.append(item)
|
| 225 |
+
|
| 226 |
+
def _on_finished(_: Any) -> None:
|
| 227 |
+
result = collected[0] if collected else PageItem(
|
| 228 |
+
url=url,
|
| 229 |
+
final_url=url,
|
| 230 |
+
status_code=None,
|
| 231 |
+
html="",
|
| 232 |
+
download_time=0.0,
|
| 233 |
+
error="Aucun item collecté par Scrapy",
|
| 234 |
+
)
|
| 235 |
+
loop.call_soon_threadsafe(
|
| 236 |
+
future.set_result, result # type: ignore[arg-type]
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
def _on_error(failure: Any) -> None:
|
| 240 |
+
exc = failure.value if hasattr(failure, "value") else Exception(str(failure))
|
| 241 |
+
loop.call_soon_threadsafe(future.set_exception, exc)
|
| 242 |
+
|
| 243 |
+
crawler.signals.connect(_on_item, signal=signals.item_scraped)
|
| 244 |
+
|
| 245 |
+
d: defer.Deferred = runner.crawl(
|
| 246 |
+
crawler,
|
| 247 |
+
url=url,
|
| 248 |
+
custom_headers=custom_headers or {},
|
| 249 |
+
)
|
| 250 |
+
d.addCallback(_on_finished)
|
| 251 |
+
d.addErrback(_on_error)
|
| 252 |
+
|
| 253 |
+
# Démarrer le reactor si pas déjà démarré
|
| 254 |
+
if not reactor.running: # type: ignore[attr-defined]
|
| 255 |
+
reactor.run(installSignalHandlers=False) # type: ignore[attr-defined]
|
| 256 |
+
|
| 257 |
+
except Exception as exc:
|
| 258 |
+
loop.call_soon_threadsafe(future.set_exception, exc)
|
| 259 |
+
|
| 260 |
+
await loop.run_in_executor(self._executor, _run_in_thread)
|
| 261 |
+
return await future
|
| 262 |
+
|
| 263 |
+
async def shutdown(self) -> None:
|
| 264 |
+
self._executor.shutdown(wait=False)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# Singleton partagé par l'application
|
| 268 |
+
scrapy_runner = ScrapyRunner()
|
utils-4.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pipeline d'extraction de contenu — version refactorisée.
|
| 3 |
+
|
| 4 |
+
- TextCleaner : nettoyage profond du texte (inchangé, déjà très bon)
|
| 5 |
+
- ContentCleaner : extraction multi-méthodes (trafilatura → readability → justext)
|
| 6 |
+
- URLValidator : validation et normalisation d'URL
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import logging
|
| 13 |
+
import re
|
| 14 |
+
from typing import Any, Optional
|
| 15 |
+
from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse
|
| 16 |
+
|
| 17 |
+
import ftfy
|
| 18 |
+
import justext
|
| 19 |
+
from langdetect import detect_langs
|
| 20 |
+
from readability import Document
|
| 21 |
+
from selectolax.parser import HTMLParser
|
| 22 |
+
from trafilatura import bare_extraction
|
| 23 |
+
from w3lib.html import remove_tags, replace_entities
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# TextCleaner
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TextCleaner:
|
| 34 |
+
"""Pipeline de nettoyage de texte ultra-agressif."""
|
| 35 |
+
|
| 36 |
+
MD_IMAGE = re.compile(r"!\[.*?\]\(.*?\)", re.DOTALL)
|
| 37 |
+
MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)", re.DOTALL)
|
| 38 |
+
WIKI_REF = re.compile(r"\[\[?\d+\]?\]\([^)]*\)|\[\[?\d+\]?\]")
|
| 39 |
+
RAW_URL = re.compile(r"https?://[^\s\)\]\,\"\'<>]+|//[^\s\)\]\,\"\'<>]+")
|
| 40 |
+
HTML_TAGS = re.compile(r"<[^>]+>")
|
| 41 |
+
MULTI_SPACE = re.compile(r" {2,}")
|
| 42 |
+
MULTI_NEWLINE = re.compile(r"\n{3,}")
|
| 43 |
+
CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]")
|
| 44 |
+
JUNK_LINE = re.compile(r"^\s*[\|\-\=\*\#\~\^]{2,}\s*$", re.MULTILINE)
|
| 45 |
+
TABLE_ROW = re.compile(r"^\|.*\|$", re.MULTILINE)
|
| 46 |
+
ONLY_PUNCTUATION = re.compile(r"^[\d\s\.\,\;\:\!\?\-\|\=\[\]\(\)]+$")
|
| 47 |
+
|
| 48 |
+
@classmethod
|
| 49 |
+
def deep_clean(cls, text: str) -> str:
|
| 50 |
+
if not text:
|
| 51 |
+
return ""
|
| 52 |
+
|
| 53 |
+
text = ftfy.fix_text(text)
|
| 54 |
+
text = replace_entities(text)
|
| 55 |
+
text = cls.MD_IMAGE.sub("", text)
|
| 56 |
+
text = cls.WIKI_REF.sub("", text)
|
| 57 |
+
text = cls.MD_LINK.sub(r"\1", text)
|
| 58 |
+
text = cls.RAW_URL.sub("", text)
|
| 59 |
+
text = cls.HTML_TAGS.sub("", text)
|
| 60 |
+
text = cls.CONTROL_CHARS.sub("", text)
|
| 61 |
+
text = cls.TABLE_ROW.sub("", text)
|
| 62 |
+
text = cls.JUNK_LINE.sub("", text)
|
| 63 |
+
|
| 64 |
+
lines = []
|
| 65 |
+
for line in text.splitlines():
|
| 66 |
+
line = line.strip()
|
| 67 |
+
if len(line) < 2:
|
| 68 |
+
continue
|
| 69 |
+
if cls.ONLY_PUNCTUATION.match(line):
|
| 70 |
+
continue
|
| 71 |
+
lines.append(line)
|
| 72 |
+
|
| 73 |
+
text = "\n".join(lines)
|
| 74 |
+
text = cls.MULTI_SPACE.sub(" ", text)
|
| 75 |
+
text = cls.MULTI_NEWLINE.sub("\n\n", text)
|
| 76 |
+
return text.strip()
|
| 77 |
+
|
| 78 |
+
@classmethod
|
| 79 |
+
def extract_clean_sentences(cls, text: str, min_length: int = 30) -> str:
|
| 80 |
+
text = cls.deep_clean(text)
|
| 81 |
+
paragraphs = text.split("\n\n")
|
| 82 |
+
valid = [p.strip() for p in paragraphs if len(p.strip()) >= min_length]
|
| 83 |
+
return "\n\n".join(valid)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
# ContentCleaner
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class ContentCleaner:
|
| 92 |
+
"""Extraction et nettoyage de contenu HTML."""
|
| 93 |
+
|
| 94 |
+
UNWANTED_CSS_SELECTORS: list[str] = [
|
| 95 |
+
"sup.reference",
|
| 96 |
+
"div.reflist",
|
| 97 |
+
"div.navbox",
|
| 98 |
+
"div.toc",
|
| 99 |
+
"div.hatnote",
|
| 100 |
+
"table.navbox",
|
| 101 |
+
"table.wikitable",
|
| 102 |
+
"div.mw-references-wrap",
|
| 103 |
+
"ol.references",
|
| 104 |
+
"span.mw-editsection",
|
| 105 |
+
"div.sidebar",
|
| 106 |
+
"div.noprint",
|
| 107 |
+
".navigation-not-searchable",
|
| 108 |
+
"script",
|
| 109 |
+
"style",
|
| 110 |
+
"noscript",
|
| 111 |
+
"iframe",
|
| 112 |
+
"embed",
|
| 113 |
+
"object",
|
| 114 |
+
"svg",
|
| 115 |
+
"canvas",
|
| 116 |
+
"head",
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
@classmethod
|
| 120 |
+
def clean_html_fast(cls, html: str) -> str:
|
| 121 |
+
"""Supprime les éléments parasites avant extraction."""
|
| 122 |
+
try:
|
| 123 |
+
tree = HTMLParser(html)
|
| 124 |
+
for selector in cls.UNWANTED_CSS_SELECTORS:
|
| 125 |
+
try:
|
| 126 |
+
for node in tree.css(selector):
|
| 127 |
+
node.decompose()
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
return tree.html or html
|
| 131 |
+
except Exception as exc:
|
| 132 |
+
logger.warning("clean_html_fast error=%s", exc)
|
| 133 |
+
return html
|
| 134 |
+
|
| 135 |
+
@classmethod
|
| 136 |
+
def extract_main_content(cls, html: str, url: str) -> dict[str, Any]:
|
| 137 |
+
"""
|
| 138 |
+
Extraction multi-méthodes : trafilatura → readability → justext → basic.
|
| 139 |
+
Retourne toujours un dict même si toutes les méthodes échouent.
|
| 140 |
+
"""
|
| 141 |
+
result: dict[str, Any] = {
|
| 142 |
+
"text": "",
|
| 143 |
+
"title": "",
|
| 144 |
+
"author": "",
|
| 145 |
+
"date": None,
|
| 146 |
+
"description": "",
|
| 147 |
+
"language": "unknown",
|
| 148 |
+
"method": "unknown",
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
clean_html = cls.clean_html_fast(html)
|
| 152 |
+
|
| 153 |
+
# --- Méthode 1 : trafilatura (SOTA précision) ---
|
| 154 |
+
try:
|
| 155 |
+
extracted = bare_extraction(
|
| 156 |
+
clean_html,
|
| 157 |
+
url=url,
|
| 158 |
+
include_comments=False,
|
| 159 |
+
include_tables=False,
|
| 160 |
+
include_images=False,
|
| 161 |
+
include_links=False,
|
| 162 |
+
deduplicate=True,
|
| 163 |
+
favor_precision=True,
|
| 164 |
+
no_fallback=False,
|
| 165 |
+
)
|
| 166 |
+
if extracted and len(extracted.get("text") or "") > 100:
|
| 167 |
+
clean_text = TextCleaner.deep_clean(extracted["text"])
|
| 168 |
+
result.update(
|
| 169 |
+
{
|
| 170 |
+
"text": clean_text,
|
| 171 |
+
"title": extracted.get("title") or "",
|
| 172 |
+
"author": extracted.get("author") or "",
|
| 173 |
+
"date": str(extracted["date"]) if extracted.get("date") else None,
|
| 174 |
+
"description": extracted.get("description") or "",
|
| 175 |
+
"method": "trafilatura",
|
| 176 |
+
}
|
| 177 |
+
)
|
| 178 |
+
result["language"] = cls._detect_language(clean_text)
|
| 179 |
+
return result
|
| 180 |
+
except Exception as exc:
|
| 181 |
+
logger.debug("trafilatura_failed error=%s", exc)
|
| 182 |
+
|
| 183 |
+
# --- Méthode 2 : readability ---
|
| 184 |
+
try:
|
| 185 |
+
doc = Document(clean_html)
|
| 186 |
+
raw_text = remove_tags(doc.summary())
|
| 187 |
+
clean_text = TextCleaner.deep_clean(raw_text)
|
| 188 |
+
if len(clean_text) > 50:
|
| 189 |
+
result.update(
|
| 190 |
+
{
|
| 191 |
+
"text": clean_text,
|
| 192 |
+
"title": doc.title(),
|
| 193 |
+
"method": "readability",
|
| 194 |
+
}
|
| 195 |
+
)
|
| 196 |
+
result["language"] = cls._detect_language(clean_text)
|
| 197 |
+
return result
|
| 198 |
+
except Exception as exc:
|
| 199 |
+
logger.debug("readability_failed error=%s", exc)
|
| 200 |
+
|
| 201 |
+
# --- Méthode 3 : justext ---
|
| 202 |
+
try:
|
| 203 |
+
paragraphs = justext.justext(
|
| 204 |
+
clean_html.encode("utf-8", errors="replace"),
|
| 205 |
+
justext.get_stoplist("English"),
|
| 206 |
+
length_low=50,
|
| 207 |
+
length_high=200,
|
| 208 |
+
stopwords_low=0.20,
|
| 209 |
+
stopwords_high=0.30,
|
| 210 |
+
max_link_density=0.3,
|
| 211 |
+
no_headings=False,
|
| 212 |
+
)
|
| 213 |
+
texts = [p.text for p in paragraphs if not p.is_boilerplate]
|
| 214 |
+
clean_text = TextCleaner.deep_clean("\n\n".join(texts))
|
| 215 |
+
if clean_text:
|
| 216 |
+
result.update({"text": clean_text, "method": "justext"})
|
| 217 |
+
result["language"] = cls._detect_language(clean_text)
|
| 218 |
+
return result
|
| 219 |
+
except Exception as exc:
|
| 220 |
+
logger.debug("justext_failed error=%s", exc)
|
| 221 |
+
|
| 222 |
+
# --- Fallback ultime ---
|
| 223 |
+
result["text"] = TextCleaner.deep_clean(remove_tags(clean_html))
|
| 224 |
+
result["method"] = "basic"
|
| 225 |
+
return result
|
| 226 |
+
|
| 227 |
+
@staticmethod
|
| 228 |
+
def _detect_language(text: str) -> str:
|
| 229 |
+
try:
|
| 230 |
+
if text:
|
| 231 |
+
langs = detect_langs(text[:500])
|
| 232 |
+
return langs[0].lang if langs else "unknown"
|
| 233 |
+
except Exception:
|
| 234 |
+
pass
|
| 235 |
+
return "unknown"
|
| 236 |
+
|
| 237 |
+
@staticmethod
|
| 238 |
+
def normalize_text(text: str) -> str:
|
| 239 |
+
return TextCleaner.deep_clean(text)
|
| 240 |
+
|
| 241 |
+
@staticmethod
|
| 242 |
+
def extract_metadata(html: str) -> dict[str, Any]:
|
| 243 |
+
"""Open Graph + Twitter Cards + meta standards."""
|
| 244 |
+
metadata: dict[str, Any] = {}
|
| 245 |
+
try:
|
| 246 |
+
tree = HTMLParser(html)
|
| 247 |
+
|
| 248 |
+
for meta in tree.css('meta[property^="og:"]'):
|
| 249 |
+
prop = meta.attributes.get("property", "").replace("og:", "").strip()
|
| 250 |
+
content = meta.attributes.get("content", "").strip()
|
| 251 |
+
if prop and content:
|
| 252 |
+
metadata[f"og_{prop}"] = content
|
| 253 |
+
|
| 254 |
+
for meta in tree.css('meta[name^="twitter:"]'):
|
| 255 |
+
name = meta.attributes.get("name", "").replace("twitter:", "").strip()
|
| 256 |
+
content = meta.attributes.get("content", "").strip()
|
| 257 |
+
if name and content:
|
| 258 |
+
metadata[f"twitter_{name}"] = content
|
| 259 |
+
|
| 260 |
+
for meta in tree.css("meta[name]"):
|
| 261 |
+
name = meta.attributes.get("name", "").strip()
|
| 262 |
+
content = meta.attributes.get("content", "").strip()
|
| 263 |
+
if name in {"description", "keywords", "author"} and content:
|
| 264 |
+
metadata[name] = content
|
| 265 |
+
|
| 266 |
+
canonical = tree.css_first('link[rel="canonical"]')
|
| 267 |
+
if canonical:
|
| 268 |
+
href = canonical.attributes.get("href", "").strip()
|
| 269 |
+
if href:
|
| 270 |
+
metadata["canonical"] = href
|
| 271 |
+
|
| 272 |
+
title_node = tree.css_first("title")
|
| 273 |
+
if title_node and not metadata.get("og_title"):
|
| 274 |
+
metadata["page_title"] = title_node.text(strip=True)
|
| 275 |
+
|
| 276 |
+
except Exception as exc:
|
| 277 |
+
logger.warning("extract_metadata error=%s", exc)
|
| 278 |
+
|
| 279 |
+
return metadata
|
| 280 |
+
|
| 281 |
+
@staticmethod
|
| 282 |
+
def extract_links(html: str, base_url: str) -> list[dict[str, str]]:
|
| 283 |
+
links: list[dict[str, str]] = []
|
| 284 |
+
seen: set[str] = set()
|
| 285 |
+
try:
|
| 286 |
+
tree = HTMLParser(html)
|
| 287 |
+
for link in tree.css("a[href]"):
|
| 288 |
+
href = link.attributes.get("href", "").strip()
|
| 289 |
+
if not href or href.startswith(("#", "javascript:", "mailto:", "tel:")):
|
| 290 |
+
continue
|
| 291 |
+
try:
|
| 292 |
+
abs_url = urljoin(base_url, href)
|
| 293 |
+
except Exception:
|
| 294 |
+
continue
|
| 295 |
+
if abs_url in seen:
|
| 296 |
+
continue
|
| 297 |
+
seen.add(abs_url)
|
| 298 |
+
parsed = urlparse(abs_url)
|
| 299 |
+
if parsed.scheme not in {"http", "https"}:
|
| 300 |
+
continue
|
| 301 |
+
links.append(
|
| 302 |
+
{
|
| 303 |
+
"url": abs_url,
|
| 304 |
+
"text": (link.text(strip=True) or "")[:200],
|
| 305 |
+
"rel": link.attributes.get("rel", ""),
|
| 306 |
+
"title": link.attributes.get("title", ""),
|
| 307 |
+
}
|
| 308 |
+
)
|
| 309 |
+
except Exception as exc:
|
| 310 |
+
logger.warning("extract_links error=%s", exc)
|
| 311 |
+
return links
|
| 312 |
+
|
| 313 |
+
@staticmethod
|
| 314 |
+
def extract_images(html: str, base_url: str) -> list[dict[str, str]]:
|
| 315 |
+
images: list[dict[str, str]] = []
|
| 316 |
+
seen: set[str] = set()
|
| 317 |
+
src_attrs = ("src", "data-src", "data-lazy-src", "data-original", "data-lazy")
|
| 318 |
+
try:
|
| 319 |
+
tree = HTMLParser(html)
|
| 320 |
+
for img in tree.css("img"):
|
| 321 |
+
src = next(
|
| 322 |
+
(img.attributes.get(a, "") for a in src_attrs if img.attributes.get(a)),
|
| 323 |
+
"",
|
| 324 |
+
).strip()
|
| 325 |
+
if not src or src.startswith("data:"):
|
| 326 |
+
continue
|
| 327 |
+
try:
|
| 328 |
+
abs_url = urljoin(base_url, src)
|
| 329 |
+
except Exception:
|
| 330 |
+
continue
|
| 331 |
+
if abs_url in seen:
|
| 332 |
+
continue
|
| 333 |
+
seen.add(abs_url)
|
| 334 |
+
if urlparse(abs_url).scheme not in {"http", "https"}:
|
| 335 |
+
continue
|
| 336 |
+
images.append(
|
| 337 |
+
{
|
| 338 |
+
"url": abs_url,
|
| 339 |
+
"alt": img.attributes.get("alt", "").strip(),
|
| 340 |
+
"title": img.attributes.get("title", "").strip(),
|
| 341 |
+
"width": img.attributes.get("width", ""),
|
| 342 |
+
"height": img.attributes.get("height", ""),
|
| 343 |
+
}
|
| 344 |
+
)
|
| 345 |
+
except Exception as exc:
|
| 346 |
+
logger.warning("extract_images error=%s", exc)
|
| 347 |
+
return images
|
| 348 |
+
|
| 349 |
+
@staticmethod
|
| 350 |
+
def compute_content_hash(text: str) -> str:
|
| 351 |
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
# ---------------------------------------------------------------------------
|
| 355 |
+
# URLValidator
|
| 356 |
+
# ---------------------------------------------------------------------------
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
class URLValidator:
|
| 360 |
+
BLOCKED_EXTENSIONS: frozenset[str] = frozenset(
|
| 361 |
+
{
|
| 362 |
+
".pdf", ".zip", ".exe", ".dmg", ".pkg", ".deb", ".rpm",
|
| 363 |
+
".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp", ".ico",
|
| 364 |
+
".mp4", ".avi", ".mov", ".mp3", ".wav", ".flac",
|
| 365 |
+
".css", ".js", ".woff", ".woff2", ".ttf", ".eot",
|
| 366 |
+
}
|
| 367 |
+
)
|
| 368 |
+
ALLOWED_SCHEMES: frozenset[str] = frozenset({"http", "https"})
|
| 369 |
+
|
| 370 |
+
@classmethod
|
| 371 |
+
def is_valid_url(cls, url: str) -> bool:
|
| 372 |
+
try:
|
| 373 |
+
parsed = urlparse(url)
|
| 374 |
+
if parsed.scheme not in cls.ALLOWED_SCHEMES:
|
| 375 |
+
return False
|
| 376 |
+
if not parsed.netloc:
|
| 377 |
+
return False
|
| 378 |
+
path_lower = parsed.path.lower()
|
| 379 |
+
if any(path_lower.endswith(ext) for ext in cls.BLOCKED_EXTENSIONS):
|
| 380 |
+
return False
|
| 381 |
+
return True
|
| 382 |
+
except Exception:
|
| 383 |
+
return False
|
| 384 |
+
|
| 385 |
+
@staticmethod
|
| 386 |
+
def normalize_url(url: str) -> str:
|
| 387 |
+
try:
|
| 388 |
+
parsed = urlparse(url)._replace(fragment="")
|
| 389 |
+
if parsed.query:
|
| 390 |
+
params = parse_qs(parsed.query)
|
| 391 |
+
new_query = urlencode(sorted(params.items()), doseq=True)
|
| 392 |
+
parsed = parsed._replace(query=new_query)
|
| 393 |
+
return urlunparse(parsed)
|
| 394 |
+
except Exception:
|
| 395 |
+
return url
|