ScrapyTheScrapper commited on
Commit
37a9ecb
·
verified ·
1 Parent(s): 07f4df0

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile (5).txt +67 -0
  2. app-4.py +577 -0
  3. models-3.py +284 -0
  4. requirements-4.txt +57 -0
  5. scraper-3.py +268 -0
  6. 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