]> code.communitydata.science - mediawiki_dump_tools.git/blob - wikiq
code review.
[mediawiki_dump_tools.git] / wikiq
1 #!/usr/bin/env python3
2
3 # original wikiq headers are: title articleid revid date_time anon
4 # editor editor_id minor text_size text_entropy text_md5 reversion
5 # additions_size deletions_size
6
7 import argparse
8 import sys
9 import os, os.path
10 import re
11
12 from subprocess import Popen, PIPE
13 from collections import deque
14 from hashlib import sha1
15
16 from mwxml import Dump
17
18 from deltas.tokenizers import wikitext_split
19 import mwpersistence
20 import mwreverts
21 from urllib.parse import quote
22 TO_ENCODE = ('title', 'editor')
23 PERSISTENCE_RADIUS=7
24 from deltas import SequenceMatcher
25 from deltas import SegmentMatcher
26
27 class PersistMethod:
28     none = 0
29     sequence = 1
30     segment = 2
31     legacy = 3
32
33 def calculate_persistence(tokens_added):
34     return(sum([(len(x.revisions)-1) for x in tokens_added]),
35            len(tokens_added))
36
37
38 class WikiqIterator():
39     def __init__(self, fh, collapse_user=False):
40         self.fh = fh
41         self.collapse_user = collapse_user
42         self.mwiterator = Dump.from_file(self.fh)
43         self.namespace_map = { ns.id : ns.name for ns in
44                                self.mwiterator.site_info.namespaces }
45         self.__pages = self.load_pages()
46
47     def load_pages(self):
48         for page in self.mwiterator:
49             yield WikiqPage(page,
50                             namespace_map = self.namespace_map,
51                             collapse_user=self.collapse_user)
52
53     def __iter__(self):
54         return self.__pages
55
56     def __next__(self):
57         return next(self._pages)
58
59 class WikiqPage():
60     __slots__ = ('id', 'title', 'namespace', 'redirect',
61                  'restrictions', 'mwpage', '__revisions',
62                  'collapse_user')
63     
64     def __init__(self, page, namespace_map, collapse_user=False):
65         self.id = page.id
66         self.namespace = page.namespace
67         # following mwxml, we assume namespace 0 in cases where
68         # page.namespace is inconsistent with namespace_map
69         if page.namespace not in namespace_map:
70             self.title = page.title
71             page.namespace = 0
72         if page.namespace != 0:
73             self.title = ':'.join([namespace_map[page.namespace], page.title])
74         else:
75             self.title = page.title
76         self.restrictions = page.restrictions
77         self.collapse_user = collapse_user
78         self.mwpage = page
79         self.__revisions = self.rev_list()
80
81     def rev_list(self):
82         # Outline for how we want to handle collapse_user=True
83         # iteration   rev.user   prev_rev.user   add prev_rev?
84         #         0          A            None           Never
85         #         1          A               A           False
86         #         2          B               A            True
87         #         3          A               B            True
88         #         4          A               A           False
89         # Post-loop                          A          Always
90         for i, rev in enumerate(self.mwpage):
91             # never yield the first time
92             if i == 0:
93                 if self.collapse_user: 
94                     collapsed_revs = 1
95                     rev.collapsed_revs = collapsed_revs
96
97             else:
98                 if self.collapse_user:
99                     # yield if this is the last edit in a seq by a user and reset
100                     # also yield if we do know who the user is
101
102                     if rev.deleted.user or prev_rev.deleted.user:
103                         yield prev_rev
104                         collapsed_revs = 1
105                         rev.collapsed_revs = collapsed_revs
106
107                     elif not rev.user.text == prev_rev.user.text:
108                         yield prev_rev
109                         collapsed_revs = 1
110                         rev.collapsed_revs = collapsed_revs
111                     # otherwise, add one to the counter
112                     else:
113                         collapsed_revs += 1
114                         rev.collapsed_revs = collapsed_revs
115                 # if collapse_user is false, we always yield
116                 else:
117                     yield prev_rev
118
119             prev_rev = rev
120
121         # also yield the final time
122         yield prev_rev
123
124     def __iter__(self):
125         return self.__revisions
126
127     def __next__(self):
128         return next(self.__revisions)
129
130
131 class RegexPair(object):
132     def __init__(self, pattern, label):
133         self.pattern = re.compile(pattern)
134         self.label = label
135         self.has_groups = bool(self.pattern.groupindex)
136         if self.has_groups:
137             self.capture_groups = list(self.pattern.groupindex.keys())
138             
139     def _make_key(self, cap_group):
140         return ("{}_{}".format(self.label, cap_group))
141
142     def matchmake(self, content, rev_data, count_only=False):
143         temp_dict = {}
144         # if there are named capture groups in the regex
145         if self.has_groups:
146
147             # if there are matches of some sort in this revision content, fill the lists for each cap_group
148             if content is not None and len(matchobjects := list(self.pattern.finditer(content))) > 0:
149                 for cap_group in self.capture_groups:
150                     key = self._make_key(cap_group)
151                     temp_list = []
152                     for match in matchobjects:
153                         # we only want to add the match for the capture group if the match is not None
154                         if (group := match.group(cap_group)) is not None:
155                             temp_list.append(group)
156
157                         # if temp_list of matches is empty just make that column None
158                         if len(temp_list)==0:
159                             temp_dict[key] = None
160                             # else we put in the list we made in the for-loop above
161                         else:
162                             if count_only:
163                                 temp_dict[key] = len(temp_list)
164                             else:
165                                 temp_dict[key] = ', '.join(temp_list)
166
167                 # there are no matches at all in this revision content, we default values to None
168             else:
169                 for cap_group in self.capture_groups:
170                     key = self._make_key(cap_group)
171                     if count_only:
172                         temp_dict[key] = 0
173                     else:
174                         temp_dict[key] = None
175
176         # there are no capture groups, we just search for all the matches of the regex
177         else:
178             #given that there are matches to be made
179             if content is not None and self.pattern.search(content) is not None:
180                 m = self.pattern.findall(content)
181                 if count_only:
182                     temp_dict[self.label] = len(m)
183                 else:
184                     temp_dict[self.label] = ', '.join(m)
185             else:
186                 if count_only:
187                     temp_dict[self.label] = 0
188                 else:
189                     temp_dict[self.label] = None
190         # update rev_data with our new columns
191         rev_data.update(temp_dict)
192         return rev_data
193
194         
195 class WikiqParser():
196     def __init__(self, input_file, output_file, regex_revision_match, regex_revision_label, regex_revision_output_count, regex_comment_match, regex_comment_label, regex_comment_output_count, collapse_user=False, persist=None, urlencode=False, namespaces = None, revert_radius=15):
197         """ 
198         Parameters:
199            persist : what persistence method to use. Takes a PersistMethod value
200         """
201         self.input_file = input_file
202         self.output_file = output_file
203         self.collapse_user = collapse_user
204         self.persist = persist
205         self.printed_header = False
206         self.namespaces = []
207         self.urlencode = urlencode
208         self.revert_radius = revert_radius
209
210         if namespaces is not None:
211             self.namespace_filter = set(namespaces)
212         else:
213             self.namespace_filter = None
214
215         self.regex_revision_pairs = self.make_matchmake_pairs(regex_revision_match, regex_revision_label)
216         self.regex_revision_output_count = regex_revision_output_count
217
218         self.regex_comment_pairs = self.make_matchmake_pairs(regex_comment_match, regex_comment_label)
219         self.regex_comment_output_count = regex_comment_output_count
220
221     def make_matchmake_pairs(self, patterns, labels):
222         if (patterns is not None and labels is not None) and \
223            (len(patterns) == len(labels)):
224             return [RegexPair(pattern, label) for pattern, label in zip(patterns, labels)]
225         elif (patterns is None and labels is None):
226             return []
227         else:
228             sys.exit('Each regular expression *must* come with a corresponding label and vice versa.')
229
230     def matchmake(self, rev, rev_data):
231         rev_data = self.matchmake_revision(rev.text, rev_data)
232         rev_data = self.matchmake_comment(rev.comment, rev_data)
233         return rev_data
234
235     def matchmake_revision(self, text, rev_data):
236         return self.matchmake_pairs(text, rev_data, self.regex_revision_pairs, self.regex_revision_output_count)
237
238     def matchmake_comment(self, comment, rev_data):
239         return self.matchmake_pairs(comment, rev_data, self.regex_comment_pairs, self.regex_comment_output_count)
240
241     def matchmake_pairs(self, text, rev_data, pairs, count_only):
242         for pair in pairs:
243             rev_data = pair.matchmake(text, rev_data, count_only)
244         return rev_data
245
246     def __get_namespace_from_title(self, title):
247         default_ns = None
248
249         for ns in self.namespaces:
250             # skip if the namespace is not defined
251             if ns == None:
252                 default_ns = self.namespaces[ns]
253                 continue
254
255             if title.startswith(ns + ":"):
256                 return self.namespaces[ns]
257
258         # if we've made it this far with no matches, we return the default namespace
259         return default_ns
260
261
262     def process(self):
263
264         # create a regex that creates the output filename
265         # output_filename = re.sub(r'^.*/(enwiki\-\d+)\-.*p(\d+)p.*$',
266         #                         r'output/wikiq-\1-\2.tsv',
267         #                         input_filename)
268
269         # Construct dump file iterator
270         dump = WikiqIterator(self.input_file, collapse_user=self.collapse_user)
271
272         # extract list of namspaces
273         self.namespaces = {ns.name : ns.id for ns in dump.mwiterator.site_info.namespaces}
274
275         page_count = 0
276         rev_count = 0
277
278
279         # Iterate through pages
280         for page in dump:
281             namespace = page.namespace if page.namespace is not None else self.__get_namespace_from_title(page.title)
282
283             # skip namespaces not in the filter
284             if self.namespace_filter is not None:
285                 if namespace not in self.namespace_filter:
286                     continue
287
288             rev_detector = mwreverts.Detector(radius = self.revert_radius)
289
290             if self.persist != PersistMethod.none:
291                 window = deque(maxlen=PERSISTENCE_RADIUS)
292
293                 if self.persist == PersistMethod.sequence:
294                     state = mwpersistence.DiffState(SequenceMatcher(tokenizer = wikitext_split),
295                                                     revert_radius=PERSISTENCE_RADIUS)
296
297                 elif self.persist == PersistMethod.segment:
298                     state = mwpersistence.DiffState(SegmentMatcher(tokenizer = wikitext_split),
299                                                     revert_radius=PERSISTENCE_RADIUS)
300
301                 # self.persist == PersistMethod.legacy
302                 else:
303                     from mw.lib import persistence
304                     state = persistence.State()
305
306             # Iterate through a page's revisions
307             for rev in page:
308                 
309                 # initialize rev_data
310                 rev_data = {
311                     'revid':rev.id,
312                     'date_time' : rev.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
313                     'articleid' : page.id,
314                     'editor_id' : "" if rev.deleted.user == True or rev.user.id is None else rev.user.id,
315                     'title' : '"' + page.title + '"',
316                     'namespace' : namespace,
317                     'deleted' : "TRUE" if rev.deleted.text else "FALSE"
318                 }
319
320                 rev_data = self.matchmake(rev, rev_data)
321
322                 # if revisions are deleted, /many/ things will be missing
323                 if rev.deleted.text:
324                     rev_data['text_chars'] = ""
325                     rev_data['sha1'] = ""
326                     rev_data['revert'] = ""
327                     rev_data['reverteds'] = ""
328
329                 else:
330                     # rev.text can be None if the page has no text
331                     if not rev.text:
332                         rev.text = ""
333                     # if text exists, we'll check for a sha1 and generate one otherwise
334
335                     if rev.sha1:
336                         text_sha1 = rev.sha1
337                     else:
338
339                         text_sha1 = sha1(bytes(rev.text, "utf8")).hexdigest()
340                     
341                     rev_data['sha1'] = text_sha1
342
343                     # TODO rev.bytes doesn't work.. looks like a bug
344                     rev_data['text_chars'] = len(rev.text)
345
346                     # generate revert data
347                     revert = rev_detector.process(text_sha1, rev.id)
348                     
349                     if revert:
350                         rev_data['revert'] = "TRUE"
351                         rev_data['reverteds'] = '"' + ",".join([str(x) for x in revert.reverteds]) + '"'
352                     else:
353                         rev_data['revert'] = "FALSE"
354                         rev_data['reverteds'] = ""
355
356                 # if the fact that the edit was minor can be hidden, this might be an issue
357                 rev_data['minor'] = "TRUE" if rev.minor else "FALSE"
358
359                 if not rev.deleted.user:
360                     # wrap user-defined editors in quotes for fread
361                     rev_data['editor'] = '"' + rev.user.text + '"'
362                     rev_data['anon'] = "TRUE" if rev.user.id == None else "FALSE"
363                     
364                 else:
365                     rev_data['anon'] = ""
366                     rev_data['editor'] = ""
367
368                 #if re.match(r'^#redirect \[\[.*\]\]', rev.text, re.I):
369                 #    redirect = True
370                 #else:
371                 #    redirect = False
372                 
373                 #TODO missing: additions_size deletions_size
374                 
375                 # if collapse user was on, lets run that
376                 if self.collapse_user:
377                     rev_data['collapsed_revs'] = rev.collapsed_revs
378
379                 if self.persist != PersistMethod.none:
380                     # initialize an empty dictionary before assigning things into it. this catches bugs if the first revision is deleted                    
381                     old_rev_data = {}
382                     if rev.deleted.text:
383                         for k in ["token_revs", "tokens_added", "tokens_removed", "tokens_window"]:
384                             old_rev_data[k] = None
385                     else:
386
387                         if self.persist != PersistMethod.legacy:
388                             _, tokens_added, tokens_removed = state.update(rev.text, rev.id)
389
390                         else:
391                             _, tokens_added, tokens_removed = state.process(rev.text, rev.id, text_sha1)
392                             
393                         window.append((rev.id, rev_data, tokens_added, tokens_removed))
394                         
395                         if len(window) == PERSISTENCE_RADIUS:
396                             old_rev_id, old_rev_data, old_tokens_added, old_tokens_removed = window[0]
397                             
398                             num_token_revs, num_tokens = calculate_persistence(old_tokens_added)
399
400                             old_rev_data["token_revs"] = num_token_revs
401                             old_rev_data["tokens_added"] = num_tokens
402                             old_rev_data["tokens_removed"] = len(old_tokens_removed)
403                             old_rev_data["tokens_window"] = PERSISTENCE_RADIUS-1
404
405                             self.print_rev_data(old_rev_data)
406
407                 else:
408                     self.print_rev_data(rev_data)
409
410                 rev_count += 1
411
412             if self.persist != PersistMethod.none:
413                 # print out metadata for the last RADIUS revisions
414                 for i, item in enumerate(window):
415                     # if the window was full, we've already printed item 0
416                     if len(window) == PERSISTENCE_RADIUS and i == 0:
417                         continue
418
419                     rev_id, rev_data, tokens_added, tokens_removed = item
420                     num_token_revs, num_tokens = calculate_persistence(tokens_added)
421
422                     rev_data["token_revs"] = num_token_revs
423                     rev_data["tokens_added"] = num_tokens
424                     rev_data["tokens_removed"] = len(tokens_removed)
425                     rev_data["tokens_window"] = len(window)-(i+1)
426                     
427                     self.print_rev_data(rev_data)
428
429             page_count += 1
430
431         print("Done: %s revisions and %s pages." % (rev_count, page_count),
432               file=sys.stderr)
433
434     def print_rev_data(self, rev_data):
435         # if it's the first time through, print the header
436         if self.urlencode:
437             for field in TO_ENCODE:
438                 rev_data[field] = quote(str(rev_data[field]))
439
440         if not self.printed_header:
441             print("\t".join([str(k) for k in sorted(rev_data.keys())]), file=self.output_file)
442             self.printed_header = True
443         
444         print("\t".join([str(v) for k, v in sorted(rev_data.items())]), file=self.output_file)
445
446
447 def open_input_file(input_filename):
448     if re.match(r'.*\.7z$', input_filename):
449         cmd = ["7za", "x", "-so", input_filename, '*'] 
450     elif re.match(r'.*\.gz$', input_filename):
451         cmd = ["zcat", input_filename] 
452     elif re.match(r'.*\.bz2$', input_filename):
453         cmd = ["bzcat", "-dk", input_filename] 
454
455     try:
456         input_file = Popen(cmd, stdout=PIPE).stdout
457     except NameError:
458         input_file = open(input_filename, 'r')
459
460     return input_file
461
462 def open_output_file(input_filename):
463     # create a regex that creates the output filename
464     output_filename = re.sub(r'\.(7z|gz|bz2)?$', '', input_filename)
465     output_filename = re.sub(r'\.xml', '', output_filename)
466     output_filename = output_filename + ".tsv"
467     output_file = open(output_filename, "w")
468
469     return output_file
470
471 parser = argparse.ArgumentParser(description='Parse MediaWiki XML database dumps into tab delimitted data.')
472
473 # arguments for the input direction
474 parser.add_argument('dumpfiles', metavar="DUMPFILE", nargs="*", type=str, 
475                     help="Filename of the compressed or uncompressed XML database dump. If absent, we'll look for content on stdin and output on stdout.")
476
477 parser.add_argument('-o', '--output-dir', metavar='DIR', dest='output_dir', type=str, nargs=1,
478                     help="Directory for output files.")
479
480 parser.add_argument('-s', '--stdout', dest="stdout", action="store_true",
481                     help="Write output to standard out (do not create dump file)")
482
483 parser.add_argument('--collapse-user', dest="collapse_user", action="store_true",
484                     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.")
485
486 parser.add_argument('-p', '--persistence', dest="persist", default=None, const='', type=str, choices = ['','segment','sequence','legacy'], nargs='?',
487                     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 defualt is -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. Use -p=segment for advanced persistence calculation method that is robust to content moves, but prone to bugs, and slower.")
488
489 parser.add_argument('-u', '--url-encode', dest="urlencode", action="store_true",
490                     help="Output url encoded text strings. This works around some data issues like newlines in editor names. In the future it may be used to output other text data.")
491
492 parser.add_argument('-n', '--namespace-include', dest="namespace_filter", type=int, action='append',
493                     help="Id number of namspace to include. Can be specified more than once.")
494
495 parser.add_argument('-rr',
496                     '--revert-radius',
497                     dest="revert_radius",
498                     type=int,
499                     action='store',
500                     default=15,
501                     help="Number of edits to check when looking for reverts (default: 15)")
502
503 parser.add_argument('-RP', '--revision-pattern', dest="regex_revision_match", default=None, type=str, action='append',
504                     help="The regular expression to search for in revision text. The regex must be surrounded by quotes.")
505
506 parser.add_argument('-RPl', '--revision-pattern-label', dest="regex_revision_label", default=None, type=str, action='append',
507                     help="The label for the outputted column based on matching the regex in revision text.")
508
509 parser.add_argument('-RPc', '--revision-pattern-count', dest="regex_revision_output_count", action='store_true',
510                     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.")
511
512 parser.add_argument('-CP', '--comment-pattern', dest="regex_comment_match", default=None, type=str, action='append',
513                     help="The regular expression to search for in comments of revisions.")
514
515 parser.add_argument('-CPl', '--comment-pattern-label', dest="regex_comment_label", default=None, type=str, action='append',
516                     help="The label for the outputted column based on matching the regex in comments.")
517
518 parser.add_argument('-CPc', '--comment-pattern-count', dest="regex_comment_output_count", action='store_true',
519                     help="If present, this will cause the comments patterns to return counts of the number of matches instead of the text of the matches themselves. It will affect all comment patterns.")
520
521 args = parser.parse_args()
522
523 # set persistence method
524
525 if args.persist is None:
526     persist = PersistMethod.none
527 elif args.persist == "segment":
528     persist = PersistMethod.segment
529 elif args.persist == "legacy":
530     persist = PersistMethod.legacy
531 else:
532     persist = PersistMethod.sequence
533
534 if args.namespace_filter is not None:
535     namespaces = args.namespace_filter
536 else:
537     namespaces = None
538
539 if len(args.dumpfiles) > 0:
540     for filename in args.dumpfiles:
541         input_file = open_input_file(filename)
542
543         # open directory for output
544         if args.output_dir:
545             output_dir = args.output_dir[0]
546         else:
547             output_dir = "."
548
549         print("Processing file: %s" % filename, file=sys.stderr)
550
551         if args.stdout:
552             output_file = sys.stdout
553         else:
554             filename = os.path.join(output_dir, os.path.basename(filename))
555             output_file = open_output_file(filename)
556
557         wikiq = WikiqParser(input_file,
558                             output_file,
559                             collapse_user=args.collapse_user,
560                             persist=persist,
561                             urlencode=args.urlencode,
562                             namespaces=namespaces,
563                             revert_radius=args.revert_radius,
564                             regex_revision_match = args.regex_revision_match,
565                             regex_revision_label = args.regex_revision_label,
566                             regex_revision_output_count = args.regex_revision_output_count,
567                             regex_comment_match = args.regex_comment_match,
568                             regex_comment_label = args.regex_comment_label,
569                             regex_comment_output_count = args.regex_comment_output_count)
570
571         wikiq.process()
572
573         # close things 
574         input_file.close()
575         output_file.close()
576 else:
577     wikiq = WikiqParser(sys.stdin,
578                         sys.stdout,
579                         collapse_user=args.collapse_user,
580                         persist=persist,
581                         #persist_legacy=args.persist_legacy,
582                         urlencode=args.urlencode,
583                         namespaces=namespaces,
584                         revert_radius=args.revert_radius,
585                         regex_revision_match = args.regex_revision_match,
586                         regex_revision_label = args.regex_revision_label,
587                         regex_revision_output_count = args.regex_revision_output_count,
588                         regex_comment_match = args.regex_comment_match,
589                         regex_comment_label = args.regex_comment_label,
590                         regex_comment_output_count = args.regex_comment_output_count)
591
592
593     wikiq.process() 
594
595 # 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"
596 # stop_words = stop_words.split(",")

Community Data Science Collective || Want to submit a patch?