Linux.do Word Count

Linux.do Word Count Script

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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
  });
})();