// ==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 // @downloadURL none // ==/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=`
跳过B站转转视频内置广告助手
状态: 等待
弹幕: 0
跳过点: 0
时间: 0:00
分析进度
0%
弹幕匹配
匹配: 0
`; 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(/0){ updateCounts(count,0); try{ parseDanmuAndAnalyze(xml); }catch(e){ addLog('解析失败(主线程): '+e.message); } return; } } tryCommentXml(cid).then(res=>{ if(res){ const c=(res.match(/0){ updateCounts(c,0); parseDanmuAndAnalyze(res); return; } } if(attempt{ 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 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 = /(?=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 = `
${formatTime(it.trigger)} → ${formatTime(B)}
${escapeHtml(it.text||'')}
`; 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,'&').replace(//g,'>'); } 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); })();