]> code.communitydata.science - mediawiki_dump_tools.git/blob - wikiq
refactor regex matching in a tidier object oriented style
[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):
143         
144         temp_dict = {}
145         # if there are named capture groups in the regex
146         if self.has_groups:
147             # initialize the {capture_group_name:list} for each capture group
148             # if there are matches of some sort in this revision content, fill the lists for each cap_group
149             if self.pattern.search(content) is not None:
150                 m = self.pattern.finditer(content)
151                 matchobjects = list(m)
152
153                 for cap_group in self.capture_groups:
154                     key = self._make_key(cap_group)
155                     temp_list = []
156                     for match in matchobjects:
157                         # we only want to add the match for the capture group if the match is not None
158                         if match.group(cap_group) != None:
159                             temp_list.append(match.group(cap_group))
160
161                     # if temp_list of matches is empty just make that column None
162                     if len(temp_list)==0:
163                         temp_dict[key] = None
164                     # else we put in the list we made in the for-loop above
165                     else:
166                         temp_dict[key] = ', '.join(temp_list)
167
168             # there are no matches at all in this revision content, we default values to None
169             else:
170                 for cap_group in self.capture_groups:
171                     key = self._make_key(cap_group)
172                     temp_dict[key] = None
173
174         # there are no capture groups, we just search for all the matches of the regex
175         else:
176             #given that there are matches to be made
177             if self.pattern.search(content) is not None:
178                 m = self.pattern.findall(content)
179                 temp_dict[self.label] = ', '.join(m)
180             else:
181                 temp_dict[self.label] = None    
182         # update rev_data with our new columns
183         rev_data.update(temp_dict)
184         return rev_data
185
186
187         
188 class WikiqParser():
189     def __init__(self, input_file, output_file, regex_match_revision, regex_match_comment, regex_revision_label, regex_comment_label, collapse_user=False, persist=None, urlencode=False, namespaces = None, revert_radius=15):
190         """ 
191         Parameters:
192            persist : what persistence method to use. Takes a PersistMethod value
193         """
194         self.input_file = input_file
195         self.output_file = output_file
196         self.collapse_user = collapse_user
197         self.persist = persist
198         self.printed_header = False
199         self.namespaces = []
200         self.urlencode = urlencode
201         self.revert_radius = revert_radius
202
203         if namespaces is not None:
204             self.namespace_filter = set(namespaces)
205         else:
206             self.namespace_filter = None
207
208         self.regex_revision_pairs = self.make_matchmake_pairs(regex_match_revision, regex_revision_label)
209         self.regex_comment_pairs = self.make_matchmake_pairs(regex_match_comment, regex_comment_label)
210         
211
212     def make_matchmake_pairs(self, patterns, labels):
213         if (patterns is not None and labels is not None) and \
214            (len(patterns) == len(labels)):
215             return [RegexPair(pattern, label) for pattern, label in zip(patterns, labels)]
216         elif (patterns is None and labels is None):
217             return []
218         else:
219             sys.exit('Each regular expression *must* come with a corresponding label and vice versa.')
220
221     def matchmake(self, rev, rev_data):
222         rev_data = self.matchmake_revision(rev.text, rev_data)
223         rev_data = self.matchmake_comment(rev.comment, rev_data)
224         return rev_data
225
226     def matchmake_revision(self, text, rev_data):
227         return self.matchmake_pairs(text, rev_data, self.regex_revision_pairs)
228
229     def matchmake_comment(self, comment, rev_data):
230         return self.matchmake_pairs(comment, rev_data, self.regex_comment_pairs)
231
232     def matchmake_pairs(self, text, rev_data, pairs):
233         for pair in pairs:
234             rev_data = pair.matchmake(text, rev_data)
235         return rev_data
236
237     def __get_namespace_from_title(self, title):
238         default_ns = None
239
240         for ns in self.namespaces:
241             # skip if the namespace is not defined
242             if ns == None:
243                 default_ns = self.namespaces[ns]
244                 continue
245
246             if title.startswith(ns + ":"):
247                 return self.namespaces[ns]
248
249         # if we've made it this far with no matches, we return the default namespace
250         return default_ns
251
252
253     def process(self):
254
255         # create a regex that creates the output filename
256         # output_filename = re.sub(r'^.*/(enwiki\-\d+)\-.*p(\d+)p.*$',
257         #                         r'output/wikiq-\1-\2.tsv',
258         #                         input_filename)
259
260         # Construct dump file iterator
261         dump = WikiqIterator(self.input_file, collapse_user=self.collapse_user)
262
263         # extract list of namspaces
264         self.namespaces = {ns.name : ns.id for ns in dump.mwiterator.site_info.namespaces}
265
266         page_count = 0
267         rev_count = 0
268
269
270         # Iterate through pages
271         for page in dump:
272             namespace = page.namespace if page.namespace is not None else self.__get_namespace_from_title(page.title)
273
274             # skip namespaces not in the filter
275             if self.namespace_filter is not None:
276                 if namespace not in self.namespace_filter:
277                     continue
278
279             rev_detector = mwreverts.Detector(radius = self.revert_radius)
280
281             if self.persist != PersistMethod.none:
282                 window = deque(maxlen=PERSISTENCE_RADIUS)
283
284                 if self.persist == PersistMethod.sequence:
285                     state = mwpersistence.DiffState(SequenceMatcher(tokenizer = wikitext_split),
286                                                     revert_radius=PERSISTENCE_RADIUS)
287
288                 elif self.persist == PersistMethod.segment:
289                     state = mwpersistence.DiffState(SegmentMatcher(tokenizer = wikitext_split),
290                                                     revert_radius=PERSISTENCE_RADIUS)
291
292                 # self.persist == PersistMethod.legacy
293                 else:
294                     from mw.lib import persistence
295                     state = persistence.State()
296
297             # Iterate through a page's revisions
298             for rev in page:
299                 
300                 # initialize rev_data
301                 rev_data = {
302                     'revid':rev.id,
303                     'date_time' : rev.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
304                     'articleid' : page.id,
305                     'editor_id' : "" if rev.deleted.user == True or rev.user.id is None else rev.user.id,
306                     'title' : '"' + page.title + '"',
307                     'namespace' : namespace,
308                     'deleted' : "TRUE" if rev.deleted.text else "FALSE"
309                 }
310
311                 rev_data = self.matchmake(rev, rev_data)
312
313                 # if revisions are deleted, /many/ things will be missing
314                 if rev.deleted.text:
315                     rev_data['text_chars'] = ""
316                     rev_data['sha1'] = ""
317                     rev_data['revert'] = ""
318                     rev_data['reverteds'] = ""
319
320                 else:
321                     # rev.text can be None if the page has no text
322                     if not rev.text:
323                         rev.text = ""
324                     # if text exists, we'll check for a sha1 and generate one otherwise
325
326                     if rev.sha1:
327                         text_sha1 = rev.sha1
328                     else:
329
330                         text_sha1 = sha1(bytes(rev.text, "utf8")).hexdigest()
331                     
332                     rev_data['sha1'] = text_sha1
333
334                     # TODO rev.bytes doesn't work.. looks like a bug
335                     rev_data['text_chars'] = len(rev.text)
336
337                     # generate revert data
338                     revert = rev_detector.process(text_sha1, rev.id)
339                     
340                     if revert:
341                         rev_data['revert'] = "TRUE"
342                         rev_data['reverteds'] = '"' + ",".join([str(x) for x in revert.reverteds]) + '"'
343                     else:
344                         rev_data['revert'] = "FALSE"
345                         rev_data['reverteds'] = ""
346
347                 # if the fact that the edit was minor can be hidden, this might be an issue
348                 rev_data['minor'] = "TRUE" if rev.minor else "FALSE"
349
350                 if not rev.deleted.user:
351                     # wrap user-defined editors in quotes for fread
352                     rev_data['editor'] = '"' + rev.user.text + '"'
353                     rev_data['anon'] = "TRUE" if rev.user.id == None else "FALSE"
354                     
355                 else:
356                     rev_data['anon'] = ""
357                     rev_data['editor'] = ""
358
359                 #if re.match(r'^#redirect \[\[.*\]\]', rev.text, re.I):
360                 #    redirect = True
361                 #else:
362                 #    redirect = False
363                 
364                 #TODO missing: additions_size deletions_size
365                 
366                 # if collapse user was on, lets run that
367                 if self.collapse_user:
368                     rev_data['collapsed_revs'] = rev.collapsed_revs
369
370                 if self.persist != PersistMethod.none:
371                     if rev.deleted.text:
372                         for k in ["token_revs", "tokens_added", "tokens_removed", "tokens_window"]:
373                             old_rev_data[k] = None
374                     else:
375
376                         if self.persist != PersistMethod.legacy:
377                             _, tokens_added, tokens_removed = state.update(rev.text, rev.id)
378
379                         else:
380                             _, tokens_added, tokens_removed = state.process(rev.text, rev.id, text_sha1)
381                             
382                         window.append((rev.id, rev_data, tokens_added, tokens_removed))
383                         
384                         if len(window) == PERSISTENCE_RADIUS:
385                             old_rev_id, old_rev_data, old_tokens_added, old_tokens_removed = window[0]
386                             
387                             num_token_revs, num_tokens = calculate_persistence(old_tokens_added)
388
389                             old_rev_data["token_revs"] = num_token_revs
390                             old_rev_data["tokens_added"] = num_tokens
391                             old_rev_data["tokens_removed"] = len(old_tokens_removed)
392                             old_rev_data["tokens_window"] = PERSISTENCE_RADIUS-1
393
394                             self.print_rev_data(old_rev_data)
395
396                 else:
397                     self.print_rev_data(rev_data)
398
399                 rev_count += 1
400
401             if self.persist != PersistMethod.none:
402                 # print out metadata for the last RADIUS revisions
403                 for i, item in enumerate(window):
404                     # if the window was full, we've already printed item 0
405                     if len(window) == PERSISTENCE_RADIUS and i == 0:
406                         continue
407
408                     rev_id, rev_data, tokens_added, tokens_removed = item
409                     num_token_revs, num_tokens = calculate_persistence(tokens_added)
410
411                     rev_data["token_revs"] = num_token_revs
412                     rev_data["tokens_added"] = num_tokens
413                     rev_data["tokens_removed"] = len(tokens_removed)
414                     rev_data["tokens_window"] = len(window)-(i+1)
415                     
416                     self.print_rev_data(rev_data)
417
418             page_count += 1
419
420         print("Done: %s revisions and %s pages." % (rev_count, page_count),
421               file=sys.stderr)
422
423     def print_rev_data(self, rev_data):
424         # if it's the first time through, print the header
425         if self.urlencode:
426             for field in TO_ENCODE:
427                 rev_data[field] = quote(str(rev_data[field]))
428
429         if not self.printed_header:
430             print("\t".join([str(k) for k in sorted(rev_data.keys())]), file=self.output_file)
431             self.printed_header = True
432         
433         print("\t".join([str(v) for k, v in sorted(rev_data.items())]), file=self.output_file)
434
435
436 def open_input_file(input_filename):
437     if re.match(r'.*\.7z$', input_filename):
438         cmd = ["7za", "x", "-so", input_filename, '*'] 
439     elif re.match(r'.*\.gz$', input_filename):
440         cmd = ["zcat", input_filename] 
441     elif re.match(r'.*\.bz2$', input_filename):
442         cmd = ["bzcat", "-dk", input_filename] 
443
444     try:
445         input_file = Popen(cmd, stdout=PIPE).stdout
446     except NameError:
447         input_file = open(input_filename, 'r')
448
449     return input_file
450
451 def open_output_file(input_filename):
452     # create a regex that creates the output filename
453     output_filename = re.sub(r'\.(7z|gz|bz2)?$', '', input_filename)
454     output_filename = re.sub(r'\.xml', '', output_filename)
455     output_filename = output_filename + ".tsv"
456     output_file = open(output_filename, "w")
457
458     return output_file
459
460 parser = argparse.ArgumentParser(description='Parse MediaWiki XML database dumps into tab delimitted data.')
461
462 # arguments for the input direction
463 parser.add_argument('dumpfiles', metavar="DUMPFILE", nargs="*", type=str, 
464                     help="Filename of the compressed or uncompressed XML database dump. If absent, we'll look for content on stdin and output on stdout.")
465
466 parser.add_argument('-o', '--output-dir', metavar='DIR', dest='output_dir', type=str, nargs=1,
467                     help="Directory for output files.")
468
469 parser.add_argument('-s', '--stdout', dest="stdout", action="store_true",
470                     help="Write output to standard out (do not create dump file)")
471
472 parser.add_argument('--collapse-user', dest="collapse_user", action="store_true",
473                     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.")
474
475 parser.add_argument('-p', '--persistence', dest="persist", default=None, const='', type=str, choices = ['','segment','sequence','legacy'], nargs='?',
476                     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.")
477
478 parser.add_argument('-u', '--url-encode', dest="urlencode", action="store_true",
479                     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.")
480
481 parser.add_argument('-n', '--namespace-include', dest="namespace_filter", type=int, action='append',
482                     help="Id number of namspace to include. Can be specified more than once.")
483
484 parser.add_argument('-rr',
485                     '--revert-radius',
486                     dest="revert_radius",
487                     type=int,
488                     action='store',
489                     default=15,
490                     help="Number of edits to check when looking for reverts (default: 15)")
491
492 parser.add_argument('-RP', '--revision-pattern', dest="regex_match_revision", default=None, type=str, action='append',
493                     help="The regular expression to search for in revision text. The regex must be surrounded by quotes.")
494
495 parser.add_argument('-RPl', '--revision-pattern-label', dest="regex_revision_label", default=None, type=str, action='append',
496                     help="The label for the outputted column based on matching the regex in revision text.")
497
498 parser.add_argument('-CP', '--comment-pattern', dest="regex_match_comment", default=None, type=str, action='append',
499                     help="The regular expression to search for in comments of revisions.")
500
501 parser.add_argument('-CPl', '--comment-pattern-label', dest="regex_comment_label", default=None, type=str, action='append',
502                     help="The label for the outputted column based on matching the regex in comments.")
503
504 args = parser.parse_args()
505
506 # set persistence method
507
508 if args.persist is None:
509     persist = PersistMethod.none
510 elif args.persist == "segment":
511     persist = PersistMethod.segment
512 elif args.persist == "legacy":
513     persist = PersistMethod.legacy
514 else:
515     persist = PersistMethod.sequence
516
517 if args.namespace_filter is not None:
518     namespaces = args.namespace_filter
519 else:
520     namespaces = None
521
522 if len(args.dumpfiles) > 0:
523     for filename in args.dumpfiles:
524         input_file = open_input_file(filename)
525
526         # open directory for output
527         if args.output_dir:
528             output_dir = args.output_dir[0]
529         else:
530             output_dir = "."
531
532         print("Processing file: %s" % filename, file=sys.stderr)
533
534         if args.stdout:
535             output_file = sys.stdout
536         else:
537             filename = os.path.join(output_dir, os.path.basename(filename))
538             output_file = open_output_file(filename)
539
540         wikiq = WikiqParser(input_file,
541                             output_file,
542                             collapse_user=args.collapse_user,
543                             persist=persist,
544                             urlencode=args.urlencode,
545                             namespaces=namespaces,
546                             revert_radius=args.revert_radius,
547                             regex_match_revision = args.regex_match_revision,
548                             regex_revision_label = args.regex_revision_label,
549                             regex_match_comment = args.regex_match_comment,
550                             regex_comment_label = args.regex_comment_label)
551
552         wikiq.process()
553
554         # close things 
555         input_file.close()
556         output_file.close()
557 else:
558     wikiq = WikiqParser(sys.stdin,
559                         sys.stdout,
560                         collapse_user=args.collapse_user,
561                         persist=persist,
562                         #persist_legacy=args.persist_legacy,
563                         urlencode=args.urlencode,
564                         namespaces=namespaces,
565                         revert_radius=args.revert_radius,
566                         regex_match_revision = args.regex_match_revision,
567                         regex_revision_label = args.regex_revision_label,
568                         regex_match_comment = args.regex_match_comment,
569                         regex_comment_label = args.regex_comment_label)
570
571     wikiq.process() 
572
573 # 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"
574 # stop_words = stop_words.split(",")

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