bouncer-feed-filter

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Bouncer Feed Filter

Bouncer 动态流过滤器

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).
ara.so开发的Skill——属于Daily 2026 Skills合集。
Bouncer是一款浏览器扩展(支持Chrome/Edge/iOS),可利用AI实时过滤Twitter/X动态流中不受欢迎的帖子。用户可以用自然语言定义过滤规则(如“加密货币”、“互动诱饵”、“激进政治内容”),Bouncer会通过AI模型——本地模型(通过WebLLM基于WebGPU运行)或云端模型(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
Bouncer/                  # 主扩展源码
  src/
    background/           # 服务工作线程/后台脚本
    content/              # 内容脚本(与Twitter DOM交互)
    popup/                # 扩展弹窗UI
    adapters/             # 站点适配器(适配Twitter/X)
    models/               # AI后端集成模块
    utils/                # 通用工具函数
  icons/                  # 扩展图标
  manifest.json           # Chrome扩展清单文件
  package.json
  tsconfig.json

Installation & Build

安装与构建

From Source (Chrome/Edge)

从源码安装(Chrome/Edge)

bash
git clone https://github.com/imbue-ai/bouncer.git
cd bouncer/Bouncer
npm install
npm run build
Load in Chrome:
  1. Go to
    chrome://extensions
  2. Enable Developer mode
  3. Click Load unpacked → select
    Bouncer/
    folder
  4. Navigate to
    twitter.com
    or
    x.com
bash
git clone https://github.com/imbue-ai/bouncer.git
cd bouncer/Bouncer
npm install
npm run build
在Chrome中加载:
  1. 打开
    chrome://extensions
  2. 开启开发者模式
  3. 点击加载已解压的扩展程序 → 选择
    Bouncer/
    文件夹
  4. 访问
    twitter.com
    x.com

Development Build (watch mode)

开发构建(监听模式)

bash
cd Bouncer
npm run dev        # watch mode with hot rebuild
bash
cd Bouncer
npm run dev        # 监听模式,支持热重载

Production Build

生产构建

bash
npm run build      # outputs to Bouncer/dist or inline
bash
npm run build      # 输出到Bouncer/dist目录或内嵌

AI Backend Configuration

AI后端配置

Bouncer supports multiple providers. Configure via the extension popup Settings panel.
Bouncer支持多种服务商,可通过扩展弹窗的设置面板进行配置。

Provider / Model Matrix

服务商/模型矩阵

ProviderModel IDsAuth
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
ImbueDefaultNone (built-in)
API keys are stored in Chrome's
chrome.storage.local
— never hardcoded.
服务商模型ID认证方式
本地WebGPU
Qwen3-4B
,
Qwen3.5-4B
,
Qwen3.5-4B Vision
无需认证
OpenAI
GPT-5 Nano
,
gpt-oss-20b
API密钥
Google Gemini
2.5 Flash Lite
,
2.5 Flash
,
3 Flash Preview
API密钥
Anthropic
Claude Haiku 4.5
API密钥
OpenRouter
Nemotron Nano 12B VL
,
Ministral 3B
账户令牌
Imbue默认无需认证(内置)
API密钥存储在Chrome的
chrome.storage.local
中——绝不会硬编码。

Core Architecture

核心架构

1. MutationObserver — Content Script

1. MutationObserver — 内容脚本

The content script watches the Twitter feed for new posts:
typescript
// 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 });
内容脚本监听Twitter动态流中的新帖子:
typescript
// 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

2. 帖子提取 — Twitter适配器

typescript
// 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,
  };
}
typescript
// 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

3. 分类请求

typescript
// 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"
}`;
}
typescript
// 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

4. 隐藏已过滤帖子

typescript
// 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`;
}
typescript
// 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

添加新AI服务商

typescript
// 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:
typescript
// 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
  }
}
typescript
// 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,
    };
  }
}
在服务商注册表中注册:
typescript
// 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);
    // ... 其他服务商的case
  }
}

Result Caching

结果缓存

Bouncer caches classification results so repeated posts don't trigger new inference calls:
typescript
// 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 });
}
Bouncer会缓存分类结果,避免重复帖子触发新的推理调用:
typescript
// 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;
  // 限制缓存大小
  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
:
typescript
// 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),
  });
}
过滤规则通过
chrome.storage.sync
存储和获取:
typescript
// 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)

本地WebGPU模型(WebLLM)

Local models run entirely in-browser via WebGPU — zero data sent externally:
typescript
// 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 ?? '';
}
本地模型完全在浏览器中通过WebGPU运行——不会向外部发送任何数据:
typescript
// 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

Chrome扩展清单关键点

json
{
  "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"
  }]
}
json
{
  "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

故障排查

ProblemFix
Extension not loadingEnsure
npm run build
completed without errors; reload unpacked extension
Posts not being filteredCheck that filters are saved in popup; open DevTools on x.com and check console for errors
API key errorsVerify key is stored via Settings panel, not hardcoded; check provider dashboard for quota
Local model not loadingBrowser must support WebGPU (
chrome://flags/#enable-unsafe-webgpu
); first load downloads model (~2-4GB)
Filtered count not updatingMutationObserver may have detached; reload the page
TypeScript errors on buildRun
npm install
to ensure all types are present; check
tsconfig.json
target is
ES2020+
问题解决方案
扩展无法加载确保
npm run build
执行无错误;重新加载已解压的扩展程序
帖子未被过滤检查弹窗中是否已保存过滤规则;在x.com打开开发者工具,查看控制台是否有错误
API密钥错误验证密钥是否通过设置面板存储,而非硬编码;检查服务商控制台的配额情况
本地模型无法加载浏览器必须支持WebGPU(可开启
chrome://flags/#enable-unsafe-webgpu
);首次加载会下载模型(约2-4GB)
已过滤计数不更新MutationObserver可能已脱离;重新加载页面
构建时出现TypeScript错误执行
npm install
确保所有类型依赖已安装;检查
tsconfig.json
的目标版本为
ES2020+

Common Patterns

常见模式

Check if a post should be processed (before API call):
typescript
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):
typescript
// 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`] ?? '';
}
检查帖子是否需要处理(API调用前):
typescript
async function classifyAndFilter(post: ExtractedPost): Promise<void> {
  // 跳过已处理的帖子
  if (post.element.dataset.bouncerProcessed) return;
  post.element.dataset.bouncerProcessed = 'true';

  // 先检查缓存
  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);
}
安全存储API密钥(弹窗UI):
typescript
// 绝不要记录或暴露密钥——仅通过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`] ?? '';
}