bilibili--分组查看b站动态

这个脚本能帮你通过关注分组筛选b站时间线上的动态

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         bilibili--分组查看b站动态
// @namespace    Felix
// @version      1.1
// @description  这个脚本能帮你通过关注分组筛选b站时间线上的动态
// @author       Felix
// @match        https://t.bilibili.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      api.bilibili.com
// @connect      api.live.bilibili.com
// @run-at       document-end
// @license      AGPL-3.0
// ==/UserScript==

(function() {
    'use strict';

    // --- CSS Styles ---
    GM_addStyle(`
        .chorme-bili-tags {
            height: 48px;
            background-color: var(--bg1);
            border-radius: 6px;
            overflow-x: auto;
            position: relative;
            margin-bottom: 10px; /* Add some space */
        }
        .chorme-bili-tags::-webkit-scrollbar {
            display: none; /* Hide scrollbar */
            scrollbar-width: none; /* Firefox */
            -ms-overflow-style: none; /* IE/Edge */
        }
        .chorme-bili-tags ul {
            padding: 0;
            position: relative;
            padding: 0px 20px;
            margin: 0;
            display: flex;
            width: max-content; /* Ensure ul takes full width of items */
        }
        .chorme-bili-tags ul li {
            list-style: none;
            display: inline-block;
            cursor: pointer;
            margin-right: 16px;
            height: 48px;
            line-height: 48px;
            flex-shrink: 0;
            color: var(--text2); /* Use Bilibili variable */
            transition: color .2s ease;
            position: relative; /* Needed for highlight positioning */
        }
         .chorme-bili-tags ul li:hover {
             color: var(--text1);
         }
         .chorme-bili-tags ul li.active {
             color: var(--brand_blue);
         }
        .chorme-bili-tags .bili-dyn-list-tabs__highlight {
            position: absolute;
            bottom: 0px;
            left: 0px;
            width: 14px; /* Adjust width */
            height: 3px; /* Adjust height */
            border-radius: 2px;
            background-color: var(--brand_blue);
            transition: transform .2s ease-in-out;
            transform: translateX(28px); /* Initial position, will be updated */
        }
        /* Bilibili's own classes for consistency (might need adjustment if Bilibili updates) */
        .fs-medium {
             font-size: 14px;
        }
    `);


    // Type Definitions (as comments for clarity)
    /*
    interface Following { mid: number; attribute: number; mtime: number; special: number; contract_info: any; uname: string; face: string; sign: string; face_nft: number; official_verify: any; vip: any; name_render: any; nft_icon: string; rec_reason: string; track_id: string; follow_time: string; tag: null | number[]; }
    interface Group { [key: number]: string[]; }
    interface TagInfo { tagid: number; name: string; count: number; tip: string; }
    */

    let groups = {}; // Will store { tagid: [uname1, uname2, ...] }
    let currentId = 0; // 0 means "All"
    let isObserve = false;
    let filterTagsCache = []; // Cache for tag info

    /**
     * Fetches data from a URL, handling credentials.
     * Uses GM_xmlhttpRequest for better cross-origin/cookie handling in userscripts if needed,
     * falls back to fetch as per original code (should work for same-origin Bilibili APIs).
     */
    async function send(url) {
        console.log(`[BiliGroupView] Fetching: ${url}`);
        try {
            // Use fetch as in original code - relies on browser handling cookies for api.bilibili.com
             const response = await fetch(url, {
                 credentials: 'include', // Important for logged-in state
             });
             if (!response.ok) {
                 throw new Error(`HTTP error! status: ${response.status}`);
             }
             const data = await response.json();
             if (data.code !== 0) {
                 console.error(`[BiliGroupView] API Error (${url}):`, data.message || data);
                 return null; // Or handle error appropriately
             }
             console.log(`[BiliGroupView] Received data for ${url}:`, data.data);
             return data.data;

            /* // Alternative using GM_xmlhttpRequest (more robust for userscripts)
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    headers: {
                        "Accept": "application/json", // Adjust if needed
                        // Bilibili might require other headers like Referer, User-Agent
                        "Referer": "https://t.bilibili.com/",
                        "User-Agent": navigator.userAgent
                    },
                    withCredentials: true, // Equivalent to fetch's include
                    onload: function(response) {
                        if (response.status >= 200 && response.status < 300) {
                            try {
                                const data = JSON.parse(response.responseText);
                                if (data.code !== 0) {
                                     console.error(`[BiliGroupView] API Error (${url}):`, data.message || data);
                                     resolve(null);
                                } else {
                                     console.log(`[BiliGroupView] Received data for ${url}:`, data.data);
                                     resolve(data.data);
                                }
                            } catch (e) {
                                console.error(`[BiliGroupView] Failed to parse JSON for ${url}`, e);
                                reject(e);
                            }
                        } else {
                            console.error(`[BiliGroupView] HTTP Error ${response.status} for ${url}`);
                            reject(new Error(`HTTP error! status: ${response.status}`));
                        }
                    },
                    onerror: function(error) {
                        console.error(`[BiliGroupView] Network Error for ${url}`, error);
                        reject(error);
                    }
                });
            });
            */
        } catch (error) {
            console.error(`[BiliGroupView] Failed to fetch ${url}:`, error);
            return null; // Indicate failure
        }
    }

    async function getTags() /* : Promise<TagInfo[] | null> */ {
        return await send('https://api.bilibili.com/x/relation/tags');
    }

    async function getProfile() /* : Promise<any | null> */ {
        // Original used live API, but maybe space API is sufficient? Let's try space first.
        // If live is needed: return await send('https://api.live.bilibili.com/User/getUserInfo');
        // Using space API as it's generally more stable for basic user info.
        return await send('https://api.bilibili.com/x/space/myinfo');
    }

    async function getFollowing(uid, pageNumber, pageSize = 50) /* : Promise<{ list: Following[], total: number } | null> */ {
        if (!uid) return null;
        return await send(`https://api.bilibili.com/x/relation/followings?vmid=${uid}&pn=${pageNumber}&ps=${pageSize}&order=desc&order_type=attention`);
    }

    /** Replaces chrome.storage.local.set */
    function saveGroupsInfo(data) {
        try {
            GM_setValue('groups', JSON.stringify(data));
            console.log('[BiliGroupView] Saved groups info to storage.');
        } catch (e) {
            console.error('[BiliGroupView] Failed to save groups info:', e);
        }
    }

    /** Resets dynamic item visibility */
    function resetDynamicItems() {
        const dynamicItems = document.querySelectorAll('.bili-dyn-list__item');
        dynamicItems.forEach((item /*: HTMLElement*/) => {
            item.style.display = ''; // Use empty string to reset to default display
        });
        console.log('[BiliGroupView] Reset dynamic item visibility.');
    }


    /** MutationObserver to filter newly loaded items */
    const dynamicCardObserver = new MutationObserver((mutationsList) => {
        if (currentId === 0) return; // Don't filter if "All" is selected

        let processed = false;
        mutationsList.forEach((mutation) => {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeType === Node.ELEMENT_NODE && node.classList?.contains('bili-dyn-list__item')) {
                        filterSingleDynamicItem(node, groups[currentId] || []);
                        processed = true;
                    }
                    // Sometimes items are nested deeper
                    else if (node.nodeType === Node.ELEMENT_NODE && node.querySelector) {
                         const nestedItems = node.querySelectorAll('.bili-dyn-list__item');
                         nestedItems.forEach(item => {
                             filterSingleDynamicItem(item, groups[currentId] || []);
                             processed = true;
                         });
                    }
                });
            }
        });
         if (processed) console.log('[BiliGroupView] Filtered newly added dynamic items.');
    });

    /** Filters a single dynamic item based on the current group */
    function filterSingleDynamicItem(itemElement, groupUsernames) {
         const nameEle = itemElement.querySelector('.bili-dyn-title__text');
         if (nameEle) {
             const name = nameEle.textContent?.trim();
             if (name && !groupUsernames.includes(name)) {
                 itemElement.style.display = 'none';
             } else {
                 itemElement.style.display = ''; // Ensure it's visible if it belongs
             }
         } else {
            // If name element isn't found, maybe hide it to be safe or log an error?
             console.warn('[BiliGroupView] Could not find name element in dynamic item:', itemElement);
             // itemElement.style.display = 'none';
         }
    }

    async function fetchAllFollowing() /* : Promise<Following[] | null> */ {
        const profile = await getProfile();
        if (!profile || !profile.mid) {
            console.error('[BiliGroupView] Could not get user profile/UID.');
            return null;
        }
        const uid = profile.mid;
        const pageSize = 50;
        let pageNumber = 1;
        let followingList = [];
        let total = Infinity; // Initialize total

        console.log(`[BiliGroupView] Starting to fetch all followings for UID: ${uid}`);

        try {
            // Fetch first page to get total
            const firstPage = await getFollowing(uid, pageNumber, pageSize);
            if (!firstPage || !firstPage.list) {
                 console.error('[BiliGroupView] Failed to fetch the first page of followings.');
                 return null;
            }
            followingList = followingList.concat(firstPage.list);
            total = firstPage.total;
            console.log(`[BiliGroupView] Total followings: ${total}. Fetched page ${pageNumber}.`);
            pageNumber++;

            // Fetch remaining pages
            while (followingList.length < total) {
                const response = await getFollowing(uid, pageNumber, pageSize);
                if (!response || !response.list) {
                    console.warn(`[BiliGroupView] Failed to fetch page ${pageNumber}. Stopping fetch.`);
                    break; // Stop if a page fails
                }
                 if (response.list.length === 0) {
                     console.warn(`[BiliGroupView] Fetched empty list on page ${pageNumber}. Stopping fetch.`);
                     break; // Stop if empty list received unexpectedly
                 }
                followingList = followingList.concat(response.list);
                console.log(`[BiliGroupView] Fetched page ${pageNumber}. Total fetched: ${followingList.length}`);
                pageNumber++;
                // Add a small delay to avoid potential rate limiting
                await new Promise(resolve => setTimeout(resolve, 100));
            }
            console.log(`[BiliGroupView] Finished fetching all followings. Got ${followingList.length} entries.`);
            return followingList;
        } catch (error) {
            console.error('[BiliGroupView] Error during fetching all followings:', error);
            return null;
        }
    }

    /** Generates the group object and saves it */
    async function buildAndSaveGroups() {
        const followings = await fetchAllFollowing();
        if (!followings) {
            alert('[BiliGroupView] 获取关注列表失败,无法生成分组。请检查控制台日志。');
            return false;
        }

        const groupedFollowings = followings.reduce((acc, item) => {
            // Tag can be null or an array. Ensure '0' (default group) is handled if tags are null/empty.
            const tags = item.tag && item.tag.length > 0 ? item.tag : [0];
            tags.forEach((tagId) => {
                acc[tagId] = acc[tagId] ?? [];
                if (item.uname && !acc[tagId].includes(item.uname)) { // Avoid duplicates just in case
                     acc[tagId].push(item.uname);
                }
            });
            return acc;
        }, {}); // Start with empty object, default group (0) will be added if needed

        // Ensure default group '0' exists if there are followings without tags
         if (!groupedFollowings[0] && followings.some(f => !f.tag || f.tag.length === 0)) {
             groupedFollowings[0] = followings.filter(f => !f.tag || f.tag.length === 0).map(f => f.uname);
         }


        groups = groupedFollowings; // Update global variable
        saveGroupsInfo(groupedFollowings); // Save to storage
        console.log('[BiliGroupView] Built and saved new groups:', groups);
        return true;
    }

    /** Handles horizontal scrolling with the mouse wheel */
    function addMouseWheelListener(element) {
        element.addEventListener('wheel', (event /*: WheelEvent*/) => {
            // Only scroll if the target is within the tags container and it's horizontal overflow
            if (element.contains(event.target) && element.scrollWidth > element.clientWidth) {
                 // Don't prevent default if scrolling vertically on page, only if scrolling the tags
                if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
                     event.preventDefault(); // Prevent page scroll if horizontal mouse wheel is used
                     element.scrollBy({ left: event.deltaX, behavior: 'smooth' });
                } else if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
                    event.preventDefault(); // Prevent page scroll if vertical mouse wheel is used
                    element.scrollBy({ left: event.deltaY, behavior: 'smooth' });
                }
            }
        }, { passive: false }); // Need passive: false to preventDefault
    }

    /** Moves the highlight bar under the selected tag */
    function moveHighlight(targetListItem) {
        const highlight = document.querySelector('.chorme-bili-tags .bili-dyn-list-tabs__highlight');
        if (highlight && targetListItem) {
            const offset = targetListItem.offsetLeft + (targetListItem.offsetWidth / 2) - (highlight.offsetWidth / 2);
            highlight.style.transform = `translateX(${offset}px)`;
        }
    }

     /** Filters all currently visible dynamic items based on tagId */
    function filterDynamicsByTag(tagId) {
        const groupUsernames = groups[tagId] || [];
        const dynamicItems = document.querySelectorAll('.bili-dyn-list__item');
        console.log(`[BiliGroupView] Filtering by tag ID: ${tagId}. Users:`, groupUsernames);

        dynamicItems.forEach((item) => {
             filterSingleDynamicItem(item, groupUsernames);
        });

         // Ensure observer is active
        const observerTarget = document.querySelector('.bili-dyn-list__items'); // Target for observer
        if (observerTarget && !isObserve) {
            try {
                 dynamicCardObserver.observe(observerTarget, { childList: true, subtree: true });
                 isObserve = true;
                 console.log('[BiliGroupView] MutationObserver started.');
            } catch (e) {
                console.error("[BiliGroupView] Failed to start MutationObserver:", e);
            }
        }
    }


    /** Handles clicks on the tag list */
    function handleTagClick(event, availableTags) {
        const target = event.target;

        if (target.tagName === 'LI') {
            const ulElement = target.parentElement;
            const liElements = ulElement.querySelectorAll('li');

            // Update active class
            liElements.forEach(item => item.classList.remove('active'));
            target.classList.add('active');

            // Move highlight
            moveHighlight(target);

            // Find the index and corresponding tagId
            const index = Array.from(ulElement.children).indexOf(target);
            if (index === 0) { // "All" clicked
                currentId = 0;
                 if (isObserve) {
                     dynamicCardObserver.disconnect();
                     isObserve = false;
                     console.log('[BiliGroupView] MutationObserver stopped.');
                 }
                resetDynamicItems();
            } else {
                currentId = availableTags[index - 1]?.tagid ?? -1; // Get tagid from cache
                 if (currentId === -1) {
                     console.error("[BiliGroupView] Couldn't find tagid for clicked element:", target.textContent);
                     return;
                 }
                filterDynamicsByTag(currentId);
            }
             console.log(`[BiliGroupView] Switched to filter ID: ${currentId}`);
        }
    }


    /** Main function to initialize the script */
    async function initialize() {
        console.log('[BiliGroupView] Initializing script...');

        // Load groups from storage
        const storedGroups = GM_getValue('groups');
        if (storedGroups) {
            try {
                groups = JSON.parse(storedGroups);
                console.log('[BiliGroupView] Loaded groups from storage.');
            } catch (e) {
                console.error('[BiliGroupView] Failed to parse stored groups:', e);
                groups = {}; // Reset if parsing fails
            }
        } else {
             console.log('[BiliGroupView] No groups found in storage.');
        }

        // Wait for the dynamic list container to be ready
        const targetNode = await waitForElement('.bili-dyn-list');
        if (!targetNode) {
            console.error('[BiliGroupView] Target .bili-dyn-list not found. Aborting.');
            return;
        }
         // Also wait for the list items container for the observer
         const observerTarget = await waitForElement('.bili-dyn-list__items');
         if (!observerTarget) {
             console.error('[BiliGroupView] Target .bili-dyn-list__items not found for observer. Aborting.');
             return;
         }


        // Fetch tags from Bilibili API
        const tags = await getTags();
         if (!tags) {
             console.warn('[BiliGroupView] Failed to fetch tags. UI might not display correctly.');
             filterTagsCache = [];
         } else {
             // Filter out tags with 0 count, keep default group '0' handling in mind
             filterTagsCache = tags.filter(item => item.count !== 0 || item.tagid === 0); // Keep default group if it exists in API? Check API response structure. Usually 0 isn't returned explicitly.
             // Filter out 0 count tags, as the "All" button handles that.
             filterTagsCache = tags.filter(item => item.count !== 0);
             console.log('[BiliGroupView] Fetched and filtered tags:', filterTagsCache);
         }

        // Check if groups are empty or seem outdated (e.g., new tags exist but not in groups)
        const needGroupUpdate = !storedGroups || Object.keys(groups).length === 0 || filterTagsCache.some(tag => !(tag.tagid in groups) && tag.tagid !== 0);

        if (needGroupUpdate) {
            console.log('[BiliGroupView] Groups data missing or potentially outdated. Fetching and building groups...');
            const success = await buildAndSaveGroups();
            if (!success) {
                 // Maybe still try to build the UI with available tags?
                 console.warn("[BiliGroupView] Failed to build groups, continuing UI setup without full filtering capability.");
            }
        }

        // --- Create and Insert UI ---
        const tagsHTML = `
            <div class='chorme-bili-tags'>
                <ul>
                    <li class='bili-dyn-list-tabs__item fs-medium active'>全部</li>
                    ${filterTagsCache.map(item => `<li class='bili-dyn-list-tabs__item fs-medium' data-tag-id='${item.tagid}'>${item.name}</li>`).join('')}
                </ul>
                <div class='bili-dyn-list-tabs__highlight'></div>
            </div>
        `;

        const tempDiv = document.createElement('div');
        tempDiv.innerHTML = tagsHTML.trim();
        const tagsDom = tempDiv.firstElementChild;

        if (tagsDom) {
            targetNode.parentNode.insertBefore(tagsDom, targetNode); // Insert before the list
            console.log('[BiliGroupView] Tags UI inserted.');

            // Add listeners
            const ulElement = tagsDom.querySelector('ul');
            if (ulElement) {
                ulElement.addEventListener('click', (event) => handleTagClick(event, filterTagsCache));
            }

             // Add wheel listener to the tags container itself
             addMouseWheelListener(tagsDom);

            // Set initial highlight position
             const initialActive = tagsDom.querySelector('li.active');
             moveHighlight(initialActive);

        } else {
            console.error('[BiliGroupView] Failed to create tags DOM element.');
        }

         // Add refresh button (optional but recommended)
         addRefreshButton(targetNode.parentNode, tagsDom);


        console.log('[BiliGroupView] Initialization complete.');
    }

    /** Utility to wait for an element to appear in the DOM */
    function waitForElement(selector, timeout = 15000) {
        return new Promise((resolve) => {
            const interval = 100;
            let timer = 0;
            const check = () => {
                const element = document.querySelector(selector);
                if (element) {
                    resolve(element);
                } else {
                    timer += interval;
                    if (timer < timeout) {
                        setTimeout(check, interval);
                    } else {
                        console.error(`[BiliGroupView] Element "${selector}" not found after ${timeout}ms.`);
                        resolve(null);
                    }
                }
            };
            check(); // Initial check
        });
    }

     /** Adds a manual refresh button */
    function addRefreshButton(parent, sibling) {
        const refreshButton = document.createElement('button');
        refreshButton.textContent = '🔄 刷新分组';
        refreshButton.title = '点击强制重新获取并保存关注列表和分组信息';
        refreshButton.style.cssText = `
            margin-left: 20px;
            padding: 5px 10px;
            cursor: pointer;
            border: 1px solid var(--line_regular);
            background-color: var(--bg1);
            color: var(--text2);
            border-radius: 4px;
            font-size: 12px;
             vertical-align: middle; /* Align with tags */
        `;
         refreshButton.addEventListener('click', async () => {
             if (confirm('确定要重新获取所有关注列表并更新分组吗?这可能需要一些时间。')) {
                 console.log('[BiliGroupView] Manual refresh requested.');
                 refreshButton.textContent = '刷新中...';
                 refreshButton.disabled = true;
                 const success = await buildAndSaveGroups();
                 if (success) {
                     alert('分组信息已刷新!请手动刷新页面以更新标签列表。'); // Simple notification
                     // Ideally, rebuild the UI dynamically here, but page refresh is easier
                 } else {
                    alert('分组信息刷新失败,请查看控制台日志。');
                 }
                 refreshButton.textContent = '🔄 刷新分组';
                 refreshButton.disabled = false;
             }
         });

         // Insert the button after the tags container
         if (parent && sibling && sibling.parentNode === parent) {
              // Insert inside the same container as tags for layout
             if (sibling.nextSibling) {
                 parent.insertBefore(refreshButton, sibling.nextSibling);
             } else {
                 parent.appendChild(refreshButton);
             }
             // Adjust tag container style for button alignment if needed
             sibling.style.display = 'inline-block'; // Make tags container inline
             sibling.style.verticalAlign = 'middle';

             console.log('[BiliGroupView] Refresh button added.');
         } else {
              console.warn('[BiliGroupView] Could not find suitable parent/sibling to add refresh button.');
         }

    }


    // --- Script Execution ---
    // Use a small delay or wait for a specific element if needed,
    // though document-end should often be sufficient.
    // Using waitForElement ensures the target container exists.
    initialize();

})();