Greasy Fork

来自缓存

Greasy Fork is available in English.

bilibili--分组查看b站动态

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

// ==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();

})();