Bluesky Content Manager

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

// ==UserScript==
// @name         Bluesky Content Manager
// @namespace    https://gf.qytechs.cn/en/users/567951-stuart-saddler
// @version      3.4
// @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', false);
    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;
    }

    // Exclude profile pages and notifications.
    function shouldProcessPage() {
        const path = window.location.pathname;
        return !path.startsWith('/profile/') && path !== '/notifications';
    }

    /***** HELPER: Identify Content Images *****/
    function isContentImage(img) {
        // Exclude images that are likely avatars.
        // If the image is inside an element with data-testid="avatar", has a class "avatar",
        // or has the class "css-9pa8cd", then skip it.
        if (img.closest('[data-testid="avatar"]') || img.classList.contains('avatar') || img.classList.contains('css-9pa8cd')) {
            return false;
        }
        return true;
    }

    /***** 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 (delete posts with content images missing alt-text/aria-labels or with banned words)
            </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) {}
    }

    /***** COMBINED POST PROCESSING *****/
    async function processPost(post) {
        // 1. Whitelist check: bypass filtering for followed accounts.
        if (isWhitelisted(post)) {
            post.classList.add('bluesky-processed');
            return;
        }
        if (!shouldProcessPage() || post.classList.contains('bluesky-processed')) return;

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

        // Determine if the post contains images.
        const imageElements = post.querySelectorAll('img');
        // Filter out images that are likely avatars.
        const contentImages = Array.from(imageElements).filter(isContentImage);

        // 2. Alt‑text Enforcement: if enabled and at least one content image is present.
        if (altTextEnforcementEnabled && contentImages.length > 0) {
            for (const img of contentImages) {
                let altText = img.alt || "";
                let ariaLabel = img.getAttribute("aria-label") || "";
                let effectiveText = "";
                if (altText.trim() !== "") {
                    effectiveText = altText;
                } else if (ariaLabel.trim() !== "") {
                    effectiveText = ariaLabel;
                } else {
                    // Neither attribute contains text – remove the post.
                    postContainer.remove();
                    blockedCount++;
                    updateMenuCommand();
                    return;
                }
                // Scan the effective text for banned words.
                let cleanedEffectiveText = cleanText(effectiveText);
                if (filteredTerms.some(term => {
                    const pattern = new RegExp(`\\b${escapeRegExp(term)}\\b`, 'i');
                    return pattern.test(effectiveText) || pattern.test(cleanedEffectiveText);
                })) {
                    postContainer.remove();
                    blockedCount++;
                    updateMenuCommand();
                    return;
                }
            }
        }

        // 3. Blocklist Check: scan author names and post text for banned words.
        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(`\\b${escapeRegExp(term)}\\b`, 'i');
                return pattern.test(rawAuthorName.toLowerCase()) || pattern.test(cleanedAuthorName);
            })) {
                postContainer.remove();
                blockedCount++;
                updateMenuCommand();
                return;
            }
        }

        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(`\\b${escapeRegExp(term)}\\b`, 'i');
                return pattern.test(rawPostText.toLowerCase()) || pattern.test(cleanedPostText);
            })) {
                postContainer.remove();
                blockedCount++;
                updateMenuCommand();
                return;
            }
        }

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

    // Utility: Skip posts from whitelisted accounts.
    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}`));
    }

    /***** 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');
                        if (addedImages.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(() => {});
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址