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 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()