UNPKG

7.44 kBJavaScriptView Raw
1/**
2 * Helper "class" for accessing MediaWiki API and handling cookie-based session
3 */
4'use strict';
5
6// introspect package.json to get module version
7const VERSION = require( '../package' ).version;
8
9// @see https://github.com/caolan/async
10const async = require( 'async' );
11
12// @see https://github.com/mikeal/request
13const request = require( 'request' );
14
15function doRequest( params, callback, method, done ) {
16 // store requested action - will be used when parsing a response
17 const actionName = params.action;
18 // "request" options
19 const options = {
20 method: method || 'GET',
21 proxy: this.proxy || false,
22 jar: this.jar,
23 headers: {
24 'User-Agent': this.userAgent
25 }
26 };
27
28 // HTTP request parameters
29 params = params || {};
30
31 // force JSON format
32 params.format = 'json';
33
34 // handle uploads
35 if ( method === 'UPLOAD' ) {
36 options.method = 'POST';
37
38 const CRLF = '\r\n',
39 postBody = [],
40 boundary = `nodemw${Math.random().toString().slice( 2 )}`;
41
42 // encode each field
43 Object.keys( params ).forEach( function ( fieldName ) {
44 const value = params[ fieldName ];
45
46 postBody.push( `--${boundary}` );
47 postBody.push( CRLF );
48
49 if ( typeof value === 'string' ) {
50 // properly encode UTF8 in binary-safe POST data
51 postBody.push( `Content-Disposition: form-data; name="${fieldName}"` );
52 postBody.push( CRLF );
53 postBody.push( CRLF );
54 postBody.push( Buffer.from( value, 'utf8' ) );
55 } else {
56 // send attachment
57 postBody.push( `Content-Disposition: form-data; name="${fieldName}"; filename="foo"` );
58 postBody.push( CRLF );
59 postBody.push( CRLF );
60 postBody.push( value );
61 }
62
63 postBody.push( CRLF );
64 } );
65
66 postBody.push( `--${boundary}--` );
67
68 // encode post data
69 options.headers[ 'content-type' ] = `multipart/form-data; boundary=${boundary}`;
70 options.body = postBody;
71
72 params = {};
73 }
74
75 // form an URL to API
76 options.url = this.formatUrl( {
77 protocol: this.protocol,
78 port: this.port,
79 hostname: this.server,
80 pathname: this.path + '/api.php',
81 query: ( options.method === 'GET' ) ? params : {}
82 } );
83
84 // POST all parameters (avoid "request string too long" errors)
85 if ( method === 'POST' ) {
86 options.form = params;
87 }
88
89 this.logger.debug( 'API action: %s', actionName );
90 this.logger.debug( '%s <%s>', options.method, options.url );
91
92 if ( options.form ) {
93 this.logger.debug( 'POST fields: %s', Object.keys( options.form ).join( ', ' ) );
94 }
95
96 request( options, ( error, response, body ) => {
97 response = response || {};
98
99 if ( error ) {
100 this.logger.error( 'Request to API failed: %s', error );
101 callback( new Error( `Request to API failed: ${error}` ) );
102 done();
103 return;
104 }
105
106 if ( response.statusCode !== 200 ) {
107 this.logger.error( 'Request to API failed: HTTP status code was %d for <%s>', response.statusCode || 'unknown', options.url );
108 this.logger.debug( 'Body: %s', body );
109 this.logger.error( 'Stacktrace', new Error().stack );
110
111 callback( new Error( `Request to API failed: HTTP status code was ${response.statusCode}` ) );
112 done();
113 return;
114 }
115
116 // parse response
117 let data,
118 info,
119 next;
120
121 try {
122 data = JSON.parse( body );
123 info = data && data[ actionName ];
124
125 // acfrom=Zeppelin Games
126 next = data && data[ 'query-continue' ] && data[ 'query-continue' ][ params.list || params.prop ];
127
128 // handle the new continuing queries introduced in MW 1.21
129 // (and to be made default in MW 1.26)
130 // issue #64
131 // @see https://www.mediawiki.org/wiki/API:Query#Continuing_queries
132 if ( !next ) {
133 // cmcontinue=page|5820414e44205920424f534f4e53|12253446, continue=-||
134 next = data && data.continue;
135 }
136 } catch ( e ) {
137 this.logger.error( 'Error parsing JSON response: %s', body );
138
139 callback( new Error( 'Error parsing JSON response' ) );
140 done();
141 return;
142 }
143
144 // if (!callback) data.error = {info: 'foo'}; // debug
145
146 if ( data && !data.error ) {
147 if ( next ) {
148 this.logger.debug( 'There\'s more data' );
149 this.logger.debug( next );
150 }
151
152 callback( null, info, next, data );
153 } else if ( data.error ) {
154 this.logger.error( 'Error returned by API: %s', data.error.info );
155 this.logger.error( 'Raw error', data.error );
156
157 callback( new Error( `Error returned by API: ${data.error.info}` ) );
158 }
159 done();
160 } );
161}
162
163function Api( options ) {
164 this.protocol = options.protocol || 'http';
165 this.port = options.port;
166 this.server = options.server;
167 this.path = options.path;
168 this.proxy = options.proxy;
169 this.jar = request.jar(); // create new cookie jar for each instance
170
171 this.debug = options.debug;
172
173 // set up logging
174 const winston = require( 'winston' ),
175 // file logging
176 path = require( 'path' ),
177 fs = require( 'fs' ),
178 logDir = path.dirname( process.argv[ 1 ] ) + '/log/',
179 logFile = logDir + path.basename( process.argv[ 1 ], '.js' ) + '.log',
180 // how many tasks (i.e. requests) to run in parallel
181 concurrency = options.concurrency || 3;
182
183 this.logger = winston.createLogger();
184
185 // console logging
186 this.logger.add( new winston.transports.Console( {
187 level: this.debug ? 'debug' : 'error'
188 } ) );
189
190 if ( fs.existsSync( logDir ) ) {
191 this.logger.add( new winston.transports.File( {
192 colorize: true,
193 filename: logFile,
194 json: false,
195 level: this.debug ? 'debug' : 'info'
196 } ) );
197 }
198
199 // requests queue
200 // @see https://github.com/caolan/async#queue
201 this.queue = async.queue( function ( task, callback ) {
202 // process the task (and call the provided callback once it's completed)
203 task( callback );
204 }, concurrency );
205
206 // HTTP client
207 this.formatUrl = require( 'url' ).format;
208
209 this.userAgent = options.userAgent || ( `nodemw/${VERSION} (node.js ${process.version}; ${process.platform} ${process.arch})` );
210 this.version = VERSION;
211
212 // debug info
213 this.info( process.argv.join( ' ' ) );
214 this.info( this.userAgent );
215
216 let port = this.port ? `:${this.port}` : '';
217
218 this.info( `Using <${this.protocol}://${this.server}${port}${this.path}/api.php> as API entry point` );
219 this.info( '----' );
220}
221
222// public interface
223Api.prototype = {
224 log() {
225 this.logger.log.apply( this.logger, arguments );
226 },
227
228 info() {
229 this.logger.info.apply( this.logger, arguments );
230 },
231
232 warn() {
233 this.logger.warn.apply( this.logger, arguments );
234 },
235
236 error() {
237 this.logger.error.apply( this.logger, arguments );
238 },
239
240 // adds request to the queue
241 call( params, callback, method ) {
242 this.queue.push( ( done ) => {
243 doRequest.apply( this, [ params, callback, method, done ] );
244 } );
245 },
246
247 // fetch an external resource
248 fetchUrl( url, callback, encoding ) {
249 encoding = encoding || 'utf-8';
250
251 // add a request to the queue
252 this.queue.push( ( done ) => {
253 this.info( 'Fetching <%s> (as %s)...', url, encoding );
254
255 const options = {
256 url,
257 method: 'GET',
258 proxy: this.proxy || false,
259 jar: this.jar,
260 encoding: ( encoding === 'binary' ) ? null : encoding,
261 headers: {
262 'User-Agent': this.userAgent
263 }
264 };
265
266 request( options, ( error, response, body ) => {
267 if ( !error && response.statusCode === 200 ) {
268 this.info( '<%s>: fetched %s kB', url, ( body.length / 1024 ).toFixed( 2 ) );
269 callback( null, body );
270 } else {
271 if ( !error ) {
272 error = new Error( `HTTP status ${response.statusCode}` );
273 }
274
275 this.error( `Failed to fetch <${url}>` );
276 this.error( error.message );
277 callback( error, body );
278 }
279
280 done();
281 } );
282 } );
283 }
284};
285
286module.exports = Api;