Bluesky Content Manager

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

当前为 2025-03-31 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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(() => {});
    }
})();