Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
'use strict';
// based on [[User:RoySmith/tag-check.js]]
mw.loader.using(['mediawiki.api', 'mediawiki.storage', 'mediawiki.util']).then(function () {
const title = mw.config.get('wgPageName');
if (!/^Wikipedia:Sockpuppet_investigations\/[^\/]+/.test(title)) {
return;
}
const IPV4REGEX = /^((?:1?\d\d?|2[0-4]\d|25[0-5])\b(?:\.(?:1?\d\d?|2[0-4]\d|25[0-5])){3})(?:\/(?:[12]?\d|3[0-2]))?$/;
const IPV6REGEX = /^((?:[\dA-Fa-f]{1,4}:){7}[\dA-Fa-f]{1,4}|(?:[\dA-Fa-f]{1,4}:){1,7}:|(?:[\dA-Fa-f]{1,4}:){1,6}:[\dA-Fa-f]{1,4}|(?:[\dA-Fa-f]{1,4}:){1,5}(?:\:[\dA-Fa-f]{1,4}){1,2}|(?:[\dA-Fa-f]{1,4}:){1,4}(?:\:[\dA-Fa-f]{1,4}){1,3}|(?:[\dA-Fa-f]{1,4}:){1,3}(?:\:[\dA-Fa-f]{1,4}){1,4}|(?:[\dA-Fa-f]{1,4}:){1,2}(?:\:[\dA-Fa-f]{1,4}){1,5}|[\dA-Fa-f]{1,4}:(?:(?:\:[\dA-Fa-f]{1,4}){1,6}))(?:\/(1[6-9]|[2-9]\d|1[01]\d|12[0-8]))?$/; // based on https://stackoverflow.com/a/17871737
const api = new mw.Api();
const redirectMap = {
sockmaster: 'sockpuppeteer',
sock: 'sockpuppet',
sockpuppetcheckuser: 'checked sockpuppet',
checkedsockpuppet: 'checked sockpuppet',
...(mw.storage.getObject('socktags-redirect-map') || {})
};
addCSS();
processContent(document.getElementById('mw-content-text'));
refreshRedirectMap();
function addCSS() {
mw.util.addCSS(`
span[class^="socktag-"] {
display: inline-block;
width: 1.4em;
height: 1.4em;
line-height: 1.4em;
text-align: center;
font-size: 1em;
font-weight: bold;
font-family: monospace;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
border: 1px solid darkgrey;
color: #000;
}
.socktag-type-master::before { content: "M"; }
.socktag-type-puppet::before { content: "P"; }
.socktag-status-banned { background-color: #b080ff; }
.socktag-status-blocked { background-color: #ffff66; }
.socktag-status-confirmed { background-color: #ff3300; }
.socktag-status-proven { background-color: #ffcc99; }
.socktag-status-suspected { background-color: #ffffff; }
.socktag-status-unknown { background-color: #ffffff; }
`);
}
async function processContent(content) {
const userMap = new Map();
// collect all usernames and elements
const entries = content.querySelectorAll('span.cuEntry');
for (const entry of entries) {
const userLinks = entry.querySelectorAll('a[href*="/User:"]');
for (const userNode of userLinks) {
const username = userNode.textContent.trim();
if (!username || ipAddress(username)) continue;
if (!userMap.has(username)) {
userMap.set(username, []);
}
userMap.get(username).push(userNode);
}
}
// for each unique user, fetch parse tree and decorate all their nodes
await Promise.all(
Array.from(userMap.entries()).map(async ([username, nodes]) => {
const parseTree = await getParseTree('User:' + username);
if (!parseTree) return;
const tags = getTags(parseTree);
if (!tags.length) return;
for (const userNode of nodes) {
for (const tag of tags) {
userNode.parentNode.insertBefore(createTagSpan(tag), userNode);
}
}
})
);
}
function ipAddress(input) {
return IPV4REGEX.test(input) || IPV6REGEX.test(input);
}
async function getParseTree(pageTitle) {
try {
const response = await api.get({
action: 'parse',
page: pageTitle,
prop: 'parsetree',
formatversion: 2
});
if (!response || !response.parse || typeof response.parse.parsetree !== 'string') {
console.debug('No parse tree found in response for', pageTitle);
return null;
}
const parseTreeXml = response.parse.parsetree;
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(parseTreeXml, 'application/xml');
if (xmlDoc.getElementsByTagName('parsererror').length) {
console.warn('XML parse error in getParseTree for', pageTitle);
return null;
}
return xmlDoc;
} catch (error) {
if (error !== 'missingtitle') {
console.warn('Failed to get parse tree for', pageTitle, error);
}
return null;
}
}
function getTags(parseTree) {
const templates = parseTree.getElementsByTagName('template');
const tags = [];
let unmatched = 0;
for (const template of templates) {
const rawTemplateName = template.getElementsByTagName('title')[0]?.textContent;
if (!rawTemplateName) continue;
const templateName = resolveRedirect(rawTemplateName.trim().toLowerCase());
switch (templateName) {
case 'sockpuppeteer':
tags.unshift({
type: 'master',
status: extractParam(template, '1', true) || 'suspected'
});
break;
case 'sockpuppet':
const altmaster = extractParam(template, 'altmaster');
tags.push({
type: 'puppet',
master: extractParam(template, '1'),
status: extractParam(template, '2', true) || 'unknown',
altmaster,
altstatus: extractParam(template, 'altmaster-status', true) || (altmaster ? 'unknown' : null)
});
break;
case 'checked sockpuppet':
tags.push({
type: 'puppet',
status: 'confirmed',
master: extractParam(template, '1')
});
break;
default:
if (++unmatched >= 5) return tags;
continue;
}
if (tags.length >= 2) return tags;
unmatched = 0;
}
return tags;
}
function extractParam(template, target, lowercase = false) {
const parts = template.getElementsByTagName('part');
for (const part of parts) {
const nameNode = part.getElementsByTagName('name')[0];
const name = nameNode?.getAttribute('index') ?? nameNode?.textContent.trim();
if (name === target) {
const value = part.getElementsByTagName('value')[0]?.textContent.trim();
return lowercase ? value?.toLowerCase() : value;
}
}
return null;
}
function resolveRedirect(templateName) {
return redirectMap[templateName] || templateName;
}
function createTagSpan(tag) {
const span = document.createElement('span');
span.classList.add('socktag-type-' + tag.type);
span.classList.add('socktag-status-' + tag.status);
let title = tag.status;
if (tag.master) {
title += ' (' + tag.master + ')';
}
if (tag.altmaster) {
title += ' / ' + tag.altstatus + ' (' + tag.altmaster + ')';
}
span.title = title;
return span;
}
async function refreshRedirectMap() {
if (Math.abs(Date.now() - (redirectMap?.__timestamp || 0)) < 2592000000) {
return;
}
const templates = ['Sockpuppeteer', 'Sockpuppet', 'Checked sockpuppet'];
const map = {};
for (const base of templates) {
const baseKey = base.toLowerCase();
for (const redirect of await getRedirects(base)) {
const redirectKey = redirect.toLowerCase();
if (redirectKey !== baseKey && await isUsedOnUserPage(redirect)) {
map[redirectKey] = baseKey;
}
}
}
map.__timestamp = Date.now();
mw.storage.setObject('socktags-redirect-map', map, 15552000);
}
async function getRedirects(template) {
const res = await api.get({
action: 'query',
list: 'backlinks',
bltitle: 'Template:' + template,
blnamespace: 10,
blfilterredir: 'redirects',
bllimit: 'max'
});
return res.query.backlinks.map(b => b.title.replace(/^Template:/, ''));
}
async function isUsedOnUserPage(template) {
const res = await api.get({
action: 'query',
list: 'embeddedin',
eititle: 'Template:' + template,
einamespace: 2,
eilimit: 1
});
return res.query.embeddedin.length > 0;
}
});
