Bluesky Content Manager

A powerful and reliable content filter for Bluesky. Features a keyword blocklist, manual/auto whitelisting, and alt-text enforcement.

// ==UserScript==
// @name         Bluesky Content Manager
// @namespace    https://gf.qytechs.cn/en/users/567951-stuart-saddler
// @version      3.5.2
// @description  A powerful and reliable content filter for Bluesky. Features a keyword blocklist, manual/auto whitelisting, and alt-text enforcement.
// @license      MIT
// @match        https://bsky.app/*
// @icon         https://i.ibb.co/YySpmDk/Bluesky-Content-Manager.png
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @connect      bsky.social
// @run-at       document-idle
// ==/UserScript==

(async function () {
'use strict';

/*** DEBUG CONFIG: Set to true to enable console logging for troubleshooting ***/
const DEBUG_MODE = false;
const DEEP_DEBUG = false; // Enables even more verbose logging
function debugLog(...args) { if (DEBUG_MODE) console.log('[BCM-DEBUG]', ...args); }
function debugWarn(...args) { if (DEBUG_MODE) console.warn('[BCM-WARN]', ...args); }
function debugError(...args) { if (DEBUG_MODE) console.error('[BCM-ERROR]', ...args); }
function deepDebug(...args) { if (DEEP_DEBUG) console.log('[BCM-DEEP]', ...args); }

/*** CONFIG & GLOBALS ***/
let filteredTerms, manualWhitelistTerms, autoWhitelistEnabled, whitelistedUsers, altTextEnforcementEnabled;
let blockedCount = 0, menuCommandId = null;
let sessionBlockedPosts = [];
const processedPosts = new WeakMap();
let totalPostsProcessed = 0, totalPostsBlocked = 0;

// Safe initialization with error handling
function initializeSettings() {
  try {
    filteredTerms = (JSON.parse(GM_getValue('filteredTerms','[]'))||[]).map(t=>t.trim().toLowerCase()).filter(Boolean);
    manualWhitelistTerms = (JSON.parse(GM_getValue('manualWhitelistTerms','[]'))||[]).map(t=>t.trim().toLowerCase()).filter(Boolean);
    autoWhitelistEnabled = GM_getValue('autoWhitelistEnabled', false);
    whitelistedUsers = new Set((JSON.parse(GM_getValue('whitelistedUsers','[]'))||[]).map(u=>normalizeUsername(u)));
    altTextEnforcementEnabled = GM_getValue('altTextEnforcementEnabled', false);

    debugLog('🔧 Settings initialized:', {
      filteredTermsCount: filteredTerms.length,
      manualWhitelistTermsCount: manualWhitelistTerms.length,
      autoWhitelistEnabled,
      altTextEnforcementEnabled,
      whitelistedUsersCount: whitelistedUsers.size
    });
  } catch (error) {
    debugError('Failed to initialize settings:', error);
    filteredTerms = []; manualWhitelistTerms = []; autoWhitelistEnabled = false;
    whitelistedUsers = new Set(); altTextEnforcementEnabled = false;
  }
}

/*** CSS ***/
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: 520px; font-family: Arial, sans-serif; color:#333; max-height: 80vh; overflow-y: auto; }
.bluesky-filter-dialog h2 { margin-top: 0; color: #0079d3; font-size: 1.5em; font-weight: bold; }
.bluesky-filter-dialog h3 { margin: 15px 0 5px 0; color: #0079d3; font-size: 1.1em; font-weight: bold; }
.bluesky-filter-dialog .section-label { font-size: 1.1em; font-weight: bold; margin: 0 0 5px 0; color: #0079d3; }
.bluesky-filter-dialog textarea { width: calc(100% - 16px); height: 100px; 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 .row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.bluesky-filter-dialog .button-container { display:flex; justify-content:flex-end; gap:10px; margin-top:10px; }
.bluesky-filter-dialog button { padding:8px 16px; border:none; border-radius:4px; cursor:pointer; font-size:1em; }
.bluesky-filter-dialog .save-btn { background-color:#0079d3; color:white; }
.bluesky-filter-dialog .cancel-btn { background-color:#f2f2f2; color:#333; }
.bluesky-filter-overlay { position: fixed; inset:0; background: rgba(0,0,0,0.5); z-index: 999999; }
.blocked-posts-section { margin-top: 15px; padding-top: 15px; border-top: 1px solid #eee; }
.blocked-posts-list { max-height: 200px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9; }
.blocked-post-item { padding: 8px 10px; border-bottom: 1px solid #eee; font-size: 0.85em; line-height: 1.3; }
.blocked-post-item:last-child { border-bottom: 0; }
.blocked-post-excerpt { color:#333; margin-bottom:4px; }
.blocked-post-reason { color:#666; font-size:0.8em; font-style:italic; }
.no-blocked-posts { padding: 20px; text-align:center; color:#888; font-style:italic; }
`;
GM_addStyle(CSS);

/*** UTILS ***/
function normalizeUsername(u){
  const normalized = u.toLowerCase().replace(/[\u200B-\u200F\u202A-\u202F]/g,'').trim();
  deepDebug('Normalized username:', u, '->', normalized);
  return normalized;
}
function escapeRegExp(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); }
function cleanText(t){ return t.normalize('NFKD').replace(/\s+/g,' ').toLowerCase().trim(); }
function termToRegex(term){
  if (!term || typeof term !== 'string' || term.trim() === '') return null;
  try {
    const esc = escapeRegExp(term);
    // This check correctly identifies simple words that benefit from word boundaries.
    const isPlainWord = /^[a-z]+$/i.test(term) && term.length >= 3;

    if (isPlainWord) {
        // For plain words, use word boundaries and common suffixes.
        const suffix = '(?:s|es|ed|ing|er|ers)?';
        return new RegExp(`\\b${esc}${suffix}\\b`, 'i');
    } else {
        // For emojis, phrases, or terms with special characters, don't use word boundaries.
        // This creates a simple, direct substring search.
        return new RegExp(esc, 'i');
    }
  } catch (error) {
    debugError('Failed to create regex for term:', term, error);
    return null;
  }
}
function getPostContainer(node){
  let cur=node;
  while(cur&&cur!==document.body){
    if(cur.matches('[data-testid="post"], div[role="link"], article')) return cur;
    cur=cur.parentElement;
  }
  return null;
}
function shouldRequireAltText(img) {
    const imgSrc = img.src || '';
    if (imgSrc.includes('/avatar') || imgSrc.includes('/banner')) {
        deepDebug('Image skipped (URL pattern) - likely avatar/banner:', imgSrc.substring(0, 70));
        return false;
    }
    if (img.closest('[data-testid="avatar"]') || img.classList.contains('avatar')) {
        deepDebug('Image skipped (DOM marker) - avatar:', imgSrc.substring(0, 70));
        return false;
    }
    const w = img.naturalWidth || img.width || 0;
    const h = img.naturalHeight || img.height || 0;
    if (w > 0 && h > 0 && (w <= 48 || h <= 48)) {
        deepDebug('Image skipped - too small:', `${w}x${h}`, imgSrc.substring(0, 70));
        return false;
    }
    if (img.getAttribute('aria-hidden') === 'true' || (img.getAttribute('role') || '').toLowerCase() === 'presentation') {
        deepDebug('Image skipped - decorative/presentation:', imgSrc.substring(0, 70));
        return false;
    }
    debugLog('Image REQUIRES alt-text:', `${w}x${h}`, imgSrc.substring(0, 70));
    return true;
}
function shouldProcessPage(){
  const p=window.location.pathname;
  const should = p !== '/notifications';
  deepDebug('Should process page?', p, '->', should);
  return should;
}

/*** WHITELIST BY CONTENT ***/
function isWhitelistedByContent(post){
  if (manualWhitelistTerms.length === 0) return false;
  const allText = post.textContent || "";
  const postText = post.querySelector('div[data-testid="postText"]')?.textContent || "";
  const altTexts = Array.from(post.querySelectorAll('img')).map(img => img.alt || img.getAttribute('aria-label') || "").join(" ");
  const ariaTitles = Array.from(post.querySelectorAll('[aria-label],[title]')).map(el => `${el.getAttribute('aria-label')||''} ${el.getAttribute('title')||''}`).join(' ');
  const combined = `${allText} ${postText} ${altTexts} ${ariaTitles}`;
  const cleaned = cleanText(combined);

  for (const term of manualWhitelistTerms) {
    const re = termToRegex(term);
    if (re && (re.test(combined) || re.test(cleaned))) {
      debugLog('✅ Post whitelisted by content term:', term);
      return true;
    }
  }
  return false;
}

/*** BLOCKED-POSTS LEDGER ***/
function addBlockedPost(post, reason, keyword){
  const excerpt = extractPostExcerpt(post);
  const author = extractAuthorHandle(post);
  const timestamp = new Date().toLocaleTimeString();
  const postId = `${author}:${excerpt.substring(0,50)}:${reason}:${keyword}`;
  if (!sessionBlockedPosts.some(b => b.postId === postId)) {
    sessionBlockedPosts.unshift({ postId, excerpt, author, reason, keyword, timestamp });
    // The 50-post limit has been removed to allow for a complete session log.
  }
  totalPostsBlocked++;
}
function extractPostExcerpt(post){
  const postText = post.querySelector('div[data-testid="postText"]');
  if (postText && postText.textContent.trim()) {
    const t=postText.textContent.trim(); return t.length>100? t.slice(0,100)+'...' : t;
  }
  const all=(post.textContent||'').replace(/\s+/g,' ').trim();
  return all? (all.length>100? all.slice(0,100)+'...' : all) : '[No text content]';
}
function extractAuthorHandle(post){
  const a = post.querySelector('a[href^="/profile/"]');
  if (!a) return '[Unknown author]';
  const seg = a.href.split('/profile/')[1]?.split(/[/?#]/)[0];
  return seg ? `@${seg}` : '[Unknown author]';
}

/*** MENU UI ***/
async function refreshSettings(){
  try {
    filteredTerms = (JSON.parse(await GM_getValue('filteredTerms','[]'))||[]).map(t=>t.trim().toLowerCase()).filter(Boolean);
    manualWhitelistTerms = (JSON.parse(await GM_getValue('manualWhitelistTerms','[]'))||[]).map(t=>t.trim().toLowerCase()).filter(Boolean);
    altTextEnforcementEnabled = await GM_getValue('altTextEnforcementEnabled', false);
    autoWhitelistEnabled = await GM_getValue('autoWhitelistEnabled', false);
  } catch (error) { debugError('Failed to refresh settings:', error); }
}
function updateMenuCommand(){
  if (menuCommandId) GM_unregisterMenuCommand(menuCommandId);
  menuCommandId = GM_registerMenuCommand(`Configure Filters (${totalPostsBlocked} blocked)`, showConfigUI);
}
async function showConfigUI(){ await refreshSettings(); createConfigUI(); }
function createConfigUI(){
  const overlay=document.createElement('div'); overlay.className='bluesky-filter-overlay';
  const dialog=document.createElement('div'); dialog.className='bluesky-filter-dialog';
  const blockedHTML = sessionBlockedPosts.length ? sessionBlockedPosts.map(p=>`<div class="blocked-post-item"><div class="blocked-post-excerpt">"${p.excerpt}" - ${p.author}</div><div class="blocked-post-reason">Blocked by "${p.keyword}" (${p.reason}) at ${p.timestamp}</div></div>`).join('') : '<div class="no-blocked-posts">No posts blocked this session</div>';
  dialog.innerHTML = `<h2>Bluesky Content Manager</h2><div class="section-label">Blocklist Keywords</div><textarea spellcheck="false" id="blocklist-textarea">${filteredTerms.join('\n')}</textarea><div class="row"><label><input type="checkbox" id="alt-text-checkbox" ${altTextEnforcementEnabled?'checked':''}> Enforce Alt-Text</label><label><input type="checkbox" id="auto-whitelist-checkbox" ${autoWhitelistEnabled?'checked':''}> Auto-Whitelist Followed Accounts</label></div><div class="section-label" style="margin-top:10px;">Manual Whitelist Override</div><div style="font-size:0.85em;color:#666;margin-top:-6px;">@usernames or keywords that override blocking (e.g., @alice.bsky.social or important)</div><textarea spellcheck="false" id="whitelist-textarea">${manualWhitelistTerms.join('\n')}</textarea><div class="blocked-posts-section"><h3>Blocked Posts This Session (${sessionBlockedPosts.length})</h3><div class="blocked-posts-list">${blockedHTML}</div></div><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 close=()=>{ dialog.remove(); overlay.remove(); };
  dialog.querySelector('.cancel-btn').addEventListener('click', close);
  overlay.addEventListener('click', close);
  dialog.querySelector('.save-btn').addEventListener('click', async ()=>{
    const terms = dialog.querySelector('#blocklist-textarea').value.split('\n').map(l=>l.trim().toLowerCase()).filter(Boolean);
    const whitelist = dialog.querySelector('#whitelist-textarea').value.split('\n').map(l=>l.trim().toLowerCase()).filter(Boolean);
    await GM_setValue('filteredTerms', JSON.stringify(terms)); await GM_setValue('manualWhitelistTerms', JSON.stringify(whitelist)); await GM_setValue('altTextEnforcementEnabled', dialog.querySelector('#alt-text-checkbox').checked); await GM_setValue('autoWhitelistEnabled', dialog.querySelector('#auto-whitelist-checkbox').checked);
    blockedCount=0; updateMenuCommand(); close(); location.reload();
  });
}

/*** AUTH & FOLLOWS ***/
let sessionToken=null, currentUserDid=null;
async function waitForAuth(){
  debugLog('Waiting for auth...');
  return new Promise((res,rej)=>{
    let tries=0, max=30;
    (function check(){
      tries++;
      const s=localStorage.getItem('BSKY_STORAGE');
      if(s){
        try{
          const p=JSON.parse(s);
          if(p.session?.accounts?.[0]?.accessJwt){
            sessionToken=p.session.accounts[0].accessJwt; currentUserDid=p.session.accounts[0].did;
            debugLog('Auth successful:', currentUserDid);
            return res();
          }
        }catch(e){ debugError('Auth parse error:', e); }
      }
      if(tries>=max) { debugWarn('Auth timeout'); return rej('Auth timeout'); }
      setTimeout(check,1000);
    })();
  });
}
async function fetchAllFollows(cursor=null, acc=[]){
  let url=`https://bsky.social/xrpc/app.bsky.graph.getFollows?actor=${encodeURIComponent(currentUserDid)}`;
  if(cursor) url+=`&cursor=${cursor}`;
  debugLog('Fetching follows:', url);
  return new Promise((res,rej)=>{
    GM_xmlhttpRequest({
      method:'GET', url, headers:{ 'Authorization':`Bearer ${sessionToken}`, 'Accept':'application/json' },
      onload(resp){
        if(resp.status===200){
          try{
            const d=JSON.parse(resp.responseText); const all=acc.concat(d.follows||[]);
            if(d.cursor) return fetchAllFollows(d.cursor, all).then(res).catch(rej);
            return res(all);
          }catch(e){ return rej(e); }
        }
        rej(`HTTP ${resp.status}`);
      },
      onerror:e=>{ rej(e); }
    });
  });
}
async function autoWhitelistFollowedAccounts(){
  if(!sessionToken||!currentUserDid) return;
  try{
    const f = await fetchAllFollows();
    f.forEach(u=>{
      let handle = (u.subject?.handle)||u.handle; let did = (u.subject?.did)||u.did;
      if(handle){ if(!handle.startsWith('@')) handle='@'+handle; whitelistedUsers.add(normalizeUsername(handle)); }
      if(did){ whitelistedUsers.add(normalizeUsername('@'+did)); }
    });
    debugLog('Auto-whitelisted', f.length, 'followed accounts');
  }catch(e){ debugError('Auto-whitelist error:', e); }
}

/*** CORE FILTER ***/
function regexMatchAny(text, terms){
  if (!text || !terms || terms.length === 0) return null;
  const cleaned = cleanText(text);
  for(const term of terms){
    try {
      const re = termToRegex(term);
      if (re && (re.test(text) || re.test(cleaned))) {
        debugLog('🎯 MATCH FOUND:', term, 'in text:', text.substring(0, 50) + '...');
        return term;
      }
    } catch(e) { continue; }
  }
  return null;
}
async function processPost(post){
  totalPostsProcessed++;
  if(!shouldProcessPage()) return;
  const container=getPostContainer(post);
  if(!container) return;

  const postTextNode = container.querySelector('div[data-testid="postText"]');
  const postText = postTextNode ? postTextNode.textContent : '';
  const author = extractAuthorHandle(container);
  const sig = [postText, author].join('||');
  if (processedPosts.get(container) === sig) return;
  processedPosts.set(container, sig);

  if(isWhitelisted(container) || isWhitelistedByContent(container)) {
    debugLog('✅ Post whitelisted by user or content');
    return;
  }

  const imgsAll = Array.from(container.querySelectorAll('img'));

  if (altTextEnforcementEnabled) {
    const imagesNeedingAlt = imgsAll.filter(shouldRequireAltText);
    if (imagesNeedingAlt.length > 0) {
      const missingAlt = imagesNeedingAlt.some(img => !(img.alt || '').trim());
      if (missingAlt) {
        debugLog('🚫 BLOCKING POST - Alt-text enforcement');
        addBlockedPost(container, "Alt-text enforcement", "missing alt-text");
        container.classList.add('content-filtered'); blockedCount++; updateMenuCommand(); return;
      }
    }
  }

  const checkAndBlock = (text, reason, terms) => {
    if (!text) return false;
    const matched = regexMatchAny(text, terms);
    if(matched){
      debugLog(`🚫 BLOCKING POST - ${reason} match:`, matched);
      addBlockedPost(container, reason, matched); container.classList.add('content-filtered'); blockedCount++; updateMenuCommand(); return true;
    }
    return false;
  };

  for(const img of imgsAll){
    const txt = ((img.alt ?? img.getAttribute('aria-label')) ?? '').trim();
    if(checkAndBlock(txt, "Alt-text keyword", filteredTerms)) return;
  }
  const authorLink=container.querySelector('a[href^="/profile/"]');
  if(authorLink) if(checkAndBlock(authorLink.querySelector('span')?.textContent||authorLink.textContent||"", "Author name", filteredTerms)) return;
  if(checkAndBlock(postText, "Post text", filteredTerms)) return;
  const ariaTitles = Array.from(container.querySelectorAll('[aria-label],[title]')).map(el => `${el.getAttribute('aria-label')||''} ${el.getAttribute('title')||''}`).join(' ');
  if(checkAndBlock(ariaTitles, "ARIA/TITLE attribute", filteredTerms)) return;
  if(checkAndBlock(container.textContent||"", "General content", filteredTerms)) return;

  debugLog('✅ POST PASSED ALL FILTERS');
}
function isWhitelisted(post){
  const link=post.querySelector('a[href^="/profile/"]');
  if(!link) return false;
  const id=link.href.split('/profile/')[1]?.split(/[/?#]/)[0];
  return whitelistedUsers.has(normalizeUsername('@'+id));
}

/*** OBSERVE + ROUTE WATCH ***/
function observePosts(){
  debugLog('👀 Starting post observation...');
  const processNode = (node) => {
    if (node.nodeType !== 1) return;
    const container = getPostContainer(node);
    if (container) setTimeout(() => processPost(container), 150);
  };
  const observer=new MutationObserver(muts=>{
    if(!shouldProcessPage()) return;
    for (const m of muts) {
      for (const node of m.addedNodes) processNode(node);
      if (m.type === 'attributes') processNode(m.target);
    }
  });
  observer.observe(document.body,{ childList:true, subtree:true, attributes:true, attributeFilter:['alt','aria-label','src']});
}

/*** INIT ***/
async function main() {
    debugLog('🚀 Bluesky Content Manager starting...');
    initializeSettings();
    document.querySelectorAll('[data-testid="post"], article').forEach(el=>processPost(el));
    updateMenuCommand();
    observePosts();

    try{
        await waitForAuth();
        if(autoWhitelistEnabled) await autoWhitelistFollowedAccounts();
        debugLog('✅ Initialization complete with auth');
    }catch(e){
        debugWarn('Auth failed, continuing without auto-whitelist:', e);
        debugLog('✅ Initialization complete without auth');
    }

    debugLog('🎯 Script fully loaded.');
}

main();

})();

QingJ © 2025

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