Skip to content

Instantly share code, notes, and snippets.

@euppi98
Last active March 8, 2026 20:07
Show Gist options
  • Select an option

  • Save euppi98/ce5a49f8b81b0c05f4633a8ce1f7c4b4 to your computer and use it in GitHub Desktop.

Select an option

Save euppi98/ce5a49f8b81b0c05f4633a8ce1f7c4b4 to your computer and use it in GitHub Desktop.
VK ER Calculator — Tampermonkey script. Shows ERV, ER, Viral Index for every post in VK communities.

VK ER Calculator

Tampermonkey-скрипт для анализа постов сообществ ВКонтакте.

Что показывает

Метрика Формула Зачем
ERV (Качество) (likes + comments + reposts) / views × 100 Оценить качество контента
ER (Лояльность) (likes + comments + reposts) / subscribers × 100 Насколько пост зашёл аудитории
Viral Index (Охват) views / subscribers Помогает ли лента ВК посту расти

Установка

  1. Установите Tampermonkey
  2. Откройте файл vk-er-calculator.user.js → нажмите Raw
  3. Tampermonkey предложит установить — нажмите Install

Использование

  • Зайдите на страницу любого сообщества ВК
  • Под каждым постом появится кнопка ER (рядом с лайком/комментом/репостом)
  • Наведите — popup с метриками (просмотры подгрузятся через ~1 сек)
  • Кликните — popup закрепится
  • Кликните снаружи — popup закроется

Особенности

  • Работает без API — берёт данные из DOM
  • Адаптируется под светлую и тёмную тему VK
  • Просмотры кешируются — повторное открытие мгновенно
  • Автоматически подхватывает новые посты при скролле

Скриншот

Добавьте скриншот сюда

