来自3D坦克-WIKI
注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。
- Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
- Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
- Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/* Wiki 批量上传工具 — duplicate-archive 白名单补丁
* 替换 MediaWiki:BatchUpload.js;基于源站修订 20890,仅改变允许自动确认的警告及对应文案。
* 自动确认仅限 duplicate / duplicate-archive / exists-normalized,且须启用界面复选框。
* 第一次请求不忽略警告;确认时必须有 filekey,并再次检查目标不存在。
* 完全同名文件、说明页及共享文件仍会跳过;其他警告仍需人工处理。
* 不恢复或清理删除存档,不绕过上传权限,不向外站发送文件。
*/
(function () {
'use strict';
var CONFIG = {
namespace: 4,
pageTitle: '批量上传',
containerId: 'mw-batch-upload',
maxFiles: 10000,
pageSize: 50,
defaultConcurrency: 3,
maxConcurrency: 6,
minStartIntervalMs: 500, // 全局 API 请求启动间隔;不是“每个 worker 各自间隔”
ignoreContentDuplicates: true,
maxRateRetries: 3, // 每个文件的限流/负载错误最多自动重试 3 次
rateBackoffBaseMs: 30000,
minRetryIntervalMs: 1000, // 限流后至少将本轮请求间隔提高到此值
maxlagBackoffBaseMs: 5000,
renderIntervalMs: 100
};
if (mw.config.get('wgNamespaceNumber') !== CONFIG.namespace ||
mw.config.get('wgTitle') !== CONFIG.pageTitle ||
mw.config.get('wgAction') !== 'view' || mw.config.get('wgDiffNewId')) {
return;
}
mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(function () {
jQuery(function () {
var root = document.getElementById(CONFIG.containerId);
if (!root || root.dataset.batchUploadReady) { return; }
root.dataset.batchUploadReady = '1';
mount(root);
});
}, function () {
jQuery(function () {
var root = document.getElementById(CONFIG.containerId);
if (root) { root.textContent = '无法加载 MediaWiki API 模块,请强制刷新或联系界面管理员。'; }
});
});
function mount(root) {
mw.util.addCSS([
'#mw-batch-upload{--bu-border:var(--border-color-subtle,#c8ccd1);--bu-muted:var(--color-subtle,#54595d);font-size:14px;line-height:1.6;color:var(--color-base,#202122);margin:1em 0;min-width:0}',
'#mw-batch-upload *{box-sizing:border-box}',
'#mw-batch-upload .bu-panel{background:var(--background-color-base,#fff);border:1px solid var(--bu-border);border-radius:8px;padding:20px;min-width:0}',
'#mw-batch-upload .bu-heading{font-size:22px;font-weight:700;margin:0 0 4px}',
'#mw-batch-upload .bu-note{font-size:13px;color:var(--bu-muted);margin:6px 0 12px}',
'#mw-batch-upload .bu-drop{padding:22px 12px;text-align:center;border:2px dashed var(--bu-border);border-radius:6px;margin:16px 0;background:var(--background-color-interactive-subtle,#f8f9fa)}',
'#mw-batch-upload .bu-drop.bu-over{border-color:#36c;background:#eaf3ff;color:#202122}',
'#mw-batch-upload label{display:block;font-weight:600}',
'#mw-batch-upload input[type=text],#mw-batch-upload input[type=number],#mw-batch-upload select,#mw-batch-upload textarea{max-width:100%;width:100%;padding:8px;border:1px solid var(--bu-border);border-radius:4px;background:var(--background-color-base,#fff);color:inherit;font:inherit;font-weight:400}',
'#mw-batch-upload textarea{min-height:96px;resize:vertical}',
'#mw-batch-upload .bu-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:14px;margin:14px 0}',
'#mw-batch-upload .bu-settings{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,170px),1fr));gap:14px;margin:14px 0}',
'#mw-batch-upload .bu-inline{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:8px 0 12px}',
'#mw-batch-upload .bu-inline input{flex:1;min-width:100px}',
'#mw-batch-upload .bu-check{display:flex;gap:8px;align-items:flex-start;font-weight:400}',
'#mw-batch-upload .bu-check input{margin-top:6px}',
'#mw-batch-upload button{padding:7px 12px;border:1px solid var(--bu-border);border-radius:4px;background:var(--background-color-interactive,#eaecf0);color:inherit;font:inherit;font-weight:600;cursor:pointer;white-space:normal}',
'#mw-batch-upload .bu-primary{background:#36c;color:#fff;border-color:#36c}',
'#mw-batch-upload button:disabled{opacity:.5;cursor:not-allowed}',
'#mw-batch-upload input:disabled,#mw-batch-upload textarea:disabled,#mw-batch-upload select:disabled{opacity:.7}',
'#mw-batch-upload button:focus-visible,#mw-batch-upload input:focus-visible,#mw-batch-upload select:focus-visible,#mw-batch-upload textarea:focus-visible{outline:2px solid #36c;outline-offset:2px}',
'#mw-batch-upload .bu-toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}',
'#mw-batch-upload .bu-message{padding:10px 12px;border-left:3px solid #36c;background:var(--background-color-interactive-subtle,#f8f9fa);margin:10px 0;white-space:pre-wrap;overflow-wrap:anywhere}',
'#mw-batch-upload .bu-summary{font-weight:600;overflow-wrap:anywhere}',
'#mw-batch-upload .bu-runtime{font-size:13px;min-height:1.6em;color:var(--bu-muted)}',
'#mw-batch-upload progress{display:block;width:100%;height:8px;accent-color:#36c;margin:8px 0}',
'#mw-batch-upload .bu-filter{display:flex;flex-wrap:wrap;align-items:end;gap:10px;margin:18px 0 8px}',
'#mw-batch-upload .bu-filter label:first-child{flex:2;min-width:160px}',
'#mw-batch-upload .bu-filter label:nth-child(2){flex:1;min-width:135px}',
'#mw-batch-upload .bu-table-region{container-type:inline-size;container-name:buqueue}',
'#mw-batch-upload table{width:100%;border-collapse:collapse;table-layout:fixed}',
'#mw-batch-upload th,#mw-batch-upload td{padding:10px 8px;border-bottom:1px solid var(--bu-border);text-align:left;vertical-align:top;overflow-wrap:anywhere}',
'#mw-batch-upload th{background:var(--background-color-interactive-subtle,#f8f9fa)}',
'#mw-batch-upload th:nth-child(1){width:23%}#mw-batch-upload th:nth-child(2){width:29%}#mw-batch-upload th:nth-child(3){width:35%}#mw-batch-upload th:nth-child(4){width:13%}',
'#mw-batch-upload .bu-size{font-size:12px;color:var(--bu-muted)}',
'#mw-batch-upload .bu-status{white-space:pre-wrap}',
'#mw-batch-upload tr[data-state=success] .bu-status{color:var(--color-success,#14866d)}',
'#mw-batch-upload tr[data-state=warning] .bu-status{color:var(--color-warning,#8b5a00)}',
'#mw-batch-upload tr[data-state=failed] .bu-status{color:var(--color-error,#b32424)}',
'#mw-batch-upload tr[data-state=skipped] .bu-status{color:var(--bu-muted)}',
'#mw-batch-upload details{margin:6px 0;font-size:12px}',
'#mw-batch-upload summary{cursor:pointer;color:var(--bu-muted)}',
'#mw-batch-upload pre{font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto;margin:5px 0;padding:8px;border:1px solid var(--bu-border);background:var(--background-color-interactive-subtle,#f8f9fa);color:inherit}',
'#mw-batch-upload .bu-row-actions{display:flex;gap:6px;flex-wrap:wrap}',
'#mw-batch-upload .bu-row-actions button{padding:4px 8px;font-size:12px}',
'#mw-batch-upload .bu-empty{text-align:center;padding:24px;color:var(--bu-muted)}',
'#mw-batch-upload .bu-pages{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-top:12px}',
'#mw-batch-upload [hidden]{display:none!important}',
'@container buqueue (max-width:620px){#mw-batch-upload table,#mw-batch-upload tbody,#mw-batch-upload tr,#mw-batch-upload td{display:block;width:100%}#mw-batch-upload thead{display:none}#mw-batch-upload tr{padding:12px 0;border-bottom:1px solid var(--bu-border)}#mw-batch-upload td{border:0;padding:4px 0}#mw-batch-upload td::before{content:attr(data-label);display:block;font-size:12px;color:var(--bu-muted)}}',
'@media(max-width:620px){#mw-batch-upload .bu-panel{padding:12px}#mw-batch-upload table,#mw-batch-upload tbody,#mw-batch-upload tr,#mw-batch-upload td{display:block;width:100%}#mw-batch-upload thead{display:none}#mw-batch-upload tr{padding:12px 0;border-bottom:1px solid var(--bu-border)}#mw-batch-upload td{border:0;padding:4px 0}#mw-batch-upload td::before{content:attr(data-label);display:block;font-size:12px;color:var(--bu-muted)}}'
].join('\n'));
// 这里只插入固定界面;文件名、输入、API 信息一律通过 textContent/value 写入。
root.innerHTML = `
<section class="bu-panel" aria-label="批量上传文件">
<div class="bu-heading">批量上传文件</div>
<p class="bu-note">内容重复(duplicate)、与已删除存档内容重复(duplicate-archive)、同名不同扩展名(exists-normalized)可自动确认;已有完全同名文件会跳过。大批量任务建议使用电脑并保持此页打开。</p>
<div data-el="message" class="bu-message" role="status" aria-live="polite">正在检查登录状态和上传权限……</div>
<div data-el="drop" class="bu-drop">
<button type="button" data-el="choose" disabled>选择多个文件</button> 或将文件拖到这里
<input type="file" multiple hidden data-el="files" aria-label="选择待上传文件" disabled>
<div class="bu-note" data-el="queueLimit"></div>
</div>
<div class="bu-settings">
<label>并发文件数 <select data-el="concurrency" disabled></select></label>
<label>API 请求启动间隔(毫秒)<input type="number" data-el="gap" min="0" max="60000" step="100" disabled></label>
</div>
<label class="bu-check"><input type="checkbox" data-el="duplicates" disabled><span>自动确认 duplicate、duplicate-archive 与 exists-normalized 警告
<span class="bu-note">duplicate-archive:上传内容与曾被删除并进入存档的文件内容相同。允许以当前目标名重新上传,不会恢复或清理原删除存档;不应据此重新上传因侵权、隐私等原因被删除的内容。exists-normalized:站内已有同名但扩展名不同的文件(如已有 X.jpg 时上传 X.png)。完全同名文件仍会跳过;不修改已有文件说明。目标名称本身曾被删除的 was-deleted 警告仍需人工处理。</span></span></label>
<div class="bu-fields">
<label>统一文件说明(维基语法)<textarea data-el="description" placeholder="填写真实来源、作者和授权说明。许可模板需使用本站已有且确实适用的模板。" disabled></textarea></label>
<label>分类(可选,每行一个名称)<textarea data-el="categories" placeholder="活动照片 2026年资料" disabled></textarea></label>
</div>
<label for="bu-prefix">文件名前缀(可选)</label>
<div class="bu-inline"><input id="bu-prefix" data-el="prefix" type="text" placeholder="例如:项目A-" disabled><button data-el="applyPrefix" type="button" disabled>应用到未成功项</button></div>
<p class="bu-note">应用前缀会用“前缀 + 原始文件名”替换未成功项的手工改名,并将它们重新排队;不会叠加前缀。</p>
<div class="bu-toolbar">
<button type="button" data-el="start" class="bu-primary" disabled>开始 / 继续上传</button>
<button type="button" data-el="pause" disabled>暂停新请求</button>
<button type="button" data-el="retry" disabled>失败项重新排队</button>
<button type="button" data-el="export" disabled>导出结果 CSV</button>
<button type="button" data-el="clear" disabled>清空列表</button>
</div>
<div data-el="summary" class="bu-summary" role="status" aria-live="polite">尚未选择文件</div>
<div data-el="runtime" class="bu-runtime"></div>
<progress data-el="overall" max="1" value="0" aria-label="已处理文件数量"></progress>
<div class="bu-filter">
<label>筛选文件名<input type="text" data-el="search" placeholder="本地名称或目标名称"></label>
<label>状态<select data-el="filter"><option value="all">全部</option><option value="uploading">正在处理 / 等待重试</option><option value="queued">待处理</option><option value="success">成功</option><option value="skipped">跳过(同名已存在)</option><option value="warning">其他警告</option><option value="failed">失败</option></select></label>
</div>
<div class="bu-table-region">
<table><thead><tr><th scope="col">本地文件</th><th scope="col">目标文件名</th><th scope="col">状态 / 传输进度</th><th scope="col">操作</th></tr></thead><tbody data-el="rows"></tbody></table>
<div data-el="empty" class="bu-empty">选择文件后显示上传队列。</div>
</div>
<div class="bu-pages"><button type="button" data-el="prev">上一页</button><span data-el="pageInfo"></span><button type="button" data-el="next">下一页</button></div>
<p class="bu-note">列表分页不影响后台队列,所有待处理项都会依次调度。100% 传输不等于成功;以服务器确认结果为准。清空列表不删除站内文件。</p>
<p class="bu-note">暂停不强行中断已发出的请求,仍可能有少量在途文件完成。关闭或刷新会丢失待传队列;不支持分片或跨刷新断点续传。并发与间隔都不能绕过本站限流。</p>
</section>`;
var ui = {};
root.querySelectorAll('[data-el]').forEach(function (el) { ui[el.dataset.el] = el; });
for (var n = 1; n <= CONFIG.maxConcurrency; n++) {
var option = document.createElement('option');
option.value = String(n); option.textContent = n + ' 路'; ui.concurrency.appendChild(option);
}
ui.concurrency.value = String(CONFIG.defaultConcurrency);
ui.gap.value = String(CONFIG.minStartIntervalMs);
ui.duplicates.checked = CONFIG.ignoreContentDuplicates;
ui.queueLimit.textContent = '可分批添加,队列上限 ' + CONFIG.maxFiles.toLocaleString() + ' 项;只渲染当前页,不生成数千行 DOM。不支持文件夹。';
var items = [];
var serial = 0;
var counts = { queued: 0, uploading: 0, success: 0, skipped: 0, warning: 0, failed: 0 };
var labels = { queued: '待处理', uploading: '处理中', success: '成功', skipped: '已跳过', warning: '警告', failed: '失败' };
var ready = false;
var busy = false;
var session = null;
var api;
var activeClaims = new Map();
var cooldownUntil = 0;
var cooldownReason = '';
var pageNumber = 1;
var queueVersion = 0;
var viewVersion = -1;
var viewKey = '';
var filtered = [];
var renderedIds = '';
var rowMap = new Map();
var renderTimer = null;
var runtimeTimer = null;
var searchTimer = null;
var userName = mw.config.get('wgUserName');
var namespaces = mw.config.get('wgFormattedNamespaces') || {};
var fileNamespace = namespaces[6] || 'File';
var categoryNamespace = namespaces[14] || 'Category';
var number = function (v) { return v.toLocaleString(); };
function setText(el, text) { if (el.textContent !== text) { el.textContent = text; } }
function notice(text) { setText(ui.message, text); }
function fileUrl(name) { return mw.util.getUrl(fileNamespace + ':' + name); }
function sizeText(bytes) {
if (bytes < 1024) { return bytes + ' B'; }
if (bytes < 1048576) { return (bytes / 1024).toFixed(1) + ' KiB'; }
if (bytes < 1073741824) { return (bytes / 1048576).toFixed(1) + ' MiB'; }
return (bytes / 1073741824).toFixed(2) + ' GiB';
}
function scheduleRender() {
if (renderTimer === null) {
renderTimer = window.setTimeout(function () { renderTimer = null; render(); }, CONFIG.renderIntervalMs);
}
}
function flushRender() {
if (renderTimer !== null) { window.clearTimeout(renderTimer); renderTimer = null; }
render();
}
function state(item, name, text, detail, code) {
if (item.state !== name) { counts[item.state]--; counts[name]++; item.state = name; }
item.message = text;
item.detail = detail || '';
item.code = code || '';
if (name === 'queued') { item.progress = 0; }
if (name === 'success') { item.progress = 100; item.stashKey = null; }
queueVersion++;
scheduleRender();
}
function phase(item, text, progress) {
item.message = text;
if (progress !== undefined) { item.progress = progress; }
scheduleRender();
}
function resetItem(item, discardStash) {
if (item.state === 'success') { return; }
if (discardStash) { item.stashKey = null; }
item.autoConfirmed = false;
state(item, 'queued', '等待上传');
}
function getView() {
var term = ui.search.value.trim().toLowerCase();
var filter = ui.filter.value;
var key = term + '\u0000' + filter;
if (viewVersion !== queueVersion || key !== viewKey) {
filtered = items.filter(function (item) {
return (filter === 'all' || item.state === filter) &&
(!term || item.file.name.toLowerCase().includes(term) || item.target.toLowerCase().includes(term));
});
viewVersion = queueVersion; viewKey = key;
}
return filtered;
}
function makeRow(item) {
var row = document.createElement('tr');
row.dataset.id = String(item.id);
row.innerHTML = '<td data-label="本地文件"><div class="bu-name"></div><div class="bu-size"></div></td>' +
'<td data-label="目标文件名"><input type="text"></td>' +
'<td data-label="状态 / 传输进度"><div class="bu-status"></div><progress max="100" value="0" hidden></progress><details hidden><summary>完整信息</summary><pre></pre></details><a target="_blank" rel="noopener noreferrer" hidden></a></td>' +
'<td data-label="操作"><div class="bu-row-actions"><button type="button" data-action="requeue">重新排队</button><button type="button" data-action="remove" title="仅移出队列,不删除站内文件">移除</button></div></td>';
var refs = {
row: row, name: row.querySelector('.bu-name'), size: row.querySelector('.bu-size'),
input: row.querySelector('input'), status: row.querySelector('.bu-status'), progress: row.querySelector('progress'),
details: row.querySelector('details'), pre: row.querySelector('pre'), link: row.querySelector('a'),
retry: row.querySelector('[data-action=requeue]'), remove: row.querySelector('[data-action=remove]')
};
refs.name.textContent = item.file.name;
refs.size.textContent = '#' + item.id + ' · ' + sizeText(item.file.size);
refs.input.setAttribute('aria-label', item.file.name + ' 的目标文件名');
refs.progress.setAttribute('aria-label', item.file.name + ' 的传输进度');
refs.input.addEventListener('input', function () {
if (busy || item.state === 'success') { return; }
item.target = refs.input.value;
resetItem(item, true);
});
refs.retry.addEventListener('click', function () {
if (busy || !ready) { return; }
resetItem(item, true);
notice('已将该项重新排队。确认目标名称后,点击“开始 / 继续上传”。');
flushRender();
});
refs.remove.addEventListener('click', function () {
if (busy) { return; }
counts[item.state]--;
items = items.filter(function (other) { return other.id !== item.id; });
queueVersion++;
flushRender();
});
return refs;
}
function updateRow(refs, item) {
refs.row.dataset.state = item.state;
if (document.activeElement !== refs.input || busy) { refs.input.value = item.target; }
refs.input.disabled = busy || !ready || item.state === 'success';
setText(refs.status, item.message);
refs.progress.hidden = item.state !== 'uploading' && item.state !== 'success';
if (item.progress === null) { refs.progress.removeAttribute('value'); }
else { refs.progress.value = item.progress; }
refs.details.hidden = !item.detail;
setText(refs.pre, item.detail);
refs.link.hidden = !['success', 'skipped', 'warning', 'failed'].includes(item.state);
refs.link.href = fileUrl(item.target.trim());
setText(refs.link, item.state === 'success' ? '查看文件' : '检查目标页面');
refs.retry.hidden = !['failed', 'warning', 'skipped'].includes(item.state);
refs.retry.disabled = busy || !ready;
refs.remove.disabled = busy;
}
function render() {
var locked = busy || !ready;
['choose', 'files', 'description', 'categories', 'prefix', 'concurrency', 'gap', 'duplicates'].forEach(function (key) { ui[key].disabled = locked; });
ui.applyPrefix.disabled = locked || items.length === counts.success;
ui.start.disabled = locked || !counts.queued;
ui.pause.disabled = !busy || !session || session.stopped;
ui.retry.disabled = locked || !counts.failed;
ui.export.disabled = !items.length;
ui.clear.disabled = busy || !items.length;
var done = counts.success + counts.skipped + counts.warning + counts.failed;
setText(ui.summary, items.length ? '共 ' + number(items.length) + ' 项 · 成功 ' + number(counts.success) +
' · 跳过 ' + number(counts.skipped) + ' · 警告 ' + number(counts.warning) + ' · 失败 ' + number(counts.failed) +
' · 处理中 ' + counts.uploading + ' · 待处理 ' + number(counts.queued) : '尚未选择文件');
ui.overall.max = Math.max(items.length, 1);
ui.overall.value = done;
var seconds = Math.max(0, Math.ceil((cooldownUntil - Date.now()) / 1000));
var runtime = '已处理 ' + number(done) + ' / ' + number(items.length) + ' 项(按文件数,不是字节数)';
if (busy && session) { runtime += ' · 并发上限 ' + session.settings.concurrency + ' · 当前请求间隔 ' + session.effectiveGap + ' ms'; }
if (seconds > 0) { runtime += ' · ' + cooldownReason + ',新请求至少等待 ' + seconds + ' 秒'; }
setText(ui.runtime, runtime);
var view = getView();
var pages = Math.max(1, Math.ceil(view.length / CONFIG.pageSize));
pageNumber = Math.min(Math.max(pageNumber, 1), pages);
var offset = (pageNumber - 1) * CONFIG.pageSize;
var visible = view.slice(offset, offset + CONFIG.pageSize);
var ids = visible.map(function (item) { return item.id; }).join(',');
if (ids !== renderedIds) {
renderedIds = ids;
rowMap.clear();
ui.rows.textContent = '';
var fragment = document.createDocumentFragment();
visible.forEach(function (item) {
var refs = makeRow(item); rowMap.set(item.id, refs); fragment.appendChild(refs.row);
});
ui.rows.appendChild(fragment);
}
visible.forEach(function (item) { updateRow(rowMap.get(item.id), item); });
ui.empty.hidden = visible.length > 0;
setText(ui.empty, items.length ? '没有符合筛选条件的文件。' : '选择文件后显示上传队列。');
ui.prev.disabled = pageNumber <= 1;
ui.next.disabled = pageNumber >= pages;
setText(ui.pageInfo, '第 ' + pageNumber + ' / ' + pages + ' 页 · 筛选 ' + number(view.length) + ' 项 · 每页 ' + CONFIG.pageSize + ' 项');
}
function addFiles(list) {
if (busy || !ready) { return; }
var added = 0;
var excess = 0;
Array.from(list || []).forEach(function (file) {
if (!(file instanceof window.File)) { return; }
if (items.length >= CONFIG.maxFiles) { excess++; return; }
items.push({
id: ++serial, file: file, target: ui.prefix.value + file.name,
state: 'queued', message: '等待上传', detail: '', code: '', progress: 0,
stashKey: null, autoConfirmed: false
});
counts.queued++; added++;
});
queueVersion++;
notice('已添加 ' + number(added) + ' 个文件。检查文件名、来源与授权说明后再开始。' +
(excess ? '\n另有 ' + number(excess) + ' 个未添加:队列上限为 ' + number(CONFIG.maxFiles) + ' 项。' : '') +
'\n仅将 File 引用加入队列,不会提前把全部文件读成内存数组或生成缩略图。');
flushRender();
}
function pageText() {
var categories = [];
ui.categories.value.split(/\r?\n/).forEach(function (line) {
var name = line.trim();
if (!name) { return; }
[categoryNamespace, 'Category', '分类', '分類'].forEach(function (ns) {
if (name.slice(0, ns.length + 1).toLowerCase() === (ns + ':').toLowerCase()) { name = name.slice(ns.length + 1).trim(); }
});
if (!name || /[\[\]{}|<>#\u0000-\u001f\u007f]/.test(name)) {
throw new Error('分类请每行只填一个名称。');
}
if (!categories.includes(name)) { categories.push(name); }
});
var text = ui.description.value.trim();
if (categories.length) {
text += (text ? '\n\n' : '') + categories.map(function (name) { return '[[' + categoryNamespace + ':' + name + ']]'; }).join('\n');
}
return text;
}
function readSettings() {
var concurrency = Number(ui.concurrency.value);
var gap = Number(ui.gap.value);
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > CONFIG.maxConcurrency) {
throw new Error('并发数需在 1~' + CONFIG.maxConcurrency + ' 之间。');
}
if (!Number.isFinite(gap) || gap < 0 || gap > 60000) { throw new Error('启动间隔需在 0~60000 毫秒之间。'); }
return { concurrency: concurrency, gap: Math.round(gap), text: pageText(), duplicates: ui.duplicates.checked };
}
function userError(text) { var error = new Error(text); error.userInput = true; return error; }
function stopError() { var error = new Error('队列已暂停'); error.queueStopped = true; return error; }
function assertRunning(run) { if (run.stopped) { throw stopError(); } }
function wait(ms, run) {
assertRunning(run);
return new Promise(function (resolve, reject) {
var timer;
function stop() { window.clearTimeout(timer); run.controller.signal.removeEventListener('abort', stop); reject(stopError()); }
run.controller.signal.addEventListener('abort', stop, { once: true });
timer = window.setTimeout(function () { run.controller.signal.removeEventListener('abort', stop); resolve(); }, Math.max(0, ms));
});
}
function stopRun(reason) {
if (!session || session.stopped) { return; }
session.stopped = true;
session.stopReason = reason;
session.controller.abort(); // 只取消本地调度/退避等待,不传给 AJAX,不中断在途写请求
notice(reason + '\n已停止发起新请求;正在传输的请求会等待服务器返回。');
flushRender();
}
// 只串行化“开始请求”的时刻,而不是串行等待请求结束,因此仍有有限并发。
function gate(run) {
var next = run.dispatchTail.then(async function () {
while (true) {
assertRunning(run);
var due = Math.max(cooldownUntil, run.nextRequestAt);
var delay = due - Date.now();
if (delay <= 0) { break; }
await wait(delay, run);
}
assertRunning(run);
run.nextRequestAt = Date.now() + run.effectiveGap;
});
run.dispatchTail = next.catch(function () {});
return next;
}
function retryAfter(xhr) {
if (!xhr || typeof xhr.getResponseHeader !== 'function') { return 0; }
var value = xhr.getResponseHeader('Retry-After');
if (!value) { return 0; }
var seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) { return seconds * 1000; }
var date = Date.parse(value);
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
}
function apiError(code, data, xhr) {
xhr = xhr || (data && data.xhr);
var body = data;
if (code === 'http' && xhr && xhr.responseJSON) { body = xhr.responseJSON; }
var raw = body && (body.error || (body.errors && body.errors[0]));
var actualCode = (raw && raw.code) || code || 'unknown';
var original = raw && (raw.info || raw.text || raw['*']);
var error = new Error(original || String(actualCode));
error.code = String(actualCode);
error.detail = original || '';
error.httpStatus = xhr && xhr.status;
error.retryAfterMs = retryAfter(xhr);
error.rateLimited = /^(?:apierror-)?(?:ratelimited|actionthrottled|maxlag)$/.test(error.code) || error.httpStatus === 429;
error.stopQueue = /assert|badtoken|notloggedin|read.?only|upload.*disabled|permissiondenied|blocked|noapiwrite|writeapidenied/i.test(error.code) ||
/^(?:apierror-)?(?:lockmanager|filebackend)-/i.test(error.code) || code === 'http' || code === 'ok-but-empty';
if (/^(?:apierror-)?(?:lockmanager|filebackend)-/i.test(error.code)) {
error.message = '服务器文件锁 / 存储故障,请联系管理员。';
} else if (error.rateLimited) {
error.message = /maxlag/.test(error.code) ? '服务器负载过高,需要等待。' : '触发服务器限流,需要等待。';
} else if (code === 'http' || code === 'ok-but-empty') {
error.message = '网络 / HTTP 响应异常,上传结果可能未确认;先检查目标页面再重试。';
error.detail = (original || '') + (error.httpStatus ? '\nHTTP ' + error.httpStatus : '');
} else {
error.message = '请求失败:' + error.code + '(展开完整信息查看原因)';
}
return error;
}
function request(jqPromise) {
return new Promise(function (resolve, reject) {
jqPromise.done(resolve).fail(function (code, data, result, xhr) { reject(apiError(code, data, xhr)); });
});
}
async function call(run, factory) {
await gate(run);
assertRunning(run);
return request(factory());
}
function backoff(error, attempt, run) {
if (!/maxlag/.test(error.code)) {
run.effectiveGap = Math.max(run.effectiveGap, Math.min(60000, Math.max(CONFIG.minRetryIntervalMs, run.effectiveGap * 2)));
}
var base = /maxlag/.test(error.code) ? CONFIG.maxlagBackoffBaseMs : CONFIG.rateBackoffBaseMs;
var delay = Math.max(error.retryAfterMs, Math.min(base * Math.pow(2, attempt - 1), 300000));
cooldownUntil = Math.max(cooldownUntil, Date.now() + delay);
cooldownReason = /maxlag/.test(error.code) ? '服务器负载退避' : '服务器限流退避';
scheduleRender();
}
async function inspectTarget(item, run) {
var name = item.target.trim();
if (!name || /[\\\/:#<>\[\]|{}\u0000-\u001f\u007f]/.test(name) || !/\.[^.\s]+$/.test(name)) {
throw userError('目标名需包含扩展名,不能含路径、命名空间或非法字符(如 / : # [ ])。');
}
if (!item.file.size) { throw userError('文件为空或不可读取。请选普通文件,不要拖入文件夹。'); }
phase(item, '检查目标文件名……', null);
var result = await call(run, function () {
return api.get({ action: 'query', titles: fileNamespace + ':' + name, prop: 'imageinfo', iiprop: 'timestamp', maxage: 0 });
});
var page = result.query && result.query.pages && result.query.pages[0];
if (!page || page.invalid || page.ns !== 6 || typeof page.title !== 'string') {
throw userError('服务器未返回有效的文件页面信息,请检查目标文件名。');
}
item.target = page.title.slice(page.title.indexOf(':') + 1);
queueVersion++;
if (!page.missing || (page.imageinfo && page.imageinfo.length) || (page.imagerepository && page.imagerepository !== '')) {
state(item, 'skipped', '已有同名文件 / 说明页,跳过;未核对其内容。',
'也可能是共享仓库文件。本工具不会主动替换该目标。若要保留另一个文件,请修改目标名称后重新排队。');
return false;
}
var owner = activeClaims.get(page.title);
if (owner !== undefined && owner !== item.id) {
state(item, 'warning', '队列内另一项正在处理同一个规范化文件名,未上传。', '请使用不同的目标名称,或等待另一项完成后检查目标页面。', 'queue-name-conflict');
return false;
}
activeClaims.set(page.title, item.id);
item.claimedTitle = page.title;
return true;
}
function warningKeys(upload) {
return upload && upload.warnings && typeof upload.warnings === 'object' && !Array.isArray(upload.warnings) ? Object.keys(upload.warnings) : [];
}
function recordWarning(item, upload, extra) {
var keys = warningKeys(upload);
var detail = JSON.stringify(upload.warnings || {}, null, 2);
state(item, 'warning', extra || ('未自动确认服务器警告:' + (keys.join(', ') || '未知警告')),
(detail.length > 6000 ? detail.slice(0, 6000) + '\n……信息已截断' : detail) + '\n此项未完成正式发布。', keys.join('|'));
}
function uploaded(item, result, autoConfirmed) {
var upload = result && result.upload;
if (upload && upload.result === 'Success') {
item.target = upload.filename || item.target;
item.autoConfirmed = autoConfirmed;
state(item, 'success', autoConfirmed ? '成功 · 服务器警告已自动确认' : '成功');
return true;
}
return false;
}
function unknownResult() {
var error = new Error('服务器返回未识别的上传结果。请先检查目标页面,不要直接重复上传。');
error.stopQueue = true; error.code = 'unknown-upload-result';
return error;
}
function uploadParameters(item, run) {
return { action: 'upload', filename: item.target, text: run.settings.text,
comment: '使用批量上传页面上传', watchlist: 'nochange' };
}
function binaryOptions(item) {
return {
contentType: 'multipart/form-data', timeout: 0,
xhr: function () {
var xhr = jQuery.ajaxSettings.xhr();
if (xhr.upload) {
xhr.upload.addEventListener('progress', function (event) {
if (event.lengthComputable && item.state === 'uploading') {
var percent = Math.min(100, Math.round(event.loaded / event.total * 100));
phase(item, percent < 100 ? '传输中 ' + percent + '%' : '传输完成,等待服务器确认……', percent);
}
});
}
return xhr;
}
};
}
async function attemptUpload(item, run) {
if (!await inspectTarget(item, run)) { return; }
var params = uploadParameters(item, run);
var fromStash = typeof item.stashKey === 'string' && !!item.stashKey;
if (fromStash) { params.filekey = item.stashKey; }
else { params.file = item.file; }
phase(item, fromStash ? '重新检查暂存文件的发布条件……' : '等待发送文件……', fromStash ? null : 0);
// 第一次上传 / 重试暂存文件时不忽略任何警告,先获取完整警告集合。
var result = await call(run, function () {
return api.postWithToken('csrf', params, fromStash ? { timeout: 0 } : binaryOptions(item));
});
if (uploaded(item, result, false)) { return; }
var upload = result && result.upload;
if (!upload || (upload.result !== 'Warning' && !upload.warnings)) { throw unknownResult(); }
if (typeof upload.filekey === 'string' && upload.filekey) { item.stashKey = upload.filekey; }
var keys = warningKeys(upload);
// 只自动确认以下内容重复 / 近似名称警告;必须整组警告都在白名单内。
// duplicate-archive:内容与已删除存档中的文件相同,不代表当前目标名已存在。
// 其余警告(完全同名 exists、目标曾删除 was-deleted、非法文件名等)仍需人工处理。
var autoConfirmable = keys.length > 0 && keys.every(function (key) {
return key === 'duplicate' || key === 'duplicate-archive' || key === 'exists-normalized';
});
if (!run.settings.duplicates || !autoConfirmable) { recordWarning(item, upload); return; }
if (!item.stashKey) {
recordWarning(item, upload, '存在可自动确认的警告,但服务器未返回 filekey,无法安全自动确认。');
return;
}
// 自动确认前再查一次目标,并用本页互斥表防止多个 worker 同时发布同一名称。
// 跨客户端竞态仍需服务器 reupload 权限控制;API 没有逐项忽略参数。
phase(item, '存在可自动确认的警告,先再次检查目标……', null);
if (!await inspectTarget(item, run)) { return; }
phase(item, '正在自动确认并发布……', null);
var confirm = uploadParameters(item, run);
confirm.filekey = item.stashKey;
confirm.ignorewarnings = true;
var committed = await call(run, function () { return api.postWithToken('csrf', confirm, { timeout: 0 }); });
if (uploaded(item, committed, true)) { return; }
if (committed && committed.upload && (committed.upload.result === 'Warning' || committed.upload.warnings)) {
recordWarning(item, committed.upload); return;
}
throw unknownResult();
}
async function processItem(item, run) {
state(item, 'uploading', '准备检查……');
var retries = 0;
try {
while (true) {
try {
assertRunning(run);
await attemptUpload(item, run);
return;
} catch (error) {
if (error.queueStopped) { state(item, 'queued', '已暂停,等待继续'); return; }
if (error.rateLimited) {
// 先更新全局退避:即便当前重试额度用尽,其他 worker 也不能绕开冷却。
backoff(error, retries + 1, run);
if (!run.stopped && retries < CONFIG.maxRateRetries) {
retries++;
phase(item, '限流 / 负载等待,第 ' + retries + ' / ' + CONFIG.maxRateRetries + ' 次自动重试……', null);
// 下次 attempt 会重新检查目标;如有 filekey,会重查警告,不重新传二进制。
continue;
}
if (run.stopped) { state(item, 'queued', '已暂停,稍后继续限流重试'); return; }
error.stopQueue = true;
error.message = '限流 / 负载错误持续,自动重试次数已用完。请降低并发或增加间隔。';
}
var unexpected = !error.userInput && !error.code;
state(item, 'failed', error.message || String(error), error.detail || '', error.code || (unexpected ? 'client-error' : 'invalid-input'));
if (error.stopQueue || unexpected) { stopRun('队列已暂停:' + (error.message || '客户端异常')); }
return;
}
}
} finally {
if (item.claimedTitle && activeClaims.get(item.claimedTitle) === item.id) { activeClaims.delete(item.claimedTitle); }
item.claimedTitle = null;
}
}
async function start() {
if (busy || !ready || !counts.queued) { return; }
var settings;
try { settings = readSettings(); } catch (error) { notice(error.message); return; }
// 创建有界 worker 池,不对 6000 个文件直接 Promise.all。
var pending = items.filter(function (item) { return item.state === 'queued'; });
var run = {
settings: settings, effectiveGap: settings.gap, stopped: false, stopReason: '', next: 0,
controller: new AbortController(), dispatchTail: Promise.resolve(), nextRequestAt: 0
};
session = run; busy = true;
notice('正在处理 ' + number(pending.length) + ' 项,并发上限 ' + settings.concurrency + '。' +
(settings.duplicates ? 'duplicate、duplicate-archive 与 exists-normalized 警告会自动确认;其他警告不自动忽略。' : '所有上传警告均需人工处理。') +
'\n不要关闭页面;如触发限流会统一等待,不会通过增加并发绕过。');
flushRender();
runtimeTimer = window.setInterval(scheduleRender, 1000);
async function worker() {
var item;
try {
while (!run.stopped && run.next < pending.length) {
item = pending[run.next++];
await processItem(item, run);
}
} catch (error) {
if (item && item.state === 'uploading') {
state(item, 'failed', '客户端调度异常,请检查目标页面后再重试。', error.message || String(error), 'client-error');
}
stopRun('客户端调度异常,已暂停后续请求:' + (error.message || String(error)));
}
}
try {
await Promise.all(Array.from({ length: Math.min(settings.concurrency, pending.length) }, function () { return worker(); }));
} catch (error) {
stopRun('客户端调度异常,请刷新前导出结果并检查目标页面:' + (error.message || String(error)));
} finally {
// 即便 worker 出现未预料的异常,也先等待仍在途的任务,见 worker 内保护。
busy = false;
if (runtimeTimer !== null) { window.clearInterval(runtimeTimer); runtimeTimer = null; }
activeClaims.clear();
notice(run.stopped ? run.stopReason + '\n本轮在途请求已返回。待处理项可继续;失败项需排查后重新排队。' :
'本轮处理结束。成功项不会再次上传;“跳过”只表示同名已存在,不保证其内容与本地一致。可筛选警告/失败项或导出 CSV。');
session = null;
flushRender();
}
}
function csvCell(value) {
var text = String(value === undefined || value === null ? '' : value);
// 防止文件名等在 Excel/其他表格软件中被解释成公式。
if (/^[\s]*[=+\-@]/.test(text) || /^[\t\r\n]/.test(text)) { text = "'" + text; }
return '"' + text.replace(/"/g, '""') + '"';
}
function exportResults() {
if (!items.length) { return; }
var columns = ['队列ID', '本地文件名', '目标文件名', '大小(字节)', '本地修改时间', '状态', '提示', '错误码', '已自动确认白名单警告', '目标页面'];
var lines = [columns.map(csvCell).join(',')];
items.forEach(function (item) {
lines.push([item.id, item.file.name, item.target, item.file.size,
item.file.lastModified ? new Date(item.file.lastModified).toISOString() : '', labels[item.state],
item.message, item.code, item.autoConfirmed ? '是' : '否', new URL(fileUrl(item.target), window.location.href).href].map(csvCell).join(','));
});
var blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a'); a.href = url;
// ASCII 下载文件名兼容部分浏览器;CSV 表头与内容仍是 UTF-8 中文。
a.download = 'MediaWiki-upload-results-' + new Date().toISOString().replace(/[:.]/g, '-') + '.csv';
document.body.appendChild(a); a.click(); a.remove();
window.setTimeout(function () { URL.revokeObjectURL(url); }, 60000);
}
ui.choose.addEventListener('click', function () { ui.files.click(); });
ui.files.addEventListener('change', function () { addFiles(ui.files.files); ui.files.value = ''; });
['dragenter', 'dragover'].forEach(function (type) {
ui.drop.addEventListener(type, function (event) { event.preventDefault(); if (ready && !busy) { ui.drop.classList.add('bu-over'); } });
});
ui.drop.addEventListener('dragleave', function (event) { if (!ui.drop.contains(event.relatedTarget)) { ui.drop.classList.remove('bu-over'); } });
ui.drop.addEventListener('drop', function (event) {
event.preventDefault(); ui.drop.classList.remove('bu-over');
if (event.dataTransfer) { addFiles(event.dataTransfer.files); }
});
ui.applyPrefix.addEventListener('click', function () {
if (busy || !ready) { return; }
items.forEach(function (item) { if (item.state !== 'success') { item.target = ui.prefix.value + item.file.name; resetItem(item, true); } });
notice('已按“前缀 + 原始文件名”更新未成功项,并将它们重新排队。请确认后开始。'); flushRender();
});
ui.start.addEventListener('click', function () { start(); });
ui.pause.addEventListener('click', function () { stopRun('已按你的操作暂停队列。'); });
ui.retry.addEventListener('click', function () {
if (busy || !ready) { return; }
items.forEach(function (item) { if (item.state === 'failed') { resetItem(item, true); } });
notice('失败项已重新排队;未重新排队成功项、同名跳过项和其他警告项。检查原因后点击“开始 / 继续上传”。'); flushRender();
});
ui.export.addEventListener('click', exportResults);
ui.clear.addEventListener('click', function () {
if (busy) { return; }
items = []; Object.keys(counts).forEach(function (key) { counts[key] = 0; });
pageNumber = 1; queueVersion++;
notice('已清空本地队列,不会删除已上传到本站的文件。'); flushRender();
});
ui.search.addEventListener('input', function () {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(function () { pageNumber = 1; flushRender(); }, 150);
});
ui.filter.addEventListener('change', function () { pageNumber = 1; flushRender(); });
ui.prev.addEventListener('click', function () { pageNumber--; flushRender(); });
ui.next.addEventListener('click', function () { pageNumber++; flushRender(); });
window.addEventListener('beforeunload', function (event) {
if (busy || counts.queued || counts.failed || counts.warning) { event.preventDefault(); event.returnValue = ''; }
});
flushRender();
if (!window.File || !window.FormData || !window.Promise || !window.AbortController) {
notice('浏览器不支持所需功能,请使用较新的 Chrome、Firefox、Edge 或 Safari。'); return;
}
if (!userName) {
notice('请先登录。此工具不支持匿名上传。');
var login = document.createElement('a');
login.href = mw.util.getUrl('Special:UserLogin', { returnto: mw.config.get('wgPageName').replace(/_/g, ' ') });
login.textContent = ' 前往登录'; ui.message.appendChild(login); return;
}
api = new mw.Api({
parameters: { formatversion: 2, errorformat: 'plaintext', assert: 'user', assertuser: userName, maxlag: 5 },
ajax: { headers: { 'Api-User-Agent': 'MediaWikiBatchUpload (' + new URL(mw.util.getUrl(mw.config.get('wgPageName')), window.location.href).href + ')' } }
});
request(api.get({ action: 'query', meta: 'userinfo', uiprop: 'rights' })).then(function (data) {
var info = data.query && data.query.userinfo;
if (!info || !Array.isArray(info.rights)) { throw new Error('未能确认账号上传权限,请刷新或联系管理员。'); }
if (!info.rights.includes('upload')) { notice('当前账号没有 upload 权限。站点脚本不能授予上传权限。'); return; }
ready = true;
notice('当前账号:' + userName + '。duplicate、duplicate-archive 与 exists-normalized 警告默认自动确认,并发默认 ' + CONFIG.defaultConcurrency + ' 路。建议先用 10~20 个小文件测试,再添加大批量文件。');
flushRender();
}).catch(function (error) { notice('初始化失败:' + error.message + '。请检查登录状态及 API,再刷新。'); });
}
}());