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

识别多种时间格式,UI简化。监控 BV 变化并重启脚本(每 10s)

// ==UserScript==
// @name         基于弹幕识别的跳过B站内置广告(v0.2)
// @namespace    http://tampermonkey.net/
// @version      0.2.1
// @description  识别多种时间格式,UI简化。监控 BV 变化并重启脚本(每 10s)
// @match        https://www.bilibili.com/video/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @license MIT
// ==/UserScript==

(function(){ 'use strict';

/* ========== 配置 ========== */
const CONFIG = {
  maxDanmuLoad: 6000,
  fetchRetries: 3,
  fetchRetryDelayMs: 800,
  maxDisplayDanmu: 120,
  triggerWindow: 2,
  earliestClusterMinCount: 2,
  minDeltaSeconds: 5,
  maxDeltaSeconds: 300,
  maxBackwardAllowedSec: 5,
  weightWindowSeconds: 20,
  minSkipDuration: 5,
  maxSkipFraction: 0.5,
  baseWeight: 0.6,
  timeKwExtra: 2.0,
  targetKwExtra: 2.0,
  weightDanmuBoost: 2.5,
  clusterBoostFactor: 0.5,
  rightStepSeconds: 5,
  rightMaxN: 10,
  forbiddenTokens: ['+','%','年','月','日','人','个','比','对','W','万','K','千','百','M','B','G','w','kb','k','a','b','d','g','T'],
  measurementTokens: ['米','码','百米','速度','跑','m','km','km/h','秒','公斤','kg','斤','票','票房','分数','评分','百分比','%','rpm','W','万','K','千','百','M','B','G'],
  maxNonTimeCharsAllowed: 3,
  nonTimeCharsPenaltyFactor: 0.2,
  acceptWeightThreshold: 0.75,
  chineseNumberMaxParseLen: 6,
  fastForwardBoost: 1.2,
  POSTAGE_WINDOW_SECONDS: 25,
  POSTAGE_TARGET_SECONDS: 60,
  TARGET_GROUP_WINDOW: 4,

  // 低权重特殊数字弹幕
  lowWeightTokens: ['9.9','111','555','233','444','333','222','911','0721','250','38','711','1024','2048','315','918','123'],
  lowWeightMultiplier: 0.01,

  // 过早触发剔除阈值(秒)
  earliestIgnoreDeltaSeconds: 30,
};

/* ========== 关键词 ========== */
const TIME_KEYWORDS = ['跳伞','跳','快进','空降','跳过','跳至','快进到','加速','向右','右','→','朗','郎','侠','绯红之王','向右下','右下'];
const WEIGHT_A_KEYWORDS = ['0帧起手','零帧起手','丝滑','起手','0帧','变声期','触发连招','连招','回马枪','加速时间','广告','广告跳过','不想看','广告点','触发','起招','起手帧','感谢甲方','恭喜恰饭','高能预警','贴脸开大'];
const WEIGHT_B_KEYWORDS = ['欢迎回来','欢迎回','感谢侠','感谢郎','感谢朗','感谢绯红之王','谢谢回来','指挥部','感谢指挥部'];

/* ========== 状态对象(全局) ========== */
const state = {
  runId: 0,
  video: null,
  cid: null,
  danmuCount: 0,
  isAnalyzing: false,
  jumpRules: new Map(),
  lastCandidatesLog: [],
  videoListenerRef: null,
  mutationObserver: null,
  uiRootId: 'bili-ad-skip-ui-root',
  bvMonitorId: null,
  lastBV: null,
  pendingTimeouts: [],
  gmRequestSeq: 0,
  activeRequestSeqs: new Set(),
  uiCreated: false,
};

/* ========== UI ========== */
function createUI(){
  const existing = document.getElementById(state.uiRootId);
  if(existing) return;

  const root = document.createElement('div'); root.id = state.uiRootId;
  root.style.cssText = 'position:fixed;top:78px;right:48px;z-index:2147483647;font-family:Microsoft YaHei,Arial;';

  const mini = document.createElement('div'); mini.id='bili-mini-ui';
  mini.style.cssText='background:rgba(0,10,26,0.95);color:#fff;border:2px solid #00a1d6;border-radius:10px;padding:8px 12px;cursor:pointer;box-shadow:0 8px 30px rgba(0,0,0,0.6);';
  mini.innerHTML = `<div style="display:flex;align-items:center;gap:10px"><span id="bili-mini-status">跳点: 等待</span><button id="bili-expand-ui" style="background:transparent;border:none;color:#00a1d6;cursor:pointer;font-weight:700">展开</button></div>`;
  root.appendChild(mini);

  const panel = document.createElement('div'); panel.id='bili-ad-skip-ui';
  panel.style.cssText='display:none; width:720px; max-width:92vw; min-width:340px; background:rgba(0,10,26,0.95); color:#fff; border:2px solid #00a1d6; border-radius:10px; padding:12px; box-shadow:0 10px 40px rgba(0,0,0,0.6);';
  panel.innerHTML = `
    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
      <div style="font-weight:700;font-size:15px">跳过助手(v0.1)</div>
      <div><button id="bili-close-ui" style="background:rgba(0,161,214,0.12);border:none;color:#fff;padding:6px 8px;border-radius:6px;cursor:pointer">缩小</button></div>
    </div>
    <div style="display:flex;gap:10px;margin-bottom:10px;">
      <div style="flex:1;">
        <div style="font-weight:600">弹幕匹配(A → B) 匹配: <span id="bili-match-count">0</span></div>
        <div id="bili-danmu-list" style="max-height:170px;overflow:auto;font-size:13px;color:#e6f7ff;padding:8px;background:rgba(0,0,0,0.12);border-radius:6px;margin-top:6px"></div>
      </div>
      <div style="width:360px;">
        <div style="font-weight:600">候选时间对(全部列出) 总数: <span id="bili-candidates-count">0</span></div>
        <div id="bili-candidates-list" style="max-height:250px;overflow:auto;font-size:13px;color:#e6f7ff;padding:8px;background:rgba(0,0,0,0.12);border-radius:6px;margin-top:6px"></div>
      </div>
    </div>
    <div id="bili-log" style="max-height:360px;overflow:auto;font-size:13px;padding:8px;border-radius:6px;background:rgba(0,0,0,0.2);"></div>
  `;
  root.appendChild(panel);
  document.body.appendChild(root);
  state.uiCreated = true;

  document.getElementById('bili-expand-ui').addEventListener('click', ()=>{ panel.style.display='block'; mini.style.display='none'; });
  document.getElementById('bili-close-ui').addEventListener('click', ()=>{ panel.style.display='none'; mini.style.display='block'; addLog('UI 已隐藏(可点击展开)'); });
}

function removeUI(){
  const el = document.getElementById(state.uiRootId);
  if(el && el.parentNode) el.parentNode.removeChild(el);
  state.uiCreated = false;
}

/* ========== 日志与显示 ========== */
function addLog(msg){
  const el = document.getElementById('bili-log');
  const line = `[${new Date().toLocaleTimeString()}] ${msg}`;
  if(el){
    const p = document.createElement('div');
    p.style.padding='6px 4px';
    p.style.borderBottom='1px dashed rgba(255,255,255,0.04)';
    p.textContent = line;
    el.appendChild(p);
    el.scrollTop = el.scrollHeight;
  }else{
    console.log(line);
  }
}
function updateMiniStatus(textOrBool){
  const el = document.getElementById('bili-mini-status');
  if(!el) return;
  if(typeof textOrBool === 'boolean') el.textContent = textOrBool ? '跳点: 有' : '跳点: 无';
  else el.textContent = textOrBool;
}
function formatTime(sec){ sec = Math.floor(sec||0); const m=Math.floor(sec/60); const s=sec%60; return `${m}:${s.toString().padStart(2,'0')}`; }
function normalizeText(s){ if(!s) return s; s = s.replace(/[0-9]/g,c=>String.fromCharCode(c.charCodeAt(0)-0xFF10+0x30)); s = s.replace(/:/g,':').replace(/,/g,',').replace(/\s+/g,' ').trim(); return s; }

/* ========== 中文数字解析 ========== */
const CN_NUM = { '零':0,'一':1,'二':2,'两':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'百':100 };
function chineseToNumber(str){
  if(!str) return NaN; str = str.trim();
  if(str.length > CONFIG.chineseNumberMaxParseLen) return NaN;
  if(str.indexOf('百')!==-1){
    const parts = str.split('百'); const h = CN_NUM[parts[0]] || parseInt(parts[0]) || 0; const rest = parts[1] ? (chineseToNumber(parts[1])||0) : 0; return h*100 + rest;
  }
  if(str.indexOf('十')!==-1){
    const parts = str.split('十'); let tens = parts[0]===''?1:(CN_NUM[parts[0]]||parseInt(parts[0])||0); const rest = parts[1] ? (CN_NUM[parts[1]]||parseInt(parts[1])||0) : 0; return tens*10 + rest;
  }
  let total = 0;
  for(const ch of str){
    if(CN_NUM.hasOwnProperty(ch)) total = total*10 + CN_NUM[ch];
    else if(!isNaN(parseInt(ch))) total = total*10 + parseInt(ch);
    else return NaN;
  }
  return total;
}
function parseNumberToken(tok){
  if(tok === undefined || tok === null) return NaN;
  tok = tok.toString().trim();
  if(tok === '') return NaN;
  if(!isNaN(parseInt(tok))) return parseInt(tok);
  const cn = chineseToNumber(tok);
  return isNaN(cn) ? NaN : cn;
}

/* ========== 上下文判断 ========== */
function isMeasurementContext(text){
  if(!text) return false;
  const lower = text.toLowerCase();
  for(const tk of CONFIG.measurementTokens) if(lower.indexOf(tk)!==-1) return true;
  const extra = ['跑','速度','计时','百米','百码','成绩','公里','km','m/s','秒表','米/s'];
  for(const e of extra) if(text.indexOf(e)!==-1) return true;
  return false;
}
function isScoreContext(text){
  if(!text) return false;
  const t = text.replace(/\s+/g,'');
  const scoreKw = ['满分','评分','分数','打分','得分','多少分','评分为','分数是','给分','给我分','给他分'];
  for(const kw of scoreKw) if(t.indexOf(kw) !== -1) return true;
  if(/给.{0,8}分/.test(text)) return true;
  if(/(?:\d+|[零一二两三四五六七八九十百])分(?:是|,|,|。|$)/.test(text)) {
    if(/秒|分钟|:|:/.test(text)) return false;
    return true;
  }
  return false;
}
function isFastForwardInstruction(text){
  if(!text) return false;
  if(/(?:快进到|快进|跳到|跳至|跳过)(?:到)?/.test(text) && /[0-9零一二两三四五六七八九十百]{1,3}\s*分/.test(text)) return true;
  return false;
}
function isPostAgeContext(text){
  if(!text) return false;
  const raw = text.replace(/\s+/g,'');
  const kws = ['发布一分钟','发布于','发布后','刚出炉','刚发布','刚刚发布','刚发布','刚出','第一分钟','刚刚','发布','新鲜','热乎'];
  for(const k of kws){ if(raw.indexOf(k) !== -1) return true; }
  if(/(?:发布|刚|刚刚|刚出炉).{0,6}[0-9零一二两三四五六七八九十百]{1,3}分/.test(text)) return true;
  return false;
}

/* ========== 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,12})/);
      if(m) return m[1];
    }
    const bvidMatch = location.href.match(/(BV[0-9A-Za-z]+)/);
    if(bvidMatch){
      const bv=bvidMatch[1];
      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, runIdLocal){
  addLog(`开始请求弹幕 (cid=${cid})`);
  const url = `https://api.bilibili.com/x/v1/dm/list.so?oid=${cid}`;
  let attempt = 0;
  function doRequest(){
    attempt++;
    const seq = ++state.gmRequestSeq;
    state.activeRequestSeqs.add(seq);
    GM_xmlhttpRequest({
      method:'GET', url,
      onload(resp){
        state.activeRequestSeqs.delete(seq);
        if(runIdLocal !== state.runId){ addLog('弹幕响应来自旧 runId,忽略'); return; }
        if(resp.status===200 && resp.responseText){
          const xml = resp.responseText;
          const count = (xml.match(/<d\b/gi) || []).length;
          state.danmuCount = count;
          addLog(`初步弹幕数量: ${count}`);
          if(count>0){ parseDanmuAndAnalyze(xml, runIdLocal); return; }
        }
        tryCommentXml(cid).then(res=>{
          if(runIdLocal !== state.runId){ addLog('fallback XML 来自旧 runId,忽略'); return; }
          if(res){ const c=(res.match(/<d\b/gi)||[]).length; state.danmuCount=c; addLog(`fallback XML 条数: ${c}`); if(c>0){ parseDanmuAndAnalyze(res, runIdLocal); return; } }
          if(attempt < CONFIG.fetchRetries){ addLog(`重试 list.so(第 ${attempt+1} 次)`); const to = setTimeout(doRequest, CONFIG.fetchRetryDelayMs*attempt); state.pendingTimeouts.push(to); }
          else addLog('未能通过 XML 接口获取到弹幕,可能受限(登录(不可用)/权限)');
        });
      },
      onerror(err){
        state.activeRequestSeqs.delete(seq);
        addLog(`弹幕请求错误: ${err}`);
        if(attempt < CONFIG.fetchRetries){ const to = setTimeout(doRequest, CONFIG.fetchRetryDelayMs*attempt); state.pendingTimeouts.push(to); }
        else addLog('请求出错');
      }
    });
  }
  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 countNonTimeChars(s){
  if(!s) return 0;
  let t = s.replace(/[0-90-9]/g,'');
  t = t.replace(/[零一二两三四五六七八九十百千]/g,'');
  t = t.replace(/分|分钟|秒|:|:|\.|,|,|%|\(|\)|\?|!|\!|\/|\\|→|>/g,'');
  t = t.replace(/[A-Za-z]/g,'').replace(/\s+/g,'');
  return t.length;
}

function parseDanmuAndAnalyze(xml, runIdLocal){
  try{
    if(runIdLocal !== state.runId){ addLog('parseDanmuAndAnalyze 来自旧 runId,忽略'); return; }
    addLog('解析弹幕中...');
    const parser = new DOMParser();
    const doc = parser.parseFromString(xml, 'text/xml');
    const dnodes = Array.from(doc.getElementsByTagName('d') || []);
    const items = [];
    for(let i=0;i<dnodes.length && i<CONFIG.maxDanmuLoad;i++){
      const d = dnodes[i];
      const p = d.getAttribute('p') || '';
      const t = parseFloat((p.split(',')[0])||0) || 0;
      const txt = (d.textContent||'').replace(/\u3000/g,' ').replace(/\u00A0/g,' ').trim();
      items.push({ time: t, text: txt });
    }
    analyzeItems(items, runIdLocal);
  }catch(e){ addLog('解析出错: ' + (e.message||e)); }
}

function analyzeItems(items, runIdLocal){
  try{
    if(runIdLocal !== state.runId){ addLog('analyzeItems 来自旧 runId,忽略'); return; }
    state.isAnalyzing = true;
    updateMiniStatus('分析中');
    const videoDuration = (state.video && state.video.duration) ? state.video.duration : 0;
    if(!videoDuration || videoDuration <= 0){ addLog('无法获取视频长度,终止分析'); state.isAnalyzing=false; updateMiniStatus(false); return; }
    addLog(`视频长度: ${formatTime(videoDuration)}`);

    const colonRegex = /([0-90-9零一二两三四五六七八九十百]{1,3})\s*[::]\s*([0-90-9零一二两三四五六七八九十百]{1,3})/g;
    const minuteSecondRegex = /([0-90-9零一二两三四五六七八九十百]{1,3})\s*(?:分|分钟)\s*([0-90-9零一二两三四五六七八九十百]{1,3})\s*(?:秒)?/g;
    const minuteOnlyRegex = /([0-90-9零一二两三四五六七八九十百]{1,3})\s*(?:分|分钟)(?!\s*秒)/g;
    const spaceSeparatedRegex = /(?<!\d)([0-90-9零一二两三四五六七八九十百]{1,3})\s+([0-90-9零一二两三四五六七八九十百]{1,3})(?!\d)/g;
    const dotSeparatedRegex = /(?<!\d)([0-90-9零一二两三四五六七八九十百]{1,3})\.([0-90-9零一二两三四五六七八九十百]{1,2})(?!\d)/g;
    const contiguous3Regex = /(?<!\d)([0-90-9零一二两三四五六七八九十百]{3})(?!\d)/g;
    const contiguous4Regex = /(?<!\d)([0-90-9零一二两三四五六七八九十百]{4})(?!\d)/g;
    const arrowRightRegex = /((?:向右)|右)\s*([0-90-9零一二两三四五六七八九十百]{1,3})\s*(下)?/i;
    const dianRegex = /([0-90-9零一二两三四五六七八九十百]{1,3})\s*(?:点|点钟)\s*([0-90-9零一二两三四五六七八九十百]{1,3})?(?:\s*(?:分|分钟))?(?:\s*([0-90-9零一二两三四五六七八九十百]{1,3})\s*秒)?/g;

    const timeKwRegex = new RegExp(TIME_KEYWORDS.map(k=>k.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('|'), 'i');
    const weightARegex = new RegExp(WEIGHT_A_KEYWORDS.map(k=>k.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('|'), 'i');
    const weightBRegex = new RegExp(WEIGHT_B_KEYWORDS.map(k=>k.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('|'), 'i');

    addLog(`[${new Date().toLocaleTimeString()}] 初步识别候选(未严格过滤)开始`);

    const rawCandidates = [];
    const weightOnlyA = [], weightOnlyB = [];

    for(const it of items){
      const orig = it.text || '';
      const raw = normalizeText(orig);
      if(!raw) continue;
      let baseW = CONFIG.baseWeight;
      const containsTimeKw = timeKwRegex.test(raw);
      const containsWeightA = weightARegex.test(raw);
      const containsWeightB = weightBRegex.test(raw);
      if(containsTimeKw) baseW += CONFIG.timeKwExtra;
      if(containsWeightA) baseW += 0.5;
      if(containsWeightB) baseW += CONFIG.targetKwExtra;
      if(!containsTimeKw && raw.length <= 6) baseW = Math.max(0.1, baseW * 0.2);

      const hasDigit = /[0-9零一二两三四五六七八九十百]/.test(raw);
      if(!hasDigit && containsWeightA) weightOnlyA.push({time: it.time, text: orig});
      if(!hasDigit && containsWeightB) weightOnlyB.push({time: it.time, text: orig});

      // 向右规则
      arrowRightRegex.lastIndex = 0;
      let m = arrowRightRegex.exec(raw);
      if(m){
        const prefix = m[1]; const nRaw = m[2]; const hasXia = !!m[3];
        const isXiangYou = /^向右/i.test(prefix);
        if(isXiangYou || hasXia){
          const n = isNaN(parseInt(nRaw)) ? chineseToNumber(nRaw) : parseInt(nRaw);
          if(!isNaN(n) && n>0 && n <= CONFIG.rightMaxN){
            const A = it.time; const B = Math.round(A + CONFIG.rightStepSeconds * n);
            if(B < videoDuration && B - A > 0) rawCandidates.push({trigger:A, target:B, text:orig, weight: baseW + 1.0, reasons:[`向右/右${n}下 -> B = A + ${CONFIG.rightStepSeconds}*${n}`]});
          }
        }
      }

      // 冒号
      colonRegex.lastIndex = 0;
      while((m = colonRegex.exec(raw)) !== null){
        const rMin=m[1], rSec=m[2];
        const min = parseNumberToken(rMin); const sec = parseNumberToken(rSec);
        if(isNaN(min)||isNaN(sec)) continue; if(sec>=60) continue;
        const target = min*60 + sec; if(target>=videoDuration||target<0) continue;
        rawCandidates.push({trigger: it.time, target, text: orig, weight: baseW + 0.5, reasons:[`冒号 ${m[0]}`]});
      }

      // 分秒
      minuteSecondRegex.lastIndex = 0;
      while((m = minuteSecondRegex.exec(raw)) !== null){
        const rMin=m[1], rSec=m[2];
        const min = parseNumberToken(rMin); const sec = parseNumberToken(rSec);
        if(isNaN(min)||isNaN(sec)) continue; if(sec>=60) continue;
        const target = min*60 + sec; if(target>=videoDuration||target<0) continue;
        rawCandidates.push({trigger: it.time, target, text: orig, weight: baseW + 0.5, reasons:[`分秒 ${m[0]}`]});
      }

      // 只有分钟
      minuteOnlyRegex.lastIndex = 0;
      while((m = minuteOnlyRegex.exec(raw)) !== null){
        const rMin=m[1]; const min = parseNumberToken(rMin);
        if(isNaN(min)) continue;
        const target = min*60; if(target>=videoDuration||target<0) continue;

        if(isScoreContext(raw)){
          addLog(`剔除候选(打分语境): ${formatTime(it.time)} → ${min}:00 ; 文本: "${orig}"`);
          continue;
        }

        if(min === Math.floor(CONFIG.POSTAGE_TARGET_SECONDS/60) && it.time <= CONFIG.POSTAGE_WINDOW_SECONDS){
          if(isPostAgeContext(raw)
             || /分钟前/.test(raw)
             || /(^|\s)一分钟前/.test(raw)
             || /^\s*第?一?分钟[!!!]*$/.test(orig)
             || /^\s*一分钟[!!!]*$/.test(orig)
             || /发布.{0,6}分钟/.test(orig)
             || /刚.{0,6}发布/.test(orig)
             || /新鲜/.test(raw)
             || /热乎/.test(raw)
          ){
            addLog(`剔除候选(发布时长语境): ${formatTime(it.time)} → ${min}:00 ; 文本: "${orig}"`);
            continue;
          }
        }

        if(isFastForwardInstruction(raw)){
          const boosted = Math.max(1.0, baseW + CONFIG.fastForwardBoost);
          rawCandidates.push({trigger: it.time, target, text: orig, weight: boosted, reasons:[`快进指令(提权) ${m[0]}`]});
          addLog(`保留候选(快进指令 -> 提权): ${formatTime(it.time)} → ${min}:00 ; 文本: "${orig}"`);
          continue;
        }

        rawCandidates.push({trigger: it.time, target, text: orig, weight: Math.max(0.5, baseW - 0.5), reasons:[`只有分钟 ${m[0]}(模糊)`]});
      }

      // 空格分隔
      spaceSeparatedRegex.lastIndex = 0;
      while((m = spaceSeparatedRegex.exec(raw)) !== null){
        const a=m[1], b=m[2]; const A=parseNumberToken(a), B=parseNumberToken(b);
        if(isNaN(A)||isNaN(B)) continue; if(B>=60) continue;
        const target = A*60 + B; if(target>=videoDuration||target<0) continue;
        if(isScoreContext(raw)){
          addLog(`剔除候选(打分语境): ${formatTime(it.time)} → ${formatTime(target)} ; 文本: "${orig}"`);
          continue;
        }
        if(isFastForwardInstruction(raw)){
          const boosted = Math.max(1.0, baseW + CONFIG.fastForwardBoost);
          rawCandidates.push({trigger: it.time, target, text: orig, weight: boosted, reasons:[`快进指令(提权) 空格 ${m[0]}`]});
          addLog(`保留候选(快进指令 -> 提权): ${formatTime(it.time)} → ${formatTime(target)} ; 文本: "${orig}"`);
          continue;
        }
        rawCandidates.push({trigger: it.time, target, text: orig, weight: baseW, reasons:[`空格 ${m[0]}`]});
      }

      // 点格式(X点Y)
      dianRegex.lastIndex = 0;
      while((m = dianRegex.exec(raw)) !== null){
        const g1=m[1], g2=m[2], g3=m[3];
        const A = parseNumberToken(g1);
        const B = g2 ? parseNumberToken(g2) : 0;
        const secPart = g3 ? parseNumberToken(g3) : 0;
        if(isNaN(A)) continue;
        let target = A*60 + (isNaN(B) ? 0 : B);
        if(!isNaN(secPart) && secPart>0) target = A*60 + (isNaN(B)?0:B) + secPart;
        if(target >= videoDuration || target < 0) continue;
        rawCandidates.push({trigger: it.time, target, text: orig, weight: Math.max(0.6, baseW + 0.5), reasons:[`点格式 ${m[0]}`]});
      }

      // 点号 6.20 / 8.43
      dotSeparatedRegex.lastIndex = 0;
      while((m = dotSeparatedRegex.exec(raw)) !== null){
        if(isMeasurementContext(raw)) continue;
        const a=m[1], b=m[2]; const A=parseNumberToken(a), B=parseNumberToken(b);
        if(isNaN(A)||isNaN(B)) continue; if(B>=60) continue;
        const target = A*60 + B; if(target>=videoDuration||target<0) continue;
        rawCandidates.push({trigger: it.time, target, text: orig, weight: Math.max(0.5, baseW - 0.2), reasons:[`点号 ${m[0]}`]});
      }

      // 连续 4/3 数字
      contiguous4Regex.lastIndex = 0;
      while((m = contiguous4Regex.exec(raw)) !== null){
        if(isMeasurementContext(raw)) continue;
        const numStr = m[1].replace(/[0-9]/g,c=>String.fromCharCode(c.charCodeAt(0)-0xFF10+0x30));
        const numVal = parseInt(numStr); if(isNaN(numVal)) continue;
        const mod100 = numVal % 100; if(mod100 >= 60) continue;
        const mm = Math.floor(numVal/100), ss = mod100; const target = mm*60 + ss;
        if(target>=videoDuration||target<0) continue; if(mm>99) continue;
        rawCandidates.push({trigger: it.time, target, text: orig, weight: baseW, reasons:[`连续4 ${numStr} -> ${mm}:${ss}`]});
      }
      contiguous3Regex.lastIndex = 0;
      while((m = contiguous3Regex.exec(raw)) !== null){
        if(isMeasurementContext(raw)) continue;
        const numStr = m[1].replace(/[0-9]/g,c=>String.fromCharCode(c.charCodeAt(0)-0xFF10+0x30));
        const numVal = parseInt(numStr); if(isNaN(numVal)) continue;
        const mod100 = numVal % 100; if(mod100 >= 60) continue;
        const mm = Math.floor(numVal/100), ss = mod100; const target = mm*60 + ss;
        if(target>=videoDuration||target<0) continue;
        rawCandidates.push({trigger: it.time, target, text: orig, weight: baseW - 0.2, reasons:[`连续3 ${numStr} -> ${mm}:${ss}`]});
      }
    } // end items loop

    addLog(`初步识别候选(未严格过滤): ${rawCandidates.length} 条`);

    // 对常见垃圾数字弹幕进行大幅降权(如 9.9, 111, 233 等)
    const lowTokSet = new Set(CONFIG.lowWeightTokens.map(t=>t.toString()));
    const repeatedDigitsRegex = /(?<!\d)(\d)\1{2,}(?!\d)/; // 三个或以上重复数字,如 111, 333
    for(const c of rawCandidates){
      const txtNorm = (c.text || '').replace(/\s+/g,'').replace(/[0-9]/g,ch=>String.fromCharCode(ch.charCodeAt(0)-0xFF10+0x30));
      let matchedLow = false;
      for(const tk of lowTokSet){ if(txtNorm.indexOf(tk) !== -1){ matchedLow = true; break; } }
      if(!matchedLow && repeatedDigitsRegex.test(txtNorm)) matchedLow = true;
      if(matchedLow){
        const old = c.weight || CONFIG.baseWeight;
        c.weight = (c.weight || CONFIG.baseWeight) * CONFIG.lowWeightMultiplier;
        c.reasons = (c.reasons||[]).concat([`包含低权重数字弹幕(降权 x${CONFIG.lowWeightMultiplier})`]);
      }
    }

    // 过滤阶段
    const filtered = [];
    for(const c of rawCandidates){
      const A = c.trigger, B = c.target;
      const delta = Math.abs(B - A);
      if(B + CONFIG.maxBackwardAllowedSec < A) continue;
      if(delta <= CONFIG.minDeltaSeconds){ addLog(`排除候选(差值 <= ${CONFIG.minDeltaSeconds}s): ${formatTime(A)} → ${formatTime(B)} ; delta=${delta.toFixed(3)}s`); continue; }
      if(delta > CONFIG.maxDeltaSeconds){ addLog(`排除候选(差值 > ${CONFIG.maxDeltaSeconds}s): ${formatTime(A)} → ${formatTime(B)} ; delta=${delta.toFixed(3)}s`); continue; }
      let hasForbidden = false;
      for(const tk of CONFIG.forbiddenTokens){ if((c.text||'').indexOf(tk) !== -1){ hasForbidden = true; break; } }
      if(hasForbidden){ addLog(`剔除候选(含不允许标记): ${formatTime(A)} → ${formatTime(B)} ; 文本: "${(c.text||'').slice(0,60)}"`); continue; }
      const non = countNonTimeChars(c.text || '');
      if(non > CONFIG.maxNonTimeCharsAllowed){
        addLog(`非时间字符过多(降权): ${formatTime(A)} → ${formatTime(B)} ; 非时间字符=${non} ; 文本: "${(c.text||'').slice(0,60)}"`);
        c.weight = (c.weight || CONFIG.baseWeight) * CONFIG.nonTimeCharsPenaltyFactor;
        c.reasons = (c.reasons||[]).concat([`非时间字符=${non}(降权 x${CONFIG.nonTimeCharsPenaltyFactor})`]);
      }
      filtered.push(c);
    }

    addLog(`严格过滤后(含降权)候选数:${filtered.length}`);
    if(filtered.length === 0){ addLog('无有效时间弹幕候选(被过滤或静默剔除)'); state.isAnalyzing=false; updateMiniStatus(false); return; }

    // 合并 target -> triggers
    const targetMap = new Map();
    for(const c of filtered){
      if(!targetMap.has(c.target)) targetMap.set(c.target, []);
      targetMap.get(c.target).push({ trigger: c.trigger, text: c.text, weight: c.weight || CONFIG.baseWeight, reasons: c.reasons || [] });
    }

    // 转成数组并做合并(邻近 target 合并为一个组)
    const interim = [];
    for(const [target, arr] of targetMap.entries()){
      arr.sort((a,b)=>a.trigger - b.trigger);
      interim.push({ target, arr });
    }
    interim.sort((a,b)=>a.target - b.target);

    // 合并窗口:CONFIG.TARGET_GROUP_WINDOW 秒以内的 target 视为一组
    const grouped = [];
    for(const entry of interim){
      if(grouped.length===0) grouped.push({ targets: [entry.target], arr: entry.arr.slice() });
      else{
        const last = grouped[grouped.length-1];
        const lastTarget = last.targets[last.targets.length-1];
        if(entry.target - lastTarget <= CONFIG.TARGET_GROUP_WINDOW){
          last.targets.push(entry.target);
          last.arr = last.arr.concat(entry.arr);
        }else grouped.push({ targets: [entry.target], arr: entry.arr.slice() });
      }
    }

    // 对每组进行过早触发弹幕剔除(如果有明显早于中位的触发)
    for(const g of grouped){
      const triggers = g.arr.map(x=>x.trigger).sort((a,b)=>a-b);
      if(triggers.length>=3){
        const median = triggers[Math.floor(triggers.length/2)];
        const originalLen = g.arr.length;
        const filteredArr = g.arr.filter(x=> !(x.trigger < median - CONFIG.earliestIgnoreDeltaSeconds));
        if(filteredArr.length >= 2 && filteredArr.length < originalLen){
          addLog(`剔除过早触发弹幕:原 ${originalLen} 条 -> 剔除 ${originalLen - filteredArr.length} 条(>= ${CONFIG.earliestIgnoreDeltaSeconds}s 早于中位)`);
          g.arr = filteredArr.sort((a,b)=>a.trigger - b.trigger);
        }
      }
    }

    // 构造 targetStats (合并后) 并重新计算 clusters/weight 等
    const targetStats = [];
    for(const g of grouped){
      g.arr.sort((a,b)=>a.trigger - b.trigger);
      const triggerTimes = g.arr.map(x=>x.trigger);
      const earliest = triggerTimes.length>0 ? Math.min(...triggerTimes) : 0;
      const clusters = [];
      for(const t of triggerTimes){
        if(clusters.length===0) clusters.push([t]);
        else{
          const last = clusters[clusters.length-1];
          const avg = last.reduce((s,v)=>s+v,0)/last.length;
          if(Math.abs(t-avg) <= CONFIG.triggerWindow) last.push(t); else clusters.push([t]);
        }
      }
      const maxClusterSize = clusters.length ? Math.max(...clusters.map(c=>c.length)) : 1;
      let weightSum = g.arr.reduce((s,x)=>s + (x.weight || CONFIG.baseWeight), 0);
      weightSum *= (1 + maxClusterSize * CONFIG.clusterBoostFactor);
      targetStats.push({ targets: g.targets.slice(), arr: g.arr.slice(), count: g.arr.length, earliest, weightSum, clusters, maxClusterSize });
    }

    // 列出无数字的权重弹幕 A/B(从 items 中检测并重用)
    const waRe = new RegExp(WEIGHT_A_KEYWORDS.map(k=>k.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('|'), 'i');
    const wbRe = new RegExp(WEIGHT_B_KEYWORDS.map(k=>k.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('|'), 'i');
    const weightAList = [], weightBList = [];
    for(const it of items){
      if(!(/[0-9零一二两三四五六七八九十百]/.test(it.text))){
        if(waRe.test(it.text)) weightAList.push({time: it.time, text: it.text});
        if(wbRe.test(it.text)) weightBList.push({time: it.time, text: it.text});
      }
    }
    if(weightAList.length>0){ addLog(`Detected weight-A 弹幕 共 ${weightAList.length} 条(列出前 30 条)`); weightAList.slice(0,30).forEach(w=> addLog(`  A: ${formatTime(w.time)} -> "${w.text}"`)); }
    if(weightBList.length>0){ addLog(`Detected weight-B 弹幕 共 ${weightBList.length} 条(列出前 30 条)`); weightBList.slice(0,30).forEach(w=> addLog(`  B: ${formatTime(w.time)} -> "${w.text}"`)); }

    // 为每个合并组计算 nearASupport/nearBSupport,并微调 weightSum(再次加权)
    for(const ts of targetStats){
      ts.nearASupport = [];
      ts.nearBSupport = [];
      const representativeTarget = Math.round(ts.targets.reduce((s,t)=>s+t,0)/ts.targets.length);
      for(const w of weightAList) if(Math.abs(w.time - ts.earliest) <= Math.floor(CONFIG.weightWindowSeconds/2)) ts.nearASupport.push(w);
      for(const w of weightBList) if(Math.abs(w.time - representativeTarget) <= Math.floor(CONFIG.weightWindowSeconds/2)) ts.nearBSupport.push(w);
      if(ts.nearASupport.length>0) ts.weightSum += ts.nearASupport.length * CONFIG.weightDanmuBoost;
      if(ts.nearBSupport.length>0) ts.weightSum += ts.nearBSupport.length * CONFIG.weightDanmuBoost;
      ts.representativeTarget = Math.round(ts.targets.reduce((s,t)=>s+t,0)/ts.targets.length);
      ts.maxClusterSize = ts.clusters.length ? Math.max(...ts.clusters.map(c=>c.length)) : 1;
    }

    // 排序输出:权重优先 -> cnt 次之 -> 最早时间兜底
    targetStats.sort((a,b)=>{
      if(b.weightSum !== a.weightSum) return b.weightSum - a.weightSum;
      if(b.count !== a.count) return b.count - a.count;
      return a.earliest - b.earliest;
    });

    const candListEl = document.getElementById('bili-candidates-list'); if(candListEl) candListEl.innerHTML='';
    const cntEl = document.getElementById('bili-candidates-count'); if(cntEl) cntEl.textContent = targetStats.length;

    addLog(`候选汇总(按 weight/cnt 排序,已合并近邻 B)共 ${targetStats.length} 条:`);
    state.lastCandidatesLog = [];
    targetStats.forEach((ts, idx) => {
      const reasonParts = [`来自弹幕 ${ts.count} 条`, `maxCluster=${ts.maxClusterSize}`];
      if(ts.nearASupport && ts.nearASupport.length) reasonParts.push(`A 权重弹幕: ${ts.nearASupport.map(w=>`${formatTime(w.time)} "${w.text}"`).join(' | ')}`);
      if(ts.nearBSupport && ts.nearBSupport.length) reasonParts.push(`B 权重弹幕: ${ts.nearBSupport.map(w=>`${formatTime(w.time)} "${w.text}"`).join(' | ')}`);
      const reason = reasonParts.join(';');
      addLog(`${idx+1}: ${formatTime(ts.earliest)} → ${formatTime(ts.representativeTarget)} (weight: ${ts.weightSum.toFixed(2)} , cnt:${ts.count})`);
      addLog(`    合并目标范围: [${ts.targets.map(t=>formatTime(t)).join(', ')}]`);
      addLog(`    触发示例: ${ts.arr.slice(0,6).map(x=>`${formatTime(x.trigger)} "${(x.text||'').slice(0,40)}"`).join(' ; ')}`);
      if(ts.nearBSupport && ts.nearBSupport.length) addLog(`    B 支持弹幕(时间+文本): ${ts.nearBSupport.map(w=>`${formatTime(w.time)} "${w.text}"`).join(' | ')}`);
      if(ts.nearASupport && ts.nearASupport.length) addLog(`    A 支持弹幕(时间+文本): ${ts.nearASupport.map(w=>`${formatTime(w.time)} "${w.text}"`).join(' | ')}`);
      state.lastCandidatesLog.push({A:ts.earliest, B:ts.representativeTarget, weight:ts.weightSum, count:ts.count, reason, samples: ts.arr.slice(0,6), nearASupport:ts.nearASupport, nearBSupport:ts.nearBSupport, mergedTargets: ts.targets});
      if(candListEl){
        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'>${idx+1}: ${formatTime(ts.earliest)} → ${formatTime(ts.representativeTarget)} (weight: ${ts.weightSum.toFixed(2)} , cnt:${ts.count})</div>
                         <div style='font-size:13px;opacity:0.95'>${(ts.arr[0] && ts.arr[0].text) || ''}
                         <div style="opacity:0.7;font-size:12px;">合并目标: ${ts.targets.map(t=>formatTime(t)).join(', ')};${reason}</div></div>`;
        candListEl.appendChild(row);
      }
    });

    // 顶级阈值检查:最高候选必须 >= CONFIG.acceptWeightThreshold
    if(targetStats.length === 0){
      addLog('无候选可选(空列表)'); state.isAnalyzing=false; updateMiniStatus(false); return;
    }
    const top = targetStats[0];
    if(top.weightSum < CONFIG.acceptWeightThreshold){
      addLog(`最高候选权重 ${top.weightSum.toFixed(2)} < 阈值 ${CONFIG.acceptWeightThreshold.toFixed(2)} ,拒绝所有候选(按你的要求)`);
      state.isAnalyzing=false; updateMiniStatus(false); return;
    }

    // 选择跳点:按排序后优先选最前面的(即权重最高)
    let selected = false;
    for(let attempt=0; attempt < Math.min(5, targetStats.length); attempt++){
      const cand = targetStats[attempt];
      const A = cand.earliest, B = cand.representativeTarget, skip = B - A;
      addLog(`尝试候选 ${attempt+1}: ${formatTime(A)} → ${formatTime(B)} (跳过 ${skip}s) ,权重=${cand.weightSum.toFixed(2)} cnt=${cand.count}`);
      if(skip <= CONFIG.minSkipDuration){ addLog(`拒绝:跳过时长 <= ${CONFIG.minSkipDuration}s`); continue; }
      if(skip > videoDuration * CONFIG.maxSkipFraction){ addLog(`拒绝:跳过时长 > 视频长度的一半`); continue; }
      if(B > videoDuration){ addLog(`拒绝:B 超出视频时长`); continue; }
      const maxClusterSize = cand.maxClusterSize;
      const hasSupport = (cand.nearASupport && cand.nearASupport.length>0) || (cand.nearBSupport && cand.nearBSupport.length>0);
      if(maxClusterSize >= CONFIG.earliestClusterMinCount || hasSupport || cand.weightSum >= CONFIG.acceptWeightThreshold){
        state.jumpRules.clear(); state.jumpRules.set(A,B);
        addLog(`选定跳点:${formatTime(A)} → ${formatTime(B)}(cluster=${maxClusterSize}, weight=${cand.weightSum.toFixed(2)})`);
        const listEl = document.getElementById('bili-danmu-list'); if(listEl) listEl.innerHTML='';
        cand.arr.slice(0, CONFIG.maxDisplayDanmu).forEach(x=>{
          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(x.trigger)} → ${formatTime(B)}</div><div style='font-size:13px;opacity:0.95'>${(x.text||'')}</div>`;
          listEl.appendChild(row);
        });
        updateMiniStatus(true); selected = true; break;
      }else{
        addLog(`拒绝:候选验证不足(cluster=${maxClusterSize}, nearA=${cand.nearASupport?cand.nearASupport.length:0}, nearB=${cand.nearBSupport?cand.nearBSupport.length:0}, weight=${cand.weightSum.toFixed(2)})`);
      }
    }
    if(!selected) addLog('未选中跳点(按当前阈值与验证标准)');

    state.isAnalyzing = false;
    updateMiniStatus(selected);
  }catch(e){ console.error(e); addLog('分析异常: '+(e.message||e)); state.isAnalyzing=false; updateMiniStatus(false); }
}

/* ========== video 监听 ========== */
function initVideoListener(){
  try{
    if(!state.video) return;
    // 若已绑定过 listener,先移除
    if(state.videoListenerRef && typeof state.video.removeEventListener === 'function'){
      try{ state.video.removeEventListener('timeupdate', state.videoListenerRef); }catch(e){}
    }
    const onTimeUpdate = function(){
      const ct = this.currentTime;
      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);
        }
      }
    };
    state.videoListenerRef = onTimeUpdate;
    state.video.addEventListener('timeupdate', onTimeUpdate);
  }catch(e){ console.warn(e); }
}

