MediaWiki:BatchDelete.js

来自3D坦克-WIKI

注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。

  • Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
  • Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
  • Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/* Wiki 批量删除工具 v1.1 — 放入 MediaWiki:BatchDelete.js
 * 入口:Project:批量删除。使用当前登录账号及本站 API,不发送数据至外站。
 * 只删除人工输入并经预检、二次确认的页面;不递归删除链接、子页面或文件实体。
 */
(function () {
    'use strict';
    if (mw.config.get('wgNamespaceNumber') !== 4 || mw.config.get('wgTitle') !== '批量删除' ||
        mw.config.get('wgAction') !== 'view') { return; }
    if (window.wikiBatchDeleteLoaded) { return; }
    window.wikiBatchDeleteLoaded = true;

    mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(function () {
        $(function () {
            var root = document.getElementById('wiki-batch-delete');
            if (!root) {
                root = document.createElement('div');
                (document.getElementById('mw-content-text') || document.body).appendChild(root);
            }
            var api = new mw.Api(), account = null, rightsOK = false;
            var busy = false, running = false, stopping = false, ready = false;
            var records = [], activeJob = null;
            var LIMIT = 500;

            mw.util.addCSS(
                '#wiki-batch-delete{font-size:14px;line-height:1.6;max-width:1100px;color:inherit}' +
                '#wiki-batch-delete *{box-sizing:border-box}' +
                '#wiki-batch-delete .bd-card{border:1px solid #a2a9b1;border-radius:8px;padding:20px;margin:14px 0}' +
                '#wiki-batch-delete .bd-warning{border-left:4px solid #d33;padding:12px 16px;background:#fff4f3;color:#202122}' +
                '#wiki-batch-delete label{display:block;font-weight:bold;margin:12px 0 6px}' +
                '#wiki-batch-delete textarea,#wiki-batch-delete input[type=text]{display:block;width:100%;padding:9px;border:1px solid #72777d;border-radius:4px;background:#fff;color:#202122;font:inherit}' +
                '#wiki-batch-delete textarea{min-height:210px;font-family:monospace}' +
                '#wiki-batch-delete button{padding:8px 14px;margin:6px 8px 6px 0;border:1px solid #72777d;border-radius:4px;cursor:pointer;background:#f8f9fa;color:#202122;font:inherit}' +
                '#wiki-batch-delete button.bd-primary{background:#36c;border-color:#36c;color:#fff}' +
                '#wiki-batch-delete button.bd-danger{background:#b32424;border-color:#b32424;color:#fff}' +
                '#wiki-batch-delete button:disabled{opacity:.45;cursor:not-allowed}' +
                '#wiki-batch-delete .bd-help{color:#54595d;font-size:13px}' +
                '#wiki-batch-delete .bd-scroll{overflow:auto;max-height:520px}' +
                '#wiki-batch-delete table{border-collapse:collapse;width:100%;font-size:13px}' +
                '#wiki-batch-delete th,#wiki-batch-delete td{border-bottom:1px solid #a2a9b1;padding:9px;text-align:left;vertical-align:top;overflow-wrap:anywhere}' +
                '#wiki-batch-delete th{background:#eaecf0;color:#202122;position:sticky;top:0}' +
                '#wiki-batch-delete progress{width:100%;height:18px}' +
                '#wiki-batch-delete .bd-check{font-weight:normal;display:flex;align-items:flex-start;gap:8px}' +
                '#wiki-batch-delete .bd-check input{margin-top:6px}' +
                '#wiki-batch-delete .bd-status{white-space:pre-wrap}'
            );
            root.innerHTML =
                '<h2>批量删除页面</h2>' +
                '<div class="bd-warning"><b>此操作会删除页面及其修订历史。</b>通常可由具有恢复权限的管理员恢复,但请先备份。' +
                '本工具不清理缓存、关联图片、入链或子页面,也不会绕过站点权限。</div>' +
                '<p id="bd-permission" role="status">正在检查当前账号权限……</p>' +
                '<div class="bd-card">' +
                '<label for="bd-titles">页面名称 · 每行一个</label>' +
                '<textarea id="bd-titles" placeholder="示例页面一\n示例页面二\n模板:示例模板" spellcheck="false"></textarea>' +
                '<p class="bd-help">最多 500 个不重复名称。输入完整页面名称,不要输入网址、[[链接语法]]或 #章节。' +
                '空行和重复名称自动忽略;重定向只删除重定向页本身。文件及其讨论页、MediaWiki 界面页及其讨论页、本工具入口受到安全限制。</p>' +
                '<label for="bd-reason">删除理由(必填,会进入公开删除日志)</label>' +
                '<input id="bd-reason" type="text" maxlength="150" placeholder="例如:清理不适用于国服的外服赛事及平台内容">' +
                '<button id="bd-preview" class="bd-primary" disabled>① 检查并预览</button>' +
                '<button id="bd-export" disabled>下载 CSV 日志</button>' +
                '</div>' +
                '<div class="bd-card">' +
                '<p id="bd-summary" role="status">尚未检查页面。</p>' +
                '<progress id="bd-progress" max="1" value="0"></progress>' +
                '<div class="bd-scroll"><table><thead><tr><th>#</th><th>页面</th><th>状态</th><th>详情</th></tr></thead><tbody id="bd-rows"></tbody></table></div>' +
                '</div>' +
                '<div class="bd-card">' +
                '<label class="bd-check"><input id="bd-agree" type="checkbox">' +
                '<span>我已检查以上列表并做好备份,同意删除所有标记为“待删除”的页面及其历史修订。</span></label>' +
                '<label for="bd-confirm">输入“确认删除”</label><input id="bd-confirm" type="text" autocomplete="off">' +
                '<button id="bd-delete" class="bd-danger" disabled>② 执行批量删除</button>' +
                '<button id="bd-stop" disabled>停止后续删除</button>' +
                '<p class="bd-help">逐页串行执行,约每秒处理一页。停止不会撤销已完成的删除,也不能取消已经发送的请求。' +
                '预检后有新修订或页面 ID 变化的页面会跳过;发生结果不明或权限错误时自动停止。失败后请核实删除日志,再重新预检,不自动重试。</p>' +
                '<p id="bd-status" class="bd-status" role="status" aria-live="polite"></p>' +
                '</div>';

            function el(id) { return root.querySelector('#bd-' + id); }
            function text(id, value) { el(id).textContent = value; }
            function pending() { return records.filter(function (r) { return r.status === '待删除'; }); }
            function status(message) { text('status', message); }
            function updateButtons() {
                el('preview').disabled = busy || !rightsOK;
                el('titles').disabled = busy;
                el('reason').disabled = busy;
                el('agree').disabled = busy || !ready;
                el('confirm').disabled = busy || !ready;
                el('delete').disabled = busy || !ready || !rightsOK || !pending().length ||
                    !el('reason').value.trim() || !el('agree').checked || el('confirm').value.trim() !== '确认删除';
                el('stop').disabled = !running || stopping;
                el('export').disabled = !records.length;
            }
            function invalidate() {
                if (busy) { return; }
                ready = false;
                el('agree').checked = false;
                el('confirm').value = '';
                status('输入已变更,请重新检查并预览。');
                updateButtons();
            }
            function request(params, write) {
                return new Promise(function (resolve, reject) {
                    var q = write ? api.postWithToken('csrf', params) : api.post(params);
                    q.then(resolve, function (code, result) {
                        var detail = result && result.error && result.error.info;
                        if (!detail && result && result.textStatus) { detail = result.textStatus; }
                        reject({ code: String(code || 'unknown'), info: String(detail || code || '请求失败') });
                    });
                });
            }
            function identity() {
                return request({ action: 'query', meta: 'userinfo', uiprop: 'rights', formatversion: 2 }).then(function (data) {
                    var u = data.query && data.query.userinfo;
                    if (!u || !u.id || (u.rights || []).indexOf('delete') === -1) {
                        throw { code: 'permissiondenied', info: '当前账号未登录或没有 delete(删除页面)权限。界面管理员身份不一定包含此权限。' };
                    }
                    if (account && account.id !== u.id) {
                        throw { code: 'accountchanged', info: '登录账号发生变化,请刷新本页面后重试。' };
                    }
                    return u;
                });
            }
            function render() {
                var body = el('rows');
                body.textContent = '';
                records.forEach(function (r, i) {
                    var tr = document.createElement('tr');
                    [String(i + 1), r.title, r.status, r.detail || ''].forEach(function (s, j) {
                        var td = document.createElement('td');
                        if (j === 1 && r.pageid) {
                            var a = document.createElement('a');
                            a.href = mw.util.getUrl(r.title);
                            a.target = '_blank';
                            a.rel = 'noopener noreferrer';
                            a.textContent = s;
                            td.appendChild(a);
                        } else { td.textContent = s; }
                        tr.appendChild(td);
                    });
                    body.appendChild(tr);
                });
                var counts = {};
                records.forEach(function (r) { counts[r.status] = (counts[r.status] || 0) + 1; });
                text('summary', '共 ' + records.length + ' 页 · ' + Object.keys(counts).map(function (k) { return k + ' ' + counts[k]; }).join(' / '));
            }
            function unsafePage(p) {
                if ([7, 8, 9].indexOf(p.ns) !== -1) {
                    return '安全限制:不批量删除文件讨论、界面及界面讨论页面。';
                }
                if (p.ns === 4 && p.title.slice(p.title.indexOf(':') + 1) === '批量删除') {
                    return '安全限制:不能删除本工具入口。';
                }
                return '';
            }
            function collect() {
                var seen = Object.create(null), result = [];
                el('titles').value.split(/\r?\n/).forEach(function (line) {
                    var s = line.trim().replace(/_/g, ' ');
                    if (!s || seen[s]) { return; }
                    seen[s] = true;
                    var r = { input: s, title: s, status: '待检查', detail: '', pageid: null, revid: null, time: '', reason: '' };
                    if (/[\[\]#|<>\u0000-\u001f]/.test(s) || /^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(s)) {
                        r.status = '输入无效'; r.detail = '请填写页面名称,不要填写网址、链接语法、章节或控制字符。';
                    }
                    result.push(r);
                });
                return result;
            }
            async function preview() {
                if (busy) { return; }
                var next = collect();
                if (!next.length) { status('请至少输入一个页面名称。'); return; }
                if (next.length > LIMIT) { status('一次最多检查 500 页,请分批执行。'); return; }
                if (!el('reason').value.trim()) { status('请填写删除理由。'); return; }
                records = next; busy = true; ready = false;
                el('agree').checked = false; el('confirm').value = '';
                el('progress').max = records.length; el('progress').value = 0;
                records.forEach(function (r) { r.reason = el('reason').value.trim(); });
                updateButtons(); render(); status('正在预检,尚未执行任何删除……');
                try {
                    await identity();
                    var list = records.filter(function (r) { return r.status === '待检查'; });
                    var seenIds = Object.create(null);
                    for (var i = 0; i < list.length; i += 50) {
                        var batch = list.slice(i, i + 50);
                        var data = await request({ action: 'query', prop: 'info', titles: batch.map(function (r) { return r.title; }).join('|'),
                            assert: 'user', formatversion: 2 });
                        var q = data.query || {}, aliases = Object.create(null), pages = q.pages || [];
                        (q.normalized || []).concat(q.converted || []).forEach(function (n) { aliases[n.from] = n.to; });
                        function canonical(title) {
                            var visited = Object.create(null);
                            while (aliases[title] && !visited[title]) { visited[title] = true; title = aliases[title]; }
                            return title;
                        }
                        batch.forEach(function (r) {
                            r.title = canonical(r.title);
                            var p = pages.find(function (x) { return x.title === r.title; });
                            if (!p) { r.status = '跳过'; r.detail = '未找到本站页面信息(可能为跨站名称)。'; return; }
                            if (p.invalid || p.ns < 0) { r.status = '输入无效'; r.detail = p.invalidreason || '无效或特殊页面。'; return; }
                            if (p.missing || !p.pageid) { r.status = '不存在'; r.detail = '无需删除。'; return; }
                            r.pageid = p.pageid; r.revid = p.lastrevid; r.title = p.title;
                            var blocked = unsafePage(p);
                            if (blocked) { r.status = '跳过'; r.detail = blocked; return; }
                            if (seenIds[p.pageid]) { r.status = '重复页面'; r.detail = '规范化后与其他输入相同,不重复操作。'; return; }
                            seenIds[p.pageid] = true;
                            r.status = '待删除';
                            r.detail = '页面 ID ' + p.pageid + ';最新修订 ' + p.lastrevid + (p.redirect ? ';这是重定向页,只删除此页。' : '');
                        });
                        el('progress').value = Math.min(records.length, i + batch.length);
                        render();
                    }
                    ready = true; el('progress').value = records.length;
                    status('预检完成。请核对列表,再勾选确认并输入“确认删除”。');
                } catch (e) {
                    ready = false;
                    records.forEach(function (r) { if (r.status === '待检查') { r.status = '检查失败'; r.detail = e.info || String(e); } });
                    status('预检未完成,禁止执行删除:' + (e.info || String(e)));
                } finally { busy = false; render(); updateButtons(); }
            }
            function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }
            async function execute() {
                if (el('delete').disabled || busy) { return; }
                var jobs = pending().slice(), reason = el('reason').value.trim();
                if (!window.confirm('最后确认:以“' + account.name + '”删除 ' + jobs.length + ' 个页面及其修订历史?\n\n理由:' + reason + '\n\n已完成的删除不会因点击停止而撤销。')) { return; }
                busy = true; running = true; stopping = false; ready = false;
                el('progress').max = jobs.length; el('progress').value = 0;
                updateButtons();
                var processed = 0;
                try {
                    await identity();
                    for (var i = 0; i < jobs.length; i++) {
                        if (stopping) { break; }
                        var r = jobs[i]; activeJob = r; r.status = '核对中'; render();
                        status('正在处理 ' + (i + 1) + ' / ' + jobs.length + ':' + r.title);
                        var sentDelete = false;
                        try {
                            // Preflight by title: a move/recreation/revision change since preview is skipped.
                            var check = await request({ action: 'query', titles: r.title, prop: 'info', formatversion: 2, assert: 'user' });
                            var p = check.query && check.query.pages && check.query.pages[0];
                            if (stopping) { r.status = '未执行'; r.detail = '已停止,未发送删除请求。'; break; }
                            if (!p || p.missing || p.invalid || p.pageid !== r.pageid || p.lastrevid !== r.revid || p.title !== r.title) {
                                r.status = '变化跳过'; r.detail = '预检后页面已改动、移动、重建或删除。请重新检查。';
                            } else {
                                r.status = '删除中'; render(); sentDelete = true;
                                var result = await request({ action: 'delete', pageid: r.pageid, reason: reason,
                                    assert: 'user', assertuser: account.name, watchlist: 'nochange', formatversion: 2, maxlag: 5 }, true);
                                // MediaWiki 1.43 returns title + reason + logid, NOT pageid.
                                // Large deletions may return scheduled:true instead of a log ID.
                                var deletion = result && result.delete;
                                var scheduled = deletion && deletion.scheduled === true;
                                var logId = deletion && Number(deletion.logid);
                                var hasLog = Number.isSafeInteger(logId) && logId > 0;
                                var optionalPageIdOK = deletion && (deletion.pageid === undefined || Number(deletion.pageid) === r.pageid);
                                if (!deletion || deletion.title !== r.title || !optionalPageIdOK || (!scheduled && !hasLog)) {
                                    throw { code: 'unexpected-response', info: '响应未包含匹配的页面标题及删除日志 ID 或后台任务确认,请检查本站删除日志,不要直接重试。' };
                                }
                                if (scheduled) {
                                    r.status = '已提交后台';
                                    r.detail = '服务器已接受后台删除任务,尚不代表完成。请稍后核对页面和删除日志,不要重复提交。';
                                } else {
                                    r.status = '已删除';
                                    r.detail = '删除成功;' + deletion.title + ';日志 ID ' + logId;
                                }
                            }
                        } catch (e) {
                            var code = e.code || 'unknown';
                            var uncertain = sentDelete && ['http', 'unknown', 'unexpected-response', 'timeout', 'abort'].indexOf(code) !== -1;
                            r.status = uncertain ? '结果待核实' : '失败';
                            r.detail = code + ':' + (e.info || String(e));
                            // Do not retry deletion. Stop on any exception; user must verify current state first.
                            stopping = true;
                            status('已暂停:' + r.title + '\n' + r.detail + '\n请检查页面和本站删除日志,再重新预检。');
                        } finally {
                            r.time = new Date().toISOString(); r.reason = reason;
                            processed++; el('progress').value = processed; render();
                        }
                        activeJob = null;
                        if (!stopping && i < jobs.length - 1) { await sleep(1000); }
                    }
                } catch (e) {
                    stopping = true; status('无法继续:' + (e.info || String(e)));
                } finally {
                    jobs.forEach(function (r) {
                        if (r.status === '待删除' || r.status === '核对中') { r.status = '未执行'; r.detail = '批次停止,未发送删除请求。'; }
                    });
                    activeJob = null; busy = false; running = false; ready = false;
                    el('agree').checked = false; el('confirm').value = '';
                    render(); updateButtons();
                    if (!stopping) {
                        var queued = jobs.filter(function (r) { return r.status === '已提交后台'; }).length;
                        status('本批次请求处理完成。' + (queued ? '其中 ' + queued + ' 页已提交后台,尚未确认删除完成,请稍后核对,勿重复提交。' : '') + '建议下载日志留档。再次执行需要重新预检。');
                    }
                    else { status(el('status').textContent + '\n批次已停止。已完成的删除未撤销;请下载日志。'); }
                }
            }
            function csvCell(value) {
                var s = String(value == null ? '' : value);
                // Protect spreadsheet formula injection, including page names beginning with =,+,-,@.
                if (/^[\s]*[=+\-@]/.test(s)) { s = "'" + s; }
                return '"' + s.replace(/"/g, '""') + '"';
            }
            function exportLog() {
                var rows = [['序号', '输入名称', '规范页面名', '页面ID', '预检修订ID', '状态', '详情', '删除理由', '处理时间UTC', '操作账号']];
                records.forEach(function (r, i) { rows.push([i + 1, r.input, r.title, r.pageid, r.revid, r.status, r.detail, r.reason, r.time, account ? account.name : '']); });
                var csv = '\uFEFF' + rows.map(function (row) { return row.map(csvCell).join(','); }).join('\r\n');
                var url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
                var a = document.createElement('a'); a.href = url;
                a.download = 'Wiki批量删除日志-' + new Date().toISOString().replace(/[:.]/g, '-') + '.csv';
                document.body.appendChild(a); a.click(); a.remove();
                setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
            }
            el('titles').addEventListener('input', invalidate);
            el('reason').addEventListener('input', invalidate);
            el('agree').addEventListener('change', updateButtons);
            el('confirm').addEventListener('input', updateButtons);
            el('preview').addEventListener('click', preview);
            el('delete').addEventListener('click', execute);
            el('stop').addEventListener('click', function () {
                stopping = true; updateButtons();
                status('已请求停止。' + (activeJob ? '等待当前步骤返回:' + activeJob.title : '不再开始下一页。'));
            });
            el('export').addEventListener('click', exportLog);
            window.addEventListener('beforeunload', function (event) {
                if (busy) { event.preventDefault(); event.returnValue = ''; }
            });
            identity().then(function (u) {
                account = { id: u.id, name: u.name }; rightsOK = true;
                text('permission', '当前账号:' + u.name + ' · 已具备删除页面权限。');
                updateButtons();
            }).catch(function (e) {
                rightsOK = false; text('permission', e.info || String(e)); updateButtons();
            });
            updateButtons();
        });
    }).catch(function () {
        var root = document.getElementById('wiki-batch-delete');
        if (root) { root.textContent = '批量删除工具加载失败,请刷新页面或检查浏览器控制台。'; }
    });
}());
报告 Wiki 中的错误
请不要使用此表单申诉封号或封锁处罚。此表单仅用于反馈与 Wiki 相关的错误或改进建议。
您的游戏昵称
页面链接
我们已为您自动填写此项,如有误,请从浏览器地址栏复制所需页面的链接并粘贴至此处。
留言内容
请告诉我们您希望修改该页面的哪些内容,或指出其中的错误/不准确之处。可附带截图链接。