Bluesky Content Manager

Content filtering for Bluesky: block keywords, enforce alt-text, and auto-whitelist followed accounts.

目前為 2025-03-31 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 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
// @namespace    https://greasyfork.org/en/users/567951-stuart-saddler
// @version      3.1
// @description  Content filtering for Bluesky: block keywords, enforce alt-text, and auto-whitelist followed accounts.
// @license      MIT
// @match        https://bsky.app/*
// @icon         https://i.ibb.co/YySpmDk/Bluesky-Content-Manager.png
// @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==

(async function () {
    'use strict';

    /***** CONFIGURATION & GLOBALS *****/
    const filteredTerms = (JSON.parse(GM_getValue('filteredTerms', '[]')) || []).map(t => t.trim().toLowerCase());
    const whitelistedUsers = new Set((JSON.parse(GM_getValue('whitelistedUsers', '[]')) || []).map(u => normalizeUsername(u)));
    let altTextEnforcementEnabled = GM_getValue('altTextEnforcementEnabled', true);
    let blockedCount = 0;
    let menuCommandId = null;

    /***** CSS INJECTION *****/
    const CSS = `
    .content-filtered {
        display: none !important;
        height: 0 !important;
        overflow: hidden !important;
    }
    .bluesky-filter-dialog {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: white;
        padding: 20px;
        border-radius: 8px;
        z-index: 1000000;
        box-shadow: 0 4px 12px rgba(0,0,0,0.15);
        min-width: 300px;
        max-width: 350px;
        font-family: Arial, sans-serif;
        color: #333;
    }
    .bluesky-filter-dialog h2 {
        margin-top: 0;
        color: #0079d3;
        font-size: 1.5em;
        font-weight: bold;
    }
    .bluesky-filter-dialog p {
        font-size: 0.9em;
        margin-bottom: 10px;
        color: #555;
    }
    .bluesky-filter-dialog textarea {
        width: calc(100% - 16px);
        height: 150px;
        padding: 8px;
        margin: 10px 0;
        border: 1px solid #ccc;
        border-radius: 4px;
        font-family: monospace;
        background: #f9f9f9;
        color: #000;
    }
    .bluesky-filter-dialog label {
        display: block;
        margin-top: 10px;
        font-size: 0.9em;
        color: #333;
    }
    .bluesky-filter-dialog input[type="checkbox"] {
        margin-right: 6px;
    }
    .bluesky-filter-dialog .button-container {
        display: flex;
        justify-content: flex-end;
        gap: 10px;
        margin-top: 10px;
    }
    .bluesky-filter-dialog button {
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 8px 16px;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 1em;
        text-align: center;
    }
    .bluesky-filter-dialog .save-btn {
        background-color: #0079d3;
        color: white;
    }
    .bluesky-filter-dialog .cancel-btn {
        background-color: #f2f2f2;
        color: #333;
    }
    .bluesky-filter-dialog button:hover {
        opacity: 0.9;
    }
    .bluesky-filter-overlay {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(0,0,0,0.5);
        z-index: 999999;
    }
    `;
    GM_addStyle(CSS);

    /***** UTILITY FUNCTIONS *****/
    function normalizeUsername(username) {
        return username.toLowerCase().replace(/[\u200B-\u200F\u202A-\u202F]/g, '').trim();
    }

    function escapeRegExp(string) {
        return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    function cleanText(text) {
        return text.normalize('NFKD').replace(/\s+/g, ' ').toLowerCase().trim();
    }

    function getPostContainer(node) {
        let current = node;
        while (current && current !== document.body) {
            if (current.matches('[data-testid="post"], div[role="link"], article')) {
                return current;
            }
            current = current.parentElement;
        }
        return null;
    }

    function shouldProcessPage() {
        return window.location.pathname !== '/notifications';
    }

    /***** MENU & CONFIG UI *****/
    function updateMenuCommand() {
        if (menuCommandId) {
            GM_unregisterMenuCommand(menuCommandId);
        }
        menuCommandId = GM_registerMenuCommand(`Configure Filters (${blockedCount} blocked)`, showConfigUI);
    }

    function createConfigUI() {
        const overlay = document.createElement('div');
        overlay.className = 'bluesky-filter-overlay';
        const dialog = document.createElement('div');
        dialog.className = 'bluesky-filter-dialog';
        dialog.innerHTML = `
            <h2>Bluesky Content Manager</h2>
            <p>Blocklist Keywords (one per line). Filtering is case-insensitive and matches common plural forms.</p>
            <textarea spellcheck="false">${filteredTerms.join('\n')}</textarea>
            <label>
                <input type="checkbox" ${altTextEnforcementEnabled ? 'checked' : ''}>
                Enable Alt-Text Enforcement (analyze alt-text, aria-label, and <img> alt attributes)
            </label>
            <div class="button-container">
                <button class="cancel-btn">Cancel</button>
                <button class="save-btn">Save</button>
            </div>
        `;
        document.body.appendChild(overlay);
        document.body.appendChild(dialog);

        const closeDialog = () => {
            dialog.remove();
            overlay.remove();
        };

        dialog.querySelector('.save-btn').addEventListener('click', async () => {
            const textareaValue = dialog.querySelector('textarea').value;
            const newKeywords = textareaValue.split('\n').map(k => k.trim().toLowerCase()).filter(k => k.length > 0);
            await GM_setValue('filteredTerms', JSON.stringify(newKeywords));

            const checkbox = dialog.querySelector('input[type="checkbox"]');
            altTextEnforcementEnabled = checkbox.checked;
            await GM_setValue('altTextEnforcementEnabled', altTextEnforcementEnabled);

            blockedCount = 0;
            closeDialog();
            location.reload();
        });

        dialog.querySelector('.cancel-btn').addEventListener('click', closeDialog);
        overlay.addEventListener('click', closeDialog);
    }

    function showConfigUI() {
        createConfigUI();
    }

    /***** AUTHENTICATION & PROFILE FETCHING *****/
    let sessionToken = null;
    let currentUserDid = null;
    const profileCache = new Map();

    function waitForAuth() {
        return new Promise((resolve, reject) => {
            const maxAttempts = 30;
            let attempts = 0;
            const checkAuth = () => {
                attempts++;
                const 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;
                            currentUserDid = parsed.session.accounts[0].did;
                            resolve(true);
                            return;
                        }
                    } catch (e) {}
                }
                if (attempts >= maxAttempts) {
                    reject('Authentication timeout');
                    return;
                }
                setTimeout(checkAuth, 1000);
            };
            checkAuth();
        });
    }

    async function fetchProfile(did) {
        if (!sessionToken) return null;
        if (profileCache.has(did)) 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) {
                            reject(e);
                        }
                    } else if (response.status === 401) {
                        sessionToken = null;
                        reject('Auth expired');
                    } else {
                        reject(`HTTP ${response.status}`);
                    }
                },
                onerror: function(error) {
                    reject(error);
                }
            });
        });
    }

    /***** AUTO‑WHITELIST FOLLOWED ACCOUNTS (with Pagination) *****/
    async function fetchAllFollows(cursor = null, accumulated = []) {
        let url = `https://bsky.social/xrpc/app.bsky.graph.getFollows?actor=${encodeURIComponent(currentUserDid)}`;
        if (cursor) url += `&cursor=${cursor}`;
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                headers: {
                    'Authorization': `Bearer ${sessionToken}`,
                    'Accept': 'application/json'
                },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            const newAccumulated = accumulated.concat(data.follows || []);
                            if (data.cursor) {
                                fetchAllFollows(data.cursor, newAccumulated).then(resolve).catch(reject);
                            } else {
                                resolve(newAccumulated);
                            }
                        } catch (e) {
                            reject(e);
                        }
                    } else {
                        reject(`HTTP ${response.status}`);
                    }
                },
                onerror: function(err) {
                    reject(err);
                }
            });
        });
    }

    async function autoWhitelistFollowedAccounts() {
        if (!sessionToken || !currentUserDid) return;
        try {
            const follows = await fetchAllFollows();
            follows.forEach(follow => {
                let handle = (follow.subject && follow.subject.handle) || follow.handle;
                if (handle) {
                    if (!handle.startsWith('@')) handle = '@' + handle;
                    whitelistedUsers.add(normalizeUsername(handle));
                }
            });
        } catch (err) {}
    }

    // Check textual alt‑text in post content for blocklisted words.
    function checkAltTextTagInPost(rawPostText, filteredTerms) {
        const altTextRegex = /alt-text\s*"([^"]*)"/gi;
        let match;
        while ((match = altTextRegex.exec(rawPostText)) !== null) {
            const contentBetweenQuotes = match[1].trim();
            if (!contentBetweenQuotes) return false;
            const lowered = contentBetweenQuotes.toLowerCase();
            for (const term of filteredTerms) {
                if (lowered.includes(term)) return false;
            }
        }
        return true;
    }

    // Skip posts by whitelisted (followed) users.
    function isWhitelisted(post) {
        const authorLink = post.querySelector('a[href^="/profile/"]');
        if (!authorLink) return false;
        const profileIdentifier = authorLink.href.split('/profile/')[1].split(/[/?#]/)[0];
        return whitelistedUsers.has(normalizeUsername(`@${profileIdentifier}`));
    }

    /***** COMBINED POST PROCESSING *****/
    async function processPost(post) {
        if (isWhitelisted(post)) {
            post.classList.add('bluesky-processed');
            return;
        }
        if (!shouldProcessPage() || post.classList.contains('bluesky-processed')) return;

        const postContainer = getPostContainer(post);
        if (!postContainer) return;

        // <img> Alt Check: remove if any <img> has empty alt; if non-empty, scan for blocklisted words.
        const imageElements = post.querySelectorAll('img');
        if (imageElements.length > 0) {
            if (Array.from(imageElements).some(img => !img.alt || img.alt.trim() === '')) {
                postContainer.remove();
                blockedCount++;
                updateMenuCommand();
                return;
            }
            const altTexts = Array.from(imageElements).map(img => img.alt || '');
            const cleanedAltTexts = altTexts.map(alt => cleanText(alt));
            if (filteredTerms.some(term => {
                const pattern = new RegExp(escapeRegExp(term), 'i');
                return altTexts.some(alt => pattern.test(alt.toLowerCase())) ||
                       cleanedAltTexts.some(alt => pattern.test(alt));
            })) {
                postContainer.remove();
                blockedCount++;
                updateMenuCommand();
                return;
            }
        }

        // aria-label Check: remove if any aria-label is empty; otherwise, check for blocklisted words.
        const ariaLabelElements = post.querySelectorAll('[aria-label]');
        if (ariaLabelElements.length > 0) {
            const ariaLabels = Array.from(ariaLabelElements).map(el => el.getAttribute('aria-label') || '');
            if (ariaLabels.some(label => label.trim() === '')) {
                postContainer.remove();
                blockedCount++;
                updateMenuCommand();
                return;
            }
            const cleanedAriaLabels = ariaLabels.map(label => cleanText(label));
            if (filteredTerms.some(term => {
                const pattern = new RegExp(escapeRegExp(term), 'i');
                return ariaLabels.some(label => pattern.test(label.toLowerCase())) ||
                       cleanedAriaLabels.some(label => pattern.test(label));
            })) {
                postContainer.remove();
                blockedCount++;
                updateMenuCommand();
                return;
            }
        }

        // Textual Alt‑Text Check in post content.
        if (altTextEnforcementEnabled) {
            const postContentElement = post.querySelector('div[data-testid="postText"]');
            const rawPostText = postContentElement ? postContentElement.textContent : '';
            if (!checkAltTextTagInPost(rawPostText, filteredTerms)) {
                postContainer.remove();
                blockedCount++;
                updateMenuCommand();
                return;
            }
        }

        // Author Name Blocklist Check.
        const authorLink = post.querySelector('a[href^="/profile/"]');
        if (authorLink) {
            const nameElement = authorLink.querySelector('span');
            const rawAuthorName = nameElement ? nameElement.textContent : authorLink.textContent;
            const cleanedAuthorName = cleanText(rawAuthorName);
            if (filteredTerms.some(term => {
                const pattern = new RegExp(escapeRegExp(term), 'i');
                return pattern.test(rawAuthorName.toLowerCase()) || pattern.test(cleanedAuthorName);
            })) {
                postContainer.remove();
                blockedCount++;
                updateMenuCommand();
                return;
            }
        }

        // Post Text Blocklist Check.
        const postContentElement = post.querySelector('div[data-testid="postText"]');
        if (postContentElement) {
            const rawPostText = postContentElement.textContent;
            const cleanedPostText = cleanText(rawPostText);
            if (filteredTerms.some(term => {
                const pattern = new RegExp(escapeRegExp(term), 'i');
                return pattern.test(rawPostText.toLowerCase()) || pattern.test(cleanedPostText);
            })) {
                postContainer.remove();
                blockedCount++;
                updateMenuCommand();
                return;
            }
        }

        post.classList.add('bluesky-processed');
    }

    /***** OBSERVER SETUP *****/
    let observer = null;
    function observePosts() {
        observer = new MutationObserver((mutations) => {
            if (!shouldProcessPage()) return;
            mutations.forEach(mutation => {
                if (mutation.type === 'childList') {
                    const addedNodes = Array.from(mutation.addedNodes).filter(node => node.nodeType === Node.ELEMENT_NODE);
                    addedNodes.forEach(node => {
                        const authorLinks = node.querySelectorAll('a[href^="/profile/"]');
                        if (authorLinks.length > 0) {
                            authorLinks.forEach(authorLink => {
                                const container = getPostContainer(authorLink);
                                if (container) {
                                    setTimeout(() => processPost(container), 100);
                                }
                            });
                        }
                        const addedImages = node.querySelectorAll('img');
                        const addedAria = node.querySelectorAll('[aria-label]');
                        if (addedImages.length > 0 || addedAria.length > 0) {
                            const container = getPostContainer(node);
                            if (container) {
                                setTimeout(() => processPost(container), 100);
                            }
                        }
                    });
                } else if (mutation.type === 'attributes' && (mutation.attributeName === 'alt' || mutation.attributeName === 'aria-label')) {
                    const container = getPostContainer(mutation.target);
                    if (container) {
                        setTimeout(() => processPost(container), 100);
                    }
                }
            });
        });
        if (shouldProcessPage()) {
            observer.observe(document.body, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['alt', 'aria-label']
            });
        }
        let lastPath = window.location.pathname;
        setInterval(() => {
            if (window.location.pathname !== lastPath) {
                lastPath = window.location.pathname;
                if (!shouldProcessPage()) {
                    observer.disconnect();
                } else {
                    observer.observe(document.body, {
                        childList: true,
                        subtree: true,
                        attributes: true,
                        attributeFilter: ['alt', 'aria-label']
                    });
                }
            }
        }, 1000);
    }

    /***** INITIALIZATION *****/
    document.querySelectorAll('[data-testid="post"], article, div[role="link"]').forEach(el => processPost(el));
    updateMenuCommand();

    if (shouldProcessPage()) {
        waitForAuth().then(() => {
            autoWhitelistFollowedAccounts();
            observePosts();
        }).catch(() => {});
    }
})();