Greasy Fork is available in English.
SOOP 방송국 VOD에서 썸네일에 순수 조회수 표시
// ==UserScript==
// @name SOOP VOD - 순수 조회수 확인
// @namespace soop-vod-readcnt
// @version 1.0.2
// @author hakkutakku
// @description SOOP 방송국 VOD에서 썸네일에 순수 조회수 표시
// @match https://www.sooplive.com/station/*
// @icon https://res.sooplive.com/favicon.ico
// @grant GM_xmlhttpRequest
// @connect chapi.sooplive.com
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
perPage: 60,
orderBy: 'reg_date',
debug: false,
fallbackFirstDelayMs: 350,
fallbackRetryMs: 1200,
fallbackRetryCount: 3,
minAnchorsForContainer: 2,
maxContainerScanDepth: 8,
};
const STYLE_ID = 'tm-soop-vod-badge-style';
const BADGE_CLASS = 'tm-vod-readcnt-badge';
const CANDIDATE_SELECTOR = [
'a[href*="/player/"]',
'a[href*="title_no="]',
'a[href*="/vod/"]',
].join(',');
const state = {
itemsByTitleNo: new Map(),
itemsLoaded: false,
lastUrl: location.href,
lastRouteKey: '',
renderQueued: false,
fullRenderNeeded: false,
pendingAnchors: new Set(),
listContainer: null,
containerObserver: null,
bootstrapObserver: null,
fallbackTimer: null,
inflightFallbackKey: '',
historyHooked: false,
fetchHooked: false,
xhrHooked: false,
initStarted: false,
};
function log(...args) {
if (CONFIG.debug) {
console.log('[SOOP vod badge]', ...args);
}
}
function isStationPage() {
return /^\/station\/[^/]+(?:\/.*)?$/.test(location.pathname);
}
function getStationId() {
const match = location.pathname.match(/^\/station\/([^/]+)(?:\/|$)/);
return match ? decodeURIComponent(match[1]) : null;
}
function getRouteKind(pathname = location.pathname) {
const stationVodBase = /^\/station\/[^/]+\/vod(?:\/)?$/;
const reviewRoute = /^\/station\/[^/]+\/vod\/review(?:\/)?$/;
const clipRoute = /^\/station\/[^/]+\/vod\/clip(?:\/)?$/;
const normalRoute = /^\/station\/[^/]+\/vod\/normal(?:\/)?$/;
if (stationVodBase.test(pathname)) return 'ALL';
if (reviewRoute.test(pathname)) return 'REVIEW';
if (clipRoute.test(pathname)) return 'CLIP';
if (normalRoute.test(pathname)) return 'NORMAL';
return 'OTHER';
}
function isEnabledVodRoute() {
const kind = getRouteKind();
return kind === 'ALL' || kind === 'REVIEW';
}
function getCurrentPage() {
const url = new URL(location.href);
const candidates = [
url.searchParams.get('page'),
url.searchParams.get('p'),
(location.hash.match(/[?&]page=(\d+)/) || [])[1],
document.querySelector('[aria-current="page"]')?.textContent?.trim(),
document.querySelector('.active, .is-active')?.textContent?.trim(),
];
for (const value of candidates) {
const page = Number(value);
if (Number.isInteger(page) && page > 0) {
return page;
}
}
return 1;
}
function getRouteStateKey() {
return [
getStationId() || '',
getRouteKind(),
getCurrentPage(),
].join('|');
}
function buildApiUrl() {
const stationId = getStationId();
if (!stationId) return null;
const routeKind = getRouteKind();
const page = getCurrentPage();
const params = new URLSearchParams({
keyword: '',
orderby: CONFIG.orderBy,
page: String(page),
field: 'title,contents,user_nick,user_id',
per_page: String(CONFIG.perPage),
start_date: '',
end_date: '',
});
if (routeKind === 'ALL') {
return `https://chapi.sooplive.com/api/${encodeURIComponent(stationId)}/vods/all/streamer?${params.toString()}`;
}
if (routeKind === 'REVIEW') {
return `https://chapi.sooplive.com/api/${encodeURIComponent(stationId)}/vods/review?${params.toString()}`;
}
return null;
}
function getApiKindFromUrl(requestUrl) {
const url = String(requestUrl || '').toLowerCase();
if (url.includes('/vods/all/streamer')) return 'ALL';
if (url.includes('/vods/review')) return 'REVIEW';
if (url.includes('/vods/clip/all')) return 'CLIP';
if (url.includes('/vods/normal/all')) return 'NORMAL';
return 'OTHER';
}
function isAcceptedApiKind(apiKind) {
return apiKind === 'ALL' || apiKind === 'REVIEW';
}
function doesApiMatchCurrentRoute(requestUrl) {
const apiKind = getApiKindFromUrl(requestUrl);
const routeKind = getRouteKind();
if (!isAcceptedApiKind(apiKind)) return false;
return apiKind === routeKind;
}
function extractItems(payload) {
const candidates = [
payload,
payload?.data,
payload?.items,
payload?.list,
payload?.vods,
payload?.data?.items,
payload?.data?.list,
payload?.data?.vods,
].filter(Array.isArray);
for (const arr of candidates) {
if (arr.length > 0) {
return arr;
}
}
return [];
}
function getItemFileType(item) {
return String(item?.ucc?.file_type || '').toUpperCase().trim();
}
function shouldIncludeItem(item, requestUrl = '') {
if (!item || item.title_no == null) {
return false;
}
const routeKind = getRouteKind();
const apiKind = getApiKindFromUrl(requestUrl);
const fileType = getItemFileType(item);
if (
(routeKind === 'ALL' || apiKind === 'ALL') &&
(fileType === 'CLIP' || fileType === 'NORMAL')
) {
return false;
}
return true;
}
function applyPayload(payload, requestUrl = '') {
if (!isEnabledVodRoute()) return false;
if (requestUrl && !doesApiMatchCurrentRoute(requestUrl)) {
log('ignored payload for route mismatch:', requestUrl);
return false;
}
const items = extractItems(payload);
if (!items.length) return false;
const filteredItems = items.filter(item => shouldIncludeItem(item, requestUrl));
state.itemsByTitleNo = new Map(
filteredItems.map(item => [String(item.title_no), item])
);
state.itemsLoaded = true;
log('payload applied:', {
total: items.length,
filtered: filteredItems.length,
requestUrl,
});
ensureListContainerObserved();
queueFullRender();
return true;
}
function gmGetJson(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
Accept: 'application/json, text/plain, */*',
},
onload(response) {
if (response.status < 200 || response.status >= 300) {
reject(new Error(`HTTP ${response.status}`));
return;
}
try {
resolve(JSON.parse(response.responseText));
} catch (error) {
reject(error);
}
},
onerror() {
reject(new Error('request failed'));
},
ontimeout() {
reject(new Error('request timeout'));
},
});
});
}
async function fallbackFetch(routeKeyAtRequest) {
const url = buildApiUrl();
if (!url) return;
const fetchKey = `${routeKeyAtRequest}|${url}`;
if (state.inflightFallbackKey === fetchKey) return;
state.inflightFallbackKey = fetchKey;
try {
log('fallback fetch:', url);
const payload = await gmGetJson(url);
if (routeKeyAtRequest !== state.lastRouteKey) {
return;
}
applyPayload(payload, url);
} catch (error) {
console.error('[SOOP vod badge] fallback fetch error:', error);
} finally {
if (state.inflightFallbackKey === fetchKey) {
state.inflightFallbackKey = '';
}
}
}
function scheduleFallbackFetches() {
clearTimeout(state.fallbackTimer);
if (!isEnabledVodRoute()) return;
const routeKeyAtStart = state.lastRouteKey;
let attempts = 0;
const run = async () => {
if (!isEnabledVodRoute()) return;
if (routeKeyAtStart !== state.lastRouteKey) return;
if (state.itemsLoaded) return;
attempts += 1;
await fallbackFetch(routeKeyAtStart);
if (!state.itemsLoaded && attempts < CONFIG.fallbackRetryCount) {
state.fallbackTimer = setTimeout(run, CONFIG.fallbackRetryMs);
}
};
state.fallbackTimer = setTimeout(run, CONFIG.fallbackFirstDelayMs);
}
function parseTitleNoFromHref(href) {
if (!href) return null;
const patterns = [
/\/player\/(\d+)(?:[/?#]|$)/,
/[?&]title_no=(\d+)/,
/\/vod\/(\d+)(?:[/?#]|$)/,
];
for (const pattern of patterns) {
const match = String(href).match(pattern);
if (match) return match[1];
}
return null;
}
function getAnchorTitleNo(anchor) {
if (!(anchor instanceof HTMLAnchorElement)) return null;
const cached = anchor.dataset.tmTitleNo;
if (cached) return cached;
const titleNo = parseTitleNoFromHref(anchor.href);
if (titleNo) {
anchor.dataset.tmTitleNo = titleNo;
}
return titleNo;
}
function formatNumber(value) {
const num = Number(value || 0);
return Number.isFinite(num) ? num.toLocaleString('ko-KR') : '0';
}
function ensureStyles() {
if (!document.head) return;
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.${BADGE_CLASS} {
position: absolute;
top: 8px;
right: 8px;
z-index: 30;
padding: 4px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.82);
color: #fff;
font-size: 12px;
font-weight: 700;
line-height: 1;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
white-space: nowrap;
}
`;
document.head.appendChild(style);
}
function anchorHasThumbnailMedia(anchor) {
if (!(anchor instanceof HTMLAnchorElement)) return false;
return !!anchor.querySelector('img, picture, video, canvas, svg');
}
function isCandidateAnchor(anchor) {
return (
anchor instanceof HTMLAnchorElement &&
!!getAnchorTitleNo(anchor) &&
anchorHasThumbnailMedia(anchor)
);
}
function getCandidateAnchorsWithin(root) {
const result = new Set();
if (!root) return [];
if (root instanceof HTMLAnchorElement && isCandidateAnchor(root)) {
result.add(root);
}
if (root.querySelectorAll) {
root.querySelectorAll(CANDIDATE_SELECTOR).forEach(anchor => {
if (isCandidateAnchor(anchor)) {
result.add(anchor);
}
});
}
return [...result];
}
function getElementDepth(el) {
let depth = 0;
let cur = el;
while (cur && cur !== document.body) {
depth += 1;
cur = cur.parentElement;
}
return depth;
}
function scoreContainerCandidate(el, count) {
const cls = `${el.className || ''}`.toLowerCase();
const id = `${el.id || ''}`.toLowerCase();
const tag = (el.tagName || '').toLowerCase();
const hints = /(vod|list|grid|wrap|thumb|item|contents|content|box|area|section)/;
let score = count * 100 + getElementDepth(el);
if (hints.test(cls)) score += 20;
if (hints.test(id)) score += 20;
if (tag === 'ul' || tag === 'ol' || tag === 'section' || tag === 'main') score += 10;
return score;
}
function findBestListContainer() {
if (!document.body || !isEnabledVodRoute()) return null;
const anchors = getCandidateAnchorsWithin(document.body);
if (anchors.length < CONFIG.minAnchorsForContainer) {
return null;
}
const counts = new Map();
for (const anchor of anchors) {
let cur = anchor.parentElement;
let depth = 0;
while (cur && cur !== document.body && depth < CONFIG.maxContainerScanDepth) {
counts.set(cur, (counts.get(cur) || 0) + 1);
cur = cur.parentElement;
depth += 1;
}
}
let best = null;
let bestScore = -Infinity;
for (const [el, count] of counts.entries()) {
if (count < CONFIG.minAnchorsForContainer) continue;
const ownAnchors = getCandidateAnchorsWithin(el).length;
if (ownAnchors < CONFIG.minAnchorsForContainer) continue;
const score = scoreContainerCandidate(el, ownAnchors);
if (score > bestScore) {
best = el;
bestScore = score;
}
}
return best;
}
function removeBadge(anchor) {
if (!(anchor instanceof Element)) return;
anchor.querySelectorAll(`.${BADGE_CLASS}`).forEach(el => el.remove());
}
function upsertBadge(anchor, text, title) {
let badge = anchor.querySelector(`.${BADGE_CLASS}`);
const extraBadges = anchor.querySelectorAll(`.${BADGE_CLASS}`);
if (extraBadges.length > 1) {
extraBadges.forEach((el, index) => {
if (index > 0) el.remove();
});
badge = extraBadges[0];
}
if (!badge) {
badge = document.createElement('div');
badge.className = BADGE_CLASS;
anchor.appendChild(badge);
}
if (badge.textContent !== text) {
badge.textContent = text;
}
if (badge.title !== title) {
badge.title = title;
}
}
function processAnchor(anchor) {
if (!(anchor instanceof HTMLAnchorElement)) return;
if (!anchor.isConnected) return;
const titleNo = getAnchorTitleNo(anchor);
if (!titleNo || !anchorHasThumbnailMedia(anchor)) {
removeBadge(anchor);
return;
}
const item = state.itemsByTitleNo.get(String(titleNo));
if (!item) {
removeBadge(anchor);
return;
}
if (anchor.dataset.tmBadgeReady !== '1') {
if (getComputedStyle(anchor).position === 'static') {
anchor.style.position = 'relative';
}
anchor.dataset.tmBadgeReady = '1';
}
const text = `VOD ${formatNumber(item?.count?.vod_read_cnt ?? 0)}`;
const title = `count.vod_read_cnt / route=${getRouteKind()} / file_type=${getItemFileType(item)}`;
upsertBadge(anchor, text, title);
}
function flushRender() {
state.renderQueued = false;
if (!document.body) return;
if (!isEnabledVodRoute()) {
state.pendingAnchors.clear();
state.fullRenderNeeded = false;
clearAllBadges();
return;
}
ensureStyles();
const renderRoot = state.listContainer || document.body;
const anchors = state.fullRenderNeeded
? getCandidateAnchorsWithin(renderRoot)
: [...state.pendingAnchors].filter(anchor => anchor?.isConnected);
state.pendingAnchors.clear();
state.fullRenderNeeded = false;
if (!anchors.length) return;
for (const anchor of anchors) {
processAnchor(anchor);
}
}
function queueRender() {
if (state.renderQueued) return;
state.renderQueued = true;
requestAnimationFrame(flushRender);
}
function queueFullRender() {
state.fullRenderNeeded = true;
queueRender();
}
function enqueueAnchorsFrom(root) {
const anchors = getCandidateAnchorsWithin(root);
if (!anchors.length) return false;
for (const anchor of anchors) {
state.pendingAnchors.add(anchor);
}
return true;
}
function clearAllBadges() {
if (!document.body) return;
document.querySelectorAll(`.${BADGE_CLASS}`).forEach(el => el.remove());
}
function disconnectContainerObserver() {
if (state.containerObserver) {
state.containerObserver.disconnect();
state.containerObserver = null;
}
}
function disconnectBootstrapObserver() {
if (state.bootstrapObserver) {
state.bootstrapObserver.disconnect();
state.bootstrapObserver = null;
}
}
function observeListContainer(container) {
if (!(container instanceof HTMLElement)) return;
disconnectContainerObserver();
state.listContainer = container;
state.containerObserver = new MutationObserver(mutations => {
if (!isEnabledVodRoute()) return;
let found = false;
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.classList?.contains(BADGE_CLASS)) continue;
if (enqueueAnchorsFrom(node)) {
found = true;
}
}
}
if (found) {
queueRender();
}
});
state.containerObserver.observe(container, {
childList: true,
subtree: true,
});
log('observing list container:', container);
}
function ensureListContainerObserved() {
if (!document.body || !isEnabledVodRoute()) return;
const nextContainer = findBestListContainer();
if (!nextContainer) {
startBootstrapObserver();
return;
}
const containerChanged = state.listContainer !== nextContainer;
if (containerChanged) {
observeListContainer(nextContainer);
}
disconnectBootstrapObserver();
}
function startBootstrapObserver() {
if (state.bootstrapObserver || state.listContainer || !document.body) return;
state.bootstrapObserver = new MutationObserver(mutations => {
if (!isEnabledVodRoute()) return;
let shouldRetryFind = false;
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.classList?.contains(BADGE_CLASS)) continue;
if (enqueueAnchorsFrom(node)) {
shouldRetryFind = true;
}
}
}
if (shouldRetryFind) {
ensureListContainerObserved();
queueRender();
}
});
state.bootstrapObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
function resetRouteState() {
state.itemsLoaded = false;
state.itemsByTitleNo = new Map();
state.inflightFallbackKey = '';
state.pendingAnchors.clear();
state.fullRenderNeeded = false;
state.listContainer = null;
clearTimeout(state.fallbackTimer);
disconnectContainerObserver();
disconnectBootstrapObserver();
clearAllBadges();
if (!isEnabledVodRoute()) return;
startBootstrapObserver();
queueFullRender();
scheduleFallbackFetches();
}
function handlePossibleRouteChange(force = false) {
const nextUrl = location.href;
const nextRouteKey = getRouteStateKey();
if (!force && nextUrl === state.lastUrl && nextRouteKey === state.lastRouteKey) {
return;
}
state.lastUrl = nextUrl;
state.lastRouteKey = nextRouteKey;
log('route changed:', nextRouteKey);
resetRouteState();
}
function hookHistory() {
if (state.historyHooked) return;
state.historyHooked = true;
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function (...args) {
const result = originalPushState.apply(this, args);
handlePossibleRouteChange();
return result;
};
history.replaceState = function (...args) {
const result = originalReplaceState.apply(this, args);
handlePossibleRouteChange();
return result;
};
window.addEventListener('popstate', () => handlePossibleRouteChange());
window.addEventListener('hashchange', () => handlePossibleRouteChange());
}
function hookFetch() {
if (state.fetchHooked || !window.fetch) return;
state.fetchHooked = true;
const originalFetch = window.fetch;
window.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
try {
const requestUrl =
typeof args[0] === 'string'
? args[0]
: args[0] instanceof Request
? args[0].url
: String(args[0] || '');
const apiKind = getApiKindFromUrl(requestUrl);
if (isAcceptedApiKind(apiKind)) {
response.clone().json().then(payload => {
applyPayload(payload, requestUrl);
setTimeout(() => handlePossibleRouteChange(), 0);
}).catch(() => {});
}
} catch (_) {}
return response;
};
}
function hookXhr() {
if (state.xhrHooked) return;
state.xhrHooked = true;
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
const requestUrl = String(url || '');
this.__tm_url = requestUrl;
this.__tm_watch = isAcceptedApiKind(getApiKindFromUrl(requestUrl));
return originalOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (...args) {
if (this.__tm_watch) {
this.addEventListener('load', function onLoad() {
try {
applyPayload(JSON.parse(this.responseText), this.__tm_url);
setTimeout(() => handlePossibleRouteChange(), 0);
} catch (_) {}
}, { once: true });
}
return originalSend.apply(this, args);
};
}
function startDomSide() {
ensureStyles();
state.lastUrl = location.href;
state.lastRouteKey = getRouteStateKey();
if (isEnabledVodRoute()) {
resetRouteState();
} else {
clearAllBadges();
}
}
function init() {
if (state.initStarted || !isStationPage()) return;
state.initStarted = true;
hookFetch();
hookXhr();
hookHistory();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startDomSide, { once: true });
} else {
startDomSide();
}
window.addEventListener('load', () => {
ensureStyles();
if (isEnabledVodRoute()) {
ensureListContainerObserved();
queueFullRender();
if (!state.itemsLoaded) {
scheduleFallbackFetches();
}
}
});
}
init();
})();