User:Polygnotus/Scripts/GetAllContribs.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/GetAllContribs.
// <nowiki>
// GetAllContribs - fetches all edits of a list of usernames and exports them as a text file.
// Lives at Special:BlankPage/GetAllContribs and adds a link in the tools menu.
$(document).ready(function () {
// Add link to tools menu on every page
mw.util.addPortletLink(
'p-tb', // portlet ID (tools menu)
mw.util.getUrl('Special:BlankPage/GetAllContribs'),
'GetAllContribs', // link text
't-getallcontribs', // link ID
'Fetch contributions for a list of accounts'
);
// Only build the UI when actually viewing the dedicated page.
// We can't compare wgPageName against a fixed English string because the
// Special namespace and the special page title are localised on non-English
// wikis (e.g. de.wiki has "Spezial:Leere_Seite/GetAllContribs"). Instead,
// check the canonical special page name and parse the subpage portion.
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage') {
return;
}
var fullPageName = mw.config.get('wgPageName');
var firstSlash = fullPageName.indexOf('/');
var subpageName = (firstSlash >= 0) ? fullPageName.substring(firstSlash + 1) : '';
if (subpageName !== 'GetAllContribs') {
return;
}
// ----- Page setup -----
document.title = 'GetAllContribs';
$('#firstHeading').text('GetAllContribs');
// Build the UI inside the content area
var $content = $('#mw-content-text');
$content.empty();
var $wrapper = $('<div>').css({ 'max-width': '900px' });
$wrapper.append(
$('<p>').text(
'Enter one username per line. Click "Run" to fetch contributions and download the report as a text file.'
)
);
var $textarea = $('<textarea>')
.attr('id', 'gac-usernames')
.attr('rows', 12)
.attr('placeholder', 'One username per line')
.css({ 'width': '100%', 'font-family': 'monospace' });
// Mode selector: edit summaries only, diffs only, or both
var $modeBox = $('<fieldset>').css({ 'margin': '0.75em 0', 'padding': '0.5em 0.75em' });
$modeBox.append($('<legend>').text('Collect'));
var modes = [
{ value: 'both', label: 'Edit summaries and diffs (default)' },
{ value: 'summary', label: 'Edit summaries only (fast - no diff API calls)' },
{ value: 'diff', label: 'Diffs only (no edit summary in output)' }
];
for (var m = 0; m < modes.length; m++) {
var id = 'gac-mode-' + modes[m].value;
var $label = $('<label>').css({ 'display': 'block', 'margin': '0.15em 0' });
var $radio = $('<input>')
.attr('type', 'radio')
.attr('name', 'gac-mode')
.attr('id', id)
.attr('value', modes[m].value)
.css({ 'margin-right': '0.4em' });
if (modes[m].value === 'both') $radio.prop('checked', true);
$label.append($radio, document.createTextNode(modes[m].label));
$modeBox.append($label);
}
var $runBtn = $('<button>').attr('id', 'gac-run').text('Run').css({ 'margin-right': '0.5em' });
var $stopBtn = $('<button>').attr('id', 'gac-stop').text('Stop').prop('disabled', true);
var $status = $('<div>').attr('id', 'gac-status').css({ 'margin': '0.75em 0', 'font-weight': 'bold' });
var $progress = $('<div>').attr('id', 'gac-progress').css({
'margin': '0.25em 0 0.75em 0',
'font-family': 'monospace'
});
var $log = $('<pre>').attr('id', 'gac-log').css({
'max-height': '400px',
'overflow': 'auto',
'background': '#f8f9fa',
'border': '1px solid #c8ccd1',
'padding': '0.5em',
'white-space': 'pre-wrap'
});
$wrapper.append(
$textarea,
$modeBox,
$('<div>').css('margin-top', '0.5em').append($runBtn, $stopBtn),
$status,
$progress,
$log
);
$content.append($wrapper);
// ----- Worker logic -----
var API = mw.util.wikiScript('api'); // path to api.php on this wiki
var INDEX_SCRIPT = mw.util.wikiScript('index'); // path to index.php (for diff URLs)
var DELAY_MS = 1500;
var MAX_DIFF_CHARS = 4000;
var MAXLAG = 5; // seconds; server errors out if replication lag exceeds this
var MAX_RETRIES = 6; // per-request cap before giving up
var INITIAL_BACKOFF_MS = 1000;
var MAX_BACKOFF_MS = 30000;
var REQUEST_TIMEOUT_MS = 60000; // per-request timeout: aborts hung connections
// ucuser / ususers accept up to 50 names per call for regular accounts
// (500 with apihighlimits). uclimit=max is a global cap across the whole batch,
// not per user, so batching mainly helps when most accounts have few edits.
// Heavy accounts still need many continuation rounds regardless of batch size.
var BATCH_SIZE = 50;
// Api-User-Agent header (browsers won't let us set User-Agent directly,
// but MediaWiki reads this as a fallback per the User-Agent policy).
var USER_AGENT = 'GetAllContribs/1.6 (user script; ' +
(mw.config.get('wgUserName') ? 'User:' + mw.config.get('wgUserName') : 'anonymous') +
'; ' + location.hostname + ')';
var cancelRequested = false;
var running = false;
var activeAbortControllers = new Set();
var sleepRejecters = new Set();
// Cancellation-aware sleep: rejects with Error('cancelled') if Stop is hit mid-wait.
function sleep(ms) {
return new Promise(function (resolve, reject) {
if (cancelRequested) { reject(new Error('cancelled')); return; }
var rejecter = function () { clearTimeout(t); sleepRejecters.delete(rejecter); reject(new Error('cancelled')); };
var t = setTimeout(function () {
sleepRejecters.delete(rejecter);
resolve();
}, ms);
sleepRejecters.add(rejecter);
});
}
function abortAllInFlight() {
// Abort any pending fetch
activeAbortControllers.forEach(function (ac) {
try { ac.abort(); } catch (e) { /* ignore */ }
});
// Wake any pending sleep
sleepRejecters.forEach(function (fn) {
try { fn(); } catch (e) { /* ignore */ }
});
}
function setStatus(text) { $status.text(text); }
function setProgress(text) { $progress.text(text); }
function logLine(text) { $log.append(document.createTextNode(text + '\n')); $log.scrollTop($log[0].scrollHeight); }
function getMode() {
var v = $('input[name="gac-mode"]:checked').val();
return v || 'both';
}
// MediaWiki canonicalises usernames before storage. Match its rules so we can
// group results returned by the API back to the input names the user typed:
// - apply Unicode NFC normalisation (MediaWiki does this on titles/usernames)
// - trim and convert underscores to spaces
// - IPv6 addresses are stored fully uppercase (the API returns them in this
// canonical form, so simple uppercase is sufficient for grouping)
// - IPv4 addresses are unchanged by case
// - regular usernames have only their first character uppercased
function isIPv6(s) {
// Real IPv6 addresses always contain at least two colons (one between
// every pair of hextets, or the "::" zero-run shorthand). Restricting
// to >=2 colons avoids matching strings like "abc:def" that happen to
// contain a single colon.
if (s.split(':').length < 3) return false;
return /^[0-9A-Fa-f:.]+$/.test(s);
}
function normalizeUsername(name) {
name = String(name || '');
// String.prototype.normalize is available in all modern browsers.
try { name = name.normalize('NFC'); } catch (e) { /* ignore on ancient engines */ }
name = name.trim().replace(/_/g, ' ');
if (name.length === 0) return name;
if (isIPv6(name)) return name.toUpperCase();
return name.charAt(0).toUpperCase() + name.slice(1);
}
// Truncate by Unicode code points (not UTF-16 code units) so we never split
// a surrogate pair mid-character.
function truncateForDiff(s) {
var arr = Array.from(s);
if (arr.length <= MAX_DIFF_CHARS) return s;
return arr.slice(0, MAX_DIFF_CHARS).join('') + '\n...(truncated)';
}
// Centralised API request: adds maxlag + Api-User-Agent, handles retries with
// exponential backoff for maxlag, ratelimited, network errors, timeouts, and
// 5xx responses. Throws Error('cancelled') promptly when Stop is pressed.
// Uses POST so long pipe-separated user lists never hit URL length limits.
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 ac = new AbortController();
activeAbortControllers.add(ac);
// Per-request timeout. We track timedOut separately from cancelRequested
// so we can distinguish a stalled connection from a user-initiated stop.
var timedOut = false;
var timeoutId = setTimeout(function () {
timedOut = true;
try { ac.abort(); } catch (e) { /* ignore */ }
}, REQUEST_TIMEOUT_MS);
try {
var res, data, retryAfter, reason;
try {
res = await fetch(API, {
method: 'POST',
headers: {
'Api-User-Agent': USER_AGENT,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString(),
credentials: 'same-origin',
signal: ac.signal
});
} catch (e) {
if (cancelRequested) throw new Error('cancelled');
reason = timedOut
? 'request timeout (' + (REQUEST_TIMEOUT_MS / 1000) + 's)'
: '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;
}
// Server-side errors (rare, but possible)
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) {
if (cancelRequested) throw new Error('cancelled');
reason = timedOut
? 'timeout reading body (' + (REQUEST_TIMEOUT_MS / 1000) + 's)'
: 'invalid JSON: ' + 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;
}
// Maxlag: server is lagging, back off using Retry-After if provided
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;
}
// Rate limited: also honour Retry-After when provided
if (data.error && data.error.code === 'ratelimited') {
retryAfter = parseInt(res.headers.get('Retry-After'), 10);
var waitRl = (retryAfter > 0 ? retryAfter * 1000 : backoff);
if (attempt >= MAX_RETRIES) throw new Error('ratelimited: ' + data.error.info);
logLine(' ratelimited - waiting ' + Math.round(waitRl / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
await sleep(waitRl);
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
continue;
}
// Other API errors are surfaced to the caller (they may be expected, e.g. nosuchrev)
return data;
} finally {
clearTimeout(timeoutId);
activeAbortControllers.delete(ac);
}
}
}
// Fetch editcount and canonical name for a batch of users via list=users.
// Returns { counts: Map<canonicalName, number|null>, canonicalByInput: Map<inputCanonical, apiCanonical> }
// - counts: editcount per canonical name; null means unknown - this happens
// for IP addresses (invalid for list=users), missing/renamed accounts, or
// anything else the API does not return an editcount for.
// - canonicalByInput: maps the canonical form of each input name to the
// canonical name returned by the API. For renamed accounts these differ:
// the old name was queryable, but list=users returns the *current* name.
async function getEditcountsBatch(users) {
var counts = new Map();
var canonicalByInput = new Map();
for (var i = 0; i < users.length; i++) {
var inputCanonical = normalizeUsername(users[i]);
counts.set(inputCanonical, null);
// Default: assume input name == api name. Will be overwritten below
// if the API returns a different canonical form.
canonicalByInput.set(inputCanonical, inputCanonical);
}
var params = new URLSearchParams({
action: 'query',
format: 'json',
formatversion: '2',
list: 'users',
ususers: users.join('|'),
usprop: 'editcount'
});
var data = await apiRequest(params);
if (data.error) {
throw new Error('API error: ' + data.error.code + ' - ' + data.error.info);
}
if (data.query && Array.isArray(data.query.users)) {
var arr = data.query.users;
// The API echoes ususers in order: arr[i] corresponds to users[i].
// Each entry has a .name field which is the canonical name the API
// uses internally. For renamed accounts this differs from the input
// name and is what usercontribs will return in c.user.
for (var j = 0; j < arr.length; j++) {
var u = arr[j];
if (j < users.length) {
var inCanon = normalizeUsername(users[j]);
if (u && u.name) {
canonicalByInput.set(inCanon, normalizeUsername(u.name));
}
}
if (!u || u.missing || u.invalid) continue;
if (typeof u.editcount === 'number') {
counts.set(normalizeUsername(u.name), u.editcount);
}
}
}
return { counts: counts, canonicalByInput: canonicalByInput };
}
// Fetch contribs for a batch of users in a single continuation loop.
// Returns Map<canonicalName, contribs[]>. Names with no contribs map to [].
async function getContribsBatch(users) {
var byUser = new Map();
for (var i = 0; i < users.length; i++) {
byUser.set(normalizeUsername(users[i]), []);
}
var hasCont = false;
var contParams = {};
do {
if (cancelRequested) throw new Error('cancelled');
var params = new URLSearchParams({
action: 'query',
format: 'json',
formatversion: '2',
list: 'usercontribs',
ucuser: users.join('|'),
uclimit: 'max',
ucprop: 'ids|title|timestamp|comment|sizediff|flags'
});
// Per the MediaWiki API contract, replay every key returned in the
// continue object - not just uccontinue. The bare 'continue' marker
// is part of the protocol and additional keys may appear in future.
if (hasCont) {
Object.keys(contParams).forEach(function (k) {
params.set(k, contParams[k]);
});
}
var data = await apiRequest(params);
if (data.error) {
throw new Error('API error: ' + data.error.code + ' - ' + data.error.info);
}
if (data.query && data.query.usercontribs) {
var contribs = data.query.usercontribs;
for (var j = 0; j < contribs.length; j++) {
var c = contribs[j];
var key = normalizeUsername(c.user || '');
if (!byUser.has(key)) byUser.set(key, []);
byUser.get(key).push(c);
}
}
if (data['continue']) {
hasCont = true;
contParams = {};
Object.keys(data['continue']).forEach(function (k) {
contParams[k] = data['continue'][k];
});
} else {
hasCont = false;
}
if (hasCont) await sleep(DELAY_MS);
} while (hasCont);
return byUser;
}
// Parse the diff HTML into removed/added line text with line-number markers.
// Uses DOMParser instead of innerHTML to avoid executing any embedded resources
// (e.g. <img onerror=...>) if the API ever returned unsanitised content.
function parseDiff(diffHTML) {
if (!diffHTML) return '(no diff returned)';
var doc = new DOMParser().parseFromString(
'<!DOCTYPE html><html><body><table>' + diffHTML + '</table></body></html>',
'text/html'
);
var out = [];
var rows = doc.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var lineno = row.querySelector('.diff-lineno');
if (lineno) {
out.push('\n[' + lineno.textContent.trim() + ']');
continue;
}
var removed = row.querySelector('.diff-deletedline');
var added = row.querySelector('.diff-addedline');
if (removed || added) {
if (removed) {
var rText = removed.textContent.replace(/\s+/g, ' ').trim();
if (rText) out.push('- ' + rText);
}
if (added) {
var aText = added.textContent.replace(/\s+/g, ' ').trim();
if (aText) out.push('+ ' + aText);
}
}
}
var result = out.join('\n').trim();
if (!result) return '(no textual changes detected)';
return truncateForDiff(result);
}
// For first edits (parentid = 0), the action=compare API needs an explicit
// content model when comparing against empty fromtext, otherwise it defaults
// to wikitext - which is wrong for JS, CSS, JSON, Lua/Scribunto, etc.
// Instead of hardcoding the content model, fetch the revision's content
// directly via prop=revisions and synthesise a diff where every line is
// an addition. This is one API call (same cost as the compare call would
// have been) and works for any content model.
async function getFirstEditDiff(revid) {
var params = new URLSearchParams({
action: 'query',
format: 'json',
formatversion: '2',
prop: 'revisions',
revids: String(revid),
rvprop: 'content|contentmodel',
rvslots: 'main'
});
try {
var data = await apiRequest(params);
if (data.error) return '(API error: ' + data.error.info + ')';
if (!data.query || !Array.isArray(data.query.pages) || data.query.pages.length === 0) {
return '(could not fetch first-edit content)';
}
var page = data.query.pages[0];
if (!page.revisions || page.revisions.length === 0) {
return '(could not fetch first-edit content)';
}
var rev = page.revisions[0];
var slot = rev.slots && rev.slots.main;
if (!slot) return '(no main slot)';
if (slot.contentmissing) return '(content missing)';
if (slot.texthidden) return '(content hidden / revdel)';
var content = slot.content;
if (content == null) return '(content unavailable)';
var contentModel = slot.contentmodel || 'unknown';
// Treat every line as an addition. Strip CR and tabs so they don't
// clobber output formatting; preserve the rest verbatim.
var lines = content.split('\n');
var added = [];
for (var i = 0; i < lines.length; i++) {
var t = lines[i].replace(/[\r\t]/g, ' ');
added.push('+ ' + t);
}
var header = '(first edit on page; content model: ' + contentModel + ')';
return truncateForDiff(header + '\n' + added.join('\n'));
} catch (e) {
// Re-throw cancellation so the outer loop stops cleanly instead of
// writing "(fetch error: cancelled)" entries into the report.
if (e.message === 'cancelled') throw e;
return '(fetch error: ' + e.message + ')';
}
}
async function getDiff(revid, parentid) {
if (!parentid || parentid <= 0) {
// First edit on the page - handle separately to avoid the wikitext
// assumption baked into action=compare with empty fromtext.
return getFirstEditDiff(revid);
}
var params = new URLSearchParams({
action: 'compare',
format: 'json',
formatversion: '2',
fromrev: String(parentid),
torev: String(revid),
prop: 'diff'
});
try {
var data = await apiRequest(params);
if (data.error) return '(API error: ' + data.error.info + ')';
var diffHTML = (data.compare && (data.compare.body || data.compare['*'])) || '';
return parseDiff(diffHTML);
} catch (e) {
if (e.message === 'cancelled') throw e;
return '(fetch error: ' + e.message + ')';
}
}
// Normalise a one-line value: replace newlines/CR/tabs with a single space.
// Internal whitespace runs are preserved (we don't want to silently change
// a comment that contains, say, deliberate double-spacing).
function oneLine(s) {
return String(s).replace(/[\r\n\t]+/g, ' ').trim();
}
// Format a number with thousands separators. Locale fixed to en-US for
// stable, predictable output in the report file.
function fmt(n) {
return Number(n).toLocaleString('en-US');
}
async function run() {
if (running) return;
// Set running immediately to close the re-entry window. Any early
// return below resets it before exiting.
running = true;
var raw = $textarea.val();
// Parse usernames and deduplicate by canonical form. Keeping the first
// occurrence preserves whatever spelling the user typed for display
// purposes, while preventing the same account from being processed
// twice (which would double-count edits in the progress denominator).
var seen = new Set();
var skipped = 0;
var usernames = [];
var lines = raw.split('\n');
for (var li = 0; li < lines.length; li++) {
var s = lines[li].trim();
if (s.length === 0) continue;
var canonical = normalizeUsername(s);
if (canonical.length === 0) { skipped++; continue; }
if (seen.has(canonical)) { skipped++; continue; }
seen.add(canonical);
usernames.push(s);
}
if (usernames.length === 0) {
setStatus('No usernames provided.');
running = false;
return;
}
var mode = getMode();
var includeSummary = (mode === 'both' || mode === 'summary');
var includeDiff = (mode === 'both' || mode === 'diff');
cancelRequested = false;
$runBtn.prop('disabled', true);
$stopBtn.prop('disabled', false);
$log.empty();
setProgress('');
if (skipped > 0) {
logLine('Note: ' + skipped + ' duplicate or empty entries were skipped.');
}
var modeLabel = (mode === 'summary') ? 'edit summaries only'
: (mode === 'diff') ? 'diffs only'
: 'edit summaries and diffs';
// Use an array of fragments that we hand directly to Blob() at the end.
// Blob accepts a BlobPart[], so we never need to .join('') the array
// into one giant string - that would double peak memory for large
// reports (200MB+ on heavy editors with diffs enabled).
var out = [];
function w(s) { out.push(s); }
var cancelledMidRun = false;
try {
// Split usernames into batches of BATCH_SIZE
var batches = [];
for (var b = 0; b < usernames.length; b += BATCH_SIZE) {
batches.push(usernames.slice(b, b + BATCH_SIZE));
}
// Phase 1: fetch editcount for every user so we can show real progress.
// Note: editcount from list=users (user_editcount) and the count from
// usercontribs can diverge for several reasons - deleted edits are
// included in user_editcount but not returned by usercontribs, and
// user_editcount is denormalised so it can lag behind reality after
// renames/imports/merges. We treat editcount as an estimate and
// recalibrate the running total once we have actual contrib counts
// per user.
// We also collect canonical-name mapping here so that contribs
// returned under a renamed account's *current* name get reported
// against the input name the user typed.
setStatus('Fetching edit counts...');
logLine('Fetching edit counts for ' + usernames.length + ' user(s)...');
var editcountByCanonical = new Map();
// Maps canonical(input name) -> canonical(api name). Identity for
// most users; differs for renamed accounts.
var apiNameByInputCanonical = new Map();
for (var pi = 0; pi < batches.length; pi++) {
if (cancelRequested) { cancelledMidRun = true; break; }
setStatus('Fetching edit counts (batch ' + (pi + 1) + '/' + batches.length + ')...');
try {
var ecResult = await getEditcountsBatch(batches[pi]);
ecResult.counts.forEach(function (v, k) { editcountByCanonical.set(k, v); });
ecResult.canonicalByInput.forEach(function (v, k) { apiNameByInputCanonical.set(k, v); });
} catch (e) {
if (e.message === 'cancelled') { cancelledMidRun = true; break; }
logLine('Edit count fetch failed for batch ' + (pi + 1) + ': ' + e.message);
// Fall back to identity mapping for this batch's users so
// contrib lookup still attempts something sensible.
for (var fb = 0; fb < batches[pi].length; fb++) {
var fbCanon = normalizeUsername(batches[pi][fb]);
if (!apiNameByInputCanonical.has(fbCanon)) {
apiNameByInputCanonical.set(fbCanon, fbCanon);
}
}
}
if (pi < batches.length - 1 && !cancelRequested) {
try { await sleep(DELAY_MS); }
catch (e) {
if (e.message === 'cancelled') { cancelledMidRun = true; break; }
throw e;
}
}
}
// Compute initial total estimate. Edit counts are stored against
// the *api* canonical name, so look them up via the rename mapping.
var expectedTotal = 0;
var unknownCountUsers = 0;
for (var et = 0; et < usernames.length; et++) {
var etInput = normalizeUsername(usernames[et]);
var etApi = apiNameByInputCanonical.get(etInput) || etInput;
var etCount = editcountByCanonical.get(etApi);
if (typeof etCount === 'number') expectedTotal += etCount;
else unknownCountUsers++;
}
if (!cancelledMidRun) {
logLine('Estimated total edits: ' + fmt(expectedTotal) +
(unknownCountUsers > 0 ? ' (+' + unknownCountUsers + ' user(s) with unknown count)' : ''));
}
// Write report header (after phase 1 so we can include the estimate)
w('Account contribution report\n');
w('Generated: ' + new Date().toISOString() + '\n');
w('Wiki: ' + location.hostname + '\n');
w('User-Agent: ' + USER_AGENT + '\n');
w('Mode: ' + modeLabel + '\n');
w('Accounts queried: ' + usernames.length + '\n');
if (skipped > 0) {
w('Duplicate/empty entries skipped: ' + skipped + '\n');
}
w('Estimated total edits: ' + fmt(expectedTotal) +
(unknownCountUsers > 0 ? ' (+' + unknownCountUsers + ' with unknown count)' : '') + '\n');
w('Batch size: ' + BATCH_SIZE + ' (' + batches.length + ' batch' + (batches.length === 1 ? '' : 'es') + ')\n');
w('='.repeat(80) + '\n');
// Phase 2: process batches.
// editsDone counts edits processed across the whole run.
// dynamicTotal is the running denominator: starts as expectedTotal and
// is recalibrated per user as actual contribs.length becomes known.
var processedUsers = 0;
var editsDone = 0;
var dynamicTotal = expectedTotal;
function updateProgress(currentUser, editIdx, userTotal) {
var parts = [];
parts.push('User ' + processedUsers + '/' + usernames.length);
if (currentUser) parts.push(currentUser);
if (editIdx != null) {
parts.push('edit ' + fmt(editIdx) + '/' + (userTotal != null ? fmt(userTotal) : '?'));
}
var totalStr = 'overall ' + fmt(editsDone) + '/' + fmt(dynamicTotal);
if (dynamicTotal > 0) {
var pct = (100 * editsDone / dynamicTotal);
totalStr += ' (' + (pct >= 99.95 ? '100' : pct.toFixed(1)) + '%)';
}
parts.push(totalStr);
setProgress(parts.join(' - '));
}
outer:
for (var bi = 0; bi < batches.length; bi++) {
if (cancelRequested) { cancelledMidRun = true; logLine('Cancelled by user.'); break; }
var batch = batches[bi];
var batchHeader = 'Batch ' + (bi + 1) + '/' + batches.length +
' - fetching contribs for ' + batch.length + ' user(s)...';
setStatus(batchHeader);
logLine(batchHeader);
var byUser;
try {
byUser = await getContribsBatch(batch);
} catch (e) {
if (e.message === 'cancelled') { cancelledMidRun = true; logLine('Cancelled by user.'); break; }
logLine('Batch fetch failed: ' + e.message);
// Record errors for every user in this batch and continue
for (var k = 0; k < batch.length; k++) {
var bUser = batch[k];
var bInputCanon = normalizeUsername(bUser);
var bApiCanon = apiNameByInputCanonical.get(bInputCanon) || bInputCanon;
var bEstimate = editcountByCanonical.get(bApiCanon);
// Remove this user's estimate from the denominator since
// we will not be processing their edits.
if (typeof bEstimate === 'number') dynamicTotal -= bEstimate;
w('\n' + '='.repeat(80) + '\n');
w('User: ' + bUser + '\n');
w('='.repeat(80) + '\n');
w('(error fetching contributions: ' + e.message + ')\n');
}
processedUsers += batch.length;
updateProgress(null, null, null);
continue;
}
// Recalibrate dynamicTotal: replace each user's pre-pass estimate
// with their actual contribs.length. Look up contribs under the
// api canonical name (which is what usercontribs returns in c.user)
// even when the input name was an old/renamed alias.
for (var rb = 0; rb < batch.length; rb++) {
var rbInputCanon = normalizeUsername(batch[rb]);
var rbApiCanon = apiNameByInputCanonical.get(rbInputCanon) || rbInputCanon;
var rbActual = (byUser.get(rbApiCanon) || byUser.get(rbInputCanon) || []).length;
var rbEstimate = editcountByCanonical.get(rbApiCanon);
if (typeof rbEstimate === 'number') {
dynamicTotal += (rbActual - rbEstimate);
} else {
dynamicTotal += rbActual;
}
}
// Track which keys in byUser we've "consumed" so we can detect
// contribs returned for names we didn't expect (extra renames,
// API quirks). Keys we know about up-front: the api-canonical
// name of every input in this batch.
var consumedKeys = new Set();
// Iterate users in input order
for (var u = 0; u < batch.length; u++) {
if (cancelRequested) { cancelledMidRun = true; break outer; }
var user = batch[u];
processedUsers++;
var inputCanon = normalizeUsername(user);
var apiCanon = apiNameByInputCanonical.get(inputCanon) || inputCanon;
// Try the api-canonical name first (handles renamed users
// where c.user is the new name), then fall back to the
// input name in case the rename mapping wasn't available.
var contribs = byUser.get(apiCanon);
if (contribs == null) contribs = byUser.get(inputCanon);
if (contribs == null) contribs = [];
consumedKeys.add(apiCanon);
consumedKeys.add(inputCanon);
var perUserHeader = '[' + processedUsers + '/' + usernames.length + '] ' +
user + ' - ' + contribs.length + ' edit(s)';
setStatus(perUserHeader);
logLine(perUserHeader);
updateProgress(user, 0, contribs.length);
// Build absolute URLs without hardcoding /wiki/ or /w/index.php,
// so the script also works on wikis with custom $wgArticlePath
// or $wgScriptPath.
var contribsUrl = location.origin + mw.util.getUrl('Special:Contributions/' + user);
w('\n' + '='.repeat(80) + '\n');
w('User: ' + user + '\n');
if (apiCanon !== inputCanon) {
w('Current name (after rename): ' + apiCanon + '\n');
}
w('Contribs: ' + contribsUrl + '\n');
w('='.repeat(80) + '\n');
if (contribs.length === 0) {
w('(no contributions found - account may be hidden, renamed, or never edited)\n');
continue;
}
w('Total contributions: ' + contribs.length + '\n\n');
for (var i = 0; i < contribs.length; i++) {
if (cancelRequested) { cancelledMidRun = true; break outer; }
var c = contribs[i];
var diffUrl = location.origin + INDEX_SCRIPT + '?diff=' + c.revid;
w('--- Edit ' + (i + 1) + '/' + contribs.length + ' ---\n');
w('Timestamp: ' + c.timestamp + '\n');
w('Article: ' + c.title + '\n');
w('Diff URL: ' + diffUrl + '\n');
w('Revision ID: ' + c.revid + ' (parent: ' + (c.parentid || 0) + ')\n');
var sd = (c.sizediff != null) ? (c.sizediff > 0 ? '+' + c.sizediff : c.sizediff) : '?';
w('Size change: ' + sd + '\n');
if (c.flags && c.flags.length) w('Flags: ' + c.flags.join(', ') + '\n');
if (includeSummary) {
if (c.commenthidden !== undefined) {
w('Edit summary: (hidden / revdel)\n');
} else if (c.comment != null && c.comment !== '') {
w('Edit summary: ' + oneLine(c.comment) + '\n');
} else if (c.comment === '') {
w('Edit summary: (empty)\n');
} else {
w('Edit summary: (none)\n');
}
}
if (includeDiff) {
// Wrap both the sleep and the getDiff call in a
// single try/catch. A cancellation during the
// delay must set cancelledMidRun and break out
// cleanly so the partial report still gets
// written and downloaded.
try {
await sleep(DELAY_MS);
var diff = await getDiff(c.revid, c.parentid);
w('\nDiff:\n' + diff + '\n');
} catch (e) {
if (e.message === 'cancelled') { cancelledMidRun = true; break outer; }
w('\nDiff:\n(fetch error: ' + e.message + ')\n');
}
}
w('\n');
editsDone++;
updateProgress(user, i + 1, contribs.length);
}
}
// Surface contribs returned for keys we never iterated. This
// catches rename mappings the API didn't give us, or any other
// case where c.user differs from both the input name and the
// api-reported canonical name.
byUser.forEach(function (arr, key) {
if (consumedKeys.has(key)) return;
if (!arr || arr.length === 0) return;
w('\n' + '='.repeat(80) + '\n');
w('Unmatched contribs returned for: ' + key + '\n');
w('(this name was not in the input list - possibly a renamed account whose current name was not detected)\n');
w('='.repeat(80) + '\n');
w('Total contributions: ' + arr.length + '\n\n');
for (var ui = 0; ui < arr.length; ui++) {
var uc = arr[ui];
w('--- Edit ' + (ui + 1) + '/' + arr.length + ' ---\n');
w('Timestamp: ' + uc.timestamp + '\n');
w('Article: ' + uc.title + '\n');
w('Diff URL: ' + location.origin + INDEX_SCRIPT + '?diff=' + uc.revid + '\n');
w('Revision ID: ' + uc.revid + ' (parent: ' + (uc.parentid || 0) + ')\n');
if (includeSummary) {
if (uc.commenthidden !== undefined) {
w('Edit summary: (hidden / revdel)\n');
} else if (uc.comment != null && uc.comment !== '') {
w('Edit summary: ' + oneLine(uc.comment) + '\n');
}
}
w('\n');
}
logLine('Note: ' + arr.length + ' contribs returned under unexpected name "' + key + '"');
});
// Small breather between batches
if (bi < batches.length - 1 && !cancelRequested) {
try { await sleep(DELAY_MS); }
catch (e) { if (e.message === 'cancelled') { cancelledMidRun = true; break; } else throw e; }
}
}
// Trigger download. Pass the fragment array directly to Blob -
// BlobPart[] is part of the Blob spec, so this avoids the peak
// memory doubling that out.join('') would cause on large reports.
var blob = new Blob(out, { type: 'text/plain;charset=utf-8' });
// Drop our reference to the fragment array so the GC can reclaim
// it while the download is being saved.
out = null;
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'contribs_report_' + new Date().toISOString().replace(/[:.]/g, '-') + '.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatus(cancelledMidRun ? 'Cancelled. Partial file downloaded.' : 'Done. File downloaded.');
logLine(cancelledMidRun ? 'Cancelled. Partial file downloaded.' : '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;
activeAbortControllers.clear();
sleepRejecters.clear();
$runBtn.prop('disabled', false);
$stopBtn.prop('disabled', true);
}
}
$runBtn.on('click', run);
$stopBtn.on('click', function () {
if (running) {
cancelRequested = true;
setStatus('Cancelling...');
abortAllInFlight();
}
});
});
// </nowiki>