// ==UserScript==
// @name VK ER Calculator
// @namespace vk-er
// @version 2.4
// @description ER / ERV / Viral Index — fetch on demand
// @match *://vk.com/*
// @match *://*.vk.com/*
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* ╔═══════════════════════════════════════════════╗
║ НАСТРОЙКИ — меняй тут ║
╠═══════════════════════════════════════════════╣
║ ERV_COEFF — коэффициент для просмотров ║
║ Формула: (eng) / (views × coeff) × 100 ║
║ ║
║ 0.65 — учитывает что ~35% просмотров боты ║
║ 0 — отключить коэф (делить на чистые ║
║ просмотры без коэффициента) ║
║ 1 — то же что и 0, без изменений ║
╚═══════════════════════════════════════════════╝ */
const ERV_COEFF = 0.65;
const ATTR = 'data-er-done';
let subsCache = null;
let lastURL = location.href;
const viewsCache = new Map();
let fetchLock = false;
GM_addStyle(`
.er-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
border-radius: var(--vkui--size_border_radius--regular, 8px);
color: var(--vkui--color_icon_secondary, #939cb0);
transition: color var(--vkui--animation_duration_s, .1s) var(--vkui--animation_easing_default),
background var(--vkui--animation_duration_s, .1s) var(--vkui--animation_easing_default);
user-select: none;
position: relative;
font-family: var(--vkui--font_family_base);
}
.er-trigger:hover {
color: var(--vkui--color_icon_accent, #3f8ae0);
background: var(--vkui--color_background_secondary_alpha, rgba(0,0,0,0.04));
}
.er-trigger svg { width: 24px; height: 24px; }
.er-trigger__label {
font-size: var(--vkui--font_footnote--font_size--regular, 13px);
line-height: var(--vkui--font_footnote--line_height--regular, 16px);
font-weight: var(--vkui--font_weight_accent2, 500);
}
.er-popup {
display: none;
position: absolute;
bottom: calc(100% + var(--vkui--size_tooltip_margin--regular, 8px));
left: 50%;
transform: translateX(-50%);
background: var(--vkui--color_background_modal, var(--vkui--color_background_content, #fff));
border-radius: var(--vkui--size_border_radius_paper--regular, 12px);
padding: 16px;
box-shadow: var(--vkui--elevation3, 0 0 2px rgba(0,0,0,.1), 0 4px 16px rgba(0,0,0,.15));
z-index: var(--vkui--z_index_popout, 1100);
min-width: 280px;
max-width: 340px;
font-family: var(--vkui--font_family_base);
line-height: 1.4;
text-align: left;
user-select: text;
-webkit-user-select: text;
cursor: default;
border: var(--vkui--size_border2x--regular, .5px) solid var(--vkui--color_separator_primary, transparent);
}
.er-popup * { user-select: text; -webkit-user-select: text; }
.er-popup.visible { display: block; }
.er-popup::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-top-color: var(--vkui--color_background_modal, var(--vkui--color_background_content, #fff));
}
.er-row {
padding: 10px 0;
border-bottom: var(--vkui--size_border2x--regular, .5px) solid var(--vkui--color_separator_primary, #ebedf0);
}
.er-row:last-child { border-bottom: none; padding-bottom: 0; }
.er-row:first-child { padding-top: 0; }
.er-label {
font-size: var(--vkui--font_caption1--font_size--regular, 12px);
line-height: var(--vkui--font_caption1--line_height--regular, 14px);
font-weight: var(--vkui--font_weight_accent2, 500);
color: var(--vkui--color_text_secondary, #818c99);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.er-val {
font-size: var(--vkui--font_title2--font_size--regular, 20px);
line-height: var(--vkui--font_title2--line_height--regular, 24px);
font-family: var(--vkui--font_family_accent, var(--vkui--font_family_base));
font-weight: var(--vkui--font_weight_accent1, 600);
color: var(--vkui--color_text_primary, #222);
margin-bottom: 2px;
}
.er-formula {
font-size: var(--vkui--font_caption2--font_size--regular, 11px);
line-height: var(--vkui--font_caption2--line_height--regular, 14px);
color: var(--vkui--color_text_tertiary, #99a2ad);
font-family: 'SF Mono', Monaco, Consolas, monospace;
}
.er-raw {
margin-top: 10px;
padding: 8px 10px;
border-radius: var(--vkui--size_border_radius--regular, 8px);
background: var(--vkui--color_background_secondary, #f5f5f5);
font-size: var(--vkui--font_caption1--font_size--regular, 12px);
line-height: var(--vkui--font_caption1--line_height--regular, 14px);
color: var(--vkui--color_text_secondary, #818c99);
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
}
.er-raw-item {
display: inline-flex;
align-items: center;
gap: 3px;
white-space: nowrap;
}
.er-raw-num {
font-weight: var(--vkui--font_weight_accent2, 500);
color: var(--vkui--color_text_primary, #222);
}
`);
/* ══════════ УТИЛИТЫ ══════════ */
function num(raw) {
if (!raw) return 0;
let t = raw.trim().toLowerCase();
if (!t) return 0;
let mul = 1;
if (/тыс|k/i.test(t)) mul = 1e3;
else if (/млн|m/i.test(t)) mul = 1e6;
t = t.replace(/[^\d,.\s]/g, '').replace(/\s/g, '').replace(',', '.');
const v = parseFloat(t);
return isNaN(v) ? 0 : Math.round(v * mul);
}
function fmtExact(n) {
if (!n && n !== 0) return '—';
if (n === 0) return '0';
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
function fmtViews(n) {
if (!n) return '—';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return String(n);
}
/* ══════════ ДАННЫЕ ══════════ */
function getActionNum(post, testid) {
const el = post.querySelector(`[data-testid="${testid}"]`);
if (!el) return 0;
const span = el.querySelector('span[class*="vkuiFootnote"]');
if (span) { const v = num(span.textContent); if (v > 0) return v; }
const label = el.getAttribute('aria-label') || '';
const match = label.match(/([\d\s,.]+)/);
if (match) { const v = num(match[1]); if (v > 0) return v; }
return 0;
}
function getSubs() {
if (subsCache !== null) return subsCache;
// Ищем все блоки vkuiHeader__main
const headers = document.querySelectorAll('.vkuiHeader__main');
for (const header of headers) {
const text = header.textContent.toLowerCase();
if (!/подписчик|участник/.test(text)) continue;
// Берём число именно из indicator
const indicator = header.querySelector('.vkuiHeader__indicator');
if (indicator) {
const raw = indicator.textContent.trim();
const v = num(raw);
if (v > 0) {
subsCache = v;
return v;
}
}
}
// Фоллбэк — старый способ, но ограничиваем длину текста до 30 символов
for (const el of document.querySelectorAll('a, span, div, button')) {
const txt = el.textContent.toLowerCase();
if (!/подписчик|участник/.test(txt)) continue;
if (el.textContent.length > 30) continue;
if (el.querySelector('.vkuiHeader__indicator')) continue; // пропускаем составные
const v = num(el.textContent);
if (v > 0) { subsCache = v; return v; }
}
return 0;
}
/* ══════════ ПРОСМОТРЫ ══════════ */
function extractViews(node) {
const full = node.textContent || '';
let cleaned = full;
const timeMatch = cleaned.match(/\d{1,2}:\d{2}/);
if (timeMatch) {
cleaned = cleaned.substring(timeMatch.index + timeMatch[0].length);
}
const m = cleaned.match(/([\d]+[,.]?\d*)\s*([KkMmтыс.млн]*)\s*просмотр/i);
if (m) return num(m[1] + (m[2] || ''));
return 0;
}
function fetchViewsForPost(post) {
return new Promise((resolve) => {
const postId = post.getAttribute('data-post-id');
if (viewsCache.has(postId)) return resolve(viewsCache.get(postId));
const dateLink = post.querySelector('[data-testid="post_date_block_preview"]');
if (!dateLink) return resolve(0);
function tryFetch() {
if (fetchLock) {
setTimeout(tryFetch, 200);
return;
}
if (viewsCache.has(postId)) return resolve(viewsCache.get(postId));
fetchLock = true;
let done = false;
const obs = new MutationObserver((muts) => {
for (const m of muts) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1 || done) continue;
if (/просмотр/i.test(node.textContent)) {
const v = extractViews(node);
if (v > 0) {
done = true;
viewsCache.set(postId, v);
obs.disconnect();
dateLink.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
dateLink.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
setTimeout(() => { fetchLock = false; }, 300);
resolve(v);
}
}
}
}
});
obs.observe(document.body, { childList: true, subtree: false });
dateLink.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
dateLink.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
setTimeout(() => {
if (!done) {
done = true;
obs.disconnect();
dateLink.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
dateLink.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
setTimeout(() => { fetchLock = false; }, 300);
resolve(0);
}
}, 1200);
}
tryFetch();
});
}
new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
if (/просмотр/i.test(node.textContent)) {
const post = document.querySelector('[data-post-id]:hover');
if (post) {
const v = extractViews(node);
if (v > 0) viewsCache.set(post.getAttribute('data-post-id'), v);
}
}
}
}
}).observe(document.body, { childList: true, subtree: false });
/* ══════════ ERV С КОЭФФИЦИЕНТОМ ══════════ */
function calcERV(eng, views) {
if (views <= 0) return { val: 'N/A', divisor: '—' };
const useCoeff = ERV_COEFF > 0 && ERV_COEFF !== 1;
const divisor = useCoeff ? views * ERV_COEFF : views;
const val = ((eng / divisor) * 100).toFixed(2);
// Строка для формулы
const divisorStr = useCoeff
? `${fmtViews(views)} × ${ERV_COEFF}`
: fmtViews(views);
return { val, divisorStr };
}
/* ══════════ РЕНДЕР ══════════ */
function renderPopup(popup, likes, comments, reposts, views, subs, eng) {
const erv = calcERV(eng, views);
const er = subs > 0 ? ((eng / subs) * 100).toFixed(2) : 'N/A';
const viral = subs > 0 && views > 0 ? (views / subs).toFixed(2) : 'N/A';
const coeffLabel = (ERV_COEFF > 0 && ERV_COEFF !== 1)
? ` · коэф ${ERV_COEFF}`
: '';
popup.innerHTML = `
<div class="er-row">
<div class="er-label">ERV · Качество${coeffLabel}</div>
<div class="er-val">${erv.val}${erv.val !== 'N/A' ? '%' : ''}</div>
<div class="er-formula">(${fmtExact(likes)} + ${fmtExact(comments)} + ${fmtExact(reposts)}) / ${erv.divisorStr} × 100</div>
</div>
<div class="er-row">
<div class="er-label">ER · Лояльность</div>
<div class="er-val">${er}${er !== 'N/A' ? '%' : ''}</div>
<div class="er-formula">(${fmtExact(likes)} + ${fmtExact(comments)} + ${fmtExact(reposts)}) / ${fmtExact(subs)} × 100</div>
</div>
<div class="er-row">
<div class="er-label">Viral · Охват</div>
<div class="er-val">${viral}</div>
<div class="er-formula">${fmtViews(views)} / ${fmtExact(subs)}</div>
</div>
<div class="er-raw">
<span class="er-raw-item">👁 <span class="er-raw-num">${fmtViews(views)}</span></span>
<span class="er-raw-item">👍 <span class="er-raw-num">${fmtExact(likes)}</span></span>
<span class="er-raw-item">💬 <span class="er-raw-num">${fmtExact(comments)}</span></span>
<span class="er-raw-item">🔁 <span class="er-raw-num">${fmtExact(reposts)}</span></span>
<span class="er-raw-item">👥 <span class="er-raw-num">${fmtExact(subs)}</span></span>
</div>`;
}
async function refreshPopup(popup, post) {
const likes = getActionNum(post, 'post_footer_action_like');
const comments = getActionNum(post, 'post_footer_action_comment');
const reposts = getActionNum(post, 'post_footer_action_share');
const subs = getSubs();
const eng = likes + comments + reposts;
const postId = post.getAttribute('data-post-id');
let views = viewsCache.get(postId) || 0;
if (views > 0) {
renderPopup(popup, likes, comments, reposts, views, subs, eng);
} else {
renderPopup(popup, likes, comments, reposts, 0, subs, eng);
views = await fetchViewsForPost(post);
renderPopup(popup, likes, comments, reposts, views, subs, eng);
}
}
/* ══════════ ВСТАВКА ══════════ */
function inject(post) {
if (post.getAttribute(ATTR)) return;
post.setAttribute(ATTR, '1');
const shareBtn = post.querySelector('[data-testid="post_footer_action_share"]');
if (!shareBtn) return;
const btn = document.createElement('div');
btn.className = 'er-trigger';
btn.title = 'ER-метрики';
const popup = document.createElement('div');
popup.className = 'er-popup';
btn.innerHTML = `
<svg viewBox="0 0 24 24" fill="currentColor">
<rect x="3" y="14" width="4" height="7" rx="1"/>
<rect x="10" y="9" width="4" height="12" rx="1"/>
<rect x="17" y="4" width="4" height="17" rx="1"/>
</svg>
<span class="er-trigger__label">ER</span>`;
btn.appendChild(popup);
let pinned = false;
let hideTimer = null;
function showPopup() {
clearTimeout(hideTimer);
refreshPopup(popup, post);
popup.classList.add('visible');
}
function hidePopup() {
if (pinned) return;
hideTimer = setTimeout(() => popup.classList.remove('visible'), 300);
}
btn.addEventListener('mouseenter', showPopup);
btn.addEventListener('mouseleave', hidePopup);
popup.addEventListener('mouseenter', () => clearTimeout(hideTimer));
popup.addEventListener('mouseleave', hidePopup);
popup.addEventListener('click', e => e.stopPropagation());
popup.addEventListener('mousedown', e => e.stopPropagation());
popup.addEventListener('mouseup', e => e.stopPropagation());
btn.addEventListener('click', (e) => {
if (e.target.closest('.er-popup')) return;
e.stopPropagation();
e.preventDefault();
pinned = !pinned;
if (pinned) {
refreshPopup(popup, post);
popup.classList.add('visible');
btn.style.color = 'var(--vkui--color_icon_accent)';
} else {
popup.classList.remove('visible');
btn.style.color = '';
}
});
shareBtn.insertAdjacentElement('afterend', btn);
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.er-trigger')) {
document.querySelectorAll('.er-trigger').forEach(btn => {
const popup = btn.querySelector('.er-popup');
if (popup) popup.classList.remove('visible');
btn.style.color = '';
});
}
});
function scan() {
document.querySelectorAll('[data-post-id]:not([' + ATTR + '])').forEach(inject);
}
[3000, 5000, 7000, 10000].forEach(d => setTimeout(scan, d));
let timer;
new MutationObserver(() => {
if (location.href !== lastURL) { lastURL = location.href; subsCache = null; }
clearTimeout(timer);
timer = setTimeout(scan, 800);
}).observe(document.body, { childList: true, subtree: true });
window.addEventListener('scroll', () => {
clearTimeout(timer);
timer = setTimeout(scan, 800);
}, { passive: true });
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment