基于弹幕识别的跳过B站内置广告 (改)

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

// ==UserScript==
// @name         基于弹幕识别的跳过B站内置广告 (改)
// @namespace    http://tampermonkey.net/
// @version      0.0.4
// @description  爬取B站视频弹幕进行识别,识别到关键弹幕后跳过视频内置转转广告时间
// @match        https://www.bilibili.com/video/*
// @icon         data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAyRJREFUWEftll1IU2EYx/9n35sfOAvnbEgfRmJ+EH4QmRQtkiiKJCQIu7HM6EIhAim7sqygcBWVGV1JXaXddOOF3kheBWF+JKSlNtEpqamba2eeU++ZZ27OM8/rMgl6b7Z3e97///c+z/uc8zLY4MFssD/+TYDhiovVCoOhJjB73LyzOtnWcIs2o9QZsFdV8mAklvE8LHdsVJpUwYNnjntV23Yqw+6SEkI2QL81p0yXV/As1loId18vPCPf/BzktxhrIWZbWzDT2gK4ndctdQ21csohG2DAmsNr8wogmnnHHXDY7goeIQAALLfrZGn7gx48eZ5t3mJ5L0Wd/fAGAgHEuInHddClpgVnAEDH3iOSCXD/ZK3nik+0kYAlgPoXvNmcJLlICmD80X3o09KpAIhJ8cmjgncIQI8lHzviFCEg+WUHoUwwI770smDGjo6AmEuVoO18UJf69XTd7cJ3agBT+1ukNN7D5qoauD/1CADiWH4GBrIOYCj38IrZXDMAUdtXbgXDLUCbux/hngVtF25KljIiAB/EITAcJ5RDtTUlyIgdGkB7zauw3RcxAFEXyxHo1F9yFY6CY6u2/h8BWNUlTIBsANIeGUaN0CZj7oUgyUSdEn0zLFJj1fju4WB3eqFVMsKcjAWex+dZrzCfYTl8nfPSd0HJ9ih0T7Pg+NDtZBrV+DjFwqRXIE6tQMeEB5u0CmHumOcQ+L+KYaBXMWgacglCsjNAALqmWXROepBp1GBXrErY5YdJD/bEa2QBEMPOKQ+IVuMXJx0ASX2WUYOMxd0aVAxSYlR4PezC6WSDH6B32gu7yyvELs9ARABEUMEA6XG+dAcOMcWiAfkk8YGGYonUDAOdkkHTMGUJiCAPHl1TLBL0wVcAcgjFg+mY9x1Q02IMmZO14iH9wXIYXMshjKTVwq2VdQjXy5zo7ra/E+QlX0braR6ovSLA3zInPhXlpUv3gUtXrq3wuJHGSUo0ITYmOihgds6JkdExqj08vV/ru1+XlFfy0VEGqsWRBs85XWist/kArEVnm38/tE5FKkq5/k1r88siWTdXSmGq8P8AvwDygpwwSaTkBgAAAABJRU5ErkJggg==
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @license MIT
// ==/UserScript==

