|
// ==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 }); |
|
})(); |