// ==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 // @downloadURL https://update.greasyfork.icu/scripts/574084/SOOP%20VOD%20-%20%EC%88%9C%EC%88%98%20%EC%A1%B0%ED%9A%8C%EC%88%98%20%ED%99%95%EC%9D%B8.user.js // @updateURL https://update.greasyfork.icu/scripts/574084/SOOP%20VOD%20-%20%EC%88%9C%EC%88%98%20%EC%A1%B0%ED%9A%8C%EC%88%98%20%ED%99%95%EC%9D%B8.meta.js // ==/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(); })();