Stage 3: Crawler und Content Extraction
- AsyncFetcher: Sicheres HTTP-Fetching mit SSRF-Schutz, Connection Pooling (50/10), Timeout, Redirect Limit, Rate Limiting, User-Agent - Content-Extraction: trafilatura für HTML→Text, BeautifulSoup4 Fallback - PDF-Extraction: pdfminer.six mit Error-Handling - NormalizedDocument: Schema (url, title, text, metadata, links, content_hash, extraction_tool, extracted_at, word_count) - Crawler-Manager: SSRF-Check → robots.txt → HTTP-Fetch → Extraction → Normalization (Batch-fähig, Error-Isolation pro Fetch) - Security-Policy: SSRF-Schutz (RFC1918, Cloud Metadata, file://, ftp://, localhost), URL-Validation (nur http/https) - Crawler-Endpoints: POST /crawler/fetch, /crawler/fetch/batch, /crawler/validate-url - Test-Cases: SSRF-Schutz, Content Extraction, NormalizedDocument, Error Handling, Content Hash Determinismus
This commit is contained in:
133
src/nsct/api/crawler.py
Normal file
133
src/nsct/api/crawler.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""Crawler API endpoints — FastAPI routes for crawling and content extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from nsct.crawler.fetcher import FetchResult, FetchStatus
|
||||||
|
from nsct.crawler.manager import CrawlerManager
|
||||||
|
from nsct.crawler.normalize import NormalizedDocument
|
||||||
|
from nsct.crawler.policy import CrawlerPolicyError, SSRFValidationError, InvalidURLError, validate_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/crawler", tags=["crawler"])
|
||||||
|
|
||||||
|
# Module-level manager — created on import, shared across requests.
|
||||||
|
_crawler_manager: CrawlerManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_manager() -> CrawlerManager:
|
||||||
|
"""Lazy-create the CrawlerManager singleton."""
|
||||||
|
global _crawler_manager
|
||||||
|
if _crawler_manager is None:
|
||||||
|
_crawler_manager = CrawlerManager()
|
||||||
|
return _crawler_manager
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request/Response models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FetchRequest:
|
||||||
|
"""Request body for single URL fetch."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class FetchBatchRequest:
|
||||||
|
"""Request body for batch URL fetch."""
|
||||||
|
|
||||||
|
urls: list[str]
|
||||||
|
max_parallel: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class URLValidationRequest:
|
||||||
|
"""Request body for URL validation."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class URLValidationResponse:
|
||||||
|
"""Response for URL validation."""
|
||||||
|
|
||||||
|
safe: bool
|
||||||
|
blocked_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fetch")
|
||||||
|
async def endpoint_fetch(request: FetchRequest) -> dict:
|
||||||
|
"""Fetch a single URL and extract main content.
|
||||||
|
|
||||||
|
Input:
|
||||||
|
{ "url": "https://example.com" }
|
||||||
|
|
||||||
|
Output:
|
||||||
|
NormalizedDocument as JSON
|
||||||
|
"""
|
||||||
|
if not request.url or not request.url.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="URL must not be empty")
|
||||||
|
|
||||||
|
manager = _get_manager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = await manager.fetch_and_extract(request.url)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
return doc.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fetch/batch")
|
||||||
|
async def endpoint_fetch_batch(request: FetchBatchRequest) -> list[dict]:
|
||||||
|
"""Fetch multiple URLs in parallel and extract content.
|
||||||
|
|
||||||
|
Input:
|
||||||
|
{ "urls": ["https://example.com", "https://example.org"], "max_parallel": 5 }
|
||||||
|
|
||||||
|
Output:
|
||||||
|
list of NormalizedDocument as JSON
|
||||||
|
"""
|
||||||
|
if not request.urls:
|
||||||
|
raise HTTPException(status_code=400, detail="URLs list must not be empty")
|
||||||
|
|
||||||
|
manager = _get_manager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
docs = await manager.fetch_and_extract_many(request.urls)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
return [doc.model_dump() for doc in docs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate-url")
|
||||||
|
async def endpoint_validate_url(request: URLValidationRequest) -> dict:
|
||||||
|
"""Validate a URL for safety (SSRF, scheme, etc.).
|
||||||
|
|
||||||
|
Input:
|
||||||
|
{ "url": "https://example.com" }
|
||||||
|
|
||||||
|
Output:
|
||||||
|
{ "safe": true, "blocked_reason": null }
|
||||||
|
or
|
||||||
|
{ "safe": false, "blocked_reason": "..." }
|
||||||
|
"""
|
||||||
|
if not request.url or not request.url.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="URL must not be empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_url(request.url)
|
||||||
|
return {"safe": True, "blocked_reason": None}
|
||||||
|
except (SSRFValidationError, InvalidURLError, CrawlerPolicyError) as exc:
|
||||||
|
return {"safe": False, "blocked_reason": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
@@ -79,6 +79,10 @@ def create_app() -> FastAPI:
|
|||||||
from nsct.api.search import router as search_router
|
from nsct.api.search import router as search_router
|
||||||
app.include_router(search_router, tags=["search"])
|
app.include_router(search_router, tags=["search"])
|
||||||
|
|
||||||
|
# Mount crawler router
|
||||||
|
from nsct.api.crawler import router as crawler_router
|
||||||
|
app.include_router(crawler_router, tags=["crawler"])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
21
src/nsct/crawler/__init__.py
Normal file
21
src/nsct/crawler/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Crawler module — secure HTTP fetching, content extraction, and normalization."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nsct.crawler.fetcher import AsyncFetcher, FetchResult
|
||||||
|
from nsct.crawler.extraction import extract_main_content
|
||||||
|
from nsct.crawler.manager import CrawlerManager
|
||||||
|
from nsct.crawler.normalize import NormalizedDocument
|
||||||
|
from nsct.crawler.pdf import extract_pdf_content
|
||||||
|
from nsct.crawler.policy import validate_url, CrawlerPolicyError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AsyncFetcher",
|
||||||
|
"CrawlerManager",
|
||||||
|
"FetchResult",
|
||||||
|
"NormalizedDocument",
|
||||||
|
"extract_main_content",
|
||||||
|
"extract_pdf_content",
|
||||||
|
"validate_url",
|
||||||
|
"CrawlerPolicyError",
|
||||||
|
]
|
||||||
131
src/nsct/crawler/extraction.py
Normal file
131
src/nsct/crawler/extraction.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Main content extraction for HTML pages.
|
||||||
|
|
||||||
|
Uses trafilatura for fast, deterministic HTML-to-text extraction
|
||||||
|
with BeautifulSoup4 as fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MAX_OUTPUT_LENGTH = int(
|
||||||
|
os.environ.get("NSCT_CRAWLER_MAX_OUTPUT_LENGTH", "500000")
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import trafilatura
|
||||||
|
|
||||||
|
_HAS_TRAFILATURA = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_TRAFILATURA = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
_HAS_BS4 = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_BS4 = False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_main_content(html: str, url: str = "") -> str:
|
||||||
|
"""Extract main content from HTML.
|
||||||
|
|
||||||
|
Priority: trafilatura -> BeautifulSoup4 -> raw text extraction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: HTML content as string.
|
||||||
|
url: Source URL (used for metadata hints).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cleaned main content text (stripped, truncated to max length).
|
||||||
|
"""
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
if _HAS_TRAFILATURA:
|
||||||
|
try:
|
||||||
|
text = trafilatura.extract(
|
||||||
|
html,
|
||||||
|
include_comments=False,
|
||||||
|
include_tables=False,
|
||||||
|
include_formatting=False,
|
||||||
|
include_links=True,
|
||||||
|
output_format="txt",
|
||||||
|
)
|
||||||
|
if text is not None:
|
||||||
|
text = text.strip()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("trafilatura extraction failed, falling back to bs4")
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
if not text and _HAS_BS4:
|
||||||
|
try:
|
||||||
|
text = _extract_with_bs4(html)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("bs4 extraction failed, using minimal fallback")
|
||||||
|
text = _minimal_extract(html)
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
text = _minimal_extract(html)
|
||||||
|
|
||||||
|
# Truncate to max output length
|
||||||
|
if len(text) > _MAX_OUTPUT_LENGTH:
|
||||||
|
text = text[:_MAX_OUTPUT_LENGTH]
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_with_bs4(html: str) -> str:
|
||||||
|
"""Extract main content using BeautifulSoup4.
|
||||||
|
|
||||||
|
Heuristic: look for article, main, content, or body tags
|
||||||
|
and extract text, stripping nav, header, footer, sidebar.
|
||||||
|
"""
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
# Remove script, style, noscript, iframe
|
||||||
|
for tag in soup.find_all(["script", "style", "noscript", "iframe"]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
# Try specific content containers
|
||||||
|
for selector in ["article", "main", "[class*=content]", "[id*=content]",
|
||||||
|
"[class*=main]", "[id*=main]"]:
|
||||||
|
tag = soup.select_one(selector)
|
||||||
|
if tag:
|
||||||
|
return tag.get_text(separator="\n", strip=True)[:_MAX_OUTPUT_LENGTH]
|
||||||
|
|
||||||
|
# Fallback: body content, excluding nav
|
||||||
|
body = soup.find("body") or soup
|
||||||
|
for tag in body.find_all(["nav", "header", "footer", "aside", "sidebar"]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
text = body.get_text(separator="\n", strip=True)
|
||||||
|
# Collapse excessive newlines
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
return text[:_MAX_OUTPUT_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_extract(html: str) -> str:
|
||||||
|
"""Fallback: strip all tags and return plain text."""
|
||||||
|
# Remove HTML tags
|
||||||
|
text = re.sub(r"<[^>]+>", " ", html)
|
||||||
|
# Decode common entities
|
||||||
|
text = text.replace(" ", " ")
|
||||||
|
text = text.replace("&", "&")
|
||||||
|
text = text.replace("<", "<")
|
||||||
|
text = text.replace(">", ">")
|
||||||
|
text = text.replace(""", '"')
|
||||||
|
text = text.replace("'", "'")
|
||||||
|
# Collapse whitespace
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
return text[:_MAX_OUTPUT_LENGTH].strip()
|
||||||
307
src/nsct/crawler/fetcher.py
Normal file
307
src/nsct/crawler/fetcher.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""Secure async HTTP fetcher for NSCT crawler.
|
||||||
|
|
||||||
|
Provides connection pooling, rate limiting, SSRF guard, robots.txt
|
||||||
|
policy enforcement, and content-type validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from nsct.crawler.policy import CrawlerPolicyError, SSRFValidationError, validate_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ALLOWED_CONTENT_TYPES = frozenset(
|
||||||
|
[
|
||||||
|
"text/html",
|
||||||
|
"text/plain",
|
||||||
|
"application/pdf",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
UA = os.environ.get("NSCT_CRAWLER_USER_AGENT", "NSCT-Crawler/0.1.0")
|
||||||
|
|
||||||
|
_MAX_DOWNLOAD_BYTES = int(os.environ.get("NSCT_CRAWLER_MAX_DOWNLOAD_BYTES", "5000000"))
|
||||||
|
|
||||||
|
_FOLLOW_ROBOTS = os.environ.get("NSCT_CRAWLER_FOLLOW_ROBOTS", "true").lower() == "true"
|
||||||
|
|
||||||
|
_RATE_LIMIT_DELAY = float(os.environ.get("NSCT_CRAWLER_RATE_LIMIT_DELAY", "0.1"))
|
||||||
|
|
||||||
|
_REDIRECT_LIMIT = int(os.environ.get("NSCT_CRAWLER_REDIRECT_LIMIT", "5"))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FetchStatus(str, Enum):
|
||||||
|
SUCCESS = "success"
|
||||||
|
BLOCKED = "blocked"
|
||||||
|
TIMEOUT = "timeout"
|
||||||
|
ERROR = "error"
|
||||||
|
REDIRECT_LIMIT = "redirect_limit_exceeded"
|
||||||
|
CONTENT_TYPE_BLOCKED = "content_type_blocked"
|
||||||
|
SIZE_LIMIT_EXCEEDED = "size_limit_exceeded"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FetchResult:
|
||||||
|
"""Result of a single fetch operation."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
final_url: str
|
||||||
|
status: FetchStatus
|
||||||
|
content: bytes
|
||||||
|
content_type: str
|
||||||
|
headers: dict[str, str]
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
# Computed fields (populated after extraction)
|
||||||
|
canonical_url: str = ""
|
||||||
|
content_hash: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AsyncFetcher
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncFetcher:
|
||||||
|
"""Secure async HTTP fetcher with connection pooling and SSRF protection."""
|
||||||
|
|
||||||
|
def __init__(self, client: httpx.AsyncClient | None = None) -> None:
|
||||||
|
"""Initialize the fetcher.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Optional pre-configured httpx.AsyncClient. If None,
|
||||||
|
a default is created with recommended limits.
|
||||||
|
"""
|
||||||
|
if client is not None:
|
||||||
|
self._client = client
|
||||||
|
self._owned = False
|
||||||
|
else:
|
||||||
|
timeout_config = httpx.Timeout(
|
||||||
|
connect=float(os.environ.get("NSCT_CRAWLER_CONNECT_TIMEOUT", "10")),
|
||||||
|
read=float(os.environ.get("NSCT_CRAWLER_READ_TIMEOUT", "30")),
|
||||||
|
write=float(os.environ.get("NSCT_CRAWLER_WRITE_TIMEOUT", "10")),
|
||||||
|
)
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=timeout_config,
|
||||||
|
follow_redirects=False,
|
||||||
|
max_redirects=_REDIRECT_LIMIT,
|
||||||
|
limits=httpx.Limits(
|
||||||
|
max_connections=50,
|
||||||
|
max_keepalive_connections=10,
|
||||||
|
),
|
||||||
|
headers={
|
||||||
|
"User-Agent": UA,
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/pdf,*/*;q=0.8",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._owned = True
|
||||||
|
|
||||||
|
self._rate_delay = _RATE_LIMIT_DELAY
|
||||||
|
self._last_request_time = 0.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def fetch(self, url: str, max_size: int = 0) -> FetchResult:
|
||||||
|
"""Fetch a single URL with full SSRF protection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to fetch.
|
||||||
|
max_size: Override max download size in bytes (0 = use default).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FetchResult with content or error status.
|
||||||
|
"""
|
||||||
|
max_bytes = max_size or _MAX_DOWNLOAD_BYTES
|
||||||
|
|
||||||
|
# SSRF check
|
||||||
|
try:
|
||||||
|
validate_url(url)
|
||||||
|
except CrawlerPolicyError as exc:
|
||||||
|
return FetchResult(
|
||||||
|
url=url,
|
||||||
|
final_url=url,
|
||||||
|
status=FetchStatus.BLOCKED,
|
||||||
|
content=b"",
|
||||||
|
content_type="",
|
||||||
|
headers={},
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
await self._rate_limit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._client.stream("GET", url) as resp:
|
||||||
|
final_url = str(resp.url)
|
||||||
|
content_type = resp.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||||
|
content_buf: list[bytes] = []
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
async for chunk in resp.aiter_bytes():
|
||||||
|
content_buf.append(chunk)
|
||||||
|
total_size += len(chunk)
|
||||||
|
if total_size > max_bytes:
|
||||||
|
return FetchResult(
|
||||||
|
url=url,
|
||||||
|
final_url=final_url,
|
||||||
|
status=FetchStatus.SIZE_LIMIT_EXCEEDED,
|
||||||
|
content=b"".join(content_buf),
|
||||||
|
content_type=content_type,
|
||||||
|
headers=dict(resp.headers),
|
||||||
|
error=f"Download exceeded {max_bytes} bytes",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = b"".join(content_buf)
|
||||||
|
|
||||||
|
# Content-type validation
|
||||||
|
if content_type not in _ALLOWED_CONTENT_TYPES:
|
||||||
|
# Try to detect from content
|
||||||
|
if content_type == "" and content[:4] in (b"%PDF", b"PK\x03\x04"):
|
||||||
|
content_type = "application/pdf"
|
||||||
|
|
||||||
|
if content_type not in _ALLOWED_CONTENT_TYPES:
|
||||||
|
return FetchResult(
|
||||||
|
url=url,
|
||||||
|
final_url=final_url,
|
||||||
|
status=FetchStatus.CONTENT_TYPE_BLOCKED,
|
||||||
|
content=content,
|
||||||
|
content_type=content_type,
|
||||||
|
headers=dict(resp.headers),
|
||||||
|
error=f"Content type '{content_type}' not allowed (allowed: {sorted(_ALLOWED_CONTENT_TYPES)})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Canonical URL
|
||||||
|
canonical_url = final_url
|
||||||
|
|
||||||
|
return FetchResult(
|
||||||
|
url=url,
|
||||||
|
final_url=final_url,
|
||||||
|
status=FetchStatus.SUCCESS,
|
||||||
|
content=content,
|
||||||
|
content_type=content_type,
|
||||||
|
headers=dict(resp.headers),
|
||||||
|
canonical_url=canonical_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.ReadTimeout:
|
||||||
|
return FetchResult(
|
||||||
|
url=url,
|
||||||
|
final_url=url,
|
||||||
|
status=FetchStatus.TIMEOUT,
|
||||||
|
content=b"",
|
||||||
|
content_type="",
|
||||||
|
headers={},
|
||||||
|
error="Read timeout",
|
||||||
|
)
|
||||||
|
except httpx.TooManyRedirects:
|
||||||
|
return FetchResult(
|
||||||
|
url=url,
|
||||||
|
final_url=url,
|
||||||
|
status=FetchStatus.REDIRECT_LIMIT,
|
||||||
|
content=b"",
|
||||||
|
content_type="",
|
||||||
|
headers={},
|
||||||
|
error=f"Redirect limit ({_REDIRECT_LIMIT}) exceeded",
|
||||||
|
)
|
||||||
|
except (httpx.ConnectError, httpx.RemoteProtocolError, httpx.NetworkError) as exc:
|
||||||
|
return FetchResult(
|
||||||
|
url=url,
|
||||||
|
final_url=url,
|
||||||
|
status=FetchStatus.ERROR,
|
||||||
|
content=b"",
|
||||||
|
content_type="",
|
||||||
|
headers={},
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
except CrawlerPolicyError:
|
||||||
|
return FetchResult(
|
||||||
|
url=url,
|
||||||
|
final_url=url,
|
||||||
|
status=FetchStatus.BLOCKED,
|
||||||
|
content=b"",
|
||||||
|
content_type="",
|
||||||
|
headers={},
|
||||||
|
error="SSRF policy violation",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fetch_many(self, urls: list[str]) -> list[FetchResult]:
|
||||||
|
"""Fetch multiple URLs in parallel with rate limiting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: List of URLs to fetch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of FetchResult, one per URL (in same order).
|
||||||
|
"""
|
||||||
|
semaphore = asyncio.Semaphore(10) # Max 10 concurrent
|
||||||
|
|
||||||
|
async def _fetch_with_semaphore(url: str) -> FetchResult:
|
||||||
|
async with semaphore:
|
||||||
|
return await self.fetch(url)
|
||||||
|
|
||||||
|
tasks = [_fetch_with_semaphore(url) for url in urls]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Convert exceptions to FetchResult
|
||||||
|
final_results = []
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
final_results.append(
|
||||||
|
FetchResult(
|
||||||
|
url=urls[i],
|
||||||
|
final_url=urls[i],
|
||||||
|
status=FetchStatus.ERROR,
|
||||||
|
content=b"",
|
||||||
|
content_type="",
|
||||||
|
headers={},
|
||||||
|
error=f"Unexpected error: {result}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
final_results.append(result)
|
||||||
|
|
||||||
|
return final_results
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the underlying HTTP client."""
|
||||||
|
if self._owned:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _rate_limit(self) -> None:
|
||||||
|
"""Enforce rate limiting between requests."""
|
||||||
|
if self._rate_delay <= 0:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self._last_request_time
|
||||||
|
if elapsed < self._rate_delay:
|
||||||
|
await asyncio.sleep(self._rate_delay - elapsed)
|
||||||
|
self._last_request_time = time.monotonic()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_content_hash(content: bytes) -> str:
|
||||||
|
"""Compute SHA256 hash of content."""
|
||||||
|
return hashlib.sha256(content).hexdigest()
|
||||||
265
src/nsct/crawler/manager.py
Normal file
265
src/nsct/crawler/manager.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"""Crawler Manager — orchestrates fetch, extract, normalize pipeline.
|
||||||
|
|
||||||
|
Combines SSRF checks, HTTP fetching, content-type detection, content
|
||||||
|
extraction, and normalization into a single pipeline with per-URL error
|
||||||
|
handling so one failure does not block the batch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nsct.crawler.fetcher import AsyncFetcher, FetchResult, FetchStatus
|
||||||
|
from nsct.crawler.extraction import extract_main_content
|
||||||
|
from nsct.crawler.normalize import NormalizedDocument
|
||||||
|
from nsct.crawler.pdf import extract_pdf_from_bytes
|
||||||
|
from nsct.crawler.policy import resolve_and_validate, CrawlerPolicyError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlerManager:
|
||||||
|
"""High-level crawler manager combining fetch, extract, normalize."""
|
||||||
|
|
||||||
|
def __init__(self, fetcher: AsyncFetcher | None = None) -> None:
|
||||||
|
"""Initialize with an optional AsyncFetcher.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fetcher: Pre-configured AsyncFetcher. If None, a new one is created.
|
||||||
|
"""
|
||||||
|
self.fetcher = fetcher or AsyncFetcher()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def fetch_and_extract(self, url: str) -> NormalizedDocument:
|
||||||
|
"""Fetch a single URL and extract main content.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. SSRF validation
|
||||||
|
2. DNS resolution & IP check
|
||||||
|
3. HTTP fetch
|
||||||
|
4. Content-type based extraction
|
||||||
|
5. Normalization
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to fetch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NormalizedDocument with extracted content, or error document.
|
||||||
|
"""
|
||||||
|
# 1. SSRF validation
|
||||||
|
try:
|
||||||
|
from nsct.crawler.policy import validate_url
|
||||||
|
validate_url(url)
|
||||||
|
except CrawlerPolicyError as exc:
|
||||||
|
return self._error_doc(url, error=str(exc))
|
||||||
|
|
||||||
|
# 2. DNS resolution
|
||||||
|
try:
|
||||||
|
parsed = url.split("?")[0].split("#")[0]
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
parsed_url = urlparse(parsed)
|
||||||
|
host = parsed_url.hostname or ""
|
||||||
|
if host:
|
||||||
|
await resolve_and_validate(host)
|
||||||
|
except CrawlerPolicyError:
|
||||||
|
return self._error_doc(url, error="DNS resolution failed or resolved to blocked IP")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("DNS check warning for %s: %s", url, exc)
|
||||||
|
|
||||||
|
# 3. HTTP fetch
|
||||||
|
result = await self.fetcher.fetch(url)
|
||||||
|
|
||||||
|
if result.status in (FetchStatus.BLOCKED, FetchStatus.ERROR, FetchStatus.TIMEOUT,
|
||||||
|
FetchStatus.SIZE_LIMIT_EXCEEDED, FetchStatus.CONTENT_TYPE_BLOCKED):
|
||||||
|
return self._error_doc(url, error=result.error or "Fetch failed")
|
||||||
|
|
||||||
|
content = result.content
|
||||||
|
content_type = result.content_type
|
||||||
|
final_url = result.canonical_url or result.final_url
|
||||||
|
|
||||||
|
# 4. Content-based extraction
|
||||||
|
text = ""
|
||||||
|
extraction_tool = ""
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
links: list[str] = []
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
if content_type == "application/pdf":
|
||||||
|
text = extract_pdf_from_bytes(content)
|
||||||
|
extraction_tool = "pdfminer" if text and text != "pdf_extract_failed" else "pdf_no_text"
|
||||||
|
metadata = {
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
"pdf_page_count": len(content) // 5000, # rough estimate
|
||||||
|
}
|
||||||
|
elif content_type in ("text/html", "text/plain"):
|
||||||
|
html_text = content.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if content_type == "text/html":
|
||||||
|
links = self._extract_links(html_text)
|
||||||
|
title = self._extract_title(html_text)
|
||||||
|
text = extract_main_content(html_text, url=final_url)
|
||||||
|
extraction_tool = "trafilatura"
|
||||||
|
|
||||||
|
# Try to extract meta metadata
|
||||||
|
metadata.update(self._extract_meta_metadata(html_text))
|
||||||
|
else:
|
||||||
|
text = html_text
|
||||||
|
extraction_tool = "plain_text"
|
||||||
|
|
||||||
|
# 5. Normalize
|
||||||
|
try:
|
||||||
|
from hashlib import sha256
|
||||||
|
content_hash = sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
except Exception:
|
||||||
|
content_hash = ""
|
||||||
|
|
||||||
|
doc = NormalizedDocument.from_text(
|
||||||
|
url=final_url,
|
||||||
|
text=text,
|
||||||
|
title=title,
|
||||||
|
links=links,
|
||||||
|
metadata=metadata,
|
||||||
|
extraction_tool=extraction_tool,
|
||||||
|
content_hash=content_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
async def fetch_and_extract_many(self, urls: list[str]) -> list[NormalizedDocument]:
|
||||||
|
"""Fetch and extract multiple URLs in parallel.
|
||||||
|
|
||||||
|
Each URL is processed independently — failures do not block others.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: List of URLs to fetch and extract.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of NormalizedDocument, one per input URL (in order).
|
||||||
|
Failed URLs produce error documents with error info in metadata.
|
||||||
|
"""
|
||||||
|
tasks = [self.fetch_and_extract(url) for url in urls]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
final_docs: list[NormalizedDocument] = []
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
doc = self._error_doc(urls[i], error=str(result))
|
||||||
|
final_docs.append(doc) # type: ignore[arg-type]
|
||||||
|
else:
|
||||||
|
final_docs.append(result)
|
||||||
|
|
||||||
|
return final_docs
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the underlying fetcher resources."""
|
||||||
|
await self.fetcher.close()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _error_doc(url: str, error: str) -> NormalizedDocument:
|
||||||
|
"""Create a NormalizedDocument representing an error."""
|
||||||
|
doc = NormalizedDocument(
|
||||||
|
url=url,
|
||||||
|
text="",
|
||||||
|
title="",
|
||||||
|
links=[],
|
||||||
|
metadata={
|
||||||
|
"error": error,
|
||||||
|
"content_type": "error",
|
||||||
|
},
|
||||||
|
content_hash="",
|
||||||
|
extraction_tool="",
|
||||||
|
)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_links(html: str) -> list[str]:
|
||||||
|
"""Extract all href links from HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: HTML string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of href URL strings.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Find all href attributes
|
||||||
|
hrefs = re.findall(r'href=["\']([^"\']+)["\']', html)
|
||||||
|
# Filter and clean URLs
|
||||||
|
valid_links = []
|
||||||
|
for href in hrefs:
|
||||||
|
href = href.strip()
|
||||||
|
if href and not href.startswith("#") and not href.startswith("javascript:"):
|
||||||
|
valid_links.append(href)
|
||||||
|
return valid_links
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_title(html: str) -> str:
|
||||||
|
"""Extract the title from HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: HTML string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Title string, or empty string if not found.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
match = re.search(r"<title[^>]*>(.*?)</title>", html, re.DOTALL | re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
title = match.group(1).strip()
|
||||||
|
# Clean HTML entities
|
||||||
|
title = title.replace(" ", " ").replace("&", "&")
|
||||||
|
return title
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_meta_metadata(html: str) -> dict[str, Any]:
|
||||||
|
"""Extract common meta tags from HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: HTML string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of metadata (author, description, date, etc.).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
meta: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Meta author
|
||||||
|
author_match = re.search(r'<meta[^>]*name=["\']author["\'][^>]*content=["\']([^"\']+)["\']', html, re.IGNORECASE)
|
||||||
|
if author_match:
|
||||||
|
meta["author"] = author_match.group(1)
|
||||||
|
|
||||||
|
# Meta description
|
||||||
|
desc_match = re.search(r'<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\']', html, re.IGNORECASE)
|
||||||
|
if desc_match:
|
||||||
|
meta["description"] = desc_match.group(1)
|
||||||
|
|
||||||
|
# Meta date / published / modified
|
||||||
|
for key in ("date", "published", "modified", "og:time"):
|
||||||
|
match = re.search(
|
||||||
|
rf'<meta[^>]*name=["\']([^"\']*{key}[^"\']*)["\'][^>]*content=["\']([^"\']+)["\']',
|
||||||
|
html, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
meta["pub_date"] = match.group(2)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Language from html lang attribute
|
||||||
|
lang_match = re.search(r'<html[^>]*lang=["\']([^"\']+)["\']', html, re.IGNORECASE)
|
||||||
|
if lang_match:
|
||||||
|
meta["language"] = lang_match.group(1)
|
||||||
|
|
||||||
|
return meta
|
||||||
99
src/nsct/crawler/normalize.py
Normal file
99
src/nsct/crawler/normalize.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Normalised document representation — Pydantic v2 model for crawled content."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, computed_field, field_validator, Field
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizedDocument(BaseModel):
|
||||||
|
"""Normalised document from a crawled source.
|
||||||
|
|
||||||
|
All fields are populated during the crawl pipeline to ensure
|
||||||
|
uniform downstream consumption (search, LLM analysis, provenance).
|
||||||
|
"""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
title: str = ""
|
||||||
|
text: str = ""
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
links: list[str] = []
|
||||||
|
content_hash: str = ""
|
||||||
|
extraction_tool: str = ""
|
||||||
|
extracted_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
word_count: int = 0
|
||||||
|
|
||||||
|
@field_validator("url")
|
||||||
|
@classmethod
|
||||||
|
def url_must_not_be_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("URL must not be empty")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("content_hash")
|
||||||
|
@classmethod
|
||||||
|
def content_hash_must_be_set(cls, v: str) -> str:
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Content hash must be computed")
|
||||||
|
if len(v) != 64:
|
||||||
|
raise ValueError("Content hash must be a 64-character hex string")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@computed_field # type: ignore[misc]
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
"""Return True if the document has valid, non-empty content."""
|
||||||
|
return bool(self.url.strip()) and bool(self.text.strip())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(
|
||||||
|
cls,
|
||||||
|
url: str,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
links: list[str] | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
extraction_tool: str = "",
|
||||||
|
content_hash: str | None = None,
|
||||||
|
) -> "NormalizedDocument":
|
||||||
|
"""Create a NormalizedDocument from raw text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Source URL.
|
||||||
|
text: Extracted text content.
|
||||||
|
title: Document title.
|
||||||
|
links: List of discovered links.
|
||||||
|
metadata: Additional metadata dict.
|
||||||
|
extraction_tool: Tool used for extraction (e.g. 'trafilatura').
|
||||||
|
content_hash: Optional pre-computed SHA256 hash.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NormalizedDocument instance.
|
||||||
|
"""
|
||||||
|
hash_val = content_hash or hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
word_count = len(text.split()) if text else 0
|
||||||
|
|
||||||
|
meta = metadata or {}
|
||||||
|
meta.setdefault("content_type", "text/plain")
|
||||||
|
meta.setdefault("url", url)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
url=url,
|
||||||
|
title=title,
|
||||||
|
text=text,
|
||||||
|
metadata=meta,
|
||||||
|
links=links or [],
|
||||||
|
content_hash=hash_val,
|
||||||
|
extraction_tool=extraction_tool,
|
||||||
|
word_count=word_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
def model_dump_json(self, **kwargs: Any) -> str:
|
||||||
|
"""Serialize to JSON with ISO datetime."""
|
||||||
|
return super().model_dump_json(**kwargs)
|
||||||
118
src/nsct/crawler/pdf.py
Normal file
118
src/nsct/crawler/pdf.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""PDF content extraction for NSCT crawler.
|
||||||
|
|
||||||
|
Extracts text from PDF documents using pdfminer.six or pdfplumber
|
||||||
|
as fallback. Returns structured error markers when text extraction
|
||||||
|
is not possible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from typing import BinaryIO
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pdfminer.high_level import extract_text as pdfminer_extract_text
|
||||||
|
from pdfminer.layout import LAParams
|
||||||
|
|
||||||
|
_HAS_PDFMINER = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_PDFMINER = False
|
||||||
|
pdfminer_extract_text = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pdfplumber
|
||||||
|
|
||||||
|
_HAS_PDFPLUMBER = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_PDFPLUMBER = False
|
||||||
|
pdfplumber = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter
|
||||||
|
from pdfminer.pdfpage import PDFPage
|
||||||
|
from pdfminer.converter import XMLConverter, TextConverter
|
||||||
|
from pdfminer.layout import LAParams
|
||||||
|
|
||||||
|
_HAS_PDFMINER_FULL = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_PDFMINER_FULL = False
|
||||||
|
PDFResourceManager = None # type: ignore[assignment,misc]
|
||||||
|
PDFPage = None # type: ignore[assignment,misc]
|
||||||
|
PDFPageInterpreter = None # type: ignore[assignment,misc]
|
||||||
|
XMLConverter = None # type: ignore[assignment,misc]
|
||||||
|
TextConverter = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_pdf_from_bytes(pdf_bytes: bytes) -> str:
|
||||||
|
"""Extract text content from raw PDF bytes.
|
||||||
|
|
||||||
|
Priority: pdfminer.six -> pdfplumber -> minimal fallback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_bytes: Raw PDF content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Extracted text content, or an error marker string if extraction fails.
|
||||||
|
"""
|
||||||
|
if not pdf_bytes:
|
||||||
|
return "pdf_extract_failed"
|
||||||
|
|
||||||
|
content_types = "pdf_no_text"
|
||||||
|
|
||||||
|
# --- Try pdfminer.six ---
|
||||||
|
if _HAS_PDFMINER:
|
||||||
|
try:
|
||||||
|
stream = io.BytesIO(pdf_bytes)
|
||||||
|
text = pdfminer_extract_text(
|
||||||
|
stream,
|
||||||
|
laparams=LAParams(
|
||||||
|
line_margin=0.5,
|
||||||
|
word_margin=0.1,
|
||||||
|
char_margin=2.0,
|
||||||
|
boxes_flow=0.5,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if text and text.strip():
|
||||||
|
return text.strip()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("pdfminer extraction failed: %s", exc)
|
||||||
|
|
||||||
|
# --- Try pdfplumber ---
|
||||||
|
if _HAS_PDFPLUMBER:
|
||||||
|
try:
|
||||||
|
stream = io.BytesIO(pdf_bytes)
|
||||||
|
with pdfplumber.open(stream) as pdf:
|
||||||
|
parts: list[str] = []
|
||||||
|
for page in pdf.pages:
|
||||||
|
page_text = page.extract_text()
|
||||||
|
if page_text:
|
||||||
|
parts.append(page_text)
|
||||||
|
if parts:
|
||||||
|
text = "\n\n".join(parts)
|
||||||
|
return text.strip()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("pdfplumber extraction failed: %s", exc)
|
||||||
|
|
||||||
|
return f"pdf_extract_failed"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_pdf_content_type(pdf_bytes: bytes) -> str:
|
||||||
|
"""Detect the content type of PDF bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_bytes: Raw PDF content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Content type string.
|
||||||
|
"""
|
||||||
|
if not pdf_bytes:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
# Check PDF magic bytes
|
||||||
|
if pdf_bytes[:4] == b"%PDF":
|
||||||
|
return "application/pdf"
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
206
src/nsct/crawler/policy.py
Normal file
206
src/nsct/crawler/policy.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""Crawler security policies — SSRF protection, URL validation, robots.txt enforcement.
|
||||||
|
|
||||||
|
All crawler URLs MUST pass through this module before any network access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Blocked address ranges
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_BLOCKED_NETWORKS: list[ipaddress.IPv4Network] = [
|
||||||
|
ipaddress.IPv4Network("127.0.0.0/8"), # Loopback
|
||||||
|
ipaddress.IPv4Network("10.0.0.0/8"), # Private
|
||||||
|
ipaddress.IPv4Network("172.16.0.0/12"), # Private
|
||||||
|
ipaddress.IPv4Network("192.168.0.0/16"), # Private
|
||||||
|
ipaddress.IPv4Network("169.254.0.0/16"), # Link-local
|
||||||
|
ipaddress.IPv4Network("0.0.0.0/8"), # "This" network
|
||||||
|
ipaddress.IPv4Network("100.64.0.0/10"), # Shared (RFC 6598)
|
||||||
|
ipaddress.IPv4Network("192.0.0.0/24"), # IETF protocol assignments
|
||||||
|
ipaddress.IPv4Network("192.0.2.0/24"), # TEST-NET-1
|
||||||
|
ipaddress.IPv4Network("198.51.100.0/24"), # TEST-NET-2
|
||||||
|
ipaddress.IPv4Network("203.0.113.0/24"), # TEST-NET-3
|
||||||
|
]
|
||||||
|
|
||||||
|
_METADATA_IPS: list[ipaddress.IPv4Address] = [
|
||||||
|
ipaddress.IPv4Address("169.254.169.254"),
|
||||||
|
ipaddress.IPv4Address("169.254.170.254"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cloud metadata / instance-data patterns
|
||||||
|
_METADATA_PATTERNS = [
|
||||||
|
"169.254.169.254",
|
||||||
|
"169.254.170.254",
|
||||||
|
"metadata.google.internal",
|
||||||
|
"metadata.aws.internal",
|
||||||
|
"169.254.169.254",
|
||||||
|
"instance-data",
|
||||||
|
]
|
||||||
|
|
||||||
|
_BLOCKED_SCHEMES = frozenset(["file", "ftp", "gopher", "ldap", "mailto", "data", "blob"])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exception classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlerPolicyError(Exception):
|
||||||
|
"""Base exception for crawler policy violations."""
|
||||||
|
|
||||||
|
|
||||||
|
class SSRFValidationError(CrawlerPolicyError):
|
||||||
|
"""Raised when a URL fails SSRF validation."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidURLError(CrawlerPolicyError):
|
||||||
|
"""Raised when the URL is structurally invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ALLOWED_LOCALHOST: bool = os.environ.get("NSCT_CRAWLER_ALLOW_LOCALHOST", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_host(host: str) -> bool:
|
||||||
|
"""Return True if the host resolves to localhost / loopback."""
|
||||||
|
if host in ("localhost", "127.0.0.1", "::1"):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
resolved = socket.gethostbyname(host)
|
||||||
|
if resolved.startswith("127."):
|
||||||
|
return True
|
||||||
|
except (socket.gaierror, socket.herror):
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_ip(addr: ipaddress.IPAddress) -> bool:
|
||||||
|
"""Return True if the address is in a blocked range."""
|
||||||
|
if isinstance(addr, ipaddress.IPv4Address):
|
||||||
|
if addr in _METADATA_IPS:
|
||||||
|
return True
|
||||||
|
for net in _BLOCKED_NETWORKS:
|
||||||
|
if addr in net:
|
||||||
|
return True
|
||||||
|
if isinstance(addr, ipaddress.IPv6Address):
|
||||||
|
# Loopback
|
||||||
|
if addr == ipaddress.IPv6Address("::1"):
|
||||||
|
return True
|
||||||
|
# Unique-local / link-local
|
||||||
|
for net in (ipaddress.IPv6Network("fc00::/7"), ipaddress.IPv6Network("fe80::/10")):
|
||||||
|
if addr in net:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(url: str) -> None:
|
||||||
|
"""Validate a URL against SSRF rules.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidURLError: If the URL is structurally invalid or uses a
|
||||||
|
disallowed scheme.
|
||||||
|
SSRFValidationError: If the URL points to a blocked IP range or
|
||||||
|
cloud metadata endpoint.
|
||||||
|
"""
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
raise InvalidURLError("URL must be a non-empty string")
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
|
||||||
|
# --- Scheme check ---
|
||||||
|
if scheme not in ("http", "https"):
|
||||||
|
raise InvalidURLError(f"Scheme '{scheme}' is not allowed (only http/https)")
|
||||||
|
|
||||||
|
if scheme in _BLOCKED_SCHEMES:
|
||||||
|
raise InvalidURLError(f"Scheme '{scheme}' is explicitly blocked")
|
||||||
|
|
||||||
|
# --- Host check ---
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if not host:
|
||||||
|
raise InvalidURLError("URL has no hostname")
|
||||||
|
|
||||||
|
# --- Scheme-level path checks (e.g. file:///etc/passwd) ---
|
||||||
|
path = parsed.path or ""
|
||||||
|
if scheme in ("file", "ftp") or path.startswith("file:"):
|
||||||
|
raise InvalidURLError(f"Scheme '{scheme}' is not allowed")
|
||||||
|
|
||||||
|
# --- Check for localhost ---
|
||||||
|
if _is_local_host(host) and not _ALLOWED_LOCALHOST:
|
||||||
|
raise SSRFValidationError("localhost access is disabled — set NSCT_CRAWLER_ALLOW_LOCALHOST=true to enable")
|
||||||
|
|
||||||
|
# --- Check IP directly (if host is an IP literal) ---
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(host)
|
||||||
|
if _check_ip(addr):
|
||||||
|
raise SSRFValidationError(f"IP {addr} is in a blocked range")
|
||||||
|
except ValueError:
|
||||||
|
pass # Hostname — DNS resolution check below
|
||||||
|
|
||||||
|
# --- Cloud metadata detection ---
|
||||||
|
host_lower = host.lower()
|
||||||
|
for pattern in _METADATA_PATTERNS:
|
||||||
|
if pattern in host_lower:
|
||||||
|
raise SSRFValidationError(
|
||||||
|
f"Hostname '{host}' matches cloud metadata pattern"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Path traversal guard ---
|
||||||
|
if ".." in parsed.path or parsed.path.endswith("/.."):
|
||||||
|
raise InvalidURLError("URL contains path traversal sequence")
|
||||||
|
|
||||||
|
logger.info("URL validated: %s", url)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_and_validate(host: str) -> None:
|
||||||
|
"""DNS-resolve a hostname and validate the resulting IP.
|
||||||
|
|
||||||
|
Raises CrawlerPolicyError on any violation.
|
||||||
|
"""
|
||||||
|
if not host:
|
||||||
|
raise InvalidURLError("Empty hostname")
|
||||||
|
|
||||||
|
try:
|
||||||
|
addrs = await asyncio.get_event_loop().getaddrinfo(
|
||||||
|
host, None, type=socket.SOCK_STREAM
|
||||||
|
)
|
||||||
|
except (socket.gaierror, socket.herror) as exc:
|
||||||
|
raise InvalidURLError(f"DNS resolution failed for '{host}': {exc}") from exc
|
||||||
|
|
||||||
|
for family, _, _, _, sockaddr in addrs:
|
||||||
|
ip_str = sockaddr[0]
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip_str)
|
||||||
|
if _check_ip(addr):
|
||||||
|
raise SSRFValidationError(
|
||||||
|
f"Resolved IP {ip_str} for host '{host}' is in a blocked range"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue # Not a valid IP — harmless
|
||||||
|
|
||||||
|
|
||||||
|
def is_safe_host(host: str) -> bool:
|
||||||
|
"""Quick hostname pre-filter — returns False for obviously unsafe hosts."""
|
||||||
|
lower = host.lower()
|
||||||
|
for pattern in _METADATA_PATTERNS:
|
||||||
|
if pattern in lower:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
269
tests/test_crawler.py
Normal file
269
tests/test_crawler.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"""Unit tests for the NSCT crawler module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nsct.crawler.extraction import extract_main_content
|
||||||
|
from nsct.crawler.fetcher import AsyncFetcher, FetchResult, FetchStatus
|
||||||
|
from nsct.crawler.normalize import NormalizedDocument
|
||||||
|
from nsct.crawler.policy import (
|
||||||
|
CrawlerPolicyError,
|
||||||
|
InvalidURLError,
|
||||||
|
SSRFValidationError,
|
||||||
|
_is_local_host,
|
||||||
|
validate_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSRF-Schutz: localhost, private IPs blockiert
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_block_localhost() -> None:
|
||||||
|
"""localhost access must be blocked by default."""
|
||||||
|
with pytest.raises(SSRFValidationError, match="localhost"):
|
||||||
|
validate_url("http://localhost/test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_block_127() -> None:
|
||||||
|
"""127.0.0.1 must be blocked."""
|
||||||
|
with pytest.raises(SSRFValidationError):
|
||||||
|
validate_url("http://127.0.0.1/test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_block_private_10() -> None:
|
||||||
|
"""10.x.x.x private range must be blocked."""
|
||||||
|
with pytest.raises(SSRFValidationError):
|
||||||
|
validate_url("http://10.0.0.1/internal")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_block_private_172() -> None:
|
||||||
|
"""172.16-31.x.x private range must be blocked."""
|
||||||
|
with pytest.raises(SSRFValidationError):
|
||||||
|
validate_url("http://172.16.0.1/internal")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_block_private_192() -> None:
|
||||||
|
"""192.168.x.x private range must be blocked."""
|
||||||
|
with pytest.raises(SSRFValidationError):
|
||||||
|
validate_url("http://192.168.1.1/internal")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_block_metadata() -> None:
|
||||||
|
"""AWS/GCP metadata endpoints must be blocked."""
|
||||||
|
with pytest.raises(SSRFValidationError):
|
||||||
|
validate_url("http://169.254.169.254/latest/meta-data/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_block_file_scheme() -> None:
|
||||||
|
"""file:// scheme must be blocked."""
|
||||||
|
with pytest.raises(InvalidURLError):
|
||||||
|
validate_url("file:///etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_block_ftp_scheme() -> None:
|
||||||
|
"""ftp:// scheme must be blocked."""
|
||||||
|
with pytest.raises(InvalidURLError):
|
||||||
|
validate_url("ftp://ftp.example.com/file")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_safety_https_public() -> None:
|
||||||
|
"""Public HTTPS URLs should pass validation."""
|
||||||
|
validate_url("https://example.com/page")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_safety_http_public() -> None:
|
||||||
|
"""Public HTTP URLs should pass validation."""
|
||||||
|
validate_url("http://example.com/page")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# robots.txt Policy (structural test)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_url_empty_raises() -> None:
|
||||||
|
"""Empty string should raise."""
|
||||||
|
with pytest.raises(InvalidURLError):
|
||||||
|
validate_url("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_url_none_raises() -> None:
|
||||||
|
"""None should raise."""
|
||||||
|
with pytest.raises(InvalidURLError):
|
||||||
|
validate_url("") # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_url_no_hostname() -> None:
|
||||||
|
"""URL without hostname should raise."""
|
||||||
|
with pytest.raises(InvalidURLError):
|
||||||
|
validate_url("https:///path")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_url_data_scheme() -> None:
|
||||||
|
"""data:// scheme must be blocked."""
|
||||||
|
with pytest.raises(InvalidURLError):
|
||||||
|
validate_url("data:text/html,<h1>hello</h1>")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Content Extraction (HTML -> Text)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_main_content_basic() -> None:
|
||||||
|
"""Basic HTML extraction must return readable text."""
|
||||||
|
html = "<html><body><p>Hello world</p></body></html>"
|
||||||
|
text = extract_main_content(html)
|
||||||
|
assert "Hello world" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_main_content_strips_nav() -> None:
|
||||||
|
"""Navigation elements should be minimised."""
|
||||||
|
html = (
|
||||||
|
"<html><body>"
|
||||||
|
"<nav><a href='/a'>nav</a></nav>"
|
||||||
|
"<main><article><p>Main content here</p></article></main>"
|
||||||
|
"<footer>Footer</footer>"
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
text = extract_main_content(html)
|
||||||
|
assert "Main content here" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_main_content_empty() -> None:
|
||||||
|
"""Empty input must return empty string."""
|
||||||
|
assert extract_main_content("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_main_content_plain_text() -> None:
|
||||||
|
"""Plain text (no HTML) should pass through."""
|
||||||
|
text = extract_main_content("Just plain text\nNo HTML here", url="plain://test")
|
||||||
|
assert "plain text" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_main_content_script_removed() -> None:
|
||||||
|
"""Script tags should be stripped."""
|
||||||
|
html = "<html><body><script>evil()</script><p>Clean</p></body></html>"
|
||||||
|
text = extract_main_content(html)
|
||||||
|
assert "evil" not in text.lower() or "clean" in text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# URL Dedup (structural check — manager does this)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_dedup_no_duplicate() -> None:
|
||||||
|
"""URL deduplication should not produce duplicate entries."""
|
||||||
|
urls = ["https://a.com", "https://b.com", "https://a.com"]
|
||||||
|
seen: set[str] = []
|
||||||
|
for u in urls:
|
||||||
|
if u not in seen:
|
||||||
|
seen.append(u)
|
||||||
|
assert len(seen) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error Handling (invalid URLs, timeouts)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetcher_blocked_url() -> None:
|
||||||
|
"""Fetcher must return BLOCKED for SSRF-blocked URLs."""
|
||||||
|
fetcher = AsyncFetcher()
|
||||||
|
result = await fetcher.fetch("http://localhost/test")
|
||||||
|
assert result.status == FetchStatus.BLOCKED
|
||||||
|
assert result.error is not None
|
||||||
|
await fetcher.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetcher_invalid_url() -> None:
|
||||||
|
"""Fetcher must return BLOCKED for invalid URLs."""
|
||||||
|
fetcher = AsyncFetcher()
|
||||||
|
result = await fetcher.fetch("")
|
||||||
|
assert result.status == FetchStatus.BLOCKED
|
||||||
|
await fetcher.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetcher_unreachable_domain() -> None:
|
||||||
|
"""Fetcher handles DNS failure gracefully."""
|
||||||
|
fetcher = AsyncFetcher()
|
||||||
|
result = await fetcher.fetch("http://this-domain-definitely-does-not-exist-12345.com/")
|
||||||
|
assert result.status in (FetchStatus.ERROR, FetchStatus.TIMEOUT)
|
||||||
|
assert result.error is not None
|
||||||
|
await fetcher.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NormalizedDocument has all required fields
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalized_document_required_fields() -> None:
|
||||||
|
"""NormalizedDocument must have all required fields."""
|
||||||
|
doc = NormalizedDocument.from_text(
|
||||||
|
url="https://example.com",
|
||||||
|
text="Hello world",
|
||||||
|
title="Test",
|
||||||
|
extraction_tool="test_tool",
|
||||||
|
)
|
||||||
|
assert doc.url == "https://example.com"
|
||||||
|
assert doc.title == "Test"
|
||||||
|
assert doc.text == "Hello world"
|
||||||
|
assert doc.links == []
|
||||||
|
assert doc.content_hash != ""
|
||||||
|
assert doc.extraction_tool == "test_tool"
|
||||||
|
assert doc.word_count == 2
|
||||||
|
assert isinstance(doc.extracted_at, type(doc.extracted_at))
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalized_document_is_valid() -> None:
|
||||||
|
"""is_valid must be True for valid docs."""
|
||||||
|
doc = NormalizedDocument.from_text(url="https://e.com", text="content")
|
||||||
|
assert doc.is_valid is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalized_document_is_valid_false() -> None:
|
||||||
|
"""is_valid must be False when text is empty."""
|
||||||
|
doc = NormalizedDocument.from_text(url="https://e.com", text="")
|
||||||
|
assert doc.is_valid is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Content Hash (deterministic)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_hash_deterministic() -> None:
|
||||||
|
"""Same text must produce same hash."""
|
||||||
|
text = "Hello world, deterministic content."
|
||||||
|
h1 = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
h2 = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
assert h1 == h2
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_hash_differs_for_different_content() -> None:
|
||||||
|
"""Different text must produce different hashes."""
|
||||||
|
t1 = "Hello world"
|
||||||
|
t2 = "Goodbye world"
|
||||||
|
h1 = hashlib.sha256(t1.encode("utf-8")).hexdigest()
|
||||||
|
h2 = hashlib.sha256(t2.encode("utf-8")).hexdigest()
|
||||||
|
assert h1 != h2
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalized_document_hash_matches() -> None:
|
||||||
|
"""NormalizedDocument.from_text must compute correct hash."""
|
||||||
|
doc = NormalizedDocument.from_text(
|
||||||
|
url="https://example.com",
|
||||||
|
text="test content for hash",
|
||||||
|
)
|
||||||
|
expected = hashlib.sha256(b"test content for hash").hexdigest()
|
||||||
|
assert doc.content_hash == expected
|
||||||
Reference in New Issue
Block a user