MediaWiki:Gadget-ConfirmAccountSpamHighlight.js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/*
ConfirmAccount Fast Spam Gadget
Author: Mark W. Datysgeld
License: GPL-3.0
Highlights arbitrary suspicious request containers in Special:ConfirmAccounts and allows for their speedy flagging as spam. Fully customizable as to what each wiki gets in terms of suspicious traffic.
Premises:
- No external JSON/config
- If the container is flagged/highlighted, clicking the existing "Spam" link bypasses the OOUI confirmation modal entirely and immediately submits the spam action
- For non-flagged containers, the extension’s normal behavior (modal confirmation) remains untouched
*/
/*
SETTINGS!
*/
(function () {
'use strict';
// 1) Highlight colors
// Tweak opacity first; keep outline stronger than background
const HIGHLIGHT = {
day: {
bg: 'rgba(255, 0, 255, 0.4)',
outline: 'rgba(255, 0, 255, 0.5)'
},
night: {
bg: 'rgba(255, 0, 255, 0.4)',
outline: 'rgba(255, 0, 255, 0.5)'
}
};
// 2) Arbitrary strings to look for (case-insensitive substring match)
// Applied to the entire request container text ("Biography" currently does not get its own class, so this is a must ATM; it would definitely be cleaner to be able to target that one TD)
const WATCH_STRINGS = [
'I life in',
//'will soon finish my study at',
//'my hobbies are',
];
// 3) Feature flags
const FLAG_NON_LATIN = true;
const FLAG_NEW_TLD = true;
// Internal constants
const STYLE_ID = 'icw-confirmaccount-fastspam-style';
const FLAGGED_CLASS = 'icw-ca-flagged';
const RUNNING_CLASS = 'icw-ca-fastspam-running';
const PROCESSED_ATTR = 'data-icw-fastspam-processed';
const WIRED_ATTR = 'data-icw-fastspam-wired';
const INFLIGHT_ATTR = 'data-icw-fastspam-inflight';
// Legacy TLDs list; ccTLDs are excluded separately by length==2
const LEGACY_TLDS = new Set([
'com', 'net', 'org', 'info', 'edu', 'gov', 'mil', 'int', 'aero', 'asia', 'cat', 'coop', 'jobs', 'mobi', 'museum', 'post', 'tel', 'travel', 'biz'
]);
function isConfirmAccountsPage() {
return mw.config.get('wgCanonicalSpecialPageName') === 'ConfirmAccounts';
}
function injectStylesOnce() {
if (document.getElementById(STYLE_ID)) return;
const css = `
/* Overlay support applied only when flagged (portable; no Common.css dependency) */
li.mw-confirmaccount-type-0.${FLAGGED_CLASS} {
position: relative;
overflow: hidden;
}
li.mw-confirmaccount-type-0.${FLAGGED_CLASS}::before {
content: "";
position: absolute;
inset: 0;
background: var(--icw-ca-overlay, transparent);
pointer-events: none;
z-index: 0;
}
li.mw-confirmaccount-type-0.${FLAGGED_CLASS} > * {
position: relative;
z-index: 1;
}
/* Flagged container (day/default): use HIGHLIGHT.day.bg as overlay color */
.${FLAGGED_CLASS} {
--icw-ca-overlay: ${HIGHLIGHT.day.bg};
box-shadow: 0 0 0 2px ${HIGHLIGHT.day.outline};
border-radius: 2px;
margin-bottom: 8px;
}
/* In-flight state (avoid confusion during fast action) */
.${RUNNING_CLASS} {
opacity: 0.7;
}
/* Night mode (Wikimedia client preference) */
html.skin-theme-clientpref-night .${FLAGGED_CLASS} {
--icw-ca-overlay: ${HIGHLIGHT.night.bg};
box-shadow: 0 0 0 2px ${HIGHLIGHT.night.outline};
}
/* Night mode (OS preference via Wikimedia "os" mode) */
@media (prefers-color-scheme: dark) {
html.skin-theme-clientpref-os .${FLAGGED_CLASS} {
--icw-ca-overlay: ${HIGHLIGHT.night.bg};
box-shadow: 0 0 0 2px ${HIGHLIGHT.night.outline};
}
}
`;
const style = document.createElement('style');
style.id = STYLE_ID;
style.type = 'text/css';
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
}
function normalizeText(s) {
return String(s || '')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
}
// Unicode non-Latin detection with a safe fallback
function makeNonLatinDetector() {
// Prefer Unicode property escapes where available
try {
// Flags characters not in Latin/Common/Inherited scripts
// Common includes whitespace, punctuation, digits, many symbols/emoji
const re = /[^\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]/u;
return (text) => re.test(text);
} catch (e) {
// Fallback: explicit ranges for common non-Latin scripts (approximation)
const re = /[\u0400-\u052F\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0900-\u097F\u0980-\u09FF\u0A00-\u0A7F\u0A80-\u0AFF\u0B00-\u0B7F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F\u0E00-\u0E7F\u1100-\u11FF\u3040-\u30FF\u3400-\u9FFF\uAC00-\uD7AF]/;
return (text) => re.test(text);
}
}
const hasNonLatin = makeNonLatinDetector();
function extractContainerText(li) {
// Premise: whole box; FUTURE: Maybe "Biography" only
return li ? (li.innerText || li.textContent || '') : '';
}
function hasWatchString(textNorm) {
if (!WATCH_STRINGS || !WATCH_STRINGS.length) return false;
for (let i = 0; i < WATCH_STRINGS.length; i++) {
const token = normalizeText(WATCH_STRINGS[i]);
if (!token) continue;
if (textNorm.indexOf(token) !== -1) return true;
}
return false;
}
function extractDomainsFromText(textNorm) {
const domains = new Set();
// Email domains
const emailRe = /@([a-z0-9.-]+\.[a-z]{2,63})\b/gi;
let m;
while ((m = emailRe.exec(textNorm)) !== null) {
domains.add(cleanDomain(m[1]));
}
// URL-ish / domain-ish tokens (kept deliberately simple)
const urlRe = /\b(?:https?:\/\/)?([a-z0-9.-]+\.[a-z]{2,63})(?::\d{2,5})?(?:\/[^\s]*)?/gi;
while ((m = urlRe.exec(textNorm)) !== null) {
domains.add(cleanDomain(m[1]));
}
domains.delete('');
return Array.from(domains);
}
function cleanDomain(d) {
d = String(d || '').toLowerCase().trim();
// Strip common trailing punctuation
d = d.replace(/[)\].,;:!?]+$/g, '');
// Strip leading punctuation
d = d.replace(/^[\[(]+/g, '');
// Basic sanity
if (d.indexOf('.') === -1) return '';
return d;
}
function isNewTldHit(textNorm) {
const domains = extractDomainsFromText(textNorm);
for (let i = 0; i < domains.length; i++) {
const domain = domains[i];
const parts = domain.split('.').filter(Boolean);
if (parts.length < 2) continue;
const tld = parts[parts.length - 1];
if (!tld) continue;
// Punycode TLDs are treated as suspicious in this heuristic
if (tld.indexOf('xn--') === 0) return true;
// Skip ccTLDs by length
if (tld.length === 2) continue;
// If not in a small legacy allowlist, treat as "new TLD"
if (!LEGACY_TLDS.has(tld)) return true;
}
return false;
}
function shouldFlagContainer(li) {
const text = extractContainerText(li);
const textNorm = normalizeText(text);
if (!textNorm) return { flag: false, reasons: [] };
const reasons = [];
if (hasWatchString(textNorm)) reasons.push('STR');
if (FLAG_NON_LATIN && hasNonLatin(text)) reasons.push('NL');
if (FLAG_NEW_TLD && isNewTldHit(textNorm)) reasons.push('TLD');
return { flag: reasons.length > 0, reasons };
}
function applyFlagging(li) {
if (!li) return;
const existing = li.getAttribute(PROCESSED_ATTR);
if (existing === '1') return;
const result = shouldFlagContainer(li);
if (result.flag) {
li.classList.add(FLAGGED_CLASS);
li.dataset.icwSpamReasons = result.reasons.join(',');
} else {
li.classList.remove(FLAGGED_CLASS);
delete li.dataset.icwSpamReasons;
}
li.setAttribute(PROCESSED_ATTR, '1');
}
function absUrl(href) {
try {
return new URL(href, window.location.href).href;
} catch (e) {
return href;
}
}
function findReviewLink(li) {
return li.querySelector('a[href*="acrid="]') || null;
}
function findSpamLink(li, reviewLink) {
// Prefer the extension-injected id convention if present
const byId = li.querySelector('a[id^="mw-confirmaccount-spam-"]');
if (byId) return byId;
if (!reviewLink) return null;
// Most stable: spam link is adjacent in the same tiny header container
const container = reviewLink.parentElement;
if (container) {
const anchors = Array.from(container.querySelectorAll('a'));
// First try: any anchor that is not the review link and looks like an action link
const candidate = anchors.find(a => a !== reviewLink && a.id && a.id.indexOf('mw-confirmaccount-spam-') === 0);
if (candidate) return candidate;
// Fallback: choose the other anchor next to review
const other = anchors.find(a => a !== reviewLink);
if (other) return other;
}
return null;
}
function notify(msg, type) {
// type: 'error' | 'success' | 'warn' | undefined
if (mw && typeof mw.notify === 'function') {
mw.notify(msg, { type: type || 'info' });
} else {
// Last resort
if (type === 'error') console.error(msg);
else console.log(msg);
}
}
function serializeForm(formEl) {
const data = {};
const fields = formEl.querySelectorAll('input, textarea, select');
fields.forEach((el) => {
const name = el.getAttribute('name');
if (!name) return;
const tag = el.tagName.toLowerCase();
const type = (el.getAttribute('type') || '').toLowerCase();
if (tag === 'input' && type === 'radio') {
if (el.checked) data[name] = el.value;
return;
}
if (tag === 'input' && type === 'checkbox') {
data[name] = el.checked ? el.value : '';
return;
}
if (tag === 'select' && el.multiple) {
const selected = Array.from(el.options).filter(o => o.selected).map(o => o.value);
data[name] = selected.join('\n');
return;
}
data[name] = el.value;
});
return data;
}
function findConfirmForm(doc) {
return (
doc.querySelector('form[name="accountconfirm"]') ||
doc.querySelector('form#accountconfirm') ||
doc.querySelector('form[action*="ConfirmAccounts"]') ||
null
);
}
function fastSpam(li, reviewUrl) {
if (!li || !reviewUrl) return;
if (li.getAttribute(INFLIGHT_ATTR) === '1') return;
li.setAttribute(INFLIGHT_ATTR, '1');
li.classList.add(RUNNING_CLASS);
// Fetch review page to obtain the confirm form + token fields
$.get(reviewUrl)
.done(function (html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const form = findConfirmForm(doc);
if (!form) {
li.classList.remove(RUNNING_CLASS);
li.removeAttribute(INFLIGHT_ATTR);
notify('Fast spam failed: could not locate confirm form on review page.', 'error');
return;
}
const data = serializeForm(form);
// Force spam action
data.wpSubmitType = 'spam';
if (Object.prototype.hasOwnProperty.call(data, 'wpReason')) data.wpReason = '';
const actionUrl = absUrl(form.getAttribute('action') || reviewUrl);
$.post(actionUrl, data)
.done(function () {
// Remove entry from queue immediately
try {
li.parentNode && li.parentNode.removeChild(li);
} catch (e) {
// Ignore DOM removal errors
}
})
.fail(function (_xhr, _status, error) {
li.classList.remove(RUNNING_CLASS);
li.removeAttribute(INFLIGHT_ATTR);
notify('Fast spam failed: ' + (error || 'POST error'), 'error');
});
})
.fail(function () {
li.classList.remove(RUNNING_CLASS);
li.removeAttribute(INFLIGHT_ATTR);
notify('Fast spam failed: could not load the review page.', 'error');
});
}
function wireSpamBypass(li, spamLink, reviewLink) {
if (!li || !spamLink || !reviewLink) return;
if (spamLink.getAttribute(WIRED_ATTR) === '1') return;
spamLink.setAttribute(WIRED_ATTR, '1');
spamLink.addEventListener(
'click',
function (ev) {
// Ensure flagging is applied even if scan has not processed this li yet
if (!li.classList.contains(FLAGGED_CLASS)) {
const res = shouldFlagContainer(li);
if (res.flag) {
li.classList.add(FLAGGED_CLASS);
li.dataset.icwSpamReasons = res.reasons.join(',');
}
}
// Only bypass when container is flagged/highlighted
if (!li.classList.contains(FLAGGED_CLASS)) return;
// Suppress the extension handler entirely (no modal)
ev.preventDefault();
ev.stopImmediatePropagation();
ev.stopPropagation();
const reviewUrl = absUrl(reviewLink.getAttribute('href'));
fastSpam(li, reviewUrl);
},
true // capturing phase: beats bubbling/delegated handlers reliably
);
}
function scanAndWire() {
const root = document.getElementById('mw-content-text') || document.body;
// Queue items are list items with mw-confirmaccount-type-* classes
const items = root.querySelectorAll('li[class*="mw-confirmaccount-type-"]');
items.forEach((li) => {
// Apply highlighting once
applyFlagging(li);
const reviewLink = findReviewLink(li);
if (!reviewLink) return;
const spamLink = findSpamLink(li, reviewLink);
if (!spamLink) return;
wireSpamBypass(li, spamLink, reviewLink);
});
}
function init() {
if (!isConfirmAccountsPage()) return;
injectStylesOnce();
scanAndWire();
// Load order is messy in Wikimedia; re-scan a few times to catch late-inserted spam links
window.setTimeout(scanAndWire, 400);
window.setTimeout(scanAndWire, 1200);
window.setTimeout(scanAndWire, 2500);
// Also re-run if MediaWiki replaces content (rare here, but cheap)
if (mw && mw.hook) {
mw.hook('wikipage.content').add(function () {
injectStylesOnce();
scanAndWire();
});
}
}
mw.loader.using(['mediawiki.util'], function () {
$(init);
});
})();