Greasy Fork

来自缓存

Greasy Fork is available in English.

SOOP VOD - 순수 조회수 확인

SOOP 방송국 VOD에서 썸네일에 순수 조회수 표시

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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