/* ========== 清理 / 终止 ========== */
function teardown(){
  addLog('开始 teardown:清理旧状态与 UI');
  // 增加 runId 以使旧回调不再生效
  state.runId = (state.runId || 0) + 1;

  // 移除 UI
  try{ removeUI(); }catch(e){ console.warn(e); }

  // 移除 video 事件
  try{
    if(state.video && state.videoListenerRef) state.video.removeEventListener('timeupdate', state.videoListenerRef);
  }catch(e){ console.warn(e); }
  state.videoListenerRef = null;
  state.video = null;

  // 断开 mutationObserver
  try{ if(state.mutationObserver) { state.mutationObserver.disconnect(); state.mutationObserver = null; } }catch(e){}

  // 清除 pending timeouts
  try{ state.pendingTimeouts.forEach(t=>clearTimeout(t)); state.pendingTimeouts = []; }catch(e){}

  // 取消 active GM 请求标记(回调会被 runId 检查忽略)
  try{ state.activeRequestSeqs.clear(); }catch(e){}

  // 清除 jump rules 等
  try{ state.jumpRules.clear(); }catch(e){}
  state.lastCandidatesLog = [];
  state.danmuCount = 0;
  state.isAnalyzing = false;
}

/* ========== 初始化流程 ========== */
async function runOnce(){
  // 每次运行前,确保已有旧 run 清理(避免冲突)
  teardown();
  const myRunId = ++state.runId;
  addLog('脚本启动(runId=' + myRunId + ')');

  createUI();
  addLog('脚本已加载(v0.1.5:新增低权重数字弹幕降权 + 过早触发剔除 + BV监控重启)');

  function findVideo(){
    state.video = document.querySelector('video');
    if(state.video){ addLog('检测到 video 元素'); initVideoListener(); return true; }
    return false;
  }
  if(!findVideo()){
    addLog('等待 video 元素加载...');
    const obs = new MutationObserver(()=>{ if(findVideo()){ try{ obs.disconnect(); }catch(e){} } });
    obs.observe(document.body, { childList:true, subtree:true });
    state.mutationObserver = obs;
  }

  const cid = await resolveCid();
  if(cid){ state.cid = cid; addLog('获取到 CID: '+cid); fetchDanmu(cid, myRunId); }
  else addLog('无法获取 CID,请刷新页面或检查视频');
}

