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);
  });
})();