UNPKG

33.1 kBJavaScriptView Raw
1/* eslint max-len: ["error", { "code": 150 }] */
2/**
3 * Defines bot API
4 */
5'use strict';
6
7const Api = require( './api' ),
8 _ = require( 'underscore' ),
9 async = require( 'async' ),
10 fs = require( 'fs' ),
11 querystring = require( 'querystring' ),
12 // the upper limit for bots (will be reduced by MW for users without a bot right)
13 API_LIMIT = 5000;
14
15// get the object being the first key/value entry of a given object
16function getFirstItem( obj ) {
17 const key = Object.keys( obj ).shift();
18 return obj[ key ];
19}
20
21// bot public API
22function Bot( params ) {
23 let env = process.env,
24 options;
25
26 // read configuration from the file
27 if ( typeof params === 'string' ) {
28 let configFile,
29 configParsed;
30
31 try {
32 configFile = fs.readFileSync( params, 'utf-8' );
33 configParsed = JSON.parse( configFile );
34 } catch ( e ) {
35 throw new Error( `Loading config failed: ${e.message}` );
36 }
37
38 if ( typeof configParsed === 'object' ) {
39 options = configParsed;
40 }
41 } else if ( typeof params === 'object' ) { // configuration provided as an object
42 options = params;
43 }
44
45 if ( !params ) {
46 throw new Error( 'No configuration was provided!' );
47 }
48
49 this.protocol = options.protocol;
50 this.server = options.server;
51
52 const protocol = options.protocol || 'http';
53 this.api = new Api( {
54 protocol,
55 port: options.port,
56 server: options.server,
57 path: options.path || '',
58 proxy: options.proxy,
59 userAgent: options.userAgent,
60 concurrency: options.concurrency,
61 debug: ( options.debug === true || env.DEBUG === '1' )
62 } );
63
64 this.version = this.api.version;
65
66 // store options
67 this.options = options;
68
69 // in dry-run mode? (issue #48)
70 this.dryRun = ( options.dryRun === true || env.DRY_RUN === '1' );
71
72 if ( this.dryRun ) {
73 this.log( 'Running in dry-run mode' );
74 }
75
76 // bind provider-specific "namespaces"
77 this.wikia.call = this.wikia.call.bind( this );
78}
79
80Bot.prototype = {
81 log() {
82 this.api.info.apply( this.api, arguments );
83 },
84
85 logData( obj ) {
86 this.log( JSON.stringify( obj, undefined, 2 ) );
87 },
88
89 error() {
90 this.api.error.apply( this.api, arguments );
91 },
92
93 getConfig( key, def ) {
94 return this.options[ key ] || def;
95 },
96
97 setConfig( key, val ) {
98 this.options[ key ] = val;
99 },
100
101 getRand() {
102 return Math.random().toString().split( '.' ).pop();
103 },
104
105 getAll( params, key, callback ) {
106 let res = [],
107 // @see https://www.mediawiki.org/wiki/API:Query#Continuing_queries
108 continueParams = { continue: '' };
109 let titles, pageids = params.pageids;
110 if ( params.titles ) {
111 if ( params.titles.length === 0 ) {
112 delete params.titles;
113 } else {
114 titles = params.titles;
115 }
116 }
117 if ( params.pageids ) {
118 if ( params.pageids.length === 0 ) {
119 delete params.pageids;
120 } else {
121 pageids = params.pageids;
122 if ( titles ) {
123 delete params.pageids;
124 }
125 }
126 }
127
128 async.whilst(
129 ( cb ) => { cb( null, true ); }, // run as long as there's more data
130 ( cb ) => {
131 this.api.call( _.extend( params, continueParams ), ( err, data, next ) => {
132 if ( err ) {
133 cb( err );
134 } else {
135 // append batch data
136 const batchData = ( typeof key === 'function' ) ? key( data ) : data[ key ];
137
138 res = res.concat( batchData );
139
140 // more pages?
141 continueParams = next;
142 cb( next ? null : true );
143 }
144 } );
145 if ( titles && pageids ) {
146 params.pageids = pageids;
147 delete params.titles;
148 this.api.call( _.extend( params, continueParams ), ( err, data, next ) => {
149 if ( err ) {
150 cb( err );
151 } else {
152 // append batch data
153 const batchData = ( typeof key === 'function' ) ? key( data ) : data[ key ];
154
155 res = res.concat( batchData );
156
157 // more pages?
158 continueParams = next;
159 cb( next ? null : true );
160 }
161 } );
162 }
163 },
164 ( err ) => {
165 if ( err instanceof Error ) {
166 callback( err );
167 } else {
168 callback( null, res );
169 }
170 }
171 );
172 },
173
174 logIn( username, password, callback /* or just callback */ ) {
175
176 // assign domain if applicable
177 let domain = this.options.domain || '';
178
179 // username and password params can be omitted
180 if ( typeof username !== 'string' ) {
181 callback = username;
182
183 // use data from config
184 username = this.options.username;
185 password = this.options.password;
186 }
187
188 this.log( 'Obtaining login token...' );
189
190 const logInCallback = ( err, data ) => {
191 if ( data === null || typeof data === 'undefined' ) {
192 this.error( 'Logging in failed: no data received' );
193 callback( err || new Error( 'Logging in failed: no data received' ) );
194 } else if ( !err && typeof data.lgusername !== 'undefined' ) {
195 this.log( `Logged in as ${data.lgusername}` );
196 callback( null, data );
197 } else if ( typeof data.reason === 'undefined' ) {
198 this.error( 'Logging in failed' );
199 this.error( data.result );
200 callback( err || new Error( `Logging in failed: ${data.result}` ) );
201 } else {
202 this.error( 'Logging in failed' );
203 this.error( data.result );
204 this.error( data.reason );
205 callback( err || new Error( `Logging in failed: ${data.result} - ${data.reason}` ) );
206 }
207 };
208
209 // request a token
210 this.api.call( {
211 action: 'login',
212 lgname: username,
213 lgpassword: password,
214 lgdomain: domain
215 }, ( err, data ) => {
216 if ( err ) {
217 callback( err );
218 return;
219 }
220
221 if ( data.result === 'NeedToken' ) {
222 const token = data.token;
223
224 this.log( `Got token ${token}` );
225
226 // log in using a token
227 this.api.call( {
228 action: 'login',
229 lgname: username,
230 lgpassword: password,
231 lgtoken: token,
232 lgdomain: domain
233 }, logInCallback, 'POST' );
234 } else {
235 logInCallback( err, data );
236 }
237 }, 'POST' );
238 },
239
240 getCategories( prefix, callback ) {
241 if ( typeof prefix === 'function' ) {
242 callback = prefix;
243 }
244
245 this.getAll(
246 {
247 action: 'query',
248 list: 'allcategories',
249 acprefix: prefix || '',
250 aclimit: API_LIMIT
251 },
252 ( data ) => data.allcategories.map( ( cat ) => cat[ '*' ] ),
253 callback
254 );
255 },
256
257 getUsers( data, callback ) {
258 if ( typeof data === 'function' ) {
259 callback = data;
260 }
261
262 data = data || {};
263
264 this.api.call( {
265 action: 'query',
266 list: 'allusers',
267 auprefix: data.prefix || '',
268 auwitheditsonly: data.witheditsonly || false,
269 aulimit: API_LIMIT
270 }, function ( err, _data ) {
271 callback( err, _data && _data.allusers || [] );
272 } );
273 },
274
275 getAllPages( callback ) {
276 this.log( 'Getting all pages...' );
277 this.getAll(
278 {
279 action: 'query',
280 list: 'allpages',
281 apfilterredir: 'nonredirects', // do not include redirects
282 aplimit: API_LIMIT
283 },
284 'allpages',
285 callback
286 );
287 },
288
289 getPagesInCategory( category, callback ) {
290 category = `Category:${category}`;
291 this.log( `Getting pages from ${category}...` );
292
293 this.getAll(
294 {
295 action: 'query',
296 list: 'categorymembers',
297 cmtitle: category,
298 cmlimit: API_LIMIT
299 },
300 'categorymembers',
301 callback
302 );
303 },
304
305 getPagesInNamespace( namespace, callback ) {
306 this.log( `Getting pages in namespace ${namespace}` );
307
308 this.getAll(
309 {
310 action: 'query',
311 list: 'allpages',
312 apnamespace: namespace,
313 apfilterredir: 'nonredirects', // do not include redirects
314 aplimit: API_LIMIT
315 },
316 'allpages',
317 callback
318 );
319 },
320
321 getPagesByPrefix( prefix, callback ) {
322 this.log( `Getting pages by ${prefix} prefix...` );
323
324 this.api.call( {
325 action: 'query',
326 list: 'allpages',
327 apprefix: prefix,
328 aplimit: API_LIMIT
329 }, function ( err, data ) {
330 callback( err, data && data.allpages || [] );
331 } );
332 },
333
334 getPagesTranscluding( template, callback ) {
335 this.log( `Getting pages from ${template}...` );
336
337 this.getAll(
338 {
339 action: 'query',
340 prop: 'transcludedin',
341 titles: template
342 },
343 ( data ) => getFirstItem( getFirstItem( data ) ).transcludedin,
344 callback
345 );
346 },
347
348 getArticle( title, redirect, callback ) {
349 let params = {
350 action: 'query',
351 prop: 'revisions',
352 rvprop: 'content',
353 rand: this.getRand()
354 };
355
356 if ( typeof redirect === 'function' ) {
357 callback = redirect;
358 redirect = undefined;
359 }
360
361 // @see https://www.mediawiki.org/wiki/API:Query#Resolving_redirects
362 if ( redirect === true ) {
363 params.redirects = '';
364 }
365
366 // both page ID or title can be provided
367 if ( typeof title === 'number' ) {
368 this.log( `Getting content of article #${title}...` );
369 params.pageids = title;
370 } else {
371 this.log( `Getting content of ${title}...` );
372 params.titles = title;
373 }
374
375 this.api.call( params, function ( err, data ) {
376 if ( err ) {
377 callback( err );
378 return;
379 }
380
381 const page = getFirstItem( data.pages ),
382 revision = page.revisions && page.revisions.shift(),
383 content = revision && revision[ '*' ],
384 redirectInfo = data.redirects && data.redirects.shift() || undefined;
385
386 callback( null, content, redirectInfo );
387 } );
388 },
389
390 getArticleRevisions( title, callback ) {
391 const params = {
392 action: 'query',
393 prop: 'revisions',
394 rvprop: [ 'ids', 'timestamp', 'size', 'flags', 'comment', 'user' ].join( '|' ),
395 rvdir: 'newer', // order by timestamp asc
396 rvlimit: API_LIMIT
397 };
398
399 // both page ID or title can be provided
400 if ( typeof title === 'number' ) {
401 this.log( `Getting revisions of article #${title}...` );
402 params.pageids = title;
403 } else {
404 this.log( `Getting revisions of ${title}...` );
405 params.titles = title;
406 }
407
408 this.getAll(
409 params,
410 function ( batch ) {
411 const page = getFirstItem( batch.pages );
412 return page.revisions;
413 },
414 callback
415 );
416 },
417
418 getArticleCategories( title, callback ) {
419 this.api.call( {
420 action: 'query',
421 prop: 'categories',
422 cllimit: API_LIMIT,
423 titles: title
424 }, function ( err, data ) {
425 if ( err ) {
426 callback( err );
427 return;
428 }
429
430 if ( data === null ) {
431 callback( new Error( `"${title}" page does not exist` ) );
432 return;
433 }
434
435 const page = getFirstItem( data.pages );
436
437 callback(
438 null,
439 ( page.categories || [] ).map( ( cat ) =>
440 // { ns: 14, title: 'Kategoria:XX wiek' }
441 cat.title
442 )
443 );
444 } );
445 },
446
447 getArticleInfo( title, options, callback ) {
448 if ( typeof options === 'function' ) { // This is the callback; options was nonexistant
449 callback = options;
450 options = {};
451 }
452 if ( !options ) {
453 options = {};
454 }
455 if ( options.inprop === undefined ) { // If not specified, get almost everything.
456 options.inprop = [
457 'associatedpage',
458 'displaytitle',
459 'notificationtimestamp',
460 'preload',
461 'protection',
462 'subjectid',
463 'talkid',
464 'url',
465 'varianttitles',
466 'visitingwatchers',
467 'watched',
468 'watchers'
469 ];
470 }
471 const params = {
472 action: 'query',
473 prop: 'info',
474 inprop: options.inprop.join( '|' ),
475 titles: []
476 };
477
478 const titles = [];
479 if ( options.intestactions && options.intestactions.length !== 0 ) {
480 params.intestactions = options.intestactions.join( '|' );
481 }
482 if ( typeof title === 'string' ) {
483 titles.push( title );
484 }
485 if ( typeof title === 'number' ) {
486 titles.push( title );
487 }
488 if ( typeof title === 'object' ) {
489 let objTitle = title;
490 for ( let key in objTitle ) {
491 titles.push( objTitle[ key ] );
492 }
493 }
494 titles.forEach( ( t ) => {
495 if ( typeof t === 'string' ) {
496 params.titles.push( t );
497 }
498 if ( typeof t === 'number' ) {
499 params.pageids.push( t );
500 }
501 } );
502
503 this.getAll(
504 params,
505 function ( batch ) {
506 const page = getFirstItem( batch.pages );
507 return page;
508 },
509 callback
510 );
511 },
512
513 search( keyword, callback ) {
514 this.getAll(
515 {
516 action: 'query',
517 list: 'search',
518 srsearch: keyword,
519 srprop: 'timestamp',
520 srlimit: 5000
521 },
522 'search',
523 callback
524 );
525 },
526
527 // get token required to perform a given action
528 getToken( title, action, callback ) {
529 this.log( `Getting ${action} token (for ${title})...` );
530
531 this.getMediaWikiVersion( ( ( err, version ) => {
532 let compare = require( 'node-version-compare' ),
533 params,
534 useTokenApi = compare( version, '1.24.0' ) > -1;
535
536 // @see https://www.mediawiki.org/wiki/API:Tokens (for MW 1.24+)
537 if ( useTokenApi ) {
538 params = {
539 action: 'query',
540 meta: 'tokens',
541 type: 'csrf'
542 };
543 } else {
544 params = {
545 action: 'query',
546 prop: 'info',
547 intoken: action,
548 titles: title
549 };
550 }
551
552 this.api.call( params, ( ( _err, data, next, raw ) => {
553 let token;
554
555 if ( _err ) {
556 callback( _err );
557 return;
558 }
559
560 if ( useTokenApi ) {
561 token = data.tokens.csrftoken.toString(); // MW 1.24+
562 } else {
563 token = getFirstItem( data.pages )[ action + 'token' ]; // older MW version
564 }
565
566 if ( !token ) {
567 const msg = raw.warnings.info[ '*' ];
568 this.log( `getToken: ${msg}` );
569 err = new Error( `Can't get "${action}" token for "${title}" page - ${msg}` );
570 token = undefined;
571 }
572
573 callback( err, token );
574 } ) );
575 } ) );
576 },
577
578 // this should only be used internally (see #84)
579 doEdit( type, title, summary, params, callback ) {
580 if ( this.dryRun ) {
581 callback( new Error( 'In dry-run mode' ) );
582 return;
583 }
584
585 // @see http://www.mediawiki.org/wiki/API:Edit
586 this.getToken( title, 'edit', ( err, token ) => {
587 if ( err ) {
588 callback( err );
589 return;
590 }
591
592 this.log( `Editing '${title}' with a summary '${summary}' (${type})...` );
593
594 const editParams = _.extend( {
595 action: 'edit',
596 bot: '',
597 title,
598 summary
599 }, params, { token } );
600
601 this.api.call( editParams, ( _err, data ) => {
602 if ( !_err && data.result && data.result === 'Success' ) {
603 this.log( 'Rev #%d created for \'%s\'', data.newrevid, data.title );
604 callback( null, data );
605 } else {
606 callback( _err || data );
607 }
608 }, 'POST' );
609 } );
610 },
611
612 edit( title, content, summary, minor, callback ) {
613 let params = {
614 text: content
615 };
616
617 if ( typeof minor === 'function' ) {
618 callback = minor;
619 minor = undefined;
620 }
621
622 if ( minor ) {
623 params.minor = '';
624 } else {
625 params.notminor = '';
626 }
627
628 this.doEdit( 'edit', title, summary, params, callback );
629 },
630
631 append( title, content, summary, callback ) {
632 let params = {
633 appendtext: content
634 };
635
636 this.doEdit( 'append', title, summary, params, callback );
637 },
638
639 prepend( title, content, summary, callback ) {
640 let params = {
641 prependtext: content
642 };
643
644 this.doEdit( 'prepend', title, summary, params, callback );
645 },
646
647 addFlowTopic( title, subject, content, callback ) {
648 if ( this.dryRun ) {
649 callback( new Error( 'In dry-run mode' ) );
650 return;
651 }
652
653 // @see http://www.mediawiki.org/wiki/API:Flow
654 this.getToken( title, 'flow', ( err, token ) => {
655 if ( err ) {
656 callback( err );
657 return;
658 }
659
660 this.log( `Adding a topic to page '${title}' with subject '${subject}'...` );
661
662 const params = {
663 action: 'flow',
664 submodule: 'new-topic',
665 page: title,
666 nttopic: subject,
667 ntcontent: content,
668 ntformat: 'wikitext',
669 bot: '',
670 token: token
671 };
672
673 this.api.call( params, ( _err, data ) => {
674 if ( !_err && data[ 'new-topic' ] && data[ 'new-topic' ].status && data[ 'new-topic' ].status === 'ok' ) {
675 this.log( 'Workflow \'%s\' created on \'%s\'', data[ 'new-topic' ].workflow, title );
676 callback( null, data );
677 } else {
678 callback( _err );
679 }
680 }, 'POST' );
681 } );
682 },
683
684 'delete'( title, reason, callback ) {
685 if ( this.dryRun ) {
686 callback( new Error( 'In dry-run mode' ) );
687 return;
688 }
689
690 // @see http://www.mediawiki.org/wiki/API:Delete
691 this.getToken( title, 'delete', ( err, token ) => {
692 if ( err ) {
693 callback( err );
694 return;
695 }
696
697 this.log( 'Deleting \'%s\' because \'%s\'...', title, reason );
698
699 this.api.call( {
700 action: 'delete',
701 title,
702 reason,
703 token
704 }, ( _err, data ) => {
705 if ( !_err && data.title && data.reason ) {
706 callback( null, data );
707 } else {
708 callback( _err );
709 }
710 }, 'POST' );
711 } );
712 },
713
714 protect( title, protections, options, callback ) {
715 // @see https://www.mediawiki.org/wiki/API:Protect
716 if ( this.dryRun ) {
717 callback( new Error( 'In dry-run mode' ) );
718 return;
719 }
720
721 if ( typeof options === 'function' ) {
722 // This is the callback; options was nonexistent.
723 callback = options;
724 options = {};
725 }
726
727 if ( !options ) {
728 options = {};
729 }
730
731 const params = {
732 action: 'protect'
733 };
734
735 if ( typeof title === 'number' ) {
736 params.pageid = title;
737 } else {
738 params.title = title;
739 }
740
741 const formattedProtections = [];
742 const expiries = [];
743 let failed = false;
744 Array.from( protections ).forEach( ( protection ) => {
745 if ( !protection.type ) {
746 callback( new Error( 'Invalid protection. An action type must be specified.' ) );
747 failed = true;
748 return;
749 }
750
751 const level = protection.level ? protection.level : 'all';
752 formattedProtections.push( `${protection.type}=${level}` );
753
754 if ( protection.expiry ) {
755 expiries.push( protection.expiry );
756 } else {
757 // If no expiry was specified, then set the expiry to never.
758 expiries.push( 'never' );
759 }
760 } );
761
762 if ( failed ) {
763 return;
764 }
765
766 params.protections = formattedProtections.join( '|' );
767 params.expiry = expiries.join( '|' );
768
769 if ( options.reason ) {
770 params.reason = options.reason;
771 }
772
773 if ( options.tags ) {
774 if ( Array.isArray( options.tags ) ) {
775 params.tags = options.tags.join( '|' );
776 } else if ( typeof options.tags === 'string' ) {
777 params.tags = options.tags;
778 }
779 }
780
781 if ( options.cascade ) {
782 params.cascade = options.cascade ? 1 : 1;
783 }
784
785 if ( options.watchlist && typeof options.watchlist === 'string' ) {
786 params.watchlist = options.watchlist;
787 }
788
789 // Params have been generated. Now fetch the csrf token and call the API.
790 this.getToken( title, 'csrf', ( err, token ) => {
791 if ( err ) {
792 callback( err );
793 return;
794 }
795
796 params.token = token;
797
798 this.api.call( params, ( _err, data ) => {
799 if ( !_err && data.title && data.protections ) {
800 callback( null, data );
801 } else {
802 callback( _err );
803 }
804 }, 'POST' );
805 } );
806 },
807
808 purge( titles, callback ) {
809 // @see https://www.mediawiki.org/wiki/API:Purge
810 const params = {
811 action: 'purge'
812 };
813
814 if ( this.dryRun ) {
815 callback( new Error( 'In dry-run mode' ) );
816 return;
817 }
818
819 if ( typeof titles === 'string' && titles.indexOf( 'Category:' ) === 0 ) {
820 // @see https://docs.moodle.org/archive/pl/api.php?action=help&modules=purge
821 // @see https://docs.moodle.org/archive/pl/api.php?action=help&modules=query%2Bcategorymembers
822 // since MW 1.21 - @see https://github.com/wikimedia/mediawiki/commit/62216932c197f1c248ca2d95bc230f87a79ccd71
823 this.log( 'Purging all articles in category \'%s\'...', titles );
824 params.generator = 'categorymembers';
825 params.gcmtitle = titles;
826 } else {
827 // cast a single item to an array
828 titles = Array.isArray( titles ) ? titles : [ titles ];
829
830 // both page IDs or titles can be provided
831 if ( typeof titles[ 0 ] === 'number' ) {
832 this.log( 'Purging the list of article IDs: #%s...', titles.join( ', #' ) );
833 params.pageids = titles.join( '|' );
834 } else {
835 this.log( 'Purging the list of articles: \'%s\'...', titles.join( '\', \'' ) );
836 params.titles = titles.join( '|' );
837 }
838 }
839
840 this.api.call(
841 params,
842 ( err, data ) => {
843 if ( !err ) {
844 data.forEach( ( page ) => {
845 if ( typeof page.purged !== 'undefined' ) {
846 this.log( 'Purged "%s"', page.title );
847 }
848 } );
849 }
850
851 callback( err, data );
852 },
853 'POST'
854 );
855 },
856
857 sendEmail( username, subject, text, callback ) {
858 if ( this.dryRun ) {
859 callback( new Error( 'In dry-run mode' ) );
860 return;
861 }
862
863 // @see http://www.mediawiki.org/wiki/API:Email
864 this.getToken( `User:${username}`, 'email', ( err, token ) => {
865 if ( err ) {
866 callback( err );
867 return;
868 }
869
870 this.log( 'Sending an email to \'%s\' with subject \'%s\'...', username, subject );
871
872 this.api.call( {
873 action: 'emailuser',
874 target: username,
875 subject,
876 text,
877 ccme: '',
878 token
879 }, ( _err, data ) => {
880 if ( !_err && data.result && data.result === 'Success' ) {
881 this.log( 'Email sent' );
882 callback( null, data );
883 } else {
884 callback( _err );
885 }
886 }, 'POST' );
887 } );
888 },
889
890 getUserContribs( options, callback ) {
891 options = options || {};
892
893 this.api.call( {
894 action: 'query',
895 list: 'usercontribs',
896 ucuser: options.user,
897 ucstart: options.start,
898 uclimit: API_LIMIT,
899 ucnamespace: options.namespace || ''
900 }, function ( err, data, next ) {
901 callback( err, data && data.usercontribs || [], next && next.ucstart || false );
902 } );
903 },
904
905 whoami( callback ) {
906 // @see http://www.mediawiki.org/wiki/API:Meta#userinfo_.2F_ui
907 const props = [
908 'groups',
909 'rights',
910 'ratelimits',
911 'editcount',
912 'realname',
913 'email'
914 ];
915
916 this.api.call( {
917 action: 'query',
918 meta: 'userinfo',
919 uiprop: props.join( '|' )
920 }, function ( err, data ) {
921 if ( !err && data && data.userinfo ) {
922 callback( null, data.userinfo );
923 } else {
924 callback( err );
925 }
926 } );
927 },
928
929 whois( username, callback ) {
930 this.whoare( [ username ], function ( err, usersinfo ) {
931 callback( err, usersinfo && usersinfo[ 0 ] );
932 } );
933 },
934
935 whoare( usernames, callback ) {
936 // @see https://www.mediawiki.org/wiki/API:Users
937 const props = [
938 'blockinfo',
939 'groups',
940 'implicitgroups',
941 'rights',
942 'editcount',
943 'registration',
944 'emailable',
945 'gender'
946 ];
947
948 this.api.call( {
949 action: 'query',
950 list: 'users',
951 ususers: usernames.join( '|' ),
952 usprop: props.join( '|' )
953 }, function ( err, data ) {
954 if ( !err && data && data.users ) {
955 callback( null, data.users );
956 } else {
957 callback( err );
958 }
959 } );
960 },
961
962 createAccount( username, password, callback ) {
963 // @see https://www.mediawiki.org/wiki/API:Account_creation
964 this.log( `creating account ${username}` );
965 this.api.call( {
966 action: 'query',
967 meta: 'tokens',
968 type: 'createaccount'
969 }, ( err, data ) => {
970 this.api.call( {
971 action: 'createaccount',
972 createreturnurl: `${this.api.protocol}://${this.api.server}:${this.api.port}/`,
973 createtoken: data.tokens.createaccounttoken,
974 username: username,
975 password: password,
976 retype: password
977 }, ( _err, _data ) => {
978 if ( _err ) {
979 callback( _err );
980 return;
981 }
982 callback( _data );
983 }, 'POST' );
984 } );
985 },
986
987 move( from, to, summary, callback ) {
988 if ( this.dryRun ) {
989 callback( new Error( 'In dry-run mode' ) );
990 return;
991 }
992
993 // @see http://www.mediawiki.org/wiki/API:Move
994 this.getToken( from, 'move', ( err, token ) => {
995 if ( err ) {
996 callback( err );
997 return;
998 }
999
1000 this.log( 'Moving \'%s\' to \'%s\' because \'%s\'...', from, to, summary );
1001
1002 this.api.call( {
1003 action: 'move',
1004 from,
1005 to,
1006 bot: '',
1007 reason: summary,
1008 token
1009 }, ( _err, data ) => {
1010 if ( !_err && data.from && data.to && data.reason ) {
1011 callback( null, data );
1012 } else {
1013 callback( _err );
1014 }
1015 }, 'POST' );
1016 } );
1017 },
1018
1019 getImages( start, callback ) {
1020 this.api.call( {
1021 action: 'query',
1022 list: 'allimages',
1023 aifrom: start,
1024 ailimit: API_LIMIT
1025 }, function ( err, data, next ) {
1026 callback( err, ( ( data && data.allimages ) || [] ), ( ( next && next.aifrom ) || false ) );
1027 } );
1028 },
1029
1030 getImagesFromArticle( title, callback ) {
1031 this.api.call( {
1032 action: 'query',
1033 prop: 'images',
1034 titles: title
1035 }, function ( err, data ) {
1036 const page = getFirstItem( data && data.pages || [] );
1037 callback( err, ( page && page.images ) || [] );
1038 } );
1039 },
1040
1041 getImagesFromArticleWithOptions( title, options, callback ) {
1042 let requestOptions = {
1043 action: 'query',
1044 prop: 'images',
1045 titles: title
1046 };
1047 if ( !options || typeof ( options ) !== 'object' ) {
1048 callback( new Error( 'Incorrect options parameter' ) );
1049 }
1050 Object.keys( options ).forEach( function ( x ) {
1051 requestOptions[ x ] = options[ x ];
1052 } );
1053 this.api.call( requestOptions, function ( err, data ) {
1054 const page = getFirstItem( data && data.pages || [] );
1055 callback( err, ( page && page.images ) || [] );
1056 } );
1057 },
1058
1059 getImageUsage( filename, callback ) {
1060 this.api.call( {
1061 action: 'query',
1062 list: 'imageusage',
1063 iutitle: filename,
1064 iulimit: API_LIMIT
1065 }, function ( err, data ) {
1066 callback( err, ( data && data.imageusage ) || [] );
1067 } );
1068 },
1069
1070 getImageInfo( filename, callback ) {
1071 const props = [
1072 'timestamp',
1073 'user',
1074 'metadata',
1075 'size',
1076 'url'
1077 ];
1078
1079 this.api.call( {
1080 action: 'query',
1081 titles: filename,
1082 prop: 'imageinfo',
1083 iiprop: props.join( '|' )
1084 }, function ( err, data ) {
1085 const image = getFirstItem( data && data.pages || [] ),
1086 imageinfo = image && image.imageinfo && image.imageinfo[ 0 ];
1087
1088 // process EXIF metadata into key / value structure
1089 if ( !err && imageinfo && imageinfo.metadata ) {
1090 imageinfo.exif = {};
1091
1092 imageinfo.metadata.forEach( function ( entry ) {
1093 imageinfo.exif[ entry.name ] = entry.value;
1094 } );
1095 }
1096
1097 callback( err, imageinfo );
1098 } );
1099 },
1100
1101 getLog( type, start, callback ) {
1102 let params = {
1103 action: 'query',
1104 list: 'logevents',
1105 lestart: start,
1106 lelimit: API_LIMIT
1107 };
1108
1109 if ( type.indexOf( '/' ) > 0 ) {
1110 // Filter log entries to only this type.
1111 params.leaction = type;
1112 } else {
1113 // Filter log actions to only this action. Overrides letype. In the list of possible values,
1114 // values with the asterisk wildcard such as action/* can have different strings after the slash (/).
1115 params.letype = type;
1116 }
1117
1118 this.api.call( params, function ( err, data, next ) {
1119 if ( next && next.lecontinue ) {
1120 // 20150101124329|22700494
1121 next = next.lecontinue.split( '|' ).shift();
1122 }
1123
1124 callback( err, ( ( data && data.logevents ) || [] ), next );
1125 } );
1126 },
1127
1128 expandTemplates( text, title, callback ) {
1129 this.api.call( {
1130 action: 'expandtemplates',
1131 text,
1132 title,
1133 generatexml: 1
1134 }, function ( err, data, next, raw ) {
1135 const xml = getFirstItem( raw.parsetree );
1136 callback( err, xml );
1137 }, 'POST' );
1138 },
1139
1140 parse( text, title, callback ) {
1141 this.api.call( {
1142 action: 'parse',
1143 text,
1144 title,
1145 contentmodel: 'wikitext',
1146 generatexml: 1
1147 }, function ( err, data, next, raw ) {
1148 if ( err ) {
1149 callback( err );
1150 return;
1151 }
1152 const xml = getFirstItem( raw.parse.text ),
1153 images = raw.parse.images;
1154 callback( err, xml, images );
1155 }, 'POST' );
1156 },
1157
1158 getRecentChanges( start, callback ) {
1159 const props = [
1160 'title',
1161 'timestamp',
1162 'comments',
1163 'user',
1164 'flags',
1165 'sizes'
1166 ];
1167
1168 this.api.call( {
1169 action: 'query',
1170 list: 'recentchanges',
1171 rcprop: props.join( '|' ),
1172 rcstart: start || '',
1173 rclimit: API_LIMIT
1174 }, function ( err, data, next ) {
1175 callback( err, ( ( data && data.recentchanges ) || [] ), ( ( next && next.rcstart ) || false ) );
1176 } );
1177 },
1178
1179 getSiteInfo( props, callback ) {
1180 // @see http://www.mediawiki.org/wiki/API:Siteinfo
1181 if ( typeof props === 'string' ) {
1182 props = [ props ];
1183 }
1184
1185 this.api.call( {
1186 action: 'query',
1187 meta: 'siteinfo',
1188 siprop: props.join( '|' )
1189 }, function ( err, data ) {
1190 callback( err, data );
1191 } );
1192 },
1193
1194 getSiteStats( callback ) {
1195 const prop = 'statistics';
1196
1197 this.getSiteInfo( prop, function ( err, info ) {
1198 callback( err, info && info[ prop ] );
1199 } );
1200 },
1201
1202 getMediaWikiVersion( callback ) {
1203 // cache it for each instance of the client
1204 // we will call it multiple times for features detection
1205 if ( typeof this._mwVersion !== 'undefined' ) { // eslint-disable-line no-underscore-dangle
1206 callback( null, this._mwVersion ); // eslint-disable-line no-underscore-dangle
1207 return;
1208 }
1209
1210 this.getSiteInfo( [ 'general' ], ( ( err, info ) => {
1211 let version;
1212
1213 if ( err ) {
1214 callback( err );
1215 return;
1216 }
1217
1218 version = info && info.general && info.general.generator; // e.g. "MediaWiki 1.27.0-wmf.19"
1219 version = version.match( /[\d.]+/ )[ 0 ]; // 1.27.0
1220
1221 this.log( 'Detected MediaWiki v%s', version );
1222
1223 // cache it
1224 this._mwVersion = version; // eslint-disable-line no-underscore-dangle
1225 callback( null, this._mwVersion ); // eslint-disable-line no-underscore-dangle
1226 } ) );
1227 },
1228
1229 getQueryPage( queryPage, callback ) {
1230 // @see http://www.mediawiki.org/wiki/API:Querypage
1231 this.api.call( {
1232 action: 'query',
1233 list: 'querypage',
1234 qppage: queryPage,
1235 qplimit: API_LIMIT
1236 }, ( err, data ) => {
1237 if ( !err && data && data.querypage ) {
1238 this.log( '%s data was generated %s', queryPage, data.querypage.cachedtimestamp );
1239 callback( null, data.querypage.results || [] );
1240 } else {
1241 callback( err, [] );
1242 }
1243 } );
1244 },
1245
1246 upload( filename, content, extraParams, callback ) {
1247 let params = {
1248 action: 'upload',
1249 ignorewarnings: '',
1250 filename,
1251 file: ( typeof content === 'string' ) ? Buffer.from( content, 'binary' ) : content,
1252 text: ''
1253 };
1254
1255 if ( this.dryRun ) {
1256 callback( new Error( 'In dry-run mode' ) );
1257 return;
1258 }
1259
1260 if ( typeof extraParams === 'object' ) {
1261 params = _.extend( params, extraParams );
1262 } else { // it's summary (comment)
1263 params.comment = extraParams;
1264 }
1265
1266 // @see http://www.mediawiki.org/wiki/API:Upload
1267 this.getToken( `File:${filename}`, 'edit', ( err, token ) => {
1268 if ( err ) {
1269 callback( err );
1270 return;
1271 }
1272
1273 this.log( 'Uploading %s kB as File:%s...', ( content.length / 1024 ).toFixed( 2 ), filename );
1274
1275 params.token = token;
1276 this.api.call( params, ( _err, data ) => {
1277 if ( data && data.result && data.result === 'Success' ) {
1278 this.log( 'Uploaded as <%s>', data.imageinfo.descriptionurl );
1279 callback( null, data );
1280 } else {
1281 callback( _err );
1282 }
1283 }, 'UPLOAD' /* fake method to set a proper content type for file uploads */ );
1284 } );
1285 },
1286
1287 uploadByUrl( filename, url, summary, callback ) {
1288 this.api.fetchUrl( url, ( error, content ) => {
1289 if ( error ) {
1290 callback( error, content );
1291 return;
1292 }
1293
1294 this.upload( filename, content, summary, callback );
1295 }, 'binary' /* use binary-safe fetch */ );
1296 },
1297
1298 // Wikia-specific API entry-point
1299 uploadVideo( fileName, url, callback ) {
1300 const parseVideoUrl = require( './utils' ).parseVideoUrl;
1301 const parsed = parseVideoUrl( url );
1302
1303 if ( parsed === null ) {
1304 callback( new Error( 'Not supported URL provided' ) );
1305 return;
1306 }
1307
1308 let provider = parsed[ 0 ], videoId = parsed[ 1 ];
1309
1310 this.getToken( `File:${fileName}`, 'edit', ( err, token ) => {
1311 if ( err ) {
1312 callback( err );
1313 return;
1314 }
1315
1316 this.log( 'Uploading <%s> (%s provider with video ID %s)', url, provider, videoId );
1317
1318 this.api.call( {
1319 action: 'addmediapermanent',
1320 title: fileName,
1321 provider: provider,
1322 videoId: videoId,
1323 token: token
1324 }, callback, 'POST' /* The addmediapermanent module requires a POST request */ );
1325 } );
1326 },
1327
1328 getExternalLinks( title, callback ) {
1329 this.api.call( {
1330 action: 'query',
1331 prop: 'extlinks',
1332 titles: title,
1333 ellimit: API_LIMIT
1334 }, function ( err, data ) {
1335 callback( err, ( data && getFirstItem( data.pages ).extlinks ) || [] );
1336 } );
1337 },
1338
1339 getBacklinks( title, callback ) {
1340 this.api.call( {
1341 action: 'query',
1342 list: 'backlinks',
1343 blnamespace: 0,
1344 bltitle: title,
1345 bllimit: API_LIMIT
1346 }, function ( err, data ) {
1347 callback( err, ( data && data.backlinks ) || [] );
1348 } );
1349 },
1350
1351 // utils section
1352 getTemplateParamFromXml( tmplXml, paramName ) {
1353 paramName = paramName
1354 .trim()
1355 .replace( '-', '\\-' );
1356
1357 const re = new RegExp( `<part><name>${paramName}\\s*</name>=<value>([^>]+)</value>` ),
1358 matches = tmplXml.match( re );
1359
1360 return matches && matches[ 1 ].trim() || false;
1361 },
1362
1363 fetchUrl( url, callback, encoding ) {
1364 this.api.fetchUrl( url, callback, encoding );
1365 },
1366
1367 diff( prev, current ) {
1368 let colors = require( 'ansicolors' ),
1369 jsdiff = require( 'diff' ),
1370 diff = jsdiff.diffChars( prev, current ),
1371 res = '';
1372
1373 diff.forEach( function ( part ) {
1374 const color = part.added ? 'green' :
1375 part.removed ? 'red' : 'brightBlack';
1376
1377 res += colors[ color ]( part.value );
1378 } );
1379
1380 return res;
1381 }
1382};
1383
1384// Wikia-specific methods (issue #56)
1385// @see http://www.wikia.com/api/v1
1386Bot.prototype.wikia = {
1387 API_PREFIX: '/api/v1',
1388
1389 call( path, params, callback ) {
1390 let url = this.api.protocol + '://' + this.api.server + this.wikia.API_PREFIX + path;
1391
1392 if ( typeof params === 'function' ) {
1393 callback = params;
1394 this.log( 'Wikia API call:', path );
1395 } else if ( typeof params === 'object' ) {
1396 url += `?${querystring.stringify( params )}`;
1397 this.log( 'Wikia API call:', path, params );
1398 }
1399
1400 this.fetchUrl( url, function ( err, res ) {
1401 const data = JSON.parse( res );
1402 callback( err, data );
1403 } );
1404 },
1405
1406 getWikiVariables( callback ) {
1407 this.call( '/Mercury/WikiVariables', function ( err, res ) {
1408 callback( err, res.data );
1409 } );
1410 },
1411
1412 getUser( ids, callback ) {
1413 this.getUsers( [ ids ], function ( err, users ) {
1414 callback( err, users && users[ 0 ] );
1415 } );
1416 },
1417
1418 getUsers( ids, callback ) {
1419 this.call( '/User/Details', {
1420 ids: ids.join( ',' ),
1421 size: 50
1422 }, function ( err, res ) {
1423 callback( err, res.items );
1424 } );
1425 }
1426};
1427
1428module.exports = Bot;