基于弹幕识别的跳过B站内置转转广告

爬取B站视频弹幕进行识别,识别到关键弹幕后跳过视频内置转转广告时间

// ==UserScript==
// @name         基于弹幕识别的跳过B站内置转转广告
// @namespace    http://tampermonkey.net/
// @version      0.0.1
// @description  爬取B站视频弹幕进行识别,识别到关键弹幕后跳过视频内置转转广告时间
// @match        https://www.bilibili.com/video/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @license MIT
// ==/UserScript==

(function(){ 'use strict';

const CONFIG = {
  minDanmuCount: 3,            // 弹幕数量阈值
  triggerWindow: 2,
  triggerStdThreshold: 6,
  maxDisplayDanmu: 120,
  maxDanmuLoad: 6000,
  fetchRetries: 3,
  fetchRetryDelayMs: 800,
  weightKeywords: ['0帧起手','零帧起手','丝滑','跳伞','跳','快进','起手','0帧','空降','转转'],
  extraWeight: 3,
  targetClusterWindow: 2,
  requireForwardJump: true,
  forwardJumpMinDelta: 1,

  earliestClusterMinCount: 2,
  allowSingleEarliestFallback: true,

  earlyBoost: 1.0,
  logSnippetMaxLen: 40
};

const state = { video:null, cid:null, bvid:null, danmuCount:0, isAnalyzing:false, jumpRules:new Map() };

function createUI(){
  try{
    const old=document.getElementById('bili-ad-skip-ui'); if(old) old.remove();
    const ui=document.createElement('div');
    ui.id='bili-ad-skip-ui';
    ui.style.cssText='position:fixed;top:20px;right:20px;z-index:2147483647;';
    ui.innerHTML=`
<div style="background:rgba(0,10,26,0.95);color:#fff;border:2px solid #00a1d6;border-radius:10px;box-shadow:0 6px 30px rgba(0,0,0,0.6);font-family:Microsoft YaHei,'Helvetica Neue',Arial;font-size:14px;max-width:520px;min-width:320px;padding:12px;">
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
    <div style="display:flex;align-items:center;gap:8px;">
      <div style="color:#00a1d6;font-size:16px;font-weight:700">▶</div>
      <div style="font-weight:700;font-size:15px">跳过B站转转视频内置广告助手</div>
    </div>
    <div>
      <button id="bili-close-ui" style="background:rgba(0,161,214,0.12);border:none;color:#fff;width:30px;height:30px;border-radius:6px;cursor:pointer">✕</button>
    </div>
  </div>
  <div id="bili-status-bar" style="background:linear-gradient(90deg, rgba(0,161,214,0.12), rgba(0,100,150,0.08));padding:10px;border-radius:8px;border:1px solid rgba(0,161,214,0.2);margin-bottom:8px;">
    <div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;"><div>状态: <span id="bili-skip-state">等待</span></div><div>弹幕: <span id="bili-danmu-count">0</span></div></div>
    <div style="display:flex;justify-content:space-between;font-size:13px;"><div>跳过点: <span id="bili-jump-rules-count">0</span></div><div>时间: <span id="bili-current-time">0:00</span></div></div>
  </div>
  <div id="bili-progress-wrap" style="margin-bottom:8px;">
    <div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;"><div>分析进度</div><div id="bili-progress-text">0%</div></div>
    <div style="height:8px;background:#14202a;border-radius:6px;overflow:hidden"><div id="bili-progress-bar" style="height:100%;width:0%;background:linear-gradient(90deg,#00a1d6,#1dd1a1);transition:width 0.2s;"></div></div>
  </div>
  <div id="bili-danmu-container" style="max-height:280px;overflow:auto;border-radius:6px;padding:8px;background:rgba(0,0,0,0.2);margin-bottom:8px;display:block;">
    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;"><div style="font-weight:600">弹幕匹配</div><div style="font-size:13px">匹配: <span id="bili-match-count">0</span></div></div>
    <div id="bili-danmu-list" style="font-size:13px;line-height:1.5;color:#e6f7ff"></div>
  </div>
  <div id="bili-log" style="max-height:260px;overflow:auto;font-size:13px;padding:8px;border-radius:6px;background:rgba(0,0,0,0.25)"></div>
</div>
`;
    document.body.appendChild(ui);
    document.getElementById('bili-close-ui').addEventListener('click', ()=>{ ui.style.display='none'; addLog('UI 已隐藏(刷新可恢复)'); });
  }catch(e){
    console.error('createUI error', e);
  }
}

function addLog(msg){
  try{
    const el=document.getElementById('bili-log'); if(!el) return; const p=document.createElement('div'); p.style.padding='6px 4px'; p.style.borderBottom='1px dashed rgba(255,255,255,0.04)'; p.textContent=`[${new Date().toLocaleTimeString()}] ${msg}`; el.appendChild(p); el.scrollTop = el.scrollHeight;
  }catch(e){ console.log(msg); }
}
function updateUIProgress(processed,total){ try{ const percent = total>0?Math.round((processed/total)*100):0; const bar=document.getElementById('bili-progress-bar'); const text=document.getElementById('bili-progress-text'); if(bar) bar.style.width=percent+'%'; if(text) text.textContent=percent+'%'; }catch(e){} }
function updateStatus(text){ try{ const el=document.getElementById('bili-skip-state'); if(el) el.textContent=text; }catch(e){} }
function updateCounts(danmuCount,matchCount){ try{ const d=document.getElementById('bili-danmu-count'); const m=document.getElementById('bili-match-count'); if(d)d.textContent=danmuCount; if(m)m.textContent=matchCount; const r=document.getElementById('bili-jump-rules-count'); if(r) r.textContent = state.jumpRules.size; }catch(e){} }

async function resolveCid(){
  try{
    const initial = window.__INITIAL_STATE__ || window.__PLAYINFO__ || window.__playinfo__ || null;
    if(initial){
      if(initial.videoData && initial.videoData.cid) return initial.videoData.cid;
      if(initial.cid) return initial.cid;
      if(initial.data && initial.data.cid) return initial.data.cid;
    }
    const metaCid = document.querySelector('meta[itemprop="cid"]') || document.querySelector('meta[name="video-cid"]');
    if(metaCid && metaCid.content) return metaCid.content;
    const scripts = Array.from(document.scripts||[]);
    for(const s of scripts){
      if(!s.textContent) continue;
      const m = s.textContent.match(/"cid"\s*:\s*(\d{4,10})/);
      if(m) return m[1];
    }
    const bvidMatch = location.href.match(/(BV[0-9A-Za-z]+)/);
    if(bvidMatch){
      const bv=bvidMatch[1];
      addLog(`检测到 BV: ${bv},尝试通过 API 获取 CID`);
      try{
        const url = `https://api.bilibili.com/x/web-interface/view?bvid=${bv}`;
        const resp = await new Promise((res,rej)=> GM_xmlhttpRequest({ method:'GET', url, onload:r=>res(r), onerror:err=>rej(err) }));
        let json = null;
        try{ json = (typeof resp.response==='object')?resp.response:JSON.parse(resp.responseText||'{}'); }catch(e){}
        if(json && json.data){
          if(Array.isArray(json.data.pages) && json.data.pages.length>0) return json.data.pages[0].cid || json.data.cid || null;
          if(json.data.cid) return json.data.cid;
        }
      }catch(e){}
    }
    if(window.__playinfo__ && window.__playinfo__.data && window.__playinfo__.data.cid) return window.__playinfo__.data.cid;
    return null;
  }catch(e){
    console.error('resolveCid error', e);
    return null;
  }
}

function fetchDanmu(cid){
  updateStatus('正在获取弹幕');
  addLog(`开始请求弹幕 (cid=${cid})`);
  const url = `https://api.bilibili.com/x/v1/dm/list.so?oid=${cid}`;
  let attempt=0;
  function doRequest(){
    attempt++;
    GM_xmlhttpRequest({
      method:'GET', url,
      onload(resp){
        if(resp.status===200 && resp.responseText){
          const xml=resp.responseText;
          const count=(xml.match(/<d\b/gi)||[]).length;
          state.danmuCount=count;
          addLog(`弹幕获取成功,XML 条数检测: ${count}`);
          if(count>0){ updateCounts(count,0); try{ parseDanmuAndAnalyze(xml); }catch(e){ addLog('解析失败(主线程): '+e.message); } return; }
        }
        tryCommentXml(cid).then(res=>{
          if(res){ const c=(res.match(/<d\b/gi)||[]).length; state.danmuCount=c; addLog(`comment.bilibili.com 返回 XML 条数: ${c}`); if(c>0){ updateCounts(c,0); parseDanmuAndAnalyze(res); return; } }
          if(attempt<CONFIG.fetchRetries){ addLog(`重试 list.so(第 ${attempt+1} 次)`); setTimeout(doRequest, CONFIG.fetchRetryDelayMs*attempt); }
          else { addLog('未能通过 XML 接口获取到弹幕,可能受限(登录/权限)', 'error'); updateStatus('弹幕为空或受限'); }
        });
      },
      onerror(err){ addLog(`弹幕请求错误: ${err}`); if(attempt<CONFIG.fetchRetries) setTimeout(doRequest, CONFIG.fetchRetryDelayMs*attempt); else updateStatus('请求出错'); }
    });
  }
  doRequest();
}
function tryCommentXml(cid){
  return new Promise((resolve)=>{ const url=`https://comment.bilibili.com/${cid}.xml`; GM_xmlhttpRequest({ method:'GET', url, onload(r){ if(r.status===200 && r.responseText) resolve(r.responseText); else resolve(null); }, onerror(){ resolve(null); } }); });
}

// DOMParser
function parseDanmuAndAnalyze(xmlText){
  try{
    updateStatus('解析弹幕中');
    const parser = new DOMParser();
    const doc = parser.parseFromString(xmlText, 'text/xml');
    const dnodes = Array.from(doc.getElementsByTagName('d')||[]);
    const items = [];
    for(let i=0;i<dnodes.length && i<CONFIG.maxDanmuLoad;i++){
      try{
        const d = dnodes[i];
        const p = d.getAttribute('p') || '';
        const time = parseFloat((p.split(',')[0])||0) || 0;
        const txt = (d.textContent||'').replace(/\u3000/g,' ').replace(/\u00A0/g,' ').trim();
        items.push({time, text: txt});
      }catch(e){}
    }
    updateUIProgress(items.length, items.length);
    analyzeItems(items);
  }catch(e){
    addLog('DOMParser 解析失败: '+(e.message||e));
    updateStatus('解析失败');
  }
}

function analyzeItems(items){
  try{
    state.isAnalyzing = true;

    const CN_NUM = { '零':0,'一':1,'二':2,'两':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'百':100 };
    function chineseToNumber(str){
      if(!str) return NaN;
      if(str.indexOf('百') !== -1){ const parts=str.split('百'); const hundreds=CN_NUM[parts[0]]||parseInt(parts[0])||0; let tot=hundreds*100; if(parts[1]) tot+=chineseToNumber(parts[1])||0; return tot; }
      if(str.indexOf('十') !== -1){ const parts=str.split('十'); const tens=(parts[0]===''?1:(CN_NUM[parts[0]]||parseInt(parts[0])||1)); let tot=tens*10; if(parts[1]) tot+=CN_NUM[parts[1]]||parseInt(parts[1])||0; return tot; }
      let total=0; for(let ch of str) total = total*10 + (CN_NUM[ch]||(!isNaN(parseInt(ch))?parseInt(ch):0)); return total;
    }
    function normalizeText(s){ if(!s) return s; s = s.replace(/[0-9]/g,function(c){ return String.fromCharCode(c.charCodeAt(0)-0xFF10+0x30); }); s = s.replace(/:/g,':').replace(/,/g,',').replace(/\s+/g,' ').trim(); return s; }
    function hasCountOrScoreContext(text){
      if(!text) return false;
      const ctx = ['记者','人','人数','被杀','死亡','杀死','遇难','伤亡','票','票房','得分','分数','评分','票数','播放','观看','播放量','热度','点赞','赞','观众'];
      for(const k of ctx) if(text.indexOf(k)!==-1) return true;
      return false;
    }

    const kwRegex = new RegExp(CONFIG.weightKeywords.map(k => k.replace(/[.*+?^${}()|[\\]\\]/g,'\\$&')).join('|'), 'i');

    // 识别机制
    const colonRegex = /([0-90-9零一二两三四五六七八九十百]{1,3})\s*[::]\s*([0-90-9零一二两三四五六七八九十百]{1,2})/g;
    const minuteSecondRegex = /([0-90-9零一二两三四五六七八九十百]{1,3})\s*分\s*([0-90-9零一二两三四五六七八九十百]{1,3})\s*(?:秒)?/g;
    const spaceSeparatedRegex = /(?<!\d)([0-90-9零一二两三四五六七八九十百]{1,3})\s+([0-90-9零一二两三四五六七八九十百]{1,2})(?!\d)/g;

    const candidates = []; // {trigger, target, text, weight}
    let totalCandidates = 0;

    for(let i=0;i<items.length;i++){
      const it = items[i];
      const originalText = it.text || '';
      const rawText = normalizeText(originalText || '');
      if(!rawText) continue;

      let baseWeight = 1;
      if(kwRegex.test(rawText)) baseWeight += CONFIG.extraWeight;


      colonRegex.lastIndex = 0;
      let m;
      while((m = colonRegex.exec(rawText)) !== null){
        const rawMin = m[1], rawSec = m[2];
        const min = isNaN(parseInt(rawMin))? chineseToNumber(rawMin) : parseInt(rawMin);
        const sec = isNaN(parseInt(rawSec))? chineseToNumber(rawSec) : parseInt(rawSec);
        if(!isNaN(min) && !isNaN(sec) && sec < 60){
          candidates.push({trigger: it.time, target: min*60 + sec, text: originalText, weight: baseWeight});
          totalCandidates++;
        }
      }


      minuteSecondRegex.lastIndex = 0;
      while((m = minuteSecondRegex.exec(rawText)) !== null){
        const rawMin = m[1], rawSec = m[2];
        const min = isNaN(parseInt(rawMin))? chineseToNumber(rawMin) : parseInt(rawMin);
        const sec = isNaN(parseInt(rawSec))? chineseToNumber(rawSec) : parseInt(rawSec);
        if(min>=60 && (isNaN(sec) || sec===0)) continue; // 过滤像“100分”之类
        if(hasCountOrScoreContext(rawText)) continue;
        if(!isNaN(min) && !isNaN(sec) && sec < 60){
          candidates.push({trigger: it.time, target: min*60 + sec, text: originalText, weight: baseWeight});
          totalCandidates++;
        }
      }


      spaceSeparatedRegex.lastIndex = 0;
      while((m = spaceSeparatedRegex.exec(rawText)) !== null){
        const a = m[1], b = m[2];
        const A = isNaN(parseInt(a))? chineseToNumber(a) : parseInt(a);
        const B = isNaN(parseInt(b))? chineseToNumber(b) : parseInt(b);
        if(!isNaN(A) && !isNaN(B) && B < 60 && A >= 0 && A <= 999){
          candidates.push({trigger: it.time, target: A*60 + B, text: originalText, weight: baseWeight});
          totalCandidates++;
        }
      }
    }

    addLog(`候选时间对数量: ${totalCandidates}(来自 ${items.length} 条弹幕,严格匹配分/:/空格且分后必须有秒)`);

    // 构建 target -> triggers 列表
    const targetMap = new Map(); // target -> [{trigger, text, weight}, ...]
    for(const c of candidates){
      if(!targetMap.has(c.target)) targetMap.set(c.target, []);
      targetMap.get(c.target).push({trigger:c.trigger, text:c.text, weight:c.weight||1});
    }

    if(targetMap.size === 0){
      addLog('未发现任何严格匹配的时间弹幕。');
      updateCounts(items.length, 0);
      updateStatus('未发现时间弹幕');
      state.isAnalyzing = false;
      return;
    }

    const targetStats = [];
    for(const [target, arr] of targetMap.entries()){
      const count = arr.length;
      arr.sort((a,b)=>a.trigger - b.trigger);
      const earliest = arr[0].trigger;
      const weightSum = arr.reduce((s,x)=>s + (x.weight||1), 0);
      targetStats.push({target, count, weightSum, earliest, arr});
    }


    targetStats.sort((a,b)=>{
      if(b.count !== a.count) return b.count - a.count;
      if(b.weightSum !== a.weightSum) return b.weightSum - a.weightSum;
      return a.earliest - b.earliest;
    });

    const chosen = targetStats[0];
    const B = chosen.target;
    addLog(`选定目标 B = ${formatTime(B)}(被指向 ${chosen.count} 条,权重和 ${Math.round(chosen.weightSum)})`);


    const triggers = chosen.arr.map(x=>({trigger:x.trigger, text:x.text, weight:x.weight}));
    triggers.sort((a,b)=>a.trigger - b.trigger);

    const clusters = [];
    for(const t of triggers){
      if(clusters.length === 0){
        clusters.push({triggers:[t.trigger], texts:[t.text], weights:[t.weight]});
        continue;
      }
      const last = clusters[clusters.length-1];
      const lastAvg = last.triggers.reduce((s,v)=>s+v,0)/last.triggers.length;
      if(Math.abs(t.trigger - lastAvg) <= CONFIG.triggerWindow){
        last.triggers.push(t.trigger);
        last.texts.push(t.text);
        last.weights.push(t.weight);
      }else{
        clusters.push({triggers:[t.trigger], texts:[t.text], weights:[t.weight]});
      }
    }

    let chosenCluster = null;
    for(const c of clusters){
      if(c.triggers.length >= CONFIG.earliestClusterMinCount){
        chosenCluster = c;
        break;
      }
    }
    let triggerA = null;
    let clusterSize = 0;
    let snippet = '';
    if(chosenCluster){
      clusterSize = chosenCluster.triggers.length;
      const earliestT = Math.min(...chosenCluster.triggers);
      triggerA = earliestT;
      snippet = chosenCluster.texts[chosenCluster.triggers.indexOf(earliestT)] || chosenCluster.texts[0] || '';
      addLog(`在目标 ${formatTime(B)} 的触发列表中找到 earliest cluster size=${clusterSize},选取最早触发 A=${formatTime(triggerA)}`);
    }else{
      if(CONFIG.allowSingleEarliestFallback){
        triggerA = triggers[0].trigger;
        clusterSize = 1;
        snippet = triggers[0].text || '';
        addLog(`未找到 >=${CONFIG.earliestClusterMinCount} 的 cluster,允许单条回退,选取最早弹幕 A=${formatTime(triggerA)}`);
      }else{
        addLog(`未找到满足 cluster 条件(>=${CONFIG.earliestClusterMinCount}),且单条回退禁用,放弃设置跳点`);
        updateCounts(items.length, 0);
        updateStatus('未发现合适跳点');
        state.isAnalyzing = false;
        return;
      }
    }

    snippet = (snippet||'弹幕').trim().replace(/\s+/g,' ');
    if(snippet.length > CONFIG.logSnippetMaxLen) snippet = snippet.slice(0, CONFIG.logSnippetMaxLen-1) + '…';

    if(CONFIG.requireForwardJump && B <= triggerA + CONFIG.forwardJumpMinDelta){
      addLog(`${formatTime(triggerA)}[${snippet}] 选定跳转 ${formatTime(B)} 被拒(前跳 or 非向后)`);
      updateCounts(items.length, 0);
      updateStatus('未发现合适跳点(前跳被拒)');
      state.isAnalyzing = false;
      return;
    }

    state.jumpRules.clear();
    state.jumpRules.set(triggerA, B);

    addLog(`${formatTime(triggerA)}[${snippet}] 选定跳转 ${formatTime(B)}(target 共 ${chosen.count} 条;使用 cluster size=${clusterSize};${clusterSize>1?'多人支持':'单条回退'})`);

    const displayList = triggers.slice(0, CONFIG.maxDisplayDanmu);
    const listEl = document.getElementById('bili-danmu-list'); if(listEl) listEl.innerHTML='';
    for(const it of displayList){
      const row=document.createElement('div'); row.style.padding='6px 4px'; row.style.marginBottom='6px'; row.style.borderBottom='1px solid rgba(255,255,255,0.03)';
      row.innerHTML = `<div style='font-size:12px;color:#00e6ff'>${formatTime(it.trigger)} → ${formatTime(B)}</div><div style='font-size:13px;opacity:0.95'>${escapeHtml(it.text||'')}</div>`;
      listEl && listEl.appendChild(row);
    }

    updateCounts(items.length, displayList.length);
    updateStatus(`就绪(单跳点 ${formatTime(triggerA)} → ${formatTime(B)})`);

    state.isAnalyzing = false;
  }catch(e){
    console.error('analyzeItems error', e);
    addLog('分析出错: '+(e.message||e));
    updateStatus('分析出错');
    state.isAnalyzing = false;
  }
}

function formatTime(seconds){ seconds = Math.floor(seconds||0); const m = Math.floor(seconds/60); const s = seconds%60; return `${m}:${s.toString().padStart(2,'0')}`; }
function escapeHtml(s){ return (s+'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

function initVideoListener(){
  try{
    if(!state.video) return;
    state.video.addEventListener('timeupdate', function(){
      const ct = this.currentTime;
      const ctEl = document.getElementById('bili-current-time'); if(ctEl) ctEl.textContent = formatTime(ct);
      if(state.jumpRules.size===0) return;
      for(const [trigger,target] of state.jumpRules.entries()){
        if(ct >= trigger - 1 && ct <= trigger + 1){
          addLog(`在 ${formatTime(ct)} 触发跳转 → ${formatTime(target)}`);
          try{ this.currentTime = target; }catch(e){ console.warn('跳转失败', e); }
          state.jumpRules.delete(trigger);
        }
      }
      const rulesEl=document.getElementById('bili-jump-rules-count'); if(rulesEl) rulesEl.textContent = state.jumpRules.size;
    });
  }catch(e){ console.warn(e); }
}

async function init(){
  createUI();
  addLog('脚本已加载(单跳点优先策略,严格分秒匹配)');
  function findVideo(){
    state.video = document.querySelector('video');
    if(state.video){ addLog('检测到 video 元素'); initVideoListener(); runOnce(); return true; }
    return false;
  }
  if(!findVideo()){
    addLog('等待 video 元素加载...');
    const obs = new MutationObserver(()=>{ if(findVideo()) obs.disconnect(); });
    obs.observe(document.body, { childList:true, subtree:true });
  }
}

async function runOnce(){
  updateStatus('初始化 CID 获取');
  const cid = await resolveCid();
  if(cid){ state.cid = cid; addLog('获取到 CID: '+cid); fetchDanmu(cid); }
  else { addLog('无法获取 CID,请刷新或报告问题'); updateStatus('无法获取 CID'); }
}

if(document.readyState==='complete' || document.readyState==='interactive'){ setTimeout(init, 400); } else window.addEventListener('load', init);

})();