mwextract 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+
11
+ # Tooling
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .DS_Store
16
+
17
+ # Local data / outputs
18
+ source_data/
19
+ extracted_data/
20
+ out/
21
+ *.7z
22
+ *.xml
23
+ *.xml.bz2
24
+
25
+ # ...but keep test fixtures
26
+ !tests/fixtures/
27
+ !tests/fixtures/*.xml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aditya Parab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.5
2
+ Name: mwextract
3
+ Version: 0.1.0
4
+ Summary: Convert a MediaWiki XML dump (.7z / .xml / .xml.bz2) into structured Markdown, JSON, or XML.
5
+ Project-URL: Homepage, https://github.com/adityaparab/mwextract
6
+ Project-URL: Repository, https://github.com/adityaparab/mwextract
7
+ Project-URL: Issues, https://github.com/adityaparab/mwextract/issues
8
+ Author-email: Aditya Parab <mradityaparab@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: converter,dump,fandom,markdown,mediawiki,rag,wiki,wikia,wikitext
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Text Processing :: Markup
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: mwparserfromhell>=0.6
25
+ Requires-Dist: mwxml>=0.3.3
26
+ Requires-Dist: py7zr>=0.20
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # mwextract
32
+
33
+ Convert a **MediaWiki XML dump** (as shipped by Fandom/Wikia and other MediaWiki
34
+ sites) into clean, structured **Markdown**, **JSON**, or **XML** — one command,
35
+ no pandoc, no other binaries.
36
+
37
+ It uses only the Wikimedia parsing stack:
38
+
39
+ - [`mwxml`](https://pypi.org/project/mwxml/) streams pages out of the dump
40
+ (memory-safe, namespace-aware).
41
+ - [`mwparserfromhell`](https://pypi.org/project/mwparserfromhell/) parses
42
+ wikitext: infobox/template fields become metadata, and the article body is
43
+ rendered to clean Markdown.
44
+ - [`py7zr`](https://pypi.org/project/py7zr/) extracts the `.7z` archive the dump
45
+ usually comes in.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ uv add mwextract
51
+ # or
52
+ pip install mwextract
53
+ ```
54
+
55
+ ## Get a dump
56
+
57
+ `mwextract` does **not** download anything — you fetch the dump yourself. Fandom
58
+ wikis expose current-page dumps under a URL like:
59
+
60
+ ```
61
+ https://s3.amazonaws.com/wikia_xml_dumps/<x>/<xx>/<wiki>_pages_current.xml.7z
62
+ ```
63
+
64
+ For example, the Game of Thrones wiki:
65
+ `https://s3.amazonaws.com/wikia_xml_dumps/g/ga/gameofthrones_pages_current.xml.7z`
66
+
67
+ Download the `.7z` and point `mwextract` at it.
68
+
69
+ ## Use it from Python
70
+
71
+ ```python
72
+ import mwextract
73
+
74
+ result = mwextract.convert(
75
+ "gameofthrones_pages_current.xml.7z", # .7z, .xml, or .xml.bz2
76
+ "out/", # output directory
77
+ base_url="https://gameofthrones.fandom.com/wiki/", # optional
78
+ fmt="md", # "md" | "json" | "xml"
79
+ layout="combined", # "combined" | "per-file"
80
+ )
81
+
82
+ print(result.pages, "pages ->", result.outputs)
83
+ ```
84
+
85
+ `convert()` extracts the archive to a temporary directory (removed afterwards),
86
+ streams every main-namespace, non-redirect article, and writes the chosen format.
87
+
88
+ ## Use it from the command line
89
+
90
+ ```bash
91
+ mwextract gameofthrones_pages_current.xml.7z out/ \
92
+ --base-url https://gameofthrones.fandom.com/wiki/ \
93
+ --format md \
94
+ --layout combined
95
+ ```
96
+
97
+ ```
98
+ usage: mwextract [-h] [-b BASE_URL] [-f {md,json,xml}]
99
+ [-l {combined,per-file}] [-q] [-V]
100
+ dump output_dir
101
+ ```
102
+
103
+ | Option | Default | Meaning |
104
+ | --- | --- | --- |
105
+ | `dump` | — | Path to the dump: `.7z`, `.xml`, or `.xml.bz2`. |
106
+ | `output_dir` | — | Directory to write into (created if missing). |
107
+ | `-b`, `--base-url` | *(none)* | Wiki URL prefix; when set, each page gets a `url`. |
108
+ | `-f`, `--format` | `md` | `md`, `json`, or `xml`. |
109
+ | `-l`, `--layout` | `combined` | `combined` (one file) or `per-file` (one per article). |
110
+ | `-q`, `--quiet` | off | Only warnings/errors. |
111
+
112
+ ## Output
113
+
114
+ ### Layouts
115
+
116
+ - **`combined`** (default) — a single file for the whole corpus:
117
+ `corpus.md`, `corpus.json` (a JSON array), or `corpus.xml`
118
+ (`<pages><page>…</page></pages>`).
119
+ - **`per-file`** — one file per article, named after the title:
120
+ `Daenerys_Targaryen.md`, `.json`, or `.xml`.
121
+
122
+ Both layouts are written incrementally as pages stream in, so memory stays flat
123
+ no matter how large the dump is.
124
+
125
+ ### Formats
126
+
127
+ **Markdown** — YAML front-matter (title, url, infobox fields) + an optional
128
+ summary line + the rendered body:
129
+
130
+ ```markdown
131
+ ---
132
+ title: "Jon Snow"
133
+ url: https://gameofthrones.fandom.com/wiki/Jon_Snow
134
+ house: "Stark"
135
+ allegiance: "Night's Watch"
136
+ ---
137
+
138
+ # Jon Snow
139
+
140
+ > **House:** Stark · **Allegiance:** Night's Watch
141
+
142
+ Jon Snow is the illegitimate son of ...
143
+ ```
144
+
145
+ **JSON** — the same content, structured:
146
+
147
+ ```json
148
+ {
149
+ "title": "Jon Snow",
150
+ "url": "https://gameofthrones.fandom.com/wiki/Jon_Snow",
151
+ "metadata": { "house": "Stark", "allegiance": "Night's Watch" },
152
+ "body": "Jon Snow is the illegitimate son of ..."
153
+ }
154
+ ```
155
+
156
+ **XML** — the same content as elements:
157
+
158
+ ```xml
159
+ <page>
160
+ <title>Jon Snow</title>
161
+ <url>https://gameofthrones.fandom.com/wiki/Jon_Snow</url>
162
+ <metadata>
163
+ <field name="house">Stark</field>
164
+ <field name="allegiance">Night's Watch</field>
165
+ </metadata>
166
+ <body>Jon Snow is the illegitimate son of ...</body>
167
+ </page>
168
+ ```
169
+
170
+ `body` is the rendered Markdown body only — the structured `metadata`/`url`
171
+ carry everything the Markdown front-matter would, so nothing is duplicated.
172
+
173
+ ## What gets kept / dropped
174
+
175
+ - **Kept:** main-namespace articles, section hierarchy, bold/italic, list items,
176
+ wikilink and external-link visible text, infobox fields (as metadata).
177
+ - **Dropped:** redirects and non-article namespaces, file/image/category/media
178
+ links, citation footnotes (`<ref>`), templates (after their infobox fields are
179
+ extracted), and comments.
180
+
181
+ ## License
182
+
183
+ MIT © Aditya Parab
@@ -0,0 +1,153 @@
1
+ # mwextract
2
+
3
+ Convert a **MediaWiki XML dump** (as shipped by Fandom/Wikia and other MediaWiki
4
+ sites) into clean, structured **Markdown**, **JSON**, or **XML** — one command,
5
+ no pandoc, no other binaries.
6
+
7
+ It uses only the Wikimedia parsing stack:
8
+
9
+ - [`mwxml`](https://pypi.org/project/mwxml/) streams pages out of the dump
10
+ (memory-safe, namespace-aware).
11
+ - [`mwparserfromhell`](https://pypi.org/project/mwparserfromhell/) parses
12
+ wikitext: infobox/template fields become metadata, and the article body is
13
+ rendered to clean Markdown.
14
+ - [`py7zr`](https://pypi.org/project/py7zr/) extracts the `.7z` archive the dump
15
+ usually comes in.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ uv add mwextract
21
+ # or
22
+ pip install mwextract
23
+ ```
24
+
25
+ ## Get a dump
26
+
27
+ `mwextract` does **not** download anything — you fetch the dump yourself. Fandom
28
+ wikis expose current-page dumps under a URL like:
29
+
30
+ ```
31
+ https://s3.amazonaws.com/wikia_xml_dumps/<x>/<xx>/<wiki>_pages_current.xml.7z
32
+ ```
33
+
34
+ For example, the Game of Thrones wiki:
35
+ `https://s3.amazonaws.com/wikia_xml_dumps/g/ga/gameofthrones_pages_current.xml.7z`
36
+
37
+ Download the `.7z` and point `mwextract` at it.
38
+
39
+ ## Use it from Python
40
+
41
+ ```python
42
+ import mwextract
43
+
44
+ result = mwextract.convert(
45
+ "gameofthrones_pages_current.xml.7z", # .7z, .xml, or .xml.bz2
46
+ "out/", # output directory
47
+ base_url="https://gameofthrones.fandom.com/wiki/", # optional
48
+ fmt="md", # "md" | "json" | "xml"
49
+ layout="combined", # "combined" | "per-file"
50
+ )
51
+
52
+ print(result.pages, "pages ->", result.outputs)
53
+ ```
54
+
55
+ `convert()` extracts the archive to a temporary directory (removed afterwards),
56
+ streams every main-namespace, non-redirect article, and writes the chosen format.
57
+
58
+ ## Use it from the command line
59
+
60
+ ```bash
61
+ mwextract gameofthrones_pages_current.xml.7z out/ \
62
+ --base-url https://gameofthrones.fandom.com/wiki/ \
63
+ --format md \
64
+ --layout combined
65
+ ```
66
+
67
+ ```
68
+ usage: mwextract [-h] [-b BASE_URL] [-f {md,json,xml}]
69
+ [-l {combined,per-file}] [-q] [-V]
70
+ dump output_dir
71
+ ```
72
+
73
+ | Option | Default | Meaning |
74
+ | --- | --- | --- |
75
+ | `dump` | — | Path to the dump: `.7z`, `.xml`, or `.xml.bz2`. |
76
+ | `output_dir` | — | Directory to write into (created if missing). |
77
+ | `-b`, `--base-url` | *(none)* | Wiki URL prefix; when set, each page gets a `url`. |
78
+ | `-f`, `--format` | `md` | `md`, `json`, or `xml`. |
79
+ | `-l`, `--layout` | `combined` | `combined` (one file) or `per-file` (one per article). |
80
+ | `-q`, `--quiet` | off | Only warnings/errors. |
81
+
82
+ ## Output
83
+
84
+ ### Layouts
85
+
86
+ - **`combined`** (default) — a single file for the whole corpus:
87
+ `corpus.md`, `corpus.json` (a JSON array), or `corpus.xml`
88
+ (`<pages><page>…</page></pages>`).
89
+ - **`per-file`** — one file per article, named after the title:
90
+ `Daenerys_Targaryen.md`, `.json`, or `.xml`.
91
+
92
+ Both layouts are written incrementally as pages stream in, so memory stays flat
93
+ no matter how large the dump is.
94
+
95
+ ### Formats
96
+
97
+ **Markdown** — YAML front-matter (title, url, infobox fields) + an optional
98
+ summary line + the rendered body:
99
+
100
+ ```markdown
101
+ ---
102
+ title: "Jon Snow"
103
+ url: https://gameofthrones.fandom.com/wiki/Jon_Snow
104
+ house: "Stark"
105
+ allegiance: "Night's Watch"
106
+ ---
107
+
108
+ # Jon Snow
109
+
110
+ > **House:** Stark · **Allegiance:** Night's Watch
111
+
112
+ Jon Snow is the illegitimate son of ...
113
+ ```
114
+
115
+ **JSON** — the same content, structured:
116
+
117
+ ```json
118
+ {
119
+ "title": "Jon Snow",
120
+ "url": "https://gameofthrones.fandom.com/wiki/Jon_Snow",
121
+ "metadata": { "house": "Stark", "allegiance": "Night's Watch" },
122
+ "body": "Jon Snow is the illegitimate son of ..."
123
+ }
124
+ ```
125
+
126
+ **XML** — the same content as elements:
127
+
128
+ ```xml
129
+ <page>
130
+ <title>Jon Snow</title>
131
+ <url>https://gameofthrones.fandom.com/wiki/Jon_Snow</url>
132
+ <metadata>
133
+ <field name="house">Stark</field>
134
+ <field name="allegiance">Night's Watch</field>
135
+ </metadata>
136
+ <body>Jon Snow is the illegitimate son of ...</body>
137
+ </page>
138
+ ```
139
+
140
+ `body` is the rendered Markdown body only — the structured `metadata`/`url`
141
+ carry everything the Markdown front-matter would, so nothing is duplicated.
142
+
143
+ ## What gets kept / dropped
144
+
145
+ - **Kept:** main-namespace articles, section hierarchy, bold/italic, list items,
146
+ wikilink and external-link visible text, infobox fields (as metadata).
147
+ - **Dropped:** redirects and non-article namespaces, file/image/category/media
148
+ links, citation footnotes (`<ref>`), templates (after their infobox fields are
149
+ extracted), and comments.
150
+
151
+ ## License
152
+
153
+ MIT © Aditya Parab
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mwextract"
7
+ version = "0.1.0"
8
+ description = "Convert a MediaWiki XML dump (.7z / .xml / .xml.bz2) into structured Markdown, JSON, or XML."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Aditya Parab", email = "mradityaparab@gmail.com" }]
13
+ keywords = [
14
+ "mediawiki",
15
+ "wiki",
16
+ "fandom",
17
+ "wikia",
18
+ "dump",
19
+ "wikitext",
20
+ "markdown",
21
+ "converter",
22
+ "rag",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Environment :: Console",
27
+ "Intended Audience :: Developers",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Programming Language :: Python :: 3.13",
34
+ "Topic :: Text Processing :: Markup",
35
+ "Topic :: Utilities",
36
+ ]
37
+ dependencies = [
38
+ "py7zr>=0.20",
39
+ "mwxml>=0.3.3",
40
+ "mwparserfromhell>=0.6",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/adityaparab/mwextract"
45
+ Repository = "https://github.com/adityaparab/mwextract"
46
+ Issues = "https://github.com/adityaparab/mwextract/issues"
47
+
48
+ [project.scripts]
49
+ mwextract = "mwextract.cli:main"
50
+
51
+ [project.optional-dependencies]
52
+ dev = ["pytest>=7"]
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/mwextract"]
56
+
57
+ [tool.hatch.build.targets.sdist]
58
+ include = ["src/mwextract", "README.md", "LICENSE"]
@@ -0,0 +1,111 @@
1
+ """mwextract -- convert a MediaWiki XML dump into Markdown, JSON, or XML.
2
+
3
+ Typical use::
4
+
5
+ import mwextract
6
+
7
+ result = mwextract.convert(
8
+ "gameofthrones_pages_current.xml.7z",
9
+ "out/",
10
+ base_url="https://gameofthrones.fandom.com/wiki/",
11
+ fmt="md",
12
+ )
13
+ print(result.pages, "pages written to", result.output_dir)
14
+
15
+ The input may be a ``.7z`` archive (extracted automatically), or an
16
+ already-extracted ``.xml`` / ``.xml.bz2`` dump.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import List, Optional
24
+
25
+ from .archive import resolve_dump
26
+ from .parser import PageDoc, iter_pages, parse_page
27
+ from .writers import write
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "convert",
33
+ "ConvertResult",
34
+ "PageDoc",
35
+ "iter_pages",
36
+ "parse_page",
37
+ "FORMATS",
38
+ "LAYOUTS",
39
+ "__version__",
40
+ ]
41
+
42
+ # Library code shouldn't configure logging; attach a no-op handler so callers
43
+ # who never set up logging don't see "No handlers could be found" noise.
44
+ logging.getLogger("mwextract").addHandler(logging.NullHandler())
45
+
46
+ FORMATS = ("md", "json", "xml")
47
+ LAYOUTS = ("combined", "per-file")
48
+
49
+
50
+ @dataclass
51
+ class ConvertResult:
52
+ """Summary of a completed conversion."""
53
+
54
+ pages: int
55
+ fmt: str
56
+ layout: str
57
+ output_dir: Path
58
+ outputs: List[Path] = field(default_factory=list)
59
+
60
+
61
+ def convert(
62
+ dump_path,
63
+ output_dir,
64
+ *,
65
+ base_url: Optional[str] = None,
66
+ fmt: str = "md",
67
+ layout: str = "combined",
68
+ ) -> ConvertResult:
69
+ """Convert a MediaWiki dump to Markdown, JSON, or XML.
70
+
71
+ Args:
72
+ dump_path: Path to the dump -- a ``.7z`` archive, or an ``.xml`` /
73
+ ``.xml.bz2`` file. ``.7z`` archives are extracted to a temporary
74
+ directory that is removed when conversion finishes.
75
+ output_dir: Directory to write into (created if missing).
76
+ base_url: Optional wiki URL prefix, e.g.
77
+ ``"https://gameofthrones.fandom.com/wiki/"``. When given, each page
78
+ gets a ``url`` built by appending its underscored title. When
79
+ ``None``, no url is emitted.
80
+ fmt: Output format -- one of ``"md"``, ``"json"``, ``"xml"``.
81
+ layout: ``"combined"`` (one ``corpus.<fmt>`` file, the default) or
82
+ ``"per-file"`` (one file per article).
83
+
84
+ Returns:
85
+ A :class:`ConvertResult` with the page count and the path(s) written.
86
+ """
87
+ fmt = fmt.lower()
88
+ if fmt not in FORMATS:
89
+ raise ValueError(f"fmt must be one of {FORMATS}, got {fmt!r}")
90
+ if layout not in LAYOUTS:
91
+ raise ValueError(f"layout must be one of {LAYOUTS}, got {layout!r}")
92
+
93
+ if base_url and not base_url.endswith("/"):
94
+ base_url += "/"
95
+
96
+ output_dir = Path(output_dir)
97
+
98
+ with resolve_dump(dump_path) as dump_file:
99
+ pages = iter_pages(dump_file, base_url=base_url)
100
+ count, outputs = write(pages, output_dir, fmt, layout)
101
+
102
+ logging.getLogger("mwextract").info(
103
+ "Done: %d pages -> %s", count, ", ".join(str(o) for o in outputs)
104
+ )
105
+ return ConvertResult(
106
+ pages=count,
107
+ fmt=fmt,
108
+ layout=layout,
109
+ output_dir=output_dir,
110
+ outputs=outputs,
111
+ )
@@ -0,0 +1,74 @@
1
+ """Resolve a user-supplied dump path into a readable MediaWiki XML dump.
2
+
3
+ The user downloads the dump manually (Fandom/Wikia ship them as ``.7z``). This
4
+ module takes whatever they hand us -- a ``.7z`` archive, or an already-extracted
5
+ ``.xml`` / ``.xml.bz2`` -- and yields a path the parser can stream from,
6
+ cleaning up any temporary extraction afterwards.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import logging
12
+ import shutil
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import Iterator
16
+
17
+ log = logging.getLogger("mwextract")
18
+
19
+ # Suffixes the parser can read directly (see parser.open_dump).
20
+ _READABLE_SUFFIXES = (".xml", ".xml.bz2", ".bz2")
21
+
22
+
23
+ def _find_dump_file(root: Path) -> Path:
24
+ """Locate the dump inside an extracted archive."""
25
+ candidates = [
26
+ p
27
+ for p in root.rglob("*")
28
+ if p.is_file() and p.name.lower().endswith(_READABLE_SUFFIXES)
29
+ ]
30
+ if candidates:
31
+ return max(candidates, key=lambda p: p.stat().st_size)
32
+
33
+ # Fall back to the largest file (some dumps extract without a suffix).
34
+ all_files = [p for p in root.rglob("*") if p.is_file()]
35
+ if not all_files:
36
+ raise FileNotFoundError("archive contained no files")
37
+ return max(all_files, key=lambda p: p.stat().st_size)
38
+
39
+
40
+ @contextlib.contextmanager
41
+ def resolve_dump(dump_path) -> Iterator[Path]:
42
+ """Yield a path to a readable dump file.
43
+
44
+ For a ``.7z`` archive, extract into a temporary directory (removed on exit)
45
+ and yield the dump found inside. For ``.xml`` / ``.xml.bz2`` / ``.bz2``,
46
+ yield the path unchanged.
47
+ """
48
+ dump_path = Path(dump_path)
49
+ if not dump_path.exists():
50
+ raise FileNotFoundError(f"dump not found: {dump_path}")
51
+
52
+ name = dump_path.name.lower()
53
+
54
+ if name.endswith(".7z"):
55
+ import py7zr
56
+
57
+ tmp = Path(tempfile.mkdtemp(prefix="mwextract_"))
58
+ try:
59
+ log.info("Extracting %s ...", dump_path.name)
60
+ with py7zr.SevenZipFile(dump_path, mode="r") as archive:
61
+ archive.extractall(path=tmp)
62
+ found = _find_dump_file(tmp)
63
+ log.info("Extracted dump: %s", found.name)
64
+ yield found
65
+ finally:
66
+ shutil.rmtree(tmp, ignore_errors=True)
67
+
68
+ elif name.endswith(_READABLE_SUFFIXES):
69
+ yield dump_path
70
+
71
+ else:
72
+ raise ValueError(
73
+ f"unsupported input {dump_path.name!r}: expected .7z, .xml, or .xml.bz2"
74
+ )
@@ -0,0 +1,104 @@
1
+ """Command-line interface for mwextract.
2
+
3
+ mwextract DUMP OUTPUT_DIR [-b URL] [-f {md,json,xml}] [-l {combined,per-file}]
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import logging
9
+ import sys
10
+ from typing import Optional, Sequence
11
+
12
+ from . import FORMATS, LAYOUTS, __version__, convert
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="mwextract",
18
+ description=(
19
+ "Convert a MediaWiki XML dump (.7z / .xml / .xml.bz2) into "
20
+ "structured Markdown, JSON, or XML."
21
+ ),
22
+ )
23
+ parser.add_argument(
24
+ "dump",
25
+ help="Path to the dump: a .7z archive, or an .xml / .xml.bz2 file.",
26
+ )
27
+ parser.add_argument(
28
+ "output_dir",
29
+ help="Directory to write output into (created if missing).",
30
+ )
31
+ parser.add_argument(
32
+ "-b",
33
+ "--base-url",
34
+ default=None,
35
+ help=(
36
+ "Optional wiki URL prefix, e.g. "
37
+ "https://gameofthrones.fandom.com/wiki/ . When set, each page gets "
38
+ "a url built from its title."
39
+ ),
40
+ )
41
+ parser.add_argument(
42
+ "-f",
43
+ "--format",
44
+ dest="fmt",
45
+ choices=FORMATS,
46
+ default="md",
47
+ help="Output format (default: md).",
48
+ )
49
+ parser.add_argument(
50
+ "-l",
51
+ "--layout",
52
+ choices=LAYOUTS,
53
+ default="combined",
54
+ help=(
55
+ "combined = one corpus.<fmt> file (default); "
56
+ "per-file = one file per article."
57
+ ),
58
+ )
59
+ parser.add_argument(
60
+ "-q",
61
+ "--quiet",
62
+ action="store_true",
63
+ help="Only report warnings and errors.",
64
+ )
65
+ parser.add_argument(
66
+ "-V",
67
+ "--version",
68
+ action="version",
69
+ version=f"mwextract {__version__}",
70
+ )
71
+ return parser
72
+
73
+
74
+ def main(argv: Optional[Sequence[str]] = None) -> int:
75
+ args = build_parser().parse_args(argv)
76
+
77
+ logging.basicConfig(
78
+ level=logging.WARNING if args.quiet else logging.INFO,
79
+ format="%(message)s",
80
+ stream=sys.stderr,
81
+ )
82
+
83
+ try:
84
+ result = convert(
85
+ args.dump,
86
+ args.output_dir,
87
+ base_url=args.base_url,
88
+ fmt=args.fmt,
89
+ layout=args.layout,
90
+ )
91
+ except (FileNotFoundError, ValueError) as exc:
92
+ print(f"error: {exc}", file=sys.stderr)
93
+ return 1
94
+
95
+ print(
96
+ f"{result.pages} pages -> {', '.join(str(o) for o in result.outputs)} "
97
+ f"({result.fmt}, {result.layout})",
98
+ file=sys.stderr,
99
+ )
100
+ return 0
101
+
102
+
103
+ if __name__ == "__main__":
104
+ raise SystemExit(main())
@@ -0,0 +1,177 @@
1
+ """Parse a MediaWiki XML dump into structured page documents.
2
+
3
+ Uses only the Wikimedia stack:
4
+
5
+ mwxml -> stream <page>/<revision> out of the dump (memory-safe,
6
+ namespace-aware)
7
+ mwparserfromhell -> parse wikitext into a node tree; pull infobox/template
8
+ fields into metadata, render the body to clean Markdown
9
+
10
+ The rendering logic here is a faithful refactor of the original
11
+ ``ingest_mwparser.py`` script, restructured so the writers can serialize the
12
+ same :class:`PageDoc` to Markdown, JSON, or XML.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import bz2
17
+ import logging
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Iterator, Optional
22
+
23
+ import mwparserfromhell as mw
24
+ import mwxml
25
+ from mwparserfromhell.nodes import (
26
+ ExternalLink,
27
+ Heading,
28
+ HTMLEntity,
29
+ Tag,
30
+ Text,
31
+ Wikilink,
32
+ )
33
+
34
+ log = logging.getLogger("mwextract")
35
+
36
+ # Wikilink namespaces we never want as prose.
37
+ SKIP_LINK_PREFIXES = ("file:", "image:", "category:", "media:")
38
+
39
+
40
+ @dataclass
41
+ class PageDoc:
42
+ """One article, parsed into structured pieces.
43
+
44
+ ``body`` is the rendered Markdown body only -- it does *not* include the
45
+ YAML front-matter or the ``# Title`` heading. Each writer serializes these
46
+ fields into its own format.
47
+ """
48
+
49
+ title: str
50
+ body: str
51
+ metadata: dict = field(default_factory=dict)
52
+ url: Optional[str] = None
53
+
54
+
55
+ # --------------------------------------------------------------------------- #
56
+ # 1. Render a sequence of wikitext nodes -> Markdown string #
57
+ # --------------------------------------------------------------------------- #
58
+ def render(nodes) -> str:
59
+ out = []
60
+ for node in nodes:
61
+ if isinstance(node, Text):
62
+ out.append(str(node))
63
+
64
+ elif isinstance(node, Heading):
65
+ title = render(node.title.nodes).strip()
66
+ out.append(f"\n\n{'#' * node.level} {title}\n\n")
67
+
68
+ elif isinstance(node, Wikilink):
69
+ target = str(node.title).strip()
70
+ if target.lower().startswith(SKIP_LINK_PREFIXES):
71
+ continue # drop media/category links entirely
72
+ # keep only the visible text, not the link itself
73
+ out.append(render(node.text.nodes) if node.text else target)
74
+
75
+ elif isinstance(node, ExternalLink):
76
+ # keep only the visible text; drop the URL entirely
77
+ if node.title:
78
+ out.append(render(node.title.nodes))
79
+
80
+ elif isinstance(node, Tag):
81
+ tag = str(node.tag)
82
+ content = render(node.contents.nodes) if node.contents else ""
83
+ if tag == "b":
84
+ out.append(f"**{content}**")
85
+ elif tag == "i":
86
+ out.append(f"*{content}*")
87
+ elif tag == "li":
88
+ marker = "1. " if node.wiki_markup == "#" else "- "
89
+ out.append(marker)
90
+ elif tag in ("dt", "dd"):
91
+ out.append("") # definition lists -> flatten to text
92
+ elif tag in ("ref", "references"):
93
+ continue # drop citation footnotes
94
+ else:
95
+ out.append(content) # br, span, small, etc. -> keep inner text
96
+
97
+ elif isinstance(node, HTMLEntity):
98
+ out.append(node.normalize())
99
+
100
+ # Template, Comment, Argument -> intentionally dropped here
101
+ return "".join(out)
102
+
103
+
104
+ # --------------------------------------------------------------------------- #
105
+ # 2. Tidy whitespace / list spacing in the rendered Markdown #
106
+ # --------------------------------------------------------------------------- #
107
+ def tidy(md: str) -> str:
108
+ md = re.sub(r"[ \t]+\n", "\n", md) # trailing spaces
109
+ md = re.sub(r"^(\s*(?:- |\d+\. ))[ \t]+", r"\1", md, flags=re.M) # marker spacing
110
+ md = re.sub(r"\n{3,}", "\n\n", md) # collapse blank lines
111
+ return md.strip()
112
+
113
+
114
+ # --------------------------------------------------------------------------- #
115
+ # 3. Pull infobox / template fields into a metadata dict #
116
+ # --------------------------------------------------------------------------- #
117
+ def extract_metadata(code) -> dict:
118
+ meta: dict = {}
119
+ for tmpl in code.filter_templates():
120
+ if "infobox" in str(tmpl.name).strip().lower():
121
+ for param in tmpl.params:
122
+ key = str(param.name).strip()
123
+ val = mw.parse(str(param.value)).strip_code().strip()
124
+ val = re.sub(r"\s+", " ", val)
125
+ if key and val and not key.isdigit():
126
+ meta[key] = val
127
+ return meta
128
+
129
+
130
+ def strip_templates(code):
131
+ for tmpl in list(code.filter_templates()):
132
+ try:
133
+ code.remove(tmpl)
134
+ except ValueError:
135
+ pass
136
+ return code
137
+
138
+
139
+ # --------------------------------------------------------------------------- #
140
+ # 4. Parse one page into a structured PageDoc #
141
+ # --------------------------------------------------------------------------- #
142
+ def parse_page(title: str, wikitext: str, base_url: Optional[str] = None) -> PageDoc:
143
+ code = mw.parse(wikitext)
144
+ meta = extract_metadata(code)
145
+ strip_templates(code)
146
+ body = tidy(render(code.nodes))
147
+
148
+ url = base_url + title.replace(" ", "_") if base_url else None
149
+ return PageDoc(title=title, body=body, metadata=meta, url=url)
150
+
151
+
152
+ # --------------------------------------------------------------------------- #
153
+ # 5. Stream the dump -> PageDoc per article #
154
+ # --------------------------------------------------------------------------- #
155
+ def open_dump(path: str):
156
+ """Open an .xml or .xml.bz2/.bz2 dump as a UTF-8 text stream."""
157
+ if path.endswith(".bz2"):
158
+ import io
159
+
160
+ return io.TextIOWrapper(bz2.open(path, "rb"), encoding="utf-8")
161
+ return open(path, encoding="utf-8")
162
+
163
+
164
+ def iter_pages(dump_path, base_url: Optional[str] = None) -> Iterator[PageDoc]:
165
+ """Yield one :class:`PageDoc` per main-namespace, non-redirect article."""
166
+ dump = mwxml.Dump.from_file(open_dump(str(dump_path)))
167
+ for page in dump:
168
+ if page.namespace != 0 or page.redirect: # main namespace, no redirects
169
+ continue
170
+ wikitext = next((rev.text for rev in page if rev.text), None)
171
+ if not wikitext:
172
+ continue
173
+ try:
174
+ yield parse_page(page.title, wikitext, base_url)
175
+ except Exception as exc: # noqa: BLE001 - one bad page shouldn't stop the run
176
+ log.warning("skip %r: %s", page.title, exc)
177
+ continue
File without changes
@@ -0,0 +1,171 @@
1
+ """Serialize a stream of :class:`~mwextract.parser.PageDoc` to disk.
2
+
3
+ Every format supports two layouts:
4
+
5
+ combined -> one file for the whole corpus (``corpus.md`` / ``corpus.json``
6
+ / ``corpus.xml``). This is the default.
7
+ per-file -> one file per article (``Title.md`` / ``Title.json`` /
8
+ ``Title.xml``), mirroring the original ingest script.
9
+
10
+ Both layouts are written incrementally as pages stream in, so memory use stays
11
+ flat regardless of dump size.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import re
18
+ import xml.etree.ElementTree as ET
19
+ from pathlib import Path
20
+ from typing import Iterable, List, Tuple
21
+
22
+ from .parser import PageDoc
23
+
24
+ log = logging.getLogger("mwextract")
25
+
26
+ # Infobox fields promoted into the human-readable Markdown summary line.
27
+ SUMMARY_KEYS = ("house", "allegiance", "status", "culture", "born", "died")
28
+
29
+ _PROGRESS_EVERY = 200
30
+
31
+
32
+ def safe_filename(title: str) -> str:
33
+ name = re.sub(r"[^\w\-]+", "_", title).strip("_")[:120]
34
+ return name or "untitled"
35
+
36
+
37
+ # --------------------------------------------------------------------------- #
38
+ # Per-format serialization of a single PageDoc #
39
+ # --------------------------------------------------------------------------- #
40
+ def render_markdown_doc(doc: PageDoc) -> str:
41
+ """Full Markdown document: YAML front-matter + title + summary + body."""
42
+ fm: List[str] = ["---", f'title: "{doc.title}"']
43
+ if doc.url:
44
+ fm.append(f"url: {doc.url}")
45
+ for key, val in doc.metadata.items():
46
+ safe = val.replace('"', "'")
47
+ fm.append(f'{key}: "{safe}"')
48
+ fm.append("---")
49
+
50
+ summary = ""
51
+ keys = [k for k in SUMMARY_KEYS if k in doc.metadata]
52
+ if keys:
53
+ summary = (
54
+ "> "
55
+ + " · ".join(f"**{k.title()}:** {doc.metadata[k]}" for k in keys)
56
+ + "\n\n"
57
+ )
58
+
59
+ return "\n".join(fm) + f"\n\n# {doc.title}\n\n{summary}{doc.body}\n"
60
+
61
+
62
+ def _json_obj(doc: PageDoc) -> dict:
63
+ obj: dict = {"title": doc.title}
64
+ if doc.url:
65
+ obj["url"] = doc.url
66
+ obj["metadata"] = doc.metadata
67
+ obj["body"] = doc.body
68
+ return obj
69
+
70
+
71
+ def _xml_elem(doc: PageDoc) -> ET.Element:
72
+ page = ET.Element("page")
73
+ ET.SubElement(page, "title").text = doc.title
74
+ if doc.url:
75
+ ET.SubElement(page, "url").text = doc.url
76
+ metadata = ET.SubElement(page, "metadata")
77
+ for key, val in doc.metadata.items():
78
+ field_el = ET.SubElement(metadata, "field", {"name": key})
79
+ field_el.text = val
80
+ ET.SubElement(page, "body").text = doc.body
81
+ return page
82
+
83
+
84
+ # --------------------------------------------------------------------------- #
85
+ # Combined layout: one file for the whole corpus #
86
+ # --------------------------------------------------------------------------- #
87
+ def _write_combined(
88
+ pages: Iterable[PageDoc], out_dir: Path, fmt: str
89
+ ) -> Tuple[int, List[Path]]:
90
+ out_path = out_dir / f"corpus.{fmt}"
91
+ count = 0
92
+
93
+ with open(out_path, "w", encoding="utf-8") as fh:
94
+ if fmt == "md":
95
+ for count, doc in _enumerate(pages):
96
+ if count > 1:
97
+ fh.write("\n\n\n")
98
+ fh.write(render_markdown_doc(doc))
99
+
100
+ elif fmt == "json":
101
+ fh.write("[\n")
102
+ for count, doc in _enumerate(pages):
103
+ if count > 1:
104
+ fh.write(",\n")
105
+ fh.write(json.dumps(_json_obj(doc), ensure_ascii=False))
106
+ fh.write("\n]\n")
107
+
108
+ elif fmt == "xml":
109
+ fh.write('<?xml version="1.0" encoding="utf-8"?>\n<pages>\n')
110
+ for count, doc in _enumerate(pages):
111
+ fh.write(ET.tostring(_xml_elem(doc), encoding="unicode"))
112
+ fh.write("\n")
113
+ fh.write("</pages>\n")
114
+
115
+ return count, [out_path]
116
+
117
+
118
+ # --------------------------------------------------------------------------- #
119
+ # Per-file layout: one file per article #
120
+ # --------------------------------------------------------------------------- #
121
+ def _write_per_file(
122
+ pages: Iterable[PageDoc], out_dir: Path, fmt: str
123
+ ) -> Tuple[int, List[Path]]:
124
+ seen: dict[str, int] = {}
125
+ count = 0
126
+
127
+ for count, doc in _enumerate(pages):
128
+ stem = safe_filename(doc.title)
129
+ # Disambiguate distinct titles that collapse to the same filename.
130
+ if stem in seen:
131
+ seen[stem] += 1
132
+ stem = f"{stem}_{seen[stem]}"
133
+ else:
134
+ seen[stem] = 1
135
+
136
+ out_path = out_dir / f"{stem}.{fmt}"
137
+ with open(out_path, "w", encoding="utf-8") as fh:
138
+ if fmt == "md":
139
+ fh.write(render_markdown_doc(doc))
140
+ elif fmt == "json":
141
+ fh.write(json.dumps(_json_obj(doc), ensure_ascii=False, indent=2))
142
+ fh.write("\n")
143
+ elif fmt == "xml":
144
+ elem = _xml_elem(doc)
145
+ ET.indent(elem)
146
+ fh.write('<?xml version="1.0" encoding="utf-8"?>\n')
147
+ fh.write(ET.tostring(elem, encoding="unicode"))
148
+ fh.write("\n")
149
+
150
+ return count, [out_dir]
151
+
152
+
153
+ def _enumerate(pages: Iterable[PageDoc]):
154
+ """Enumerate pages from 1, logging progress every _PROGRESS_EVERY pages."""
155
+ for i, doc in enumerate(pages, start=1):
156
+ if i % _PROGRESS_EVERY == 0:
157
+ log.info(" ... %d pages", i)
158
+ yield i, doc
159
+
160
+
161
+ # --------------------------------------------------------------------------- #
162
+ # Public entry point #
163
+ # --------------------------------------------------------------------------- #
164
+ def write(
165
+ pages: Iterable[PageDoc], out_dir, fmt: str, layout: str
166
+ ) -> Tuple[int, List[Path]]:
167
+ out_dir = Path(out_dir)
168
+ out_dir.mkdir(parents=True, exist_ok=True)
169
+ if layout == "combined":
170
+ return _write_combined(pages, out_dir, fmt)
171
+ return _write_per_file(pages, out_dir, fmt)