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.
- mwextract-0.1.0/.gitignore +27 -0
- mwextract-0.1.0/LICENSE +21 -0
- mwextract-0.1.0/PKG-INFO +183 -0
- mwextract-0.1.0/README.md +153 -0
- mwextract-0.1.0/pyproject.toml +58 -0
- mwextract-0.1.0/src/mwextract/__init__.py +111 -0
- mwextract-0.1.0/src/mwextract/archive.py +74 -0
- mwextract-0.1.0/src/mwextract/cli.py +104 -0
- mwextract-0.1.0/src/mwextract/parser.py +177 -0
- mwextract-0.1.0/src/mwextract/py.typed +0 -0
- mwextract-0.1.0/src/mwextract/writers.py +171 -0
|
@@ -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
|
mwextract-0.1.0/LICENSE
ADDED
|
@@ -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.
|
mwextract-0.1.0/PKG-INFO
ADDED
|
@@ -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)
|