#!/usr/bin/env python3 """ scrape.py — Download and process all Necyklopedie articles. Three phases: 1. Enumerate all article titles via MediaWiki API 2. Bulk export via Special:Export (XML dump with full wikitext) 3. Parse wikitext to plaintext and save as JSONL """ import json import os import re import sys import time import urllib.parse import urllib.request import xml.etree.ElementTree as ET API_URL = "https://necyklopedie.org/w/api.php" EXPORT_URL = "https://necyklopedie.org/wiki/Speci%C3%A1ln%C3%AD:Exportovat_str%C3%A1nky" OUTPUT_DIR = "data" OUTPUT_FILE = os.path.join(OUTPUT_DIR, "articles.jsonl") TITLES_CACHE = os.path.join(OUTPUT_DIR, "titles.json") DUMP_FILE = os.path.join(OUTPUT_DIR, "dump.xml") MW_NS = {"mw": "http://www.mediawiki.org/xml/export-0.11/"} UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" def api_get(params, timeout=30, max_bytes=None): """One MediaWiki API call; `max_bytes` caps the reply (ValueError past it).""" params["format"] = "json" url = f"{API_URL}?{urllib.parse.urlencode(params)}" req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=timeout) as resp: payload = resp.read(-1 if max_bytes is None else max_bytes + 1) if max_bytes is not None and len(payload) > max_bytes: raise ValueError(f"API reply too large (> {max_bytes} B)") return json.loads(payload.decode("utf-8")) def fetch_pages(titles, timeout=30, max_bytes=None): """Current text of these pages: {pageid: {pageid, title, text}}. Redirects, missing pages and stubs under 50 characters are left out. Uses the API, 50 titles a call: since 2026-10 necyklopedie.org answers a POST to Special:Export with 405. """ results = {} for i in range(0, len(titles), 50): params = { "action": "query", "prop": "revisions", "rvprop": "content", "rvslots": "main", "formatversion": "2", "titles": "|".join(titles[i:i + 50]), } while True: data = api_get(params, timeout, max_bytes) for page in data.get("query", {}).get("pages", []): if page.get("redirect") or "revisions" not in page: continue # a page held back by a size limit comes after `continue` text = wikitext_to_plaintext(page["revisions"][0]["slots"]["main"]["content"]) if len(text) >= 50: results[page["pageid"]] = { "pageid": page["pageid"], "title": page["title"], "text": text, } if "continue" not in data: break params.update(data["continue"]) return results def get_all_titles(): """Enumerate all article titles in namespace 0.""" if os.path.exists(TITLES_CACHE): with open(TITLES_CACHE, "r", encoding="utf-8") as f: titles = json.load(f) print(f"Nalezeno {len(titles)} titulků v keši. Necyklopedie si pamatuje.", file=sys.stderr) return titles titles = [] params = {"action": "query", "list": "allpages", "apnamespace": "0", "aplimit": "500"} while True: data = api_get(params) for page in data["query"]["allpages"]: titles.append({"pageid": page["pageid"], "title": page["title"]}) print(f"\rProbírám se seznamem článků... {len(titles)}", end="", file=sys.stderr) if "continue" not in data: break params["apcontinue"] = data["continue"]["apcontinue"] time.sleep(0.5) print(f"\nNalezeno {len(titles)} článků. Tolik pravdy na jednom místě!", file=sys.stderr) os.makedirs(OUTPUT_DIR, exist_ok=True) with open(TITLES_CACHE, "w", encoding="utf-8") as f: json.dump(titles, f, ensure_ascii=False) return titles def bulk_export(titles): """Export all articles via Special:Export in one POST request.""" if os.path.exists(DUMP_FILE): print(f"Používám keš ({DUMP_FILE}). Necyklopedie se stahuje jen jednou.", file=sys.stderr) return all_titles = "\n".join(t["title"] for t in titles) print(f"Stahuji {len(titles)} stránek přes Special:Export — tohle chvíli potrvá...", file=sys.stderr) data = urllib.parse.urlencode({ "catname": "", "pages": all_titles, "curonly": "1", "wpDownload": "1", }).encode() req = urllib.request.Request(EXPORT_URL, data=data, headers={"User-Agent": UA}) resp = urllib.request.urlopen(req, timeout=300) content = resp.read() with open(DUMP_FILE, "wb") as f: f.write(content) print(f"Staženo {len(content) / 1e6:.1f} MB čisté moudrosti do {DUMP_FILE}.", file=sys.stderr) def wikitext_to_plaintext(wikitext): """Convert MediaWiki wikitext to clean plaintext.""" text = wikitext # Remove categories, interwiki links text = re.sub(r"\[\[Kategorie:[^\]]*\]\]", "", text) text = re.sub(r"\[\[[a-z]{2,3}:[^\]]*\]\]", "", text) # Remove files/images: [[Soubor:...]] [[File:...]] [[Image:...]] text = re.sub(r"\[\[(?:Soubor|File|Image|Obrázek):[^\]]*\]\]", "", text, flags=re.IGNORECASE) # Remove templates {{...}} — handle nested braces for _ in range(5): # multiple passes for nesting text = re.sub(r"\{\{[^{}]*\}\}", "", text) # Remove HTML tags text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL) text = re.sub(r"", "", text) text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL) text = re.sub(r".*?", "", text, flags=re.DOTALL) text = re.sub(r"<[^>]+>", "", text) # Remove tables {| ... |} text = re.sub(r"\{\|.*?\|\}", "", text, flags=re.DOTALL) # Convert wikilinks [[target|display]] -> display, [[target]] -> target text = re.sub(r"\[\[[^\]]*?\|([^\]]*?)\]\]", r"\1", text) text = re.sub(r"\[\[([^\]]*?)\]\]", r"\1", text) # Remove external links [url text] -> text text = re.sub(r"\[https?://\S+\s+([^\]]+)\]", r"\1", text) text = re.sub(r"\[https?://\S+\]", "", text) # Convert headers === Text === -> Text text = re.sub(r"^(=+)\s*(.+?)\s*\1\s*$", r"\2", text, flags=re.MULTILINE) # Remove bold/italic markers text = re.sub(r"'{2,5}", "", text) # Remove magic words / behavior switches text = re.sub(r"__[A-Z]+__", "", text) # Clean up whitespace text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def _fetch_parsed_html(title): """Fetch rendered HTML for a page via MediaWiki API and extract plaintext. Used as fallback for template-heavy pages where wikitext stripping yields nothing. Also follows redirects to other namespaces (e.g. Necyklopedie:Necyklokrám). """ try: params = urllib.parse.urlencode({ "action": "parse", "page": title, "prop": "text", "format": "json", }) url = f"{API_URL}?{params}" req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=15) as resp: data = json.loads(resp.read().decode("utf-8")) html = data.get("parse", {}).get("text", {}).get("*", "") if not html: return "" # Strip HTML tags, keeping text text = re.sub(r"]*>.*?", "", html, flags=re.DOTALL) text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL) text = re.sub(r"<[^>]+>", " ", text) # Decode HTML entities import html as html_mod text = html_mod.unescape(text) # Clean whitespace text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() except Exception: # noqa: BLE001 - best-effort fallback (network, HTTP, JSON); "" skips the page return "" # Minimum plaintext length from wikitext before trying HTML fallback MIN_WIKITEXT_LEN = 20 # Minimum length for HTML fallback result to be accepted MIN_HTML_LEN = 50 # Max pages to fetch via HTML fallback (rate-limited) MAX_HTML_FALLBACKS = 200 def parse_dump(): """Parse XML dump and extract articles as JSONL. Pages whose wikitext converts to very short plaintext (template-heavy pages) get a second chance via the MediaWiki parse API which renders templates. """ print(f"Parsuju {DUMP_FILE} — převádím wikisyntaxi na čistý text...", file=sys.stderr) tree = ET.parse(DUMP_FILE) root = tree.getroot() total = 0 skipped = 0 html_fallback_candidates = [] with open(OUTPUT_FILE, "w", encoding="utf-8") as f: for page in root.findall(".//mw:page", MW_NS): # Skip redirects — but collect redirect targets for HTML fallback redirect_el = page.find("mw:redirect", MW_NS) if redirect_el is not None: title = page.findtext("mw:title", "", MW_NS) pageid = int(page.findtext("mw:id", "", MW_NS)) target = redirect_el.get("title", "") # If redirect points to a non-main namespace, try HTML fallback if ":" in target and not target.startswith("Kategorie:"): html_fallback_candidates.append((pageid, title)) skipped += 1 continue title = page.findtext("mw:title", "", MW_NS) pageid = int(page.findtext("mw:id", "", MW_NS)) wikitext = page.findtext(".//mw:text", "", MW_NS) plaintext = wikitext_to_plaintext(wikitext) if len(plaintext) < MIN_WIKITEXT_LEN: # Template-heavy page — candidate for HTML fallback html_fallback_candidates.append((pageid, title)) continue record = {"pageid": pageid, "title": title, "text": plaintext} f.write(json.dumps(record, ensure_ascii=False) + "\n") total += 1 print(f"\r[{total + skipped}] Uloženo {total}, přeskočeno {skipped}...", end="", file=sys.stderr) # Phase 2: HTML fallback for template-heavy pages if html_fallback_candidates: candidates = html_fallback_candidates[:MAX_HTML_FALLBACKS] print(f"\nZkouším HTML fallback pro {len(candidates)} šablonových stránek...", file=sys.stderr) fetched = 0 with open(OUTPUT_FILE, "a", encoding="utf-8") as f: for i, (pageid, title) in enumerate(candidates): plaintext = _fetch_parsed_html(title) if len(plaintext) >= MIN_HTML_LEN: record = {"pageid": pageid, "title": title, "text": plaintext} f.write(json.dumps(record, ensure_ascii=False) + "\n") fetched += 1 total += 1 else: skipped += 1 if (i + 1) % 10 == 0: print(f"\r [{i + 1}/{len(candidates)}] Získáno {fetched}...", end="", file=sys.stderr) time.sleep(0.3) # rate limit print(f"\n HTML fallback: {fetched} stránek doplněno.", file=sys.stderr) print(f"Hotovo. {total} článků uloženo do {OUTPUT_FILE}. Pravda je připravena k indexaci.", file=sys.stderr) def scrape(): os.makedirs(OUTPUT_DIR, exist_ok=True) # Phase 1: enumerate titles titles = get_all_titles() # Phase 2: bulk export bulk_export(titles) # Phase 3: parse to plaintext JSONL parse_dump() if __name__ == "__main__": scrape()