Счётчик сообщений за день / 7 дней / 30 дней (API)
// ==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);
}
})();