wikiq 0.3.0__py3-none-any.whl
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.
- wikiq/__init__.py +1590 -0
- wikiq/diff_pyarrow_schema.py +49 -0
- wikiq/resume.py +97 -0
- wikiq/tables.py +397 -0
- wikiq/wiki_diff_matcher.py +432 -0
- wikiq/wikitext_parser.py +162 -0
- wikiq-0.3.0.dist-info/METADATA +165 -0
- wikiq-0.3.0.dist-info/RECORD +11 -0
- wikiq-0.3.0.dist-info/WHEEL +4 -0
- wikiq-0.3.0.dist-info/entry_points.txt +2 -0
- wikiq-0.3.0.dist-info/licenses/COPYING +674 -0
wikiq/__init__.py
ADDED
|
@@ -0,0 +1,1590 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# Copyright (C) 2015-2026 Benjamin Mako Hill, Nathan TeBlunthuis, Will
|
|
4
|
+
# Beason, Sohyeon Hwang, Kaylea Champion, and other contributors
|
|
5
|
+
#
|
|
6
|
+
# This program is free software: you can redistribute it and/or modify it
|
|
7
|
+
# under the terms of the GNU General Public License as published by the
|
|
8
|
+
# Free Software Foundation, either version 3 of the License, or (at your
|
|
9
|
+
# option) any later version.
|
|
10
|
+
#
|
|
11
|
+
# This program is distributed in the hope that it will be useful, but
|
|
12
|
+
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
14
|
+
# General Public License for more details.
|
|
15
|
+
#
|
|
16
|
+
# You should have received a copy of the GNU General Public License along
|
|
17
|
+
# with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
18
|
+
|
|
19
|
+
# original wikiq headers are: title articleid revid date_time anon
|
|
20
|
+
# editor editor_id minor text_size text_entropy text_md5 reversion
|
|
21
|
+
# additions_size deletions_size
|
|
22
|
+
import argparse
|
|
23
|
+
import gc
|
|
24
|
+
import json
|
|
25
|
+
import os.path
|
|
26
|
+
import re
|
|
27
|
+
import signal
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from collections import deque, defaultdict
|
|
32
|
+
from hashlib import sha1
|
|
33
|
+
from io import TextIOWrapper
|
|
34
|
+
from itertools import groupby
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from subprocess import PIPE, Popen
|
|
37
|
+
from typing import IO, Any, Generator, TextIO, Union
|
|
38
|
+
|
|
39
|
+
import mwpersistence
|
|
40
|
+
import mwreverts
|
|
41
|
+
import mwxml
|
|
42
|
+
import pyarrow as pa
|
|
43
|
+
import pyarrow.csv as pacsv
|
|
44
|
+
from deltas import SegmentMatcher, SequenceMatcher
|
|
45
|
+
from deltas.tokenizers import wikitext_split
|
|
46
|
+
from more_itertools import peekable
|
|
47
|
+
from mwxml import Dump
|
|
48
|
+
|
|
49
|
+
import wikiq.tables as tables
|
|
50
|
+
from wikiq.resume import get_resume_point
|
|
51
|
+
from wikiq.tables import RevisionTable
|
|
52
|
+
from wikiq.wikitext_parser import WikitextParser
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from importlib.metadata import PackageNotFoundError
|
|
56
|
+
from importlib.metadata import version as _package_version
|
|
57
|
+
__version__ = _package_version("wikiq")
|
|
58
|
+
except PackageNotFoundError:
|
|
59
|
+
__version__ = "unknown"
|
|
60
|
+
|
|
61
|
+
TO_ENCODE = ("title", "editor")
|
|
62
|
+
PERSISTENCE_RADIUS = 7
|
|
63
|
+
DIFF_TIMEOUT_MS = 60000
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Some dependencies serve a single feature and are awkward enough to install
|
|
67
|
+
# that wikiq leaves them out of the base install. Each is imported inside the
|
|
68
|
+
# code path that needs it, so importing wikiq works without them and a plain
|
|
69
|
+
# `pip install` needs no C++ compiler. These helpers do that import and, when
|
|
70
|
+
# it fails, explain what to install rather than raising an ImportError.
|
|
71
|
+
|
|
72
|
+
def require_pywikidiff2(feature: str):
|
|
73
|
+
"""Import and return pywikidiff2, or exit explaining how to install it.
|
|
74
|
+
|
|
75
|
+
feature names the wikiq option that needs it, so the message points at
|
|
76
|
+
whatever the user actually asked for.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
import pywikidiff2
|
|
80
|
+
except ImportError:
|
|
81
|
+
raise SystemExit(
|
|
82
|
+
f"{feature} requires pywikidiff2, which wikiq does not install by "
|
|
83
|
+
"default because it compiles a C++ extension.\n"
|
|
84
|
+
"Install it with:\n"
|
|
85
|
+
" pip install 'pywikidiff2 @ git+"
|
|
86
|
+
"https://gitea.communitydata.science/groceryheist/pywikidiff2.git'\n"
|
|
87
|
+
"A C++ compiler and libthai must be available. Other persistence "
|
|
88
|
+
"methods (-p sequence, -p segment, -p legacy) do not need it."
|
|
89
|
+
)
|
|
90
|
+
return pywikidiff2
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def require_mw_persistence():
|
|
94
|
+
"""Import and return mw.lib.persistence, or exit explaining how to get it."""
|
|
95
|
+
try:
|
|
96
|
+
from mw.lib import persistence
|
|
97
|
+
except ImportError:
|
|
98
|
+
raise SystemExit(
|
|
99
|
+
"-p legacy requires mediawiki-utilities, which wikiq no longer "
|
|
100
|
+
"installs by default.\n"
|
|
101
|
+
"Install it with:\n"
|
|
102
|
+
" pip install 'wikiq[legacy]'\n"
|
|
103
|
+
"This method exists to reproduce results from older research "
|
|
104
|
+
"projects; -p sequence is the current equivalent."
|
|
105
|
+
)
|
|
106
|
+
return persistence
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def pyarrow_type_to_spark(pa_type):
|
|
110
|
+
"""Convert a PyArrow type to Spark JSON schema format."""
|
|
111
|
+
if pa.types.is_int64(pa_type):
|
|
112
|
+
return "long"
|
|
113
|
+
elif pa.types.is_int32(pa_type):
|
|
114
|
+
return "integer"
|
|
115
|
+
elif pa.types.is_int8(pa_type):
|
|
116
|
+
return "byte"
|
|
117
|
+
elif pa.types.is_boolean(pa_type):
|
|
118
|
+
return "boolean"
|
|
119
|
+
elif pa.types.is_string(pa_type) or pa.types.is_large_string(pa_type):
|
|
120
|
+
return "string"
|
|
121
|
+
elif pa.types.is_timestamp(pa_type):
|
|
122
|
+
return "timestamp"
|
|
123
|
+
elif pa.types.is_list(pa_type):
|
|
124
|
+
return {
|
|
125
|
+
"type": "array",
|
|
126
|
+
"elementType": pyarrow_type_to_spark(pa_type.value_type),
|
|
127
|
+
"containsNull": True
|
|
128
|
+
}
|
|
129
|
+
elif pa.types.is_struct(pa_type):
|
|
130
|
+
return {
|
|
131
|
+
"type": "struct",
|
|
132
|
+
"fields": [
|
|
133
|
+
{
|
|
134
|
+
"name": field.name,
|
|
135
|
+
"type": pyarrow_type_to_spark(field.type),
|
|
136
|
+
"nullable": field.nullable,
|
|
137
|
+
"metadata": {}
|
|
138
|
+
}
|
|
139
|
+
for field in pa_type
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
elif pa.types.is_map(pa_type):
|
|
143
|
+
return {
|
|
144
|
+
"type": "map",
|
|
145
|
+
"keyType": pyarrow_type_to_spark(pa_type.key_type),
|
|
146
|
+
"valueType": pyarrow_type_to_spark(pa_type.item_type),
|
|
147
|
+
"valueContainsNull": True
|
|
148
|
+
}
|
|
149
|
+
else:
|
|
150
|
+
return "string"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def pyarrow_to_spark_schema(schema: pa.Schema) -> dict:
|
|
154
|
+
"""Convert a PyArrow schema to Spark JSON schema format."""
|
|
155
|
+
return {
|
|
156
|
+
"type": "struct",
|
|
157
|
+
"fields": [
|
|
158
|
+
{
|
|
159
|
+
"name": field.name,
|
|
160
|
+
"type": pyarrow_type_to_spark(field.type),
|
|
161
|
+
"nullable": field.nullable,
|
|
162
|
+
"metadata": {}
|
|
163
|
+
}
|
|
164
|
+
for field in schema
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_table(
|
|
170
|
+
text: bool = False,
|
|
171
|
+
collapse_user: bool = False,
|
|
172
|
+
external_links: bool = False,
|
|
173
|
+
citations: bool = False,
|
|
174
|
+
wikilinks: bool = False,
|
|
175
|
+
templates: bool = False,
|
|
176
|
+
headings: bool = False,
|
|
177
|
+
redirect_aliases: Union[list[str], None] = None,
|
|
178
|
+
):
|
|
179
|
+
"""Build the RevisionTable with appropriate columns based on flags.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
(table, reverts_column, wikitext_parser) - the table, a reference to
|
|
183
|
+
the reverts column (which process() needs for setting the revert
|
|
184
|
+
detector), and the wikitext parser (None unless a wikitext-parsing
|
|
185
|
+
column was requested).
|
|
186
|
+
"""
|
|
187
|
+
reverts_column = tables.RevisionReverts()
|
|
188
|
+
redirect_detector = tables.RedirectDetector(redirect_aliases)
|
|
189
|
+
|
|
190
|
+
table = RevisionTable([
|
|
191
|
+
tables.RevisionId(),
|
|
192
|
+
tables.RevisionTimestamp(),
|
|
193
|
+
tables.RevisionArticleId(),
|
|
194
|
+
tables.RevisionPageTitle(),
|
|
195
|
+
tables.RevisionNamespace(),
|
|
196
|
+
tables.RevisionIsRedirect(redirect_detector),
|
|
197
|
+
tables.RevisionRedirectTarget(redirect_detector),
|
|
198
|
+
tables.RevisionDeleted(),
|
|
199
|
+
tables.RevisionEditorId(),
|
|
200
|
+
tables.RevisionEditSummary(),
|
|
201
|
+
tables.RevisionTextChars(),
|
|
202
|
+
reverts_column,
|
|
203
|
+
tables.RevisionSha1(),
|
|
204
|
+
tables.RevisionIsMinor(),
|
|
205
|
+
tables.RevisionEditorText(),
|
|
206
|
+
tables.RevisionIsAnon(),
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
if text:
|
|
210
|
+
table.columns.append(tables.RevisionText())
|
|
211
|
+
|
|
212
|
+
if collapse_user:
|
|
213
|
+
table.columns.append(tables.RevisionCollapsed())
|
|
214
|
+
|
|
215
|
+
wikitext_parser = None
|
|
216
|
+
if external_links or citations or wikilinks or templates or headings:
|
|
217
|
+
wikitext_parser = WikitextParser()
|
|
218
|
+
|
|
219
|
+
if external_links:
|
|
220
|
+
table.columns.append(tables.RevisionExternalLinks(wikitext_parser))
|
|
221
|
+
|
|
222
|
+
if citations:
|
|
223
|
+
table.columns.append(tables.RevisionCitations(wikitext_parser))
|
|
224
|
+
|
|
225
|
+
if wikilinks:
|
|
226
|
+
table.columns.append(tables.RevisionWikilinks(wikitext_parser))
|
|
227
|
+
|
|
228
|
+
if templates:
|
|
229
|
+
table.columns.append(tables.RevisionTemplates(wikitext_parser))
|
|
230
|
+
|
|
231
|
+
if headings:
|
|
232
|
+
table.columns.append(tables.RevisionHeadings(wikitext_parser))
|
|
233
|
+
|
|
234
|
+
table.columns.append(tables.RevisionParserTimeout(wikitext_parser))
|
|
235
|
+
|
|
236
|
+
return table, reverts_column, wikitext_parser
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def build_schema(
|
|
240
|
+
table,
|
|
241
|
+
diff: bool = False,
|
|
242
|
+
persist: int = 0,
|
|
243
|
+
text: bool = False,
|
|
244
|
+
regex_revision_pairs: list = None,
|
|
245
|
+
regex_comment_pairs: list = None,
|
|
246
|
+
) -> pa.Schema:
|
|
247
|
+
"""Build the PyArrow schema from a table, adding output-only fields."""
|
|
248
|
+
schema = table.schema()
|
|
249
|
+
schema = schema.append(pa.field("revert", pa.bool_(), nullable=True))
|
|
250
|
+
|
|
251
|
+
if diff:
|
|
252
|
+
from wikiq.diff_pyarrow_schema import diff_field
|
|
253
|
+
schema = schema.append(diff_field)
|
|
254
|
+
schema = schema.append(pa.field("diff_timeout", pa.bool_()))
|
|
255
|
+
|
|
256
|
+
if regex_revision_pairs:
|
|
257
|
+
for pair in regex_revision_pairs:
|
|
258
|
+
for field in pair.get_pyarrow_fields():
|
|
259
|
+
schema = schema.append(field)
|
|
260
|
+
|
|
261
|
+
if regex_comment_pairs:
|
|
262
|
+
for pair in regex_comment_pairs:
|
|
263
|
+
for field in pair.get_pyarrow_fields():
|
|
264
|
+
schema = schema.append(field)
|
|
265
|
+
|
|
266
|
+
if persist != PersistMethod.none:
|
|
267
|
+
# RevisionText is added to the table for extraction, but not to schema
|
|
268
|
+
# (unless text=True, in which case it's already in the schema from build_table)
|
|
269
|
+
schema = schema.append(pa.field("token_revs", pa.int64(), nullable=True))
|
|
270
|
+
schema = schema.append(pa.field("tokens_added", pa.int64(), nullable=True))
|
|
271
|
+
schema = schema.append(pa.field("tokens_removed", pa.int64(), nullable=True))
|
|
272
|
+
schema = schema.append(pa.field("tokens_window", pa.int64(), nullable=True))
|
|
273
|
+
|
|
274
|
+
return schema
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def make_regex_pairs(patterns, labels, count_only=False) -> list:
|
|
278
|
+
"""Create RegexPair objects from patterns and labels."""
|
|
279
|
+
if (patterns is not None and labels is not None) and (len(patterns) == len(labels)):
|
|
280
|
+
return [RegexPair(pattern, label, count_only) for pattern, label in zip(patterns, labels)]
|
|
281
|
+
elif patterns is None and labels is None:
|
|
282
|
+
return []
|
|
283
|
+
else:
|
|
284
|
+
sys.exit("Each regular expression *must* come with a corresponding label and vice versa.")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class JSONLWriter:
|
|
288
|
+
"""Write JSONL output with schema validation."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, output_file: str, schema: pa.Schema, append: bool = False):
|
|
291
|
+
self.output_file = output_file
|
|
292
|
+
self.schema = schema
|
|
293
|
+
self.field_names = [field.name for field in schema]
|
|
294
|
+
|
|
295
|
+
if append and os.path.exists(output_file):
|
|
296
|
+
self._validate_and_fix_last_line(output_file)
|
|
297
|
+
|
|
298
|
+
mode = "a" if append else "w"
|
|
299
|
+
self._file = open(output_file, mode)
|
|
300
|
+
|
|
301
|
+
def _validate_and_fix_last_line(self, filepath: str):
|
|
302
|
+
"""Validate the last line of JSONL file; truncate if corrupted.
|
|
303
|
+
|
|
304
|
+
If the previous run was interrupted mid-write, the last line may be
|
|
305
|
+
incomplete JSON. This detects and removes such corrupted lines.
|
|
306
|
+
"""
|
|
307
|
+
with open(filepath, 'rb') as f:
|
|
308
|
+
f.seek(0, 2)
|
|
309
|
+
file_size = f.tell()
|
|
310
|
+
if file_size == 0:
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
# Read backwards to find the last newline
|
|
314
|
+
chunk_size = min(8192, file_size)
|
|
315
|
+
f.seek(-chunk_size, 2)
|
|
316
|
+
chunk = f.read(chunk_size)
|
|
317
|
+
|
|
318
|
+
# Find the last complete line
|
|
319
|
+
last_newline = chunk.rfind(b'\n')
|
|
320
|
+
if last_newline == -1:
|
|
321
|
+
# Entire file is one line (possibly corrupted)
|
|
322
|
+
last_line = chunk.decode('utf-8', errors='replace')
|
|
323
|
+
truncate_pos = 0
|
|
324
|
+
else:
|
|
325
|
+
last_line = chunk[last_newline + 1:].decode('utf-8', errors='replace')
|
|
326
|
+
truncate_pos = file_size - chunk_size + last_newline + 1
|
|
327
|
+
|
|
328
|
+
# If last line is empty, file ends with newline - that's fine
|
|
329
|
+
if not last_line.strip():
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
# Try to parse the last line as JSON
|
|
333
|
+
try:
|
|
334
|
+
json.loads(last_line)
|
|
335
|
+
except json.JSONDecodeError:
|
|
336
|
+
print(f"Warning: Last line of {filepath} is corrupted JSON, removing it",
|
|
337
|
+
file=sys.stderr)
|
|
338
|
+
# Truncate the file to remove the corrupted last line
|
|
339
|
+
with open(filepath, 'r+b') as f:
|
|
340
|
+
f.truncate(truncate_pos)
|
|
341
|
+
|
|
342
|
+
def write_batch(self, data: dict):
|
|
343
|
+
"""Write a batch of rows as JSONL.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
data: dict mapping column names to lists of values
|
|
347
|
+
"""
|
|
348
|
+
if not data or not data.get(self.field_names[0]):
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
num_rows = len(data[self.field_names[0]])
|
|
352
|
+
for i in range(num_rows):
|
|
353
|
+
row = {}
|
|
354
|
+
for name in self.field_names:
|
|
355
|
+
if name in data:
|
|
356
|
+
value = data[name][i]
|
|
357
|
+
row[name] = self._convert_value(value)
|
|
358
|
+
self._file.write(json.dumps(row) + "\n")
|
|
359
|
+
|
|
360
|
+
def _convert_value(self, value):
|
|
361
|
+
"""Convert a value to JSON-serializable format."""
|
|
362
|
+
if value is None:
|
|
363
|
+
return None
|
|
364
|
+
elif isinstance(value, (str, int, float, bool)):
|
|
365
|
+
return value
|
|
366
|
+
elif hasattr(value, "isoformat"):
|
|
367
|
+
return value.isoformat()
|
|
368
|
+
elif isinstance(value, (list, tuple)):
|
|
369
|
+
return [self._convert_value(v) for v in value]
|
|
370
|
+
elif isinstance(value, dict):
|
|
371
|
+
return {k: self._convert_value(v) for k, v in value.items()}
|
|
372
|
+
else:
|
|
373
|
+
return str(value)
|
|
374
|
+
|
|
375
|
+
def close(self):
|
|
376
|
+
self._file.close()
|
|
377
|
+
|
|
378
|
+
def __enter__(self):
|
|
379
|
+
return self
|
|
380
|
+
|
|
381
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
382
|
+
self.close()
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class PersistMethod:
|
|
387
|
+
none = 0
|
|
388
|
+
sequence = 1
|
|
389
|
+
segment = 2
|
|
390
|
+
legacy = 3
|
|
391
|
+
wikidiff2 = 4
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def diff_with_timeout(differ, last_text, text):
|
|
395
|
+
"""Returns (result, timed_out) tuple using native pywikidiff2 timeout."""
|
|
396
|
+
result = differ.inline_json_diff(last_text, text, timeout_ms=DIFF_TIMEOUT_MS)
|
|
397
|
+
return result, differ.timed_out()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def calculate_persistence(tokens_added):
|
|
401
|
+
return (sum([(len(x.revisions) - 1) for x in tokens_added]), len(tokens_added))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def fix_hex_digests(revs: list[mwxml.Revision]) -> list[mwxml.Revision]:
|
|
405
|
+
i = 0
|
|
406
|
+
for rev in revs:
|
|
407
|
+
if rev.text is None:
|
|
408
|
+
rev.text = ""
|
|
409
|
+
if not rev.sha1 and not rev.deleted.text:
|
|
410
|
+
rev.sha1 = sha1(bytes(rev.text, "utf8")).hexdigest()
|
|
411
|
+
revs[i] = rev
|
|
412
|
+
i += 1
|
|
413
|
+
return revs
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class WikiqIterator:
|
|
417
|
+
def __init__(self, fh, collapse_user=False):
|
|
418
|
+
self.fh = fh
|
|
419
|
+
self.collapse_user = collapse_user
|
|
420
|
+
self.mwiterator = Dump.from_file(self.fh)
|
|
421
|
+
self.namespace_map = {
|
|
422
|
+
ns.id: ns.name for ns in self.mwiterator.site_info.namespaces
|
|
423
|
+
}
|
|
424
|
+
self.__pages: Generator[WikiqPage] = self.load_pages()
|
|
425
|
+
|
|
426
|
+
def load_pages(self):
|
|
427
|
+
for page in self.mwiterator:
|
|
428
|
+
yield WikiqPage(
|
|
429
|
+
page, namespace_map=self.namespace_map, collapse_user=self.collapse_user
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def __iter__(self):
|
|
433
|
+
return self.__pages
|
|
434
|
+
|
|
435
|
+
def __next__(self):
|
|
436
|
+
return next(self.__pages)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class WikiqPage:
|
|
440
|
+
__slots__ = (
|
|
441
|
+
"id",
|
|
442
|
+
"redirect",
|
|
443
|
+
"restrictions",
|
|
444
|
+
"mwpage",
|
|
445
|
+
"__revisions",
|
|
446
|
+
"collapse_user",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def __init__(self, page, namespace_map, collapse_user=False):
|
|
450
|
+
self.id = page.id
|
|
451
|
+
# following mwxml, we assume namespace 0 in cases where
|
|
452
|
+
# page.namespace is inconsistent with namespace_map
|
|
453
|
+
if page.namespace not in namespace_map:
|
|
454
|
+
page.namespace = 0
|
|
455
|
+
# mwxml >= 0.3.7 keeps the namespace prefix on the title when the dump
|
|
456
|
+
# has <ns> tags, but strips it on the no-<ns> fallback path
|
|
457
|
+
if page.namespace != 0:
|
|
458
|
+
ns_prefix = namespace_map[page.namespace] + ":"
|
|
459
|
+
if not page.title.startswith(ns_prefix):
|
|
460
|
+
page.title = ns_prefix + page.title
|
|
461
|
+
self.restrictions = page.restrictions
|
|
462
|
+
self.collapse_user = collapse_user
|
|
463
|
+
self.mwpage = page
|
|
464
|
+
self.__revisions: Generator[list[mwxml.Revision]] = self.rev_list()
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def user_text(rev) -> Union[str, None]:
|
|
468
|
+
return None if rev.deleted.user else rev.user.text
|
|
469
|
+
|
|
470
|
+
def rev_list(self):
|
|
471
|
+
# Outline for how we want to handle collapse_user=True
|
|
472
|
+
# iteration rev.user prev_rev.user add prev_rev?
|
|
473
|
+
# 0 A None Never
|
|
474
|
+
# 1 A A False
|
|
475
|
+
# 2 B A True
|
|
476
|
+
# 3 A B True
|
|
477
|
+
# 4 A A False
|
|
478
|
+
# Post-loop A Always
|
|
479
|
+
|
|
480
|
+
if not self.collapse_user:
|
|
481
|
+
for rev in self.mwpage:
|
|
482
|
+
yield [rev]
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
for _, revs in groupby(self.mwpage, self.user_text):
|
|
486
|
+
# All revisions are either from the same user, or this is a single
|
|
487
|
+
# revision where the user is missing.
|
|
488
|
+
yield list(revs)
|
|
489
|
+
|
|
490
|
+
def __iter__(self):
|
|
491
|
+
return self.__revisions
|
|
492
|
+
|
|
493
|
+
def __next__(self):
|
|
494
|
+
return next(self.__revisions)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
"""
|
|
498
|
+
A RegexPair is defined by a regular expression (pattern) and a label.
|
|
499
|
+
The pattern can include capture groups. If it does then each capture group will have a resulting column in the output.
|
|
500
|
+
If the pattern does not include a capture group, then only one output column will result.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class RegexPair(object):
|
|
505
|
+
def __init__(self, pattern, label, count_only=False):
|
|
506
|
+
self.pattern = re.compile(pattern)
|
|
507
|
+
self.label = label
|
|
508
|
+
self.count_only = count_only
|
|
509
|
+
self.has_groups = bool(self.pattern.groupindex)
|
|
510
|
+
if self.has_groups:
|
|
511
|
+
self.capture_groups = list(self.pattern.groupindex.keys())
|
|
512
|
+
|
|
513
|
+
def get_pyarrow_fields(self):
|
|
514
|
+
value_type = pa.int64() if self.count_only else pa.string()
|
|
515
|
+
if self.has_groups:
|
|
516
|
+
fields = [
|
|
517
|
+
pa.field(self._make_key(cap_group), value_type)
|
|
518
|
+
for cap_group in self.capture_groups
|
|
519
|
+
]
|
|
520
|
+
else:
|
|
521
|
+
fields = [pa.field(self.label, value_type)]
|
|
522
|
+
|
|
523
|
+
return fields
|
|
524
|
+
|
|
525
|
+
def _make_key(self, cap_group):
|
|
526
|
+
return "{}_{}".format(self.label, cap_group)
|
|
527
|
+
|
|
528
|
+
def matchmake(self, content: str) -> dict:
|
|
529
|
+
temp_dict = {}
|
|
530
|
+
# if there are named capture groups in the regex
|
|
531
|
+
if self.has_groups:
|
|
532
|
+
# if there are matches of some sort in this revision content, fill the lists for each cap_group
|
|
533
|
+
# content can be None when the text or comment was deleted/suppressed
|
|
534
|
+
if content is not None and (matchobjects := list(self.pattern.finditer(content))):
|
|
535
|
+
for cap_group in self.capture_groups:
|
|
536
|
+
key = self._make_key(cap_group)
|
|
537
|
+
temp_list = []
|
|
538
|
+
for match in matchobjects:
|
|
539
|
+
# we only want to add the match for the capture group if the match is not None
|
|
540
|
+
if (group := match.group(cap_group)) is not None:
|
|
541
|
+
temp_list.append(group)
|
|
542
|
+
|
|
543
|
+
# if temp_list of matches is empty just make that column None (0 in count mode)
|
|
544
|
+
if len(temp_list) == 0:
|
|
545
|
+
if self.count_only:
|
|
546
|
+
temp_dict[key] = 0
|
|
547
|
+
else:
|
|
548
|
+
temp_dict[key] = None
|
|
549
|
+
# else we put in the list we made in the for-loop above
|
|
550
|
+
else:
|
|
551
|
+
if self.count_only:
|
|
552
|
+
temp_dict[key] = len(temp_list)
|
|
553
|
+
else:
|
|
554
|
+
temp_dict[key] = ", ".join(temp_list)
|
|
555
|
+
|
|
556
|
+
# there are no matches at all in this revision content, we default values to None (0 in count mode)
|
|
557
|
+
else:
|
|
558
|
+
for cap_group in self.capture_groups:
|
|
559
|
+
key = self._make_key(cap_group)
|
|
560
|
+
if self.count_only:
|
|
561
|
+
temp_dict[key] = 0
|
|
562
|
+
else:
|
|
563
|
+
temp_dict[key] = None
|
|
564
|
+
|
|
565
|
+
# there are no capture groups, we just search for all the matches of the regex
|
|
566
|
+
else:
|
|
567
|
+
# given that there are matches to be made
|
|
568
|
+
if content is not None and (matches := self.pattern.findall(content)):
|
|
569
|
+
if self.count_only:
|
|
570
|
+
temp_dict[self.label] = len(matches)
|
|
571
|
+
else:
|
|
572
|
+
temp_dict[self.label] = ", ".join(matches)
|
|
573
|
+
else:
|
|
574
|
+
if self.count_only:
|
|
575
|
+
temp_dict[self.label] = 0
|
|
576
|
+
else:
|
|
577
|
+
temp_dict[self.label] = None
|
|
578
|
+
|
|
579
|
+
return temp_dict
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class WikiqParser:
|
|
583
|
+
def __init__(
|
|
584
|
+
self,
|
|
585
|
+
input_file: Union[TextIOWrapper, IO[Any], IO[bytes]],
|
|
586
|
+
output_file: Union[TextIO, str],
|
|
587
|
+
regex_match_revision: list[str],
|
|
588
|
+
regex_match_comment: list[str],
|
|
589
|
+
regex_revision_label: list[str],
|
|
590
|
+
regex_comment_label: list[str],
|
|
591
|
+
regex_revision_output_count: bool = False,
|
|
592
|
+
regex_comment_output_count: bool = False,
|
|
593
|
+
text: bool = False,
|
|
594
|
+
diff: bool = False,
|
|
595
|
+
collapse_user: bool = False,
|
|
596
|
+
persist: int = None,
|
|
597
|
+
namespaces: Union[list[int], None] = None,
|
|
598
|
+
revert_radius: int = 15,
|
|
599
|
+
output_jsonl: bool = False,
|
|
600
|
+
output_jsonl_dir: bool = False,
|
|
601
|
+
batch_size: int = 1024,
|
|
602
|
+
resume_point: Union[tuple, None] = None,
|
|
603
|
+
external_links: bool = False,
|
|
604
|
+
citations: bool = False,
|
|
605
|
+
wikilinks: bool = False,
|
|
606
|
+
templates: bool = False,
|
|
607
|
+
headings: bool = False,
|
|
608
|
+
redirect_aliases: Union[list[str], None] = None,
|
|
609
|
+
time_limit_seconds: Union[float, None] = None,
|
|
610
|
+
input_filename: Union[str, None] = None,
|
|
611
|
+
):
|
|
612
|
+
"""
|
|
613
|
+
Parameters:
|
|
614
|
+
persist : what persistence method to use. Takes a PersistMethod value
|
|
615
|
+
resume_point : if set, a (pageid, revid) tuple; skip all revisions up
|
|
616
|
+
to and including this point.
|
|
617
|
+
input_filename : original input filename (needed for .jsonl.d output to derive output filename)
|
|
618
|
+
"""
|
|
619
|
+
self.input_file = input_file
|
|
620
|
+
self.input_filename = input_filename
|
|
621
|
+
|
|
622
|
+
self.collapse_user: bool = collapse_user
|
|
623
|
+
self.persist: int = persist
|
|
624
|
+
self.namespaces = []
|
|
625
|
+
self.revert_radius = revert_radius
|
|
626
|
+
self.diff = diff
|
|
627
|
+
self.text = text
|
|
628
|
+
self.resume_point = resume_point
|
|
629
|
+
self.external_links = external_links
|
|
630
|
+
self.citations = citations
|
|
631
|
+
self.wikilinks = wikilinks
|
|
632
|
+
self.templates = templates
|
|
633
|
+
self.headings = headings
|
|
634
|
+
self.redirect_aliases = redirect_aliases
|
|
635
|
+
self.shutdown_requested = False
|
|
636
|
+
self.time_limit_seconds = time_limit_seconds
|
|
637
|
+
if namespaces is not None:
|
|
638
|
+
self.namespace_filter = set(namespaces)
|
|
639
|
+
else:
|
|
640
|
+
self.namespace_filter = None
|
|
641
|
+
|
|
642
|
+
self.regex_schemas = []
|
|
643
|
+
self.regex_revision_pairs: list[RegexPair] = self.make_matchmake_pairs(
|
|
644
|
+
regex_match_revision, regex_revision_label, regex_revision_output_count
|
|
645
|
+
)
|
|
646
|
+
self.regex_comment_pairs: list[RegexPair] = self.make_matchmake_pairs(
|
|
647
|
+
regex_match_comment, regex_comment_label, regex_comment_output_count
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# Initialize output
|
|
651
|
+
self.batch_size = batch_size
|
|
652
|
+
self.output_jsonl = output_jsonl
|
|
653
|
+
self.output_jsonl_dir = output_jsonl_dir
|
|
654
|
+
self.output_file = output_file
|
|
655
|
+
|
|
656
|
+
if output_jsonl:
|
|
657
|
+
pass # JSONLWriter created in process()
|
|
658
|
+
else:
|
|
659
|
+
# TSV output
|
|
660
|
+
self.print_header = True
|
|
661
|
+
if output_file == sys.stdout.buffer:
|
|
662
|
+
pass
|
|
663
|
+
else:
|
|
664
|
+
self.output_file = open(output_file, "wb")
|
|
665
|
+
|
|
666
|
+
def request_shutdown(self):
|
|
667
|
+
"""Request graceful shutdown. The process() method will exit after completing the current batch."""
|
|
668
|
+
self.shutdown_requested = True
|
|
669
|
+
|
|
670
|
+
def _time_limit_expired(self):
|
|
671
|
+
"""Timer callback when time limit is reached."""
|
|
672
|
+
hours = self.time_limit_seconds / 3600
|
|
673
|
+
print(f"Time limit of {hours:.2f} hours reached, requesting shutdown...", file=sys.stderr)
|
|
674
|
+
self.request_shutdown()
|
|
675
|
+
|
|
676
|
+
def _start_time_limit_timer(self):
|
|
677
|
+
"""Start a background timer to trigger shutdown when time limit is reached."""
|
|
678
|
+
if self.time_limit_seconds is None:
|
|
679
|
+
return None
|
|
680
|
+
timer = threading.Timer(self.time_limit_seconds, self._time_limit_expired)
|
|
681
|
+
timer.daemon = True
|
|
682
|
+
timer.start()
|
|
683
|
+
return timer
|
|
684
|
+
|
|
685
|
+
def _cancel_time_limit_timer(self, timer):
|
|
686
|
+
"""Cancel the time limit timer if it's still running."""
|
|
687
|
+
if timer is not None:
|
|
688
|
+
timer.cancel()
|
|
689
|
+
|
|
690
|
+
def make_matchmake_pairs(self, patterns, labels, count_only=False) -> list[RegexPair]:
|
|
691
|
+
if (patterns is not None and labels is not None) and (
|
|
692
|
+
len(patterns) == len(labels)
|
|
693
|
+
):
|
|
694
|
+
result: list[RegexPair] = []
|
|
695
|
+
for pattern, label in zip(patterns, labels):
|
|
696
|
+
rp = RegexPair(pattern, label, count_only)
|
|
697
|
+
result.append(rp)
|
|
698
|
+
self.regex_schemas = self.regex_schemas + rp.get_pyarrow_fields()
|
|
699
|
+
return result
|
|
700
|
+
elif (patterns is None) and (labels is None):
|
|
701
|
+
return []
|
|
702
|
+
else:
|
|
703
|
+
sys.exit(
|
|
704
|
+
"Each regular expression *must* come with a corresponding label and vice versa."
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
def matchmake_revision(self, rev: mwxml.Revision):
|
|
708
|
+
result = self.matchmake_text(rev.text)
|
|
709
|
+
for k, v in self.matchmake_comment(rev.comment).items():
|
|
710
|
+
result[k] = v
|
|
711
|
+
return result
|
|
712
|
+
|
|
713
|
+
def matchmake_text(self, text: str):
|
|
714
|
+
return self.matchmake_pairs(text, self.regex_revision_pairs)
|
|
715
|
+
|
|
716
|
+
def matchmake_comment(self, comment: str):
|
|
717
|
+
return self.matchmake_pairs(comment, self.regex_comment_pairs)
|
|
718
|
+
|
|
719
|
+
@staticmethod
|
|
720
|
+
def matchmake_pairs(text, pairs):
|
|
721
|
+
result = {}
|
|
722
|
+
for pair in pairs:
|
|
723
|
+
for k, v in pair.matchmake(text).items():
|
|
724
|
+
result[k] = v
|
|
725
|
+
return result
|
|
726
|
+
|
|
727
|
+
def __get_namespace_from_title(self, title):
|
|
728
|
+
default_ns = None
|
|
729
|
+
|
|
730
|
+
for ns in self.namespaces:
|
|
731
|
+
# skip if the namespace is not defined
|
|
732
|
+
if ns is None:
|
|
733
|
+
default_ns = self.namespaces[ns]
|
|
734
|
+
continue
|
|
735
|
+
|
|
736
|
+
if title.startswith(ns + ":"):
|
|
737
|
+
return self.namespaces[ns]
|
|
738
|
+
|
|
739
|
+
# if we've made it this far with no matches, we return the default namespace
|
|
740
|
+
return default_ns
|
|
741
|
+
|
|
742
|
+
def process(self):
|
|
743
|
+
# Start time limit timer if configured
|
|
744
|
+
time_limit_timer = self._start_time_limit_timer()
|
|
745
|
+
|
|
746
|
+
# Track whether we've passed the resume point
|
|
747
|
+
found_resume_point = self.resume_point is None
|
|
748
|
+
|
|
749
|
+
# Construct dump file iterator
|
|
750
|
+
dump = WikiqIterator(self.input_file, collapse_user=self.collapse_user)
|
|
751
|
+
|
|
752
|
+
table, reverts_column, wikitext_parser = build_table(
|
|
753
|
+
text=self.text,
|
|
754
|
+
collapse_user=self.collapse_user,
|
|
755
|
+
external_links=self.external_links,
|
|
756
|
+
citations=self.citations,
|
|
757
|
+
wikilinks=self.wikilinks,
|
|
758
|
+
templates=self.templates,
|
|
759
|
+
headings=self.headings,
|
|
760
|
+
redirect_aliases=self.redirect_aliases,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# Extract list of namespaces
|
|
764
|
+
self.namespaces = {
|
|
765
|
+
ns.name: ns.id for ns in dump.mwiterator.site_info.namespaces
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
page_count = 0
|
|
769
|
+
rev_count = 0
|
|
770
|
+
|
|
771
|
+
schema = build_schema(
|
|
772
|
+
table,
|
|
773
|
+
diff=self.diff,
|
|
774
|
+
persist=self.persist,
|
|
775
|
+
text=self.text,
|
|
776
|
+
regex_revision_pairs=self.regex_revision_pairs,
|
|
777
|
+
regex_comment_pairs=self.regex_comment_pairs,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
# Add RevisionText to table for diff/persist computation (extraction only, not output)
|
|
781
|
+
if (self.diff or self.persist != PersistMethod.none) and not self.text:
|
|
782
|
+
table.columns.append(tables.RevisionText())
|
|
783
|
+
|
|
784
|
+
# Initialize writer
|
|
785
|
+
writer = None
|
|
786
|
+
|
|
787
|
+
if self.output_jsonl:
|
|
788
|
+
append_mode = self.resume_point is not None
|
|
789
|
+
if self.output_jsonl_dir:
|
|
790
|
+
# Create directory for JSONL output
|
|
791
|
+
Path(self.output_file).mkdir(parents=True, exist_ok=True)
|
|
792
|
+
# Derive JSONL filename from input filename
|
|
793
|
+
if self.input_filename:
|
|
794
|
+
jsonl_basename = os.path.basename(get_output_filename(self.input_filename, 'jsonl'))
|
|
795
|
+
else:
|
|
796
|
+
jsonl_basename = "data.jsonl"
|
|
797
|
+
jsonl_path = Path(self.output_file) / jsonl_basename
|
|
798
|
+
print(f"Writing to JSONL: {jsonl_path} (append={append_mode})", file=sys.stderr)
|
|
799
|
+
writer = JSONLWriter(str(jsonl_path), schema, append=append_mode)
|
|
800
|
+
else:
|
|
801
|
+
writer = JSONLWriter(self.output_file, schema, append=append_mode)
|
|
802
|
+
else:
|
|
803
|
+
writer = pacsv.CSVWriter(
|
|
804
|
+
self.output_file,
|
|
805
|
+
schema,
|
|
806
|
+
write_options=pacsv.WriteOptions(delimiter="\t"),
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# Initialize diff machinery
|
|
810
|
+
differ = None
|
|
811
|
+
fast_differ = None
|
|
812
|
+
if self.diff:
|
|
813
|
+
pywikidiff2 = require_pywikidiff2("--diff")
|
|
814
|
+
differ = pywikidiff2.pywikidiff2(
|
|
815
|
+
num_context_lines=1000000,
|
|
816
|
+
max_word_level_diff_complexity=-1,
|
|
817
|
+
moved_paragraph_detection_cutoff=-1,
|
|
818
|
+
words_cache_capacity=10000,
|
|
819
|
+
diff_cache_capacity=10000,
|
|
820
|
+
stats_cache_capacity=10000,
|
|
821
|
+
)
|
|
822
|
+
fast_differ = pywikidiff2.pywikidiff2(
|
|
823
|
+
num_context_lines=1000000,
|
|
824
|
+
max_word_level_diff_complexity=40000000,
|
|
825
|
+
moved_paragraph_detection_cutoff=100,
|
|
826
|
+
words_cache_capacity=-1,
|
|
827
|
+
diff_cache_capacity=-1,
|
|
828
|
+
stats_cache_capacity=-1,
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
# Write buffer: accumulate rows before flushing
|
|
832
|
+
write_buffer = defaultdict(list)
|
|
833
|
+
buffer_count = 0
|
|
834
|
+
|
|
835
|
+
def flush_buffer():
|
|
836
|
+
nonlocal write_buffer, buffer_count
|
|
837
|
+
if buffer_count == 0:
|
|
838
|
+
return
|
|
839
|
+
row_buffer = dict(write_buffer)
|
|
840
|
+
if self.output_jsonl:
|
|
841
|
+
writer.write_batch(row_buffer)
|
|
842
|
+
else:
|
|
843
|
+
writer.write(pa.record_batch(row_buffer, schema=schema))
|
|
844
|
+
write_buffer = defaultdict(list)
|
|
845
|
+
buffer_count = 0
|
|
846
|
+
|
|
847
|
+
# Iterate through pages
|
|
848
|
+
for page in dump:
|
|
849
|
+
# Skip namespaces not in the filter
|
|
850
|
+
if self.namespace_filter is not None:
|
|
851
|
+
if page.mwpage.namespace not in self.namespace_filter:
|
|
852
|
+
continue
|
|
853
|
+
|
|
854
|
+
# Resume logic: skip pages before the resume point
|
|
855
|
+
is_resume_page = False
|
|
856
|
+
page_resume_revid = None
|
|
857
|
+
if self.resume_point is not None and not found_resume_point:
|
|
858
|
+
page_id = page.mwpage.id
|
|
859
|
+
resume_pageid, resume_revid = self.resume_point[0], self.resume_point[1]
|
|
860
|
+
if page_id < resume_pageid:
|
|
861
|
+
continue
|
|
862
|
+
elif page_id == resume_pageid:
|
|
863
|
+
is_resume_page = True
|
|
864
|
+
page_resume_revid = resume_revid
|
|
865
|
+
else:
|
|
866
|
+
found_resume_point = True
|
|
867
|
+
|
|
868
|
+
# Reset revert detector for new page
|
|
869
|
+
if self.revert_radius > 0:
|
|
870
|
+
reverts_column.rev_detector = mwreverts.Detector(radius=self.revert_radius)
|
|
871
|
+
else:
|
|
872
|
+
reverts_column.rev_detector = None
|
|
873
|
+
|
|
874
|
+
# State for this page
|
|
875
|
+
prev_text = ""
|
|
876
|
+
persist_state = None
|
|
877
|
+
persist_window = None
|
|
878
|
+
|
|
879
|
+
# Adaptive parse-skip: once we observe a parse timeout on this
|
|
880
|
+
# page, record the smallest revision text size that timed out;
|
|
881
|
+
# subsequent revisions at or above that size short-circuit the
|
|
882
|
+
# parse instead of waiting for another 60s SIGALRM. Resets per
|
|
883
|
+
# page so this only suppresses pages that have already proven
|
|
884
|
+
# pathological (e.g. zhwiki bot-vandalism log pages).
|
|
885
|
+
page_min_timeout_size: Union[int, None] = None
|
|
886
|
+
page_skipped_parses = 0
|
|
887
|
+
|
|
888
|
+
if self.persist != PersistMethod.none:
|
|
889
|
+
persist_window = deque(maxlen=PERSISTENCE_RADIUS)
|
|
890
|
+
if self.persist == PersistMethod.sequence:
|
|
891
|
+
persist_state = mwpersistence.DiffState(
|
|
892
|
+
SequenceMatcher(tokenizer=wikitext_split),
|
|
893
|
+
revert_radius=PERSISTENCE_RADIUS,
|
|
894
|
+
)
|
|
895
|
+
elif self.persist == PersistMethod.segment:
|
|
896
|
+
persist_state = mwpersistence.DiffState(
|
|
897
|
+
SegmentMatcher(tokenizer=wikitext_split),
|
|
898
|
+
revert_radius=PERSISTENCE_RADIUS,
|
|
899
|
+
)
|
|
900
|
+
elif self.persist == PersistMethod.wikidiff2:
|
|
901
|
+
require_pywikidiff2("-p wikidiff2")
|
|
902
|
+
from wikiq.wiki_diff_matcher import WikiDiffMatcher
|
|
903
|
+
wikidiff_matcher = WikiDiffMatcher(tokenizer=wikitext_split)
|
|
904
|
+
persist_state = mwpersistence.DiffState(
|
|
905
|
+
wikidiff_matcher, revert_radius=PERSISTENCE_RADIUS
|
|
906
|
+
)
|
|
907
|
+
else:
|
|
908
|
+
persistence = require_mw_persistence()
|
|
909
|
+
persist_state = persistence.State()
|
|
910
|
+
|
|
911
|
+
# Pending persistence values waiting for window to fill
|
|
912
|
+
pending_persistence = []
|
|
913
|
+
|
|
914
|
+
# Use peekable to detect last revision in page
|
|
915
|
+
revs_iter = peekable(page)
|
|
916
|
+
|
|
917
|
+
for revs in revs_iter:
|
|
918
|
+
# revs is either a single revision or a group (collapse_user mode)
|
|
919
|
+
revs = list(revs)
|
|
920
|
+
revs = fix_hex_digests(revs)
|
|
921
|
+
rev = revs[-1] # Last revision in the group
|
|
922
|
+
is_last_in_page = revs_iter.peek(None) is None
|
|
923
|
+
|
|
924
|
+
# Skip revisions before resume point
|
|
925
|
+
if is_resume_page:
|
|
926
|
+
if rev.id <= page_resume_revid:
|
|
927
|
+
# Update state for correctness when we resume output
|
|
928
|
+
if self.diff or self.persist != PersistMethod.none:
|
|
929
|
+
prev_text = rev.text or ""
|
|
930
|
+
if persist_state is not None:
|
|
931
|
+
text = rev.text or ""
|
|
932
|
+
if self.persist != PersistMethod.legacy:
|
|
933
|
+
persist_state.update(text, rev.id)
|
|
934
|
+
else:
|
|
935
|
+
persist_state.process(text, rev.id)
|
|
936
|
+
# Update revert detector so it has history for post-resume revisions
|
|
937
|
+
if reverts_column.rev_detector is not None and not rev.deleted.text:
|
|
938
|
+
reverts_column.rev_detector.process(rev.sha1, rev.id)
|
|
939
|
+
if rev.id == page_resume_revid:
|
|
940
|
+
found_resume_point = True
|
|
941
|
+
is_resume_page = False
|
|
942
|
+
print(f"Resuming output after revid {rev.id}", file=sys.stderr, flush=True)
|
|
943
|
+
continue
|
|
944
|
+
|
|
945
|
+
rev_count += 1
|
|
946
|
+
|
|
947
|
+
# If this page has already produced a parse timeout, force
|
|
948
|
+
# the cache to "timed out" for any same-or-larger revision so
|
|
949
|
+
# extract_row returns nulls without spending another 60s on
|
|
950
|
+
# mwparserfromhell. See page_min_timeout_size init above.
|
|
951
|
+
if (
|
|
952
|
+
wikitext_parser is not None
|
|
953
|
+
and page_min_timeout_size is not None
|
|
954
|
+
and rev.text is not None
|
|
955
|
+
and len(rev.text) >= page_min_timeout_size
|
|
956
|
+
):
|
|
957
|
+
wikitext_parser.force_timeout(rev.text)
|
|
958
|
+
page_skipped_parses += 1
|
|
959
|
+
|
|
960
|
+
# Extract base row data
|
|
961
|
+
row = table.extract_row(page.mwpage, revs)
|
|
962
|
+
|
|
963
|
+
# Record the smallest size at which this page has timed out
|
|
964
|
+
# so far, so future revisions at >= that size are auto-skipped.
|
|
965
|
+
if (
|
|
966
|
+
wikitext_parser is not None
|
|
967
|
+
and wikitext_parser.last_parse_timed_out
|
|
968
|
+
and rev.text is not None
|
|
969
|
+
):
|
|
970
|
+
rev_size = len(rev.text)
|
|
971
|
+
if page_min_timeout_size is None or rev_size < page_min_timeout_size:
|
|
972
|
+
page_min_timeout_size = rev_size
|
|
973
|
+
print(
|
|
974
|
+
f" parse timeout on pageid={page.mwpage.id} "
|
|
975
|
+
f"title={page.mwpage.title!r} revid={rev.id} "
|
|
976
|
+
f"text_bytes={rev_size:,} — will skip future revs "
|
|
977
|
+
f">= {rev_size:,} bytes on this page",
|
|
978
|
+
file=sys.stderr, flush=True,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
# Compute revert flag
|
|
982
|
+
if self.revert_radius == 0 or row["deleted"]:
|
|
983
|
+
row["revert"] = None
|
|
984
|
+
else:
|
|
985
|
+
row["revert"] = row["reverteds"] is not None
|
|
986
|
+
|
|
987
|
+
# Regex matching
|
|
988
|
+
regex_dict = self.matchmake_revision(rev)
|
|
989
|
+
for k, v in regex_dict.items():
|
|
990
|
+
row[k] = v
|
|
991
|
+
|
|
992
|
+
# Compute diff
|
|
993
|
+
text = row.get("text", "") or ""
|
|
994
|
+
if self.diff:
|
|
995
|
+
diff_result, timed_out = diff_with_timeout(differ, prev_text, text)
|
|
996
|
+
if timed_out:
|
|
997
|
+
print(f"WARNING! wikidiff2 timeout for rev: {rev.id}. Falling back to default limits.", file=sys.stderr)
|
|
998
|
+
diff_result = fast_differ.inline_json_diff(prev_text, text)
|
|
999
|
+
row["diff"] = [entry for entry in json.loads(diff_result)["diff"] if entry["type"] != 0]
|
|
1000
|
+
row["diff_timeout"] = timed_out
|
|
1001
|
+
|
|
1002
|
+
# Compute persistence
|
|
1003
|
+
if persist_state is not None:
|
|
1004
|
+
if self.persist != PersistMethod.legacy:
|
|
1005
|
+
_, tokens_added, tokens_removed = persist_state.update(text, rev.id)
|
|
1006
|
+
else:
|
|
1007
|
+
_, tokens_added, tokens_removed = persist_state.process(text, rev.id)
|
|
1008
|
+
|
|
1009
|
+
persist_window.append((rev.id, tokens_added, tokens_removed))
|
|
1010
|
+
pending_persistence.append(row)
|
|
1011
|
+
|
|
1012
|
+
# When window is full, emit persistence for oldest revision
|
|
1013
|
+
if len(persist_window) == PERSISTENCE_RADIUS:
|
|
1014
|
+
old_rev_id, old_tokens_added, old_tokens_removed = persist_window.popleft()
|
|
1015
|
+
oldest_row = pending_persistence.pop(0)
|
|
1016
|
+
num_token_revs, num_tokens = calculate_persistence(old_tokens_added)
|
|
1017
|
+
oldest_row["token_revs"] = num_token_revs
|
|
1018
|
+
oldest_row["tokens_added"] = num_tokens
|
|
1019
|
+
oldest_row["tokens_removed"] = len(old_tokens_removed)
|
|
1020
|
+
oldest_row["tokens_window"] = PERSISTENCE_RADIUS - 1
|
|
1021
|
+
|
|
1022
|
+
# Remove text if not outputting it
|
|
1023
|
+
if not self.text and "text" in oldest_row:
|
|
1024
|
+
del oldest_row["text"]
|
|
1025
|
+
|
|
1026
|
+
# Add to write buffer
|
|
1027
|
+
for k, v in oldest_row.items():
|
|
1028
|
+
write_buffer[k].append(v)
|
|
1029
|
+
buffer_count += 1
|
|
1030
|
+
|
|
1031
|
+
if buffer_count >= self.batch_size:
|
|
1032
|
+
flush_buffer()
|
|
1033
|
+
|
|
1034
|
+
# Update prev_text for next iteration
|
|
1035
|
+
if self.diff or self.persist != PersistMethod.none:
|
|
1036
|
+
prev_text = text
|
|
1037
|
+
|
|
1038
|
+
# If no persistence, write row directly
|
|
1039
|
+
if persist_state is None:
|
|
1040
|
+
if not self.text and "text" in row:
|
|
1041
|
+
del row["text"]
|
|
1042
|
+
|
|
1043
|
+
for k, v in row.items():
|
|
1044
|
+
write_buffer[k].append(v)
|
|
1045
|
+
buffer_count += 1
|
|
1046
|
+
|
|
1047
|
+
if buffer_count >= self.batch_size:
|
|
1048
|
+
flush_buffer()
|
|
1049
|
+
|
|
1050
|
+
# Check for shutdown
|
|
1051
|
+
if self.shutdown_requested:
|
|
1052
|
+
print("Shutdown requested, closing writers...", file=sys.stderr)
|
|
1053
|
+
break
|
|
1054
|
+
|
|
1055
|
+
# End of page: flush remaining persistence window
|
|
1056
|
+
if persist_state is not None and not self.shutdown_requested:
|
|
1057
|
+
for i, (pending_row, window_item) in enumerate(zip(pending_persistence, persist_window)):
|
|
1058
|
+
rev_id, tokens_added, tokens_removed = window_item
|
|
1059
|
+
num_token_revs, num_tokens = calculate_persistence(tokens_added)
|
|
1060
|
+
pending_row["token_revs"] = num_token_revs
|
|
1061
|
+
pending_row["tokens_added"] = num_tokens
|
|
1062
|
+
pending_row["tokens_removed"] = len(tokens_removed)
|
|
1063
|
+
pending_row["tokens_window"] = len(persist_window) - (i + 1)
|
|
1064
|
+
|
|
1065
|
+
if not self.text and "text" in pending_row:
|
|
1066
|
+
del pending_row["text"]
|
|
1067
|
+
|
|
1068
|
+
for k, v in pending_row.items():
|
|
1069
|
+
write_buffer[k].append(v)
|
|
1070
|
+
buffer_count += 1
|
|
1071
|
+
|
|
1072
|
+
if self.shutdown_requested:
|
|
1073
|
+
break
|
|
1074
|
+
page_count += 1
|
|
1075
|
+
if page_skipped_parses > 0:
|
|
1076
|
+
print(
|
|
1077
|
+
f" pageid={page.mwpage.id} done, skipped {page_skipped_parses} "
|
|
1078
|
+
f"parse(s) after timeout at {page_min_timeout_size:,} bytes",
|
|
1079
|
+
file=sys.stderr, flush=True,
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
# Flush remaining buffer
|
|
1083
|
+
flush_buffer()
|
|
1084
|
+
|
|
1085
|
+
# Cancel time limit timer
|
|
1086
|
+
self._cancel_time_limit_timer(time_limit_timer)
|
|
1087
|
+
|
|
1088
|
+
print(
|
|
1089
|
+
"Done: %s revisions and %s pages." % (rev_count, page_count),
|
|
1090
|
+
file=sys.stderr,
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
# Close all writers
|
|
1094
|
+
if writer is not None:
|
|
1095
|
+
writer.close()
|
|
1096
|
+
|
|
1097
|
+
def match_archive_suffix(input_filename):
|
|
1098
|
+
if re.match(r".*\.7z$", input_filename):
|
|
1099
|
+
cmd = ["7za", "x", "-so", input_filename]
|
|
1100
|
+
elif re.match(r".*\.gz$", input_filename):
|
|
1101
|
+
cmd = ["zcat", input_filename]
|
|
1102
|
+
elif re.match(r".*\.bz2$", input_filename):
|
|
1103
|
+
cmd = ["bzcat", "-dk", input_filename]
|
|
1104
|
+
else:
|
|
1105
|
+
raise ValueError("Unrecognized file type: %s" % input_filename)
|
|
1106
|
+
return cmd
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def open_input_file(input_filename, fandom_2020=False):
|
|
1110
|
+
cmd = match_archive_suffix(input_filename)
|
|
1111
|
+
if fandom_2020:
|
|
1112
|
+
cmd.append("*.xml")
|
|
1113
|
+
try:
|
|
1114
|
+
return Popen(cmd, stdout=PIPE).stdout
|
|
1115
|
+
except NameError:
|
|
1116
|
+
return open(input_filename, "r")
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def get_output_filename(input_filename, output_format='tsv') -> str:
|
|
1120
|
+
"""Generate output filename based on input filename and format.
|
|
1121
|
+
|
|
1122
|
+
Args:
|
|
1123
|
+
input_filename: Input dump file path
|
|
1124
|
+
output_format: 'tsv' or 'jsonl'
|
|
1125
|
+
"""
|
|
1126
|
+
output_filename = re.sub(r"\.(7z|gz|bz2)?$", "", input_filename)
|
|
1127
|
+
output_filename = re.sub(r"\.xml", "", output_filename)
|
|
1128
|
+
if output_format == 'jsonl':
|
|
1129
|
+
output_filename = output_filename + ".jsonl"
|
|
1130
|
+
else:
|
|
1131
|
+
output_filename = output_filename + ".tsv"
|
|
1132
|
+
return output_filename
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def main():
|
|
1136
|
+
parser = argparse.ArgumentParser(
|
|
1137
|
+
description="Parse MediaWiki XML database dumps into tab delimited data."
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
# arguments for the input direction
|
|
1141
|
+
parser.add_argument(
|
|
1142
|
+
"dumpfiles",
|
|
1143
|
+
metavar="DUMPFILE",
|
|
1144
|
+
nargs="*",
|
|
1145
|
+
type=str,
|
|
1146
|
+
help="Filename of the compressed or uncompressed XML database dump. If absent, we'll look for content on stdin and output on stdout.",
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
parser.add_argument(
|
|
1150
|
+
"-o",
|
|
1151
|
+
"--output",
|
|
1152
|
+
metavar="OUTPUT",
|
|
1153
|
+
dest="output",
|
|
1154
|
+
type=str,
|
|
1155
|
+
nargs=1,
|
|
1156
|
+
help="Output file or directory. Format is detected from extension: .jsonl for JSONL, otherwise TSV.",
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
parser.add_argument(
|
|
1160
|
+
"--version",
|
|
1161
|
+
action="version",
|
|
1162
|
+
version=f"wikiq {__version__}",
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
parser.add_argument(
|
|
1166
|
+
"-s",
|
|
1167
|
+
"--stdout",
|
|
1168
|
+
dest="stdout",
|
|
1169
|
+
action="store_true",
|
|
1170
|
+
help="Write output to standard out (do not create dump file)",
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
parser.add_argument(
|
|
1174
|
+
"--print-schema",
|
|
1175
|
+
dest="print_schema",
|
|
1176
|
+
action="store_true",
|
|
1177
|
+
help="Print the Spark-compatible JSON schema for the output and exit. No dump file is processed.",
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
parser.add_argument(
|
|
1181
|
+
"--collapse-user",
|
|
1182
|
+
dest="collapse_user",
|
|
1183
|
+
action="store_true",
|
|
1184
|
+
help="Operate only on the final revision made by user a user within all sequences of consecutive edits made by a user. This can be useful for addressing issues with text persistence measures.",
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
parser.add_argument(
|
|
1188
|
+
"-p",
|
|
1189
|
+
"--persistence",
|
|
1190
|
+
dest="persist",
|
|
1191
|
+
default=None,
|
|
1192
|
+
const="",
|
|
1193
|
+
type=str,
|
|
1194
|
+
choices=["", "wikidiff2", "segment", "sequence", "legacy"],
|
|
1195
|
+
nargs="?",
|
|
1196
|
+
help="Compute and report measures of content persistent: (1) persistent token revisions, (2) tokens added, and (3) number of revision used in computing the first measure. This may by slow. The default is no persistence. -p=sequence, which uses the same algorithm as in the past, but with improvements to wikitext parsing. Use -p=legacy for old behavior used in older research projects. -p=segment attempts advanced persistence calculation method that is robust to content moves, but prone to bugs, and slower. -p=wikidiff2 is like segment, but uses the wikidiff2 algorithm, which (should be) faster and more robust.",
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
parser.add_argument(
|
|
1200
|
+
"-n",
|
|
1201
|
+
"--namespace-include",
|
|
1202
|
+
dest="namespace_filter",
|
|
1203
|
+
type=int,
|
|
1204
|
+
action="append",
|
|
1205
|
+
help="Id number of namespace to include. Can be specified more than once.",
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
parser.add_argument(
|
|
1209
|
+
"-rr",
|
|
1210
|
+
"--revert-radius",
|
|
1211
|
+
dest="revert_radius",
|
|
1212
|
+
type=int,
|
|
1213
|
+
action="store",
|
|
1214
|
+
default=15,
|
|
1215
|
+
help="Number of edits to check when looking for reverts (default: 15)",
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
parser.add_argument(
|
|
1219
|
+
"-RP",
|
|
1220
|
+
"--revision-pattern",
|
|
1221
|
+
dest="regex_match_revision",
|
|
1222
|
+
default=None,
|
|
1223
|
+
type=str,
|
|
1224
|
+
action="append",
|
|
1225
|
+
help="The regular expression to search for in revision text. The regex must be surrounded by quotes.",
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
parser.add_argument(
|
|
1229
|
+
"-RPl",
|
|
1230
|
+
"--revision-pattern-label",
|
|
1231
|
+
dest="regex_revision_label",
|
|
1232
|
+
default=None,
|
|
1233
|
+
type=str,
|
|
1234
|
+
action="append",
|
|
1235
|
+
help="The label for the outputted column based on matching the regex in revision text.",
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
parser.add_argument(
|
|
1239
|
+
"-RPc",
|
|
1240
|
+
"--revision-pattern-count",
|
|
1241
|
+
dest="regex_revision_output_count",
|
|
1242
|
+
action="store_true",
|
|
1243
|
+
help="If present, this will cause the revision patterns to return counts of the number of matches instead of the text of the matches themselves. It will affect all revision patterns.",
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
parser.add_argument(
|
|
1247
|
+
"-CP",
|
|
1248
|
+
"--comment-pattern",
|
|
1249
|
+
dest="regex_match_comment",
|
|
1250
|
+
default=None,
|
|
1251
|
+
type=str,
|
|
1252
|
+
action="append",
|
|
1253
|
+
help="The regular expression to search for in comments of revisions.",
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
parser.add_argument(
|
|
1257
|
+
"-CPl",
|
|
1258
|
+
"--comment-pattern-label",
|
|
1259
|
+
dest="regex_comment_label",
|
|
1260
|
+
default=None,
|
|
1261
|
+
type=str,
|
|
1262
|
+
action="append",
|
|
1263
|
+
help="The label for the outputted column based on matching the regex in comments.",
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
parser.add_argument(
|
|
1267
|
+
"-CPc",
|
|
1268
|
+
"--comment-pattern-count",
|
|
1269
|
+
dest="regex_comment_output_count",
|
|
1270
|
+
action="store_true",
|
|
1271
|
+
help="If present, this will cause the comment patterns to return counts of the number of matches instead of the text of the matches themselves. It will affect all comment patterns.",
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
parser.add_argument(
|
|
1275
|
+
"-d",
|
|
1276
|
+
"--diff",
|
|
1277
|
+
dest="diff",
|
|
1278
|
+
default=False,
|
|
1279
|
+
action="store_true",
|
|
1280
|
+
help="Output a diff structure for each revision with information about changed or moved lines.",
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
parser.add_argument(
|
|
1284
|
+
"-t",
|
|
1285
|
+
"--text",
|
|
1286
|
+
dest="text",
|
|
1287
|
+
default=False,
|
|
1288
|
+
action="store_true",
|
|
1289
|
+
help="Output the text of the revision.",
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
parser.add_argument(
|
|
1293
|
+
"--external-links",
|
|
1294
|
+
dest="external_links",
|
|
1295
|
+
action="store_true",
|
|
1296
|
+
default=False,
|
|
1297
|
+
help="Extract external links from each revision using mwparserfromhell.",
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
parser.add_argument(
|
|
1301
|
+
"--citations",
|
|
1302
|
+
dest="citations",
|
|
1303
|
+
action="store_true",
|
|
1304
|
+
default=False,
|
|
1305
|
+
help="Extract citations (ref tags and cite templates) from each revision.",
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
parser.add_argument(
|
|
1309
|
+
"--wikilinks",
|
|
1310
|
+
dest="wikilinks",
|
|
1311
|
+
action="store_true",
|
|
1312
|
+
default=False,
|
|
1313
|
+
help="Extract internal wikilinks from each revision.",
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
parser.add_argument(
|
|
1317
|
+
"--templates",
|
|
1318
|
+
dest="templates",
|
|
1319
|
+
action="store_true",
|
|
1320
|
+
default=False,
|
|
1321
|
+
help="Extract templates with their parameters from each revision.",
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
parser.add_argument(
|
|
1325
|
+
"--headings",
|
|
1326
|
+
dest="headings",
|
|
1327
|
+
action="store_true",
|
|
1328
|
+
default=False,
|
|
1329
|
+
help="Extract section headings from each revision.",
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
parser.add_argument(
|
|
1333
|
+
"--redirect-aliases",
|
|
1334
|
+
dest="redirect_aliases",
|
|
1335
|
+
default=None,
|
|
1336
|
+
type=str,
|
|
1337
|
+
action="append",
|
|
1338
|
+
help="Additional localized redirect keyword recognized alongside #REDIRECT when detecting redirect revisions (e.g. OMDIRIGERING on Swedish wikis). May be given multiple times or as a comma-separated list.",
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
parser.add_argument(
|
|
1342
|
+
"--fandom-2020",
|
|
1343
|
+
dest="fandom_2020",
|
|
1344
|
+
action="store_true",
|
|
1345
|
+
help="Whether the archive is from the fandom 2020 dumps by Wikiteam. These dumps can have multiple .xml files in their archives.",
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
parser.add_argument(
|
|
1349
|
+
"--batch-size",
|
|
1350
|
+
dest="batch_size",
|
|
1351
|
+
default=1500,
|
|
1352
|
+
type=int,
|
|
1353
|
+
help="How many revisions to process in each batch.",
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
parser.add_argument(
|
|
1357
|
+
"--resume",
|
|
1358
|
+
dest="resume",
|
|
1359
|
+
action="store_true",
|
|
1360
|
+
help="Resume processing from the last successfully written revision in the output file.",
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
parser.add_argument(
|
|
1364
|
+
"--time-limit",
|
|
1365
|
+
dest="time_limit",
|
|
1366
|
+
type=float,
|
|
1367
|
+
default=0,
|
|
1368
|
+
help="Time limit in hours before graceful shutdown. Set to 0 to disable (default).",
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
args = parser.parse_args()
|
|
1372
|
+
|
|
1373
|
+
# set persistence method
|
|
1374
|
+
|
|
1375
|
+
if args.persist is None:
|
|
1376
|
+
persist = PersistMethod.none
|
|
1377
|
+
elif args.persist == "segment":
|
|
1378
|
+
persist = PersistMethod.segment
|
|
1379
|
+
elif args.persist == "legacy":
|
|
1380
|
+
persist = PersistMethod.legacy
|
|
1381
|
+
elif args.persist == "wikidiff2":
|
|
1382
|
+
persist = PersistMethod.wikidiff2
|
|
1383
|
+
else:
|
|
1384
|
+
persist = PersistMethod.sequence
|
|
1385
|
+
|
|
1386
|
+
# Check for the optional dependencies up front. Both are used deep in the
|
|
1387
|
+
# per-revision loop, and a run can stream for hours before reaching them.
|
|
1388
|
+
if args.diff:
|
|
1389
|
+
require_pywikidiff2("--diff")
|
|
1390
|
+
if persist == PersistMethod.wikidiff2:
|
|
1391
|
+
require_pywikidiff2("-p wikidiff2")
|
|
1392
|
+
elif persist == PersistMethod.legacy:
|
|
1393
|
+
require_mw_persistence()
|
|
1394
|
+
|
|
1395
|
+
if args.namespace_filter is not None:
|
|
1396
|
+
namespaces = args.namespace_filter
|
|
1397
|
+
else:
|
|
1398
|
+
namespaces = None
|
|
1399
|
+
|
|
1400
|
+
# --redirect-aliases may be given multiple times or comma-separated
|
|
1401
|
+
redirect_aliases = None
|
|
1402
|
+
if args.redirect_aliases:
|
|
1403
|
+
redirect_aliases = [
|
|
1404
|
+
alias.strip()
|
|
1405
|
+
for arg in args.redirect_aliases
|
|
1406
|
+
for alias in arg.split(",")
|
|
1407
|
+
if alias.strip()
|
|
1408
|
+
]
|
|
1409
|
+
|
|
1410
|
+
# Handle --print-schema: build and output schema, then exit
|
|
1411
|
+
if args.print_schema:
|
|
1412
|
+
regex_revision_pairs = make_regex_pairs(args.regex_match_revision, args.regex_revision_label, args.regex_revision_output_count)
|
|
1413
|
+
regex_comment_pairs = make_regex_pairs(args.regex_match_comment, args.regex_comment_label, args.regex_comment_output_count)
|
|
1414
|
+
|
|
1415
|
+
table, _, _ = build_table(
|
|
1416
|
+
text=args.text,
|
|
1417
|
+
collapse_user=args.collapse_user,
|
|
1418
|
+
external_links=args.external_links,
|
|
1419
|
+
citations=args.citations,
|
|
1420
|
+
wikilinks=args.wikilinks,
|
|
1421
|
+
templates=args.templates,
|
|
1422
|
+
headings=args.headings,
|
|
1423
|
+
redirect_aliases=redirect_aliases,
|
|
1424
|
+
)
|
|
1425
|
+
schema = build_schema(
|
|
1426
|
+
table,
|
|
1427
|
+
diff=args.diff,
|
|
1428
|
+
persist=persist,
|
|
1429
|
+
text=args.text,
|
|
1430
|
+
regex_revision_pairs=regex_revision_pairs,
|
|
1431
|
+
regex_comment_pairs=regex_comment_pairs,
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
spark_schema = pyarrow_to_spark_schema(schema)
|
|
1435
|
+
print(json.dumps(spark_schema, indent=2))
|
|
1436
|
+
sys.exit(0)
|
|
1437
|
+
|
|
1438
|
+
print(f"wikiq {__version__}", file=sys.stderr)
|
|
1439
|
+
print(args, file=sys.stderr)
|
|
1440
|
+
if len(args.dumpfiles) > 0:
|
|
1441
|
+
for filename in args.dumpfiles:
|
|
1442
|
+
# Determine output file path before opening input (so resume errors are caught early)
|
|
1443
|
+
if args.output:
|
|
1444
|
+
output = args.output[0]
|
|
1445
|
+
else:
|
|
1446
|
+
output = "."
|
|
1447
|
+
|
|
1448
|
+
# Detect output format from extension
|
|
1449
|
+
output_jsonl_dir = output.endswith(".jsonl.d")
|
|
1450
|
+
output_jsonl = output.endswith(".jsonl") or output_jsonl_dir
|
|
1451
|
+
|
|
1452
|
+
if args.stdout:
|
|
1453
|
+
output_file = sys.stdout.buffer
|
|
1454
|
+
elif output_jsonl:
|
|
1455
|
+
# Output is a JSONL file path - use it directly
|
|
1456
|
+
output_file = output
|
|
1457
|
+
elif os.path.isdir(output):
|
|
1458
|
+
# Output is a directory - derive filename from input
|
|
1459
|
+
output_filename = os.path.join(output, os.path.basename(filename))
|
|
1460
|
+
output_file = get_output_filename(output_filename, output_format='tsv')
|
|
1461
|
+
else:
|
|
1462
|
+
output_file = output
|
|
1463
|
+
|
|
1464
|
+
# Handle resume functionality before opening input file
|
|
1465
|
+
resume_point = None
|
|
1466
|
+
if args.resume:
|
|
1467
|
+
if output_jsonl and not args.stdout:
|
|
1468
|
+
# JSONL: get resume point from last line of file
|
|
1469
|
+
resume_point = get_resume_point(output_file, input_file=filename)
|
|
1470
|
+
if resume_point is not None:
|
|
1471
|
+
pageid, revid = resume_point[0], resume_point[1]
|
|
1472
|
+
print(f"Resuming from: pageid={pageid}, revid={revid}", file=sys.stderr)
|
|
1473
|
+
else:
|
|
1474
|
+
sys.exit("Error: --resume only works with JSONL output (not stdout or TSV)")
|
|
1475
|
+
|
|
1476
|
+
# Now open the input file
|
|
1477
|
+
print("Processing file: %s" % filename, file=sys.stderr)
|
|
1478
|
+
input_file = open_input_file(filename, args.fandom_2020)
|
|
1479
|
+
|
|
1480
|
+
time_limit_seconds = args.time_limit * 3600 if args.time_limit > 0 else None
|
|
1481
|
+
|
|
1482
|
+
wikiq = WikiqParser(
|
|
1483
|
+
input_file,
|
|
1484
|
+
output_file,
|
|
1485
|
+
collapse_user=args.collapse_user,
|
|
1486
|
+
persist=persist,
|
|
1487
|
+
namespaces=namespaces,
|
|
1488
|
+
revert_radius=args.revert_radius,
|
|
1489
|
+
regex_match_revision=args.regex_match_revision,
|
|
1490
|
+
regex_revision_label=args.regex_revision_label,
|
|
1491
|
+
regex_revision_output_count=args.regex_revision_output_count,
|
|
1492
|
+
regex_match_comment=args.regex_match_comment,
|
|
1493
|
+
regex_comment_label=args.regex_comment_label,
|
|
1494
|
+
regex_comment_output_count=args.regex_comment_output_count,
|
|
1495
|
+
text=args.text,
|
|
1496
|
+
diff=args.diff,
|
|
1497
|
+
output_jsonl=output_jsonl,
|
|
1498
|
+
output_jsonl_dir=output_jsonl_dir,
|
|
1499
|
+
batch_size=args.batch_size,
|
|
1500
|
+
resume_point=resume_point,
|
|
1501
|
+
external_links=args.external_links,
|
|
1502
|
+
citations=args.citations,
|
|
1503
|
+
wikilinks=args.wikilinks,
|
|
1504
|
+
templates=args.templates,
|
|
1505
|
+
headings=args.headings,
|
|
1506
|
+
redirect_aliases=redirect_aliases,
|
|
1507
|
+
time_limit_seconds=time_limit_seconds,
|
|
1508
|
+
input_filename=filename,
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
# Register signal handlers for graceful shutdown (CLI only)
|
|
1512
|
+
def handle_shutdown(signum, frame):
|
|
1513
|
+
sig_name = signal.Signals(signum).name
|
|
1514
|
+
print(f"\nReceived {sig_name}, requesting graceful shutdown...", file=sys.stderr)
|
|
1515
|
+
wikiq.request_shutdown()
|
|
1516
|
+
|
|
1517
|
+
original_sigterm = signal.signal(signal.SIGTERM, handle_shutdown)
|
|
1518
|
+
original_sigint = signal.signal(signal.SIGINT, handle_shutdown)
|
|
1519
|
+
original_sigusr1 = signal.signal(signal.SIGUSR1, handle_shutdown)
|
|
1520
|
+
original_sigusr2 = signal.signal(signal.SIGUSR2, handle_shutdown)
|
|
1521
|
+
|
|
1522
|
+
try:
|
|
1523
|
+
wikiq.process()
|
|
1524
|
+
finally:
|
|
1525
|
+
# Restore original signal handlers
|
|
1526
|
+
signal.signal(signal.SIGTERM, original_sigterm)
|
|
1527
|
+
signal.signal(signal.SIGINT, original_sigint)
|
|
1528
|
+
signal.signal(signal.SIGUSR1, original_sigusr1)
|
|
1529
|
+
signal.signal(signal.SIGUSR2, original_sigusr2)
|
|
1530
|
+
|
|
1531
|
+
# close things
|
|
1532
|
+
input_file.close()
|
|
1533
|
+
|
|
1534
|
+
else:
|
|
1535
|
+
if args.resume:
|
|
1536
|
+
print("Warning: --resume cannot be used with stdin/stdout", file=sys.stderr)
|
|
1537
|
+
|
|
1538
|
+
time_limit_seconds = args.time_limit * 3600 if args.time_limit > 0 else None
|
|
1539
|
+
|
|
1540
|
+
wikiq = WikiqParser(
|
|
1541
|
+
sys.stdin,
|
|
1542
|
+
sys.stdout,
|
|
1543
|
+
collapse_user=args.collapse_user,
|
|
1544
|
+
persist=persist,
|
|
1545
|
+
# persist_legacy=args.persist_legacy,
|
|
1546
|
+
namespaces=namespaces,
|
|
1547
|
+
revert_radius=args.revert_radius,
|
|
1548
|
+
regex_match_revision=args.regex_match_revision,
|
|
1549
|
+
regex_revision_label=args.regex_revision_label,
|
|
1550
|
+
regex_revision_output_count=args.regex_revision_output_count,
|
|
1551
|
+
regex_match_comment=args.regex_match_comment,
|
|
1552
|
+
regex_comment_label=args.regex_comment_label,
|
|
1553
|
+
regex_comment_output_count=args.regex_comment_output_count,
|
|
1554
|
+
diff=args.diff,
|
|
1555
|
+
text=args.text,
|
|
1556
|
+
batch_size=args.batch_size,
|
|
1557
|
+
resume_point=None,
|
|
1558
|
+
external_links=args.external_links,
|
|
1559
|
+
citations=args.citations,
|
|
1560
|
+
wikilinks=args.wikilinks,
|
|
1561
|
+
templates=args.templates,
|
|
1562
|
+
headings=args.headings,
|
|
1563
|
+
redirect_aliases=redirect_aliases,
|
|
1564
|
+
time_limit_seconds=time_limit_seconds,
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
# Register signal handlers for graceful shutdown (CLI only)
|
|
1568
|
+
def handle_shutdown(signum, frame):
|
|
1569
|
+
sig_name = signal.Signals(signum).name
|
|
1570
|
+
print(f"\nReceived {sig_name}, requesting graceful shutdown...", file=sys.stderr)
|
|
1571
|
+
wikiq.request_shutdown()
|
|
1572
|
+
|
|
1573
|
+
original_sigterm = signal.signal(signal.SIGTERM, handle_shutdown)
|
|
1574
|
+
original_sigint = signal.signal(signal.SIGINT, handle_shutdown)
|
|
1575
|
+
original_sigusr1 = signal.signal(signal.SIGUSR1, handle_shutdown)
|
|
1576
|
+
|
|
1577
|
+
try:
|
|
1578
|
+
wikiq.process()
|
|
1579
|
+
finally:
|
|
1580
|
+
# Restore original signal handlers
|
|
1581
|
+
signal.signal(signal.SIGTERM, original_sigterm)
|
|
1582
|
+
signal.signal(signal.SIGINT, original_sigint)
|
|
1583
|
+
signal.signal(signal.SIGUSR1, original_sigusr1)
|
|
1584
|
+
|
|
1585
|
+
# stop_words = "a,able,about,across,after,all,almost,also,am,among,an,and,any,are,as,at,be,because,been,but,by,can,cannot,could,dear,did,do,does,either,else,ever,every,for,from,get,got,had,has,have,he,her,hers,him,his,how,however,i,if,in,into,is,it,its,just,least,let,like,likely,may,me,might,most,must,my,neither,no,nor,not,of,off,often,on,only,or,other,our,own,rather,said,say,says,she,should,since,so,some,than,that,the,their,them,then,there,these,they,this,tis,to,too,twas,us,wants,was,we,were,what,when,where,which,while,who,whom,why,will,with,would,yet,you,your"
|
|
1586
|
+
# stop_words = stop_words.split(",")
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
if __name__ == "__main__":
|
|
1590
|
+
main()
|