User:Polygnotus/Scripts/GetUserspace.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
Documentation for this user script can be added at User:Polygnotus/Scripts/GetUserspace.
// <nowiki>
// GetUserspace - dumps all pages in a user's userspace (User: and User_talk: namespaces,
// including subpages) into a single .txt file.
// Lives at Special:BlankPage/GetUserspace and adds a link in the tools menu.
$(document).ready(function () {
// Add link to tools menu on every page
mw.util.addPortletLink(
'p-tb',
mw.util.getUrl('Special:BlankPage/GetUserspace'),
'GetUserspace',
't-getuserspace',
'Dump all pages in a user\'s userspace to a text file'
);
// Only build UI on the dedicated page
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' ||
mw.config.get('wgPageName') !== 'Special:BlankPage/GetUserspace') {
return;
}
// ----- Page setup -----
document.title = 'GetUserspace';
$('#firstHeading').text('GetUserspace');
var $content = $('#mw-content-text');
$content.empty();
var $wrapper = $('<div>').css({ 'max-width': '900px' });
$wrapper.append(
$('<p>').text(
'Enter a username, or click "Run for me" to use the currently logged-in user. ' +
'Fetches all pages in User: and User_talk: namespaces (including subpages) and downloads them as a single .txt file.'
)
);
var $input = $('<input>')
.attr({ type: 'text', id: 'gus-username', placeholder: 'Username' })
.css({ 'width': '300px', 'margin-right': '0.5em', 'padding': '0.25em' });
var $runBtn = $('<button>').attr('id', 'gus-run').text('Run').css({ 'margin-right': '0.5em' });
var $meBtn = $('<button>').attr('id', 'gus-me').text('Run for me').css({ 'margin-right': '0.5em' });
var $stopBtn = $('<button>').attr('id', 'gus-stop').text('Stop').prop('disabled', true);
var $status = $('<div>').attr('id', 'gus-status').css({ 'margin': '0.75em 0', 'font-weight': 'bold' });
var $log = $('<pre>').attr('id', 'gus-log').css({
'max-height': '400px',
'overflow': 'auto',
'background': '#f8f9fa',
'border': '1px solid #c8ccd1',
'padding': '0.5em',
'white-space': 'pre-wrap'
});
$wrapper.append(
$('<div>').append($input, $runBtn, $meBtn, $stopBtn),
$status,
$log
);
$content.append($wrapper);
if (!mw.config.get('wgUserName')) {
$meBtn.prop('disabled', true).attr('title', 'You are not logged in');
}
// ----- Worker logic -----
var API = mw.util.wikiScript('api');
var DELAY_MS = 150;
var BATCH_SIZE = 20; // titles per content-fetch batch
var MAXLAG = 5;
var MAX_RETRIES = 6;
var INITIAL_BACKOFF_MS = 1000;
var MAX_BACKOFF_MS = 30000;
var USER_AGENT = 'GetUserspace/1.0 (user script; ' +
(mw.config.get('wgUserName') ? 'User:' + mw.config.get('wgUserName') : 'anonymous') +
'; ' + location.hostname + ')';
var cancelRequested = false;
var running = false;
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
function setStatus(text) { $status.text(text); }
function logLine(text) { $log.append(document.createTextNode(text + '\n')); $log.scrollTop($log[0].scrollHeight); }
// Centralised API request: maxlag, Api-User-Agent, retry with exponential backoff
async function apiRequest(params) {
params.set('maxlag', String(MAXLAG));
var attempt = 0;
var backoff = INITIAL_BACKOFF_MS;
while (true) {
if (cancelRequested) throw new Error('cancelled');
attempt++;
var res, data, retryAfter, reason;
try {
res = await fetch(API + '?' + params.toString(), {
headers: { 'Api-User-Agent': USER_AGENT },
credentials: 'same-origin'
});
} catch (e) {
reason = 'network error: ' + e.message;
if (attempt >= MAX_RETRIES) throw new Error(reason);
logLine(' ' + reason + ' - retrying in ' + Math.round(backoff / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
await sleep(backoff);
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
continue;
}
if (res.status >= 500 && res.status < 600) {
retryAfter = parseInt(res.headers.get('Retry-After'), 10);
reason = 'HTTP ' + res.status;
if (attempt >= MAX_RETRIES) throw new Error(reason);
var wait5xx = (retryAfter > 0 ? retryAfter * 1000 : backoff);
logLine(' ' + reason + ' - retrying in ' + Math.round(wait5xx / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
await sleep(wait5xx);
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
continue;
}
try {
data = await res.json();
} catch (e) {
reason = 'invalid JSON: ' + e.message;
if (attempt >= MAX_RETRIES) throw new Error(reason);
logLine(' ' + reason + ' - retrying in ' + Math.round(backoff / 1000) + 's');
await sleep(backoff);
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
continue;
}
if (data.error && data.error.code === 'maxlag') {
retryAfter = parseInt(res.headers.get('Retry-After'), 10);
var waitLag = (retryAfter > 0 ? retryAfter * 1000 : backoff);
if (attempt >= MAX_RETRIES) throw new Error('maxlag: ' + data.error.info);
logLine(' maxlag - waiting ' + Math.round(waitLag / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
await sleep(waitLag);
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
continue;
}
if (data.error && data.error.code === 'ratelimited') {
if (attempt >= MAX_RETRIES) throw new Error('ratelimited: ' + data.error.info);
logLine(' ratelimited - waiting ' + Math.round(backoff / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
await sleep(backoff);
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
continue;
}
return data;
}
}
// Normalize a username the way MediaWiki does: first character uppercased, underscores -> spaces
function normalizeUsername(name) {
var n = name.replace(/_/g, ' ').trim();
if (!n) return '';
return n.charAt(0).toUpperCase() + n.slice(1);
}
// Fetch all pages in a namespace whose title is exactly `username` or starts with `username/`
async function getPagesInNamespace(namespace, username) {
var pages = [];
var cont = null;
do {
if (cancelRequested) throw new Error('cancelled');
var params = new URLSearchParams({
action: 'query',
format: 'json',
formatversion: '2',
list: 'allpages',
apnamespace: String(namespace),
apprefix: username,
aplimit: 'max'
});
if (cont) params.set('apcontinue', cont);
var data = await apiRequest(params);
if (data.query && data.query.allpages) {
for (var i = 0; i < data.query.allpages.length; i++) {
var p = data.query.allpages[i];
// Strip namespace prefix to compare just the title portion
var titleNoNs = p.title.replace(/^[^:]+:/, '');
if (titleNoNs === username || titleNoNs.indexOf(username + '/') === 0) {
pages.push(p.title);
}
}
}
cont = data['continue'] ? data['continue'].apcontinue : null;
if (cont) await sleep(DELAY_MS);
} while (cont);
return pages;
}
// Fetch page contents in batches. Returns ordered array of {title, content, missing}.
async function getPageContents(titles) {
var results = {};
for (var i = 0; i < titles.length; i += BATCH_SIZE) {
if (cancelRequested) throw new Error('cancelled');
var batch = titles.slice(i, i + BATCH_SIZE);
var params = new URLSearchParams({
action: 'query',
format: 'json',
formatversion: '2',
prop: 'revisions',
rvprop: 'content|timestamp',
rvslots: 'main',
titles: batch.join('|')
});
var data = await apiRequest(params);
if (data.query && data.query.pages) {
for (var j = 0; j < data.query.pages.length; j++) {
var p = data.query.pages[j];
if (p.missing) {
results[p.title] = { missing: true, content: null };
} else if (p.revisions && p.revisions[0] && p.revisions[0].slots && p.revisions[0].slots.main) {
results[p.title] = {
missing: false,
content: p.revisions[0].slots.main.content || '',
timestamp: p.revisions[0].timestamp
};
} else {
results[p.title] = { missing: false, content: '', timestamp: null };
}
}
}
logLine(' Fetched contents ' + Math.min(i + BATCH_SIZE, titles.length) + '/' + titles.length);
if (i + BATCH_SIZE < titles.length) await sleep(DELAY_MS);
}
// Return in the original order
return titles.map(function (t) {
return { title: t, data: results[t] || { missing: true, content: null } };
});
}
async function run(usernameRaw) {
if (running) return;
var username = normalizeUsername(usernameRaw || '');
if (!username) {
setStatus('No username provided.');
return;
}
running = true;
cancelRequested = false;
$runBtn.prop('disabled', true);
$meBtn.prop('disabled', true);
$stopBtn.prop('disabled', false);
$log.empty();
try {
setStatus('Listing pages for ' + username + '...');
logLine('Listing User: pages...');
var userPages = await getPagesInNamespace(2, username);
logLine(' Found ' + userPages.length + ' page(s) in User: namespace');
logLine('Listing User_talk: pages...');
var talkPages = await getPagesInNamespace(3, username);
logLine(' Found ' + talkPages.length + ' page(s) in User_talk: namespace');
var allTitles = userPages.concat(talkPages);
if (allTitles.length === 0) {
setStatus('No pages found in userspace.');
logLine('Nothing to download.');
return;
}
setStatus('Fetching contents for ' + allTitles.length + ' page(s)...');
logLine('Fetching page contents (' + BATCH_SIZE + ' per batch)...');
var pages = await getPageContents(allTitles);
// Build the output text
var sep = '='.repeat(80);
var subsep = '-'.repeat(80);
var output = '';
output += 'Username: ' + username + '\n';
output += 'Wiki: ' + location.hostname + '\n';
output += 'Generated: ' + new Date().toISOString() + '\n';
output += 'User-Agent: ' + USER_AGENT + '\n';
output += 'Pages: ' + pages.length + '\n';
output += sep + '\n\n';
for (var i = 0; i < pages.length; i++) {
var entry = pages[i];
var url = 'https://' + location.hostname + mw.util.getUrl(entry.title);
output += 'URL: ' + url + '\n';
if (entry.data.timestamp) output += 'Last edited: ' + entry.data.timestamp + '\n';
output += subsep + '\n';
if (entry.data.missing) {
output += '(page does not exist)\n';
} else {
output += (entry.data.content || '(empty page)') + '\n';
}
output += '\n';
}
// Trigger download
var blob = new Blob([output], { type: 'text/plain;charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'userspace_' + username.replace(/[^A-Za-z0-9_-]+/g, '_') +
'_' + new Date().toISOString().replace(/[:.]/g, '-') + '.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatus('Done. ' + pages.length + ' page(s) downloaded.');
logLine('Done.');
} catch (e) {
if (e.message === 'cancelled') {
setStatus('Cancelled.');
logLine('Cancelled by user.');
} else {
setStatus('Error: ' + e.message);
logLine('Error: ' + e.message);
}
} finally {
running = false;
cancelRequested = false;
$runBtn.prop('disabled', false);
$meBtn.prop('disabled', !mw.config.get('wgUserName'));
$stopBtn.prop('disabled', true);
}
}
$runBtn.on('click', function () { run($input.val()); });
$meBtn.on('click', function () {
var me = mw.config.get('wgUserName');
if (me) {
$input.val(me);
run(me);
}
});
$input.on('keydown', function (e) { if (e.key === 'Enter') run($input.val()); });
$stopBtn.on('click', function () {
if (running) {
cancelRequested = true;
setStatus('Cancelling after current request...');
}
});
});
// </nowiki>