Bluesky Content Manager Test

Customize your Bluesky feed by filtering and removing specific content

目前為 2024-11-24 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name Bluesky Content Manager Test
// @namespace https://greasyfork.org/en/users/567951-stuart-saddler
// @version 1.3
// @description Customize your Bluesky feed by filtering and removing specific content
// @license MIT
// @icon https://images.seeklogo.com/logo-png/52/2/bluesky-logo-png_seeklogo-520643.png
// @match https://bsky.app/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @connect bsky.social
// @run-at document-idle
// ==/UserScript==

(function() {
    'use strict';

    GM_addStyle(`
        #filter-config {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            z-index: 10000;
            display: none;
            color: #000000;
        }
        #filter-config h3, #filter-config p {
            color: #000000;
        }
        #filter-config textarea {
            width: 300px;
            height: 150px;
            margin: 10px 0;
        }
        #filter-config button {
            margin: 5px;
            padding: 5px 10px;
        }
    `);

    const filteredTerms = JSON.parse(GM_getValue('filteredTerms', '[]'));
    const processedPosts = new WeakSet();
    let sessionToken = null;
    const profileCache = new Map();
    let blockedCount = 0;
    let menuCommandId = null;

    function updateMenuCommand() {
        if (menuCommandId) {
            GM_unregisterMenuCommand(menuCommandId);
        }
        menuCommandId = GM_registerMenuCommand(`Configure blocklist (${blockedCount} blocked)`, () => {
            document.getElementById('filter-config').style.display = 'block';
        });
    }

    function createConfigUI() {
        const div = document.createElement('div');
        div.id = 'filter-config';
        div.innerHTML = `
            <h3>Configure Filter Terms</h3>
            <p>Enter one term per line:</p>
            <textarea id="filter-terms">${filteredTerms.join('\n')}</textarea>
            <br>
            <button id="save-filters">Save & Reload</button>
            <button id="cancel-filters">Cancel</button>
        `;
        document.body.appendChild(div);

        document.getElementById('save-filters').addEventListener('click', () => {
            const newTerms = document.getElementById('filter-terms').value
                .split('\n')
                .map(t => t.trim())
                .filter(t => t);
            GM_setValue('filteredTerms', JSON.stringify(newTerms));
            div.style.display = 'none';
            window.location.reload();
        });

        document.getElementById('cancel-filters').addEventListener('click', () => {
            div.style.display = 'none';
        });
    }

    function debugLog(type, data = null) {
        console.log(`🔍 [Profile Filter] ${type}:`, data || '');
    }

    function listStorage() {
        debugLog('Listing localStorage');
        for (let i = 0; i < localStorage.length; i++) {
            const key = localStorage.key(i);
            const value = localStorage.getItem(key);
            console.log(`localStorage[${key}]:`, value);
        }
    }

    function waitForAuth() {
        return new Promise((resolve, reject) => {
            const maxAttempts = 30;
            let attempts = 0;

            const checkAuth = () => {
                attempts++;
                let session = localStorage.getItem('BSKY_STORAGE');
                if (session) {
                    try {
                        const parsed = JSON.parse(session);
                        if (parsed.session?.accounts?.[0]?.accessJwt) {
                            sessionToken = parsed.session.accounts[0].accessJwt;
                            debugLog('Auth Success', 'Token retrieved');
                            resolve(true);
                            return;
                        }
                    } catch (e) {
                        debugLog('Auth Error', e);
                    }
                }

                if (attempts === 1) {
                    listStorage();
                }

                if (attempts >= maxAttempts) {
                    reject('Authentication timeout');
                    return;
                }

                setTimeout(checkAuth, 1000);
            };

            checkAuth();
        });
    }

    async function fetchProfile(did) {
        if (!sessionToken) {
            debugLog('Fetch Profile Error', 'No session token available');
            return null;
        }

        if (profileCache.has(did)) {
            debugLog('Fetch Profile', 'Using cached profile');
            return profileCache.get(did);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://bsky.social/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
                headers: {
                    'Authorization': `Bearer ${sessionToken}`,
                    'Accept': 'application/json'
                },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            profileCache.set(did, data);
                            resolve(data);
                        } catch (e) {
                            debugLog('Profile Parsing Error', e);
                            reject(e);
                        }
                    } else if (response.status === 401) {
                        debugLog('Auth Expired', 'Session token expired');
                        sessionToken = null;
                        reject('Auth expired');
                    } else {
                        debugLog('Profile Fetch Error', `HTTP ${response.status}`);
                        reject(`HTTP ${response.status}`);
                    }
                },
                onerror: function(error) {
                    debugLog('Fetch Profile Error', error);
                    reject(error);
                }
            });
        });
    }

    function removePost(post) {
        if (!post) return false;
        post.remove();
        blockedCount++;
        updateMenuCommand();
        return true;
    }

    async function processPost(post) {
        if (!post || processedPosts.has(post)) return;
        processedPosts.add(post);

        const authorLink = post.querySelector('a[href^="/profile/"]');
        if (!authorLink) return;

        // Check author's name first
        const nameElement = authorLink.querySelector('span');
        if (nameElement) {
            const authorName = nameElement.textContent.toLowerCase();
            const nameContainsFilteredTerm = filteredTerms.some(term =>
                authorName.includes(term.toLowerCase())
            );
            if (nameContainsFilteredTerm) {
                debugLog('Filtered by Name', authorName);
                removePost(post);
                return;
            }
        }

        const didMatch = authorLink.href.match(/\/profile\/(.+)/);
        if (!didMatch || !didMatch[1]) return;
        const did = decodeURIComponent(didMatch[1]);
        if (!did) return;

        // Check post content
        const postContentElement = post.querySelector('div[data-testid="postText"]');
        if (postContentElement) {
            const postText = postContentElement.textContent.toLowerCase();
            const textContainsFilteredTerm = filteredTerms.some(term =>
                postText.includes(term.toLowerCase())
            );
            if (textContainsFilteredTerm) {
                debugLog('Filtered by Content', postText);
                removePost(post);
                return;
            }
        }

        // Check profile
        try {
            const profile = await fetchProfile(did);
            if (profile?.description || profile?.displayName) {
                const descriptionLower = (profile.description || '').toLowerCase();
                const displayNameLower = (profile.displayName || '').toLowerCase();
                const shouldHide = filteredTerms.some(term => {
                    const termLower = term.toLowerCase();
                    return descriptionLower.includes(termLower) || displayNameLower.includes(termLower);
                });
                if (shouldHide) {
                    debugLog('Filtered by Profile', { description: profile.description, displayName: profile.displayName });
                    removePost(post);
                }
            }
        } catch (error) {
            if (error === 'Auth expired') {
                debugLog('Auth Expired', 'Attempting to re-authenticate');
                try {
                    await waitForAuth();
                    await processPost(post);
                } catch (authError) {
                    debugLog('Re-authentication Failed', authError);
                }
            }
        }
    }

    function observePosts() {
        const observer = new MutationObserver((mutations) => {
            mutations.forEach(mutation => {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            // Process the node itself if it's a post
                            if (node.querySelector('a[href^="/profile/"]')) {
                                processPost(node);
                            }
                            // Process any posts within the node
                            const posts = node.querySelectorAll('div[role="article"]');
                            posts.forEach(post => processPost(post));
                        }
                    });
                }
            });
        });

        observer.observe(document.body, { childList: true, subtree: true });
        debugLog('Observer Started');
    }

    function initialScan() {
        const posts = document.querySelectorAll('div[role="article"]');
        posts.forEach(post => processPost(post));
        debugLog('Initial Scan Complete', `Processed ${posts.length} posts`);
    }

    // Start the script
    waitForAuth().then(() => {
        initialScan();
        observePosts();
    }).catch((err) => {
        debugLog('Initialization Error', err);
    });

    createConfigUI();
    updateMenuCommand();
    debugLog('Script Loaded', { filteredTerms, timestamp: new Date().toISOString() });
})();