MessageStats

Счётчик сообщений за день / 7 дней / 30 дней (API)

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         MessageStats
// @namespace    message-stats
// @version      1.2
// @author       Welhord
// @description  Счётчик сообщений за день / 7 дней / 30 дней (API)
// @match        https://zelenka.guru/*
// @match        https://lolz.live/*
// @match        https://lolz.guru/*
// @match        https://lzt.market/*
// @match        https://lolz.market/*
// @match        https://zelenka.market/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      prod-api.lolz.live
// @connect      prod-api.zelenka.guru
// ==/UserScript==

(function () {
  'use strict';

  const API_BASE = 'https://prod-api.lolz.live';
  const AUTO_TOKEN_KEY = 'msg_stat_auto_token';
  const AUTO_TOKEN_EXPIRES_KEY = 'msg_stat_auto_expires';
  const SHOW_COUNTER_KEY = 'msg_stat_show_counter';
  const AUTO_TOKEN_BUFFER_SEC = 60;
  const ONE_DAY_SEC = 24 * 3600;
  const SEVEN_DAYS_SEC = 7 * ONE_DAY_SEC;
  const ONE_MONTH_SEC = 30 * ONE_DAY_SEC;
  const RATE_DELAY_MS = 0;
  const TIMELINE_MAX_PAGES = 200;
  const USER_ID_CACHE_TTL_MS = 5 * 60 * 1000;

  let lastApiTime = 0;
  const userIdCache = new Map();
  let statsLineDone = false;
  let profileTooltipDone = false;

  const i18n = {
    ru: { counterLabel: 'Показывать счётчик сообщений (за день / 7 дней / 30 дней)' },
    en: { counterLabel: 'Show message counter (day / 7 days / 30 days)' },
    get(key) {
      const lang = (typeof XenForo !== 'undefined' && XenForo.visitor && XenForo.visitor.language_id === 1) ? 'en' : 'ru';
      return (this[lang] && this[lang][key]) ? this[lang][key] : key;
    },
  };

  GM_addStyle(`
    .msg-stat-pref-row { margin-top: 8px; }
  `);

  function getXfToken() {
    const input = document.querySelector('input[name="_xfToken"]');
    if (input && input.value) return input.value;
    if (typeof XenForo !== 'undefined' && XenForo._csrfToken) return XenForo._csrfToken;
    return '';
  }

  /** Сохраняет токен в storage и возвращает его. */
  async function saveTokenFromResponse(json) {
    const data = json && (json.ajaxData || json);
    const token = data && (data.token != null ? String(data.token).trim() : '');
    if (!token) return null;
    const expires = data && (data.expires != null ? parseInt(data.expires, 10) : 0);
    await GM_setValue(AUTO_TOKEN_KEY, token);
    await GM_setValue(AUTO_TOKEN_EXPIRES_KEY, expires);
    return token;
  }

  /** Ищем на странице URL для generate-temporary-token  */
  function findGenerateTokenUrl() {
    const base = window.location.origin + (XenForo && XenForo.canonicalizeUrl ? '' : '');
    const sel = '[href*="generate-temporary-token"], [data-url*="generate-temporary-token"], [data-href*="generate-temporary-token"], a[href*="api-tokens"]';
    const el = document.querySelector(sel);
    if (el) {
      const href = el.getAttribute('href') || el.getAttribute('data-url') || el.getAttribute('data-href') || '';
      if (href.indexOf('generate-temporary-token') !== -1) {
        const u = href.startsWith('http') ? href : new URL(href, window.location.origin).href;
        return u;
      }
    }
    return null;
  }

  /** Запрос временного токена через XenForo AJAX (scope read) */
  async function fetchTemporaryTokenFromForum() {
    const xfToken = getXfToken();
    if (!xfToken) return null;

    const canonicalize = (typeof XenForo !== 'undefined' && typeof XenForo.canonicalizeUrl === 'function')
      ? XenForo.canonicalizeUrl.bind(XenForo) : null;
    const tryUrls = [];
    const found = findGenerateTokenUrl();
    if (found) tryUrls.push(found);
    if (canonicalize) {
      tryUrls.push(canonicalize('login/generate-temporary-token'));
      tryUrls.push(canonicalize('index.php?account/api-tokens/generate-temporary-token'));
      tryUrls.push(canonicalize('account/api-tokens/generate-temporary-token'));
      tryUrls.push(canonicalize('api-tokens/generate-temporary-token'));
    }
    tryUrls.push(window.location.origin + '/login/generate-temporary-token');
    tryUrls.push(window.location.origin + '/index.php?account/api-tokens/generate-temporary-token');
    tryUrls.push(window.location.origin + '/account/api-tokens/generate-temporary-token');

    const data = {
      'scope[]': ['chatbox', 'read'],
      _xfRequestUri: window.location.pathname || '/',
      _xfNoRedirect: 1,
      _xfToken: xfToken,
      _xfResponseType: 'json',
    };

    if (typeof XenForo !== 'undefined' && typeof XenForo.ajax === 'function') {
      for (let i = 0; i < tryUrls.length; i++) {
        const url = tryUrls[i];
        if (!url) continue;
        const result = await new Promise(function (resolve) {
          let settled = false;
          function done(val) { if (!settled) { settled = true; resolve(val); } }
          XenForo.ajax(url, data, function (json) {
            saveTokenFromResponse(json).then(function (t) { done(t || null); });
          }, { skipError: true });
          setTimeout(function () { done(null); }, 4000);
        });
        if (result) return result;
      }
      return null;
    }

    const form = new FormData();
    form.append('scope[]', 'chatbox');
    form.append('scope[]', 'read');
    form.append('_xfRequestUri', window.location.pathname || '/');
    form.append('_xfNoRedirect', '1');
    form.append('_xfToken', xfToken);
    form.append('_xfResponseType', 'json');

    for (let i = 0; i < tryUrls.length; i++) {
      const url = tryUrls[i];
      if (!url) continue;
      try {
        const res = await fetch(url, { method: 'POST', body: form, credentials: 'same-origin' });
        if (res.status !== 200) continue;
        const json = await res.json();
        const token = await saveTokenFromResponse(json);
        if (token) return token;
      } catch (_) {}
    }
    return null;
  }

  /** Временный токен из storage; при истечении - перевыпуск  */
  async function getOrRefreshAutoToken() {
    const raw = await GM_getValue(AUTO_TOKEN_KEY, '');
    const token = (raw && typeof raw === 'string') ? raw.trim() : '';
    let expires = parseInt(await GM_getValue(AUTO_TOKEN_EXPIRES_KEY, '0'), 10) || 0;
    const nowSec = Math.floor(Date.now() / 1000);
    if (token && expires > nowSec + AUTO_TOKEN_BUFFER_SEC) return token;
    let newToken = await fetchTemporaryTokenFromForum();
    if (!newToken) {
      await new Promise(function (r) { setTimeout(r, 1500); });
      newToken = await fetchTemporaryTokenFromForum();
    }
    return newToken || (token || null);
  }

  /** Токен только через XenForo (generate-temporary-token). */
  async function ensureToken() {
    return getOrRefreshAutoToken();
  }

  async function isCounterEnabled() {
    const v = await GM_getValue(SHOW_COUNTER_KEY, '1');
    return v === true || v === '1';
  }

  async function setCounterEnabled(value) {
    await GM_setValue(SHOW_COUNTER_KEY, value ? '1' : '0');
  }

  /** Ищем любой подходящий ul в блоке настроек (основной или запасной вариант). */
  function findCheckboxTargetUl() {
    const uls = document.querySelectorAll('dl.ctrlUnit dd ul');
    for (const ul of uls) {
      if (ul.querySelector('#ctrl_ask_for_hidden_content') || ul.querySelector('#ctrl_default_watch_state')) {
        return ul;
      }
    }
    return uls.length > 0 ? uls[0] : null;
  }

  /** Запасной вариант: вставить чекбокс в блок настроек, если основная разметка не найдена. */
  function addCounterCheckboxFallback() {
    const block = document.querySelector('.block-body');
    if (!block) return null;
    let ul = block.querySelector('dl.ctrlUnit dd ul');
    if (!ul) {
      const dd = block.querySelector('dl.ctrlUnit dd');
      if (!dd) return null;
      ul = document.createElement('ul');
      ul.className = 'listInline';
      dd.appendChild(ul);
    }
    const li = document.createElement('li');
    li.className = 'msg-stat-pref-row';
    const checkbox = document.createElement('input');
    checkbox.type = 'checkbox';
    checkbox.id = 'ctrl_msg_stat_show_counter';
    checkbox.name = 'msg_stat_show_counter';
    checkbox.value = '1';
    const label = document.createElement('label');
    label.htmlFor = 'ctrl_msg_stat_show_counter';
    label.textContent = i18n.get('counterLabel');
    li.appendChild(checkbox);
    li.appendChild(label);
    ul.appendChild(li);
    return { checkbox, label };
  }

  function addCounterCheckbox() {
    if (document.getElementById('ctrl_msg_stat_show_counter')) return;
    let targetUl = findCheckboxTargetUl();
    let checkbox, label;
    if (targetUl) {
      const li = document.createElement('li');
      li.className = 'msg-stat-pref-row';
      checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.id = 'ctrl_msg_stat_show_counter';
      checkbox.name = 'msg_stat_show_counter';
      checkbox.value = '1';
      label = document.createElement('label');
      label.htmlFor = 'ctrl_msg_stat_show_counter';
      label.textContent = i18n.get('counterLabel');
      li.appendChild(checkbox);
      li.appendChild(label);
      targetUl.appendChild(li);
    } else {
      const fallback = addCounterCheckboxFallback();
      if (!fallback) return;
      checkbox = fallback.checkbox;
      label = fallback.label;
    }

    isCounterEnabled().then(enabled => { checkbox.checked = !!enabled; });
    checkbox.addEventListener('change', () => setCounterEnabled(checkbox.checked));
  }

  function apiRequest(method, path, params = {}, token) {
    return new Promise((resolve, reject) => {
      if (!token) return reject(new Error('NO_TOKEN'));
      const url = new URL(path.startsWith('http') ? path : API_BASE.replace(/\/$/, '') + path);
      Object.keys(params).forEach(k => url.searchParams.set(k, params[k]));
      GM_xmlhttpRequest({
        method,
        url: url.toString(),
        headers: {
          'Authorization': 'Bearer ' + token,
          'Accept': 'application/json',
        },
        onload: (res) => {
          if (res.status !== 200) return reject(new Error('API ' + res.status));
          try {
            resolve(res.responseText ? JSON.parse(res.responseText) : null);
          } catch (_) {
            reject(new Error('Invalid JSON'));
          }
        },
        onerror: () => reject(new Error('Network error')),
      });
    });
  }

  async function apiGet(path, params = {}, token) {
    const now = Date.now();
    const elapsed = now - lastApiTime;
    if (lastApiTime > 0 && elapsed < RATE_DELAY_MS) {
      await new Promise(r => setTimeout(r, RATE_DELAY_MS - elapsed));
    }
    lastApiTime = Date.now();
    return apiRequest('GET', path, params, token);
  }

  async function getUserIdByUsername(username, token) {
    const key = (username || '').trim().toLowerCase();
    if (key) {
      const cached = userIdCache.get(key);
      if (cached && Date.now() - cached.ts < USER_ID_CACHE_TTL_MS) return cached.userId;
    }
    const res = await apiGet('/users/find', { username }, token);
    const userId = res && res.users && res.users.length ? res.users[0].user_id : null;
    if (key && userId) userIdCache.set(key, { userId, ts: Date.now() });
    return userId;
  }

  /** Начало текущих суток 00:00 по локальному времени */
  function getStartOfTodaySec() {
    const d = new Date();
    d.setHours(0, 0, 0, 0);
    return Math.floor(d.getTime() / 1000);
  }

  /**
   * Только счёт за 7 дней (для тултипа в профиле). Останавливается на первой записи старше 7 дней.
   */
  async function getMessagesCountWeekOnly(userId, token) {
    const now = Math.floor(Date.now() / 1000);
    const fromDay = getStartOfTodaySec();
    const fromWeek = now - SEVEN_DAYS_SEC;
    let dayCount = 0;
    let weekCount = 0;
    let page = 1;
    const limit = 100;
    while (page <= TIMELINE_MAX_PAGES) {
      const res = await apiGet('/users/' + userId + '/timeline', { page, limit }, token);
      const data = res && res.data;
      if (!Array.isArray(data) || data.length === 0) break;
      for (const item of data) {
        const ts = item.post_create_date;
        if (ts == null) continue;
        if (ts < fromWeek) return { day: dayCount, week: weekCount };
        weekCount++;
        if (ts >= fromDay) dayCount++;
      }
      page++;
    }
    return { day: dayCount, week: weekCount };
  }

  /**
   * Загружает timeline и возвращает { day, week, month }.
   * День - с 00:00 сегодняшних суток (локально); неделя/месяц - последние 7 и 30 дней.
   */
  async function getMessagesCountDayWeekMonth(userId, token) {
    const now = Math.floor(Date.now() / 1000);
    const fromDay = getStartOfTodaySec();
    const fromWeek = now - SEVEN_DAYS_SEC;
    const fromMonth = now - ONE_MONTH_SEC;
    let dayCount = 0;
    let weekCount = 0;
    let monthCount = 0;
    let limitedMonth = false;
    let page = 1;
    const limit = 100;
    const processPage = (data) => {
      if (!Array.isArray(data) || data.length === 0) return 'break';
      for (const item of data) {
        const ts = item.post_create_date;
        if (ts == null) continue;
        if (ts >= fromMonth) {
          monthCount++;
          if (ts >= fromWeek) weekCount++;
          if (ts >= fromDay) dayCount++;
        }
      }
      return 'more';
    };
    while (page <= TIMELINE_MAX_PAGES) {
      const [res1, res2] = await Promise.all([
        apiGet('/users/' + userId + '/timeline', { page, limit }, token),
        apiGet('/users/' + userId + '/timeline', { page: page + 1, limit }, token),
      ]);
      const data1 = res1 && res1.data;
      const data2 = res2 && res2.data;
      const r1 = processPage(data1);
      if (r1 === 'break') break;
      const r2 = processPage(data2);
      if (r2 === 'break') break;
      page += 2;
    }
    if (monthCount > 0 && monthCount === weekCount) {
      limitedMonth = true;
    }
    return { day: dayCount, week: weekCount, month: monthCount, limitedMonth };
  }

  function getUsernameFromLikeNodes(likeNodes) {
    const firstLink = likeNodes.querySelector('a.node[href*="users="]');
    if (!firstLink) return null;
    const href = firstLink.getAttribute('href') || '';
    const m = href.match(/[?&]users=([^&\s]+)/);
    return m ? decodeURIComponent(m[1]) : null;
  }

  function removeStatsLine() {
    const existing = document.querySelector('.msg-stat-stats-line');
    if (existing) existing.remove();
  }

  /** Вставляет серую строку со статистикой над таблицей разделов  */
  function injectStatsLine(dayCount, weekCount, monthCount, limitedMonth) {
    const existing = document.querySelector('.msg-stat-stats-line');
    if (existing) existing.remove();
    const p = document.createElement('p');
    p.className = 'muted msg-stat-stats-line';
    p.style.margin = '0 0 10px 0';
    let text = 'Написано сообщений за день - ' + dayCount + ', за последние 7 дней - ' + weekCount + ', за 30 дней - ' + monthCount;
    if (limitedMonth) {
      text += ' (данные за 30 дней могут быть неполными по данным API)';
    }
    p.textContent = text;
    const likeNodes = document.querySelector('.likeNodes');
    const likesTabs = document.querySelector('ul.likesTabs');
    const insertBefore = likesTabs || likeNodes;
    if (insertBefore && insertBefore.parentNode) {
      insertBefore.parentNode.insertBefore(p, insertBefore);
    }
  }

  /** Ссылка «сообщения» в блоке .counts_module на странице профиля. */
  function getMessagesLinkInCountsModule() {
    const module = document.querySelector('.counts_module');
    if (!module) return null;
    const links = module.querySelectorAll('a.page_counter');
    for (const a of links) {
      const label = a.querySelector('.label.muted, .label');
      if (!label) continue;
      const text = (label.textContent || '').trim().toLowerCase();
      if (text === 'сообщения' || text === 'messages') return a;
    }
    return null;
  }

  /** Username со страницы профиля (из ссылки сообщений). */
  function getUsernameFromCountsModule() {
    const link = getMessagesLinkInCountsModule();
    if (!link) return null;
    const href = (link.getAttribute('href') || '').trim();
    const searchMatch = href.match(/[?&]users=([^&\s]+)/);
    if (searchMatch) return decodeURIComponent(searchMatch[1]);
    const likeLink = document.querySelector('.counts_module a[href*="likes"]');
    if (likeLink) {
      const h = (likeLink.getAttribute('href') || '').trim();
      const m = h.match(/([^/]+)\/(?:likes|likes2)/);
      if (m) return m[1];
    }
    return null;
  }

  function clearMessagesTooltip() {
    const link = getMessagesLinkInCountsModule();
    if (!link) return;
    link.classList.remove('Tooltip');
    link.removeAttribute('data-cachedtitle');
    link.removeAttribute('title');
  }

  /** Включает тултип при наведении на счётчик сообщений */
  function setMessagesCountTooltip(link, week) {
    const text = 'За последние 7 дней - ' + week + ' ';
    link.classList.add('Tooltip');
    link.setAttribute('data-cachedtitle', text);
    link.setAttribute('title', text.trim());
    if (typeof XenForo !== 'undefined' && XenForo.activate) {
      try {
        XenForo.activate(link);
      } catch (_) {}
    }
    if (typeof window.tippy === 'function') {
      try {
        window.tippy(link, { content: text, trigger: 'mouseenter', allowHTML: false, appendTo: document.body });
        link.removeAttribute('title');
      } catch (_) {}
    }
  }

  /**
   * На странице профиля: подставляет тултип на счётчик «сообщения» в .counts_module.
   */
  async function runProfileTooltip() {
    if (profileTooltipDone) return;
    if (!(await isCounterEnabled())) {
      clearMessagesTooltip();
      return;
    }
    const link = getMessagesLinkInCountsModule();
    if (!link) return;
    const username = getUsernameFromCountsModule();
    if (!username) return;
    const token = await ensureToken();
    if (!token) return;
    try {
      const userId = await getUserIdByUsername(username, token);
      if (!userId) return;
      const { week } = await getMessagesCountWeekOnly(userId, token);
      setMessagesCountTooltip(link, week);
      profileTooltipDone = true;
    } catch (_) {}
  }

  async function runStatsLine() {
    if (statsLineDone) return;
    if (!(await isCounterEnabled())) {
      removeStatsLine();
      return;
    }
    const likeNodes = document.querySelector('.likeNodes');
    if (!likeNodes) return;
    const username = getUsernameFromLikeNodes(likeNodes);
    if (!username) return;
    const token = await ensureToken();
    if (!token) return;
    try {
      const userId = await getUserIdByUsername(username, token);
      if (!userId) return;
      const { day, week, month, limitedMonth } = await getMessagesCountDayWeekMonth(userId, token);
      injectStatsLine(day, week, month, limitedMonth);
      statsLineDone = true;
    } catch (_) {}
  }

  function init() {
    statsLineDone = false;
    profileTooltipDone = false;
    addCounterCheckbox();
    setTimeout(addCounterCheckbox, 1000);
    setTimeout(addCounterCheckbox, 2500);

    if (document.querySelector('.likeNodes')) runStatsLine();
    if (document.querySelector('.counts_module')) runProfileTooltip();

    let debounceTimer = null;
    const debounceMs = 250;
    const onMutation = () => {
      if (debounceTimer) clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => {
        debounceTimer = null;
        addCounterCheckbox();
        if (document.querySelector('.likeNodes')) runStatsLine();
        if (document.querySelector('.counts_module')) runProfileTooltip();
      }, debounceMs);
    };
    const observer = new MutationObserver(onMutation);
    if (document.body) observer.observe(document.body, { childList: true, subtree: true });
    setTimeout(() => observer.disconnect(), 10000);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    setTimeout(init, 500);
  }
})();