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

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