mwn
Advanced tools
Comparing version 0.7.0 to 0.7.1
{ | ||
"name": "mwn", | ||
"version": "0.7.0", | ||
"description": "MediaWiki bot framework for NodeJS", | ||
"version": "0.7.1", | ||
"description": "MediaWiki bot framework for Node.js", | ||
"main": "./src/bot.js", | ||
@@ -12,3 +12,3 @@ "scripts": { | ||
"type": "git", | ||
"url": "github.com/siddharthvp/mwn" | ||
"url": "https://github.com/siddharthvp/mwn" | ||
}, | ||
@@ -15,0 +15,0 @@ "keywords": [ |
# mwn | ||
[![NPM version](https://img.shields.io/npm/v/mwn.svg)](https://www.npmjs.com/package/mwn) | ||
**mwn** is a modern MediaWiki bot framework in NodeJS, orginally adapted from [mwbot](https://github.com/Fannon/mwbot). | ||
Development status: **Unstable**. Versioning: while mwn is in version 0, changes may be made to the public interface with a change in the minor version number. | ||
Development status: Unstable. Versioning: while mwn is in version 0, changes may be made to the public interface with a change in the minor version number. | ||
@@ -38,6 +39,11 @@ Documentation given below is incomplete. There are a number of additional classes such as `bot.title`, `bot.wikitext`, `bot.page`, etc that provide useful functionality but aren't documented. | ||
#### Set up a bot password | ||
#### MediaWiki version | ||
mwn is written for and tested on the latest version of MediaWiki used on WMF wikis. | ||
To be able to login to the wiki, you have to set up a bot password using the wiki's [Special:BotPasswords](https://en.wikipedia.org/wiki/Special:BotPasswords) page. | ||
#### Set up a bot password or OAuth credentials | ||
mwn supports authentication via both BotPasswords and via OAuth. Use of OAuth is recommended as it does away the need for separate API requests for logging in, and is also a bit more secure. | ||
Bot passwords may be a bit easier to set up. To generate one, go to the wiki's [Special:BotPasswords](https://en.wikipedia.org/wiki/Special:BotPasswords) page. | ||
If you're migrating from mwbot, note that: | ||
@@ -51,14 +57,23 @@ - `edit` in mwbot is different from `edit` in mwn. You want to use `save` instead. | ||
```js | ||
const bot = new mwn(); | ||
const bot = new mwn({ | ||
apiUrl: 'https://en.wikipedia.org/w/api.php', | ||
username: 'YourBotUsername', | ||
password: 'YourBotPassword' | ||
}); | ||
await bot.login(); | ||
``` | ||
Log in to the bot: | ||
Or to use OAuth: | ||
```js | ||
bot.login({ | ||
apiUrl: 'https://en.wikipedia.org/w/api.php', | ||
username: 'YourBotUsername', | ||
password: 'YourBotPassword' | ||
const bot = new mwn({ | ||
apiUrl: 'https://en.wikipedia.org/w/api.php', | ||
oauth_consumer_token: "16_DIGIT_ALPHANUMERIC_KEY", | ||
oauth_consumer_secret: "20_DIGIT_ALPHANUMERIC_KEY", | ||
oauth_access_token: "16_DIGIT_ALPHANUMERIC_KEY", | ||
oauth_access_secret: "20_DIGIT_ALPHANUMERIC_KEY" | ||
}); | ||
bot.initOAuth(); // does not involve an API call | ||
// Any errors in authentication will surface when the first actual API call is made | ||
``` | ||
Set default parameters to be sent to be included in every API request: | ||
@@ -276,2 +291,2 @@ ```js | ||
``` | ||
Note that `seriesBatchOperation` with delay=0 is same as `batchOperation` with concurrency=1. | ||
Note that `seriesBatchOperation` with delay=0 is same as `batchOperation` with concurrency=1. |
203
src/bot.js
@@ -105,6 +105,6 @@ /** | ||
this.defaultOptions = { | ||
// suppress messages, except for error messages | ||
// suppress messages, except for error messages and warnings | ||
silent: false, | ||
// site API url, example https://en.wikipedia.org/w/api.php | ||
// site API url, example "https://en.wikipedia.org/w/api.php" | ||
apiUrl: null, | ||
@@ -150,2 +150,5 @@ | ||
// suppress logging of warnings received from the API | ||
suppressAPIWarnings: false, | ||
// options for the edit() function | ||
@@ -252,3 +255,3 @@ editConfig: { | ||
static async init(config) { | ||
var bot = new mwn(config); | ||
const bot = new mwn(config); | ||
if (bot._usingOAuth()) { | ||
@@ -383,3 +386,3 @@ bot.initOAuth(); | ||
if (!requestOptions.url) { | ||
var err = new Error('No URL provided!'); | ||
const err = new Error('No URL provided!'); | ||
err.disableRetry = true; | ||
@@ -411,3 +414,3 @@ return Promise.reject(err); | ||
var getOrPost = function(data) { | ||
const getOrPost = function (data) { | ||
if (data.action === 'query') { | ||
@@ -433,3 +436,3 @@ return 'get'; | ||
const MULTIPART_THRESHOLD = 8000; | ||
var hasLongFields = false; | ||
let hasLongFields = false; | ||
@@ -468,3 +471,3 @@ // pre-process params: | ||
var contentTypeGiven = customRequestOptions.headers && | ||
const contentTypeGiven = customRequestOptions.headers && | ||
customRequestOptions.headers['Content-Type']; | ||
@@ -475,3 +478,3 @@ | ||
// use multipart/form-data | ||
var form = new formData(); | ||
let form = new formData(); | ||
for (let [key, val] of Object.entries(params)) { | ||
@@ -601,3 +604,3 @@ if (val.stream) { | ||
if (response.warnings && !this.suppressAPIWarnings) { | ||
if (response.warnings && !this.options.suppressAPIWarnings) { | ||
for (let [key, info] of Object.entries(response.warnings)) { | ||
@@ -629,3 +632,3 @@ log(`[W] Warning received from API: ${key}: ${info.warnings}`); | ||
dieWithError(response, requestOptions) { | ||
var err = new Error(response.error.code + ': ' + response.error.info); | ||
let err = new Error(response.error.code + ': ' + response.error.info); | ||
// Enhance error object with additional information | ||
@@ -854,3 +857,3 @@ err.errorResponse = true; | ||
} catch(e) { | ||
return Promise.reject('invalidjson'); | ||
return this.rejectWithErrorCode('invalidjson'); | ||
} | ||
@@ -872,3 +875,3 @@ }); | ||
* | ||
* @returns {Promise} | ||
* @returns {Promise<{{title: string, revisions: ({content: string})[]}}>} | ||
*/ | ||
@@ -884,3 +887,3 @@ read(titles, options) { | ||
typeof titles[0] === 'number' ? 'pageids' : 'titles').then(jsons => { | ||
var data = jsons.reduce((data, json) => { | ||
let data = jsons.reduce((data, json) => { | ||
json.query.pages.forEach(pg => { | ||
@@ -903,3 +906,3 @@ if (pg.revisions) { | ||
prop: 'revisions', | ||
rvprop: 'content', | ||
rvprop: 'content|timestamp', | ||
rvslots: 'main', | ||
@@ -945,29 +948,35 @@ redirects: '1' | ||
var basetimestamp, curtimestamp; | ||
let basetimestamp, curtimestamp; | ||
return this.request(merge({ | ||
return this.request({ | ||
action: 'query', | ||
...makeTitles(title), | ||
prop: 'revisions', | ||
rvprop: ['content', 'timestamp'], | ||
rvslots: 'main', | ||
formatversion: '2', | ||
curtimestamp: !0 | ||
}, makeTitles(title))).then(data => { | ||
var page, revision; | ||
}).then(data => { | ||
let page, revision, revisionContent; | ||
if (!data.query || !data.query.pages) { | ||
return Promise.reject('unknown'); | ||
return this.rejectWithErrorCode('unknown'); | ||
} | ||
page = data.query.pages[0]; | ||
if (!page || page.invalid) { | ||
return Promise.reject('invalidtitle'); | ||
return this.rejectWithErrorCode('invalidtitle'); | ||
} | ||
if (page.missing) { | ||
return Promise.reject('nocreate-missing'); | ||
return this.rejectWithErrorCode('nocreate-missing'); | ||
} | ||
revision = page.revisions[0]; | ||
try { | ||
revisionContent = revision.slots.main.content; | ||
} catch(err) { | ||
return this.rejectWithErrorCode('unknown'); | ||
} | ||
basetimestamp = revision.timestamp; | ||
curtimestamp = data.curtimestamp; | ||
if (editConfig.exclusionRegex && editConfig.exclusionRegex.test(revision.content)) { | ||
return Promise.reject('bot-denied'); | ||
if (editConfig.exclusionRegex && editConfig.exclusionRegex.test(revisionContent)) { | ||
return this.rejectWithErrorCode('bot-denied'); | ||
} | ||
@@ -977,3 +986,3 @@ | ||
timestamp: revision.timestamp, | ||
content: revision.content | ||
content: revisionContent | ||
}); | ||
@@ -985,3 +994,3 @@ | ||
} | ||
var editParams = typeof returnVal === 'object' ? returnVal : { | ||
const editParams = typeof returnVal === 'object' ? returnVal : { | ||
text: String(returnVal) | ||
@@ -994,3 +1003,4 @@ }; | ||
starttimestamp: curtimestamp, | ||
nocreate: !0, | ||
nocreate: 1, | ||
bot: 1, | ||
token: this.csrfToken | ||
@@ -1003,10 +1013,9 @@ }, makeTitle(title), editParams)); | ||
} | ||
if (!data.edit) { | ||
log(`[W] Unusual API success response: ` + JSON.stringify(data,undefined,2)); | ||
} | ||
return data.edit; | ||
}, errorCode => { | ||
if (errorCode === 'editconflict' && editConfig.conflictRetries > 0) { | ||
}, err => { | ||
if (err.code === 'editconflict' && editConfig.conflictRetries > 0) { | ||
editConfig.conflictRetries--; | ||
return this.edit(title, transform, editConfig); | ||
} else { | ||
return Promise.reject(err); | ||
} | ||
@@ -1031,2 +1040,3 @@ }); | ||
summary: summary, | ||
bot: 1, | ||
token: this.csrfToken | ||
@@ -1052,3 +1062,4 @@ }, makeTitle(title), options)).then(data => data.edit); | ||
summary: summary, | ||
createonly: '1', | ||
createonly: 1, | ||
bot: 1, | ||
token: this.csrfToken | ||
@@ -1073,2 +1084,3 @@ }, options)).then(data => data.edit); | ||
text: message, | ||
bot: 1, | ||
token: this.csrfToken | ||
@@ -1198,3 +1210,3 @@ }, makeTitle(title), additionalParams)).then(data => data.edit); | ||
if (data.upload.warnings) { | ||
log('[W] The API returned warnings while uploading to ' + title + ':'); | ||
log(`[W] The API returned warnings while uploading to ${title}:`); | ||
log(data.upload.warnings); | ||
@@ -1250,4 +1262,4 @@ } | ||
}, makeTitles(file))).then(data => { | ||
var url = data.query.pages[0].imageinfo[0].url; | ||
var name = new this.title(data.query.pages[0].title).getMainText(); | ||
const url = data.query.pages[0].imageinfo[0].url; | ||
const name = new this.title(data.query.pages[0].title).getMainText(); | ||
return this.downloadFromUrl(url, localname || name); | ||
@@ -1313,3 +1325,3 @@ }); | ||
getPagesByPrefix(prefix, otherParams) { | ||
var title = Title.newFromText(prefix); | ||
const title = Title.newFromText(prefix); | ||
if (!title) { | ||
@@ -1336,3 +1348,3 @@ throw new Error('invalid prefix for getPagesByPrefix'); | ||
getPagesInCategory(category, otherParams) { | ||
var title = Title.newFromText(category, 14); | ||
const title = Title.newFromText(category, 14); | ||
return this.request(merge({ | ||
@@ -1361,3 +1373,3 @@ "action": "query", | ||
srlimit: limit, | ||
srprop: props || 'size|worcount|timestamp', | ||
srprop: props || 'size|wordcount|timestamp', | ||
}, otherParams)).then(data => { | ||
@@ -1379,4 +1391,4 @@ return data.query.search; | ||
continuedQuery(query, limit=10) { | ||
var responses = []; | ||
var callApi = (query, count) => { | ||
let responses = []; | ||
let callApi = (query, count) => { | ||
return this.request(query).then(response => { | ||
@@ -1446,6 +1458,6 @@ if (!this.options.silent) { | ||
massQuery(query, batchFieldName='titles') { | ||
var batchValues = query[batchFieldName]; | ||
var limit = this.options.hasApiHighLimit ? 500 : 50; | ||
var numBatches = Math.ceil(batchValues.length / limit); | ||
var batches = new Array(numBatches); | ||
let batchValues = query[batchFieldName]; | ||
const limit = this.options.hasApiHighLimit ? 500 : 50; | ||
const numBatches = Math.ceil(batchValues.length / limit); | ||
let batches = new Array(numBatches); | ||
for (let i = 0; i < numBatches - 1; i++) { | ||
@@ -1458,5 +1470,5 @@ batches[i] = new Array(limit); | ||
} | ||
var responses = new Array(numBatches); | ||
let responses = new Array(numBatches); | ||
return new Promise((resolve) => { | ||
var sendQuery = (idx) => { | ||
const sendQuery = (idx) => { | ||
if (idx === numBatches) { | ||
@@ -1471,3 +1483,3 @@ return resolve(responses); | ||
throw new Error(`[mwn] Your account doesn't have apihighlimit right.` + | ||
` Set the option hasApiHighLimit as false`); | ||
` Set the option hasApiHighLimit as false`); | ||
} | ||
@@ -1489,6 +1501,6 @@ responses[idx] = err; | ||
async *massQueryGen(query, batchFieldName='titles') { | ||
var batchValues = query[batchFieldName]; | ||
var limit = this.options.hasApiHighLimit ? 500 : 50; | ||
var batches = arrayChunk(batchValues, limit); | ||
var numBatches = batches.length; | ||
let batchValues = query[batchFieldName]; | ||
const limit = this.options.hasApiHighLimit ? 500 : 50; | ||
const batches = arrayChunk(batchValues, limit); | ||
const numBatches = batches.length; | ||
@@ -1517,18 +1529,18 @@ for (let i = 0; i < numBatches; i++) { | ||
batchOperation(list, worker, concurrency=5, retries=0) { | ||
var counts = { | ||
let counts = { | ||
successes: 0, | ||
failures: 0 | ||
}; | ||
var failures = []; | ||
var incrementSuccesses = () => { | ||
let failures = []; | ||
let incrementSuccesses = () => { | ||
counts.successes++; | ||
}; | ||
var incrementFailures = (idx) => { | ||
const incrementFailures = (idx) => { | ||
counts.failures++; | ||
failures.push(list[idx]); | ||
}; | ||
var updateStatusText = () => { | ||
var percentageFinished = Math.round((counts.successes + counts.failures) / list.length * 100); | ||
var percentageSuccesses = Math.round(counts.successes / (counts.successes + counts.failures) * 100); | ||
var statusText = `[+] Finished ${counts.successes + counts.failures}/${list.length} (${percentageFinished}%) tasks, of which ${counts.successes} (${percentageSuccesses}%) were successful, and ${counts.failures} failed.`; | ||
const updateStatusText = () => { | ||
const percentageFinished = Math.round((counts.successes + counts.failures) / list.length * 100); | ||
const percentageSuccesses = Math.round(counts.successes / (counts.successes + counts.failures) * 100); | ||
const statusText = `[+] Finished ${counts.successes + counts.failures}/${list.length} (${percentageFinished}%) tasks, of which ${counts.successes} (${percentageSuccesses}%) were successful, and ${counts.failures} failed.`; | ||
if (!this.options.silent) { | ||
@@ -1538,6 +1550,6 @@ log(statusText); | ||
}; | ||
var numBatches = Math.ceil(list.length / concurrency); | ||
const numBatches = Math.ceil(list.length / concurrency); | ||
return new Promise((resolve) => { | ||
var sendBatch = (batchIdx) => { | ||
const sendBatch = (batchIdx) => { | ||
@@ -1547,4 +1559,4 @@ // Last batch | ||
var numItemsInLastBatch = list.length - batchIdx * concurrency; | ||
var finalBatchPromises = new Array(numItemsInLastBatch); | ||
const numItemsInLastBatch = list.length - batchIdx * concurrency; | ||
const finalBatchPromises = new Array(numItemsInLastBatch); | ||
@@ -1555,3 +1567,3 @@ // Hack: Promise.allSettled requires NodeJS 12.9+ | ||
// finalBatchPromises are resolved or rejected. | ||
var finalBatchSettledPromises = new Array(numItemsInLastBatch); | ||
let finalBatchSettledPromises = new Array(numItemsInLastBatch); | ||
@@ -1586,3 +1598,3 @@ for (let i = 0; i < numItemsInLastBatch; i++) { | ||
var promise = worker(list[idx], idx); | ||
const promise = worker(list[idx], idx); | ||
if (!ispromise(promise)) { | ||
@@ -1617,18 +1629,18 @@ throw new Error('batchOperation worker function must return a promise'); | ||
seriesBatchOperation(list, worker, delay=5000, retries=0) { | ||
var counts = { | ||
let counts = { | ||
successes: 0, | ||
failures: 0 | ||
}; | ||
var failures = []; | ||
var incrementSuccesses = () => { | ||
let failures = []; | ||
const incrementSuccesses = () => { | ||
counts.successes++; | ||
}; | ||
var incrementFailures = (idx) => { | ||
const incrementFailures = (idx) => { | ||
counts.failures++; | ||
failures.push(list[idx]); | ||
}; | ||
var updateStatusText = () => { | ||
var percentageFinished = Math.round((counts.successes + counts.failures) / list.length * 100); | ||
var percentageSuccesses = Math.round(counts.successes / (counts.successes + counts.failures) * 100); | ||
var statusText = `[+] Finished ${counts.successes + counts.failures}/${list.length} (${percentageFinished}%) tasks, of which ${counts.successes} (${percentageSuccesses}%) were successful, and ${counts.failures} failed.`; | ||
const updateStatusText = () => { | ||
const percentageFinished = Math.round((counts.successes + counts.failures) / list.length * 100); | ||
const percentageSuccesses = Math.round(counts.successes / (counts.successes + counts.failures) * 100); | ||
const statusText = `[+] Finished ${counts.successes + counts.failures}/${list.length} (${percentageFinished}%) tasks, of which ${counts.successes} (${percentageSuccesses}%) were successful, and ${counts.failures} failed.`; | ||
if (!this.options.silent) { | ||
@@ -1640,3 +1652,3 @@ log(statusText); | ||
return new Promise((resolve) => { | ||
var trigger = (idx) => { | ||
const trigger = (idx) => { | ||
if (list[idx] === undefined) { // reached the end | ||
@@ -1649,3 +1661,3 @@ if (counts.failures !== 0 && retries > 0) { | ||
} | ||
var promise = worker(list[idx], idx); | ||
const promise = worker(list[idx], idx); | ||
if (!ispromise(promise)) { | ||
@@ -1733,5 +1745,5 @@ throw new Error('seriesBatchOperation worker function must return a promise'); | ||
oresQueryRevisions(endpointUrl, models, revisions) { | ||
var response = {}; | ||
var chunks = arrayChunk( | ||
(revisions instanceof Array) ? revisions : [ revisions ], | ||
let response = {}; | ||
const chunks = arrayChunk( | ||
(revisions instanceof Array) ? revisions : [revisions], | ||
50 | ||
@@ -1824,3 +1836,3 @@ ); | ||
}).sort((a, b) => { | ||
a.bytes < b.bytes ? 1 : -1; | ||
return a.bytes < b.bytes ? 1 : -1; | ||
}); | ||
@@ -1841,2 +1853,14 @@ | ||
/** | ||
* Returns a promise rejected with an error object | ||
* @private | ||
* @param {string} errorcode | ||
* @returns {Promise<Error>} | ||
*/ | ||
rejectWithErrorCode(errorcode) { | ||
let error = new Error(errorcode); | ||
error.code = errorcode; | ||
return Promise.reject(error); | ||
} | ||
} | ||
@@ -1867,3 +1891,3 @@ | ||
/** Check whether object looks like a promises-A+ promise, from https://www.npmjs.com/package/is-promise */ | ||
var ispromise = function (obj) { | ||
const ispromise = function (obj) { | ||
return !!obj && (typeof obj === 'object' || typeof obj === 'function') && | ||
@@ -1874,3 +1898,3 @@ typeof obj.then === 'function'; | ||
/** Check whether an object is plain object, from https://github.com/sindresorhus/is-plain-obj/blob/master/index.js */ | ||
var isplainobject = function(value) { | ||
const isplainobject = function (value) { | ||
if (Object.prototype.toString.call(value) !== '[object Object]') { | ||
@@ -1883,3 +1907,2 @@ return false; | ||
/** | ||
@@ -1893,3 +1916,3 @@ * Simple wrapper around Object.assign to merge objects. null and undefined | ||
*/ | ||
var merge = function(...objects) { | ||
const merge = function(...objects) { | ||
// {} used as first parameter as this object is mutated by default | ||
@@ -1907,3 +1930,3 @@ return Object.assign({}, ...objects); | ||
*/ | ||
var mergeDeep1 = function(...objects) { | ||
const mergeDeep1 = function(...objects) { | ||
let args = [...objects].filter(e => e); // skip null/undefined values | ||
@@ -1925,5 +1948,5 @@ for (let options of args.slice(1)) { | ||
/** @param {Array} arr, @param {number} size */ | ||
var arrayChunk = function(arr, size) { | ||
var numChunks = Math.ceil(arr.length / size); | ||
var result = new Array(numChunks); | ||
const arrayChunk = function(arr, size) { | ||
const numChunks = Math.ceil(arr.length / size); | ||
let result = new Array(numChunks); | ||
for (let i=0; i<numChunks; i++) { | ||
@@ -1935,3 +1958,3 @@ result[i] = arr.slice(i * size, (i + 1) * size); | ||
var makeTitles = function(pages) { | ||
const makeTitles = function(pages) { | ||
pages = Array.isArray(pages) ? pages : [ pages ]; | ||
@@ -1946,3 +1969,3 @@ if (typeof pages[0] === 'number') { | ||
var makeTitle = function(page) { | ||
const makeTitle = function(page) { | ||
if (typeof page === 'number') { | ||
@@ -1949,0 +1972,0 @@ return { pageid: page }; |
@@ -222,2 +222,13 @@ module.exports = function(bot) { | ||
// Tweak set* methods (setHours, setUTCMinutes, etc) so that they | ||
// return the modified xdate object rather than the seconds-since-epoch | ||
// representation which is what JS Date() gives | ||
Object.getOwnPropertyNames(Date.prototype).filter(f => f.startsWith('set')).forEach(func => { | ||
let proxy = xdate.prototype[func]; | ||
xdate.prototype[func] = function(...args) { | ||
proxy.call(this, ...args); | ||
return this; | ||
}; | ||
}); | ||
xdate.localeData = { | ||
@@ -224,0 +235,0 @@ months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], |
@@ -100,3 +100,3 @@ module.exports = function(bot) { | ||
if (page.missing) { | ||
return Promise.reject('missingarticle'); | ||
return bot.rejectWithErrorCode('missingarticle'); | ||
} | ||
@@ -122,3 +122,3 @@ return page.linkshere.map(pg => pg.title); | ||
if (page.missing) { | ||
return Promise.reject('missingarticle'); | ||
return bot.rejectWithErrorCode('missingarticle'); | ||
} | ||
@@ -200,3 +200,3 @@ return page.transcludedin.map(pg => pg.title); | ||
if (page.missing) { | ||
return Promise.reject('missingarticle'); | ||
return bot.rejectWithErrorCode('missingarticle'); | ||
} | ||
@@ -223,3 +223,3 @@ return page.title; | ||
if (page.missing) { | ||
return Promise.reject('missingarticle'); | ||
return bot.rejectWithErrorCode('missingarticle'); | ||
} | ||
@@ -250,2 +250,7 @@ return page.revisions[0].user; | ||
/** | ||
* Get short description, either the local one (for English Wikipedia) | ||
* or the one from wikidata. | ||
* @param {Object} customOptions | ||
*/ | ||
getDescription(customOptions) { | ||
@@ -260,3 +265,3 @@ return bot.request({ | ||
if (page.missing) { | ||
return Promise.reject('missingarticle'); | ||
return bot.rejectWithErrorCode('missingarticle'); | ||
} | ||
@@ -287,3 +292,3 @@ return data.query.pages[0].description; | ||
if (page.missing) { | ||
return Promise.reject('missingarticle'); | ||
return bot.rejectWithErrorCode('missingarticle'); | ||
} | ||
@@ -290,0 +295,0 @@ return data.query.pages[0].revisions; |
@@ -6,2 +6,6 @@ /** | ||
/* | ||
* Definitions of some private functions used | ||
*/ | ||
var rawurlencode = function( str ) { | ||
@@ -8,0 +12,0 @@ return encodeURIComponent( String( str ) ) |
175982
4619
290