bouncer-feed-filter

AI-powered browser extension that filters unwanted posts from Twitter/X feeds using natural language rules and multiple AI backends

INSTALLATION
npx skills add https://github.com/aradotso/trending-skills --skill bouncer-feed-filter
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Bouncer Feed Filter

Skill by ara.so — Daily 2026 Skills collection.

Bouncer is a browser extension (Chrome/Edge/iOS) that uses AI to filter unwanted posts from Twitter/X feeds in real time. Users define filters in plain language ("crypto", "engagement bait", "rage politics"), and Bouncer classifies and hides matching posts using AI models — local (WebGPU via WebLLM) or cloud (OpenAI, Gemini, Anthropic, OpenRouter, Imbue).

Repository Structure

Bouncer/                  # Main extension source

  src/

    background/           # Service worker / background scripts

    content/              # Content scripts (Twitter DOM interaction)

    popup/                # Extension popup UI

    adapters/             # Site adapters (Twitter/X)

    models/               # AI backend integrations

    utils/                # Shared utilities

  icons/                  # Extension icons

  manifest.json           # Chrome extension manifest

  package.json

  tsconfig.json

Installation & Build

From Source (Chrome/Edge)

git clone https://github.com/imbue-ai/bouncer.git

cd bouncer/Bouncer

npm install

npm run build

Load in Chrome:

  • Go to chrome://extensions
  • Enable Developer mode
  • Click Load unpacked → select Bouncer/ folder
  • Navigate to twitter.com or x.com

Development Build (watch mode)

cd Bouncer

npm run dev        # watch mode with hot rebuild

Production Build

npm run build      # outputs to Bouncer/dist or inline

AI Backend Configuration

Bouncer supports multiple providers. Configure via the extension popup Settings panel.

Provider / Model Matrix

Provider

Model IDs

Auth

Local WebGPU

Qwen3-4B, Qwen3.5-4B, Qwen3.5-4B Vision

None

OpenAI

GPT-5 Nano, gpt-oss-20b

API key

Google Gemini

2.5 Flash Lite, 2.5 Flash, 3 Flash Preview

API key

Anthropic

Claude Haiku 4.5

API key

OpenRouter

Nemotron Nano 12B VL, Ministral 3B

Account token

Imbue

Default

None (built-in)

API keys are stored in Chrome's chrome.storage.local — never hardcoded.

Core Architecture

1. MutationObserver — Content Script

The content script watches the Twitter feed for new posts:

// src/content/feedObserver.ts

const observer = new MutationObserver((mutations) => {

  for (const mutation of mutations) {

    for (const node of mutation.addedNodes) {

      if (node instanceof HTMLElement) {

        const post = extractPost(node);

        if (post) classifyAndFilter(post);

      }

    }

  }

});

observer.observe(document.body, { childList: true, subtree: true });

2. Post Extraction — Twitter Adapter

// src/adapters/twitter.ts

export interface ExtractedPost {

  id: string;

  text: string;

  authorHandle: string;

  imageUrls: string[];

  element: HTMLElement;

}

export function extractPost(element: HTMLElement): ExtractedPost | null {

  const article = element.querySelector('article[data-testid="tweet"]');

  if (!article) return null;

  const tweetText = article.querySelector('[data-testid="tweetText"]')?.textContent ?? '';

  const handle = article.querySelector('[data-testid="User-Name"] a')?.getAttribute('href') ?? '';

  const images = [...article.querySelectorAll('img[src*="pbs.twimg.com/media"]')]

    .map(img => (img as HTMLImageElement).src);

  return {

    id: article.closest('[data-testid]')?.getAttribute('data-testid') ?? crypto.randomUUID(),

    text: tweetText,

    authorHandle: handle.replace('/', ''),

    imageUrls: images,

    element: article as HTMLElement,

  };

}

3. Classification Request

// src/models/classify.ts

export interface ClassificationResult {

  filtered: boolean;

  matchedCategory: string | null;

  reasoning: string;

}

export async function classifyPost(

  post: ExtractedPost,

  filters: string[],

  model: ModelConfig

): Promise<ClassificationResult> {

  const prompt = buildClassificationPrompt(post.text, filters, post.imageUrls);

  const response = await model.provider.complete(prompt);

  return parseClassificationResponse(response);

}

function buildClassificationPrompt(

  text: string,

  filters: string[],

  imageUrls: string[]

): string {

  return `You are a content filter. Given a social media post, determine if it matches any of the user's filter categories.

Filter categories: ${filters.map(f => `"${f}"`).join(', ')}

Post text:

${text}

${imageUrls.length > 0 ? `The post contains ${imageUrls.length} image(s).` : ''}

Respond with JSON:

{

  "filtered": boolean,

  "matchedCategory": "category name or null",

  "reasoning": "brief explanation"

}`;

}

4. Hiding Filtered Posts

// src/content/filterUI.ts

export function hidePost(element: HTMLElement, reason: string): void {

  element.style.transition = 'opacity 0.3s ease-out';

  element.style.opacity = '0';

  setTimeout(() => {

    element.style.display = 'none';

    element.dataset.bouncerFiltered = 'true';

    element.dataset.bouncerReason = reason;

  }, 300);

}

export function showFilteredIndicator(count: number): void {

  const indicator = document.getElementById('bouncer-filtered-count');

  if (indicator) indicator.textContent = `${count} filtered`;

}

Adding a New AI Provider

// src/models/providers/myProvider.ts

import type { ModelProvider, CompletionRequest, CompletionResponse } from '../types';

