BilibiliSponsorBlock-Tampermonkey

使用 bsbsb.top API 跳过标注片段,并以绿色在进度条上标注广告时段

目前為 2025-10-27 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @license MIT
// @name         BilibiliSponsorBlock-Tampermonkey
// @namespace    https://github.com/MCfengyou/BilibiliSponsorBlock-Tampermonkey
// @version      0.3
// @description  使用 bsbsb.top API 跳过标注片段,并以绿色在进度条上标注广告时段
// @author       NeoGe_and_GPT-5
// @match        https://www.bilibili.com/video/*
// @match        https://www.bilibili.com/bangumi/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==


(function() {
  'use strict';
  console.log('[BSB+ FIX3 v3.1] loaded');

  let video = null;
  let segments = [];
  let currentBVID = null;
  let markersContainer = null;
  let manualWhitelist = new Set();
  let userInteracting = false;
  let skipCooldown = false;

  const POLL_INTERVAL = 1000;
  const SEGMENT_COLOR = 'rgba(0,255,0,0.52)';
  const LOG = (...a)=>console.log('[BSB+ FIX3]',...a);

  // --- helpers ---
  function getBVIDFromUrl() {
    const m = location.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/);
    return m ? m[1] : null;
  }
  function getCID() {
    try {
      const s = window.__INITIAL_STATE__;
      return s?.videoData?.cid || s?.epInfo?.cid || s?.pages?.[0]?.cid || null;
    } catch (e) { return null; }
  }

  // --- robust fetch with multiple endpoints and content-type check ---
  async function fetchWithTimeout(resource, options = {}) {
    const { timeout = 6000 } = options;
    const controller = new AbortController();
    const id = setTimeout(() => controller.abort(), timeout);
    try {
      const res = await fetch(resource, { ...options, signal: controller.signal });
      clearTimeout(id);
      return res;
    } finally {
      clearTimeout(id);
    }
  }

  async function fetchSegments(bvid) {
    if (!bvid) return [];
    const cid = getCID();
    // endpoint templates to try (note: real project uses /api/skipSegments?videoID=BV..)
    const endpoints = [
      { base: 'https://bsbsb.top/api/skipSegments', paramName: 'videoID' },
      { base: 'https://bsbsb.top/api/skipSegments', paramName: 'bvid' }, // try alternative
      { base: 'https://bsbsb.top/api/segments', paramName: 'videoID' },
      { base: 'https://bsbsb.top/api/segments', paramName: 'bvid' }
    ];

    for (const ep of endpoints) {
      try {
        const url = new URL(ep.base);
        url.searchParams.set(ep.paramName, bvid);
        if (cid) url.searchParams.set('cid', cid);
        LOG('Trying SponsorBlock API:', url.toString());
        let res;
        try {
          res = await fetchWithTimeout(url.toString(), { timeout: 7000, credentials: 'omit' });
        } catch (e) {
          LOG('Fetch failed/timeout for', url.toString(), e && e.name ? e.name : e);
          continue;
        }
        if (!res.ok) {
          LOG('Non-ok status', res.status, 'for', url.toString());
          continue;
        }
        const ctype = res.headers.get('content-type') || '';
        if (!ctype.includes('application/json')) {
          LOG('Response not JSON (content-type=', ctype, '), skipping this endpoint');
          // avoid trying to parse HTML error page
          continue;
        }
        const data = await res.json();
        // parse returned structure (two common shapes)
        const parsed = [];
        if (Array.isArray(data)) {
          // array might be segments directly or wrapper with segments
          for (const item of data) {
            if (item.segment && Array.isArray(item.segment)) {
              parsed.push({ start: Number(item.segment[0]), end: Number(item.segment[1]), category: item.category || '' });
            } else if (item.segments && Array.isArray(item.segments)) {
              for (const s of item.segments) {
                if (s.segment) parsed.push({ start: Number(s.segment[0]), end: Number(s.segment[1]), category: s.category || '' });
              }
            }
          }
        } else if (data && typeof data === 'object') {
          // some APIs return { segments: [...] }
          if (data.segments && Array.isArray(data.segments)) {
            for (const s of data.segments) {
              if (s.segment) parsed.push({ start: Number(s.segment[0]), end: Number(s.segment[1]), category: s.category || '' });
            }
          }
        }
        parsed.sort((a,b)=>a.start-b.start);
        LOG('Parsed segments count:', parsed.length, 'from', url.toString());
        return parsed;
      } catch (err) {
        LOG('Error while trying endpoint', err);
        continue;
      }
    }
    LOG('All endpoints tried, no valid JSON segments returned.');
    return [];
  }

  // --- DOM utilities ---
  function findVideo() {
    return document.querySelector('video');
  }
  function findProgressBar() {
    // try several selectors; B 站 UI differs by skin
    const candidates = [
      '.bpx-player-progress', '.bpx-player-progress-wrapper',
      '.bilibili-player-video-progress', '.bilibili-player-progress',
      '.bui-progress', '.bilibili-player-video-control-bottom .bui-progress'
    ];
    for (const s of candidates) {
      const el = document.querySelector(s);
      if (el) return el;
    }
    // fallback: find element that looks like a progress bar (role=slider)
    const slider = document.querySelector('[role="slider"]');
    if (slider) return slider;
    return null;
  }

  function ensureMarkerContainer(progressEl) {
    if (!progressEl) return;
    if (markersContainer && progressEl.contains(markersContainer)) return markersContainer;
    // remove old if exists
    if (markersContainer && markersContainer.parentElement) markersContainer.parentElement.removeChild(markersContainer);
    const node = document.createElement('div');
    node.className = 'bsb-marker-container';
    Object.assign(node.style, {
      position: 'absolute',
      left: 0, top: 0, width: '100%', height: '100%',
      pointerEvents: 'none', zIndex: 9999,
    });
    // make sure parent is positioned
    const computed = getComputedStyle(progressEl);
    if (computed.position === 'static') progressEl.style.position = 'relative';
    progressEl.appendChild(node);
    markersContainer = node;
    return node;
  }

  function renderMarkers() {
    if (!markersContainer || !video) return;
    markersContainer.innerHTML = '';
    const dur = video.duration || 0;
    if (!dur || !isFinite(dur) || dur <= 0) return;
    for (const s of segments) {
      if (s.end <= s.start) continue;
      const left = (s.start / dur) * 100;
      const width = ((s.end - s.start) / dur) * 100;
      const mark = document.createElement('div');
      Object.assign(mark.style, {
        position: 'absolute',
        left: left + '%',
        width: width + '%',
        top: 0,
        height: '100%',
        background: SEGMENT_COLOR,
        pointerEvents: 'none' // do not capture clicks
      });
      markersContainer.appendChild(mark);
    }
  }

  // --- skip logic (natural play only) ---
  function findSegmentAtTime(t) {
    for (const s of segments) if (t >= s.start && t < s.end) return s;
    return null;
  }

  function showSkipNotice(text) {
    const existing = document.getElementById('bsb-skip-notice');
    if (existing) existing.remove();
    const notice = document.createElement('div');
    notice.id = 'bsb-skip-notice';
    notice.innerText = text;
    Object.assign(notice.style, {
      position: 'fixed',
      right: '18px',
      bottom: '86px', // slightly higher to avoid controls
      padding: '10px 14px',
      background: 'rgba(0,170,0,0.92)',
      color: '#fff',
      borderRadius: '8px',
      zIndex: 2147483647,
      pointerEvents: 'none',
      opacity: '0',
      transition: 'opacity .25s ease'
    });
    const target = document.fullscreenElement || document.body;
    target.appendChild(notice);
    requestAnimationFrame(()=> notice.style.opacity = '1');
    setTimeout(()=> notice.style.opacity = '0', 2200);
    setTimeout(()=> notice.remove(), 2600);
  }

  function attachVideoEvents(v) {
    if (!v) return;
    video = v;
    LOG('attached to video element');

    // track lastTime to detect natural play (forward small increments)
    let lastTime = 0;
    v.addEventListener('timeupdate', () => {
      if (!video) return;
      const t = video.currentTime;
      // clean manual whitelist entries that are past
      for (const k of Array.from(manualWhitelist)) {
        const [st, ed] = k.split('-').map(Number);
        if (t >= ed) manualWhitelist.delete(k);
      }
      const seg = findSegmentAtTime(t);
      const delta = t - lastTime;
      const naturalPlay = (delta > 0 && delta < 2 && !userInteracting);
      if (seg) {
        const key = `${seg.start}-${seg.end}`;
        if (manualWhitelist.has(key)) {
          // user manually entered this segment -> do not auto skip
        } else if (naturalPlay && !skipCooldown) {
          skipCooldown = true;
          try {
            video.currentTime = Math.min(seg.end + 0.05, video.duration || seg.end + 0.05);
            showSkipNotice('赞助/恰饭段已跳过 ✓');
            LOG('auto-skipped segment', seg);
          } catch (e) {
            LOG('seek failed', e);
          }
          setTimeout(()=> skipCooldown = false, 900);
        }
      }
      lastTime = t;
    });

    // seeking handlers: mark manual interactions and add whitelist on seeked if inside a segment
    v.addEventListener('seeking', ()=> { userInteracting = true; });
    v.addEventListener('seeked', ()=> {
      setTimeout(()=>{
        const t = video.currentTime;
        const seg = findSegmentAtTime(t);
        if (seg) {
          const key = `${seg.start}-${seg.end}`;
          manualWhitelist.add(key);
          LOG('用户手动进入广告段,加入白名单:', key);
        }
        // keep userInteracting true briefly to avoid racing with timeupdate
        setTimeout(()=> userInteracting = false, 500);
      }, 20);
    });

    v.addEventListener('loadedmetadata', ()=> {
      // re-render markers when duration becomes available
      const p = findProgressBar();
      ensureMarkerContainer(p);
      renderMarkers();
    });
  }

  // --- main loop ---
  async function mainLoop() {
    const bvid = getBVIDFromUrl();
    if (!bvid) return;

    if (bvid !== currentBVID) {
      currentBVID = bvid;
      LOG('BV changed ->', bvid);
      // fetch segments robustly
      segments = await fetchSegments(bvid);
      // normalize (some API shapes might differ); ensure numeric
      segments = (segments || []).map(s=>({ start: Number(s.start), end: Number(s.end), category: s.category || '' })).filter(s=>isFinite(s.start) && isFinite(s.end) && s.end > s.start);
      LOG('segments after normalize:', segments.length);
      manualWhitelist.clear();

      // attach or refresh markers
      const p = findProgressBar();
      ensureMarkerContainer(p);
      renderMarkers();
    }

    // detect progress bar re-creation and reattach markers
    const progressEl = findProgressBar();
    if (progressEl && (!markersContainer || !progressEl.contains(markersContainer))) {
      ensureMarkerContainer(progressEl);
      renderMarkers();
      LOG('Detected progress rebuild -> reattached markers.');
    }

    // attach video element if changed
    const v = findVideo();
    if (v && v !== video) attachVideoEvents(v);
  }

  setInterval(mainLoop, POLL_INTERVAL);

})();