bouncer-feed-filter
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBouncer 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.jsonBouncer/ # 主扩展源码
src/
background/ # 服务工作线程/后台脚本
content/ # 内容脚本(与Twitter DOM交互)
popup/ # 扩展弹窗UI
adapters/ # 站点适配器(适配Twitter/X)
models/ # AI后端集成模块
utils/ # 通用工具函数
icons/ # 扩展图标
manifest.json # Chrome扩展清单文件
package.json
tsconfig.jsonInstallation & Build
安装与构建
From Source (Chrome/Edge)
从源码安装(Chrome/Edge)
bash
git clone https://github.com/imbue-ai/bouncer.git
cd bouncer/Bouncer
npm install
npm run buildLoad in Chrome:
- Go to
chrome://extensions - Enable Developer mode
- Click Load unpacked → select folder
Bouncer/ - Navigate to or
twitter.comx.com
bash
git clone https://github.com/imbue-ai/bouncer.git
cd bouncer/Bouncer
npm install
npm run build在Chrome中加载:
- 打开
chrome://extensions - 开启开发者模式
- 点击加载已解压的扩展程序 → 选择 文件夹
Bouncer/ - 访问 或
twitter.comx.com
Development Build (watch mode)
开发构建(监听模式)
bash
cd Bouncer
npm run dev # watch mode with hot rebuildbash
cd Bouncer
npm run dev # 监听模式,支持热重载Production Build
生产构建
bash
npm run build # outputs to Bouncer/dist or inlinebash
npm run build # 输出到Bouncer/dist目录或内嵌AI Backend Configuration
AI后端配置
Bouncer supports multiple providers. Configure via the extension popup Settings panel.
Bouncer支持多种服务商,可通过扩展弹窗的设置面板进行配置。
Provider / Model Matrix
服务商/模型矩阵
| Provider | Model IDs | Auth |
|---|---|---|
| Local WebGPU | | None |
| OpenAI | | API key |
| Google Gemini | | API key |
| Anthropic | | API key |
| OpenRouter | | Account token |
| Imbue | Default | None (built-in) |
API keys are stored in Chrome's — never hardcoded.
chrome.storage.local| 服务商 | 模型ID | 认证方式 |
|---|---|---|
| 本地WebGPU | | 无需认证 |
| OpenAI | | API密钥 |
| Google Gemini | | API密钥 |
| Anthropic | | API密钥 |
| OpenRouter | | 账户令牌 |
| Imbue | 默认 | 无需认证(内置) |
API密钥存储在Chrome的 中——绝不会硬编码。
chrome.storage.localCore 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.synctypescript
// 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.synctypescript
// 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
故障排查
| Problem | Fix |
|---|---|
| Extension not loading | Ensure |
| 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 ( |
| Filtered count not updating | MutationObserver may have detached; reload the page |
| TypeScript errors on build | Run |
| 问题 | 解决方案 |
|---|---|
| 扩展无法加载 | 确保 |
| 帖子未被过滤 | 检查弹窗中是否已保存过滤规则;在x.com打开开发者工具,查看控制台是否有错误 |
| API密钥错误 | 验证密钥是否通过设置面板存储,而非硬编码;检查服务商控制台的配额情况 |
| 本地模型无法加载 | 浏览器必须支持WebGPU(可开启 |
| 已过滤计数不更新 | MutationObserver可能已脱离;重新加载页面 |
| 构建时出现TypeScript错误 | 执行 |
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`] ?? '';
}