来自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 = '批量删除工具加载失败,请刷新页面或检查浏览器控制台。'; }
});
}());