'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 status = tagStatus(parseTree);
if (!status.tagType) return;
for (const userNode of nodes) {
userNode.parentNode.insertBefore(createTagSpan(status), 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 tagStatus(parseTree) {
const template = parseTree.getElementsByTagName('template')[0];
if (!template) return {};
const rawTemplateName = template.getElementsByTagName('title')[0]?.textContent;
const templateName = resolveRedirect(rawTemplateName.trim().toLowerCase());
let tagType = null;
let tagStatus = null;
const parts = template.getElementsByTagName('part');
if (templateName === 'sockpuppeteer') {
tagType = 'master';
tagStatus = extractParam(parts, '1') || 'suspected';
} else if (templateName === 'sockpuppet') {
tagType = 'puppet';
tagStatus = extractParam(parts, '2');
} else if (templateName === 'checked sockpuppet') {
tagType = 'puppet';
tagStatus = 'confirmed';
} else {
return {};
}
if (!tagStatus) tagStatus = 'unknown';
return { tagType, tagStatus };
}
function extractParam(parts, target) {
for (const part of parts) {
const nameNode = part.getElementsByTagName('name')[0];
const name = nameNode?.getAttribute('index') ?? nameNode?.textContent.trim();
if (name === target) {
return part.getElementsByTagName('value')[0]?.textContent.trim().toLowerCase();
}
}
return null;
}
function resolveRedirect(templateName) {
return redirectMap[templateName] || templateName;
}
function createTagSpan(status) {
const tag = document.createElement('span');
tag.classList.add('socktag-type-' + status.tagType);
tag.classList.add('socktag-status-' + status.tagStatus);
tag.title = status.tagStatus;
return tag;
}
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;
}
});