Bilibili 快速收藏

在B站视频播放页添加"快速收藏"按钮。完美复刻原生UI风格,不会阻塞视频缩略图加载。新增:右键图片选择收藏夹功能。

// ==UserScript==
// @name         Bilibili 快速收藏
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  在B站视频播放页添加"快速收藏"按钮。完美复刻原生UI风格,不会阻塞视频缩略图加载。新增:右键图片选择收藏夹功能。
// @author       YourName & AliubYiero (Inspired by)
// @match        https://www.bilibili.com/video/*
// @connect      api.bilibili.com
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // --- 配置区域 ---
    let defaultFavId = GM_getValue('BILI_DEFAULT_FAV_ID', null);
    let userFolders = null; // 缓存用户的收藏夹列表

    // --- 核心功能 ---
    function bvToAv(bvid) {
        const XOR_CODE = 23442827791579n;
        const MASK_CODE = 2251799813685247n;
        const BASE = 58n;
        const data = "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf";
        const bvidArr = Array.from(bvid);
        [bvidArr[3], bvidArr[9]] = [bvidArr[9], bvidArr[3]];
        [bvidArr[4], bvidArr[7]] = [bvidArr[7], bvidArr[4]];
        bvidArr.splice(0, 3);
        const tmp = bvidArr.reduce(((pre, bvidChar) => pre * BASE + BigInt(data.indexOf(bvidChar))), 0n);
        return Number(tmp & MASK_CODE ^ XOR_CODE);
    }

    function getCsrf() {
        const cookies = document.cookie.split('; ').join('&');
        const params = new URLSearchParams(cookies);
        return params.get('bili_jct');
    }

    function getAid() {
        if (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.aid) {
            return window.__INITIAL_STATE__.aid.toString();
        }
        try {
            const path = window.location.pathname;
            const match = path.match(/\/video\/(av\d+|BV1[a-zA-Z0-9]+)/);
            if (match && match[1]) {
                let videoId = match[1];
                if (videoId.startsWith('BV')) {
                    return bvToAv(videoId).toString();
                } else if (videoId.startsWith('av')) {
                    return videoId.substring(2);
                }
            }
        } catch (e) { console.error('快速收藏脚本:从URL解析aid时出错', e); }
        console.error('快速收藏脚本:所有方法都无法获取到 aid。');
        return null;
    }

    /**
     * 获取用户的收藏夹列表
     */
    function fetchUserFolders() {
        return new Promise((resolve, reject) => {
            if (userFolders) {
                resolve(userFolders);
                return;
            }

            // 获取当前用户的mid
            const mid = window.__INITIAL_STATE__?.mid || getCookieValue('DedeUserID');
            if (!mid) {
                reject(new Error('无法获取用户ID'));
                return;
            }

            const url = `https://api.bilibili.com/x/v3/fav/folder/created/list-all?up_mid=${mid}`;

            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                headers: {
                    'Referer': 'https://www.bilibili.com'
                },
                responseType: 'json',
                onload: function (response) {
                    const res = response.response;
                    if (res.code === 0 && res.data && res.data.list) {
                        userFolders = res.data.list;
                        resolve(userFolders);
                    } else {
                        reject(new Error('获取收藏夹列表失败'));
                    }
                },
                onerror: function (error) {
                    reject(error);
                }
            });
        });
    }

    /**
     * 获取Cookie值
     */
    function getCookieValue(name) {
        const value = `; ${document.cookie}`;
        const parts = value.split(`; ${name}=`);
        if (parts.length === 2) return parts.pop().split(';').shift();
        return null;
    }

    function doCollect(aid, favId, button) {
        const csrf = getCsrf();
        if (!aid || !favId || !csrf || Number(aid) <= 0) {
            console.error('快速收藏失败:参数无效', { aid, favId, csrf });
            alert('快速收藏失败,无法获取有效的视频ID。');
            const buttonText = button.querySelector('.video-toolbar-item-text');
            if (buttonText) buttonText.textContent = '快收';
            button.disabled = false;
            return;
        }
        const url = 'https://api.bilibili.com/x/v3/fav/resource/deal';
        const postData = new URLSearchParams({ 'rid': aid, 'type': '2', 'add_media_ids': favId, 'del_media_ids': '', 'csrf': csrf });
        GM_xmlhttpRequest({
            method: 'POST',
            url: url,
            headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Referer': 'https://www.bilibili.com' },
            data: postData.toString(),
            responseType: 'json',
            onload: function (response) {
                const res = response.response;
                const buttonText = button.querySelector('.video-toolbar-item-text');
                if (res.code === 0 || res.code === 11015) {
                    console.log(res.code === 0 ? '快速收藏成功!' : '视频已在该收藏夹中。');
                    if (buttonText) buttonText.textContent = '已收';
                    button.classList.add('on'); // 添加高亮样式
                    button.disabled = true;
                } else {
                    console.error('快速收藏失败:', res);
                    alert(`收藏失败:${res.message}`);
                    if (buttonText) buttonText.textContent = '快收';
                    button.disabled = false;
                }
            },
            onerror: function (error) {
                console.error('快速收藏请求错误:', error);
                alert('快速收藏请求发送失败,请检查网络或控制台报错。');
                const buttonText = button.querySelector('.video-toolbar-item-text');
                if (buttonText) buttonText.textContent = '快收';
                button.disabled = false;
            }
        });
    }

    /**
     * 弹出对话框,让用户输入并保存默认收藏夹ID。
     */
    function promptForFavId() {
        const currentId = GM_getValue('BILI_DEFAULT_FAV_ID', '');
        const newId = prompt('请输入你的B站默认收藏夹ID(纯数字):\n\n如何获取ID?\n1. 进入"我的收藏"。\n2. 点击目标收藏夹。\n3. 地址栏中 fid= 后面的数字就是ID。', currentId);

        if (newId !== null) {
            if (newId && /^\d+$/.test(newId)) {
                GM_setValue('BILI_DEFAULT_FAV_ID', newId);
                defaultFavId = newId;
                alert(`设置成功!默认收藏夹ID已更新为: ${newId}\n请刷新页面以使新按钮生效。`);
                window.location.reload();
            } else if (newId !== currentId) {
                alert('ID格式不正确,请输入纯数字。');
            }
        }
    }

    /**
     * 创建右键菜单
     */
    function createContextMenu() {
        const existingMenu = document.getElementById('fav-context-menu');
        if (existingMenu) {
            existingMenu.remove();
        }

        const menu = document.createElement('div');
        menu.id = 'fav-context-menu';
        menu.style.cssText = `
            position: fixed;
            background: #fff;
            border: 1px solid #e7e7e7;
            border-radius: 8px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.15);
            padding: 8px 0;
            z-index: 10000;
            min-width: 200px;
            max-height: 300px;
            overflow-y: auto;
            display: none;
            font-size: 14px;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', 'Helvetica Neue', Helvetica, Arial, sans-serif;
        `;

        document.body.appendChild(menu);
        return menu;
    }

    /**
     * 显示收藏夹选择菜单(用于设置默认收藏夹)
     */
    function showFolderMenu(x, y, button) {
        fetchUserFolders().then(folders => {
            const menu = createContextMenu();
            menu.innerHTML = '';

            // 添加标题
            const title = document.createElement('div');
            title.textContent = '选择默认收藏夹';
            title.style.cssText = `
                padding: 8px 16px;
                font-weight: 600;
                color: #222;
                border-bottom: 1px solid #e7e7e7;
                margin-bottom: 4px;
            `;
            menu.appendChild(title);

            // 添加收藏夹选项
            folders.forEach(folder => {
                const item = document.createElement('div');
                const isDefault = defaultFavId == folder.id;
                item.innerHTML = `
                    <span>${folder.title} (${folder.media_count})</span>
                    ${isDefault ? '<span style="color: #00aeec; font-weight: 600; margin-left: 8px;">✓ 当前默认</span>' : ''}
                `;
                item.style.cssText = `
                    padding: 10px 16px;
                    cursor: pointer;
                    color: #333;
                    transition: background-color 0.2s;
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                `;

                item.addEventListener('mouseenter', () => {
                    item.style.backgroundColor = '#f6f7f8';
                });

                item.addEventListener('mouseleave', () => {
                    item.style.backgroundColor = 'transparent';
                });

                item.addEventListener('click', () => {
                    // 设置为默认收藏夹
                    GM_setValue('BILI_DEFAULT_FAV_ID', folder.id.toString());
                    defaultFavId = folder.id.toString();

                    // 更新按钮状态
                    const buttonText = button.querySelector('.video-toolbar-item-text');
                    if (buttonText) {
                        buttonText.textContent = '快收';
                    }
                    button.classList.remove('on');
                    button.disabled = false;

                    // 显示成功提示
                    item.innerHTML = `<span>✓ 已设为默认收藏夹</span>`;
                    item.style.color = '#00aeec';
                    item.style.fontWeight = '600';

                    setTimeout(() => {
                        hideContextMenu();
                    }, 1000);
                });

                menu.appendChild(item);
            });

            // 设置菜单位置
            menu.style.left = x + 'px';
            menu.style.top = y + 'px';
            menu.style.display = 'block';

            // 确保菜单不超出屏幕
            const rect = menu.getBoundingClientRect();
            if (rect.right > window.innerWidth) {
                menu.style.left = (window.innerWidth - rect.width - 10) + 'px';
            }
            if (rect.bottom > window.innerHeight) {
                menu.style.top = (window.innerHeight - rect.height - 10) + 'px';
            }

        }).catch(error => {
            console.error('获取收藏夹列表失败:', error);
            alert('获取收藏夹列表失败,请确保已登录B站账号');
        });
    }

    /**
     * 隐藏右键菜单
     */
    function hideContextMenu() {
        const menu = document.getElementById('fav-context-menu');
        if (menu) {
            menu.style.display = 'none';
        }
    }

    /**
     * 收藏到指定文件夹
     */
    function doCollectToFolder(aid, favId, callback) {
        const csrf = getCsrf();
        if (!aid || !favId || !csrf || Number(aid) <= 0) {
            callback(false, '参数无效');
            return;
        }

        const url = 'https://api.bilibili.com/x/v3/fav/resource/deal';
        const postData = new URLSearchParams({
            'rid': aid,
            'type': '2',
            'add_media_ids': favId,
            'del_media_ids': '',
            'csrf': csrf
        });

        GM_xmlhttpRequest({
            method: 'POST',
            url: url,
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
                'Referer': 'https://www.bilibili.com'
            },
            data: postData.toString(),
            responseType: 'json',
            onload: function (response) {
                const res = response.response;
                if (res.code === 0 || res.code === 11015) {
                    callback(true, res.code === 0 ? '收藏成功' : '视频已在该收藏夹中');
                } else {
                    callback(false, res.message);
                }
            },
            onerror: function (error) {
                callback(false, '网络错误');
            }
        });
    }

    /**
     * 初始化右键菜单功能
     */
    function initContextMenu() {
        // 隐藏菜单的事件
        document.addEventListener('click', hideContextMenu);
        document.addEventListener('scroll', hideContextMenu);
        window.addEventListener('resize', hideContextMenu);
    }

    // --- 新的按钮添加逻辑 ---
    function initializeQuickFavButton() {
        // 完全延迟执行,避免与初始页面加载冲突
        setTimeout(() => {
            // 使用一次性定时器检查并添加按钮
            const checkAndAddInterval = setInterval(() => {
                const toolbarContainer = document.querySelector('.video-toolbar-left');
                const originalFavButtonWrap = document.querySelector('.video-fav')?.closest('.toolbar-left-item-wrap');

                if (toolbarContainer && originalFavButtonWrap && !document.querySelector('#quick-fav-button-wrap')) {
                    try {
                        // 创建快速收藏按钮
                        const quickFavWrap = document.createElement('div');
                        quickFavWrap.id = 'quick-fav-button-wrap';
                        quickFavWrap.className = 'toolbar-left-item-wrap';

                        // 复制样式属性
                        for (const attr of originalFavButtonWrap.attributes) {
                            if (attr.name.startsWith('data-v-')) {
                                quickFavWrap.setAttribute(attr.name, attr.value);
                            }
                        }

                        // 创建按钮本身
                        const quickFavButton = document.createElement('div');
                        quickFavButton.id = 'quick-fav-button';
                        quickFavButton.className = 'video-toolbar-left-item';
                        quickFavButton.title = '左键:一键收藏到默认收藏夹\n右键:选择默认收藏夹';

                        // 复制按钮样式属性
                        const originalButton = originalFavButtonWrap.querySelector('.video-toolbar-left-item');
                        if (originalButton) {
                            for (const attr of originalButton.attributes) {
                                if (attr.name.startsWith('data-v-')) {
                                    quickFavButton.setAttribute(attr.name, attr.value);
                                }
                            }
                        }

                        // 创建图标
                        const svgIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
                        svgIcon.setAttribute('width', '28');
                        svgIcon.setAttribute('height', '28');
                        svgIcon.setAttribute('viewBox', '0 0 28 28');
                        svgIcon.setAttribute('class', 'video-toolbar-item-icon');
                        svgIcon.innerHTML = `<path fill-rule="evenodd" clip-rule="evenodd" d="M12.636 2.444a1.5 1.5 0 0 1 2.728 0l2.339 4.742a1.5 1.5 0 0 0 1.12.814l5.235.76a1.5 1.5 0 0 1 .83 2.56l-3.787 3.69a1.5 1.5 0 0 0-.433 1.328l.894 5.214a1.5 1.5 0 0 1-2.176 1.58l-4.682-2.46a1.5 1.5 0 0 0-1.402 0l-4.682 2.46a1.5 1.5 0 0 1-2.176-1.58l.894-5.214a1.5 1.5 0 0 0-.433-1.328L3.242 11.32a1.5 1.5 0 0 1 .83-2.56l5.235-.76a1.5 1.5 0 0 0 1.12-.814l2.209-4.742h.001Z M14.5 11.5v-4l-4 6h3v4l4-6h-3Z" fill="currentColor"></path>`;

                        // 创建文字部分
                        const buttonText = document.createElement('span');
                        buttonText.className = 'video-toolbar-item-text';
                        buttonText.textContent = '快收';

                        // 复制文字样式属性
                        const originalText = originalFavButtonWrap.querySelector('.video-toolbar-item-text');
                        if (originalText) {
                            for (const attr of originalText.attributes) {
                                if (attr.name.startsWith('data-v-')) {
                                    buttonText.setAttribute(attr.name, attr.value);
                                }
                            }
                        }

                        // 组装元素
                        quickFavButton.appendChild(svgIcon);
                        quickFavButton.appendChild(buttonText);
                        quickFavWrap.appendChild(quickFavButton);

                        // 添加点击事件
                        quickFavButton.addEventListener('click', (e) => {
                            e.stopPropagation();
                            if (quickFavButton.disabled) return;
                            if (!defaultFavId) {
                                promptForFavId();
                                return;
                            }
                            buttonText.textContent = '...';
                            quickFavButton.disabled = true;
                            const aid = getAid();
                            doCollect(aid, defaultFavId, quickFavButton);
                        });

                        // 添加右键事件
                        quickFavButton.addEventListener('contextmenu', (e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            showFolderMenu(e.clientX, e.clientY, quickFavButton);
                        });

                        // 插入到DOM
                        originalFavButtonWrap.parentNode.insertBefore(quickFavWrap, originalFavButtonWrap.nextSibling);

                        // 成功添加按钮后清除定时器
                        clearInterval(checkAndAddInterval);
                        console.log('快速收藏按钮添加成功');
                    } catch (e) {
                        console.error('添加快速收藏按钮时出错:', e);
                        clearInterval(checkAndAddInterval);
                    }
                }
            }, 1000); // 每秒检查一次

            // 设置最大运行时间,防止无限循环
            setTimeout(() => {
                if (checkAndAddInterval) {
                    clearInterval(checkAndAddInterval);
                    console.log('停止尝试添加快速收藏按钮');
                }
            }, 15000); // 最多运行15秒
        }, 2500); // 页面加载后等待2.5秒再开始尝试添加按钮
    }

    // --- 脚本启动逻辑 ---
    GM_registerMenuCommand('设置默认收藏夹ID', promptForFavId);

    // 初始化右键菜单功能
    function initialize() {
        initializeQuickFavButton();
        initContextMenu();
    }

    // 使用window.onload确保页面完全加载后再执行脚本
    if (document.readyState === 'complete') {
        initialize();
    } else {
        window.addEventListener('load', initialize);
    }
})();