User:Polygnotus/Scripts/WordCount.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
Documentation for this user script can be added at User:Polygnotus/Scripts/WordCount.
// <nowiki>
// Wikipedia Talk Page Participation Analyzer
// Only runs on talk pages and adds participation analysis links to section headers
// This ignores text in links
(function() {
'use strict';
// Only run on talk pages
if (!mw.config.get('wgCanonicalNamespace') ||
!mw.config.get('wgCanonicalNamespace').toLowerCase().includes('talk')) {
return;
}
// Function to count words in text (excluding links, timestamps, and other markup)
function countWords(text) {
if (!text) return 0;
// Create a temporary element to parse HTML and extract text without signature elements
const tempDiv = document.createElement('div');
tempDiv.innerHTML = text;
// Remove all signature-related elements
const elementsToRemove = tempDiv.querySelectorAll(
'a, ' + // All links
'.ext-discussiontools-init-timestamplink, ' + // Timestamp links
'.ext-discussiontools-init-replylink-buttons, ' + // Reply buttons
'[data-mw-comment-sig], ' + // Signature markers
'.mw-editsection, ' + // Edit section links
'.ext-discussiontools-init-section-subscribe' // Subscribe links
);
elementsToRemove.forEach(el => el.remove());
// Get plain text content
let plainText = tempDiv.textContent || tempDiv.innerText || '';
// Remove common timestamp patterns and other signature artifacts
plainText = plainText
.replace(/\d{2}:\d{2},\s*\d{1,2}\s+\w+\s+\d{4}\s*\(UTC\)/g, '') // Timestamps like "12:35, 1 September 2025 (UTC)"
.replace(/\(\s*talk\s*\)/gi, '') // (talk) links
.replace(/\[\s*reply\s*\]/gi, '') // [reply] buttons
.replace(/\[\s*edit\s*\]/gi, '') // [edit] buttons
.replace(/\[\s*subscribe\s*\]/gi, '') // [subscribe] buttons
.replace(/\[\s*unsubscribe\s*\]/gi, '') // [unsubscribe] buttons
.replace(/📌|🔗/g, '') // Emoji icons
.trim();
// Debug: log what we're counting
console.log('Text being counted:', JSON.stringify(plainText));
const words = plainText.split(/\s+/).filter(word =>
word.length > 0 &&
word !== '()' && // Filter out empty parentheses
word !== '[]' && // Filter out empty brackets
!/^\(\s*\)$/.test(word) && // Filter out parentheses with only whitespace
!/^\[\s*\]$/.test(word) // Filter out brackets with only whitespace
);
console.log('Words found:', words);
console.log('Word count:', words.length);
// Count words in the cleaned text
return words.length;
}
// Function to extract username from user page link
function extractUsername(href) {
const userMatch = href.match(/\/wiki\/User:([^\/]+)$/);
if (userMatch) {
return decodeURIComponent(userMatch[1].replace(/_/g, ' '));
}
return null;
}
// Function to analyze participation in a section
function analyzeSection(sectionElement) {
const userStats = {};
// Look for DiscussionTools comment markers
const commentElements = sectionElement.querySelectorAll('[data-mw-comment-start]');
if (commentElements.length > 0) {
// DiscussionTools format - analyze each comment
commentElements.forEach(commentStart => {
const commentId = commentStart.getAttribute('data-mw-comment-start') || commentStart.id;
// Skip section headers (they start with 'h-'), only process comments (start with 'c-')
if (commentId.startsWith('h-')) {
return;
}
// Find the corresponding comment end element
let commentEnd = null;
const allElements = sectionElement.querySelectorAll('*');
for (let el of allElements) {
if (el.getAttribute('data-mw-comment-end') === commentId) {
commentEnd = el;
break;
}
}
if (commentEnd) {
// Extract comment content between start and end, excluding signature elements
let commentContent = '';
let currentNode = commentStart.nextSibling;
while (currentNode && currentNode !== commentEnd) {
if (currentNode.nodeType === Node.TEXT_NODE) {
commentContent += currentNode.textContent;
} else if (currentNode.nodeType === Node.ELEMENT_NODE) {
// Skip signature elements completely
if (!currentNode.classList.contains('ext-discussiontools-init-timestamplink') &&
!currentNode.hasAttribute('data-mw-comment-sig') &&
!currentNode.classList.contains('ext-discussiontools-init-replylink-buttons') &&
currentNode.tagName !== 'A') { // Skip all anchor tags
commentContent += currentNode.textContent;
}
}
currentNode = currentNode.nextSibling;
}
// Clean the extracted content further
commentContent = commentContent
.replace(/\d{2}:\d{2},\s*\d{1,2}\s+\w+\s+\d{4}\s*\(UTC\)/g, '') // Remove timestamps
.replace(/\(\s*talk\s*\)/gi, '') // Remove (talk)
.replace(/\[\s*reply\s*\]/gi, '') // Remove [reply]
.trim();
console.log('Raw comment content:', JSON.stringify(commentContent));
// Extract username from comment ID (format: c-Username-timestamp-...)
let username = null;
const idMatch = commentId.match(/[ch]-([^-]+)-/);
if (idMatch) {
username = decodeURIComponent(idMatch[1].replace(/_/g, ' '));
}
// If we couldn't get username from ID, look for signature links
if (!username) {
const parentElement = commentStart.parentElement;
if (parentElement) {
const userLinks = parentElement.querySelectorAll('a[href*="/wiki/User:"]');
if (userLinks.length > 0) {
username = extractUsername(userLinks[userLinks.length - 1].getAttribute('href'));
}
}
}
if (username && commentContent.trim()) {
if (!userStats[username]) {
userStats[username] = { words: 0, contributions: 0 };
}
userStats[username].words += countWords(commentContent);
userStats[username].contributions++;
}
}
});
} else {
// Fallback: traditional method for non-DiscussionTools pages
const userLinks = sectionElement.querySelectorAll('a[href*="/wiki/User:"]');
const validUserLinks = [];
// Filter to only direct user page links (not subpages)
userLinks.forEach(link => {
const href = link.getAttribute('href');
if (href.match(/\/wiki\/User:[^\/]+$/) && !href.includes('/')) {
const username = extractUsername(href);
if (username) {
validUserLinks.push({ element: link, username: username });
}
}
});
if (validUserLinks.length === 0) {
return { participants: 0, stats: [] };
}
// Get all text and attribute to users based on proximity to signatures
const textWalker = document.createTreeWalker(
sectionElement,
NodeFilter.SHOW_TEXT,
null,
false
);
let textNode;
while (textNode = textWalker.nextNode()) {
const text = textNode.textContent.trim();
if (text && text.length > 0) {
// Find nearest user signature
let currentElement = textNode.parentElement;
let foundUser = null;
while (currentElement && currentElement !== sectionElement) {
const nearbyUserLinks = currentElement.querySelectorAll('a[href*="/wiki/User:"]');
if (nearbyUserLinks.length > 0) {
const username = extractUsername(nearbyUserLinks[nearbyUserLinks.length - 1].getAttribute('href'));
if (username) {
foundUser = username;
break;
}
}
currentElement = currentElement.previousElementSibling || currentElement.parentElement;
}
if (foundUser) {
if (!userStats[foundUser]) {
userStats[foundUser] = { words: 0, contributions: 0 };
}
userStats[foundUser].words += countWords(text);
userStats[foundUser].contributions++;
}
}
}
}
// Convert to sorted array
const statsArray = Object.entries(userStats)
.map(([username, stats]) => ({
username: username,
words: stats.words,
contributions: stats.contributions
}))
.sort((a, b) => b.words - a.words);
return {
participants: statsArray.length,
stats: statsArray
};
}
// Function to create and show popup with OOUI
function showParticipationPopup(sectionTitle, analysisResult) {
mw.loader.using(['oojs-ui-core', 'oojs-ui-windows', 'oojs-ui-widgets']).then(function() {
const { participants, stats } = analysisResult;
let content = `<div style="font-family: sans-serif; max-height: 400px; overflow-y: auto; padding: 10px;">`;
content += `<h3 style="margin-top: 0; margin-bottom: 15px; font-size: 16px; line-height: 1.2;">Section: ${sectionTitle}</h3>`;
content += `<p style="margin-bottom: 15px;"><strong>Participants: ${participants}</strong></p>`;
if (stats.length > 0) {
content += `<table style="width: 100%; border-collapse: collapse; margin-top: 10px; table-layout: fixed;">`;
content += `<tr style="background-color: #f0f0f0; border-bottom: 1px solid #ccc;">
<th style="padding: 8px; text-align: left; width: 40%;">User</th>
<th style="padding: 8px; text-align: right; width: 30%;">Words</th>
<th style="padding: 8px; text-align: right; width: 30%;">Contributions</th>
</tr>`;
stats.forEach((stat, index) => {
const rowStyle = index % 2 === 0 ? 'background-color: #f9f9f9;' : '';
content += `<tr style="${rowStyle} border-bottom: 1px solid #eee;">
<td style="padding: 8px; font-weight: bold; word-wrap: break-word;">${mw.html.escape(stat.username)}</td>
<td style="padding: 8px; text-align: right;">${stat.words.toLocaleString()}</td>
<td style="padding: 8px; text-align: right;">${stat.contributions}</td>
</tr>`;
});
content += `</table>`;
} else {
content += `<p><em>No user contributions detected in this section.</em></p>`;
}
content += `</div>`;
// Create custom dialog class
function ParticipationDialog(config) {
ParticipationDialog.parent.call(this, config);
}
OO.inheritClass(ParticipationDialog, OO.ui.MessageDialog);
ParticipationDialog.static.name = 'participationDialog';
ParticipationDialog.static.title = 'Talk Page Participation Analysis';
ParticipationDialog.static.actions = [
{ action: 'close', label: 'Close', flags: ['primary'] }
];
ParticipationDialog.prototype.getBodyHeight = function() {
return 450;
};
ParticipationDialog.prototype.initialize = function() {
ParticipationDialog.parent.prototype.initialize.apply(this, arguments);
this.content = new OO.ui.PanelLayout({ padded: true, expanded: false });
this.content.$element.html(content);
this.$body.append(this.content.$element);
};
const windowManager = new OO.ui.WindowManager();
$('body').append(windowManager.$element);
const dialog = new ParticipationDialog();
windowManager.addWindows([dialog]);
windowManager.openWindow(dialog);
});
}
// Main function to add links to section headers
function addSectionLinks() {
// Handle both standard H2 tags and DiscussionTools sections
const sectionHeaders = document.querySelectorAll('h2, .mw-heading h2');
sectionHeaders.forEach((header, index) => {
// Extract section title from various possible structures
let sectionTitle;
const headlineSpan = header.querySelector('.mw-headline');
if (headlineSpan) {
// Standard structure with .mw-headline
sectionTitle = headlineSpan.textContent.trim();
} else {
// DiscussionTools structure - look for comment-end span which contains the title
const commentEnd = header.querySelector('[data-mw-comment-end]');
if (commentEnd) {
sectionTitle = commentEnd.textContent.trim();
} else {
// Fallback: clean up the full header text
sectionTitle = header.textContent
.replace('[analyze]', '')
.replace(/\[edit\]/g, '')
.replace(/\[subscribe\]/g, '')
.replace(/📌/g, '')
.replace(/🔗/g, '')
.trim();
}
}
if (!sectionTitle) {
sectionTitle = `Section ${index + 1}`;
}
// Skip if already has analysis link
if (header.querySelector('a[title*="Analyze participation"]')) {
return;
}
// Create analysis link
const analysisLink = document.createElement('a');
analysisLink.href = '#';
analysisLink.textContent = '[analyze]';
analysisLink.style.marginLeft = '10px';
analysisLink.style.fontSize = '0.8em';
analysisLink.style.color = '#0645ad';
analysisLink.title = 'Analyze participation in this section';
analysisLink.addEventListener('click', function(e) {
e.preventDefault();
// Find the section content - handle both standard and DiscussionTools structure
let sectionContent = document.createElement('div');
// Check if this is a DiscussionTools section (parent has mw-heading class)
const headingContainer = header.closest('.mw-heading');
if (headingContainer) {
// DiscussionTools: find content after the entire heading container
let currentElement = headingContainer.nextElementSibling;
while (currentElement && !currentElement.querySelector('h2') &&
!currentElement.classList.contains('mw-heading')) {
sectionContent.appendChild(currentElement.cloneNode(true));
currentElement = currentElement.nextElementSibling;
}
} else {
// Standard structure: find content after the h2
let currentElement = header.nextElementSibling;
while (currentElement && currentElement.tagName !== 'H2') {
sectionContent.appendChild(currentElement.cloneNode(true));
currentElement = currentElement.nextElementSibling;
}
}
const analysisResult = analyzeSection(sectionContent);
showParticipationPopup(sectionTitle, analysisResult);
});
// Add the link to the header
header.appendChild(analysisLink);
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addSectionLinks);
} else {
addSectionLinks();
}
})();
// </nowiki>