Linux.do Word Count Script
// ==UserScript==
// @name Linux.do Word Count
// @namespace https://linux.do/
// @description Linux.do Word Count Script
// @author RyanCross6673
// @version 1.3
// @run-at document-idle
// @match https://linux.do/t/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=linux.do
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const BADGE_CLASS = 'tm-word-count';
const OBSERVED_ATTR = 'data-tm-wordcount-observed';
const DIRTY_ATTR = 'data-tm-wordcount-dirty';
const MIN_LEN = 16;
// postId => { fingerprint, total, noPunct }
const postCache = new Map();
function isIgnoredTextNode(textNode) {
let el = textNode.parentElement;
while (el) {
const tag = el.tagName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'IMG' || tag === 'NOSCRIPT') {
return true;
}
if (el.classList) {
if (
el.classList.contains('cooked-selection-barrier') ||
el.classList.contains('emoji')
) {
return true;
}
}
el = el.parentElement;
}
return false;
}
function extractTextFast(cooked) {
const walker = document.createTreeWalker(
cooked,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const value = node.nodeValue;
if (!value || !value.trim()) return NodeFilter.FILTER_REJECT;
if (isIgnoredTextNode(node)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
}
);
let text = '';
let current;
while ((current = walker.nextNode())) {
text += current.nodeValue;
}
return text;
}
function countStats(text) {
return {
total: text.length,
noPunct: text.replace(/[^\p{L}\p{N}]/gu, '').length
};
}
function fingerprintText(text) {
const len = text.length;
if (len <= 48) return text;
return `${len}|${text.slice(0, 24)}|${text.slice(-24)}`;
}
function getPostId(article) {
return article.getAttribute('data-post-id') || '';
}
function ensureBadge(article) {
let badge = article.querySelector(`.${BADGE_CLASS}`);
if (badge) return badge;
badge = document.createElement('span');
badge.className = BADGE_CLASS;
badge.style.cssText = [
'display:inline-block',
'margin-left:8px',
'padding:2px 8px',
'font-size:12px',
'line-height:18px',
'color:#fff',
'background:#374151',
'border-radius:999px',
'white-space:nowrap'
].join(';');
const target =
article.querySelector('.post-infos') ||
article.querySelector('.topic-meta-data') ||
article;
target.appendChild(badge);
return badge;
}
function renderBadge(article, stats) {
const badge = ensureBadge(article);
// 显示格式:去标点 | 总
badge.textContent = `${stats.noPunct} | ${stats.total}`;
// 去标点 < 20 红色,否则默认
badge.style.background = stats.noPunct < MIN_LEN ? '#dc2626' : '#374151';
}
function computeAndRender(article, force = false) {
if (!article || !(article instanceof HTMLElement)) return;
const cooked = article.querySelector('.cooked');
if (!cooked) return;
const postId = getPostId(article);
const text = extractTextFast(cooked);
const fingerprint = fingerprintText(text);
if (postId) {
const cached = postCache.get(postId);
if (!force && !article.hasAttribute(DIRTY_ATTR) && cached && cached.fingerprint === fingerprint) {
renderBadge(article, cached);
return;
}
const stats = countStats(text);
const record = {
fingerprint,
total: stats.total,
noPunct: stats.noPunct
};
postCache.set(postId, record);
renderBadge(article, record);
} else {
const stats = countStats(text);
renderBadge(article, stats);
}
article.removeAttribute(DIRTY_ATTR);
}
const intersectionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
computeAndRender(entry.target, false);
}
},
{
root: null,
rootMargin: '1200px 0px 1200px 0px',
threshold: 0
}
);
function observeArticle(article) {
if (!article || !(article instanceof HTMLElement)) return;
if (article.hasAttribute(OBSERVED_ATTR)) return;
article.setAttribute(OBSERVED_ATTR, '1');
intersectionObserver.observe(article);
}
function observeExistingArticles() {
document.querySelectorAll('article').forEach(observeArticle);
}
function markArticleDirty(article) {
if (!article || !(article instanceof HTMLElement)) return;
article.setAttribute(DIRTY_ATTR, '1');
}
function handleMutations(mutations) {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.matches?.('article')) {
observeArticle(node);
continue;
}
node.querySelectorAll?.('article').forEach(observeArticle);
}
const target = mutation.target;
if (!(target instanceof Node)) continue;
const element =
target instanceof HTMLElement
? target
: target.parentElement;
if (!element) continue;
const cooked = element.closest?.('.cooked');
if (!cooked) continue;
const article = cooked.closest('article');
if (!article) continue;
markArticleDirty(article);
const rect = article.getBoundingClientRect();
const preload = 1400;
const inNearViewport =
rect.bottom >= -preload &&
rect.top <= window.innerHeight + preload;
if (inNearViewport) {
computeAndRender(article, true);
}
}
}
observeExistingArticles();
const mutationObserver = new MutationObserver(handleMutations);
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
})();