"""Pluggable page sources — where a wiki's pages come from. Everything downstream of "fetch -> HTML" is already source-agnostic: html2md, chunker, embed, indexer, search and server never learn where the HTML originated. The wiki-engine assumption is confined to exactly two operations, which is what this module abstracts: * **enumerate** the content pages, and * **fetch** one page as server-rendered HTML. Two implementations ship: * :class:`MediaWikiSource` — the good case. An open ``api.php`` gives a real page list, server-rendered HTML with templates expanded, categories, revision ids for fidelity diffing, and ``maxlag`` so the server can push back. * :class:`SitemapHtmlSource` — the fallback for wikis with no API at all (custom engines such as Fextralife). Enumerates ``sitemap.xml`` and extracts a content container from each page. Works, but gives up revision ids and ``maxlag``. Use :func:`detect_source` to pick one by probing the host. """ from __future__ import annotations import re import urllib.parse from collections.abc import Callable, Iterator from dataclasses import dataclass, field from typing import Protocol import httpx from bs4 import BeautifulSoup from . import config MEDIAWIKI = "mediawiki" HTML = "html" # Candidate content containers for HTML wikis, most specific first. Fextralife uses # #main-content; MediaWiki mirrors use .mw-parser-output; the rest are conventional. _CONTENT_SELECTORS = ( "#main-content", "#wiki-content-block", ".mw-parser-output", "#content", "main", "article", ) _LOC = re.compile(r"\s*([^<\s]+)\s*", re.IGNORECASE) @dataclass(frozen=True) class PageDoc: """A fetched page ready to be written to the corpus.""" title: str url: str html: str source: str # Revision id, or 0 for sources that have no revision concept. A real revid lets # the fidelity check say "the wiki was edited" rather than "our extractor broke"; # without one, only a content diff is available. revid: int = 0 categories: list[str] = field(default_factory=list) class Source(Protocol): """Where pages come from. Implementations must be polite and resumable.""" name: str def iter_titles(self, *, limit: int | None = None) -> Iterator[str]: """Yield content-page titles.""" ... def fetch_page(self, title: str) -> PageDoc | None: """Fetch one page, or None if it is missing.""" ... class MediaWikiSource: """Pages from a MediaWiki ``api.php``: page list + server-rendered HTML.""" name = MEDIAWIKI def __init__(self, api_get: Callable[..., dict], *, api_url: str | None = None) -> None: self._api_get = api_get self._api_url = api_url or config.API_URL def iter_titles(self, *, limit: int | None = None) -> Iterator[str]: apcontinue: str | None = None seen = 0 while True: params: dict[str, object] = { "action": "query", "list": "allpages", "apnamespace": config.CONTENT_NAMESPACE, "apfilterredir": "nonredirects", "aplimit": "500", } if apcontinue is not None: params["apcontinue"] = apcontinue data = self._api_get(params) for page in data["query"]["allpages"]: yield page["title"] seen += 1 if limit is not None and seen >= limit: return cont = data.get("continue") if not cont: return apcontinue = cont["apcontinue"] def fetch_page(self, title: str) -> PageDoc | None: data = self._api_get( { "action": "parse", "page": title, "prop": "text|categories|revid", "redirects": "1", } ) if "error" in data: return None parse = data["parse"] return PageDoc( title=parse["title"], url=config.page_url(parse["title"]), html=parse["text"], source=self.name, revid=parse["revid"], categories=[c["category"].replace("_", " ") for c in parse.get("categories", [])], ) class SitemapHtmlSource: """Pages from ``sitemap.xml`` + a content container in each page's HTML. For wikis with no API. Two capabilities are genuinely lost and callers should not pretend otherwise: there is no revision id (``revid`` stays 0), and there is no ``maxlag``, so the rate limit is purely self-imposed with no server backpressure. """ name = HTML def __init__( self, get: Callable[[str], httpx.Response], *, sitemap_url: str | None = None, selectors: tuple[str, ...] = _CONTENT_SELECTORS, ) -> None: self._get = get self._sitemap_url = sitemap_url or f"{config.WIKI_HOST}/sitemap.xml" self._selectors = selectors self._urls: dict[str, str] = {} @staticmethod def title_from_url(url: str) -> str: """Last path segment as a human title ("/Common+Longsword" -> "Common Longsword").""" slug = urllib.parse.urlparse(url).path.rstrip("/").rsplit("/", 1)[-1] return urllib.parse.unquote_plus(slug).replace("_", " ").strip() def iter_titles(self, *, limit: int | None = None) -> Iterator[str]: text = self._get(self._sitemap_url).text locs = _LOC.findall(text) # A sitemap index points at further sitemaps rather than pages. if locs and all(loc.endswith((".xml", ".xml.gz")) for loc in locs): nested: list[str] = [] for child in locs: nested.extend(_LOC.findall(self._get(child).text)) locs = nested seen = 0 for url in locs: title = self.title_from_url(url) if not title: continue self._urls.setdefault(title, url) yield title seen += 1 if limit is not None and seen >= limit: return def _extract(self, html: str) -> str | None: soup = BeautifulSoup(html, "html.parser") for selector in self._selectors: node = soup.select_one(selector) if node is not None: return str(node) return None def fetch_page(self, title: str) -> PageDoc | None: url = self._urls.get(title) or f"{config.WIKI_HOST}/{urllib.parse.quote(title)}" response = self._get(url) if response.status_code == 404: return None body = self._extract(response.text) if body is None: return None return PageDoc(title=title, url=url, html=body, source=self.name) def detect_source_kind(get: Callable[[str], httpx.Response], host: str) -> tuple[str, str]: """Probe a host and return (kind, endpoint). Order matters: an open MediaWiki API beats HTML scraping every time, so try both conventional API paths ("/api.php" on Fandom, wiki.gg and paradoxwikis; "/w/api.php" on Wikipedia) before falling back to the sitemap. Raises if neither is available — that is a real answer, not a failure to be papered over. """ for path in ("/api.php", "/w/api.php"): endpoint = f"{host}{path}" try: response = get(f"{endpoint}?action=query&meta=siteinfo&format=json") if response.status_code == 200 and "mediawiki" in ( response.json().get("query", {}).get("general", {}).get("generator", "").lower() ): return MEDIAWIKI, endpoint except (httpx.HTTPError, ValueError, KeyError): continue sitemap = f"{host}/sitemap.xml" response = get(sitemap) if response.status_code == 200 and "" in response.text.lower(): return HTML, sitemap raise RuntimeError( f"{host} exposes neither a MediaWiki API nor a sitemap.xml; " "it cannot be mirrored by this tool." )