Files
ragflow/common/data_source/sitemap_connector.py
T
VincentLambert 371cfabd95 feat(connector): add Sitemap data source for sitemap.xml-based web ingestion (#19344)
Add Sitemap data source — ingests web pages from sitemap.xml with incremental sync. Supports HTML→Markdown extraction, PDF discovery, URL filtering, SSRF protection.
2026-09-08 16:50:44 +08:00

616 lines
25 KiB
Python

import asyncio
import hashlib
import logging
import queue
import re
import threading
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urljoin, urlparse
from xml.etree import ElementTree as ET
import regex
import requests
from common.data_source.config import (
INDEX_BATCH_SIZE,
REQUEST_TIMEOUT_SECONDS,
DocumentSource,
)
from common.data_source.html_utils import web_html_to_markdown
from common.data_source.interfaces import LoadConnector, PollConnector, SlimConnectorWithPermSync
from common.data_source.models import (
Document,
GenerateDocumentsOutput,
GenerateSlimDocumentOutput,
SecondsSinceUnixEpoch,
SlimDocument,
)
from common.ssrf_guard import assert_url_is_safe
from common.ssrf_guard import pin_dns as _pin_dns
_SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9"
_MAX_REDIRECTS = 10
_MAX_SITEMAP_DEPTH = 5
_MAX_SITEMAP_FETCHES = 1000
_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
_READ_CHUNK_BYTES = 64 * 1024
_MAX_URL_FILTER_LENGTH = 512
_MAX_MATCHED_URL_LENGTH = 4096
_URL_FILTER_TIMEOUT_SECONDS = 1.0
# A quantified group that itself contains a quantifier, e.g. "(a+)+" or "(a*b)*":
# the classic shape behind catastrophic backtracking in Python's regex engine.
_NESTED_QUANTIFIER_RE = re.compile(r"\([^()]*[+*}][^()]*\)\s*[+*{?]")
logger = logging.getLogger(__name__)
_QUEUE_END = object()
_QUEUE_POLL_SECONDS = 0.5
class _WorkerThreadIterator:
"""Iterator over items produced by ``source`` in a daemon worker thread.
See :func:`iter_in_worker_thread`. ``offload_next`` tells the sync worker that
``next()`` may block and should be awaited through ``asyncio.to_thread``.
"""
offload_next = True
def __init__(self, source: Iterator[Any], maxsize: int, on_close: Callable[[], None] | None):
self._source = source
self._queue: queue.Queue = queue.Queue(maxsize=maxsize)
self._stop = threading.Event()
self._closed = False
self._on_close = on_close
self._worker = threading.Thread(target=self._produce, name="sitemap-batch-producer", daemon=True)
self._worker.start()
def _put(self, item: Any) -> bool:
"""Enqueue ``item`` unless the consumer went away; never blocks forever."""
while not self._stop.is_set():
try:
self._queue.put(item, timeout=_QUEUE_POLL_SECONDS)
return True
except queue.Full:
continue
return False
def _produce(self) -> None:
try:
for item in self._source:
if not self._put(item):
return
self._put(_QUEUE_END)
except BaseException as exc: # noqa: BLE001 - forwarded to the consumer
self._put(exc)
finally:
close = getattr(self._source, "close", None)
if close is not None:
try:
close()
except Exception:
logger.debug("worker-thread generator close() failed", exc_info=True)
def __iter__(self) -> "_WorkerThreadIterator":
return self
def __next__(self) -> Any:
while True:
if self._closed:
raise StopIteration
try:
item = self._queue.get(timeout=_QUEUE_POLL_SECONDS)
except queue.Empty:
continue
if item is _QUEUE_END:
self.close()
raise StopIteration
if isinstance(item, BaseException):
self.close()
raise item
return item
def close(self) -> None:
"""Stop the producer; also invoked when the consumer abandons iteration."""
if self._closed:
return
self._closed = True
self._stop.set()
if self._on_close is not None:
try:
self._on_close()
except Exception:
logger.debug("worker-thread on_close callback failed", exc_info=True)
def join(self, timeout: float | None = None) -> None:
self._worker.join(timeout)
@property
def producer_alive(self) -> bool:
return self._worker.is_alive()
async def validate_connector_in_thread(connector: "SitemapConnector") -> None:
"""Run ``validate_connector_settings`` off the event loop and propagate cancellation.
The sync worker wraps the whole task in ``asyncio.wait_for``. A thread started by
``asyncio.to_thread`` cannot be interrupted, so when the awaiting coroutine is
cancelled (task timeout) the connector is told to stop at its next cancellation
check instead of finishing its fetches in the background.
"""
try:
await asyncio.to_thread(connector.validate_connector_settings)
except asyncio.CancelledError:
logger.warning("Sitemap validation cancelled (task timeout) for %s; stopping the connector", connector.sitemap_url)
connector.cancel()
raise
def iter_in_worker_thread(source: Iterator[Any], maxsize: int = 2, on_close: Callable[[], None] | None = None) -> _WorkerThreadIterator:
"""Consume ``source`` in a daemon worker thread and hand its items over through a bounded queue.
The sync worker iterates connector generators on the asyncio thread. Running the
generator (and therefore all its blocking HTTP calls) in a worker thread keeps that
blocking off the event-loop thread and lets the next batch be downloaded while the
previous one is indexed. Exceptions raised by the source are re-raised in the
consumer. Closing the consumer stops the producer: queue writes are abandoned,
and ``on_close`` (typically the connector's ``cancel``) is invoked so that an
in-progress traversal gives up at its next check instead of finishing its fetches.
"""
return _WorkerThreadIterator(source, maxsize, on_close)
class SitemapConnector(LoadConnector, PollConnector, SlimConnectorWithPermSync):
"""Connector that ingests web pages listed in a sitemap.xml.
Supports:
- Standard urlset sitemaps
- Sitemap index files (recursive, up to _MAX_SITEMAP_DEPTH levels)
- Incremental polling via <lastmod>
"""
def __init__(
self,
sitemap_url: str,
batch_size: int = 10,
user_agent: str = "RAGFlow-SitemapConnector/1.0",
url_filter: str | None = None,
follow_pdf_links: bool = False,
restrict_pdf_to_domain: bool = True,
) -> None:
self.sitemap_url = sitemap_url.strip()
self.batch_size = batch_size
self.user_agent = user_agent
self._url_filter: regex.Pattern | None = self._compile_url_filter(url_filter) if url_filter else None
# Dedicated session: no ambient proxies / .netrc credentials from the worker
# environment, so every request goes straight to the DNS-pinned address.
self._session = requests.Session()
self._session.trust_env = False
self._cancel = threading.Event()
self.follow_pdf_links = follow_pdf_links
self.restrict_pdf_to_domain = restrict_pdf_to_domain
self.credentials: dict[str, Any] = {}
@classmethod
def build_connector(cls, config: dict[str, Any]) -> "SitemapConnector":
"""Build a connector from the connector config stored by the UI/API."""
connector = cls(
sitemap_url=config["sitemap_url"],
batch_size=int(config.get("batch_size") or INDEX_BATCH_SIZE),
user_agent=(config.get("user_agent") or "").strip() or "RAGFlow-SitemapConnector/1.0",
url_filter=(config.get("url_filter") or "").strip() or None,
follow_pdf_links=bool(config.get("follow_pdf_links", False)),
restrict_pdf_to_domain=bool(config.get("restrict_pdf_to_domain", True)),
)
connector.load_credentials(config.get("credentials") or {})
return connector
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
self.credentials = credentials or {}
return None
def cancel(self) -> None:
"""Ask an in-progress traversal to stop at its next check (next URL or next body chunk)."""
self._cancel.set()
@property
def cancelled(self) -> bool:
return self._cancel.is_set()
@staticmethod
def _compile_url_filter(pattern: str) -> regex.Pattern:
"""Compile the user-provided url_filter with bounded evaluation.
The pattern is matched against sitemap-controlled URLs. It is compiled with the
``regex`` module (already a transitive dependency through tiktoken / nltk) so
that every match runs under a timeout, which is the only robust defence against
catastrophic backtracking (``(a|aa)+`` and friends cannot be detected syntactically).
The obvious nested-quantifier shapes are still refused up front to give users an
immediate error, and the pattern length is bounded.
"""
if len(pattern) > _MAX_URL_FILTER_LENGTH:
raise ValueError(f"url_filter is too long (max {_MAX_URL_FILTER_LENGTH} characters)")
if _NESTED_QUANTIFIER_RE.search(pattern):
raise ValueError("url_filter must not contain nested quantifiers such as '(a+)+', which can cause catastrophic backtracking")
try:
return regex.compile(pattern)
except regex.error as exc:
raise ValueError(f"url_filter is not a valid regex: {exc}") from exc
def validate_connector_settings(self) -> None:
self._validate_url(self.sitemap_url)
if self.batch_size < 1:
raise ValueError("batch_size must be greater than 0")
if not any(self._url_matches(u[0]) for u in self._iter_sitemap_urls(self.sitemap_url, depth=0)):
raise ValueError("Sitemap contains no URLs matching the filter")
def load_from_state(self) -> GenerateDocumentsOutput:
yield from self._load_urls()
def poll_source(self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch) -> GenerateDocumentsOutput:
yield from self._load_urls(start=start, end=end)
def retrieve_all_slim_docs_perm_sync(self, callback: Any = None) -> GenerateSlimDocumentOutput:
"""Yield the complete set of document IDs the connector currently exposes.
When ``follow_pdf_links`` is enabled the HTML pages are fetched so that
the PDFs discovered in them are part of the retained set; otherwise a
prune run would delete them right after they were indexed.
"""
del callback
seen: set[str] = set()
sitemap_domain = urlparse(self.sitemap_url).netloc
batch: list[SlimDocument] = []
for url, _lastmod in self._iter_sitemap_urls(self.sitemap_url, depth=0):
if url in seen or not self._url_matches(url):
continue
seen.add(url)
batch.append(SlimDocument(id=self._build_document_id(url)))
if len(batch) >= self.batch_size:
yield batch
batch = []
if not self.follow_pdf_links:
continue
try:
raw, content_type = self._fetch_raw(url)
except (requests.RequestException, ValueError, OSError) as exc:
logger.warning("Failed to fetch page %s for PDF discovery: %s", url, exc)
continue
if "application/pdf" in content_type:
continue
for pdf_url in self._extract_pdf_links(raw, url):
if pdf_url in seen:
continue
if self.restrict_pdf_to_domain and urlparse(pdf_url).netloc != sitemap_domain:
continue
seen.add(pdf_url)
batch.append(SlimDocument(id=self._build_document_id(pdf_url)))
if len(batch) >= self.batch_size:
yield batch
batch = []
if batch:
yield batch
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _load_urls(
self,
start: SecondsSinceUnixEpoch | None = None,
end: SecondsSinceUnixEpoch | None = None,
) -> GenerateDocumentsOutput:
seen: set[str] = set()
pending_pdfs: list[tuple[str, str]] = [] # (pdf_url, parent_url)
sitemap_domain = urlparse(self.sitemap_url).netloc
# --- Pass 1: sitemap URLs ---
batch: list[Document] = []
for url, lastmod in self._iter_sitemap_urls(self.sitemap_url, depth=0):
if self._cancel.is_set():
logger.info("Sitemap traversal cancelled")
return
if url in seen or not self._url_matches(url):
continue
seen.add(url)
if start is not None or end is not None:
if lastmod is None:
if start is not None:
continue
else:
ts = lastmod.timestamp()
if start is not None and ts <= start:
continue
if end is not None and ts > end:
continue
doc = self._fetch_and_build_document(url, lastmod, seen, pending_pdfs, sitemap_domain)
if doc is None:
continue
batch.append(doc)
if len(batch) >= self.batch_size:
yield batch
batch = []
if batch:
yield batch
# --- Pass 2: PDF links discovered from HTML pages ---
if not self.follow_pdf_links or not pending_pdfs:
return
batch = []
for pdf_url, parent_url in pending_pdfs:
if self._cancel.is_set():
logger.info("Sitemap traversal cancelled during PDF pass")
return
doc = self._fetch_and_build_document(pdf_url, None, seen, [], sitemap_domain, parent_url)
if doc is None:
continue
batch.append(doc)
if len(batch) >= self.batch_size:
yield batch
batch = []
if batch:
yield batch
def _iter_sitemap_urls(self, sitemap_url: str, depth: int, _walk: dict[str, Any] | None = None) -> Iterator[tuple[str, datetime | None]]:
"""Recursively yield (url, lastmod) pairs from a sitemap or sitemap index.
Each sitemap URL is fetched at most once (an index referencing itself or an
ancestor is skipped) and the walk stops after ``_MAX_SITEMAP_FETCHES`` sitemap
documents, so one connector cannot trigger unbounded egress.
"""
if _walk is None:
_walk = {"visited": set(), "fetched": 0}
if depth > _MAX_SITEMAP_DEPTH:
logger.warning("Max sitemap depth reached, stopping at %s", sitemap_url)
return
if sitemap_url in _walk["visited"]:
logger.warning("Sitemap %s already visited, skipping", sitemap_url)
return
_walk["visited"].add(sitemap_url)
if _walk["fetched"] >= _MAX_SITEMAP_FETCHES:
logger.warning("Maximum number of sitemap fetches (%s) reached, stopping at %s", _MAX_SITEMAP_FETCHES, sitemap_url)
return
_walk["fetched"] += 1
if self._cancel.is_set():
return
try:
self._validate_url(sitemap_url)
content, _ = self._fetch_raw(sitemap_url)
except (requests.RequestException, ValueError, OSError) as exc:
logger.warning("Failed to fetch sitemap %s: %s", sitemap_url, exc)
return
try:
root = ET.fromstring(content)
except ET.ParseError as exc:
raise ValueError(f"Failed to parse sitemap XML from {sitemap_url!r}: {exc}") from exc
tag = root.tag.lower()
# Sitemap index → recurse into child sitemaps
if "sitemapindex" in tag:
for sitemap_el in root.iter(f"{{{_SITEMAP_NS}}}sitemap"):
loc_el = sitemap_el.find(f"{{{_SITEMAP_NS}}}loc")
if loc_el is None or not (loc_el.text or "").strip():
continue
child_url = loc_el.text.strip()
yield from self._iter_sitemap_urls(child_url, depth + 1, _walk)
return
# Standard urlset
for url_el in root.iter(f"{{{_SITEMAP_NS}}}url"):
loc_el = url_el.find(f"{{{_SITEMAP_NS}}}loc")
if loc_el is None or not (loc_el.text or "").strip():
continue
loc = loc_el.text.strip()
lastmod: datetime | None = None
lastmod_el = url_el.find(f"{{{_SITEMAP_NS}}}lastmod")
if lastmod_el is not None and (lastmod_el.text or "").strip():
lastmod = self._parse_lastmod(lastmod_el.text.strip())
yield loc, lastmod
def _fetch_and_build_document(
self,
url: str,
lastmod: datetime | None,
seen: set[str] | None = None,
pending_pdfs: list[tuple[str, str]] | None = None,
sitemap_domain: str | None = None,
parent_url: str | None = None,
) -> Document | None:
try:
self._validate_url(url)
raw, content_type = self._fetch_raw(url)
except (requests.RequestException, ValueError, OSError) as exc:
logger.warning("Failed to fetch page %s: %s", url, exc)
return None
updated_at = lastmod or datetime.now(UTC)
if "application/pdf" in content_type:
if not raw:
logger.debug("Empty PDF for %s, skipping", url)
return None
blob = raw
extension = ".pdf"
else:
if self.follow_pdf_links and seen is not None and pending_pdfs is not None:
for pdf_url in self._extract_pdf_links(raw, url):
if pdf_url in seen:
continue
if not self.restrict_pdf_to_domain or urlparse(pdf_url).netloc == sitemap_domain:
pending_pdfs.append((pdf_url, url))
seen.add(pdf_url)
try:
text = web_html_to_markdown(raw.decode("utf-8", errors="replace")).cleaned_text
except Exception as exc: # noqa: BLE001 - third-party extractor, any failure just skips the page
logger.warning("Failed to parse page %s: %s", url, exc)
return None
if not text or not text.strip():
logger.debug("Empty content for %s, skipping", url)
return None
blob = text.encode("utf-8")
extension = ".md"
return Document(
id=self._build_document_id(url),
source=DocumentSource.SITEMAP,
semantic_identifier=self._url_to_identifier(url),
extension=extension,
blob=blob,
doc_updated_at=updated_at,
size_bytes=len(blob),
metadata={
"sitemap_url": self.sitemap_url,
"url": url,
**({"parent_url": parent_url} if parent_url else {}),
},
)
@staticmethod
def _extract_pdf_links(html_bytes: bytes, base_url: str) -> list[str]:
"""Extract absolute PDF href links from an HTML page."""
from html.parser import HTMLParser
class _Extractor(HTMLParser):
def __init__(self):
super().__init__()
self.links: list[str] = []
def handle_starttag(self, tag, attrs):
if tag == "a":
href = (dict(attrs).get("href", "") or "").strip()
if href:
self.links.append(href)
ex = _Extractor()
ex.feed(html_bytes.decode("utf-8", errors="replace"))
pdf_links: list[str] = []
for href in ex.links:
absolute = urljoin(base_url, href)
# Match on the path only, so "/manual.pdf?download=1#page=2" counts.
if urlparse(absolute).path.lower().endswith(".pdf"):
pdf_links.append(absolute)
return pdf_links
def _fetch_raw(self, url: str) -> tuple[bytes, str]:
"""Fetch a URL with redirect following, SSRF protection and a response size cap.
Every hop is validated by ``assert_url_is_safe`` and the resolved address is
pinned for the duration of the request (DNS rebinding protection). The body
is streamed and rejected once it exceeds ``_MAX_RESPONSE_BYTES``.
Returns (content, content_type).
"""
current_url = url
current_hostname, current_ip = assert_url_is_safe(current_url)
response: requests.Response | None = None
for _ in range(_MAX_REDIRECTS + 1):
with _pin_dns(current_hostname, current_ip):
response = self._session.get(
current_url,
timeout=REQUEST_TIMEOUT_SECONDS,
allow_redirects=False,
stream=True,
headers={"User-Agent": self.user_agent},
)
if response.status_code not in (301, 302, 303, 307, 308):
response.raise_for_status()
content_type = response.headers.get("Content-Type", "").lower()
return self._read_capped(response, current_url), content_type
response.close()
location = response.headers.get("Location")
if not location:
response.raise_for_status()
raise ValueError(f"Redirect without Location header fetching {url!r}")
redirect_url = urljoin(current_url, location)
current_hostname, current_ip = assert_url_is_safe(redirect_url)
current_url = redirect_url
raise ValueError(f"Exceeded {_MAX_REDIRECTS} redirects fetching {url!r}")
def _read_capped(self, response: requests.Response, url: str) -> bytes:
"""Read a streamed response body, refusing anything larger than _MAX_RESPONSE_BYTES.
Stops early (raising ``ValueError``) when :meth:`cancel` was called.
"""
declared = response.headers.get("Content-Length")
if declared and declared.isdigit() and int(declared) > _MAX_RESPONSE_BYTES:
response.close()
raise ValueError(f"Response for {url!r} exceeds the maximum allowed size of {_MAX_RESPONSE_BYTES} bytes")
chunks: list[bytes] = []
total = 0
try:
for chunk in response.iter_content(chunk_size=_READ_CHUNK_BYTES):
if self._cancel.is_set():
raise ValueError(f"Fetch of {url!r} cancelled")
if not chunk:
continue
total += len(chunk)
if total > _MAX_RESPONSE_BYTES:
raise ValueError(f"Response for {url!r} exceeds the maximum allowed size of {_MAX_RESPONSE_BYTES} bytes")
chunks.append(chunk)
finally:
response.close()
return b"".join(chunks)
@staticmethod
def _validate_url(url: str) -> tuple[str, str]:
if not url:
raise ValueError("URL is required")
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"URL must be a valid http or https URL: {url!r}")
return assert_url_is_safe(url)
def _url_matches(self, url: str) -> bool:
if self._url_filter is None:
return True
try:
return bool(self._url_filter.search(url[:_MAX_MATCHED_URL_LENGTH], timeout=_URL_FILTER_TIMEOUT_SECONDS))
except TimeoutError:
logger.warning("url_filter evaluation timed out after %ss on %s; URL skipped", _URL_FILTER_TIMEOUT_SECONDS, url[:200])
return False
@staticmethod
def _url_to_identifier(url: str) -> str:
parsed = urlparse(url)
identifier = f"{parsed.netloc}{parsed.path}"
if parsed.query:
identifier += f"?{parsed.query}"
if parsed.fragment:
identifier += f"#{parsed.fragment}"
return identifier.strip("/") or url
@staticmethod
def _build_document_id(url: str) -> str:
return f"sitemap:{hashlib.md5(url.encode('utf-8')).hexdigest()}"
@staticmethod
def _parse_lastmod(value: str) -> datetime | None:
for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d"):
try:
dt = datetime.strptime(value, fmt) # noqa: DTZ007 - tz set explicitly below
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(UTC)
except ValueError:
continue
logger.debug("Unrecognised lastmod format: %r", value)
return None