]> code.communitydata.science - mediawiki_dump_tools.git/blob - wikiq
2c1ef3857e9cd5c02e09000fae3ab9125853cf64
[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 content is not None and 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 content is not None and 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                     # initialize an empty dictionary before assigning things into it. this catches bugs if the first revision is deleted                    
371                     old_rev_data = {}
372                     if rev.deleted.text:
373                         for k in ["token_revs", "tokens_added", "tokens_removed", "tokens_window"]:
374                             old_rev_data[k] = None
375                     else:
376
377                         if self.persist != PersistMethod.legacy:
378                             _, tokens_added, tokens_removed = state.update(rev.text, rev.id)
379
380                         else:
381                             _, tokens_added, tokens_removed = state.process(rev.text, rev.id, text_sha1)
382                             
383                         window.append((rev.id, rev_data, tokens_added, tokens_removed))
384                         
385                         if len(window) == PERSISTENCE_RADIUS:
386                             old_rev_id, old_rev_data, old_tokens_added, old_tokens_removed = window[0]
387                             
388                             num_token_revs, num_tokens = calculate_persistence(old_tokens_added)
389
390                             old_rev_data["token_revs"] = num_token_revs
391                             old_rev_data["tokens_added"] = num_tokens
392                             old_rev_data["tokens_removed"] = len(old_tokens_removed)
393                             old_rev_data["tokens_window"] = PERSISTENCE_RADIUS-1
394
395                             self.print_rev_data(old_rev_data)
396
397                 else:
398                     self.print_rev_data(rev_data)
399
400                 rev_count += 1
401
402             if self.persist != PersistMethod.none:
403                 # print out metadata for the last RADIUS revisions
404                 for i, item in enumerate(window):
405                     # if the window was full, we've already printed item 0
406                     if len(window) == PERSISTENCE_RADIUS and i == 0:
407                         continue
408
409                     rev_id, rev_data, tokens_added, tokens_removed = item
410                     num_token_revs, num_tokens = calculate_persistence(tokens_added)
411
412                     rev_data["token_revs"] = num_token_revs
413                     rev_data["tokens_added"] = num_tokens
414                     rev_data["tokens_removed"] = len(tokens_removed)
415                     rev_data["tokens_window"] = len(window)-(i+1)
416                     
417                     self.print_rev_data(rev_data)
418
419             page_count += 1
420
421         print("Done: %s revisions and %s pages." % (rev_count, page_count),
422               file=sys.stderr)
423
424     def print_rev_data(self, rev_data):
425         # if it's the first time through, print the header
426         if self.urlencode:
427             for field in TO_ENCODE:
428                 rev_data[field] = quote(str(rev_data[field]))
429
430         if not self.printed_header:
431             print("\t".join([str(k) for k in sorted(rev_data.keys())]), file=self.output_file)
432             self.printed_header = True
433         
434         print("\t".join([str(v) for k, v in sorted(rev_data.items())]), file=self.output_file)
435
436
437 def open_input_file(input_filename):
438     if re.match(r'.*\.7z$', input_filename):
439         cmd = ["7za", "x", "-so", input_filename, '*'] 
440     elif re.match(r'.*\.gz$', input_filename):
441         cmd = ["zcat", input_filename] 
442     elif re.match(r'.*\.bz2$', input_filename):
443         cmd = ["bzcat", "-dk", input_filename] 
444
445     try:
446         input_file = Popen(cmd, stdout=PIPE).stdout
447     except NameError:
448         input_file = open(input_filename, 'r')
449
450     return input_file
451
452 def open_output_file(input_filename):
453     # create a regex that creates the output filename
454     output_filename = re.sub(r'\.(7z|gz|bz2)?$', '', input_filename)
455     output_filename = re.sub(r'\.xml', '', output_filename)
456     output_filename = output_filename + ".tsv"
457     output_file = open(output_filename, "w")
458
459     return output_file
460
461 parser = argparse.ArgumentParser(description='Parse MediaWiki XML database dumps into tab delimitted data.')
462
463 # arguments for the input direction
464 parser.add_argument('dumpfiles', metavar="DUMPFILE", nargs="*", type=str, 
465                     help="Filename of the compressed or uncompressed XML database dump. If absent, we'll look for content on stdin and output on stdout.")
466
467 parser.add_argument('-o', '--output-dir', metavar='DIR', dest='output_dir', type=str, nargs=1,
468                     help="Directory for output files.")
469
470 parser.add_argument('-s', '--stdout', dest="stdout", action="store_true",
471                     help="Write output to standard out (do not create dump file)")
472
473 parser.add_argument('--collapse-user', dest="collapse_user", action="store_true",
474                     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.")
475
476 parser.add_argument('-p', '--persistence', dest="persist", default=None, const='', type=str, choices = ['','segment','sequence','legacy'], nargs='?',
477                     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.")
478
479 parser.add_argument('-u', '--url-encode', dest="urlencode", action="store_true",
480                     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.")
481
482 parser.add_argument('-n', '--namespace-include', dest="namespace_filter", type=int, action='append',
483                     help="Id number of namspace to include. Can be specified more than once.")
484
485 parser.add_argument('-rr',
486                     '--revert-radius',
487                     dest="revert_radius",
488                     type=int,
489                     action='store',
490                     default=15,
491                     help="Number of edits to check when looking for reverts (default: 15)")
492
493 parser.add_argument('-RP', '--revision-pattern', dest="regex_match_revision", default=None, type=str, action='append',
494                     help="The regular expression to search for in revision text. The regex must be surrounded by quotes.")
495
496 parser.add_argument('-RPl', '--revision-pattern-label', dest="regex_revision_label", default=None, type=str, action='append',
497                     help="The label for the outputted column based on matching the regex in revision text.")
498
499 parser.add_argument('-CP', '--comment-pattern', dest="regex_match_comment", default=None, type=str, action='append',
500                     help="The regular expression to search for in comments of revisions.")
501
502 parser.add_argument('-CPl', '--comment-pattern-label', dest="regex_comment_label", default=None, type=str, action='append',
503                     help="The label for the outputted column based on matching the regex in comments.")
504
505 args = parser.parse_args()
506
507 # set persistence method
508
509 if args.persist is None:
510     persist = PersistMethod.none
511 elif args.persist == "segment":
512     persist = PersistMethod.segment
513 elif args.persist == "legacy":
514     persist = PersistMethod.legacy
515 else:
516     persist = PersistMethod.sequence
517
518 if args.namespace_filter is not None:
519     namespaces = args.namespace_filter
520 else:
521     namespaces = None
522
523 if len(args.dumpfiles) > 0:
524     for filename in args.dumpfiles:
525         input_file = open_input_file(filename)
526
527         # open directory for output
528         if args.output_dir:
529             output_dir = args.output_dir[0]
530         else:
531             output_dir = "."
532
533         print("Processing file: %s" % filename, file=sys.stderr)
534
535         if args.stdout:
536             output_file = sys.stdout
537         else:
538             filename = os.path.join(output_dir, os.path.basename(filename))
539             output_file = open_output_file(filename)
540
541         wikiq = WikiqParser(input_file,
542                             output_file,
543                             collapse_user=args.collapse_user,
544                             persist=persist,
545                             urlencode=args.urlencode,
546                             namespaces=namespaces,
547                             revert_radius=args.revert_radius,
548                             regex_match_revision = args.regex_match_revision,
549                             regex_revision_label = args.regex_revision_label,
550                             regex_match_comment = args.regex_match_comment,
551                             regex_comment_label = args.regex_comment_label)
552
553         wikiq.process()
554
555         # close things 
556         input_file.close()
557         output_file.close()
558 else:
559     wikiq = WikiqParser(sys.stdin,
560                         sys.stdout,
561                         collapse_user=args.collapse_user,
562                         persist=persist,
563                         #persist_legacy=args.persist_legacy,
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_match_comment = args.regex_match_comment,
570                         regex_comment_label = args.regex_comment_label)
571
572     wikiq.process() 
573
574 # 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"
575 # stop_words = stop_words.split(",")

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