/* ========== BV 监控(每隔 10s) + history hook(更快速响应 SPA 路由) ========== */
function getBVFromHref(){
  const m = location.href.match(/(BV[0-9A-Za-z]+)/);
  return m ? m[1] : null;
}

function startBVMonitor(){
  // 防止重复创建
  if(state.bvMonitorId !== null) return;

  // 先初始化 lastBV
  state.lastBV = getBVFromHref();

  // history API hook(捕获 pushState/replaceState)
  (function(history){
    const push = history.pushState;
    const replace = history.replaceState;
    history.pushState = function(stateArg){
      const ret = push.apply(history, arguments);
      window.dispatchEvent(new Event('locationchange'));
      return ret;
    };
    history.replaceState = function(stateArg){
      const ret = replace.apply(history, arguments);
      window.dispatchEvent(new Event('locationchange'));
      return ret;
    };
  })(window.history);

  window.addEventListener('popstate', ()=> window.dispatchEvent(new Event('locationchange')));
  window.addEventListener('locationchange', ()=> {
    const bv = getBVFromHref();
    if(bv && bv !== state.lastBV){
      addLog(`locationchange 触发:BV 变化 ${state.lastBV} -> ${bv},将重启脚本`);
      restartForNewBV(bv);
    }
  });

  // 10s 轮询备份
  state.bvMonitorId = setInterval(()=>{
    try{
      const bv = getBVFromHref();
      if(bv && bv !== state.lastBV){
        addLog(`轮询检测到 BV 号改变: ${state.lastBV} → ${bv} ,重启脚本`);
        restartForNewBV(bv);
      }
    }catch(e){ console.warn(e); }
  }, 10000);
}

function stopBVMonitor(){
  if(state.bvMonitorId !== null){ clearInterval(state.bvMonitorId); state.bvMonitorId = null; }
}

function restartForNewBV(newBv){
  try{
    const old = state.lastBV;
    state.lastBV = newBv;
    // teardown + runOnce
    teardown();
    // 等短暂时间再 run(确保 DOM 清理完成)
    const to = setTimeout(()=>{ runOnce(); }, 200);
    state.pendingTimeouts.push(to);
  }catch(e){ console.warn(e); }
}

/* ========== 启动 ========== */
(function boot(){
  // 启动 BV 监控(一次性)
  startBVMonitor();

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

})(); // end

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址