通过分析置顶评论、字幕、弹幕,获取视频广告时间戳,自动跳过广告(轻量版)
// ==UserScript== // @name BiliAdSkipLite // @namespace BiliAdSkip // @description 通过分析置顶评论、字幕、弹幕,获取视频广告时间戳,自动跳过广告(轻量版) // @version 2.34-lite // @author BiliAdSkip // @match https://www.bilibili.com/* // @match https://space.bilibili.com/* // @match https://t.bilibili.com/* // @connect cdn.jsdelivr.net // @connect raw.githubusercontent.com // @connect akoaopeqigjwpcksqdyf.supabase.co // @connect biliadskip.vercel.app // @grant GM_setValue // @grant GM_getValue // @grant GM_listValues // @grant GM_deleteValue // @grant GM_download // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @run-at document-start // @icon https://i2.hdslb.com/bfs/emote/3087d273a78ccaff4bb1e9972e2ba2a7583c9f11.png // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/protobuf.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/js/md5.min.js // @noframes // ==/UserScript== (function() { 'use strict'; // 调试开关 const SHOW_DEBUG_LOG = false; // 输出debug日志 const SHOW_DEBUG_TIMEGAP = false; // 输出两条debug日志之间的间隔时间 const FORCE_GIT_CONFIG = false; // 强制每个页面重新从git镜像获取最新关键词 const FORCE_AI_ACTIVE = true; // 强制启用AI分析 const DOWNLOAD_SUBTITLE_FILE = false; // 下载视频字幕文件到本地 const SHORT_VIDEO_DURATION = 150; // 短视频时长评判依据,单位/s const backupIntervals = 3; // 备份脚本数据到本地的周期,默认3,最小1,单位/天 const ANALYZE_DNAMAKU = null; // 统计该文本弹幕的数量 // 公共AI平台 supabase || vercel const cloudPlatformService = 'supabase'; // --- 跟hookFetch 有关的全局变量 const gState = { originalFetch: null, isFetchHooked: false, deviceFingerprint: null }; // --- 全局变量定义 --- let biliAdWordsConfig, keywordRegex, whiteList; let logTime let subtitlePromiseResolver = null // --- 脚本运行中一些基础变量 --- const defaultState = { currentBV: null, adTime: null, video: null, isHandling: false, lastJumpTime: 0, commentText:'', bvCloudChecked: false, uploaded: false, upName: '', officialOrg: null, noAd: false, danmakuTimestampStore: {}, isAIAnalysisInProgress: false, commentAnalysisResult: null, }; const state = { ...defaultState }; const defaultConfig = { keywordStr: `[淘某]宝|京东|天猫|美团|拼多|并夕|外卖|密令|转转|补贴|折扣|福利|[下晒]单|退款|免费|大[促额漏水]|[心快速]冲|运(费?)险|[领惠叠]券|[低底特好性差降保]价`, biliAdLinks: ['taobao.com', 'tb.cn', 'jd.com', 'pinduoduo.com','zhuanzhuan.com', 'mall.bilibili.com', 'gaoneng.bilibili.com', 'bilibili.com/cheese/', 'b23.tv/mall-'], noticeAudioBase64: null, time: 0 }; const publicAiPlatform ={ vercel: 'https://biliadskip.vercel.app/api/analyze', supabase: 'https://akoaopeqigjwpcksqdyf.supabase.co/functions/v1/publicAI' } // --- 新增:全局数据缓存 --- const scriptCache = { mainAdDbKeys: [], noAdDbKeys: [] }; //protobuf库 const { Root } = window.protobuf; const supabaseAnonKey = [ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', 'eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFrb2FvcGVxaWdqd3Bja3NxZHlmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ0MDgwMzEsImV4cCI6MjA2OTk4NDAzMX0', '6JW6Gtescu5btG25b3en9w84ZbO40Z4fy3iUfWROIOM', ]; function formatLogMessage(prefix, ...args) { if (typeof args[0] === 'string' && args[0].includes('%c')) { const formatStr = args[0]; const styleArgs = args.slice(1); const cCount = (formatStr.match(/%c/g) || []).length; const filled = [...styleArgs]; while (filled.length < cCount) { filled.push('color: red; background: #fff3cd; padding: 2px; font-weight: bold;'); } return [prefix + formatStr, ...filled]; } else { return [prefix, ...args]; } } function log(...args) { logTime = Date.now(); console.log(...formatLogMessage('[BiliAdSkip] ', ...args)); } function debuglog(...args) { if (SHOW_DEBUG_LOG) { let logTimeGap = ''; if (SHOW_DEBUG_TIMEGAP) { const now = Date.now(); const gap = now - logTime; logTimeGap = (gap <= 5000 && gap >= 50) ? `[${gap}]` : ''; logTime = now; } console.log(...formatLogMessage(`[BiliAdSkip][dbg]${logTimeGap}`, ...args)); } } /** 随机延迟函数 (Promise封装) */ const randomSleep = (averageTime, fluctuation = 0) => { const finalDelay = Math.floor(averageTime - fluctuation + Math.random() * fluctuation * 2); return new Promise(resolve => setTimeout(resolve, finalDelay)); }; /** 重置脚本的数据状态 */ const resetState = () => { const isHandling_backup = state.isHandling; Object.assign(state, defaultState); state.isHandling = isHandling_backup; state.danmakuTimestampStore = {}; log('状态已重置 (保留进程标志)'); } // 查询云端,通过调用Edge Function async function fetchAdTimeDataFromCloud(bv) { log('⌛查询云端时间戳...'); try { const url = "https://akoaopeqigjwpcksqdyf.supabase.co/functions/v1/biliadskipQuery"; const response = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${supabaseAnonKey.join('.')}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ bv}) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`云函数响应错误: ${response.status} ${errorText}`); } const data = await response.json(); if (!data || data.length === 0) { log(`❌ 云端无记录`); return null; } // log(`⏬ ${bv} 云端返回数据`, data); return data; } catch (error) { console.error(`❌ 调用接口异常:`, error.message); return null; } } // 上传共享数据到云端数据库 Supabase async function uploadAdTimeDataToCloud(bv, timestamp_range, source, NoAD = null) { try { const url = "https://akoaopeqigjwpcksqdyf.supabase.co/functions/v1/biliadskip"; const upInfo = await getUpInfo(); const dataBody = { bv, timestamp_range: NoAD ? null : timestamp_range, source, user_id: getOrCreateUserId(), up_id: state.upName || upInfo?.name || 'unknown', NoAD } const Resp = await fetch(url, { method: "POST", headers: { 'Authorization': `Bearer ${supabaseAnonKey.join('.')}`, "Content-Type": "application/json" }, body: JSON.stringify(dataBody) }); if (!Resp.ok) { const errorText = await Resp.text(); console.error("❎调用接口失败:", Resp.status, Resp.statusText, errorText); return { success: false, error: errorText }; } log(`🆗已共享: ${timestamp_range || NoAD && 'noAd' }`); const biliadskipJson = await Resp.json(); return { success: true, biliadskip_result: biliadskipJson }; } catch (err) { console.error("❌调用接口异常:", err); return { success: false, error: err.message || err }; } } // 绑定视频timeupdate事件的回调函数 function handleTimeUpdate() { // 1. 基础状态检查 if ((state.video && state.video.paused) || !state.video) return; // --- 2. 核心:只在 state.adTime 存在时,才执行监控 --- if (state.adTime) { const currentTime = state.video.currentTime; const duration = state.video.duration; const now = Date.now(); const timeSinceLastJump = now - state.lastJumpTime; // 规则一:跳转抑制 if (timeSinceLastJump < MIN_JUMP_INTERVAL * 1000) { return; } // 规则二:安全区检查 const clampedEndZone = 15; //Math.max(15, Math.min(duration / 10, 90)); const isSafeToJumpFrom = currentTime > 15 && currentTime < duration - clampedEndZone; // 规则三:时间段检查 const start = timeToSeconds(state.adTime.start); const end = timeToSeconds(state.adTime.end); const isInAdSegment = currentTime >= start && currentTime <= end; // 【最终决策】 if (isInAdSegment && isSafeToJumpFrom) { log(`timeUpdate: 执行广告跳转`); JumpAndShowNotice(state.video, start, end, now); } } } function JumpAndShowNotice(video, start, end, now) { log(` ⏩ 跳转 %c${formatTimeTenths(start)} --> ${formatTimeTenths(end)}`, 'color: #e77222; font-weight: bold;'); video.currentTime = end; state.lastJumpTime = now; const container = document.querySelector('.bpx-player-video-wrap'); if (!container) return; const box = document.createElement('div'); box.innerText = `跳至 ⏩ ${formatTimeTenths(end)}`; Object.assign(box.style, { position: 'absolute', top: '15%', left: '50%', transform: 'translateX(-50%)', padding: '6px 12px', backgroundColor: 'rgba(0, 0, 0, 0.4)', color: '#fff', fontSize: '16px', fontWeight: 'bold', borderRadius: '8px', zIndex: '9999', pointerEvents: 'none', opacity: '0.8', transition: 'opacity 0.3s ease' }); container.style.position = 'relative'; container.appendChild(box); setTimeout(() => { box.remove(); }, 3000); playNoticeSound.play(); } /** 一个完全自包含的音频播放模块。 * 负责管理音频上下文和缓冲区的生命周期。 */ const playNoticeSound = (() => { let audioCtx = null; let audioBuffer = null; let isInitialized = false; async function initialize() { if (isInitialized) return; const base64String = biliAdWordsConfig.noticeAudioBase64; if (!base64String) { console.warn("未提供音频数据,音频模块将保持禁用。"); isInitialized = true; return; } try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); audioBuffer = await decodeAudioData(audioCtx, base64String); isInitialized = true; debuglog("🔊 音频模块初始化"); } catch (e) { console.error("音频模块初始化失败:", e); isInitialized = true; } } return { play: async function() { await initialize(); playDecodedAudio(audioCtx, audioBuffer); } }; })(); /**负责解码Base64音频数据。 */ async function decodeAudioData(audioCtx, base64String) { try { const remainder = base64String.length % 4; if (remainder !== 0) { base64String += '='.repeat(4 - remainder); } const binaryString = window.atob(base64String); const arrayBuffer = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { arrayBuffer[i] = binaryString.charCodeAt(i); } return await audioCtx.decodeAudioData(arrayBuffer.buffer); } catch (error) { console.error("❌ Base64 音频解码失败:", error); return null; } } /** 负责播放一个已解码的音频缓冲区。 */ function playDecodedAudio(audioCtx, buffer) { if (!buffer || !audioCtx || audioCtx.state === 'closed') return; debuglog('🔊 播放跳转提示音...'); const source = audioCtx.createBufferSource(); source.buffer = buffer; source.connect(audioCtx.destination); source.start(); } function getOrCreateUserId() { let userId = localStorage.getItem("biliadskip_random_user_id"); if (!userId) { userId = generateRandomId(8); localStorage.setItem("biliadskip_random_user_id", userId); } return userId; } // 获取新广告时间戳提示音'咚咚' function playBeepSound(frequency = 1100) { const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const now = audioContext.currentTime; const oscillator = audioContext.createOscillator(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(frequency, now); const gainNode = audioContext.createGain(); gainNode.gain.setValueAtTime(0, now); oscillator.connect(gainNode); gainNode.connect(audioContext.destination); oscillator.start(); gainNode.gain.setValueAtTime(0.5, now + 0.1); gainNode.gain.exponentialRampToValueAtTime(0.001, now + 0.2); gainNode.gain.setValueAtTime(0.5, now + 0.4); gainNode.gain.exponentialRampToValueAtTime(0.001, now + 0.5); oscillator.stop(now + 0.6); } //随机用户id创建函数 function generateRandomId(length) { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } // 提前判断是否需要开启广告检测 async function videoNeedAdAnalyze(bvNumber) { const video = await initPageObserver(); if (!video) return; state.video = video; if (video.duration < SHORT_VIDEO_DURATION ) { debuglog(`视频长度不足 - ${video.duration}s`); markVideoAsNoAd(bvNumber, {reason:'isShortVideo'}); return; } const upInfo = await getUpInfo(); if (upInfo && upInfo.name) { state.upName = upInfo.name; } if (state.upName && whiteList.includes(state.upName)) { log(`✅【白名单】中,跳过`); return; } else if (upInfo && upInfo.officialOrg) { log('✅ 认证机构账号,跳过'); return; } else if (state.noAd) { log('🟢 无广告,跳过'); danmakuManager.stop(); return; } else if (upInfo && upInfo.hasSponsor) { log('🤝 含赞助商的联合投稿'); return true; } else if (upInfo && upInfo.fanCount && upInfo.fanCount < 10000) { log(`✅ 粉丝数 (${upInfo.fanCount}) < 1万,跳过`); return; } else if (upInfo && upInfo.memberCount >= 3) { log(`🤝【${upInfo.memberCount}人联合投稿】,标记无广`); await markVideoAsNoAd(bvNumber, {reason: `${upInfo.memberCount} 人联合投稿)`}); return; } else { const videoArea = document.querySelector('.bpx-player-video-area'); const chargeVideo = videoArea.querySelector('.bpx-player-trial-watch-charging-toast') || document.querySelector('.not-charge-high-level-cover'); if (chargeVideo) { log(`🩷充电专属视频,跳过广告检测`); state.noAd = true; danmakuManager.stop(); return; } } return true; } /** (重构版) 常规模式下的总指挥。 * 负责:状态重置 -> 获取信息 -> 做出决策 -> 启动监控。*/ async function handlePageChanges(observer) { if (state.isHandling) { if(observer) observer.disconnect(); debuglog('安全措施,返回') return; } state.isHandling = true; try { log('页面变化,等待元素加载'); await waitForElement('.v-popover-wrap.header-avatar-wrap').catch(err => { debuglog(`⚠️ ${err.message}`); });//.up-panel-container debuglog('🫏 启动页面逻辑'); const bvNumber = await getBVNumber(); if (!bvNumber) { throw new Error("无法获取BV号,中止处理"); return; } // --- 1. 状态重置 --- // 只有在【非强制】模式下,才检查URL是否变化 if (state.currentBV && bvNumber !== state.currentBV) { log(`⚠️ BV 变更... ${state.currentBV} -> ${bvNumber}`); resetState(); } state.currentBV = bvNumber; log(` %c${bvNumber}`,'color: #e67e22; font-weight: bold;'); const canProceed = await videoNeedAdAnalyze(bvNumber); if (canProceed) { debuglog('启动广告相关监控...'); await bindVideoEvents(state.video); await processBV(bvNumber); } } catch (error) { console.error('❌ 处理页面变化时发生错误:', error); } finally { state.isHandling = false; } } /*** (新增) 将所有核心事件绑定到 video 元素上。*/ async function bindVideoEvents(video) { log(`视频状态:${state.video?.paused ? '暂停' : '播放'}`); const handlePlay = async () => { const currentBV = await getBVNumber(); if(currentBV !== state.currentBV) { debuglog('⚠️ 忽略旧页面的 play 事件'); return; } log('▶️ 视频播放中,恢复监控'); const isTaskConcluded = state.adTime || state.noAd || whiteList.includes(state.upName); if (!isTaskConcluded) { debuglog('👓重建弹幕观察器...'); danmakuManager.start(); } else if (isTaskConcluded) { danmakuManager.stop(); } }; const handlePause = () => { log('⏸️ 视频暂停弹幕监控'); danmakuManager.stop(); }; video.removeEventListener('play', await handlePlay); video.removeEventListener('pause', handlePause); video.addEventListener('play', await handlePlay); video.addEventListener('pause', handlePause); video.removeEventListener('ended', videoEnded); video.addEventListener('ended', videoEnded); debuglog('✅ 视频元素绑定事件'); } async function processBV(bvNumber) { // 1. 查询云端 if (!state.bvCloudChecked) { state.bvCloudChecked = true; const cloudResponse = await fetchAdTimeDataFromCloud(bvNumber); if (cloudResponse) { const { bestRecord, duplicateCount } = cloudResponse; state.cloudDuplicateCount = duplicateCount || 0; if (bestRecord) { //云端返回无广告 if (bestRecord.NoAD) { log(` 🟢%c noAd`, 'color: #3498db; font-weight: bold;'); state.noAd = true; await markVideoAsNoAd(bvNumber, { reason: "cloud_record" }); return; } else if (bestRecord.timestamp_range) { //云端返回有效时间戳 log(` 🟢 云端: %c${bestRecord.timestamp_range}, %c${bestRecord.source}`, 'color: #3498db; font-weight: bold;', 'color: #e67e22; font-weight: bold;'); const dataTimestamp = extractTimestampFromString(bestRecord.timestamp_range); if (dataTimestamp) { state.adTime = dataTimestamp; // 注意:从云端获取的数据,我们通常不希望它再次触发上传 monitorTimestamp(bvNumber, dataTimestamp, bestRecord.source, { uploadCloud: false, saveTimestamp: true }); return; } } } } } // 2. 查本地缓存 if (!state.adTime){ const result = await getStoredAdTime(bvNumber); if (result) { if (result ==="noAd") { log(`❓查询缓存 --> noAd`); danmakuManager.stop(); state.noAd = true; return; } log(`❓查询缓存 --> '${result.adTime}`); state.adTime = result.adTime; monitorTimestamp(bvNumber, result.adTime, result.source, {uploadCloud: false, saveTimestamp: false}); return; } log(`❓查询缓存 --> 无数据`) } // 3. 启动弹幕监控 danmakuManager.start(); // 4. 最后尝试调用AI debuglog('⌛检查评论区,调用AI') await checkCommentAndHandleAI(bvNumber) } /** (升级版) 主动滚动页面以帮助加载懒加载元素*/ function scrollToLoadComments(distance = 50+Math.floor(Math.random()*50)) { debuglog(`📜 下滚加载评论区`); window.scrollBy({ top: distance, left: 0, behavior: 'smooth' }); } /** 等待通过网络拦截器获取AI字幕。*/ async function fetchBilibiliSubtitleAPI(timeout = 3000) { async function showAiSubtitle() { const subtitleCtrlBtn = document.querySelector('.bpx-player-ctrl-btn.bpx-player-ctrl-subtitle'); if (subtitleCtrlBtn) { const subtitleOption = document.querySelector('.bpx-player-ctrl-subtitle-major .bpx-player-ctrl-subtitle-language-item[data-lan="ai-zh"]'); if (subtitleOption) { subtitleOption.click(); await randomSleep(2000); const subtitleClose = document.querySelector('.bpx-player-ctrl-subtitle-close-switch[data-action="close"]'); if (subtitleClose) { subtitleClose.click(); } return true; } } else { debuglog('❌ 无AI字幕') } return false; } const subtitleDataPromise = new Promise((resolve) => { subtitlePromiseResolver = (subtitles) => { subtitlePromiseResolver = null; resolve(subtitles); }; }); const timeoutPromise = new Promise((resolve) => { setTimeout(() => { if (subtitlePromiseResolver) { log('⚠️ 等待AI字幕API请求超时'); subtitlePromiseResolver = null; resolve([]); } }, timeout); }); showAiSubtitle().then(triggered => { if (!triggered) { if (subtitlePromiseResolver) { subtitlePromiseResolver([]); } } }); const subtitles = await Promise.race([subtitleDataPromise, timeoutPromise]); return subtitles; } function subtitlesFiltering(subtitleObjects, {hasAd = null, reason = null}) { const maxSubtitles = 200; const subtitlesNum = subtitleObjects.length; debuglog(`🔤字幕${subtitlesNum}条,筛选`); // 1. 提取关键词匹配的时间点(秒)+ 保留原始字幕对象 const subtitlesWithSec = subtitleObjects.map(obj => { const sec = obj.fromSec !== undefined ? obj.fromSec : timeToSeconds(obj.time); return { sec, obj }; }); const keywordMatches =subtitlesWithSec.filter(entry => keywordRegex.test(entry.obj.content)); debuglog(`关键字匹配:${keywordMatches.length}`) if (keywordMatches.length === 0) { // --- 场景一:字幕中【未】发现关键词 --- if (hasAd === true) { log('⚠️ 警告:评论区广告,但字幕无关键词。将提交中间部分字幕给AI进行深度分析...'); const start = Math.floor((subtitlesNum - maxSubtitles) / 2); const slicedObjects = subtitleObjects.slice(start, start + maxSubtitles); const formattedSlice = slicedObjects.map(obj => { if (obj.fromStr && obj.toStr) { return `${obj.fromStr}-${obj.toStr} ${obj.content}`; } return `${obj.time} ${obj.content}`; }); return { filteredSubtitles: formattedSlice, }; } else if (hasAd === false) { // 【评论区无广告,字幕也无关键词】 -> 极大概率无广告 return { filteredSubtitles: [], subtitlesNoAd:true }; } else { log('⚠️ 评论区情况不明!'); } } keywordMatches.sort((a, b) => a.sec - b.sec); // 2. 找出分段点(相邻时间大于2分钟) const segments = []; let currentSegment = [keywordMatches[0]]; for (let i = 1; i < keywordMatches.length; i++) { const prev = keywordMatches[i - 1].sec; const curr = keywordMatches[i].sec; if (curr - prev > 120) { segments.push(currentSegment); currentSegment = []; } currentSegment.push(keywordMatches[i]); } if (currentSegment.length > 0) { segments.push(currentSegment); } // 3. 输出关键词分布情况 segments.forEach((segment, index) => { const segmentInfo = segment.map(entry => { const match = entry.obj.content.match(keywordRegex); return `${formatTime(entry.sec)} ${match ? match[0] : ''}`; }).filter(info => info.trim().split(' ')[1]); if (segmentInfo.length > 1) { //debuglog(`📢广告词Block-${index + 1}: [${segmentInfo.join(', ')}]`); } }); // 4. 找到关键词数量最多的一段(你也可以选择最密集的段) const bestSegment = segments.reduce((a, b) => (a.length >= b.length ? a : b)); if (reason === "keyWords") { // 对最佳段落进行“多样性”健康度检查 --- const totalMatches = bestSegment.length; // 提取出所有匹配到的关键词本身 const matchedWords = bestSegment.map(entry => { const match = entry.obj.content.match(keywordRegex); return match ? match[0].toLowerCase() : null; }).filter(Boolean); // 计算【唯一】关键词的数量 const uniqueWords = [...new Set(matchedWords)]; const uniqueCount = uniqueWords.length; // 计算“多样性占比” const diversityRatio = uniqueCount / totalMatches; debuglog(`📊 广告词Block分析: 总匹配 ${totalMatches} 次, 唯一词 ${uniqueCount} 个 (${uniqueWords.join(', ')}), 多样性: ${diversityRatio.toFixed(2)}`); const maybeAdWords = ['评论','评论区','产品'] // 4c. 【新增】根据“多样性”进行决策 (降权) const isKeywordSpam = (totalMatches >= 8 && uniqueCount <= 3 && diversityRatio <= 0.2) || (uniqueCount <= 2 && maybeAdWords.includes(uniqueWords[0])) if (isKeywordSpam) { log('🚫 判断为“关键词滥用”,降权处理。此视频大概率无广告。'); // 降权:返回一个“无广告”的结论 return { filteredSubtitles: [], localConclusion: 'noAd' }; } } // --- 修改结束 --- const start = bestSegment[0].sec const end = bestSegment[bestSegment.length - 1].sec const ext = Math.max(10, 120 - (end - start) / 2); const startExt = Math.max(0, start - ext); const endExt = end + ext; debuglog(`❓扩展广告区域 ${formatTime(startExt)}-${formatTime(endExt)}`); // 5. 提取疑似广告部分字幕 const filteredSubtitles = subtitleObjects .filter(obj => { const s = obj.fromSec !== undefined ? obj.fromSec : timeToSeconds(obj.time); const e = obj.toSec !== undefined ? obj.toSec : s; return e >= startExt && s <= endExt; }) .map(obj => { if (obj.fromStr && obj.toStr) { return `${obj.fromStr}-${obj.toStr} ${obj.content}`; } return `${obj.time} ${obj.content}`; }); return { filteredSubtitles }; } /** 保存字幕到本地(仅限未下载)*/ async function trySaveSubtitles(subtitleObjects) { if (!DOWNLOAD_SUBTITLE_FILE) return; const subtitleArray = subtitleObjects.map(obj => { if (obj.fromStr && obj.toStr) { return `${obj.fromStr}-${obj.toStr} ${obj.content}`; } return `${obj.time} ${obj.content}`; }); const bvNumber = await getBVNumber(); let data = await GM_getValue(bvNumber, {}); if (typeof data === 'string') { try { data = JSON.parse(data); } catch(e) { data = {}; } } if (!data.isdownloaded) { data.isdownloaded = true; await GM_setValue(bvNumber, data); // 保存字幕到本地 const titleEl = document.querySelector('.video-info-title h1.video-title'); const title = titleEl ? titleEl.textContent.trim().replace(/[\\/:*?"<>|]/g, '') : '无标题'; const fileName = `UP-(${getUpInfo().name})-${bvNumber}-${title}.json`; const blob = new Blob([JSON.stringify(subtitleArray, null, 2)], { type: 'application/json' }); const urlObject = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = urlObject; a.download = fileName; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(urlObject); debuglog(`📦保存字幕文件`); } else { log(`❎跳过保存:视频 ${bvNumber}字幕 已下载`); } } /*** 纯粹的广告检测器:接收文本和链接字符串数组,返回分析结果 */ function singleFuncForAd({ linkHrefs, commentText, goods = '' }) { let hasAd = undefined; let reason = null; // 1. 检查链接字符串数组 for (const href of linkHrefs) { if (href && biliAdWordsConfig.biliAdLinks.some(blocked => href.includes(blocked))) { const cleanHref = href.split('?')[0]; debuglog('匹配链接: ', cleanHref); hasAd = true; reason = 'links'; break; } } // 2. 如果链接未命中,但我们有明确的商品名,也认为是广告 if (hasAd === undefined && goods) { debuglog(`🚚 匹配商品: %c${goods}`); hasAd = true; reason = 'goods'; } // 2. 如果链接未命中,再检查关键词 if (hasAd === undefined) { const matches = commentText.match(keywordRegex); if (matches) { const fullMatch = matches[0]; if (fullMatch === '评论' || fullMatch === '评论区') { hasAd = false; } else { debuglog('匹配关键词: ', fullMatch); hasAd = true; reason = 'keyWords'; } } else { hasAd = false; } } if (hasAd) { log(` 📢%c广告: ${hasAd}, reason: ${reason}`, `color: #e67e22; padding: 2px 5px; font-weight: bold;`); } //debuglog(`🔝 API置顶评论: %c${commentText.slice(0, 60)}...`); return { hasAd: !!hasAd, reason }; } /*** (优化版 - goods优先) 纯函数:分析从API获取的评论JSON中的置顶评论top_replies,返回标准结果。 * @returns {{hasAd: boolean, commentText: string, goods: string, reason: string|null}|null} */ function analyzeCommentJson(top_replies) { const topComment = top_replies?.[0]; if (!topComment) { debuglog("❌ 置顶评论字段为空"); return null; } if (topComment.reply_control?.is_up_top !== true) { debuglog("❌ top_replies非UP手动置顶"); return null; } const commentText = topComment.content?.message || ''; const jumpUrlObject = topComment.content?.jump_url || {}; const jumpUrlValues = Object.values(jumpUrlObject); const productJumpUrls = jumpUrlValues.filter(item => item.extra?.is_word_search !== true); const goodsTitles = productJumpUrls.map(item => item.title).filter(Boolean); if (goodsTitles.length > 0) { const limitedGoodsTitles = goodsTitles.slice(0, 2); const goods = limitedGoodsTitles.join('; '); debuglog(`🚚 匹配商品: %c${goods}`); return { hasAd: true, reason: 'goods', commentText: commentText, goods: goods }; } const linkHrefs = jumpUrlValues.map(item => item.pc_url).filter(Boolean); const adDetectionResult = singleFuncForAd({ linkHrefs, commentText, goods: '' }); return { ...adDetectionResult, commentText, goods: '' }; } /*** (AI分析引擎 - 修正版) 接收字幕和评论信息,执行完整的AI分析流程。 * @param {string} bvNumber - 视频的BV号。 * @param {Array<string>} subtitlesArray - 格式为 ["mm:ss content", ...] 的字幕数组。 * @param {object} commentAnalysis - 从 analyzeCommentJson 返回的评论分析结果。 */ async function runAiAnalysis(bvNumber, subtitlesArray, commentAnalysis, options = {}) { if (state.isAIAnalysisInProgress) { log(`[AI引擎] 警告:AI分析流程已被锁定,防止重入 ${bvNumber}。`); return; } log(`🤖 [AI引擎] 分析 ${bvNumber}...`); state.isAIAnalysisInProgress = true; try { // 1. 准备AI参考文本 let aiReferenceText; if (commentAnalysis && commentAnalysis.goods) { aiReferenceText = `置顶评论推广商品:${commentAnalysis.goods}`; } else if (commentAnalysis.commentText) { aiReferenceText = commentAnalysis.commentText; } else if (commentAnalysis.reason && commentAnalysis.reason.includes('no_top_comment')) { aiReferenceText = "无UP主置顶评论"; } else { aiReferenceText = "评论区未提供有效内容"; } state.commentText = aiReferenceText; // 2. 本地预处理 const subtitlesObjects = subtitlesArray.map(s => { const idx = s.indexOf(' '); const head = idx > -1 ? s.slice(0, idx) : s; const content = idx > -1 ? s.slice(idx + 1) : ''; if (head.includes('-')) { const [fromStr, toStr] = head.split('-'); const fromSec = timeToSeconds(fromStr); const toSec = timeToSeconds(toStr); return { fromStr, toStr, fromSec, toSec, content }; } else { const fromStr = head; const fromSec = timeToSeconds(fromStr); return { fromStr, toStr: fromStr, fromSec, toSec: fromSec, content }; } }); const { subtitlesNoAd, filteredSubtitles } = subtitlesFiltering(subtitlesObjects, commentAnalysis); trySaveSubtitles(subtitlesObjects); // 3. 根据预处理结果决策 if (subtitlesNoAd && !commentAnalysis.hasAd) { log('🚫 本地结论:无广告'); await markVideoAsNoAd(bvNumber, {upload: true, reason: 'Local Analysis'}); return; } if (!filteredSubtitles || filteredSubtitles.length < 20) { log(' - ⚠️ 本地分析:未能提取有效的疑似广告字幕,AI分析中止。'); return; } // 4. 发送给AI并处理结果 log(`🔢 提交AI ${filteredSubtitles.length} 条字幕...`); const aiResultJson = await sendSubtitlesToAI(bvNumber, filteredSubtitles); await processAiResult(bvNumber, aiResultJson, options); } catch (err) { console.error(`[AI引擎] 在处理 ${bvNumber} 时发生严重错误:`, err); } finally { debuglog('🔑 [AI引擎] 解除锁定'); state.isAIAnalysisInProgress = false; } } /** (新增的通用模块) 获取单个视频的置顶评论分析结果。 */ async function getCommentAnalysis(bvNumber, { allowDomFallback = false, useWbi = false }) { let result = { hasAd: false, goods: '', commentText: '', reason: 'unknown_failure' }; debuglog('⚙️ [评论分析器] 开始...'); const aid = await getOidFromApi(bvNumber); if (!aid) { log('ℹ️ [评论分析器] 无法获取视频aid,中止。'); result.reason = 'no_aid'; return result; } if (useWbi) { log(' -> 策略: 强制 WBI API'); const commentDataWBI = await fetchBilibiliComments_WBI({ aid }); if (commentDataWBI && commentDataWBI.data && commentDataWBI.data.top_replies) { const apiResult = analyzeCommentJson(commentDataWBI.data.top_replies); if (apiResult) { log('✅ [评论分析器] WBI API 分析成功。'); return apiResult; } } result.reason = 'wbi_api_no_top_comment'; } else { // --- 策略B:v1 API 优先,带多种回退 (为UI模式设计) --- log(' -> 策略: v1 API 优先 (带回退)'); const commentData = await fetchBilibiliComments({ aid }); if (commentData) { const apiResult = analyzeCommentJson(commentData.top_replies); if (apiResult) { log('✅ [评论分析器] 有置顶'); return apiResult; } else { result.reason = 'api_no_top_comment'; log('🏁 [评论分析器] 无置顶'); return result; } } else { // --- API 请求失败 --- // 只有在API本身调用失败(commentData为null)时,才进入回退逻辑。 if (allowDomFallback) { log('ℹ️ API v1 调用失败,回退至DOM轮询模式。'); return await getCommentTopAds_VideoPageUI(); // 直接返回DOM的结果 } else { log('ℹ️ API v1 调用失败,且不允许DOM回退。'); result.reason = 'api_v1_failed'; const commentDataWBI_fallback = await fetchBilibiliComments_WBI({ aid }); if (commentDataWBI_fallback && commentDataWBI_fallback.data && commentDataWBI_fallback.data.top_replies) { const apiResultWBI = analyzeCommentJson(commentDataWBI_fallback.data.top_replies); if (apiResultWBI) { log('✅ [评论分析器] WBI API (回退) 分析成功。'); return apiResultWBI; } } return result; } } if (commentData && commentData.top_replies) { const apiResult = analyzeCommentJson(commentData.top_replies); if (apiResult) { log('✅ [评论分析器] API v1 分析成功。'); return apiResult; } else { result.reason= 'api_no_top_comment'; } } else { if (allowDomFallback) { log('ℹ️ API调用失败或无数据,回退至DOM轮询模式。'); result = await getCommentTopAds_VideoPageUI(); } else { log('ℹ️ API v1 调用失败,且不允许DOM回退。'); result.reason = 'api_v1_failed'; } } if (result.reason === 'api_v1_failed') { const commentDataWBI_fallback = await fetchBilibiliComments_WBI({ aid }); if (commentDataWBI_fallback && commentDataWBI_fallback.data && commentDataWBI_fallback.data.top_replies) { const apiResultWBI = analyzeCommentJson(commentDataWBI_fallback.data.top_replies); if (apiResultWBI) { log('✅ [评论分析器] WBI API (回退) 分析成功。'); return apiResultWBI; } } } } log(`[评论] 结论: hasAd=${result.hasAd}, reason=${result.reason || 'none'}`); return result; } /** 检查评论区,并根据需要启动基于API拦截的AI字幕分析。 */ async function checkCommentAndHandleAI(bvNumber) { // 1. 调用通用模块获取评论分析结果,允许DOM回退 const result = await getCommentAnalysis(bvNumber, { allowDomFallback: true }); // 2. 决策:是否需要启动AI (仅在UI模式下) if (!state.isAIAnalysisInProgress && (FORCE_AI_ACTIVE || result.hasAd)) { try { log('(UI模式) 等待拦截字幕...'); const subtitlesArray = await fetchBilibiliSubtitleAPI(); if (subtitlesArray && subtitlesArray.length > 0) { await runAiAnalysis(bvNumber, subtitlesArray, result); } else { log('(UI模式) 无字幕,AI流程中止'); } } finally { state.isAIAnalysisInProgress = false; } } } // 适配新版UI,提取的评论区检测函数 function commentAdDetectorByUI() { const result = {hasAd: undefined, commentText: '', reason: null} const commentsContainer = document.querySelector('#commentapp > bili-comments') || document.querySelector('.bili-dyn-comment > bili-comments'); if (commentsContainer?.shadowRoot) { const thread = commentsContainer.shadowRoot.querySelector('bili-comment-thread-renderer'); if (thread?.shadowRoot) { const commentRenderer = thread.shadowRoot.querySelector('#comment'); if (commentRenderer?.shadowRoot) { const contentContainer = commentRenderer.shadowRoot.querySelector('#content'); if (contentContainer) { const topIndicator = contentContainer.querySelector('#top'); if (!topIndicator) { debuglog('ℹ️ 评论区首条为热评'); result.hasAd = false; return result; } const richText = contentContainer.querySelector('bili-rich-text'); if (richText?.shadowRoot) { const contentsElement = richText.shadowRoot.querySelector('#contents'); if (contentsElement) { result.commentText = contentsElement.textContent.trim(); const commentText = result.commentText; debuglog(`🔝置顶评论: %c${commentText.slice(0, 50)} ...`); const links = contentsElement.querySelectorAll('a'); const linkHrefs = Array.from(links).map(link => link.getAttribute('href')); const adDetectionResult = singleFuncForAd({ linkHrefs, commentText }); Object.assign(result, adDetectionResult); } } } } } } return result; } /** 提取评论区文本 */ async function getCommentTopAds_VideoPageUI() { debuglog('等待评论区加载...'); let result = {}; for (let i = 0; i < 5; i++) { result = commentAdDetectorByUI(); if (result.hasAd !== undefined) break; debuglog(`🛻 评论区重试 (${i + 1}/5)`); if (window.scrollY < window.innerWidth/10) { scrollToLoadComments(200); } await randomSleep(1500); } const backToTop = async () => { while (window.scrollY > 50) { debuglog('🔝评论区加载,回顶部'); window.scrollTo({ top: 0, behavior: 'smooth' }); await randomSleep(500); } } await backToTop(); return result; } async function processAiResult(bvNumber, aiResultJson, options = {}) { if (aiResultJson ) { // 只要AI有结论,就停止弹幕和评论区检查 danmakuManager.stop(); if (aiResultJson.noAd === true) { //无广告 log(` ✅ AI返回: %c无广告`, 'color: #3498db; font-weight: bold;'); playBeepSound(700); await markVideoAsNoAd(bvNumber, {upload: true, reason: aiResultJson.source || 'kimi'}); } else { //有时间戳,本地保存 + 共享上传 log(` 🎯 AD: %c${aiResultJson.start}-${aiResultJson.end}, ${aiResultJson.product}`, 'color: #3498db; font-weight: bold;'); playBeepSound(1100); const finalOptions = { ...options, saveTimestamp: true, uploadCloud: true }; monitorTimestamp(bvNumber, aiResultJson, aiResultJson.source, finalOptions); } } } async function monitorTimestamp(bvNumber, dataTimestamp, source, options = {}) { //1. 合法性检查 if (!dataTimestamp || typeof dataTimestamp !== 'object' || !dataTimestamp.start || !dataTimestamp.end) { console.warn("❌无效时间戳,跳过", dataTimestamp); return; } //2. 更新state状态,启动视频进度监测 if (isTrueVideoPage()) { state.adTime = dataTimestamp; state.video.addEventListener('timeupdate', handleTimeUpdate); } //3. 本地保存 if (options.saveTimestamp) { storeAdTime(bvNumber, dataTimestamp, source); } //4. 共享至云端 if (options.uploadCloud) { const duplicateCount = state.cloudDuplicateCount || 0; if (duplicateCount >= 2) { log(`❎云端已有 ${duplicateCount} 条相同时间戳,忽略共享`); return; } if (!state.uploaded) { state.uploaded = true; const timestamp_range = `${dataTimestamp.start}-${dataTimestamp.end}`; const source_update = (source === "manual" ? 'manual_cloud': source); debuglog('⬆️共享', timestamp_range, source_update); await uploadAdTimeDataToCloud(bvNumber, timestamp_range, source_update); } } danmakuManager.stop(); } //公共AI服务 async function publicServiceCore({ platform, body }) { const headers = { 'Content-Type': 'application/json' }; if (platform === 'supabase') { headers.Authorization = `Bearer ${supabaseAnonKey.join('.')}`; } try { const response = await fetch( publicAiPlatform[platform], { method: 'POST', headers: headers, body: JSON.stringify(body) }); if (!response.ok) { const error = await response.text(); console.warn(`❌${platform} 服务响应失败:`, response.status, error); return null; } const result = await response.json(); log(`🤖${platform} AI 返回结果:`, result); return result; } catch (err) { console.error(`❌请求 ${platform} AI 服务异常:`, err); return null; } } /** 公共AI服务的统一调用入口。*/ async function callPublicService({ platform = 'supabase', bv, subtitles }) { const user_id = getOrCreateUserId(); const upInfo = await getUpInfo(); const up_id = upInfo?.name || 'unknown'; const commentText = (state.commentText || '').slice(0, 100); const body = { bv, subtitles, user_id, up_id, commentText }; const serverResponse = await publicServiceCore({ platform, body }); if (serverResponse && serverResponse.success && serverResponse.aiResult) { return serverResponse.aiResult; } return null; } // 发送字幕到 AI 分析广告时间段 async function sendSubtitlesToAI(bvNumber, subtitles) { // 1. 读取配置 const configLibrary = await GM_getValue('localAIConfig', { lastSelected: 'kimi' }); const selectedAI = configLibrary.lastSelected || 'kimi'; const currentConfig = configLibrary[selectedAI] || {}; const apiUrl = currentConfig.apiUrl; const aiModel = currentConfig.model; const apiKey = currentConfig.apiKey || await GM_getValue(`apiKey_${selectedAI}`, null); // 2. 参数校验 if (!apiKey) { log('❌未配置AI,调用%c公共AI服务') //返回json || null return await callPublicService({ platform: cloudPlatformService, bv: bvNumber, subtitles}); } // 3. 构造请求 log(`模型:${selectedAI} - ${currentConfig.model}`) const commentText = state.commentText; const system_prompt = ` 你是一个精准的广告分析引擎。 你的唯一任务是分析用户提供的视频字幕和评论区文本,判断其中是否包含商业广告,并返回一个结构化的JSON对象。 输入说明: - 字幕行通常为 "mm:ss.s-mm:ss.s 内容" 的格式,其中前者是该条字幕的开始时间(from),后者是结束时间(to),均为 0.1s 精度; - 如遇仅有单个时间戳的行(例如 "mm:ss 内容"),将其视为仅有起始时间的旧版本字幕。 输出要求(必须严格返回一个 JSON 对象): - 必须包含字段: - "start": 广告起始时间戳("mm:ss.s" 或 "hh:mm:ss.s"),精度至少 0.1s; - "end": 广告结束时间戳("mm:ss.s" 或 "hh:mm:ss.s"),精度至少 0.1s; - "noAd": 布尔值;如果确定无广告,则为 true; - "product": 字符串;广告中推广的商品名称(无法判断可为 null)。 - 若未发现广告,返回 {"start": null, "end": null, "product": null, "noAd": true}。 判定规则: 1) 返回值必须是可被 JSON.parse() 解析的合法 JSON,且不要包含任何多余文字; 2) 时间精度至少 0.1s,且使用 "mm:ss.s" 或 "hh:mm:ss.s" 格式; 3) 广告区段边界由字幕区段决定: - "start" = 第一条广告相关字幕的开始时间(from); - "end" = 最后一条广告相关字幕的结束时间(to); 4) 将引入广告的话术也视为广告开始(如:“说到...就不得不提...” 等),并覆盖至广告收尾; 5) 返回的区段要尽可能覆盖完整广告,不要遗漏;商业广告通常不少于 30 秒; 6) 不涉及军用装备及法律禁止公开买卖的物品。 ` const titleEl = document.querySelector('.video-info-title h1.video-title'); const title = titleEl ? titleEl.textContent.trim() : ''; const user_prompt = ` 以下是视频标题和评论文本,供你参考:\n 标题: ${title}\n 评论: ${commentText}\n\n 以下是截取的部分字幕:\n ${subtitles.join('\n')} `; const requestData = { messages: [ {role: 'system', content: system_prompt,}, {role: 'user', content: user_prompt,} ], model: aiModel, temperature: 0.2, response_format: { type: "json_object" }, enable_thinking: false, max_tokens: 200, }; const isGemini = selectedAI === 'gemini'; let fetchUrl = apiUrl; let fetchOptions = { method: 'POST', headers: { 'Content-Type': 'application/json' } }; // --- 核心转换逻辑 --- if (isGemini) { // Gemini 的 URL 格式: 基础路径 + 模型名 + :generateContent?key=API_KEY fetchUrl = `${apiUrl}${aiModel}:generateContent?key=${apiKey}`; fetchOptions.body = JSON.stringify({ contents: [{ parts: [{ text: user_prompt }] }], systemInstruction: { parts: [{ text: system_prompt }] }, generationConfig: { temperature: 0.2, maxOutputTokens: 200, responseMimeType: "application/json" // 强制返回 JSON } }); } else { fetchUrl = apiUrl; fetchOptions.headers.Authorization = `Bearer ${apiKey}`; fetchOptions.headers['Content-Type'] = 'application/json'; fetchOptions.body = JSON.stringify({ messages: [ { role: 'system', content: system_prompt }, { role: 'user', content: user_prompt } ], model: aiModel, temperature: 0.2, response_format: { type: "json_object" }, max_tokens: 200 }); } try { const response = await fetch(fetchUrl, fetchOptions); if (response.status === 401) { console.warn(`❎错误 401,用户的API Key 无效,调用公共AI服务`); //返回 json || null return await callPublicService({ platform: cloudPlatformService, bv: bvNumber, subtitles}); } if (!response.ok) { const errorData = await response.json().catch(() => ({})); const errorMessage = errorData?.error?.message || '未知API错误'; // 【关键】识别“内容过滤”错误,并上报 if (errorMessage.includes('inappropriate content')) { debuglog(`🚫 AI模型-${selectedAI} 因“内容不适宜”拒绝了请求 [${bvNumber}]`); return null; } throw new Error(`API 错误: ${response.status} - ${errorMessage}`); } const rawData = await response.json(); //debuglog('🤖AI返回原始数据:', rawData); let aiRespText; if (isGemini) { // Gemini 的路径: data.candidates[0].content.parts[0].text aiRespText = rawData.candidates?.[0]?.content?.parts?.[0]?.text; } else { // OpenAI 的路径: data.choices[0].message.content aiRespText = rawData.choices?.[0]?.message?.content; } if (!aiRespText) throw new Error('AI 返回数据为空'); debuglog(`🤖AI返回数据:`, aiRespText ); // --- 6. 核心修改:直接解析JSON const aiModel = rawData.model || aiModel; let aiResultJson; try { const jsonMatch = aiRespText.match(/```json\n([\s\S]*?)\n```|({[\s\S]*})/); if (!jsonMatch) throw new Error("AI回复中未找到有效的JSON代码块"); aiResultJson= JSON.parse(jsonMatch[1] || jsonMatch[2]); debuglog(`🤖提取json:`, aiResultJson ); } catch (e) { console.error("❌ JSON解析失败!", "原始回复:", aiRespText, "错误:", e); return { status: 500, json: { error: 'AI返回的不是有效的JSON', raw: aiRespText } }; } // --- 7. 根据解析出的JSON,返回标准化的结果 --- if (aiResultJson.noAd === true) { aiResultJson.source = aiModel; return aiResultJson; } else if (aiResultJson.start && aiResultJson.end) { const product = aiResultJson.product || "unknown" // 归一化到 0.1s 精度(保持字符串格式) try { const s = timeToSeconds(aiResultJson.start); const e = timeToSeconds(aiResultJson.end); return { start: formatTimeTenths(s), end: formatTimeTenths(e), source: aiModel, product }; } catch { return { start: aiResultJson.start, end: aiResultJson.end, source: aiModel, product }; } } else { throw new Error('AI返回的JSON内容无效'); } } catch (error) { console.error(`❌[${bvNumber}] 本地请求AI失败`, error.message); } } function extractTimestampFromString(timestamp_range) { if (!timestamp_range) return null; const times = timestamp_range.match(/\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?/g); if (!times || times.length < 2) return null; log(timestamp_range, times[0], times[1]); return { start: times[0], end: times[1] }; } function timeToSeconds(timestamp) { if (typeof timestamp !== 'string' || timestamp.trim() === '') { return } const parts = timestamp.split(':').map(part => { const num = Number(part); if (isNaN(num)) { throw new Error(`❌时间戳部分无效: ${part}`); } return num; }); if (parts.length === 3) { const [hours, minutes, seconds] = parts; return hours * 3600 + minutes * 60 + seconds; } else if (parts.length === 2) { const [minutes, seconds] = parts; return minutes * 60 + seconds; } else { throw new Error(`❌无效的时间戳格式: ${timestamp}`); } } function formatTime(input) { if (typeof input === 'number') { const sec = Math.floor(input % 60).toString().padStart(2, '0'); const min = Math.floor((input / 60) % 60).toString().padStart(2, '0'); const hr = Math.floor(input / 3600).toString(); return input >= 3600 ? `${hr}:${min}:${sec}` : `${min}:${sec}`; } if (typeof input === 'string') { const parts = input.split(':'); if (parts.length === 3 && parts[0] === '00') { return parts.slice(1).join(':'); } return input; } } function formatTimeTenths(seconds) { if (typeof seconds !== 'number' || isNaN(seconds)) return ''; const tenths = Math.round(seconds * 10); const hrs = Math.floor(tenths / 36000); const mins = Math.floor((tenths % 36000) / 600); const secs = ((tenths % 600) / 10).toFixed(1); const mm = mins.toString().padStart(2, '0'); const ss = secs.padStart(4, '0'); if (hrs > 0) { return `${hrs}:${mm}:${ss}`; } return `${mm}:${ss}`; } const isUpOfficialOrgApi = (() => { const cache = new Map(); return async function(upId) { if (!upId) return false; if (cache.has(upId)) return cache.get(upId); const cardData = await fetchUserCardData({ mid: upId }); if (cardData && cardData.card) { const officialInfo = cardData.card.Official; // "role": 3~6 (机构) and "type": 1 (蓝V) const isOfficialOrg = officialInfo && (officialInfo.role >= 3 && officialInfo.role <= 6) && officialInfo.type === 1; log(`UP主 [%c${cardData.card.name}%c], 认证: ${isOfficialOrg ? '机构' : '非机构'}`, 'color: #e77222; font-weight: bold;', 'color: initial;'); cache.set(upId, isOfficialOrg); return isOfficialOrg; } console.error(`[isUpOfficialOrgApi] 无法获取UP主 ${upId} 的卡片信息。`); cache.set(upId, false); return false; }; })(); /** 通过 B 站官方 API 获取用户卡片(主页)的详细信息。 内置会话级缓存,避免对同一用户的重复请求。*/ const fetchUserCardData = (() => { const cardApiCache = new Map(); return async function({ mid }) { if (!mid) { console.error("[UserCardAPI] 必须提供 mid。"); return null; } const cacheKey = `mid_${mid}`; if (cardApiCache.has(cacheKey)) { return cardApiCache.get(cacheKey); } const apiUrl = `https://api.bilibili.com/x/web-interface/card?mid=${mid}`; try { const response = await fetch(apiUrl, { credentials: 'include' }); if (!response.ok) throw new Error(`请求失败: ${response.status}`); const result = await response.json(); if (result.code !== 0) throw new Error(`API返回错误: ${result.message}`); const cardData = result.data; if (cardData) { cardApiCache.set(cacheKey, cardData); return cardData; } return null; } catch (error) { console.error(`[UserCardAPI] 请求 ${apiUrl} 时发生错误:`, error); return null; } }; })(); /** * 获取当前页面的UP主信息。 内置会话级缓存,确保在单个页面生命周期内只执行一次API请求。*/ const getUpInfo = (() => { const upInfoCache = new Map(); async function _getUpInfoInternal() { const isSpacePage = window.location.href.match(/space.bilibili.com\/(\d+)/); const isVideoPage = isTrueVideoPage(); if (isVideoPage) { const bv = await getBVNumber(); if (!bv) return null; const viewData = await fetchVideoViewData({ bvid: bv }); if (!viewData) return null; if (viewData.staff && viewData.staff.length > 0) { log('检测到联合投稿 '); const staff = viewData.staff; const primaryUp = staff.find(s => s.title === 'UP主') || staff[0]; const fanCount = primaryUp.follower; const hasSponsor = staff.some(s => s.title === '赞助商'); if (hasSponsor) log('🎯 API确认:联合投稿中包含【赞助商】!'); const isOfficialOrg = staff.some(s => s.official && s.official.role >= 3 && s.official.role <= 6 && s.official.type === 1); state.officialOrg = isOfficialOrg; return { name: primaryUp.name, id: primaryUp.mid.toString(), memberCount: staff.length, officialOrg: isOfficialOrg, hasSponsor, fanCount }; } else if (viewData.owner) { const owner = viewData.owner; const fanCount = viewData.stat ? viewData.stat.follower : null; const officialOrg = await isUpOfficialOrgApi(owner.mid); state.officialOrg = officialOrg; return { name: owner.name, id: owner.mid.toString(), memberCount: 1, officialOrg, hasSponsor: false, fanCount }; } return null; } else if (isSpacePage) { const upId = isSpacePage[1]; const cardData = await fetchUserCardData({ mid: upId }); if (cardData && cardData.card) { const card = cardData.card; const officialInfo = card.Official; const isOfficialOrg = officialInfo && (officialInfo.role >= 3 && officialInfo.role <= 6) && officialInfo.type === 1; return { name: card.name, id: upId, memberCount: 1, officialOrg: isOfficialOrg, hasSponsor: false, fanCount: cardData.follower }; } const nicknameElement = document.querySelector('.upinfo-detail .nickname'); if(nicknameElement) return { name: nicknameElement.textContent.trim(), id: upId }; return null; } return null; } return async function() { const isVideoPage = isTrueVideoPage(); const isSpacePage = window.location.href.match(/space.bilibili.com\/(\d+)/); let cacheKey = null; if (isVideoPage) { const bv = await getBVNumber(); if (bv) cacheKey = `bv_${bv}`; } else if (isSpacePage) { cacheKey = `mid_${isSpacePage[1]}`; } if (!cacheKey) return null; if (upInfoCache.has(cacheKey)) { return upInfoCache.get(cacheKey); } //debuglog(`未命中缓存,执行API请求: ${cacheKey}`); const result = await _getUpInfoInternal(); if (result) { upInfoCache.set(cacheKey, result); } return result; }; })(); /*** 从当前URL获取BV号。 * - 兼容 BV号、festival页 和 AV号。 * - 【核心】内置了 AV->BV 的内存缓存,避免在同一页面上重复请求API。 */ const getBVNumber = (() => { return async function() { const url = new URL(window.location.href); const path = url.pathname; // --- 方案 A: 尝试从路径中直接提取 BV 号 --- const bvMatch = path.match(/\/video\/(BV\w+)/); if (bvMatch) { return bvMatch[1]; } // --- 方案 B: 尝试从 festival 等特殊页面的查询参数中提取 BV 号 --- if (path.startsWith('/festival/')) { const bvidFromQuery = url.searchParams.get('bvid'); if (bvidFromQuery) { return bvidFromQuery; } } // --- 方案 C: 尝试从路径中提取 AV 号,并调用统一接口 --- const avMatch = path.match(/\/video\/av(\d+)/); if (avMatch) { const aid = avMatch[1]; const data = await fetchVideoViewData({ aid }); if (data && data.bvid) { log(`✅ AV [${aid}] 转换为 BV [${data.bvid}]`); return data.bvid; } else { console.error(`将 AV 号 ${aid} 转换为 BV 号时出错。`); return null; } } return null; }; })(); /** 将广告时间戳存入 localStorage,并【同步更新】内存缓存。 */ async function storeAdTime(bvNumber, adTimestamp, source) { let data = await GM_getValue(bvNumber, {}); if (typeof data === 'string') try { data = JSON.parse(data); } catch(e) { data = {}; } if (!data.timestamps) data.timestamps = {}; data.timestamps[adTimestamp.source || source] = { ...adTimestamp }; delete data.noAd; await GM_setValue(bvNumber, data); debuglog(`✅ 时间戳已存入数据库`); // 2. 更新当前页面的内存缓存 if (!scriptCache.mainAdDbKeys.includes(bvNumber)) { scriptCache.mainAdDbKeys.push(bvNumber); } const noAdIndex = scriptCache.noAdDbKeys.indexOf(bvNumber); if (noAdIndex > -1) scriptCache.noAdDbKeys.splice(noAdIndex, 1); debuglog(`🧠 内存缓存已同步`); // --- 3. 【核心修复】在这里,强制同步更新GM存储中的跨域“摘要” --- debuglog(`🔁 更新跨域缓存`); const crossDomainCache = await GM_getValue('biliCrossDomainCache', { mainAdDbKeys: [], noAdDbKeys: [] }); if (!crossDomainCache.mainAdDbKeys.includes(bvNumber)) { crossDomainCache.mainAdDbKeys.push(bvNumber); } const noAdCacheIndex = crossDomainCache.noAdDbKeys.indexOf(bvNumber); if (noAdCacheIndex > -1) { crossDomainCache.noAdDbKeys.splice(noAdCacheIndex, 1); } await GM_setValue('biliCrossDomainCache', crossDomainCache); } async function getStoredAdTime(bvNumber) { if (scriptCache.noAdDbKeys.includes(bvNumber)) { return 'noAd'; } if (scriptCache.mainAdDbKeys.includes(bvNumber)) { let data = await GM_getValue(bvNumber, null); if (!data) return null; if (typeof data === 'string') { try { data = JSON.parse(data); } catch(e) { return null; } } if (data.noAd) { return 'noAd'; } const tsObj = data.timestamps; if (!tsObj || typeof tsObj !== 'object' || Object.keys(tsObj).length === 0) { return null; } if (tsObj.manual && tsObj.manual.start && tsObj.manual.end) { return { adTime: tsObj.manual, source: 'manual' }; } let highPriorityResult = null; for (const source in tsObj) { if (Object.prototype.hasOwnProperty.call(tsObj, source)) { const lk = source.toLowerCase(); if (lk !== 'manual' && lk !== 'danmaku') { const ts = tsObj[source]; if (ts && ts.start && ts.end) { highPriorityResult = { adTime: ts, source: source }; break; } } } } if (highPriorityResult) { return highPriorityResult; } if (tsObj.Danmaku && tsObj.Danmaku.start && tsObj.Danmaku.end) { return { adTime: tsObj.Danmaku, source: 'Danmaku' }; } return null; } return null; } /** 标记noAd,并同步更新localStorage, 内存缓存, 和GM跨域摘要*/ async function markVideoAsNoAd(bvNumber, options = {upload: false, reason: 'unknown'}) { log(`✅标记视频无广告`); let data = await GM_getValue(bvNumber, {}); if (typeof data === 'string') try { data = JSON.parse(data); } catch(e) { data = {}; } data.noAd = true; delete data.timestamps; await GM_setValue(bvNumber, data); // 2. 更新内存缓存 if (!scriptCache.noAdDbKeys.includes(bvNumber)) { scriptCache.noAdDbKeys.push(bvNumber); } const mainDbIndex = scriptCache.mainAdDbKeys.indexOf(bvNumber); if (mainDbIndex > -1) scriptCache.mainAdDbKeys.splice(mainDbIndex, 1); // 3. 更新GM跨域摘要 if (options.reason === 'iShortVideo') { debuglog('视频时长过短,跳过持久化存储和云端上报。'); } else { const crossDomainCache = await GM_getValue('biliCrossDomainCache', { mainAdDbKeys: [], noAdDbKeys: [] }); if (!crossDomainCache.noAdDbKeys.includes(bvNumber)) { crossDomainCache.noAdDbKeys.push(bvNumber); } const mainCacheIndex = crossDomainCache.mainAdDbKeys.indexOf(bvNumber); if (mainCacheIndex > -1) crossDomainCache.mainAdDbKeys.splice(mainCacheIndex, 1); await GM_setValue('biliCrossDomainCache', crossDomainCache); if (options.upload && !state.uploaded && options.reason !== 'Local Analysis') { state.uploaded = true; await uploadAdTimeDataToCloud(bvNumber, null, options.reason, true); } } // --- 5. 更新【当前会话】的状态和行为 --- danmakuManager.stop(); if (state.video) { state.video.removeEventListener('timeupdate', handleTimeUpdate); debuglog('移除 timeupdate'); } state.noAd = true; state.adTime = null; } // ========================================================== // ========= 界面配置,全局可复用的“拖拽管理器”模块 ============ // ========================================================== const draggableManager = (() => { let targetElement = null; let isDragging = false; let offsetX, offsetY; // --- 【核心】只在脚本启动时,绑定一次全局事件 --- document.addEventListener('mousemove', (e) => { if (isDragging && targetElement) { const newLeft = e.clientX - offsetX; const newTop = e.clientY - offsetY; targetElement.style.left = `${newLeft}px`; targetElement.style.top = `${newTop}px`; } }); document.addEventListener('mouseup', () => { isDragging = false; targetElement = null; }); return { makeDraggable: function(container, handle) { handle.addEventListener('mousedown', (e) => { isDragging = true; targetElement = container; offsetX = e.clientX - container.offsetLeft; offsetY = e.clientY - container.offsetTop; handle.style.cursor = 'move'; document.body.style.userSelect = 'none'; e.preventDefault(); }); handle.addEventListener('mouseup', () => { document.body.style.userSelect = ''; }); } }; })(); // ====================================================== // ================= AI配置UI模块 (封装版) =============== // ====================================================== /** 创建、初始化并管理AI配置UI的所有逻辑。 */ function setupAiConfigUI() { // --- 1. 定义一个固定的、唯一的ID,并实现懒加载 --- const CONFIG_POPUP_ID = 'bili-ad-skipper-ai-config-popup'; // --- 2. 定义所有数据源和配置 --- const aiOptions = [ {value: 'aliyun', text: '阿里云(平台)', apiUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', model: ['qwen-plus', 'qwen-plus-latest', 'deepseek-v3.1','deepseek-v3', 'Moonshot-Kimi-K2-Instruct','glm-4.5','glm-4.5-air'] }, { value: 'deepseek', text: '深度求索 DeepSeek', apiUrl: 'https://api.deepseek.com/v1/chat/completions', model: ['deepseek-chat'] }, {value: 'kimi', text: '月之暗面 Kimi', apiUrl: 'https://api.moonshot.cn/v1/chat/completions', model: ['kimi-k2-0905-preview','kimi-k2-0711-preview', 'kimi-k2.5', 'moonshot-v1-32k', 'moonshot-v1-8k' ] }, {value: 'siliconflow', text: '硅基流动(平台)', apiUrl: 'https://api.siliconflow.cn/v1/chat/completions', model: ['Qwen3-30B-A3B-Instruct-2507','DeepSeek-R1-Distill-Qwen-32B'] }, {value: 'baidu', text: '百度千帆(平台)', apiUrl: 'https://qianfan.baidubce.com/v2/chat/completions', model: ['ernie-4.5-turbo-latest', 'qwen3-30b-a3b-instruct-2507','qwen3-14b'] }, {value: 'glm', text: '智谱清言 GLM', apiUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: ['GLM-4.5-Air','GLM-4.5'] }, {value: 'ChatGPT', text: 'OpenAI', apiUrl: 'https://api.openai.com/v1/chat/completions', model: ['gpt-5.1', 'gpt-5.1-mini', 'gpt-5.1-nano', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-4o-mini', 'gpt-4o' ]}, { value: 'custom1', text: '自定义AI-1', apiUrl: '', model: '' }, { value: 'custom2', text: '自定义AI-2', apiUrl: '', model: '' } ]; const aiFormFields = [ { id: 'AiSelect', label: 'AI提供商:', type: 'select', options: aiOptions.map(o => ({ value: o.value, text: o.text })) }, { id: 'ModelSelect', label: '模型选择:', type: 'select', options: [] }, { id: 'ApiUrl', label: 'API URL:', type: 'input', placeholder: '请输入API URL' }, { id: 'ApiKey', label: 'API KEY:', type: 'input', placeholder: '请输入API Key' } ]; // --- 3. 创建所有UI构建的辅助函数 --- const createFormRow = (fieldConfig) => { const row = document.createElement('div'); row.style.cssText = 'display: flex; align-items: center; margin-bottom: 10px; gap: 10px;'; const label = document.createElement('label'); label.textContent = fieldConfig.label; label.style.cssText = 'flex-shrink: 0; width: 90px; text-align: right;'; let inputElement; if (fieldConfig.type === 'select') { inputElement = document.createElement('select'); (fieldConfig.options || []).forEach(opt => { const option = document.createElement('option'); option.value = opt.value; option.textContent = opt.text; inputElement.appendChild(option); }); } else { inputElement = document.createElement('input'); inputElement.type = 'text'; inputElement.placeholder = fieldConfig.placeholder || ''; } inputElement.id = fieldConfig.id; inputElement.style.cssText = `flex-grow: 1; min-width: 0; border: 1px solid #ccc;`; row.appendChild(label); row.appendChild(inputElement); return { row, inputElement }; }; const createLink = (text, url, container) => { const link = document.createElement('a'); link.href = url; link.textContent = text; link.style.cssText = 'color: blue; margin: 0 5px; text-decoration: none;'; link.target = '_blank'; container.appendChild(link); }; const createButton = (text, onClick) => { const button = document.createElement('button'); button.textContent = text; button.style.cssText = `padding: 3px 3px; border: 1px solid #ccc; background: #f0f0f0; border-radius: 4px; cursor: pointer; font-size: 14px;`; if (onClick) button.onclick = onClick; return button; }; // --- 4. 创建UI主体,并动态生成表单 --- const configContainer = document.createElement('div'); configContainer.id = CONFIG_POPUP_ID; configContainer.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 500px; padding: 20px; background: #fff; border: 1px solid #ccc; border-radius: 10px; z-index: 10000; font-size: 16px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); `; const configTitle = document.createElement('h3'); configTitle.textContent = '管理AI配置'; configTitle.style.cssText = `text-align: center; margin-bottom: 20px; font-weight: bold; cursor: move; user-select: none;`; configContainer.appendChild(configTitle); draggableManager.makeDraggable(configContainer, configTitle); const aiFormElements = {}; aiFormFields.forEach(field => { const { row, inputElement } = createFormRow(field); configContainer.appendChild(row); aiFormElements[field.id] = inputElement; }); const modelRow = aiFormElements.ModelSelect.parentElement; const modelInput = document.createElement('input'); modelInput.type = 'text'; modelInput.id = 'ModelInput'; modelInput.placeholder = '请输入自定义模型名称'; modelInput.style.cssText = 'flex-grow: 1; min-width: 0; border: 1px solid #ccc; display: none;'; modelRow.appendChild(modelInput); const linksContainer = document.createElement('div'); linksContainer.style.cssText = 'margin-top: 20px; text-align: center;'; const descriptionText = document.createTextNode('免费申请apikey:'); linksContainer.appendChild(descriptionText); createLink('阿里云', 'https://bailian.console.aliyun.com/?tab=model#/api-key', linksContainer); createLink('Deepseek', 'https://platform.deepseek.com/', linksContainer); createLink('Kimi', 'https://platform.moonshot.cn/console/api-keys/', linksContainer); createLink('硅基流动', 'https://cloud.siliconflow.cn/sft-keejoek1ys/account/ak', linksContainer); createLink('智谱清言', 'https://bigmodel.cn/usercenter/proj-mgmt/apikeys', linksContainer); configContainer.appendChild(linksContainer); const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = 'margin-top: 20px; display: flex; justify-content: center; gap: 10px;'; const saveButton = createButton('保存配置'); const cancelButton = createButton('关闭界面'); buttonContainer.appendChild(saveButton); buttonContainer.appendChild(cancelButton); configContainer.appendChild(buttonContainer); // --- 5. 定义所有事件处理和逻辑函数 --- const { AiSelect, ModelSelect, ApiUrl, ApiKey } = aiFormElements; const hideAIConfigUI = () => { configContainer.style.display = 'none'; }; const updateModelDropdown = async () => { const selectedAIValue = AiSelect.value; const selectedOptionData = aiOptions.find(option => option.value === selectedAIValue); if (selectedAIValue.startsWith('custom')) { // 自定义AI:显示 input, 隐藏 select ModelSelect.style.display = 'none'; modelInput.style.display = ''; } else { ModelSelect.style.display = ''; modelInput.style.display = 'none'; ModelSelect.innerHTML = ''; if (selectedOptionData && Array.isArray(selectedOptionData.model) && selectedOptionData.model.length > 0) { ModelSelect.disabled = false; selectedOptionData.model.forEach(modelName => { const option = document.createElement('option'); option.value = modelName; option.textContent = modelName; ModelSelect.appendChild(option); }); } else { ModelSelect.disabled = true; ModelSelect.innerHTML = '<option>N/A (请在自定义中输入)</option>'; } } }; async function saveLocalAIConfig() { const currentAI = AiSelect.value; const configLibrary = await GM_getValue('localAIConfig', { lastSelected: 'kimi' }); configLibrary.lastSelected = currentAI; const modelValue = currentAI.startsWith('custom') ? modelInput.value : ModelSelect.value; configLibrary[currentAI] = { model: modelValue, apiUrl: ApiUrl.value, apiKey: ApiKey.value }; await GM_setValue('localAIConfig', configLibrary); await GM_deleteValue(`apiKey_${currentAI}`); localStorage.setItem('localAIConfig_Backup', JSON.stringify(configLibrary)); debuglog(`AI配置已更新 (合并存储): \n ${currentAI} - ${modelValue}`); uiWindowManager.closeAll(); } async function loadLocalAIConfig() { let configLibrary = await GM_getValue('localAIConfig', null); if (!configLibrary) { const backupConfigString = localStorage.getItem('localAIConfig_Backup'); if (backupConfigString) { log('⚠️ 从localStorage备份中恢复AI配置...'); try { configLibrary = JSON.parse(backupConfigString); await GM_setValue('localAIConfig', configLibrary); } catch(e) { configLibrary = {}; } } } const lastSelectedAI = configLibrary.lastSelected || 'kimi'; const currentConfig = configLibrary[lastSelectedAI] || {}; AiSelect.value = lastSelectedAI; await updateModelDropdown(); if (lastSelectedAI.startsWith('custom')) { modelInput.value = currentConfig.model || ''; } else { ModelSelect.value = currentConfig.model || ''; } ApiUrl.value = currentConfig.apiUrl || ''; ApiKey.value = currentConfig.apiKey || await GM_getValue(`apiKey_${lastSelectedAI}`, ''); const selectedOption = aiOptions.find(option => option.value === lastSelectedAI); if (selectedOption && !lastSelectedAI.startsWith('custom')) { ApiUrl.value = selectedOption.apiUrl; } }; AiSelect.addEventListener('change', async () => { const selectedAIValue = AiSelect.value; await updateModelDropdown(); const configLibrary = await GM_getValue('localAIConfig', {}); const newConfig = configLibrary[selectedAIValue] || {}; ModelSelect.value = newConfig.model || (ModelSelect.options[0]?.value || ''); ApiKey.value = newConfig.apiKey || await GM_getValue(`apiKey_${selectedAIValue}`, localStorage.getItem(`apiKey_${selectedAIValue}_Backup`) || ''); const selectedOption = aiOptions.find(option => option.value === selectedAIValue); if (selectedAIValue.startsWith('custom')) { ApiUrl.value = newConfig.apiUrl || ''; } else if (selectedOption) { ApiUrl.value = selectedOption.apiUrl; } }); // --- 6. 最终的初始化和事件绑定 --- saveButton.onclick = saveLocalAIConfig; //cancelButton.onclick = hideAIConfigUI; cancelButton.onclick = () => uiWindowManager.closeAll(); document.body.appendChild(configContainer); loadLocalAIConfig(); configContainer.style.display = 'block'; log('✅ 初始化AI配置UI界面'); } // ====================================================== // =========== 配置界面:手动配置广告时间戳 ============= // ====================================================== async function manualAdTimestamps() { // 1. 首先,尝试从当前URL获取BV号 const isVideoPage = isTrueVideoPage(); const bvFromUrl = await getBVNumber(); const containerId = 'bili-ad-timestamp-editor'; const container = document.createElement('div'); container.id = containerId; container.style.cssText = ` position: fixed; top: 30%; left: 50%; transform: translate(-50%, -50%); width: 500px; padding: 20px; background: #fff; border: 1px solid #ccc; border-radius: 10px; z-index: 10000; font-size: 16px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); `; const title = document.createElement('h3'); title.textContent = `手动配置广告时间戳`; title.style.cssText = 'text-align: center; margin-bottom: 20px; font-weight: bold; cursor: move; user-select: none;'; container.appendChild(title); draggableManager.makeDraggable(container, title); const mainContentWrapper = document.createElement('div'); mainContentWrapper.style.cssText = 'position: relative;'; const inputArea = document.createElement('div'); // --- 1. 创建一个统一的、可重用的“行构建器” --- function createInputRow(labelText, inputId, placeholder, initialValue = '', options = {}) { const row = document.createElement('div'); row.style.cssText = 'display: flex; align-items: center; margin: 0 75px 12px 40px;'; const label = document.createElement('label'); label.textContent = labelText; label.style.cssText = 'flex: 0 0 85px; text-align: right; margin-right: 10px; font-weight: bold; color: #555;'; const input = document.createElement('input'); input.type = 'text'; input.id = inputId; input.placeholder = placeholder; input.value = initialValue; input.style.cssText = 'flex: 1 1 auto; height: 30px; border: 1px solid #ccc; border-radius: 4px; padding: 0 5px; box-sizing: border-box;'; row.appendChild(label); row.appendChild(input); // 辅助函数:执行跳转 const jumpToTime = (timeStr, offset = 0) => { const video = state.video || document.querySelector('video'); if (!video || !timeStr) return; try { const timeSec = /^\d{4}$/.test(timeStr) ? Number(timeStr.substring(0, 2)) * 60 + Number(timeStr.substring(2)) : timeToSeconds(timeStr); video.currentTime = Math.max(0, timeSec + offset); video.play(); } catch (e) { alert('时间格式不正确'); } }; // 辅助函数:抓取当前时间 const captureTime = () => { const video = state.video || document.querySelector('video'); if (!video) { alert('未找到视频元素'); return; } input.value = formatTimeTenths(video.currentTime); }; // 统一的按钮样式生成器 const createSmallBtn = (text, bgColor, borderColor, callback) => { const btn = document.createElement('button'); btn.textContent = text; btn.onclick = callback; btn.style.cssText = ` margin-left: 10px; padding: 0 10px; height: 30px; font-size: 12px; border-radius: 4px; cursor: pointer; white-space: nowrap; background-color: ${bgColor}; border: 1px solid ${borderColor}; color: #333; `; return btn; }; // --- 按钮逻辑分支 (同时支持跳转与抓取) --- if (options.showJumpButton && initialValue) { const jumpBtn = createSmallBtn('跳至此处', '#f0f0f0', '#ccc', () => { jumpToTime(input.value, options.jumpOffset || 0); }); row.appendChild(jumpBtn); } else if (isVideoPage && options.enableCapture) { const captureBtn = createSmallBtn('当前进度', '#e1f5fe', '#81d4fa', () => { captureTime(); }); captureBtn.title = "点击填入视频当前播放时间"; row.appendChild(captureBtn); } return { row, input }; } // --- 2. 使用新的行构建器来创建所有输入行 --- let bvInput, startTimeInput, endTimeInput; const bvInitialValue = isVideoPage ? bvFromUrl : ''; const bvRowData = createInputRow('视频BV号:', 'bili-manual-bv-input', '非视频页面,请输入BV号', bvInitialValue); bvInput = bvRowData.input; if (isVideoPage) { bvInput.disabled = true; bvInput.style.backgroundColor = '#f9f9f9'; bvInput.style.color = '#666'; } inputArea.appendChild(bvRowData.row); const storedData = isVideoPage ? await getStoredAdTime(bvFromUrl) : null; const start = storedData?.adTime?.start || ''; const end = storedData?.adTime?.end || ''; const startTimeRowData = createInputRow('广告起始:', 'StartTime', '格式 00:00.0 或 01:02:03.4', start, { showJumpButton: start, jumpOffset: -3, enableCapture: true } ); startTimeInput = startTimeRowData.input; const endTimeRowData = createInputRow( '广告结束:', ' EndTime', '格式 00:00.0 或 01:02:03.4', end, { showJumpButton: end, jumpOffset: 0, enableCapture: true } ); endTimeInput = endTimeRowData.input; inputArea.appendChild(startTimeRowData.row); inputArea.appendChild(endTimeRowData.row); // --- 4. 【UI优化】右侧竖条按钮的定位 --- if (isVideoPage) { const noAdButton = createButton(''); const rightPosition = '20px'; noAdButton.style.cssText = ` position: absolute; top: 0; right: ${rightPosition}; height: 100%; width: 45px; writing-mode: vertical-lr; text-orientation: mixed; display: flex; align-items: center; justify-content: center; border-radius: 0 8px 8px 0; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; letter-spacing: 2px; box-shadow: -2px 0 5px rgba(0,0,0,0.05); `; const currentData = JSON.parse(localStorage.getItem(bvFromUrl) || '{}'); if (currentData.noAd) { noAdButton.textContent = '撤销该页无广告'; noAdButton.style.backgroundColor = '#f39c12'; noAdButton.style.color = 'white'; noAdButton.onclick = async () => { document.body.removeChild(container); let currentData = await GM_getValue(bvFromUrl, {}); delete currentData.noAd; await GM_setValue(bvFromUrl, currentData); await clearAllCachesForBV(bvFromUrl); log(`已取消 ${bvFromUrl} 的无广告标记`); }; } else { noAdButton.textContent = '标记该页无广告'; noAdButton.style.backgroundColor = '#27ae60'; noAdButton.style.color = 'white'; noAdButton.onclick = async () => { //clearUI(); uiWindowManager.closeAll(); await markVideoAsNoAd(bvFromUrl, {upload: true, reason: 'manual_cloud'}); log(`已标记为无广告!`); }; } mainContentWrapper.appendChild(noAdButton); } // --- 5. 组装DOM --- mainContentWrapper.appendChild(inputArea); container.appendChild(mainContentWrapper); // --- 3. 按钮容器和按钮 (逻辑简化) --- const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = 'display: flex; justify-content: center; align-items: center; margin-top: 20px; gap: 10px;'; // 创建一个函数来生成带样式的按钮 function createButton(text, onClick) { const button = document.createElement('button'); button.textContent = text; // 定义一个基础的按钮样式 button.style.cssText = `padding: 3px 3px; border: 1px solid #ccc; background: #f0f0f0; border-radius: 4px; cursor: pointer; font-size: 14px;`; if (onClick) button.onclick = onClick; return button; } // “保存”按钮 const saveTimestampButton = createButton('保存配置', async () => { const bvNumber = bvFromUrl || bvInput.value.trim(); if (!bvNumber || !bvNumber.startsWith('BV')) { alert('请输入一个有效的BV号!'); return; } let startTime = startTimeInput.value.trim(); let endTime = endTimeInput.value.trim(); if (startTime && endTime) { // 检查时间格式是否正确(支持 0.1s 精度) const timeRegex = /^(\d{1,2}:\d{2}(?:\.\d)?|\d{1,2}:\d{2}:\d{2}(?:\.\d)?)$/; if (!timeRegex.test(startTime) ) { if (/^\d{4}$/.test(startTime)) { startTime = startTime.substring(0, 2) + ':' + startTime.substring(2); } else { alert('请输入正确的时间格式(例如:05:30.0 或 01:30:45.3)'); return; } } if (!timeRegex.test(endTime)) { if (/^\d{4}$/.test(endTime)) { endTime = endTime.substring(0, 2) + ':' + endTime.substring(2); } else { alert('请输入正确的时间格式(例如:05:30.0 或 01:30:45.3)'); return; } } // 保存广告时间戳到本地、云端 const dataTimestamp = { start: startTime, end: endTime }; state.uploaded = false; //手动设置已上传标记为 false monitorTimestamp(bvNumber, dataTimestamp, 'manual', {uploadCloud: true, saveTimestamp: true}); //document.body.removeChild(container); uiWindowManager.closeAll(); } else { alert('请输入完整的广告时间戳!'); } }); buttonContainer.appendChild(saveTimestampButton); container.appendChild(buttonContainer); if (isVideoPage) { const bvNumber = bvFromUrl; if (storedData) { // “删除”按钮 const delBtn = createButton('删除该页记录', async () => { await GM_deleteValue(bvNumber); state.adTime = null; clearAllCachesForBV(bvNumber); log(`已清除 ${bvNumber} 数据库数据`); uiWindowManager.closeAll(); }); delBtn.style.color = '#e74c3c'; buttonContainer.appendChild(delBtn); } } // “关闭”按钮 const cancelButton = createButton('关闭界面', () => { //document.body.removeChild(container); uiWindowManager.closeAll(); }); buttonContainer.appendChild(cancelButton); //插入按钮容器 document.body.appendChild(container); /* 工具函数*/ // 从【所有】缓存中,彻底移除一个BV号的记录。 async function clearAllCachesForBV(bvNumber) { log(`从所有缓存中彻底清理 [${bvNumber}]...`); // 1. 清理内存缓存 (scriptCache) const noAdIndex = scriptCache.noAdDbKeys.indexOf(bvNumber); if (noAdIndex > -1) scriptCache.noAdDbKeys.splice(noAdIndex, 1); const mainDbIndex = scriptCache.mainAdDbKeys.indexOf(bvNumber); if (mainDbIndex > -1) scriptCache.mainAdDbKeys.splice(mainDbIndex, 1); debuglog(` -> 🧠 内存缓存已清理。`); // 2. 清理GM存储中的跨域缓存摘要 const crossDomainCache = await GM_getValue('biliCrossDomainCache', { mainAdDbKeys: [], noAdDbKeys: [] }); const noAdCacheIndex = crossDomainCache.noAdDbKeys.indexOf(bvNumber); if (noAdCacheIndex > -1) crossDomainCache.noAdDbKeys.splice(noAdCacheIndex, 1); const mainCacheIndex = crossDomainCache.mainAdDbKeys.indexOf(bvNumber); if (mainCacheIndex > -1) crossDomainCache.mainAdDbKeys.splice(mainCacheIndex, 1); await GM_setValue('biliCrossDomainCache', crossDomainCache); debuglog(` -> 🌍 GM跨域缓存摘要已清理。`); } function clearUI(){ state.adTime = null; if (startTimeInput) { startTimeInput.value = ''; } if (endTimeInput) { endTimeInput.value = ''; } if (state.video) { state.video.removeEventListener('timeupdate', handleTimeUpdate); state.video = null; } document.body.removeChild(container); } } // ================================================ // ============= UP白名单管理模块 ================== // ================================================ /** 将UP主添加到白名单,并立即更新当前页面的运行状态以停止所有监控。 */ async function addUpToWhitelistAndUpdateState(upName) { if (!upName || typeof upName !== 'string' || upName.trim() === '') { debuglog(`无效昵称,无法添加到UP主白名单`); return; } if (whiteList.includes(upName)) { debuglog(` [${upName}] 已在UP主白名单中,无需重复添加。`); return; } log(` [${upName}] 添加到UP主白名单...`); // 1. 更新白名单 (内存、GM存储、localStorage备份) whiteList.push(upName); await GM_setValue('biliUpWhiteList', whiteList); localStorage.setItem('biliUpWhiteList_Backup', JSON.stringify(whiteList)); // 写入localStorage备份 log(`停止当前页面监控`); danmakuManager.stop(); if (state.video) { state.video.removeEventListener('timeupdate', handleTimeUpdate); debuglog('移除 timeupdate 监听器'); } state.noAd = true; state.adTime = null; // 4. 更新UI (逻辑不变) updateWhiteListDisplay(); log(`✅ 白名单+ UP主 [${upName}]`); } /** (新增) 将UP主从白名单移除*/ async function removeUpFromWhitelist(upName) { const index = whiteList.indexOf(upName); if (index > -1) { log(`从UP主白名单中移除[${upName}]`); whiteList.splice(index, 1); await GM_setValue('biliUpWhiteList', whiteList); localStorage.setItem('biliUpWhiteList_Backup', JSON.stringify(whiteList)); updateWhiteListDisplay(); } } /** 更新整个白名单UI的显示,包括列表和动态按钮。*/ async function updateWhiteListDisplay() { // 1. 更新列表显示 (不变) const listDisplay = document.getElementById('whiteListDisplay'); if (listDisplay) { listDisplay.textContent = whiteList.join(', ') || '白名单为空'; } const currentUserRow = document.getElementById('bili-current-up-display'); const upInfo = await getUpInfo(); if (currentUserRow) { if (upInfo && upInfo.name) { currentUserRow.innerHTML = `当前页面UP主: <b style="color: #00a1d6;">${upInfo.name}</b>`; } else { currentUserRow.innerHTML = ''; } } // 2. 更新“添加/移除当前页UP”按钮的状态 const toggleCurrentUpButton = document.getElementById('bili-add-current-up-btn'); const currentUpBtn = toggleCurrentUpButton; if (currentUpBtn) { const upInfo = await getUpInfo(); if (upInfo && upInfo.name) { currentUpBtn.style.display = ''; if (whiteList.includes(upInfo.name)) { currentUpBtn.textContent = `移除当前UP`; currentUpBtn.style.backgroundColor = '#e74c3c'; // 红色 } else { currentUpBtn.textContent = `添加当前UP`; currentUpBtn.style.backgroundColor = '#2eac31'; // 绿色 } } else { currentUpBtn.style.display = 'none'; } } } // ================================================= // ============== 配置界面:白名单管理 ============== // ================================================= async function monitorUpWhiteList() { const UpWhiteListContainer = document.createElement('div'); UpWhiteListContainer.id = 'UpWhiteListContainer'; UpWhiteListContainer.style.cssText = ` position: fixed; top: 30%; left: 50%; transform: translate(-50%, -50%); width: 500px; padding: 20px; background: #fff; border: 1px solid #ccc; border-radius: 10px; z-index: 10000; font-size: 16px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); `; const Title = document.createElement('h3'); Title.textContent = `手动管理白名单(跳过检测)`; Title.style.cssText = 'text-align: center; margin-bottom: 20px; font-weight: bold; cursor: move; user-select: none;'; UpWhiteListContainer.appendChild(Title); // --- 拖拽代码,改为调用管理器 --- draggableManager.makeDraggable(UpWhiteListContainer, Title); const toggleUpRow = document.createElement('div'); toggleUpRow.style.cssText = `display: flex; align-items: center; margin-bottom: 10px; gap: 10px;`; const toggleUpLabel = document.createElement('label'); toggleUpLabel.textContent = '添加/移除UP主:'; toggleUpLabel.style.cssText = `flex-shrink: 0;`; // 为“执行”按钮绑定智能的切换逻辑 const handleToggle = async () => { const upName = toggleUpInput.value.trim(); if (!upName) return; if (whiteList.includes(upName)) { await removeUpFromWhitelist(upName); } else { await addUpToWhitelistAndUpdateState(upName); } toggleUpInput.value = ''; }; const toggleUpInput = document.createElement('input'); toggleUpInput.type = 'text'; toggleUpInput.id = 'toggleUpInput'; toggleUpInput.placeholder = '输入UP主昵称'; toggleUpInput.style.cssText = 'flex-grow: 1; min-width: 200; max-width: 240px; border: 1px solid #ccc;'; toggleUpInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') handleToggle(); }); const toggleButton = createButton('执行', handleToggle); toggleButton.style.minWidth = '80px'; toggleUpRow.appendChild(toggleUpLabel); toggleUpRow.appendChild(toggleUpInput); toggleUpRow.appendChild(toggleButton); UpWhiteListContainer.appendChild(toggleUpRow); // 白名单列表显示区域 const listDiv = document.createElement('div'); listDiv.id = 'whiteListDisplay'; listDiv.style.cssText = ` text-align: left; color: #30b688; margin: 20px 0; padding: 5px; border: 1px dashed #ccc; border-radius: 5px; font-size: 14px; word-break: break-word; max-height: 150px; overflow-y: auto;`; listDiv.textContent = whiteList.join(', ') || '白名单为空'; UpWhiteListContainer.appendChild(listDiv); // a. 获取当前UP主信息 const currentUpInfo = await getUpInfo(); if (currentUpInfo && currentUpInfo.name) { const currentUserRow = document.createElement('div'); currentUserRow.id = 'bili-current-up-display'; currentUserRow.style.cssText = `text-align: center; font-size: 16px; color: #555; margin: 5px 0; padding: 5px;`; UpWhiteListContainer.appendChild(currentUserRow); } const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = 'display: flex; justify-content: center; margin: 10px 0; gap: 10px'; // --- 2. 核心修改:创建【动态】按钮和【统一】的事件处理器 --- // 创建“添加/移除当前页UP”按钮,并给它一个固定的ID const toggleCurrentUpButton = document.createElement('button'); toggleCurrentUpButton.id = 'bili-add-current-up-btn'; toggleCurrentUpButton.style.cssText = `color: white; padding: 4px 5px; margin: 0 5px; border: none; border-radius: 4px;`; // 创建一个函数来生成带样式的按钮 function createButton(text, onClick) { const button = document.createElement('button'); button.textContent = text; button.style.cssText = `padding: 3px 3px; border: 1px solid #ccc; background: #f0f0f0; border-radius: 4px; cursor: pointer; font-size: 14px;`; if (onClick) button.onclick = onClick; return button; } // 创建“完成”按钮 const finishButton = createButton('关闭界面', () => { //document.body.removeChild(UpWhiteListContainer); uiWindowManager.closeAll(); }) //插入元素 buttonContainer.appendChild(toggleCurrentUpButton); buttonContainer.appendChild(finishButton); UpWhiteListContainer.appendChild(buttonContainer); document.body.appendChild(UpWhiteListContainer); // --- 3. 首次渲染UI --- updateWhiteListDisplay(); // 【核心】为“动态按钮”绑定一个【统一的】点击事件处理器 toggleCurrentUpButton.addEventListener('click', async () => { const upInfo = await getUpInfo(); if (upInfo && upInfo.name) { if (whiteList.includes(upInfo.name)) { await removeUpFromWhitelist(upInfo.name); } else { await addUpToWhitelistAndUpdateState(upInfo.name); } } }); } const uiWindowManager = { windows: {}, register(id, openFunc) { this.windows[id] = { open: openFunc }; }, closeAll() { for (const id in this.windows) { const element = document.getElementById(id); if (element) { element.remove(); } } }, open(id) { this.closeAll(); if (this.windows[id] && typeof this.windows[id].open === 'function') { this.windows[id].open(); } else { console.error(`[WindowManager] 尝试打开一个未注册的窗口: ${id}`); } } }; function registerMenuUI(menuText, containerId, openFunction, options = {}) { uiWindowManager.register(containerId, openFunction); GM_registerMenuCommand(menuText, () => uiWindowManager.open(containerId)); } //----------------------------整合弹幕识别脚本------------------------------- const timeRegexList = [ { regex: /\b(\d{1,2})[::]([0-5]\d)\b/, isFuzzy: false }, // 5:14 { regex: /(\d{1,2}|[一二三四五六七八九十]{1,3})分(\d{1,2}|[零一二三四五六七八九十]{1,3})/, isFuzzy: false }, { regex: /(\d{1,2})\.(\d{1,2})[郎朗]/, isFuzzy: false }, { regex: /(?<!\d)(?:(\d{2})\.(\d{1,2})|(\d{1,2})\.(\d{2}))(?![\d郎君侠降秒分:wk++])/i, isFuzzy: true } // 模糊时间戳:纯数字 5.14,排除1.9这种 ]; const cnNumMap = { "零": 0, "一": 1, "二": 2,"两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10 }; function parseNumber(char) { return cnNumMap[char] || parseInt(char) || 0; } function parseChineseNumber(ch) { if (ch.length === 1) return parseNumber(ch); if (ch.length === 2) return (ch[1] === "十") ? parseNumber(ch[0]) * 10 : (10 + parseNumber(ch[1])); if (ch.length === 3 && ch[1] === "十") return parseNumber(ch[0]) * 10 + parseNumber(ch[2]); return 0; } const TIME_GROUP_THRESHOLD = 10; const MIN_JUMP_INTERVAL = 5; //跳转冷静期,防止频繁跳转 const MIN_COUNT_TO_LOG = 2; function extractTimestamps(text) { if (/[百千万亿wk]/i.test(text)) return null; const cleanText = text.replace(/\s+/g, ''); for (let i = 0; i < timeRegexList.length; i++) { const { regex, isFuzzy } = timeRegexList[i]; const match = regex.exec(cleanText); if (match) { const parts = [match[1], match[2]]; const isChinese = parts.map(p => /[一二三四五六七八九十]/.test(p)); const values = parts.map((p, idx) => isChinese[idx] ? parseChineseNumber(p) : parseInt(p) || 0); const ts = values[0] * 60 + values[1]; const isAdTs = /[郎朗君菌侠降猜秒谢我]/.test(text) || (isChinese[0] !== isChinese[1]) if (!isNaN(ts) && ts >= 30) { //限制广告时间戳位置在00:30之后 return { timestamp: ts, isAdTs, isFuzzy }; } } } return null; } // (重构版) 弹幕突变处理器,只负责【采集】弹幕数据并存入新仓库。 async function handleDanmakuMutations(mutationsList) { if (state.officialOrg === null) { const upInfo = await getUpInfo(); state.officialOrg = upInfo.officialOrg; } else if (state.officialOrg) { danmakuManager.stop(); return; } if (!state.video) return; // 必须有video对象才能获取当前时间 for (const mutation of mutationsList) { for (const node of mutation.addedNodes) { if (node._danmakuHandled) continue; node._danmakuHandled = true; const text = node.textContent.trim(); if (text.length === 0 || text === '9麤') continue; const result = extractTimestamps(text); if (result) { const ts = result.timestamp; const currentTime = state.video.currentTime; // --- 核心修改:在这里实现“写入时聚类” --- let clusterKey = null; for (const existingTsKey in state.danmakuTimestampStore) { const existingTs = Number(existingTsKey); if (Math.abs(ts - existingTs) <= TIME_GROUP_THRESHOLD) { clusterKey = existingTs; break; } } if (clusterKey === null) { clusterKey = ts; state.danmakuTimestampStore[clusterKey] = []; } const occurrence = { savedAt: currentTime, count: result.isAdTs ? 2 : 1 }; state.danmakuTimestampStore[clusterKey].push(occurrence); } } } } /** 弹幕心跳处理器 */ async function processDanmakuHeartbeat() { log("🩷 Danmaku 心跳 ..."); if ( !state.video || (state.video && state.video.paused) || state.noAd || state.adTime) { danmakuManager.stop(); return; } const conclusion = analyzeDanmakuStore({ isRealtime: true }); if (conclusion) { await monitorTimestamp(state.currentBV, conclusion, conclusion.source, {uploadCloud:true, saveTimestamp: true}); danmakuManager.stop(); } return; } /** 弹幕监控的【全局单例管理器】 */ const danmakuManager = (() => { let internalObserver = null; let internalInterval = null; let isRunning = false; const stopInternal = () => { if (internalObserver) { internalObserver.disconnect(); internalObserver = null; } if (internalInterval) { clearInterval(internalInterval); internalInterval = null; log('🚫 已停止弹幕监控'); } isRunning = false; }; return { stop: function() { stopInternal(); }, start: async function() { if (isRunning) { debuglog("弹幕监控运行中..."); return; } const canProceed = await videoNeedAdAnalyze(); if (!canProceed || state.noAd || state.adTime) { debuglog("无需启动弹幕"); return; } isRunning = true; log('🔛 尝试启动弹幕监控...'); try { const auxiliaryContainer = await waitForElement('.bpx-player-auxiliary', 5000); const danmakuHeader = auxiliaryContainer.querySelector('.bui-collapse-header'); const danmakuWrap = auxiliaryContainer.querySelector('.bui-collapse-wrap'); if (danmakuHeader && danmakuWrap && danmakuWrap.classList.contains('bui-collapse-wrap-folded')) { debuglog(' -> 展开弹幕...'); danmakuHeader.click(); setTimeout(() => { if (document.body.contains(danmakuHeader)) { danmakuHeader.click(); }}, 3000); await randomSleep(50); } } catch (e) { debuglog(" -> 展开弹幕列表失败 :", e.message); } const checkInterval = setInterval(() => { if (!isRunning) { clearInterval(checkInterval); return; } const container = document.querySelector('div.bpx-player-render-dm-wrap > div.bpx-player-dm-mask-wrap > div.bpx-player-row-dm-wrap'); if (container) { clearInterval(checkInterval); log('📸绑定弹幕容器监控'); internalObserver = new MutationObserver(handleDanmakuMutations); internalObserver.observe(container, { childList: true, subtree: true }); internalInterval = setInterval(() => { if (!isRunning) { stopInternal(); return; } processDanmakuHeartbeat(); }, 1500); } }, 1000); } }; })(); function videoEnded() { debuglog('🔚视频播放已结束'); danmakuManager.stop(); } /*** (重构版) “纯粹的”页面观察器,只负责异步等待关键元素加载完毕。*/ async function initPageObserver() { log('等待播放器元素加载...'); try { const videoArea = await waitForElement('.bpx-player-video-area'); const video = await waitForElement('video', 10000, videoArea); log('✅ -> 加载成功'); return video; } catch (error) { log(`❌ -> 加载失败, ${error.message}`); return null; } } function convertToRelativeTime(str) { const t = new Date(str); const now = new Date(); if (isNaN(t.getTime())) return str; const diff = Math.floor((now - t) / 1000); if (diff < 60) return `${diff}秒前`; if (diff < 3600) { const m = Math.floor(diff / 60); const s = diff % 60; return s > 0 ? `${m}分${s}秒前` : `${m}分钟前`; } if (diff < 86400) { const h = Math.floor(diff / 3600); const m = Math.floor((diff % 3600) / 60); return m > 0 ? `${h}小时${m}分前` : `${h}小时前`; } if (diff < 31536000) { const d = Math.floor(diff / 86400); const h = Math.floor((diff % 86400) / 3600); return h > 0 ? `${d}天${h}小时前` : `${d}天前`; } const y = Math.floor(diff / 31536000); const d = Math.floor((diff % 31536000) / 86400); return d > 0 ? `${y}年${d}天前` : `${y}年前`; } function setupNavigationObserver() { const styleId = 'bili-ad-skip-relative-time-style'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = ` #viewbox_report .video-info-meta .pubdate-ip-text[data-custom-time]::after { content: attr(data-custom-time); visibility: visible !important; display: inline-block !important; font-size: 13px !important; line-height: 18px !important; color: #e67e22 !important; font-weight: bold; background: rgba(245, 245, 245, 0.85); padding: 0 4px; border-radius: 4px; white-space: nowrap; position: relative; top: 0px; } `; document.head.appendChild(style); } // --- 1. 修改逻辑:增加“内容变更检测” --- const modifyVideoInfoMeta = () => { const textSpan = document.querySelector("#viewbox_report .video-info-meta .pubdate-ip .pubdate-ip-text"); if (!textSpan) return; const currentText = textSpan.textContent.trim(); if (!currentText) return; const lastProcessedText = textSpan.getAttribute('data-orig-text'); if (lastProcessedText === currentText) return; const newRelativeText = '⏱️' + convertToRelativeTime(currentText); textSpan.title = currentText; textSpan.setAttribute('data-custom-time', newRelativeText); textSpan.setAttribute('data-orig-text', currentText); }; // --- 2. 观察器部分 --- let lastExecutionTime = 0; const throttleDelay = 200; const mainObserverCallback = (mutationsList) => { const now = Date.now(); if (state.isHandling) return; if (now - lastExecutionTime < throttleDelay) return; lastExecutionTime = now; requestAnimationFrame(async () => { if (window.location.href.match(/bilibili.com\/video\//)) { modifyVideoInfoMeta(); } const currentBV = await getBVNumber(); if (currentBV) { if (currentBV !== state.currentBV || (!state.video && !state.isHandling)) { handlePageChanges(); } } }); }; const mainObserver = new MutationObserver(mainObserverCallback); const createMainObserver = () => { // 观察子节点变化 (childList) 和 字符数据变化 (characterData) // 虽然 Vue 通常是替换节点内容,但加上 subtree 比较保险 mainObserver.observe(document.body, { childList: true, subtree: true }); } if (document.body) { createMainObserver(); log('✅ 主导航观察器已启动 (SPA 适配版)'); } else { window.addEventListener('DOMContentLoaded', createMainObserver , { once: true }); } } /** * (静默巡查核心) 通过 x/player/wbi/v2 接口获取AI字幕的URL。 * @param {string|number} aid - 视频的AV号。 * @param {string|number} cid - 视频的CID。 * @returns {Promise<string|null>} 成功时返回完整的字幕文件URL,失败时返回null。 */ async function fetchSubtitleUrl({aid, cid}) { if (!gState.deviceFingerprint) { console.error(`[SilentScan] 无法获取字幕URL,因为设备指纹尚未被借用。`); return null; } try { const baseParams = { aid: aid, cid: cid, isGaiaAvoided: false // 固定参数 }; // 将借来的指纹参数合并进去 const finalParams = { ...baseParams, ...gState.deviceFingerprint }; // 使用已有的 WBI 签名工具 const signedParams = await wbiSigner.sign(finalParams); const apiUrl = new URL('https://api.bilibili.com/x/player/wbi/v2'); for (const key in signedParams) { apiUrl.searchParams.set(key, signedParams[key]); } const response = await gState.originalFetch(apiUrl.toString(), { credentials: 'include' }); if (!response.ok) throw new Error(`API response not OK: ${response.status}`); const data = await response.json(); if (data.code !== 0) throw new Error(`API returned error code: ${data.code}`); const subtitles = data?.data?.subtitle?.subtitles; if (subtitles && subtitles.length > 0) { // 查找中文AI字幕 const aiZhSubtitle = subtitles.find(s => s.lan === 'ai-zh'); if (aiZhSubtitle && aiZhSubtitle.subtitle_url) { // 补全协议并返回 return 'https:' + aiZhSubtitle.subtitle_url; } } return null; // 没有找到AI字幕 } catch (err) { console.error(`[SilentScan] fetchSubtitleUrl 失败 for aid ${aid}:`, err); return null; } } /** 弹幕时间戳仓库分析引擎*/ function analyzeDanmakuStore(options = {}) { const isRealtime = options.isRealtime || false; const currentTime = state.video ? state.video.currentTime : 0; let bestCandidate = null; const dmArray = state.danmakuTimestampStore; for (const tsKey in dmArray) { let occurrences = dmArray[tsKey] || []; const ts = Number(tsKey); // 实时模式下的筛选 (不变) if (isRealtime) { occurrences = occurrences.filter(occ => (currentTime - occ.savedAt) < 10); } if (occurrences.length === 0) continue; // 第一次筛选净化 (不变) const cleanedOccurrences = occurrences.filter(occ => { return occ.savedAt >= 15 && ((ts > occ.savedAt && ts - occ.savedAt <= 240 && ts - occ.savedAt > 10) || (ts < occ.savedAt && occ.savedAt - ts < 8)); }); // 如果清洗后为空,则跳过 if (cleanedOccurrences.length === 0) { delete dmArray[tsKey]; continue; } // 更新 dmArray[tsKey] 为净化后的版本 dmArray[tsKey] = cleanedOccurrences; // --- 基于净化后的数据进行后续所有操作 --- // 1. 计算总权重 const totalCount = cleanedOccurrences.reduce((sum, occ) => sum + occ.count, 0); if (totalCount < MIN_COUNT_TO_LOG) continue; // 2. 从 cleanedOccurrences 中筛选出有效的前置弹幕 const validStartCandidates = cleanedOccurrences.filter(occ =>ts > occ.savedAt && ts - occ.savedAt > 10); if (validStartCandidates.length === 0) continue; // 3. 实现新的起始时间计算逻辑 let finalStartTime; validStartCandidates.sort((a, b) => a.savedAt - b.savedAt); if (validStartCandidates.length >= 3) { finalStartTime = validStartCandidates[1].savedAt; } else { finalStartTime = validStartCandidates[0].savedAt; } // --- 后续检查逻辑 (剔除虚假、实时性检查) --- if (finalStartTime >= ts) continue; if (isRealtime && (ts <= currentTime)) continue; // 4. 更新最佳候选 if (!bestCandidate || totalCount > bestCandidate.count) { bestCandidate = { ts: ts, startTime: finalStartTime, count: totalCount }; } } if (bestCandidate) { const conclusion = { start: formatTime(bestCandidate.startTime), end: formatTime(bestCandidate.ts), source: 'Danmaku' }; const adTimestamp = `${conclusion.start}-${conclusion.end}` log(`🎯 弹幕: %c${adTimestamp}(权重: ${bestCandidate.count})`, 'color: #a498db; font-weight: bold;'); playBeepSound(); return conclusion; } return null; } /** * (优化版) 解析动态页评论区置顶内容,兼容新旧两种UI版本。 * @param {HTMLElement} panel - 动态卡片的评论区面板元素。 * @returns {Promise<{hasAd: boolean|undefined, commentText: string, reason:null|string}>} */ async function getCommentTopAds_DynPage(panel) { let result = {hasAd: undefined, commentText: '', reason: null} try { debuglog('检查新版动态评论区'); for (let i = 0; i < 5; i++) { result = commentAdDetectorByUI(); if (result.hasAd !== undefined) break; await randomSleep(300); } if (result.hasAd !== undefined) { return result; } throw new Error("新版评论区内部结构不匹配"); } catch (error) { try { const topCommentElement = await waitForElement('.list-item.reply-wrap.is-top .text', 1000, panel); if (!topCommentElement) return result; result.commentText = topCommentElement.textContent.trim(); const links = topCommentElement.querySelectorAll('a.comment-jump-url'); const linkHrefs = Array.from(links).map(link => link.getAttribute('href')); const adDetectionResult = singleFuncForAd({ linkHrefs, commentText: result.commentText }); Object.assign(result, adDetectionResult); log(` 📢 动态页评论区分析: %c${result.hasAd ? '发现广告' : '未发现广告'}`, `color: ${result.hasAd ? '#e67e22' : '#2ecc71'}; font-weight: bold;`); return result; } catch (e) { return result; } } } /** * (修正版) 专门处理B站动态页面的总入口和导航监控函数。 */ async function handleMainDynPageNavigation() { return; } /** (最终版 - 混合动力监控) 专门处理B站空间页面的总入口和导航监控函数。 */ async function handleSpacePageNavigation() { return; } /** (新增) 从内存缓存中【同步地】检查一个视频的广告状态。*/ function checkVideoStatusFromCache(bvNumber) { if (scriptCache.noAdDbKeys.includes(bvNumber)) { return 'noAd'; } if (scriptCache.mainAdDbKeys.includes(bvNumber)) { return 'hasAd'; } return null; } /** 辅助函数:解析B站日期字符串。*/ function parseUploadDate(dateStr) { const now = new Date(); if (dateStr.includes('前')) return now; if (dateStr.includes('昨天')) { const d = new Date(); d.setDate(d.getDate() - 1); return d; } if (/^\d{2}-\d{2}$/.test(dateStr)) return new Date(`${now.getFullYear()}-${dateStr}`); if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return new Date(dateStr); return now; } /** 辅助函数:等待元素加载。*/ function waitForElement(selector, timeout = 5000, parent = document ) { return new Promise((resolve, reject) => { const interval = setInterval(() => { const element = parent.querySelector(selector); if (element) { clearInterval(interval); resolve(element); } }, 200); setTimeout(() => { clearInterval(interval); reject(new Error(`等待元素超时: ${selector}`)); }, timeout); }); } /** (优化版) 根据当前页面类型,【轻量级地】加载数据到内存缓存。*/ async function loadDataForCurrentMode(scriptMode) { const isVideoPage = isTrueVideoPage(); const isUpListPage = window.location.href.includes('/space.bilibili.com/'); // --- 核心修改:统一从GM存储加载缓存摘要 --- if (isVideoPage || isUpListPage) { debuglog(' -> 加载跨域缓存摘要...'); const crossDomainCache = await GM_getValue('biliCrossDomainCache', { mainAdDbKeys: [], noAdDbKeys: [] }); scriptCache.mainAdDbKeys = crossDomainCache.mainAdDbKeys; scriptCache.noAdDbKeys = crossDomainCache.noAdDbKeys; debuglog(` ->广告: ${scriptCache.mainAdDbKeys.length}, 无广: ${scriptCache.noAdDbKeys.length}`); } debuglog(' -> 加载UP白名单...'); whiteList = await GM_getValue('biliUpWhiteList', []); const rawBackup = localStorage.getItem('biliUpWhiteList_Backup'); const whiteListBackup = rawBackup ? JSON.parse(rawBackup) : []; if (whiteListBackup.length > whiteList.length ) { whiteList = whiteListBackup; } debuglog('✅ 数据加载完成'); } /** (新增) 通过view API查询指定BV号是否包含重定向URL */ async function fetchRedirectUrlForBv(bvid) { if (!bvid) return null; const data = await fetchVideoViewData({ bvid }); return data?.redirect_url || null; } async function determineMode() { const scriptMode = window.location.href.includes('/video/BV') ? 'normal' : 'idle'; log(`当前脚本模式: ${scriptMode.toUpperCase()}`); return scriptMode; } function isTrueVideoPage() { const href = window.location.href; const pathname = window.location.pathname; // 规则 1 & 2: 标准的 BV/AV 视频页面 if (pathname.startsWith('/video/BV') || pathname.startsWith('/video/av')) { return true; } // 规则 3: 特殊活动页,但URL中必须带有 bvid 参数 if (pathname.startsWith('/festival/') && new URL(href).searchParams.has('bvid')) { return true; } return false; } /** 步骤三:根据模式和页面类型,执行对应的核心业务逻辑。*/ async function executeMainLogic(scriptMode) { const isBiliSpacePage = window.location.href.includes('space.bilibili.com'); const isUpVideoListPage = window.location.href.match(/space.bilibili.com\/(\d+)\/upload\/video/); const isVideoPage = isTrueVideoPage(); const isDynPage = window.location.href.includes('t.bilibili.com'); const pathWithoutSlash = window.location.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); const path = window.location.pathname; if (pathWithoutSlash !== '' || /^\d{16,}$/.test(pathWithoutSlash)) { debuglog('🌀 非动态首页, todo...'); } log(`模式 [${scriptMode.toUpperCase()}] 执行主逻辑`); if (isVideoPage) { setupNavigationObserver(); } else { debuglog('...空闲模式,无操作'); } } /** 将GM存储中的核心数据,备份到localStorage。*/ async function backupGmStorageToLocalStorage() { log('🛡️ 执行核心数据到 localStorage 的冗余备份...'); try { const biliUpWhiteList = await GM_getValue('biliUpWhiteList', []); const crossDomainCache = await GM_getValue('biliCrossDomainCache', { mainAdDbKeys: [], noAdDbKeys: [] }); const backupData = { biliUpWhiteList, biliCrossDomainCache, backupTimestamp: Date.now() }; localStorage.setItem('BiliAdSkip_GM_Backup', JSON.stringify(backupData)); debuglog(' -> ✅ 备份完成'); } catch(e) { console.error("❌ 核心数据备份失败:", e); } } /** 检查GM存储是否为空,如果为空,则尝试从localStorage的备份中恢复。*/ async function restoreGmStorageFromLocalStorage() { const backupString = localStorage.getItem('BiliAdSkip_GM_Backup'); if (!backupString) return; try { const backupData = JSON.parse(backupString); if (backupData.biliUpWhiteList) { await GM_setValue('biliUpWhiteList', backupData.biliUpWhiteList); } if (backupData.biliCrossDomainCache) { await GM_setValue('biliCrossDomainCache', backupData.biliCrossDomainCache); } log('✅ 成功从 localStorage 恢复轻量版核心数据'); } catch (e) { console.error("❌ 从备份恢复数据失败:", e); } } /** (修复版) 导出所有GM存储数据(包含视频详情)为一个JSON文件,并触发下载。*/ async function exportAllDataAsJson() { log('🛡️ 准备导出数据库备份 ...'); try { const allKeys = await GM_listValues(); const backupData = { // 元数据,方便版本管理 __meta__: { timestamp: Date.now(), exportVersion: 2, userAgent: navigator.userAgent }, videoData: {}, }; // 【核心修改】扩大了忽略列表,过滤掉所有系统状态标记 const ignoreKeys = [ // 临时运行状态 'biliScriptModeBeacon', 'biliUpScanState', 'bili_wbi_keys', 'BiliAdSkip_TransitCache', // 历史迁移与维护标记 'bili_ls_migrated_v2', ]; let videoCount = 0; let configCount = 0; await Promise.all(allKeys.map(async (key) => { if (ignoreKeys.includes(key)) return; const value = await GM_getValue(key, null); if (value === null) return; if (key.startsWith('BV')) { backupData.videoData[key] = value; videoCount++; } else { backupData[key] = value; configCount++; } })); if (videoCount === 0 && configCount === 0) { debuglog("没有找到任何可备份的数据!"); return; } // 导出文件 const jsonString = JSON.stringify(backupData, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const timestamp = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-'); a.download = `BiliAdSkip_Backup_v2_${timestamp}.json`; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); log(`✅ 备份成功!包含 ${videoCount} 个视频数据,${configCount} 项配置`); } catch (e) { console.error("❌ 导出数据时发生错误:", e); } } // ============================================================= // =================== B站加密弹幕本地解析模块 =================== // ============================================================= /** * (核心) 提供B站弹幕Protobuf消息的JSON定义。 * 这是解码 seg.so 文件的“说明书”。 * @returns {object} Protobuf的JSON描述对象。 */ function getDanmakuProtoDefinition() { return { "nested": { "bilibili": { "nested": { "community": { "nested": { "service": { "nested": { "dm": { "nested": { "v1": { "nested": { "DmSegMobileReply": { "fields": { "elems": { "rule": "repeated", "type": "DanmakuElem", "id": 1 } } }, "DanmakuElem": { "fields": { "id": { "type": "int64", "id": 1 }, "progress": { "type": "int32", "id": 2 }, "mode": { "type": "int32", "id": 3 }, "fontsize": { "type": "int32", "id": 4 }, "color": { "type": "uint32", "id": 5 }, "midHash": { "type": "string", "id": 6 }, "content": { "type": "string", "id": 7 }, "ctime": { "type": "int64", "id": 8 }, "weight": { "type": "int32", "id": 9 }, "action": { "type": "string", "id": 10 }, "pool": { "type": "int32", "id": 11 }, "idStr": { "type": "string", "id": 12 } } } } } } } } } } } } } } }; } /** * (核心) 解码 Protobuf 格式的弹幕数据。 * @param {ArrayBuffer} arrayBuffer - 从 seg.so 文件获取的原始二进制数据。 * @returns {Promise<Array<object>|null>} 返回一个包含弹幕对象的数组,或在失败时返回null。 */ async function decodeDanmakuSo(arrayBuffer) { if (!decodeDanmakuSo.protoRoot) { try { const protoJson = getDanmakuProtoDefinition(); decodeDanmakuSo.protoRoot = Root.fromJSON(protoJson); } catch (e) { console.error("❌ 加载弹幕 Protobuf 定义失败:", e); return null; } } try { const DmSegMobileReply = decodeDanmakuSo.protoRoot.lookupType("bilibili.community.service.dm.v1.DmSegMobileReply"); const decodedMessage = DmSegMobileReply.decode(new Uint8Array(arrayBuffer)); const resultObject = DmSegMobileReply.toObject(decodedMessage, { defaults: true }); return resultObject.elems || []; } catch (e) { console.error("❌ Protobuf 弹幕解码失败:", e); return null; } } /** (最终版) 处理解码后的弹幕数组,并将其送入弹幕分析引擎。*/ function processDecodedDanmakus(danmakus) { if (!danmakus || danmakus.length === 0) return; debuglog(`🔓弹幕: ${danmakus.length}`); let count = 0; for (const dm of danmakus) { const text = dm.content; if (!text) continue; if (ANALYZE_DNAMAKU && ANALYZE_DNAMAKU === text) { count ++; } const result = extractTimestamps(text); if (result) { const ts = result.timestamp; const savedAt = dm.progress / 1000; const shouldSave = savedAt >= 15 && ((ts > savedAt && ts - savedAt <= 240 && ts - savedAt > 10) || (ts <= savedAt && savedAt - ts < 8)); if (!shouldSave) continue; let clusterKey = null; for (const existingTsKey in state.danmakuTimestampStore) { const existingTs = Number(existingTsKey); if (Math.abs(ts - existingTs) <= TIME_GROUP_THRESHOLD) { clusterKey = existingTs; break; } } if (clusterKey === null) { clusterKey = ts; state.danmakuTimestampStore[clusterKey] = []; } const occurrence = { savedAt, count: result.isAdTs ? 2 : 1 }; state.danmakuTimestampStore[clusterKey].push(occurrence); debuglog(`📥采集: ${formatTime(savedAt)} -> [${formatTime(ts)}]`); } } if (ANALYZE_DNAMAKU ) { debuglog('弹幕匹配数量:', ANALYZE_DNAMAKU, count); } } /** 视频页面特殊机制,无法直接捕获原始数据,有待改进 * (新增) 核心:处理从API拦截到的评论JSON数据。 */ async function processTopComment(top_replies) { const analysisResult = analyzeCommentJson(top_replies); if (!analysisResult) { debuglog("评论数据中,未找到有效的UP主置顶评论。"); state.commentAnalysisResult = { hasAd: false, commentText: '', goods: '' }; return; } state.commentAnalysisResult = { hasAd: analysisResult.hasAd, commentText: analysisResult.commentText, goods: analysisResult.goods }; log(` -> 评论区API(拦截器)分析结论: hasAd = ${analysisResult.hasAd}, 商品 = ${analysisResult.goods || '无'}`); } /** 处理从API拦截到的原始AI字幕JSON数据。*/ function processSubtitleJson(subtitleJson) { if (!subtitleJson || !Array.isArray(subtitleJson.body) || subtitleJson.body.length === 0) { log('🚫 无效的AI字幕JSON数据或字幕内容为空。'); return []; } debuglog(`✅ 解析AI字幕 ${subtitleJson.body.length} 条`); const formattedSubtitles = subtitleJson.body.map(item => { const startStr = formatTimeTenths(item.from); const endStr = formatTimeTenths(item.to); return `${startStr}-${endStr} ${item.content}`; }); return formattedSubtitles; } /** * (核心接口) 统一获取B站 /x/web-interface/view API的数据。 带cookie */ const fetchVideoViewData = (() => { // 创建一个在此函数作用域内持久存在的私有会话缓存 const viewApiCache = new Map(); return async function({ bvid, aid }) { if (!bvid && !aid) { console.error("[ViewAPI] 必须提供 bvid 或 aid。"); return null; } // 1. 确定API URL和缓存键 const isByBvid = !!bvid; const cacheKey = isByBvid ? `bvid_${bvid}` : `aid_${aid}`; const apiUrl = `https://api.bilibili.com/x/web-interface/view?${isByBvid ? `bvid=${bvid}` : `aid=${aid}`}`; // 2. 检查会话缓存 if (viewApiCache.has(cacheKey)) { return viewApiCache.get(cacheKey); } // 3. 发送网络请求 try { // 【核心修复】添加 credentials: 'include' 选项,强制fetch请求携带Cookie const response = await fetch(apiUrl, { credentials: 'include' }); if (!response.ok) { throw new Error(`请求失败,状态码: ${response.status}`); } const result = await response.json(); if (result.code !== 0) { if (result.code === -404 || result.code === 62002) { debuglog(`[ViewAPI] 视频 ${bvid || aid} 不可见 (code: ${result.code})`); } else { console.warn(`[ViewAPI] API for ${bvid || aid} 返回错误: ${result.message}`); } return null; } const videoData = result.data; if (videoData) { // 4. 成功后,将结果存入缓存 viewApiCache.set(cacheKey, videoData); return videoData; } else { throw new Error('API响应中未找到 data 字段'); } } catch (error) { console.error(`[ViewAPI] 请求 ${apiUrl} 时发生错误:`, error); return null; } }; })(); /** (GM存储版) 辅助函数:通过视频信息API获取评论区所需的 OID。 */ async function getOidFromApi(bvid) { if (!bvid) return null; try { let cachedData = await GM_getValue(bvid, {}); if (typeof cachedData === 'string') { try { cachedData = JSON.parse(cachedData); } catch(e) { cachedData = {}; } } if (cachedData && cachedData.aid) { return cachedData.aid.toString(); } // 2. 缓存未命中,调用统一接口 const data = await fetchVideoViewData({ bvid }); if (data && data.aid) { const oidStr = data.aid.toString(); cachedData.aid = oidStr; await GM_setValue(bvid, cachedData); return oidStr; } } catch (e) { console.error(`[getOidFromApi] 操作 GM 存储或请求失败:`, e); } console.error(`[getOidFromApi] 未能为 ${bvid} 获取到 OID`); return null; } /*** (带详细日志) 通过模拟B站客户端的加载逻辑,获取一个视频的全部弹幕。 */ async function fetchAllDanmaku({ aid, cid, duration, danmakuCount, segmentLimit = Infinity }) { if (!aid || !cid || !duration) { console.error("[DanmakuFetcher] 必须提供 aid, cid, 和 duration。"); return []; } // 使用Map进行去重,key为弹幕的idStr,value为弹幕对象 const allDanmakuMap = new Map(); const segmentDuration = 360; // B站每个弹幕分段的标准时长为6分钟 (360秒) const totalSegments = Math.ceil(duration / segmentDuration); const effectiveTotalSegments = Math.min(totalSegments, segmentLimit); //随机暂停,50~100ms log(`🤫 [弹幕] 获取前 ${effectiveTotalSegments } 个分包 (时长: ${formatTime(duration)})`); // 创建一个可重用的、用于获取单个弹幕分片的内部函数 const fetchAndProcessSegment = async (segmentIndex, startTimeMs, endTimeMs) => { try { const baseParams = { type: 1, oid: cid, pid: aid, segment_index: segmentIndex, web_location: gState.deviceFingerprint?.web_location || 1315873 }; // 只有在提供了起始和结束时间时,才加入参数 if (startTimeMs !== undefined && endTimeMs !== undefined) { baseParams.ps = startTimeMs; baseParams.pe = endTimeMs; baseParams.pull_mode = 1; // 模仿B站逻辑 } const signedParams = await wbiSigner.sign(baseParams); const apiUrl = new URL('https://api.bilibili.com/x/v2/dm/wbi/web/seg.so'); for (const key in signedParams) { apiUrl.searchParams.set(key, signedParams[key]); } const response = await gState.originalFetch.call(gState.pageWindow, apiUrl.toString(), { credentials: 'include' }); if (!response.ok) throw new Error(`API response not OK: ${response.status}`); const buffer = await response.arrayBuffer(); const decodedElems = await decodeDanmakuSo(buffer); // 【核心新增】详细日志输出 let logContext = `${segmentIndex}`; if (startTimeMs !== undefined) { logContext += `(${startTimeMs / 1000}-${endTimeMs / 1000})`; } else { logContext += ``; } const receivedCount = decodedElems ? decodedElems.length : 0; log(`-> [弹幕] 分包 ${logContext}: %c${receivedCount}%c 条`, 'color: #3498db; font-weight: bold;', 'color: initial;'); if (decodedElems && decodedElems.length > 0) { decodedElems.forEach(elem => { if (elem.idStr && !allDanmakuMap.has(elem.idStr)) { allDanmakuMap.set(elem.idStr, elem); } }); } } catch (err) { let errorContext = `分包 ${segmentIndex}`; if (startTimeMs !== undefined) errorContext += ` (时间: ${startTimeMs/1000}s-${endTimeMs/1000}s)`; console.error(`[DanmakuFetcher] 获取弹幕 ${errorContext} 时失败:`, err); } }; // 【核心修复】将 Promise.all 并发模型,修改为 for...await 的串行模型 const initialSegmentDuration = 120; // 1. 请求分包1 if (effectiveTotalSegments >= 1) { if (duration > 0) { await fetchAndProcessSegment(1, 0, initialSegmentDuration * 1000); await randomSleep(75, 25); } if (duration > initialSegmentDuration) { await fetchAndProcessSegment(1, initialSegmentDuration * 1000, segmentDuration * 1000); await randomSleep(75, 25); } } // 2. 循环请求后续分包,直到达到上限 for (let i = 2; i <= effectiveTotalSegments; i++) { await fetchAndProcessSegment(i); if (i < effectiveTotalSegments) { await randomSleep(75, 25); } } const allDanmakuElems = Array.from(allDanmakuMap.values()); log(`✅ [弹幕] 共获取 ${allDanmakuElems.length}/${danmakuCount || 'unknown'} 条`); return allDanmakuElems; } /** * (全新精简版) 通过B站官方API获取视频评论区数据。 * - 使用无需WBI签名的 /x/v2/reply 接口。 * - 依赖 Cookie (SESSDATA) 进行认证。 */ async function fetchBilibiliComments({aid}) { if (!aid) { console.error("[fetchBilibiliComments] 必须提供 aid。"); return null; } // --- 主函数逻辑 --- try { const oid = aid.toString(); // 步骤 2: 构造新的、简单的 API 请求 URL const apiUrl = new URL('https://api.bilibili.com/x/v2/reply'); // 添加必要的URL参数 apiUrl.searchParams.set('oid', oid); apiUrl.searchParams.set('type', '1'); apiUrl.searchParams.set('sort', '1');// num 排序方式 非必要 默认为0,0:按时间,1:按点赞数,2:按回复数 //apiUrl.searchParams.set('mode', '3'); //apiUrl.searchParams.set('pn', '1'); //apiUrl.searchParams.set('ps', '20'); // 步骤 3: 发送请求 (无需任何 WBI 或 自定义 Header) // 需要附加 Cookie (SESSDATA) const response = await fetch(apiUrl.toString(),{ credentials: 'include' }); if (!response.ok) { throw new Error(`请求评论API失败: ${response.status}`); } const data = await response.json(); if (data.code !== 0) { throw new Error(`评论API返回错误: ${data.message} (code: ${data.code})`); } debuglog('✅ 评论数据: ', data.data); return data.data; } catch (error) { console.error(`[fetchBilibiliComments] 发生错误:`, error); return null; } } // --- WBI 签名模块 --- const wbiSigner = { // 缓存 WBI keys wbiKeys: null, // 1. 获取 img_key 和 sub_key async getWbiKeys() { // 检查缓存 const cacheKey = 'bili_wbi_keys'; const cachedData = localStorage.getItem(cacheKey); if (cachedData) { const { keys, timestamp } = JSON.parse(cachedData); // 缓存有效期6小时 if (Date.now() - timestamp < 6 * 60 * 60 * 1000) { this.wbiKeys = keys; return keys; } } try { const response = await fetch('https://api.bilibili.com/x/web-interface/nav'); if (!response.ok) throw new Error('获取WBI密钥失败'); const { data } = await response.json(); const imgUrl = data.wbi_img.img_url; const subUrl = data.wbi_img.sub_url; const keys = { imgKey: imgUrl.substring(imgUrl.lastIndexOf('/') + 1, imgUrl.lastIndexOf('.')), subKey: subUrl.substring(subUrl.lastIndexOf('/') + 1, subUrl.lastIndexOf('.')), }; this.wbiKeys = keys; localStorage.setItem(cacheKey, JSON.stringify({ keys, timestamp: Date.now() })); return keys; } catch (error) { console.error('获取WBI密钥时出错:', error); return null; } }, // 2. 实现文档中的 getMixinKey 算法 getMixinKey(imgKey, subKey) { const mixinKeyEncTab = [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52 ]; const s = imgKey + subKey; let mixinKey = ''; for (const i of mixinKeyEncTab) { mixinKey += s[i]; } return mixinKey.slice(0, 32); }, // 3. 主签名函数 async sign(params) { if (!this.wbiKeys) { await this.getWbiKeys(); } if (!this.wbiKeys) { throw new Error("无法获取WBI密钥,无法签名"); } const mixinKey = this.getMixinKey(this.wbiKeys.imgKey, this.wbiKeys.subKey); const currTime = Math.round(Date.now() / 1000); const signedParams = { ...params, wts: currTime }; // 排序并编码 const query = Object.keys(signedParams) .sort() .map(key => { // 过滤特殊字符 const value = signedParams[key].toString().replace(/[!'()*]/g, ''); return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; }) .join('&'); const w_rid = window.md5(query + mixinKey); return { ...signedParams, w_rid }; } }; /** * (最终版-签名版) 通过B站官方API获取视频评论区数据。 * @returns {Promise<object|null>} 成功时返回评论API的data对象,失败时返回null。 */ async function fetchBilibiliComments_WBI({ aid }) { if (!aid) { console.error("[fetchBilibiliComments_WBI] 必须提供 aid。"); return null; } try { const oid = aid.toString(); const baseParams = { oid: oid, type: 1, mode: 3, //pagination_str: JSON.stringify({ offset: "" }), plat: 1, }; /* oid num 目标评论区 id 必要 type num 评论区类型代码 必要 类型代码:1视频稿件;2话题 ;4活动;12专栏 mode num 排序方式 非必要 默认为 3:仅按热度 1:按热度+按时间 2:仅按时间 next num 翻页 非必要 不推荐, 已弃用, 优先级比 pagination_str 高 plat num 平台类型 非必要 如 1 pagination_str obj 分页信息 非必要 */ const signedParams = await wbiSigner.sign(baseParams); const apiUrl = new URL('https://api.bilibili.com/x/v2/reply/wbi/main'); for (const key in signedParams) { apiUrl.searchParams.set(key, signedParams[key]); } const response = await gState.originalFetch(apiUrl.toString()); if (!response.ok) { throw new Error(`请求评论API失败,状态码: ${response.status}`); } const data = await response.json(); if (data.code !== 0) { throw new Error(`评论API返回错误: ${data.message} (code: ${data.code})`); } // 返回完整的响应,让调用者自己决定用 data.data return data; } catch (error) { console.error(`[fetchBilibiliComments_WBI] 发生错误:`, error); return null; } } // --- 核心功能:Fetch 拦截 --- function hookFetch() { if (gState.isFetchHooked) return; const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; const origin = pageWindow.fetch; if (!origin) { debuglog(`pageWindow.fetch is not available.`); return; } gState.originalFetch = origin.bind(pageWindow); pageWindow.fetch = function(url, options) { const fetchPromise = origin.apply(pageWindow, arguments); const isSpaceVideoAPI = url && typeof url === 'string' && url.includes("/x/space/wbi/arc/search"); if (isSpaceVideoAPI) { debuglog(`抓取页面请求设备指纹...`); try { const fullUrl = url.startsWith('//') ? 'https:' + url : url; const urlParams = new URL(fullUrl).searchParams; gState.deviceFingerprint = { dm_img_list: urlParams.get('dm_img_list') || '[]', dm_img_str: urlParams.get('dm_img_str') || '', dm_cover_img_str: urlParams.get('dm_cover_img_str') || '', dm_img_inter: urlParams.get('dm_img_inter') || '{}', web_location: urlParams.get('web_location') || '' }; //debuglog(`获取设备指纹成功:`, gState.deviceFingerprint); localStorage.setItem('biliAdSkip_deviceFingerprint_cache', JSON.stringify(gState.deviceFingerprint)); debuglog(' -> 已缓存设备指纹到 localStorage'); } catch (err) { console.error(`获取设备指纹失败:`, err); } } return fetchPromise; }; gState.isFetchHooked = true; debuglog(`Fetch hooked successfully.`); } // --- 新增:封装 GM_xmlhttpRequest 以绕过 CSP --- function gmFetch(url, options = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ url: url, method: options.method || 'GET', headers: options.headers || {}, data: options.body, onload: (response) => { resolve({ ok: response.status >= 200 && response.status < 300, status: response.status, statusText: response.statusText, text: () => Promise.resolve(response.responseText), json: () => Promise.resolve(JSON.parse(response.responseText)) }); }, onerror: (error) => reject(error) }); }); } /** 【网络拦截器】 - 最终诊断版 */ function installNetworkInterceptor() { if (window.isUltimateInterceptorInstalled) { return; } else { window.isUltimateInterceptorInstalled = true; } try { const originalXHR_open = window.XMLHttpRequest.prototype.open; const originalXHR_send = window.XMLHttpRequest.prototype.send; window.XMLHttpRequest.prototype.open = function(method, url, ...args) { this._url = url; return originalXHR_open.apply(this, [method, url, ...args]); }; window.XMLHttpRequest.prototype.send = function(...args) { this.addEventListener('load', async function() { if (typeof this._url === 'string') { if (this._url.includes('api.bilibili.com/x/v2/dm/wbi/web/seg.so')) { //log(`✅ 捕获【弹幕】请求...`); if (this.response instanceof ArrayBuffer) { const decoded = await decodeDanmakuSo(this.response); if (decoded) await processDecodedDanmakus(decoded); } } else if (this._url.includes('api.bilibili.com/x/v2/reply/wbi/main')) { log(`✅ 捕获’评论‘请求...`); try { const commentJson = JSON.parse(this.responseText); const top_replies = commentJson?.data?.top_replies; await processTopComment(top_replies); } catch (e) { console.error("❌ XHR解析评论JSON失败:", e); } } else if (this._url.includes('aisubtitle.hdslb.com/bfs/ai_subtitle/')) { debuglog(`✅ 捕获’字幕‘请求...`); try { const subtitleJson = JSON.parse(this.responseText); const formattedSubtitles = processSubtitleJson(subtitleJson); if (subtitlePromiseResolver) { subtitlePromiseResolver(formattedSubtitles); subtitlePromiseResolver = null; } } catch (e) { console.error("❌ (XHR) 解析AI字幕JSON失败:", e); if (subtitlePromiseResolver) { subtitlePromiseResolver([]); subtitlePromiseResolver = null; } } } } }); return originalXHR_send.apply(this, args); }; } catch (e) { console.error(`安装XHR拦截器失败:`, e); } } async function fetchConfigFromGit() { let lastError = null; const gitMirror = [ 'https://cdn.jsdelivr.net/gh/chemhunter/biliadskip@main/biliadwordslinks.json', 'https://raw.githubusercontent.com/chemhunter/biliadskip/main/biliadwordslinks.json', ]; for (const source of gitMirror) { const url = `${source}?t=${Date.now()}`; try { const response = await fetch(url); if (!response.ok) {throw new Error(`HTTP错误! 状态码: ${response.status}`)}; const text = await response.text(); try { const configData = JSON.parse(text); debuglog(`✅ 获取到广告基础配置from git镜像: ${source} `); return configData; } catch (parseError) { throw new Error(`JSON解析失败: ${parseError.message}`); } } catch (error) { lastError = error; continue;} } throw new Error(`所有镜像源均无法访问: ${lastError?.message || '未知错误'}`); } async function getConfigWithFallback(maxRetries = 1) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const res = await fetchConfigFromGit(); return res; } catch (error) { console.error(`尝试 ${attempt} 失败:`, error.message); if (attempt === maxRetries) { console.warn('⚠️ 所有尝试均失败,使用默认配置'); return null; } await randomSleep(500 * attempt); } } return; } async function getScriptLocalConfig() { try { const localConfig = await GM_getValue("localConfig", null); const backupTime = await GM_getValue("backupTime") || 0; const lastBackupPassed = Date.now() - backupTime; const intervalDays = Math.max(Math.floor(backupIntervals ?? 3), 1); const ONE_DAY_MS = 24 * 3600 * 1000; if (lastBackupPassed > intervalDays * ONE_DAY_MS) { log(`每${backupIntervals}天备份一次GM存储`); await GM_setValue("backupTime", Date.now()); await exportAllDataAsJson(); } else { log(`本周期已备份GM存储,跳过`); } let fetchSuccess = false; const lastUpdatePassed = Date.now() - (localConfig?.time || 0); if (FORCE_GIT_CONFIG || lastUpdatePassed > ONE_DAY_MS) { const res = await getConfigWithFallback(); if (res) { debuglog(`⚙️ 云端配置更新成功:`, res); biliAdWordsConfig = { ...res, keywordStr: Object.values(res.keywordStr).join('|'), time: Date.now() }; await GM_setValue("localConfig", biliAdWordsConfig); fetchSuccess = true; } else { log('⚠️ 云端配置获取失败,将回退使用本地或默认配置'); } } if (!fetchSuccess) { log(`📁 使用本地/默认广告词配置`); if (localConfig && localConfig.time && localConfig.keywordStr) { biliAdWordsConfig = localConfig; } else { biliAdWordsConfig = defaultConfig; await GM_setValue("localConfig", biliAdWordsConfig); } } } catch (error) { console.error('配置加载流程异常,使用兜底默认配置:', error); biliAdWordsConfig = defaultConfig; } const safeKeywordStr = biliAdWordsConfig?.keywordStr || defaultConfig.keywordStr; keywordRegex = new RegExp(safeKeywordStr.replace(/\s+/g, ''), 'i'); } // ================================================= // =========== 智能数据迁移模块 (支持合并) =========== // ================================================= async function migrateLocalStorageToGM() { if (localStorage.getItem('bili_ls_migrated_v2')) return; log(`📦 [${location.hostname}] 检测到未迁移数据,开始增量合并到数据库...`); let count = 0; let mergeCount = 0; const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith('BV')) { try { const lsValStr = localStorage.getItem(key); if (!lsValStr) continue; const lsData = JSON.parse(lsValStr); let gmData = await GM_getValue(key, null); if (gmData && typeof gmData === 'string') { try { gmData = JSON.parse(gmData); } catch(e){} } if (!gmData) { await GM_setValue(key, lsData); count++; } else { let needsUpdate = false; if (lsData.aid && !gmData.aid) { gmData.aid = lsData.aid; needsUpdate = true; } if (lsData.timestamps) { if (!gmData.timestamps) gmData.timestamps = {}; for (const source in lsData.timestamps) { if (!gmData.timestamps[source]) { gmData.timestamps[source] = lsData.timestamps[source]; needsUpdate = true; } } } if (lsData.noAd && !gmData.noAd && !gmData.timestamps) { gmData.noAd = true; needsUpdate = true; } if (needsUpdate) { await GM_setValue(key, gmData); mergeCount++; } } keysToRemove.push(key); } catch (e) { console.error('迁移合并失败:', key, e); } } } keysToRemove.forEach(key => localStorage.removeItem(key)); localStorage.setItem('bili_ls_migrated_v2', 'true'); if (count > 0 || mergeCount > 0) { log(`✅ [${location.hostname}] 迁移完成!新增: ${count} 条,合并/补全: ${mergeCount} 条。`); } else { log(`✅ [${location.hostname}] 检查完成,无需迁移。`); } } async function main() { log('🚀 执行脚本主程序...') await getScriptLocalConfig(); const Mode = await determineMode(); await loadDataForCurrentMode(Mode); await executeMainLogic(Mode); log('✅ 初始化流程执行完毕'); } hookFetch(); installNetworkInterceptor(); if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', main, { once: true }); } else { main(); } registerMenuUI("🤖管理AI配置", 'bili-ad-skipper-ai-config-popup', setupAiConfigUI); registerMenuUI("⌚管理时间戳", 'bili-ad-timestamp-editor', manualAdTimestamps); registerMenuUI("📋管理白名单", 'UpWhiteListContainer', monitorUpWhiteList); })();