export class MyProvider implements ModelProvider {

  private apiKey: string;

  private endpoint = 'https://api.myprovider.com/v1/chat/completions';

  constructor(apiKey: string) {

    this.apiKey = apiKey;

  }

  async complete(request: CompletionRequest): Promise<CompletionResponse> {

    const response = await fetch(this.endpoint, {

      method: 'POST',

      headers: {

        'Authorization': `Bearer ${this.apiKey}`,

        'Content-Type': 'application/json',

      },

      body: JSON.stringify({

        model: request.model,

        messages: [{ role: 'user', content: request.prompt }],

        max_tokens: 256,

      }),

    });

    const data = await response.json();

    return {

      text: data.choices[0].message.content,

      usage: data.usage,

    };

  }

}

Register it in the provider registry:

// src/models/registry.ts

import { MyProvider } from './providers/myProvider';

export function createProvider(config: StoredConfig): ModelProvider {

  switch (config.provider) {

    case 'my-provider':

      return new MyProvider(config.apiKey);

    // ... other cases

  }

}

Result Caching

Bouncer caches classification results so repeated posts don't trigger new inference calls:

// src/utils/cache.ts

const CACHE_KEY = 'bouncer-post-cache';

export async function getCachedResult(postId: string): Promise<ClassificationResult | null> {

  const stored = await chrome.storage.local.get(CACHE_KEY);

  const cache = stored[CACHE_KEY] ?? {};

  return cache[postId] ?? null;

}

export async function cacheResult(postId: string, result: ClassificationResult): Promise<void> {

  const stored = await chrome.storage.local.get(CACHE_KEY);

  const cache = stored[CACHE_KEY] ?? {};

  cache[postId] = result;

  // Limit cache size

  const keys = Object.keys(cache);

  if (keys.length > 1000) delete cache[keys[0]];

  await chrome.storage.local.set({ [CACHE_KEY]: cache });

}

Filter Management

Filters are stored and retrieved via chrome.storage.sync:

// src/utils/filters.ts

export async function getFilters(): Promise<string[]> {

  const result = await chrome.storage.sync.get('bouncerFilters');

  return result.bouncerFilters ?? [];

}

export async function addFilter(topic: string): Promise<void> {

  const filters = await getFilters();

  if (!filters.includes(topic)) {

    await chrome.storage.sync.set({ bouncerFilters: [...filters, topic] });

  }

}

export async function removeFilter(topic: string): Promise<void> {

  const filters = await getFilters();

  await chrome.storage.sync.set({

    bouncerFilters: filters.filter(f => f !== topic),

  });

}

Local WebGPU Models (WebLLM)

Local models run entirely in-browser via WebGPU — zero data sent externally:

// src/models/providers/webllm.ts

import { CreateMLCEngine, type MLCEngine } from '@mlc-ai/web-llm';

let engine: MLCEngine | null = null;

export async function loadLocalModel(modelId: string, onProgress?: (p: number) => void): Promise<void> {

  engine = await CreateMLCEngine(modelId, {

    initProgressCallback: (report) => onProgress?.(report.progress),

  });

}

export async function localComplete(prompt: string): Promise<string> {

  if (!engine) throw new Error('Local model not loaded');

  const response = await engine.chat.completions.create({

    messages: [{ role: 'user', content: prompt }],

    max_tokens: 256,

  });

  return response.choices[0].message.content ?? '';

}

Chrome Extension Manifest Key Points

{

  "manifest_version": 3,

  "permissions": ["storage", "activeTab", "scripting"],

  "host_permissions": ["https://twitter.com/*", "https://x.com/*"],

  "background": { "service_worker": "background.js" },

  "content_scripts": [{

    "matches": ["https://twitter.com/*", "https://x.com/*"],

    "js": ["content.js"],

    "run_at": "document_idle"

  }]

}

Troubleshooting

Problem

Fix

Extension not loading

Ensure npm run build completed without errors; reload unpacked extension

Posts not being filtered

Check that filters are saved in popup; open DevTools on x.com and check console for errors

API key errors

Verify key is stored via Settings panel, not hardcoded; check provider dashboard for quota

Local model not loading

Browser must support WebGPU (chrome://flags/#enable-unsafe-webgpu); first load downloads model (~2-4GB)

Filtered count not updating

MutationObserver may have detached; reload the page

TypeScript errors on build

Run npm install to ensure all types are present; check tsconfig.json target is ES2020+

Common Patterns

Check if a post should be processed (before API call):

async function classifyAndFilter(post: ExtractedPost): Promise<void> {

  // Skip if already processed

  if (post.element.dataset.bouncerProcessed) return;

  post.element.dataset.bouncerProcessed = 'true';

  // Check cache first

  const cached = await getCachedResult(post.id);

  if (cached) {

    if (cached.filtered) hidePost(post.element, cached.reasoning);

    return;

  }

  const filters = await getFilters();

  if (filters.length === 0) return;

  const config = await getModelConfig();

  const result = await classifyPost(post, filters, config);

  await cacheResult(post.id, result);

  if (result.filtered) hidePost(post.element, result.reasoning);

}

Storing API key securely (popup UI):

// Never log or expose the key — store only via chrome.storage.local

async function saveApiKey(provider: string, key: string): Promise<void> {

  await chrome.storage.local.set({ [`${provider}_api_key`]: key });

}

async function getApiKey(provider: string): Promise<string> {

  const result = await chrome.storage.local.get(`${provider}_api_key`);

  return result[`${provider}_api_key`] ?? '';

}
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card