VideoAdGuard

基于大语言模型检测B站视频中的植入广告

当前为 2025-04-03 提交的版本,查看 最新版本

// ==UserScript==
// @name         VideoAdGuard
// @namespace    https://github.com/Warma10032/VideoAdGuard
// @version      1.1.0
// @description  基于大语言模型检测B站视频中的植入广告
// @author       Warma10032
// @match        *://*.bilibili.com/video/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      api.bilibili.com
// @connect      open.bigmodel.cn
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 存储设置的默认值
    const DEFAULT_API_URL = 'https://open.bigmodel.cn/api/paas/v4/chat/completions';
    const DEFAULT_MODEL = 'glm-4-flash';
    
    // 工具类 - WBI 签名
    const WbiUtils = {
        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
        ],
        
        getMixinKey(orig) {
            return this.mixinKeyEncTab
                .map(i => orig[i])
                .join('')
                .slice(0, 32);
        },
        
        // 简化版的 MD5 函数 (需要外部库支持)
        md5(text) {
            // 简单实现,实际使用时可能需要引入外部库
            let hash = 0;
            for (let i = 0; i < text.length; i++) {
                hash = ((hash << 5) - hash) + text.charCodeAt(i);
                hash |= 0;
            }
            return hash.toString(16);
        },
        
        async getWbiKeys() {
            const wbiCache = GM_getValue('wbi_cache');
            const today = new Date().setHours(0, 0, 0, 0);
            
            if (wbiCache && wbiCache.timestamp >= today) {
                return [wbiCache.img_key, wbiCache.sub_key];
            }
            
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: 'https://api.bilibili.com/x/web-interface/nav',
                    headers: {
                        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
                        'Referer': 'https://www.bilibili.com/'
                    },
                    onload: (response) => {
                        try {
                            const data = JSON.parse(response.responseText);
                            if (data.code !== 0) {
                                reject(new Error(data.message));
                                return;
                            }
                            
                            const imgUrl = data.data.wbi_img.img_url;
                            const subUrl = data.data.wbi_img.sub_url;
                            
                            const imgKey = imgUrl.substring(imgUrl.lastIndexOf('/') + 1, imgUrl.lastIndexOf('.'));
                            const subKey = subUrl.substring(subUrl.lastIndexOf('/') + 1, subUrl.lastIndexOf('.'));
                            
                            const cache = {
                                img_key: imgKey,
                                sub_key: subKey,
                                timestamp: today
                            };
                            
                            GM_setValue('wbi_cache', cache);
                            resolve([imgKey, subKey]);
                        } catch (error) {
                            reject(error);
                        }
                    },
                    onerror: reject
                });
            });
        },
        
        async encWbi(params) {
            const [imgKey, subKey] = await this.getWbiKeys();
            const mixinKey = this.getMixinKey(imgKey + subKey);
            const currTime = Math.floor(Date.now() / 1000);
            
            const newParams = {
                ...params,
                wts: currTime
            };
            
            // 按照key排序
            const query = Object.keys(newParams)
                .sort()
                .map(key => {
                    // 过滤特殊字符
                    const value = newParams[key].toString()
                        .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
                        .replace(/[&?:\/=]/g, '');
                    return `${key}=${encodeURIComponent(value)}`;
                })
                .join('&');
            
            const wbiSign = this.md5(query + mixinKey);
            return {
                ...newParams,
                w_rid: wbiSign
            };
        }
    };
    
    // B站服务类
    const BilibiliService = {
        async fetchWithCookie(url, params = {}) {
            const queryString = new URLSearchParams(params).toString();
            const fullUrl = `${url}?${queryString}`;
            console.log('[BilibiliService] Fetching URL:', fullUrl);
            
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: fullUrl,
                    headers: {
                        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
                        'Referer': 'https://www.bilibili.com/'
                    },
                    onload: (response) => {
                        try {
                            const data = JSON.parse(response.responseText);
                            console.log('[BilibiliService] Response data:', data);
                            if (data.code !== 0) {
                                reject(new Error(data.message));
                                return;
                            }
                            resolve(data.data);
                        } catch (error) {
                            reject(error);
                        }
                    },
                    onerror: reject
                });
            });
        },
        
        async getVideoInfo(bvid) {
            console.log('[BilibiliService] Getting video info for bvid:', bvid);
            const data = await this.fetchWithCookie(
                'https://api.bilibili.com/x/web-interface/view',
                { bvid }
            );
            console.log('[BilibiliService] Video info result:', data);
            return data;
        },
        
        async getComments(bvid) {
            console.log('[BilibiliService] Getting comments for bvid:', bvid);
            const data = await this.fetchWithCookie(
                'https://api.bilibili.com/x/v2/reply',
                { oid: bvid, type: 1 }
            );
            console.log('[BilibiliService] Comments result:', data);
            return data;
        },
        
        async getPlayerInfo(bvid, cid) {
            console.log('[BilibiliService] Getting player info for bvid:', bvid, 'cid:', cid);
            const params = { bvid, cid };
            const signedParams = await WbiUtils.encWbi(params);
            const data = await this.fetchWithCookie(
                'https://api.bilibili.com/x/player/wbi/v2',
                signedParams
            );
            console.log('[BilibiliService] Player info result:', data);
            return data;
        },
        
        async getCaptions(url) {
            console.log('[BilibiliService] Getting captions from URL:', url);
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    onload: (response) => {
                        try {
                            const data = JSON.parse(response.responseText);
                            console.log('[BilibiliService] Captions result:', data);
                            resolve(data);
                        } catch (error) {
                            reject(error);
                        }
                    },
                    onerror: reject
                });
            });
        }
    };
    
    // AI 服务类
    const AIService = {
        async analyze(videoInfo) {
            console.log('开始分析视频信息:', videoInfo);
            const apiKey = this.getApiKey();
            if (!apiKey) {
                throw new Error('未设置API密钥');
            }
            console.log('成功获取API密钥');
            
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'POST',
                    url: this.getApiUrl(),
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `Bearer ${apiKey}`
                    },
                    data: JSON.stringify({
                        model: this.getModel(),
                        messages: [
                            {
                                'role': 'system',
                                'content': '你是一个敏感的视频观看者,能根据视频的连贯性改变和宣传推销类内容,找出视频中可能存在的植入广告。内容如果和主题相关,即使是推荐/评价也可能只是分享而不是广告,重点要看有没有提到通过视频博主可以受益的渠道进行购买。'
                            },
                            {
                                'role': 'user',
                                'content': this.buildPrompt(videoInfo)
                            }
                        ],
                        response_format: { 'type': 'json_object' },
                        temperature: 0.1,
                        max_tokens: 1024
                    }),
                    onload: (response) => {
                        try {
                            const data = JSON.parse(response.responseText);
                            console.log('收到API响应:', data);
                            resolve(JSON.parse(data.choices[0].message.content));
                        } catch (error) {
                            reject(error);
                        }
                    },
                    onerror: reject
                });
            });
        },
        
        buildPrompt(videoInfo) {
            console.log('构建提示词,输入数据:', videoInfo);
            const prompt = `视频的标题和置顶评论如下,可供参考判断是否有植入广告。如果置顶评论中有购买链接,则肯定有广告,同时可以根据置顶评论的内容锁定视频中广告的位置。
视频标题:${videoInfo.title}
置顶评论:${videoInfo.topComment || '无'}
下面我会给你这个视频的字幕字典,形式为 index: context. 请你完整地找出其中的植入广告,返回json格式的数据。内容如果和主题相关,即使是推荐/评价也可能只是分享而不是广告,重点要看有没有提到通过视频博主可以受益的渠道进行购买。一般来说视频是没有广告的,但也有小部分会植入一段广告,极少部分是多段广告。
字幕内容:${JSON.stringify(videoInfo.captions)}
先返回'exist': bool。true表示存在植入广告,false表示不存在植入广告。
再返回'index_lists': list[list[int]]。二维数组,行数表示广告的段数,一般来说视频是没有广告的,但也有小部分会植入一段广告,极少部分是多段广告。每一行是长度为2的数组[start, end],表示一段广告的开头结尾,start和end是字幕的index。`;
            
            return prompt;
        },
        
        getApiUrl() {
            return GM_getValue('apiUrl', DEFAULT_API_URL);
        },
        
        getApiKey() {
            return GM_getValue('apiKey', null);
        },
        
        getModel() {
            return GM_getValue('model', DEFAULT_MODEL);
        }
    };
    
    // 广告检测器类
    const AdDetector = {
        adDetectionResult: null,
        adTimeRanges: [],
        
        async getCurrentBvid() {
            const match = window.location.pathname.match(/\/video\/(BV[\w]+)/);
            if (!match) throw new Error('未找到视频ID');
            return match[1];
        },
        
        async analyze() {
            try {
                // 移除已存在的跳过按钮
                const existingButton = document.querySelector('.skip-ad-button10032');
                if (existingButton) {
                    existingButton.remove();
                }
                
                const bvid = await this.getCurrentBvid();
                
                // 获取视频信息
                const videoInfo = await BilibiliService.getVideoInfo(bvid);
                const comments = await BilibiliService.getComments(bvid);
                const playerInfo = await BilibiliService.getPlayerInfo(bvid, videoInfo.cid);
                
                // 获取字幕
                if (!playerInfo.subtitle?.subtitles?.length) {
                    console.log('无字幕');
                    this.adDetectionResult = '当前视频无字幕,无法检测';
                    return;
                }
                
                const captionsUrl = 'https:' + playerInfo.subtitle.subtitles[0].subtitle_url;
                const captionsData = await BilibiliService.getCaptions(captionsUrl);
                
                // 处理数据
                const captions = {};
                captionsData.body.forEach((caption, index) => {
                    captions[index] = caption.content;
                });
                
                // AI分析
                const result = await AIService.analyze({
                    title: videoInfo.title,
                    topComment: comments.upper?.top?.content?.message || null,
                    captions
                });
                
                if (result.exist) {
                    console.log('检测到广告片段:', JSON.stringify(result.index_lists));
                    const second_lists = this.index2second(result.index_lists, captionsData.body);
                    this.adTimeRanges = second_lists;
                    this.adDetectionResult = `发现${second_lists.length}处广告:${
                        second_lists.map(([start, end]) => `${this.second2time(start)}~${this.second2time(end)}`).join(' | ')
                    }`;
                    // 注入跳过按钮
                    this.injectSkipButton();
                    // 显示通知
                    this.showNotification(this.adDetectionResult);
                } else {
                    this.adDetectionResult = '无广告内容';
                }
                
            } catch (error) {
                console.error('分析失败:', error);
                this.adDetectionResult = '分析失败:' + error.message;
            }
        },
        
        index2second(indexLists, captions) {
            // 直接生成时间范围列表
            const time_lists = indexLists.map(list => {
                const start = captions[list[0]]?.from || 0;
                const end = captions[list[list.length - 1]]?.to || 0;
                return [start, end];
            });
            return time_lists;
        },
        
        second2time(seconds) {
            const hour = Math.floor(seconds / 3600);
            const min = Math.floor((seconds % 3600) / 60);
            const sec = Math.floor(seconds % 60);
            return `${hour > 0 ? hour + ':' : ''}${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
        },
        
        injectSkipButton() {
            const player = document.querySelector('.bpx-player-control-bottom');
            if (!player) return;
            
            const skipButton = document.createElement('button');
            skipButton.className = 'skip-ad-button10032';
            skipButton.textContent = '跳过广告';
            skipButton.style.cssText = `
                font-size: 14px;
                position: absolute;
                right: 20px;
                bottom: 100px;
                z-index: 999;
                padding: 4px 4px;
                color: #000000; 
                font-weight: bold;
                background: rgba(255, 255, 255, 0.7);
                border: none;
                border-radius: 4px;
                cursor: pointer;
            `;
            
            player.appendChild(skipButton);
            
            // 监听视频播放时间
            const video = document.querySelector('video');
            if (!video) {
                console.error('未找到视频元素');
                return;
            }
            
            // 点击跳过按钮
            skipButton.addEventListener('click', () => {
                const currentTime = video.currentTime;
                console.log('当前时间:', currentTime);
                const adSegment = this.adTimeRanges.find(([start, end]) => 
                    currentTime >= start && currentTime < end
                );
                
                if (adSegment) {
                    video.currentTime = adSegment[1]; // 跳到广告段结束时间
                    console.log('跳转时间:', adSegment[1]);
                }
            });
        },
        
        showNotification(message) {
            const notification = document.createElement('div');
            notification.style.cssText = `
                position: fixed;
                top: 20px;
                right: 20px;
                background: rgba(0, 0, 0, 0.7);
                color: white;
                padding: 10px 15px;
                border-radius: 4px;
                z-index: 9999;
                max-width: 300px;
            `;
            notification.textContent = message;
            document.body.appendChild(notification);
            
            setTimeout(() => {
                notification.style.opacity = '0';
                notification.style.transition = 'opacity 0.5s';
                setTimeout(() => notification.remove(), 500);
            }, 5000);
        },
        
        // 添加设置面板
        addSettingsButton() {
            const settingsButton = document.createElement('button');
            settingsButton.textContent = '⚙️';
            settingsButton.style.cssText = `
                position: fixed;
                bottom: 20px;
                right: 20px;
                width: 40px;
                height: 40px;
                border-radius: 50%;
                background: rgba(0, 0, 0, 0.7);
                color: white;
                border: none;
                font-size: 20px;
                cursor: pointer;
                z-index: 9999;
            `;
            
            document.body.appendChild(settingsButton);
            
            settingsButton.addEventListener('click', () => {
                this.showSettingsPanel();
            });
        },
        
        showSettingsPanel() {
            // 移除已存在的面板
            const existingPanel = document.querySelector('.vag-settings-panel');
            if (existingPanel) {
                existingPanel.remove();
                return;
            }
            
            const panel = document.createElement('div');
            panel.className = 'vag-settings-panel';
            panel.style.cssText = `
                position: fixed;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                background: white;
                padding: 20px;
                border-radius: 8px;
                box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
                z-index: 10000;
                width: 300px;
            `;
            
            panel.innerHTML = `
                <h3 style="margin-top: 0;">VideoAdGuard 设置</h3>
                <div style="margin-bottom: 15px;">
                    <label style="display: block; margin-bottom: 5px;">API地址:</label>
                    <input type="text" id="vag-api-url" style="width: 100%; padding: 5px; box-sizing: border-box;" 
                           value="${AIService.getApiUrl()}">
                </div>
                <div style="margin-bottom: 15px;">
                    <label style="display: block; margin-bottom: 5px;">API密钥:</label>
                    <input type="password" id="vag-api-key" style="width: 100%; padding: 5px; box-sizing: border-box;" 
                           value="${AIService.getApiKey() || ''}">
                </div>
                <div style="margin-bottom: 15px;">
                    <label style="display: block; margin-bottom: 5px;">模型名称:</label>
                    <input type="text" id="vag-model" style="width: 100%; padding: 5px; box-sizing: border-box;" 
                           value="${AIService.getModel()}">
                </div>
                <div style="display: flex; justify-content: space-between;">
                    <button id="vag-save" style="padding: 8px 15px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;">
                        保存
                    </button>
                    <button id="vag-cancel" style="padding: 8px 15px; background: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer;">
                        取消
                    </button>
                </div>
            `;
            
            document.body.appendChild(panel);
            
            // 保存按钮事件
            document.getElementById('vag-save').addEventListener('click', () => {
                const apiUrl = document.getElementById('vag-api-url').value;
                const apiKey = document.getElementById('vag-api-key').value;
                const model = document.getElementById('vag-model').value;
                
                GM_setValue('apiUrl', apiUrl);
                GM_setValue('apiKey', apiKey);
                GM_setValue('model', model);
                
                this.showNotification('设置已保存');
                panel.remove();
            });
            
            // 取消按钮事件
            document.getElementById('vag-cancel').addEventListener('click', () => {
                panel.remove();
            });
        }
    };
    
    // 初始化
    function init() {
        // 页面加载完成后执行分析
        AdDetector.analyze();
        
        // 添加设置按钮
        AdDetector.addSettingsButton();
        
        // 添加 URL 变化监听
        let lastUrl = location.href;
        new MutationObserver(() => {
            const url = location.href;
            if (url !== lastUrl) {
                lastUrl = url;
                console.log('URL changed:', url);
                AdDetector.analyze();
            }
        }).observe(document, { subtree: true, childList: true });
        
        // 监听 history 变化
        window.addEventListener('popstate', () => {
            console.log('History changed:', location.href);
            AdDetector.analyze();
        });
    }
    
    // 等待页面加载完成
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();

QingJ © 2025

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