Jump to content

User:Polygnotus/Scripts/GetUserspace.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <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>