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

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