(function(){ 'use strict';

            const CONFIG = {
                minDanmuCount: 2,            // 弹幕数量阈值
                triggerWindow: 2,
                triggerStdThreshold: 6,
                maxDisplayDanmu: 120,
                maxDanmuLoad: 6000,
                fetchRetries: 3,
                fetchRetryDelayMs: 800,
                weightKeywords: ['0帧起手','零帧起手','连招','猝不及防','丝滑','跳伞','跳','快进','感谢甲方','恭喜恰饭','高能预警','怎么办','妙界','起手','500','666','向右','0帧','空降','转转'],
                extraWeight: 3,
                targetClusterWindow: 2,
                requireForwardJump: true,
                forwardJumpMinDelta: 1,

                earliestClusterMinCount: 2,
                allowSingleEarliestFallback: true,

                earlyBoost: 1.0,
                logSnippetMaxLen: 40,
                ignoreEarlyDanmuSec: 30,      // 忽略开头30秒内的弹幕
                maxBTimeMin: 30,              // b点最大时间(分钟)
                maxABDeltaSec: 150             // ab时间差最大秒数
            };

            const state = { video:null, cid:null, bvid:null, danmuCount:0, isAnalyzing:false, enabled: true ,jumpRules:new Map() };
            // 禁用脚本和隐藏UI
            function disableScriptAndHideUI() {
                try {
                    const ui = document.getElementById('bili-ad-skip-ui');
                    if (ui) ui.style.display = 'none';
                    state.enabled = false;
                    state.jumpRules.clear();
                    updateStatus('已禁用');
                } catch (e) {
                    console.error('disableScriptAndHideUI error', e);
                }
            }

            // 创建UI界面
            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:600px;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:320px;min-width:220px;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:100px;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:100px;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', disableScriptAndHideUI);
        } 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);
                }
            }

            // 更新UI进度
            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) {}
            }

            // 解析视频CID
            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('弹幕为空或受限');
                                    disableScriptAndHideUI();
                                }
                            });
                        },
                        onerror(err) {
                            addLog(`弹幕请求错误: ${err}`);
                            if (attempt < CONFIG.fetchRetries) {
                                setTimeout(doRequest, CONFIG.fetchRetryDelayMs * attempt);
                            } else {
                                updateStatus('请求出错');
                                disableScriptAndHideUI();
                            }
                        }
                    });
                }

                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);
                        }
                    });
                });
            }

            // 解析弹幕并分析
            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;

                            // 忽略开头30秒内的弹幕
                            if (time < CONFIG.ignoreFirstSeconds) continue;

                            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('解析失败');
                    disableScriptAndHideUI();
                }
            }

            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,2})\s*[::∶]\s*([0-90-9零一二两三四五六七八九十]{2,3})/g;
                    const minuteSecondRegex = /([0-90-9零一二两三四五六七八九十]{1,2})\s*分\s*([0-90-9零一二两三四五六七八九十]{2,3})\s*(?:秒)?/g;
                    const spaceSeparatedRegex = /(?<!\d)([0-90-9零一二两三四五六七八九十]{1,2})\s+([0-90-9零一二两三四五六七八九十]{2,3})(?!\d)/g;
                    const bracketRegex = /([0-90-9]{1,2})\s*\.\s*([0-90-9]{2})\s*/g;
                    const minuteOnlyRegex = /([0-9零一二两三四五六七八九十]{1,2})\s*分\s*([0-9零一二两三四五六七八九十]{1,2})/g;

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

                    for(let i=0;i<items.length;i++){
                        const it = items[i];

                        // 跳过开头30秒内的弹幕
                        if(it.time < CONFIG.ignoreEarlyDanmuSec) continue;

                        const originalText = it.text || '';
                        const rawText = normalizeText(originalText || '');
                        if(!rawText) continue;

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

                        // 过滤条件函数
                        function addCandidateIfValid(min, sec, baseWeight) {
                            const minVal = isNaN(parseInt(min))? chineseToNumber(min) : parseInt(min);
                            const secVal = isNaN(parseInt(sec))? chineseToNumber(sec) : parseInt(sec);

                            if(isNaN(minVal) || isNaN(secVal) || secVal >= 60) return false;

                            const target = minVal*60 + secVal;
                            const delta = target - it.time;

                            // 新增过滤条件:
                            if(target > CONFIG.maxBTimeMin * 60 || Math.abs(delta) > CONFIG.maxABDeltaSec) {
                                return false;
                            }

                            candidates.push({
                                trigger: it.time,
                                target,
                                text: originalText,
                                weight: baseWeight
                            });
                            totalCandidates++;
                            return true;
                        }

                        colonRegex.lastIndex = 0;
                        let m;
                        while((m = colonRegex.exec(rawText)) !== null){
                            addCandidateIfValid(m[1], m[2], baseWeight);
                        }

                        minuteSecondRegex.lastIndex = 0;
                        while((m = minuteSecondRegex.exec(rawText)) !== null){
                            const rawMin = m[1], rawSec = m[2];
                            if(hasCountOrScoreContext(rawText)) continue;
                            addCandidateIfValid(rawMin, rawSec, baseWeight);
                        }

                        spaceSeparatedRegex.lastIndex = 0;
                        while((m = spaceSeparatedRegex.exec(rawText)) !== null){
                            addCandidateIfValid(m[1], m[2], baseWeight);
                        }


                        bracketRegex.lastIndex = 0;
                        while((m = bracketRegex.exec(rawText)) !== null){
                            const rawMin = m[1], rawSec = m[2];
                            addCandidateIfValid(rawMin, rawSec, baseWeight);
                        }

                        minuteOnlyRegex.lastIndex = 0;
                        while((m = minuteOnlyRegex.exec(rawText)) !== null){
                            addCandidateIfValid(m[1], m[2], baseWeight);
                        }
                    }

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

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

           })();