v1.0.0 — Agent Eyes: search + read the entire internet
Major restructure from x-reader fork to independent project: Architecture: - readers/ — content extraction from 10+ platforms (based on x-reader, MIT) - search/ — semantic search via Exa, GitHub API, birdx (NEW) - config.py — configuration management (~/.agent-eyes/config.yaml) (NEW) - doctor.py — environment health checker (NEW) - core.py — AgentEyes unified entry point (NEW) - cli.py — full CLI: read, search, setup, doctor (NEW) - integrations/mcp_server.py — 8 MCP tools (NEW) - guides/ — 6 Agent-readable setup guides (NEW) - integrations/skill/ — OpenClaw Skill package (NEW) Platforms (zero config): - Web pages, GitHub, Bilibili, YouTube, RSS, single tweets Platforms (one free API key): - Web search, Reddit search, Twitter search (via Exa) Platforms (optional setup): - Reddit full reader, Twitter advanced, WeChat, XiaoHongShu Tests: 34/34 passing Credits: Built on x-reader by @runes_leo (MIT License)
This commit is contained in:
+12
-11
@@ -1,14 +1,15 @@
|
||||
# === Telegram (optional, for TG channel fetching) ===
|
||||
TG_API_ID=
|
||||
TG_API_HASH=
|
||||
# Agent Eyes Configuration
|
||||
# Copy to .env and fill in your values
|
||||
# Or use: agent-eyes setup
|
||||
|
||||
# === Groq Whisper (optional, for video/audio transcription) ===
|
||||
# Free API key: https://console.groq.com/keys
|
||||
GROQ_API_KEY=
|
||||
# Exa Search (free 1000/month) — https://exa.ai
|
||||
# EXA_API_KEY=exa-your-key-here
|
||||
|
||||
# === Gemini (optional, for AI-powered analysis) ===
|
||||
GEMINI_API_KEY=
|
||||
# GitHub Token (optional, for higher rate limits) — https://github.com/settings/tokens
|
||||
# GITHUB_TOKEN=ghp_your_token_here
|
||||
|
||||
# === Output ===
|
||||
OUTPUT_DIR=./output
|
||||
INBOX_FILE=./unified_inbox.json
|
||||
# Reddit ISP Proxy (optional, for full Reddit access)
|
||||
# REDDIT_PROXY=http://user:pass@ip:port
|
||||
|
||||
# Groq Whisper (optional, for video transcription) — https://console.groq.com
|
||||
# GROQ_API_KEY=gsk_your_key_here
|
||||
|
||||
+4
-26
@@ -1,32 +1,10 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# Telethon session
|
||||
*.session
|
||||
tg_session*
|
||||
|
||||
# Data
|
||||
*.db
|
||||
.agent-eyes/
|
||||
*.log
|
||||
data/
|
||||
unified_inbox.json
|
||||
output/
|
||||
|
||||
# Audio/video temp files
|
||||
/tmp/media_*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -2,24 +2,22 @@
|
||||
|
||||
**Give your AI Agent eyes to see the entire internet.**
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](LICENSE)
|
||||
Agent Eyes is infrastructure for the Agent world — install once, and your AI Agent can search and read content across 10+ platforms. Zero to minimal configuration.
|
||||
|
||||
Agent Eyes is an open-source infrastructure tool that gives any AI Agent the ability to **search** and **read** the entire internet. One install, 10+ platforms, unified output.
|
||||
> *Built on top of [x-reader](https://github.com/runesleo/x-reader) by [@runes_leo](https://x.com/runes_leo) — thank you for the amazing open-source work. 🙏*
|
||||
|
||||
> 🙏 Built on the shoulders of [x-reader](https://github.com/runesleo/x-reader) by [@runes_leo](https://x.com/runes_leo). Thank you for the foundation!
|
||||
---
|
||||
|
||||
## Why Agent Eyes?
|
||||
|
||||
Your AI Agent is blind. It can only see what you manually feed it.
|
||||
AI Agents are powerful, but blind. They can't browse Reddit, search Twitter, or read WeChat articles. Agent Eyes fixes that.
|
||||
|
||||
Agent Eyes gives it **eyes** — the ability to:
|
||||
- **Search** the web, Reddit, GitHub with a single command
|
||||
- **Read** any URL from 10+ platforms (articles, videos, tweets, posts)
|
||||
- **Transcribe** videos and podcasts to text
|
||||
- **One install, 10+ platforms** — web pages, GitHub, Reddit, Twitter, YouTube, Bilibili, WeChat, XiaoHongShu, RSS, Telegram
|
||||
- **Search + Read** — not just URL extraction, but actual search capability (find, then read)
|
||||
- **Agent-native design** — CLI, MCP Server, Python API — works with any AI Agent platform
|
||||
- **Mostly free** — 99% of features cost nothing. One free API key unlocks full search.
|
||||
|
||||
Without this, your Agent is a chatbot waiting for instructions.
|
||||
With this, it can autonomously find and consume information — just like you do.
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -27,134 +25,232 @@ With this, it can autonomously find and consume information — just like you do
|
||||
# Install
|
||||
pip install git+https://github.com/Panniantong/agent-eyes.git
|
||||
|
||||
# Search the web
|
||||
agent-eyes search "AI agent framework 2026"
|
||||
# Check what's available
|
||||
agent-eyes doctor
|
||||
|
||||
# Read any URL
|
||||
agent-eyes read https://reddit.com/r/LocalLLaMA/comments/xxx
|
||||
agent-eyes read https://github.com/openai/codex
|
||||
agent-eyes read https://mp.weixin.qq.com/s/xxx
|
||||
agent-eyes read https://x.com/elonmusk/status/xxx
|
||||
# Read any URL (works immediately, no config needed)
|
||||
agent-eyes read "https://github.com/openai/gpt-4"
|
||||
agent-eyes read "https://www.bilibili.com/video/BV1xx411c7mD"
|
||||
|
||||
# Your Agent now has eyes 👁️
|
||||
# Search GitHub (works immediately, no config needed)
|
||||
agent-eyes search-github "LLM framework"
|
||||
|
||||
# Unlock search (one free API key)
|
||||
agent-eyes setup
|
||||
agent-eyes search "AI agent infrastructure 2025"
|
||||
agent-eyes search-reddit "best self-hosted LLM" --sub LocalLLaMA
|
||||
agent-eyes search-twitter "OpenClaw agent"
|
||||
```
|
||||
|
||||
## Supported Platforms
|
||||
---
|
||||
|
||||
| Platform | Read URL | Search | Notes |
|
||||
|----------|:--------:|:------:|-------|
|
||||
| 🔍 Web (any) | ✅ | ✅ Exa | Semantic search across the entire web |
|
||||
| 🟠 Reddit | ✅ | ✅ | Posts + comments. Proxy support via `REDDIT_PROXY` |
|
||||
| 🐙 GitHub | ✅ | ✅ | Repos (README), Issues, PRs |
|
||||
| 🐦 X / Twitter | ✅ | — | Tweets and threads |
|
||||
| 💬 WeChat (微信公众号) | ✅ | — | Anti-scraping bypass via Playwright |
|
||||
| 📕 Xiaohongshu (小红书) | ✅ | — | Session persistence for login-gated content |
|
||||
| ▶️ YouTube | ✅ | — | Subtitles + Whisper transcription |
|
||||
| 📺 Bilibili (B站) | ✅ | — | Official API |
|
||||
| ✈️ Telegram | ✅ | — | Channel message sync |
|
||||
| 📡 RSS | ✅ | — | Any RSS/Atom feed |
|
||||
| 🎙️ Podcasts | ✅ | — | 小宇宙, Apple Podcasts (via Whisper) |
|
||||
## Platform Support
|
||||
|
||||
## Three Layers
|
||||
### ✅ Zero Config (works out of the box)
|
||||
|
||||
Use the layers you need:
|
||||
| Platform | Read | Search | Notes |
|
||||
|----------|:----:|:------:|-------|
|
||||
| Web Pages | ✅ | — | Any URL via Jina Reader |
|
||||
| GitHub | ✅ | ✅ | Repos, issues, PRs, code |
|
||||
| Bilibili | ✅ | — | Videos with subtitles |
|
||||
| YouTube | ✅ | — | Videos with subtitles (needs yt-dlp) |
|
||||
| RSS | ✅ | — | Any RSS/Atom feed |
|
||||
| Single Tweet | ✅ | — | Via Jina Reader |
|
||||
|
||||
| Layer | What | For |
|
||||
|-------|------|-----|
|
||||
| **CLI** | `agent-eyes read/search` | Quick command-line use |
|
||||
| **MCP Server** | 7 tools for any AI Agent | OpenClaw, Claude Code, etc. |
|
||||
| **Python Library** | `from agent_eyes import UniversalReader` | Custom integrations |
|
||||
### 🔑 One Free API Key (30 seconds to set up)
|
||||
|
||||
### As MCP Server (recommended for Agents)
|
||||
| Platform | Read | Search | Notes |
|
||||
|----------|:----:|:------:|-------|
|
||||
| Web (semantic) | — | ✅ | Exa API (1000 free/month) |
|
||||
| Reddit | — | ✅ | Via Exa (site:reddit.com) |
|
||||
| Twitter/X | — | ✅ | Via Exa (site:x.com) |
|
||||
|
||||
### ⚙️ Optional Setup
|
||||
|
||||
| Platform | Read | Search | What's Needed |
|
||||
|----------|:----:|:------:|---------------|
|
||||
| Reddit (full) | ✅ | — | ISP proxy (~$3-10/mo) |
|
||||
| Twitter (advanced) | ✅ | ✅ | birdx + browser cookies (free) |
|
||||
| WeChat Articles | ✅ | — | Playwright (free, auto-installed) |
|
||||
| XiaoHongShu | ✅ | — | Playwright + one-time login (free) |
|
||||
| Video Transcription | ✅ | — | Groq API key (free) |
|
||||
|
||||
---
|
||||
|
||||
## Three Ways to Use
|
||||
|
||||
### 1. Command Line (CLI)
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
python mcp_server.py
|
||||
|
||||
# Or with SSE transport
|
||||
python mcp_server.py --transport sse
|
||||
agent-eyes read <url> # Read any URL
|
||||
agent-eyes search "query" # Search the web
|
||||
agent-eyes search-reddit "query" # Search Reddit
|
||||
agent-eyes search-github "query" # Search GitHub
|
||||
agent-eyes search-twitter "query" # Search Twitter
|
||||
agent-eyes setup # Interactive setup wizard
|
||||
agent-eyes doctor # Check platform status
|
||||
```
|
||||
|
||||
MCP Tools exposed:
|
||||
### 2. MCP Server (for Claude Code, Cursor, etc.)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `read_url(url)` | Read any URL → structured content |
|
||||
| `read_batch(urls)` | Read multiple URLs concurrently |
|
||||
| `search(query)` | Semantic web search (Exa) |
|
||||
| `search_reddit(query, subreddit?)` | Search Reddit |
|
||||
| `search_github(query)` | Search GitHub repos |
|
||||
| `list_inbox()` | View previously fetched content |
|
||||
| `detect_platform(url)` | Identify platform from URL |
|
||||
```bash
|
||||
pip install agent-eyes[mcp]
|
||||
python -m agent_eyes.integrations.mcp_server
|
||||
```
|
||||
|
||||
### As Python Library
|
||||
Exposes 8 tools: `read_url`, `read_batch`, `detect_platform`, `search`, `search_reddit`, `search_github`, `search_twitter`, `get_status`
|
||||
|
||||
Add to your MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-eyes": {
|
||||
"command": "python",
|
||||
"args": ["-m", "agent_eyes.integrations.mcp_server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Python Library
|
||||
|
||||
```python
|
||||
from agent_eyes import AgentEyes
|
||||
import asyncio
|
||||
from agent_eyes.reader import UniversalReader
|
||||
|
||||
async def main():
|
||||
reader = UniversalReader()
|
||||
|
||||
# Read any URL
|
||||
content = await reader.read("https://github.com/openai/codex")
|
||||
print(content.title)
|
||||
print(content.content[:500])
|
||||
eyes = AgentEyes()
|
||||
|
||||
asyncio.run(main())
|
||||
# Read
|
||||
result = asyncio.run(eyes.read("https://github.com/openai/gpt-4"))
|
||||
print(result["title"])
|
||||
print(result["content"])
|
||||
|
||||
# Search
|
||||
results = asyncio.run(eyes.search("AI agent framework"))
|
||||
for r in results:
|
||||
print(f"{r['title']} — {r['url']}")
|
||||
|
||||
# Search Reddit
|
||||
results = asyncio.run(eyes.search_reddit("best LLM", subreddit="LocalLLaMA"))
|
||||
|
||||
# Health check
|
||||
print(eyes.doctor_report())
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Basic install
|
||||
pip install git+https://github.com/Panniantong/agent-eyes.git
|
||||
|
||||
# With browser fallback (for WeChat/XHS anti-scraping)
|
||||
pip install "agent-eyes[browser] @ git+https://github.com/Panniantong/agent-eyes.git"
|
||||
playwright install chromium
|
||||
|
||||
# With Telegram support
|
||||
pip install "agent-eyes[telegram] @ git+https://github.com/Panniantong/agent-eyes.git"
|
||||
|
||||
# Everything
|
||||
pip install "agent-eyes[all] @ git+https://github.com/Panniantong/agent-eyes.git"
|
||||
playwright install chromium
|
||||
```
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `EXA_API_KEY` | For search | Free key from [exa.ai](https://exa.ai) |
|
||||
| `REDDIT_PROXY` | For Reddit (if IP blocked) | `http://user:pass@host:port` |
|
||||
| `GITHUB_TOKEN` | No (higher rate limits) | GitHub personal access token |
|
||||
| `GROQ_API_KEY` | For Whisper | Free key from [groq.com](https://console.groq.com/keys) |
|
||||
| `TG_API_ID` | Telegram only | From https://my.telegram.org |
|
||||
| `TG_API_HASH` | Telegram only | From https://my.telegram.org |
|
||||
### Interactive Setup
|
||||
|
||||
## What's New (vs x-reader)
|
||||
```bash
|
||||
agent-eyes setup
|
||||
```
|
||||
|
||||
Agent Eyes extends x-reader with:
|
||||
Walks you through configuring each platform step by step. Only asks for what you want to set up.
|
||||
|
||||
- 🟠 **Reddit support** — Read posts + comments, search subreddits. Proxy support for blocked IPs.
|
||||
- 🐙 **GitHub support** — Read repos (README), issues, PRs. Search repositories.
|
||||
- 🔍 **Web search** — Semantic search across the entire web via Exa.
|
||||
- 🎯 **Agent-first design** — MCP Server with 7 tools, ready to plug into any AI Agent.
|
||||
### Agent-Readable Guides
|
||||
|
||||
## Philosophy
|
||||
Each platform has a detailed setup guide in `agent_eyes/guides/`. These are designed for AI Agents to read and follow — the Agent handles the technical steps, and only asks the user for things that require human action (logging in, copying API keys, buying proxies).
|
||||
|
||||
This is **Agent infrastructure**. In the Web 4.0 era where AI Agents act on behalf of humans, the first capability they need is the ability to **see the world**.
|
||||
| Guide | What It Configures |
|
||||
|-------|-------------------|
|
||||
| `setup-exa.md` | Exa search API key (free) |
|
||||
| `setup-reddit.md` | Reddit ISP proxy |
|
||||
| `setup-twitter.md` | Twitter birdx cookies |
|
||||
| `setup-xiaohongshu.md` | XiaoHongShu login |
|
||||
| `setup-wechat.md` | WeChat Playwright |
|
||||
| `setup-groq.md` | Groq Whisper API key (free) |
|
||||
|
||||
Agent Eyes is the sensory layer — the eyes — that every Agent needs.
|
||||
### Manual Config
|
||||
|
||||
Config file: `~/.agent-eyes/config.yaml`
|
||||
|
||||
```yaml
|
||||
exa_api_key: "exa-..."
|
||||
github_token: "ghp_..."
|
||||
reddit_proxy: "http://user:pass@ip:port"
|
||||
groq_api_key: "gsk_..."
|
||||
```
|
||||
|
||||
Environment variables also work (uppercase): `EXA_API_KEY`, `GITHUB_TOKEN`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
$ agent-eyes doctor
|
||||
|
||||
👁️ Agent Eyes Status
|
||||
========================================
|
||||
|
||||
✅ Ready (no setup needed):
|
||||
✅ Web Pages
|
||||
✅ GitHub
|
||||
✅ Bilibili
|
||||
✅ RSS
|
||||
✅ Tweet (single)
|
||||
✅ GitHub Search
|
||||
|
||||
🔍 Search (need free Exa API key):
|
||||
⬜ Web Search
|
||||
⬜ Reddit Search
|
||||
⬜ Twitter Search
|
||||
|
||||
🔧 Optional (advanced setup):
|
||||
⬜ Twitter Advanced — Install birdx for timeline/deep search
|
||||
⬜ Reddit Reader — Need proxy for full post reading
|
||||
⬜ WeChat — pip install agent-eyes[browser]
|
||||
⬜ XiaoHongShu — pip install agent-eyes[browser]
|
||||
|
||||
Status: 7/13 platforms active
|
||||
Run `agent-eyes setup` to unlock more!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Agent Eyes is **not** a fork of x-reader. It's a layer built on top of it.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Agent Eyes │
|
||||
│ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ Search │ │ Config + Doctor │ │
|
||||
│ │ Exa │ │ Setup Wizard │ │
|
||||
│ │ Reddit │ │ Health Check │ │
|
||||
│ │ GitHub │ │ │ │
|
||||
│ │ Twitter │ │ Agent Guides │ │
|
||||
│ └──────────┘ └──────────────────┘ │
|
||||
│ ┌──────────────────────────────────┐│
|
||||
│ │ Readers (based on x-reader) ││
|
||||
│ │ Web · GitHub · Reddit · Twitter ││
|
||||
│ │ YouTube · Bilibili · WeChat ││
|
||||
│ │ XHS · RSS · Telegram ││
|
||||
│ └──────────────────────────────────┘│
|
||||
│ ┌──────────────────────────────────┐│
|
||||
│ │ Integrations ││
|
||||
│ │ CLI · MCP Server · OpenClaw Skill│
|
||||
│ └──────────────────────────────────┘│
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Readers**: Content extraction from URLs (internalized from x-reader, MIT license)
|
||||
- **Search**: Semantic search across platforms (Agent Eyes original)
|
||||
- **Config + Doctor**: Configuration management and health checks (Agent Eyes original)
|
||||
- **Integrations**: CLI, MCP Server, OpenClaw Skill (Agent Eyes original)
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
- [x-reader](https://github.com/runesleo/x-reader) by [@runes_leo](https://x.com/runes_leo) — the original universal content reader that inspired and powers the core of Agent Eyes
|
||||
- [Jina Reader](https://jina.ai/reader/) — universal web content extraction
|
||||
- [Exa](https://exa.ai) — semantic web search API
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — video/audio extraction
|
||||
- **[x-reader](https://github.com/runesleo/x-reader)** by [@runes_leo](https://x.com/runes_leo) — the URL-to-content extraction engine that Agent Eyes is built upon. MIT License.
|
||||
- **[Exa](https://exa.ai)** — semantic search API powering web/Reddit/Twitter search.
|
||||
- **[birdx](https://github.com/runesleo/birdx)** — Twitter CLI tool for advanced Twitter features.
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it, fork it, build on it.
|
||||
MIT
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""x-reader: Universal content reader for 7+ platforms."""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Agent Eyes — Give your AI Agent eyes to see the entire internet."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Neo Reid"
|
||||
|
||||
from agent_eyes.core import AgentEyes
|
||||
|
||||
__all__ = ["AgentEyes"]
|
||||
|
||||
+225
-116
@@ -1,138 +1,247 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
x-reader CLI — fetch content from any platform.
|
||||
Agent Eyes CLI — command-line interface.
|
||||
|
||||
Usage:
|
||||
x-reader <url> # Fetch a single URL
|
||||
x-reader <url1> <url2> ... # Fetch multiple URLs
|
||||
x-reader list # Show inbox contents
|
||||
x-reader clear # Clear inbox
|
||||
agent-eyes read <url>
|
||||
agent-eyes search <query>
|
||||
agent-eyes search-reddit <query> [--sub <subreddit>]
|
||||
agent-eyes search-github <query> [--lang <language>]
|
||||
agent-eyes search-twitter <query>
|
||||
agent-eyes setup
|
||||
agent-eyes doctor
|
||||
agent-eyes version
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from agent_eyes.reader import UniversalReader
|
||||
from agent_eyes.schema import UnifiedInbox, SourceType
|
||||
|
||||
|
||||
def get_inbox_path() -> str:
|
||||
import os
|
||||
return os.getenv("INBOX_FILE", "unified_inbox.json")
|
||||
|
||||
|
||||
def cmd_fetch(urls: list[str]):
|
||||
"""Fetch one or more URLs."""
|
||||
inbox = UnifiedInbox(get_inbox_path())
|
||||
reader = UniversalReader(inbox=inbox)
|
||||
|
||||
async def run():
|
||||
if len(urls) == 1:
|
||||
item = await reader.read(urls[0])
|
||||
print(f"✅ [{item.source_type.value}] {item.title[:60]}")
|
||||
print(f" {item.url}")
|
||||
print(f" {item.content[:200]}...")
|
||||
else:
|
||||
items = await reader.read_batch(urls)
|
||||
for item in items:
|
||||
print(f"✅ [{item.source_type.value}] {item.title[:60]}")
|
||||
print(f"\n📦 Fetched {len(items)}/{len(urls)} URLs")
|
||||
|
||||
try:
|
||||
asyncio.run(run())
|
||||
except KeyboardInterrupt:
|
||||
print("\n⏹ Cancelled")
|
||||
except Exception as e:
|
||||
print(f"❌ {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_list():
|
||||
"""Show inbox contents."""
|
||||
inbox = UnifiedInbox(get_inbox_path())
|
||||
if not inbox.items:
|
||||
print("📦 Inbox is empty")
|
||||
return
|
||||
|
||||
print(f"📦 Inbox: {len(inbox.items)} items\n")
|
||||
|
||||
emoji_map = {
|
||||
SourceType.TELEGRAM: "📢", SourceType.RSS: "📰",
|
||||
SourceType.BILIBILI: "🎬", SourceType.XIAOHONGSHU: "📕",
|
||||
SourceType.TWITTER: "🐦", SourceType.WECHAT: "💬",
|
||||
SourceType.YOUTUBE: "▶️", SourceType.MANUAL: "✏️",
|
||||
}
|
||||
|
||||
for i, item in enumerate(inbox.items[-20:], 1):
|
||||
emoji = emoji_map.get(item.source_type, "📄")
|
||||
print(f" {i:2d}. {emoji} [{item.source_type.value:8s}] {item.title[:50]}")
|
||||
|
||||
|
||||
def cmd_clear():
|
||||
"""Clear inbox."""
|
||||
path = Path(get_inbox_path())
|
||||
if path.exists():
|
||||
confirm = input("Clear inbox? (y/N) ")
|
||||
if confirm.lower() == 'y':
|
||||
path.write_text("[]")
|
||||
print("✅ Inbox cleared")
|
||||
else:
|
||||
print("📦 Inbox is already empty")
|
||||
|
||||
|
||||
def cmd_login(platform: str):
|
||||
"""Open browser for manual login to a platform."""
|
||||
from agent_eyes.login import login
|
||||
login(platform)
|
||||
from agent_eyes import __version__
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("""
|
||||
📖 x-reader — Universal content reader
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="agent-eyes",
|
||||
description="👁️ Give your AI Agent eyes to see the entire internet",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
Usage:
|
||||
x-reader <url> Fetch content from any URL
|
||||
x-reader <url1> <url2> Fetch multiple URLs
|
||||
x-reader login <platform> Login to a platform (saves session for browser fallback)
|
||||
x-reader list Show inbox contents
|
||||
x-reader clear Clear inbox
|
||||
# ── read ──
|
||||
p_read = sub.add_parser("read", help="Read content from a URL")
|
||||
p_read.add_argument("url", help="URL to read")
|
||||
p_read.add_argument("--json", dest="as_json", action="store_true", help="Output as JSON")
|
||||
|
||||
Supported platforms:
|
||||
WeChat, Telegram, X/Twitter, YouTube,
|
||||
Bilibili, Xiaohongshu, RSS, and any web page
|
||||
# ── search ──
|
||||
p_search = sub.add_parser("search", help="Search the web (Exa)")
|
||||
p_search.add_argument("query", nargs="+", help="Search query")
|
||||
p_search.add_argument("-n", "--num", type=int, default=5, help="Number of results")
|
||||
|
||||
Examples:
|
||||
x-reader https://mp.weixin.qq.com/s/abc123
|
||||
x-reader https://x.com/elonmusk/status/123456
|
||||
x-reader https://www.xiaohongshu.com/explore/abc123
|
||||
x-reader login xhs
|
||||
""")
|
||||
return
|
||||
# ── search-reddit ──
|
||||
p_sr = sub.add_parser("search-reddit", help="Search Reddit")
|
||||
p_sr.add_argument("query", nargs="+", help="Search query")
|
||||
p_sr.add_argument("--sub", help="Subreddit filter")
|
||||
p_sr.add_argument("-n", "--num", type=int, default=10, help="Number of results")
|
||||
|
||||
cmd = sys.argv[1].lower()
|
||||
# ── search-github ──
|
||||
p_sg = sub.add_parser("search-github", help="Search GitHub")
|
||||
p_sg.add_argument("query", nargs="+", help="Search query")
|
||||
p_sg.add_argument("--lang", help="Language filter")
|
||||
p_sg.add_argument("-n", "--num", type=int, default=5, help="Number of results")
|
||||
|
||||
if cmd == "login":
|
||||
if len(sys.argv) < 3:
|
||||
print("❌ Usage: x-reader login <platform>")
|
||||
print(" Supported: xhs, wechat")
|
||||
sys.exit(1)
|
||||
cmd_login(sys.argv[2])
|
||||
elif cmd == "list":
|
||||
cmd_list()
|
||||
elif cmd == "clear":
|
||||
cmd_clear()
|
||||
elif cmd.startswith("http") or cmd.startswith("www.") or "." in cmd:
|
||||
urls = [arg for arg in sys.argv[1:] if arg.startswith(("http", "www.")) or "." in arg]
|
||||
cmd_fetch(urls)
|
||||
# ── search-twitter ──
|
||||
p_st = sub.add_parser("search-twitter", help="Search Twitter")
|
||||
p_st.add_argument("query", nargs="+", help="Search query")
|
||||
p_st.add_argument("-n", "--num", type=int, default=10, help="Number of results")
|
||||
|
||||
# ── setup ──
|
||||
sub.add_parser("setup", help="Interactive configuration wizard")
|
||||
|
||||
# ── doctor ──
|
||||
sub.add_parser("doctor", help="Check platform availability")
|
||||
|
||||
# ── version ──
|
||||
sub.add_parser("version", help="Show version")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
if args.command == "version":
|
||||
print(f"Agent Eyes v{__version__}")
|
||||
sys.exit(0)
|
||||
|
||||
if args.command == "doctor":
|
||||
_cmd_doctor()
|
||||
elif args.command == "setup":
|
||||
_cmd_setup()
|
||||
elif args.command == "read":
|
||||
asyncio.run(_cmd_read(args))
|
||||
elif args.command.startswith("search"):
|
||||
asyncio.run(_cmd_search(args))
|
||||
|
||||
|
||||
# ── Command handlers ────────────────────────────────
|
||||
|
||||
|
||||
def _cmd_doctor():
|
||||
from agent_eyes.config import Config
|
||||
from agent_eyes.doctor import check_all, format_report
|
||||
config = Config()
|
||||
results = check_all(config)
|
||||
print(format_report(results))
|
||||
|
||||
|
||||
def _cmd_setup():
|
||||
from agent_eyes.config import Config
|
||||
|
||||
config = Config()
|
||||
print()
|
||||
print("👁️ Agent Eyes Setup")
|
||||
print("=" * 40)
|
||||
print()
|
||||
|
||||
# Step 1: Exa
|
||||
print("【推荐】全网搜索 — Exa Search API")
|
||||
print(" 免费 1000 次/月,注册地址: https://exa.ai")
|
||||
current = config.get("exa_api_key")
|
||||
if current:
|
||||
print(f" 当前状态: ✅ 已配置 ({current[:8]}...)")
|
||||
change = input(" 要更换吗?[y/N]: ").strip().lower()
|
||||
if change != "y":
|
||||
print()
|
||||
else:
|
||||
key = input(" EXA_API_KEY: ").strip()
|
||||
if key:
|
||||
config.set("exa_api_key", key)
|
||||
print(" ✅ 已更新!")
|
||||
print()
|
||||
else:
|
||||
print(f"❌ Unknown command: {cmd}")
|
||||
print(" Run 'x-reader' with no args for help")
|
||||
print(" 当前状态: ⬜ 未配置")
|
||||
key = input(" EXA_API_KEY (回车跳过): ").strip()
|
||||
if key:
|
||||
config.set("exa_api_key", key)
|
||||
print(" ✅ 全网搜索 + Reddit搜索 + Twitter搜索 已开启!")
|
||||
else:
|
||||
print(" ℹ️ 跳过。稍后可运行 agent-eyes setup 配置")
|
||||
print()
|
||||
|
||||
# Step 2: GitHub token
|
||||
print("【可选】GitHub Token — 提高 API 限额")
|
||||
print(" 无 token: 60 次/小时 | 有 token: 5000 次/小时")
|
||||
print(" 获取: https://github.com/settings/tokens (无需任何权限)")
|
||||
current = config.get("github_token")
|
||||
if current:
|
||||
print(f" 当前状态: ✅ 已配置")
|
||||
else:
|
||||
key = input(" GITHUB_TOKEN (回车跳过): ").strip()
|
||||
if key:
|
||||
config.set("github_token", key)
|
||||
print(" ✅ GitHub API 已提升至 5000 次/小时!")
|
||||
else:
|
||||
print(" ℹ️ 跳过。公开 API 也能用")
|
||||
print()
|
||||
|
||||
# Step 3: Reddit proxy
|
||||
print("【可选】Reddit 代理 — 完整阅读 Reddit 帖子+评论")
|
||||
print(" Reddit 封锁很多 IP,需要 ISP 代理才能直接访问")
|
||||
print(" 格式: http://用户名:密码@IP:端口")
|
||||
current = config.get("reddit_proxy")
|
||||
if current:
|
||||
print(f" 当前状态: ✅ 已配置")
|
||||
else:
|
||||
proxy = input(" REDDIT_PROXY (回车跳过): ").strip()
|
||||
if proxy:
|
||||
config.set("reddit_proxy", proxy)
|
||||
print(" ✅ Reddit 完整阅读已开启!")
|
||||
else:
|
||||
print(" ℹ️ 跳过。仍可通过搜索获取 Reddit 内容")
|
||||
print()
|
||||
|
||||
# Step 4: Groq (Whisper)
|
||||
print("【可选】Groq API — 视频无字幕时的语音转文字")
|
||||
print(" 免费额度,注册: https://console.groq.com")
|
||||
current = config.get("groq_api_key")
|
||||
if current:
|
||||
print(f" 当前状态: ✅ 已配置")
|
||||
else:
|
||||
key = input(" GROQ_API_KEY (回车跳过): ").strip()
|
||||
if key:
|
||||
config.set("groq_api_key", key)
|
||||
print(" ✅ 语音转文字已开启!")
|
||||
else:
|
||||
print(" ℹ️ 跳过")
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("=" * 40)
|
||||
print(f"✅ 配置已保存到 {config.config_path}")
|
||||
print("运行 agent-eyes doctor 查看完整状态")
|
||||
print()
|
||||
|
||||
|
||||
async def _cmd_read(args):
|
||||
from agent_eyes.core import AgentEyes
|
||||
eyes = AgentEyes()
|
||||
try:
|
||||
result = await eyes.read(args.url)
|
||||
if args.as_json:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(f"\n📖 {result.get('title', 'Untitled')}")
|
||||
print(f"🔗 {result.get('url', '')}")
|
||||
if result.get("author"):
|
||||
print(f"👤 {result['author']}")
|
||||
print(f"\n{result.get('content', '')}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
async def _cmd_search(args):
|
||||
from agent_eyes.core import AgentEyes
|
||||
eyes = AgentEyes()
|
||||
query = " ".join(args.query)
|
||||
num = args.num
|
||||
|
||||
try:
|
||||
if args.command == "search":
|
||||
results = await eyes.search(query, num_results=num)
|
||||
elif args.command == "search-reddit":
|
||||
results = await eyes.search_reddit(query, subreddit=getattr(args, "sub", None), limit=num)
|
||||
elif args.command == "search-github":
|
||||
results = await eyes.search_github(query, language=getattr(args, "lang", None), limit=num)
|
||||
elif args.command == "search-twitter":
|
||||
results = await eyes.search_twitter(query, limit=num)
|
||||
else:
|
||||
print(f"Unknown command: {args.command}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not results:
|
||||
print("No results found.")
|
||||
return
|
||||
|
||||
for i, r in enumerate(results, 1):
|
||||
title = r.get("title") or r.get("name") or r.get("text", "")[:60]
|
||||
url = r.get("url", "")
|
||||
snippet = r.get("snippet") or r.get("description") or r.get("text", "")
|
||||
print(f"\n{i}. {title}")
|
||||
print(f" 🔗 {url}")
|
||||
if snippet:
|
||||
print(f" {snippet[:200]}")
|
||||
# Extra info for GitHub
|
||||
if "stars" in r:
|
||||
print(f" ⭐ {r['stars']} 🍴 {r.get('forks', 0)} 📝 {r.get('language', '')}")
|
||||
|
||||
except ValueError as e:
|
||||
print(f"⚠️ {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Configuration management for Agent Eyes.
|
||||
|
||||
Stores settings in ~/.agent-eyes/config.yaml.
|
||||
Auto-creates directory on first use.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class Config:
|
||||
"""Manages Agent Eyes configuration."""
|
||||
|
||||
CONFIG_DIR = Path.home() / ".agent-eyes"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
||||
|
||||
# Feature → required config keys
|
||||
FEATURE_REQUIREMENTS = {
|
||||
"exa_search": ["exa_api_key"],
|
||||
"reddit_proxy": ["reddit_proxy"],
|
||||
"twitter_birdx": ["twitter_auth_token", "twitter_ct0"],
|
||||
"groq_whisper": ["groq_api_key"],
|
||||
"github_token": ["github_token"],
|
||||
}
|
||||
|
||||
def __init__(self, config_path: Optional[Path] = None):
|
||||
self.config_path = Path(config_path) if config_path else self.CONFIG_FILE
|
||||
self.config_dir = self.config_path.parent
|
||||
self.data: dict = {}
|
||||
self._ensure_dir()
|
||||
self.load()
|
||||
|
||||
def _ensure_dir(self):
|
||||
"""Create config directory if it doesn't exist."""
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def load(self):
|
||||
"""Load config from YAML file."""
|
||||
if self.config_path.exists():
|
||||
with open(self.config_path, "r") as f:
|
||||
self.data = yaml.safe_load(f) or {}
|
||||
else:
|
||||
self.data = {}
|
||||
|
||||
def save(self):
|
||||
"""Save config to YAML file."""
|
||||
self._ensure_dir()
|
||||
with open(self.config_path, "w") as f:
|
||||
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a config value. Also checks environment variables (uppercase)."""
|
||||
# Config file first
|
||||
if key in self.data:
|
||||
return self.data[key]
|
||||
# Then env var (uppercase)
|
||||
env_val = os.environ.get(key.upper())
|
||||
if env_val:
|
||||
return env_val
|
||||
return default
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
"""Set a config value and save."""
|
||||
self.data[key] = value
|
||||
self.save()
|
||||
|
||||
def delete(self, key: str):
|
||||
"""Delete a config key and save."""
|
||||
self.data.pop(key, None)
|
||||
self.save()
|
||||
|
||||
def is_configured(self, feature: str) -> bool:
|
||||
"""Check if a feature has all required config."""
|
||||
required = self.FEATURE_REQUIREMENTS.get(feature, [])
|
||||
return all(self.get(k) for k in required)
|
||||
|
||||
def get_configured_features(self) -> dict:
|
||||
"""Return status of all optional features."""
|
||||
return {
|
||||
feature: self.is_configured(feature)
|
||||
for feature in self.FEATURE_REQUIREMENTS
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Return config as dict (masks sensitive values)."""
|
||||
masked = {}
|
||||
for k, v in self.data.items():
|
||||
if any(s in k.lower() for s in ("key", "token", "password", "proxy")):
|
||||
masked[k] = f"{str(v)[:8]}..." if v else None
|
||||
else:
|
||||
masked[k] = v
|
||||
return masked
|
||||
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AgentEyes — the unified entry point.
|
||||
|
||||
Usage:
|
||||
from agent_eyes import AgentEyes
|
||||
|
||||
eyes = AgentEyes()
|
||||
content = await eyes.read("https://github.com/openai/gpt-4")
|
||||
results = await eyes.search("AI agent framework")
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent_eyes.config import Config
|
||||
from agent_eyes.reader import UniversalReader
|
||||
|
||||
|
||||
class AgentEyes:
|
||||
"""Give your AI Agent eyes to see the entire internet."""
|
||||
|
||||
def __init__(self, config: Optional[Config] = None):
|
||||
self.config = config or Config()
|
||||
self.reader = UniversalReader()
|
||||
|
||||
# ── Reading ─────────────────────────────────────────
|
||||
|
||||
async def read(self, url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Read content from any URL. Auto-detects platform.
|
||||
|
||||
Supported: Web, GitHub, Reddit, Twitter, YouTube,
|
||||
Bilibili, WeChat, XiaoHongShu, RSS, Telegram, etc.
|
||||
|
||||
Returns:
|
||||
Dict with title, content, url, author, etc.
|
||||
"""
|
||||
content = await self.reader.read(url)
|
||||
return content.to_dict()
|
||||
|
||||
async def read_batch(self, urls: List[str]) -> List[Dict[str, Any]]:
|
||||
"""Read multiple URLs concurrently."""
|
||||
contents = await self.reader.read_batch(urls)
|
||||
return [c.to_dict() for c in contents]
|
||||
|
||||
def detect_platform(self, url: str) -> str:
|
||||
"""Detect what platform a URL belongs to."""
|
||||
return self.reader._detect_platform(url)
|
||||
|
||||
# ── Searching ───────────────────────────────────────
|
||||
|
||||
async def search(self, query: str, num_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Semantic web search via Exa. Requires Exa API key.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
num_results: Number of results (max 10)
|
||||
"""
|
||||
from agent_eyes.search.exa import search_web
|
||||
return await search_web(query, num_results=num_results, config=self.config)
|
||||
|
||||
async def search_reddit(
|
||||
self,
|
||||
query: str,
|
||||
subreddit: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search Reddit via Exa (bypasses IP blocks).
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
subreddit: Optional subreddit filter
|
||||
limit: Number of results
|
||||
"""
|
||||
from agent_eyes.search.reddit import search_reddit
|
||||
return await search_reddit(query, subreddit=subreddit, num_results=limit, config=self.config)
|
||||
|
||||
async def search_github(
|
||||
self,
|
||||
query: str,
|
||||
language: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search GitHub repositories.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
language: Filter by language
|
||||
limit: Number of results
|
||||
"""
|
||||
from agent_eyes.search.github import search_github
|
||||
return await search_github(query, language=language, limit=limit, config=self.config)
|
||||
|
||||
async def search_twitter(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search Twitter. Uses birdx if available, else Exa.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Number of results
|
||||
"""
|
||||
from agent_eyes.search.twitter import search_twitter
|
||||
return await search_twitter(query, limit=limit, config=self.config)
|
||||
|
||||
# ── Health ──────────────────────────────────────────
|
||||
|
||||
def doctor(self) -> Dict[str, dict]:
|
||||
"""Check all platform/feature availability."""
|
||||
from agent_eyes.doctor import check_all
|
||||
return check_all(self.config)
|
||||
|
||||
def doctor_report(self) -> str:
|
||||
"""Get formatted health report."""
|
||||
from agent_eyes.doctor import check_all, format_report
|
||||
return format_report(check_all(self.config))
|
||||
|
||||
# ── Sync wrappers ───────────────────────────────────
|
||||
|
||||
def read_sync(self, url: str) -> Dict[str, Any]:
|
||||
"""Synchronous version of read()."""
|
||||
return asyncio.run(self.read(url))
|
||||
|
||||
def search_sync(self, query: str, num_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Synchronous version of search()."""
|
||||
return asyncio.run(self.search(query, num_results))
|
||||
@@ -0,0 +1,226 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Environment health checker for Agent Eyes.
|
||||
|
||||
Checks all platforms, tools, and API keys.
|
||||
Outputs a rich-formatted status report.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Dict
|
||||
|
||||
from agent_eyes.config import Config
|
||||
|
||||
|
||||
STATUS_OK = "ok"
|
||||
STATUS_WARN = "warn"
|
||||
STATUS_OFF = "off"
|
||||
STATUS_ERROR = "error"
|
||||
|
||||
|
||||
def _check_command(cmd: str) -> bool:
|
||||
"""Check if a command exists on PATH."""
|
||||
return shutil.which(cmd) is not None
|
||||
|
||||
|
||||
def _check_python_import(module: str) -> bool:
|
||||
"""Check if a Python module is importable."""
|
||||
try:
|
||||
__import__(module)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def check_all(config: Config) -> Dict[str, dict]:
|
||||
"""Check all features and return status dict."""
|
||||
results = {}
|
||||
|
||||
# === Zero-config (always available) ===
|
||||
results["web"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "Web Pages",
|
||||
"message": "Jina Reader (built-in)",
|
||||
"tier": 0,
|
||||
}
|
||||
results["github_read"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "GitHub",
|
||||
"message": "Public API (built-in)",
|
||||
"tier": 0,
|
||||
}
|
||||
results["bilibili"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "Bilibili",
|
||||
"message": "Public API (built-in)",
|
||||
"tier": 0,
|
||||
}
|
||||
results["rss"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "RSS",
|
||||
"message": "feedparser (built-in)",
|
||||
"tier": 0,
|
||||
}
|
||||
results["tweet_read"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "Tweet (single)",
|
||||
"message": "Jina Reader (built-in)",
|
||||
"tier": 0,
|
||||
}
|
||||
|
||||
# YouTube — needs yt-dlp
|
||||
if _check_command("yt-dlp"):
|
||||
results["youtube"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "YouTube",
|
||||
"message": "yt-dlp found",
|
||||
"tier": 0,
|
||||
}
|
||||
else:
|
||||
results["youtube"] = {
|
||||
"status": STATUS_WARN,
|
||||
"name": "YouTube",
|
||||
"message": "Install yt-dlp: pip install yt-dlp",
|
||||
"tier": 0,
|
||||
}
|
||||
|
||||
# === Needs API key (Tier 1) ===
|
||||
exa_key = config.get("exa_api_key")
|
||||
if exa_key:
|
||||
results["search_web"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "Web Search",
|
||||
"message": "Exa API configured",
|
||||
"tier": 1,
|
||||
}
|
||||
results["search_reddit"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "Reddit Search",
|
||||
"message": "Via Exa (site:reddit.com)",
|
||||
"tier": 1,
|
||||
}
|
||||
results["search_twitter"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "Twitter Search",
|
||||
"message": "Via Exa (site:x.com)",
|
||||
"tier": 1,
|
||||
}
|
||||
else:
|
||||
for key in ("search_web", "search_reddit", "search_twitter"):
|
||||
results[key] = {
|
||||
"status": STATUS_OFF,
|
||||
"name": {"search_web": "Web Search", "search_reddit": "Reddit Search",
|
||||
"search_twitter": "Twitter Search"}[key],
|
||||
"message": "Need Exa API key (free). Run: agent-eyes setup",
|
||||
"tier": 1,
|
||||
}
|
||||
|
||||
# GitHub search (always works, token optional for higher limits)
|
||||
github_token = config.get("github_token")
|
||||
results["search_github"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "GitHub Search",
|
||||
"message": f"API ({'authenticated' if github_token else 'public, 60 req/hr'})",
|
||||
"tier": 0,
|
||||
}
|
||||
|
||||
# === Optional tools (Tier 2) ===
|
||||
|
||||
# Twitter advanced (birdx)
|
||||
if _check_command("birdx"):
|
||||
results["twitter_advanced"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "Twitter Advanced",
|
||||
"message": "birdx found",
|
||||
"tier": 2,
|
||||
}
|
||||
else:
|
||||
results["twitter_advanced"] = {
|
||||
"status": STATUS_OFF,
|
||||
"name": "Twitter Advanced",
|
||||
"message": "Install birdx for timeline/deep search",
|
||||
"tier": 2,
|
||||
}
|
||||
|
||||
# Reddit full reader
|
||||
reddit_proxy = config.get("reddit_proxy")
|
||||
if reddit_proxy:
|
||||
results["reddit_full"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "Reddit Reader",
|
||||
"message": "Proxy configured",
|
||||
"tier": 2,
|
||||
}
|
||||
else:
|
||||
results["reddit_full"] = {
|
||||
"status": STATUS_OFF,
|
||||
"name": "Reddit Reader",
|
||||
"message": "Need proxy for full post reading",
|
||||
"tier": 2,
|
||||
}
|
||||
|
||||
# WeChat / XHS (playwright)
|
||||
if _check_python_import("playwright"):
|
||||
results["wechat"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "WeChat",
|
||||
"message": "Playwright available",
|
||||
"tier": 2,
|
||||
}
|
||||
results["xhs"] = {
|
||||
"status": STATUS_OK,
|
||||
"name": "XiaoHongShu",
|
||||
"message": "Playwright available",
|
||||
"tier": 2,
|
||||
}
|
||||
else:
|
||||
for key in ("wechat", "xhs"):
|
||||
results[key] = {
|
||||
"status": STATUS_OFF,
|
||||
"name": "WeChat" if key == "wechat" else "XiaoHongShu",
|
||||
"message": "pip install agent-eyes[browser]",
|
||||
"tier": 2,
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def format_report(results: Dict[str, dict]) -> str:
|
||||
"""Format results as a readable text report."""
|
||||
lines = []
|
||||
lines.append("👁️ Agent Eyes Status")
|
||||
lines.append("=" * 40)
|
||||
|
||||
# Count stats
|
||||
ok_count = sum(1 for r in results.values() if r["status"] == STATUS_OK)
|
||||
total = len(results)
|
||||
|
||||
# Group by tier
|
||||
lines.append("")
|
||||
lines.append("✅ Ready (no setup needed):")
|
||||
for key, r in results.items():
|
||||
if r["tier"] == 0 and r["status"] == STATUS_OK:
|
||||
lines.append(f" ✅ {r['name']}")
|
||||
elif r["tier"] == 0 and r["status"] == STATUS_WARN:
|
||||
lines.append(f" ⚠️ {r['name']} — {r['message']}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("🔍 Search (need free Exa API key):")
|
||||
for key, r in results.items():
|
||||
if r["tier"] == 1:
|
||||
icon = "✅" if r["status"] == STATUS_OK else "⬜"
|
||||
lines.append(f" {icon} {r['name']}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("🔧 Optional (advanced setup):")
|
||||
for key, r in results.items():
|
||||
if r["tier"] == 2:
|
||||
icon = "✅" if r["status"] == STATUS_OK else "⬜"
|
||||
lines.append(f" {icon} {r['name']} — {r['message']}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Status: {ok_count}/{total} platforms active")
|
||||
if ok_count < total:
|
||||
lines.append("Run `agent-eyes setup` to unlock more!")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,94 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Search fetcher — semantic web search via Exa API.
|
||||
|
||||
Requires EXA_API_KEY env var. Get a free key at https://exa.ai
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from loguru import logger
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
EXA_API_URL = "https://api.exa.ai/search"
|
||||
|
||||
|
||||
async def search_web(
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
search_type: str = "auto",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search the web using Exa semantic search.
|
||||
|
||||
Args:
|
||||
query: Search query (supports site: prefix, e.g. "site:reddit.com AI agent")
|
||||
num_results: Number of results to return (default 5, max 10)
|
||||
search_type: "auto" (default) or "neural" or "keyword"
|
||||
|
||||
Returns:
|
||||
List of search results with title, url, snippet
|
||||
"""
|
||||
api_key = os.environ.get("EXA_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"EXA_API_KEY not set. Get a free key at https://exa.ai\n"
|
||||
"Then: export EXA_API_KEY=your_key_here"
|
||||
)
|
||||
|
||||
logger.info(f"Exa search: {query} (n={num_results})")
|
||||
|
||||
resp = requests.post(
|
||||
EXA_API_URL,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": api_key,
|
||||
},
|
||||
json={
|
||||
"query": query,
|
||||
"numResults": min(num_results, 10),
|
||||
"type": search_type,
|
||||
"contents": {
|
||||
"text": {"maxCharacters": 500},
|
||||
},
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for item in data.get("results", []):
|
||||
results.append({
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"snippet": item.get("text", ""),
|
||||
"published_date": item.get("publishedDate", ""),
|
||||
"score": item.get("score", 0),
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def search_reddit_via_exa(
|
||||
query: str,
|
||||
subreddit: Optional[str] = None,
|
||||
num_results: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search Reddit content via Exa (bypasses Reddit IP blocks).
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
subreddit: Optional subreddit to limit search (e.g. "LocalLLaMA")
|
||||
num_results: Number of results
|
||||
|
||||
Returns:
|
||||
List of Reddit posts found
|
||||
"""
|
||||
if subreddit:
|
||||
full_query = f"site:reddit.com/r/{subreddit} {query}"
|
||||
else:
|
||||
full_query = f"site:reddit.com {query}"
|
||||
|
||||
return await search_web(full_query, num_results=num_results)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Exa Search 配置指南
|
||||
|
||||
## 功能说明
|
||||
Exa 是一个语义搜索引擎,免费提供 1000 次/月搜索。配置后解锁:
|
||||
- 全网搜索
|
||||
- Reddit 搜索(通过 site:reddit.com)
|
||||
- Twitter 搜索(通过 site:x.com)
|
||||
|
||||
## Agent 可自动完成的步骤
|
||||
|
||||
1. 检查是否已配置:
|
||||
```bash
|
||||
agent-eyes doctor | grep "Web Search"
|
||||
```
|
||||
|
||||
2. 如果用户提供了 key,写入配置:
|
||||
```bash
|
||||
# 方法1: 通过 setup 命令
|
||||
agent-eyes setup
|
||||
|
||||
# 方法2: 直接写入配置文件
|
||||
python3 -c "
|
||||
from agent_eyes.config import Config
|
||||
c = Config()
|
||||
c.set('exa_api_key', '用户提供的KEY')
|
||||
print('✅ Exa API key saved')
|
||||
"
|
||||
```
|
||||
|
||||
3. 测试是否有效:
|
||||
```bash
|
||||
agent-eyes search "test query" -n 1
|
||||
```
|
||||
|
||||
## 需要用户手动做的步骤
|
||||
|
||||
请告诉用户:
|
||||
|
||||
> 我需要一个 Exa API Key 来开启搜索功能。完全免费,每月 1000 次搜索。
|
||||
>
|
||||
> 步骤:
|
||||
> 1. 打开 https://exa.ai
|
||||
> 2. 点击 "Get API Key" 或 "Sign Up"
|
||||
> 3. 用 Google 账号或邮箱注册
|
||||
> 4. 注册后在 Dashboard 页面复制你的 API Key
|
||||
> 5. 把 Key 发给我
|
||||
>
|
||||
> 大概 30 秒就能搞定。
|
||||
|
||||
## Agent 收到 key 后的操作
|
||||
|
||||
1. 写入配置:`config.set("exa_api_key", key)`
|
||||
2. 测试:运行一次搜索确认可用
|
||||
3. 反馈:"✅ 全网搜索已开启!现在我可以帮你搜索全网、Reddit 和 Twitter 了。"
|
||||
@@ -0,0 +1,47 @@
|
||||
# Groq Whisper 配置指南
|
||||
|
||||
## 功能说明
|
||||
当 YouTube/Bilibili 视频没有字幕时,用 Groq 的 Whisper API 进行语音转文字。Groq 提供免费额度。
|
||||
|
||||
## Agent 可自动完成的步骤
|
||||
|
||||
1. 检查是否已配置:
|
||||
```bash
|
||||
agent-eyes doctor | grep -i "groq\|whisper"
|
||||
```
|
||||
|
||||
2. 如果用户提供了 key,写入配置:
|
||||
```python
|
||||
from agent_eyes.config import Config
|
||||
c = Config()
|
||||
c.set("groq_api_key", "用户提供的KEY")
|
||||
```
|
||||
|
||||
3. 测试(可选):
|
||||
```bash
|
||||
curl -s https://api.groq.com/openai/v1/models \
|
||||
-H "Authorization: Bearer 用户提供的KEY" \
|
||||
-o /dev/null -w "%{http_code}"
|
||||
```
|
||||
返回 200 = 可用
|
||||
|
||||
## 需要用户手动做的步骤
|
||||
|
||||
请告诉用户:
|
||||
|
||||
> 视频语音转文字需要一个 Groq API Key(免费)。
|
||||
>
|
||||
> 步骤:
|
||||
> 1. 打开 https://console.groq.com
|
||||
> 2. 用 Google 账号或邮箱注册
|
||||
> 3. 点击左侧 "API Keys"
|
||||
> 4. 点击 "Create API Key"
|
||||
> 5. 复制生成的 Key,发给我
|
||||
>
|
||||
> Groq 提供免费额度,日常使用完全够用。
|
||||
|
||||
## Agent 收到 key 后的操作
|
||||
|
||||
1. 写入配置:`config.set("groq_api_key", key)`
|
||||
2. 测试 API 可用性
|
||||
3. 反馈:"✅ 语音转文字已开启!现在遇到没有字幕的视频,我也能帮你提取内容了。"
|
||||
@@ -0,0 +1,60 @@
|
||||
# Reddit 代理配置指南
|
||||
|
||||
## 功能说明
|
||||
Reddit 封锁了大量服务器 IP,直接访问会返回 403。需要 ISP 代理(Residential/ISP Proxy)才能读取完整的 Reddit 帖子和评论。
|
||||
|
||||
**注意**:即使没有代理,也可以通过 Exa 搜索找到 Reddit 内容(只需要配 Exa API key)。代理只是用于读取完整帖子+评论。
|
||||
|
||||
## Agent 可自动完成的步骤
|
||||
|
||||
1. 检查当前状态:
|
||||
```bash
|
||||
agent-eyes doctor | grep "Reddit"
|
||||
```
|
||||
|
||||
2. 如果用户提供了代理,测试连通性:
|
||||
```bash
|
||||
curl -s --proxy "用户提供的代理" \
|
||||
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" \
|
||||
"https://www.reddit.com/r/test.json?limit=1" \
|
||||
-o /dev/null -w "%{http_code}"
|
||||
```
|
||||
返回 200 = 可用,403 = 代理被封,其他 = 配置错误
|
||||
|
||||
3. 写入配置:
|
||||
```python
|
||||
from agent_eyes.config import Config
|
||||
c = Config()
|
||||
c.set("reddit_proxy", "http://用户名:密码@IP:端口")
|
||||
```
|
||||
|
||||
## 需要用户手动做的步骤
|
||||
|
||||
请告诉用户:
|
||||
|
||||
> 要完整阅读 Reddit 帖子和评论,需要一个 ISP 代理(约 $3-10/月)。
|
||||
>
|
||||
> 推荐代理服务商(选一个就行):
|
||||
> 1. **Smartproxy** (https://smartproxy.com) — ISP 代理,按流量计费
|
||||
> 2. **Bright Data** (https://brightdata.com) — 大厂,ISP 代理
|
||||
> 3. **IPRoyal** (https://iproyal.com) — 便宜,适合入门
|
||||
> 4. **ProxyEmpire** (https://proxyempire.io) — 有 Reddit 专用代理
|
||||
>
|
||||
> 购买时选择:
|
||||
> - 类型:**ISP Proxy**(不要选 Datacenter,会被封)
|
||||
> - 地区:**美国**
|
||||
> - 协议:**HTTP**
|
||||
>
|
||||
> 购买后你会得到一个代理地址,格式类似:
|
||||
> `http://用户名:密码@IP地址:端口号`
|
||||
>
|
||||
> 把这个地址发给我就行。
|
||||
>
|
||||
> ⚠️ 如果不想花钱,可以跳过。我仍然可以通过搜索引擎找到 Reddit 上的内容,只是不能读完整的帖子和评论。
|
||||
|
||||
## Agent 收到代理后的操作
|
||||
|
||||
1. 测试代理:用 curl 测试 reddit.com 是否返回 200
|
||||
2. 如果成功,写入配置:`config.set("reddit_proxy", proxy_url)`
|
||||
3. 反馈:"✅ Reddit 完整阅读已开启!现在我可以读取 Reddit 帖子和所有评论了。"
|
||||
4. 如果失败,告诉用户:"❌ 这个代理无法访问 Reddit,请检查代理是否有效,或换一个试试。"
|
||||
@@ -0,0 +1,69 @@
|
||||
# Twitter 高级功能配置指南(birdx)
|
||||
|
||||
## 功能说明
|
||||
基础 Twitter 功能(搜索+读单条推文)无需配置,开箱即用。
|
||||
|
||||
高级功能需要 birdx:
|
||||
- 查看用户时间线
|
||||
- 深度搜索(更精确、更多结果)
|
||||
- 读取完整线程(thread)
|
||||
- 查看关注列表推文
|
||||
|
||||
birdx 是免费开源工具,但需要你的 Twitter 账号 cookie。
|
||||
|
||||
## Agent 可自动完成的步骤
|
||||
|
||||
1. 检查 birdx 是否安装:
|
||||
```bash
|
||||
which birdx && echo "installed" || echo "not installed"
|
||||
```
|
||||
|
||||
2. 安装 birdx:
|
||||
```bash
|
||||
pip install birdx
|
||||
```
|
||||
|
||||
3. 检查是否已配置 cookie:
|
||||
```bash
|
||||
birdx whoami 2>&1
|
||||
```
|
||||
|
||||
4. 如果用户提供了 cookie,配置 birdx:
|
||||
```bash
|
||||
# birdx 的 cookie 配置文件位置
|
||||
# 通常在 ~/.birdx/cookies.json 或通过环境变量
|
||||
export TWITTER_AUTH_TOKEN="用户提供的auth_token"
|
||||
export TWITTER_CT0="用户提供的ct0"
|
||||
```
|
||||
|
||||
5. 测试:
|
||||
```bash
|
||||
birdx search "test" -n 1
|
||||
```
|
||||
|
||||
## 需要用户手动做的步骤
|
||||
|
||||
请告诉用户:
|
||||
|
||||
> Twitter 高级功能需要你的 Twitter 账号 cookie(完全免费)。
|
||||
>
|
||||
> 步骤:
|
||||
> 1. 用 Chrome 打开 https://x.com 并确保你已登录
|
||||
> 2. 按 **F12** 打开开发者工具(Mac 按 Cmd+Option+I)
|
||||
> 3. 点击顶部的 **Application**(应用)标签
|
||||
> 4. 左侧找到 **Cookies** → **https://x.com**
|
||||
> 5. 在列表中找到以下两个值,双击复制:
|
||||
> - **auth_token** — 一串字母数字
|
||||
> - **ct0** — 一串字母数字
|
||||
> 6. 把这两个值发给我
|
||||
>
|
||||
> ⚠️ 这些 cookie 让我能以你的身份读取推文(只读)。我不会发推、点赞或做任何操作。
|
||||
> ⚠️ cookie 大约 1-3 个月会过期,届时需要重新导出。
|
||||
|
||||
## Agent 收到 cookie 后的操作
|
||||
|
||||
1. 安装 birdx(如果没装):`pip install birdx`
|
||||
2. 配置 cookie:写入 birdx 配置
|
||||
3. 测试:`birdx whoami` 确认身份
|
||||
4. 反馈:"✅ Twitter 高级功能已开启!你的账号是 @xxx。现在我可以查看时间线、读取线程了。"
|
||||
5. 如果失败:"❌ Cookie 无效或已过期,请重新导出。"
|
||||
@@ -0,0 +1,40 @@
|
||||
# 微信公众号配置指南
|
||||
|
||||
## 功能说明
|
||||
读取微信公众号文章。需要 Playwright 来处理微信的反爬机制。
|
||||
|
||||
## Agent 可自动完成的步骤
|
||||
|
||||
1. 检查 Playwright 是否安装:
|
||||
```bash
|
||||
python3 -c "import playwright; print('installed')" 2>&1
|
||||
```
|
||||
|
||||
2. 安装 Playwright + 浏览器:
|
||||
```bash
|
||||
pip install playwright
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
3. 安装完成后测试:
|
||||
```bash
|
||||
agent-eyes read "https://mp.weixin.qq.com/s/一个测试链接"
|
||||
```
|
||||
|
||||
## 需要用户手动做的步骤
|
||||
|
||||
请告诉用户:
|
||||
|
||||
> 微信公众号的配置很简单,只需要安装一个浏览器组件(约 150MB)。
|
||||
>
|
||||
> 我来帮你安装,你不需要做任何事情。安装过程大约 1-2 分钟。
|
||||
>
|
||||
> 安装好之后就可以直接读取微信公众号文章了,不需要登录。
|
||||
|
||||
## Agent 操作流程
|
||||
|
||||
1. 安装 Playwright:`pip install playwright`
|
||||
2. 安装 Chromium:`playwright install chromium`
|
||||
3. 测试:读一篇微信文章
|
||||
4. 反馈:"✅ 微信公众号已配置!发给我任何公众号文章链接,我都能读取。"
|
||||
5. 如果安装失败(空间不足等):"❌ 浏览器组件安装失败。可能是磁盘空间不足(需要约 150MB)。"
|
||||
@@ -0,0 +1,42 @@
|
||||
# 小红书配置指南
|
||||
|
||||
## 功能说明
|
||||
读取小红书笔记内容。需要 Playwright(浏览器自动化)和一次性登录。
|
||||
|
||||
## Agent 可自动完成的步骤
|
||||
|
||||
1. 检查 Playwright 是否安装:
|
||||
```bash
|
||||
python3 -c "import playwright; print('installed')" 2>&1
|
||||
```
|
||||
|
||||
2. 安装 Playwright + 浏览器:
|
||||
```bash
|
||||
pip install playwright
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
3. 检查是否已有登录态:
|
||||
```bash
|
||||
# 检查 cookie 文件是否存在
|
||||
ls ~/.agent-eyes/xhs_cookies.json 2>/dev/null
|
||||
```
|
||||
|
||||
## 需要用户手动做的步骤
|
||||
|
||||
请告诉用户:
|
||||
|
||||
> 小红书需要登录一次(之后会记住你的登录状态)。
|
||||
>
|
||||
> 我现在会打开一个浏览器窗口,显示小红书登录页面。你需要:
|
||||
> 1. 用手机小红书 App 扫描屏幕上的二维码
|
||||
> 2. 在手机上确认登录
|
||||
> 3. 看到首页后告诉我"登录好了"
|
||||
>
|
||||
> 之后就不需要再登录了(除非 cookie 过期,大约 1-3 个月)。
|
||||
|
||||
## Agent 收到确认后的操作
|
||||
|
||||
1. 保存浏览器 cookie 到 `~/.agent-eyes/xhs_cookies.json`
|
||||
2. 测试:读取一条小红书笔记
|
||||
3. 反馈:"✅ 小红书已配置!现在我可以读取小红书笔记了。"
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -0,0 +1,198 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Agent Eyes MCP Server — expose all capabilities as MCP tools.
|
||||
|
||||
Run: python -m agent_eyes.integrations.mcp_server
|
||||
Or: agent-eyes serve (after pip install)
|
||||
|
||||
10 tools for any MCP-compatible AI Agent.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
from agent_eyes.config import Config
|
||||
from agent_eyes.core import AgentEyes
|
||||
|
||||
try:
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
HAS_MCP = True
|
||||
except ImportError:
|
||||
HAS_MCP = False
|
||||
|
||||
|
||||
def create_server():
|
||||
"""Create and configure the MCP server."""
|
||||
if not HAS_MCP:
|
||||
print("MCP not installed. Install: pip install agent-eyes[mcp]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
server = Server("agent-eyes")
|
||||
config = Config()
|
||||
eyes = AgentEyes(config)
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="read_url",
|
||||
description="Read content from any URL. Supports: web pages, GitHub, Reddit, Twitter, YouTube, Bilibili, WeChat, XiaoHongShu, RSS, Telegram.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL to read"},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="read_batch",
|
||||
description="Read multiple URLs concurrently.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"urls": {"type": "array", "items": {"type": "string"}, "description": "List of URLs"},
|
||||
},
|
||||
"required": ["urls"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="detect_platform",
|
||||
description="Detect what platform a URL belongs to (github, reddit, twitter, youtube, etc).",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL to detect"},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="search",
|
||||
description="Semantic web search using Exa. Find any information on the internet.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"num_results": {"type": "integer", "description": "Number of results (1-10)", "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="search_reddit",
|
||||
description="Search Reddit posts and discussions. Works even when Reddit blocks your IP.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"subreddit": {"type": "string", "description": "Optional subreddit filter (e.g. 'LocalLLaMA')"},
|
||||
"limit": {"type": "integer", "description": "Number of results", "default": 10},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="search_github",
|
||||
description="Search GitHub repositories by topic, keyword, or technology.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"language": {"type": "string", "description": "Filter by language (e.g. 'python')"},
|
||||
"limit": {"type": "integer", "description": "Number of results", "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="search_twitter",
|
||||
description="Search Twitter/X posts. Uses birdx if available, otherwise Exa.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"limit": {"type": "integer", "description": "Number of results", "default": 10},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_status",
|
||||
description="Get Agent Eyes status: which platforms are active, which need configuration.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
try:
|
||||
if name == "read_url":
|
||||
result = await eyes.read(arguments["url"])
|
||||
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
|
||||
|
||||
elif name == "read_batch":
|
||||
results = await eyes.read_batch(arguments["urls"])
|
||||
return [TextContent(type="text", text=json.dumps(results, ensure_ascii=False, indent=2))]
|
||||
|
||||
elif name == "detect_platform":
|
||||
platform = eyes.detect_platform(arguments["url"])
|
||||
return [TextContent(type="text", text=f"Platform: {platform}")]
|
||||
|
||||
elif name == "search":
|
||||
results = await eyes.search(
|
||||
arguments["query"],
|
||||
num_results=arguments.get("num_results", 5),
|
||||
)
|
||||
return [TextContent(type="text", text=json.dumps(results, ensure_ascii=False, indent=2))]
|
||||
|
||||
elif name == "search_reddit":
|
||||
results = await eyes.search_reddit(
|
||||
arguments["query"],
|
||||
subreddit=arguments.get("subreddit"),
|
||||
limit=arguments.get("limit", 10),
|
||||
)
|
||||
return [TextContent(type="text", text=json.dumps(results, ensure_ascii=False, indent=2))]
|
||||
|
||||
elif name == "search_github":
|
||||
results = await eyes.search_github(
|
||||
arguments["query"],
|
||||
language=arguments.get("language"),
|
||||
limit=arguments.get("limit", 5),
|
||||
)
|
||||
return [TextContent(type="text", text=json.dumps(results, ensure_ascii=False, indent=2))]
|
||||
|
||||
elif name == "search_twitter":
|
||||
results = await eyes.search_twitter(
|
||||
arguments["query"],
|
||||
limit=arguments.get("limit", 10),
|
||||
)
|
||||
return [TextContent(type="text", text=json.dumps(results, ensure_ascii=False, indent=2))]
|
||||
|
||||
elif name == "get_status":
|
||||
report = eyes.doctor_report()
|
||||
return [TextContent(type="text", text=report)]
|
||||
|
||||
else:
|
||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=f"Error: {str(e)}")]
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def main():
|
||||
server = create_server()
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: agent-eyes
|
||||
description: "Give your AI Agent eyes to see the entire internet. Search + read 10+ platforms with one install."
|
||||
install: pip install git+https://github.com/Panniantong/agent-eyes.git
|
||||
---
|
||||
|
||||
# Agent Eyes — 给 Agent 装上眼睛 👁️
|
||||
|
||||
安装后你就能搜索和阅读全网内容。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/Panniantong/agent-eyes.git
|
||||
```
|
||||
|
||||
## 装完直接能用的(零配置)
|
||||
|
||||
- `agent-eyes read <URL>` — 读取任意网页、GitHub、B站、YouTube、RSS
|
||||
- `agent-eyes search-github "query"` — 搜索 GitHub 仓库
|
||||
- `agent-eyes doctor` — 查看所有平台状态
|
||||
|
||||
## 解锁更多功能
|
||||
|
||||
运行 `agent-eyes setup` 进行交互式配置,或者读取以下指南:
|
||||
|
||||
| 功能 | 指南文件 |
|
||||
|------|---------|
|
||||
| 全网搜索 + Reddit搜索 + Twitter搜索 | `agent_eyes/guides/setup-exa.md` |
|
||||
| Reddit 完整阅读 | `agent_eyes/guides/setup-reddit.md` |
|
||||
| Twitter 高级(时间线/线程) | `agent_eyes/guides/setup-twitter.md` |
|
||||
| 小红书 | `agent_eyes/guides/setup-xiaohongshu.md` |
|
||||
| 微信公众号 | `agent_eyes/guides/setup-wechat.md` |
|
||||
| 视频语音转文字 | `agent_eyes/guides/setup-groq.md` |
|
||||
|
||||
每个指南里有详细的步骤说明,你(Agent)可以照着做,只有需要人类操作的部分(登录、复制 key)才需要问用户。
|
||||
|
||||
## MCP Server
|
||||
|
||||
如果你的 Agent 平台支持 MCP:
|
||||
|
||||
```bash
|
||||
pip install agent-eyes[mcp]
|
||||
python -m agent_eyes.integrations.mcp_server
|
||||
```
|
||||
|
||||
提供 8 个工具:read_url, read_batch, detect_platform, search, search_reddit, search_github, search_twitter, get_status
|
||||
|
||||
## Python API
|
||||
|
||||
```python
|
||||
from agent_eyes import AgentEyes
|
||||
import asyncio
|
||||
|
||||
eyes = AgentEyes()
|
||||
|
||||
# 读取
|
||||
result = asyncio.run(eyes.read("https://github.com/openai/gpt-4"))
|
||||
|
||||
# 搜索
|
||||
results = asyncio.run(eyes.search("AI agent framework"))
|
||||
|
||||
# 健康检查
|
||||
print(eyes.doctor_report())
|
||||
```
|
||||
@@ -1,91 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Login manager — opens a visible browser for manual login, saves session.
|
||||
|
||||
Usage:
|
||||
x-reader login xhs # Login to Xiaohongshu
|
||||
x-reader login wechat # Login to WeChat (if needed)
|
||||
|
||||
Sessions are saved as Playwright storage_state JSON files.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
SESSION_DIR = Path.home() / ".x-reader" / "sessions"
|
||||
|
||||
PLATFORM_URLS = {
|
||||
"xhs": "https://www.xiaohongshu.com/explore",
|
||||
"xiaohongshu": "https://www.xiaohongshu.com/explore",
|
||||
"wechat": "https://mp.weixin.qq.com",
|
||||
"twitter": "https://x.com/login",
|
||||
"x": "https://x.com/login",
|
||||
}
|
||||
|
||||
|
||||
def login(platform: str) -> None:
|
||||
"""
|
||||
Open a visible browser for the user to log in manually.
|
||||
After login, saves cookies/localStorage to a session file.
|
||||
|
||||
Args:
|
||||
platform: Platform key (e.g. 'xhs', 'wechat')
|
||||
"""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print(
|
||||
"❌ Playwright is not installed. Run:\n"
|
||||
' pip install "x-reader[browser]"\n'
|
||||
" playwright install chromium"
|
||||
)
|
||||
return
|
||||
|
||||
platform = platform.lower()
|
||||
login_url = PLATFORM_URLS.get(platform)
|
||||
if not login_url:
|
||||
supported = ", ".join(sorted(PLATFORM_URLS.keys()))
|
||||
print(f"❌ Unknown platform: {platform}")
|
||||
print(f" Supported: {supported}")
|
||||
return
|
||||
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
session_path = SESSION_DIR / f"{platform}.json"
|
||||
# Normalize alias to canonical name
|
||||
if platform in ("xhs", "xiaohongshu"):
|
||||
canonical = "xhs"
|
||||
elif platform in ("twitter", "x"):
|
||||
canonical = "twitter"
|
||||
else:
|
||||
canonical = platform
|
||||
session_path = SESSION_DIR / f"{canonical}.json"
|
||||
|
||||
print(f"🌐 Opening {platform} login page: {login_url}")
|
||||
print(" Please log in manually in the browser window.")
|
||||
print(" When done, close the browser or press Ctrl+C.\n")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
context = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36",
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto(login_url)
|
||||
|
||||
try:
|
||||
# Wait for user to log in — blocks until browser is closed
|
||||
page.wait_for_event("close", timeout=300_000) # 5 min max
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception:
|
||||
pass # Browser closed by user
|
||||
|
||||
# Save session regardless of how we got here
|
||||
context.storage_state(path=str(session_path))
|
||||
logger.info(f"Session saved: {session_path}")
|
||||
print(f"\n✅ Session saved to {session_path}")
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
+10
-10
@@ -15,7 +15,7 @@ from agent_eyes.schema import (
|
||||
from_bilibili, from_twitter, from_wechat,
|
||||
from_xiaohongshu, from_youtube, from_rss, from_telegram,
|
||||
)
|
||||
from agent_eyes.fetchers.jina import fetch_via_jina
|
||||
from agent_eyes.readers.jina import fetch_via_jina
|
||||
|
||||
|
||||
class UniversalReader:
|
||||
@@ -91,39 +91,39 @@ class UniversalReader:
|
||||
"""Dispatch to platform-specific fetcher."""
|
||||
|
||||
if platform == "bilibili":
|
||||
from agent_eyes.fetchers.bilibili import fetch_bilibili
|
||||
from agent_eyes.readers.bilibili import fetch_bilibili
|
||||
data = await fetch_bilibili(url)
|
||||
return from_bilibili(data)
|
||||
|
||||
if platform == "twitter":
|
||||
from agent_eyes.fetchers.twitter import fetch_twitter
|
||||
from agent_eyes.readers.twitter import fetch_twitter
|
||||
data = await fetch_twitter(url)
|
||||
return from_twitter(data)
|
||||
|
||||
if platform == "wechat":
|
||||
from agent_eyes.fetchers.wechat import fetch_wechat
|
||||
from agent_eyes.readers.wechat import fetch_wechat
|
||||
data = await fetch_wechat(url)
|
||||
return from_wechat(data)
|
||||
|
||||
if platform == "xhs":
|
||||
from agent_eyes.fetchers.xhs import fetch_xhs
|
||||
from agent_eyes.readers.xhs import fetch_xhs
|
||||
data = await fetch_xhs(url)
|
||||
return from_xiaohongshu(data)
|
||||
|
||||
if platform == "youtube":
|
||||
from agent_eyes.fetchers.youtube import fetch_youtube
|
||||
from agent_eyes.readers.youtube import fetch_youtube
|
||||
data = await fetch_youtube(url)
|
||||
return from_youtube(data)
|
||||
|
||||
if platform == "rss":
|
||||
from agent_eyes.fetchers.rss import fetch_rss
|
||||
from agent_eyes.readers.rss import fetch_rss
|
||||
articles = await fetch_rss(url, limit=1)
|
||||
if articles:
|
||||
return from_rss(articles[0])
|
||||
raise ValueError(f"No articles found in RSS feed: {url}")
|
||||
|
||||
if platform == "reddit":
|
||||
from agent_eyes.fetchers.reddit import fetch_reddit
|
||||
from agent_eyes.readers.reddit import fetch_reddit
|
||||
data = await fetch_reddit(url)
|
||||
return UnifiedContent(
|
||||
source_type=SourceType.REDDIT,
|
||||
@@ -137,7 +137,7 @@ class UniversalReader:
|
||||
)
|
||||
|
||||
if platform == "github":
|
||||
from agent_eyes.fetchers.github import fetch_github
|
||||
from agent_eyes.readers.github import fetch_github
|
||||
data = await fetch_github(url)
|
||||
return UnifiedContent(
|
||||
source_type=SourceType.GITHUB,
|
||||
@@ -151,7 +151,7 @@ class UniversalReader:
|
||||
)
|
||||
|
||||
if platform == "telegram":
|
||||
from agent_eyes.fetchers.telegram import fetch_telegram
|
||||
from agent_eyes.readers.telegram import fetch_telegram
|
||||
# Extract channel username from t.me URL
|
||||
path = urlparse(url).path.strip("/").split("/")[0]
|
||||
channel = path if path else url
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Search module init."""
|
||||
|
||||
from agent_eyes.search.exa import search_web
|
||||
from agent_eyes.search.reddit import search_reddit
|
||||
from agent_eyes.search.github import search_github, search_github_issues
|
||||
from agent_eyes.search.twitter import search_twitter, get_user_tweets
|
||||
|
||||
__all__ = [
|
||||
"search_web",
|
||||
"search_reddit",
|
||||
"search_github",
|
||||
"search_github_issues",
|
||||
"search_twitter",
|
||||
"get_user_tweets",
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Exa semantic web search.
|
||||
|
||||
Get a free API key at https://exa.ai (1000 searches/month free).
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from loguru import logger
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
EXA_API_URL = "https://api.exa.ai/search"
|
||||
|
||||
|
||||
def _get_api_key(config=None) -> str:
|
||||
"""Get Exa API key from config or env."""
|
||||
if config:
|
||||
key = config.get("exa_api_key")
|
||||
if key:
|
||||
return key
|
||||
key = os.environ.get("EXA_API_KEY")
|
||||
if key:
|
||||
return key
|
||||
raise ValueError(
|
||||
"Exa API key not configured.\n"
|
||||
"Get a free key at https://exa.ai (1000 searches/month free)\n"
|
||||
"Then run: agent-eyes setup"
|
||||
)
|
||||
|
||||
|
||||
async def search_web(
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
search_type: str = "auto",
|
||||
config=None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Semantic web search via Exa.
|
||||
|
||||
Args:
|
||||
query: Search query (supports site: prefix)
|
||||
num_results: Number of results (default 5, max 10)
|
||||
search_type: "auto" / "neural" / "keyword"
|
||||
config: Optional Config instance
|
||||
|
||||
Returns:
|
||||
List of {title, url, snippet, published_date, score}
|
||||
"""
|
||||
api_key = _get_api_key(config)
|
||||
logger.info(f"Exa search: {query} (n={num_results})")
|
||||
|
||||
resp = requests.post(
|
||||
EXA_API_URL,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": api_key,
|
||||
},
|
||||
json={
|
||||
"query": query,
|
||||
"numResults": min(num_results, 10),
|
||||
"type": search_type,
|
||||
"contents": {"text": {"maxCharacters": 500}},
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for item in data.get("results", []):
|
||||
results.append({
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"snippet": item.get("text", ""),
|
||||
"published_date": item.get("publishedDate", ""),
|
||||
"score": item.get("score", 0),
|
||||
})
|
||||
return results
|
||||
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""GitHub search via public API (no key required)."""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from loguru import logger
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
|
||||
|
||||
def _get_headers(config=None) -> dict:
|
||||
"""Get GitHub API headers with optional auth token."""
|
||||
headers = {"Accept": "application/vnd.github+json"}
|
||||
token = None
|
||||
if config:
|
||||
token = config.get("github_token")
|
||||
if not token:
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
async def search_github(
|
||||
query: str,
|
||||
language: Optional[str] = None,
|
||||
sort: str = "stars",
|
||||
limit: int = 5,
|
||||
config=None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search GitHub repositories.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
language: Filter by language (e.g. "python")
|
||||
sort: Sort by "stars" / "forks" / "updated"
|
||||
limit: Number of results (max 30)
|
||||
config: Optional Config instance
|
||||
|
||||
Returns:
|
||||
List of {name, url, description, stars, language, updated}
|
||||
"""
|
||||
q = query
|
||||
if language:
|
||||
q += f" language:{language}"
|
||||
|
||||
logger.info(f"GitHub search: {q} (n={limit})")
|
||||
|
||||
resp = requests.get(
|
||||
f"{GITHUB_API}/search/repositories",
|
||||
headers=_get_headers(config),
|
||||
params={"q": q, "sort": sort, "per_page": min(limit, 30)},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for repo in data.get("items", []):
|
||||
results.append({
|
||||
"name": repo.get("full_name", ""),
|
||||
"url": repo.get("html_url", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"stars": repo.get("stargazers_count", 0),
|
||||
"forks": repo.get("forks_count", 0),
|
||||
"language": repo.get("language", ""),
|
||||
"updated": repo.get("updated_at", ""),
|
||||
"topics": repo.get("topics", []),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
async def search_github_issues(
|
||||
query: str,
|
||||
repo: Optional[str] = None,
|
||||
state: str = "open",
|
||||
limit: int = 5,
|
||||
config=None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search GitHub issues and discussions.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
repo: Filter by repo (e.g. "owner/repo")
|
||||
state: "open" / "closed"
|
||||
limit: Number of results
|
||||
config: Optional Config instance
|
||||
"""
|
||||
q = query
|
||||
if repo:
|
||||
q += f" repo:{repo}"
|
||||
q += f" state:{state}"
|
||||
|
||||
resp = requests.get(
|
||||
f"{GITHUB_API}/search/issues",
|
||||
headers=_get_headers(config),
|
||||
params={"q": q, "sort": "reactions", "per_page": min(limit, 30)},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for issue in data.get("items", []):
|
||||
results.append({
|
||||
"title": issue.get("title", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
"body": (issue.get("body", "") or "")[:500],
|
||||
"state": issue.get("state", ""),
|
||||
"comments": issue.get("comments", 0),
|
||||
"reactions": issue.get("reactions", {}).get("total_count", 0),
|
||||
"created": issue.get("created_at", ""),
|
||||
})
|
||||
return results
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Reddit search via Exa (bypasses Reddit IP blocks)."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from agent_eyes.search.exa import search_web
|
||||
|
||||
|
||||
async def search_reddit(
|
||||
query: str,
|
||||
subreddit: Optional[str] = None,
|
||||
num_results: int = 10,
|
||||
config=None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search Reddit content via Exa semantic search.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
subreddit: Optional subreddit (e.g. "LocalLLaMA")
|
||||
num_results: Number of results
|
||||
config: Optional Config instance
|
||||
|
||||
Returns:
|
||||
List of {title, url, snippet, published_date, score}
|
||||
"""
|
||||
if subreddit:
|
||||
full_query = f"site:reddit.com/r/{subreddit} {query}"
|
||||
else:
|
||||
full_query = f"site:reddit.com {query}"
|
||||
return await search_web(full_query, num_results=num_results, config=config)
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Twitter search — uses birdx if available, falls back to Exa."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from loguru import logger
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
async def search_twitter(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
config=None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search Twitter/X content.
|
||||
|
||||
Strategy:
|
||||
1. If birdx is installed → use it (full search, timeline, threads)
|
||||
2. Otherwise → use Exa with site:x.com (basic search)
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Number of results
|
||||
config: Optional Config instance
|
||||
|
||||
Returns:
|
||||
List of {author, text, url, likes, retweets, date}
|
||||
"""
|
||||
if shutil.which("birdx"):
|
||||
return await _search_birdx(query, limit)
|
||||
else:
|
||||
return await _search_exa(query, limit, config)
|
||||
|
||||
|
||||
async def _search_birdx(query: str, limit: int) -> List[Dict[str, Any]]:
|
||||
"""Search Twitter via birdx CLI."""
|
||||
logger.info(f"birdx search: {query} (n={limit})")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["birdx", "search", query, "-n", str(limit), "--json"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# birdx might not support --json, try plain output
|
||||
result = subprocess.run(
|
||||
["birdx", "search", query, "-n", str(limit)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
return _parse_birdx_text(result.stdout)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get("tweets", data.get("results", []))
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
|
||||
logger.error(f"birdx search failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _parse_birdx_text(text: str) -> List[Dict[str, Any]]:
|
||||
"""Parse birdx plain text output into structured data."""
|
||||
results = []
|
||||
current = {}
|
||||
for line in text.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
if current:
|
||||
results.append(current)
|
||||
current = {}
|
||||
continue
|
||||
if line.startswith("@"):
|
||||
current["author"] = line.split()[0] if line else ""
|
||||
elif line.startswith("http"):
|
||||
current["url"] = line
|
||||
else:
|
||||
current["text"] = current.get("text", "") + " " + line
|
||||
if current:
|
||||
results.append(current)
|
||||
return results
|
||||
|
||||
|
||||
async def _search_exa(query: str, limit: int, config=None) -> List[Dict[str, Any]]:
|
||||
"""Search Twitter via Exa (site:x.com)."""
|
||||
from agent_eyes.search.exa import search_web
|
||||
return await search_web(
|
||||
f"site:x.com {query}",
|
||||
num_results=limit,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_tweets(
|
||||
username: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get recent tweets from a user (requires birdx)."""
|
||||
if not shutil.which("birdx"):
|
||||
raise RuntimeError(
|
||||
"birdx not installed. Install: pip install birdx\n"
|
||||
"Then configure cookies: agent-eyes setup"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["birdx", "user-tweets", f"@{username.lstrip('@')}", "-n", str(limit)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
return _parse_birdx_text(result.stdout)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("birdx timed out")
|
||||
return []
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
x-reader MCP Server — expose content reading as MCP tools.
|
||||
|
||||
Usage:
|
||||
python mcp_server.py # stdio transport (for Claude Code)
|
||||
python mcp_server.py --transport sse # SSE transport (for web clients)
|
||||
|
||||
Claude Code config (~/.claude/claude_desktop_config.json):
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-eyes": {
|
||||
"command": "python",
|
||||
"args": ["/path/to/x-reader/mcp_server.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from agent_eyes.reader import UniversalReader
|
||||
from agent_eyes.schema import UnifiedInbox
|
||||
|
||||
mcp = FastMCP(
|
||||
"agent-eyes",
|
||||
instructions="Give your AI Agent eyes to see the entire internet. Search, read, and extract content from any platform.",
|
||||
)
|
||||
|
||||
reader = UniversalReader(inbox=UnifiedInbox())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def read_url(url: str) -> str:
|
||||
"""
|
||||
Read content from any URL and return structured result.
|
||||
|
||||
Supports: YouTube, Bilibili, X/Twitter, WeChat, Xiaohongshu,
|
||||
Telegram, RSS, and any generic web page.
|
||||
|
||||
Returns JSON with: title, content, url, source_type, platform metadata.
|
||||
"""
|
||||
import json
|
||||
|
||||
content = await reader.read(url)
|
||||
result = content.to_dict()
|
||||
# Keep it readable
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def read_batch(urls: list[str]) -> str:
|
||||
"""
|
||||
Read multiple URLs concurrently. Returns JSON array of results.
|
||||
|
||||
Failed URLs are logged but don't block other results.
|
||||
"""
|
||||
import json
|
||||
|
||||
contents = await reader.read_batch(urls)
|
||||
results = [c.to_dict() for c in contents]
|
||||
return json.dumps(results, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_inbox() -> str:
|
||||
"""
|
||||
List all items in the content inbox.
|
||||
|
||||
Returns JSON array of previously fetched content.
|
||||
"""
|
||||
import json
|
||||
|
||||
items = [item.to_dict() for item in reader.inbox.items]
|
||||
return json.dumps(items, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def detect_platform(url: str) -> str:
|
||||
"""
|
||||
Detect which platform a URL belongs to.
|
||||
|
||||
Returns the platform name: youtube, bilibili, twitter, wechat,
|
||||
xhs, reddit, github, telegram, rss, or generic.
|
||||
"""
|
||||
return reader._detect_platform(url)
|
||||
|
||||
|
||||
# ==================== Search Tools (NEW in Agent Eyes) ====================
|
||||
|
||||
@mcp.tool()
|
||||
async def search(query: str, num_results: int = 5) -> str:
|
||||
"""
|
||||
Search the entire web using semantic search (powered by Exa).
|
||||
|
||||
Great for finding articles, blog posts, discussions on any topic.
|
||||
Supports site: prefix, e.g. "site:reddit.com AI agent" to limit to specific sites.
|
||||
|
||||
Requires EXA_API_KEY env var. Get a free key at https://exa.ai
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
num_results: Number of results (1-10, default 5)
|
||||
"""
|
||||
import json
|
||||
from agent_eyes.fetchers.search import search_web
|
||||
|
||||
results = await search_web(query, num_results=num_results)
|
||||
return json.dumps(results, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_reddit(query: str, subreddit: str = "", limit: int = 10) -> str:
|
||||
"""
|
||||
Search Reddit posts. Bypasses Reddit IP blocks via Exa.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
subreddit: Optional subreddit name (e.g. "LocalLLaMA"). Empty = all of Reddit.
|
||||
limit: Number of results (default 10)
|
||||
"""
|
||||
import json
|
||||
from agent_eyes.fetchers.search import search_reddit_via_exa
|
||||
|
||||
sub = subreddit if subreddit else None
|
||||
results = await search_reddit_via_exa(query, subreddit=sub, num_results=limit)
|
||||
return json.dumps(results, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_github(query: str, limit: int = 5) -> str:
|
||||
"""
|
||||
Search GitHub repositories by keyword.
|
||||
|
||||
Returns repos sorted by stars. No API key needed for public repos.
|
||||
|
||||
Args:
|
||||
query: Search query (e.g. "LLM agent framework", "language:python RAG")
|
||||
limit: Number of results (default 5)
|
||||
"""
|
||||
import json
|
||||
from agent_eyes.fetchers.github import search_github as _search_gh
|
||||
|
||||
results = await _search_gh(query, limit=limit)
|
||||
return json.dumps(results, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
transport = "stdio"
|
||||
if "--transport" in sys.argv:
|
||||
idx = sys.argv.index("--transport")
|
||||
if idx + 1 < len(sys.argv):
|
||||
transport = sys.argv[idx + 1]
|
||||
|
||||
mcp.run(transport=transport)
|
||||
+27
-15
@@ -1,40 +1,52 @@
|
||||
[project]
|
||||
name = "agent-eyes"
|
||||
version = "1.0.0"
|
||||
description = "Give your AI Agent eyes to see the entire internet — search, read, and extract content from 10+ platforms"
|
||||
description = "Give your AI Agent eyes to see the entire internet. Search + Read 10+ platforms."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "Neo Reid"},
|
||||
{name = "Leo (x-reader)", email = "runes.leo@gmail.com"},
|
||||
authors = [{name = "Neo Reid"}]
|
||||
keywords = ["ai-agent", "web-reader", "search", "mcp", "agent-infrastructure", "llm-tools"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
keywords = ["ai-agent", "web-reader", "search", "reddit", "github", "content-extraction", "mcp", "agent-infrastructure"]
|
||||
|
||||
dependencies = [
|
||||
"requests>=2.28",
|
||||
"feedparser>=6.0",
|
||||
"python-dotenv>=1.0",
|
||||
"loguru>=0.7",
|
||||
"pyyaml>=6.0",
|
||||
"rich>=13.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
browser = ["playwright>=1.40"]
|
||||
mcp = ["mcp[cli]>=1.0"]
|
||||
all = ["playwright>=1.40", "mcp[cli]>=1.0"]
|
||||
|
||||
[project.scripts]
|
||||
agent-eyes = "agent_eyes.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/Panniantong/agent-eyes"
|
||||
Repository = "https://github.com/Panniantong/agent-eyes"
|
||||
Issues = "https://github.com/Panniantong/agent-eyes/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
telegram = ["telethon>=1.34"]
|
||||
mcp = ["mcp[cli]>=1.0"]
|
||||
browser = ["playwright>=1.40"]
|
||||
all = ["telethon>=1.34", "mcp[cli]>=1.0", "playwright>=1.40"]
|
||||
|
||||
[project.scripts]
|
||||
agent-eyes = "agent_eyes.cli:main"
|
||||
x-reader = "agent_eyes.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["agent_eyes"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"agent_eyes/guides" = "agent_eyes/guides"
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# Content Analyzer Skill
|
||||
|
||||
> Any content → structured analysis report with actionable insights
|
||||
|
||||
## Trigger
|
||||
|
||||
When user sends content (URL, text, or transcript) with analysis intent:
|
||||
- `/analyze [URL]`
|
||||
- "Analyze this article"
|
||||
- "What are the key takeaways?"
|
||||
- Auto-triggered after video/podcast transcription (from video skill)
|
||||
|
||||
## Pipeline
|
||||
|
||||
### Step 1: Get Content
|
||||
|
||||
Choose tool based on input type:
|
||||
|
||||
| Input | Tool |
|
||||
|-------|------|
|
||||
| Tweet URL | `fetch_tweet` or Jina Reader |
|
||||
| Web URL | `WebFetch` or Jina Reader |
|
||||
| Local file | Read file directly |
|
||||
| Transcript from video skill | Use directly |
|
||||
|
||||
### Step 2: Multi-Dimensional Analysis
|
||||
|
||||
Scan content across these dimensions. Only output dimensions with actual content — skip empty ones.
|
||||
|
||||
```markdown
|
||||
## 📖 Summary
|
||||
|
||||
[1-3 sentence core thesis]
|
||||
|
||||
**Source**: [author/publisher] · [date]
|
||||
**Type**: [tweet/article/video/podcast/report]
|
||||
|
||||
---
|
||||
|
||||
## 💡 Key Insights
|
||||
|
||||
### 🎯 Core Arguments
|
||||
- **Thesis**: [Main argument or finding]
|
||||
- **Evidence**: [Supporting data or reasoning]
|
||||
- **Strength**: [How convincing? What's missing?]
|
||||
|
||||
### 🤖 Tools & Methods
|
||||
- **What**: [Tools, frameworks, or techniques mentioned]
|
||||
- **How**: [How they're used or applied]
|
||||
- **Relevance**: [Could you use this?]
|
||||
|
||||
### ⚙️ Workflow Ideas
|
||||
- **Optimization**: [Process improvements mentioned]
|
||||
- **Automation**: [What could be automated]
|
||||
- **Integration**: [How to fit into existing workflow]
|
||||
|
||||
### 📊 Data & Numbers
|
||||
- **Key metrics**: [Important numbers mentioned]
|
||||
- **Trends**: [Patterns in the data]
|
||||
- **Gaps**: [What data is missing]
|
||||
|
||||
### ⚠️ Risks & Warnings
|
||||
- **Pitfalls**: [Explicitly mentioned risks]
|
||||
- **Blind spots**: [What the author might be missing]
|
||||
- **Counter-arguments**: [Alternative perspectives]
|
||||
|
||||
### 🔗 Resources
|
||||
- **Tools/APIs**: [Mentioned tools or data sources]
|
||||
- **People**: [Worth following or referencing]
|
||||
- **Further reading**: [Related content]
|
||||
|
||||
### 🧠 Mental Model Shifts
|
||||
- **Before**: [Common assumption]
|
||||
- **After**: [New understanding from this content]
|
||||
- **Impact**: [How this changes decisions]
|
||||
|
||||
---
|
||||
|
||||
## ✅ Action Items
|
||||
|
||||
### Quick Wins (< 30 min)
|
||||
- [ ] [Action 1] — Impact: ★★★★ | Effort: Easy
|
||||
- [ ] [Action 2] — Impact: ★★★ | Effort: Easy
|
||||
|
||||
### Deeper Work (1-3 hours)
|
||||
- [ ] [Action 3] — Impact: ★★★ | Effort: Medium
|
||||
- [ ] [Action 4] — Impact: ★★ | Effort: Medium
|
||||
|
||||
### Exploration (needs validation)
|
||||
- [ ] [Action 5] — Impact: ★★★ | Effort: Hard | Nature: Exploratory
|
||||
```
|
||||
|
||||
### Step 3: Personalized Relevance (Customizable)
|
||||
|
||||
Map insights to YOUR context. Edit the dimensions below to match your own projects, interests, and systems.
|
||||
|
||||
```markdown
|
||||
## 🔄 How This Applies to Me
|
||||
|
||||
### My Projects
|
||||
- **[Project A]**: [How this insight connects]
|
||||
- **[Project B]**: [What I could apply]
|
||||
|
||||
### My Knowledge Base
|
||||
- **Update**: [Which notes/docs to update]
|
||||
- **New entry**: [What to add to my knowledge system]
|
||||
|
||||
### My Decision Log
|
||||
- **Changed my mind about**: [what and why]
|
||||
- **Confirmed my belief that**: [what]
|
||||
```
|
||||
|
||||
> **Customization**: Edit the dimensions in Step 2 and Step 3 to match your own
|
||||
> domain. A trader might add "Market Impact" and "Risk Assessment". A developer
|
||||
> might add "Architecture Patterns" and "Tech Debt". Make it yours.
|
||||
|
||||
## Output Modes
|
||||
|
||||
| Mode | Trigger | Output |
|
||||
|------|---------|--------|
|
||||
| **Full** (default) | `/analyze [URL]` | All dimensions |
|
||||
| **Sparse** | `/analyze [URL] --sparse` | Only hit dimensions, skip empty |
|
||||
| **Brief** | `/analyze [URL] --brief` | Action items only |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Scan all dimensions, but don't force-fill** — skip empty dimensions cleanly
|
||||
2. **Actions must be specific** — not "learn about X" but "read X docs chapter Y"
|
||||
3. **Distinguish fact from opinion** — mark the author's claims vs verified facts
|
||||
4. **Source everything** — tag where each insight comes from in the original content
|
||||
5. **ROI awareness** — not every action is worth doing, assess effort vs impact
|
||||
@@ -1,292 +0,0 @@
|
||||
# Video & Podcast Digest Skill
|
||||
|
||||
> Send a video/podcast link → get full transcript + structured summary
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Type | Subtitles | Whisper Transcription |
|
||||
|----------|------|-----------|----------------------|
|
||||
| YouTube | Video | ✅ | ✅ |
|
||||
| Bilibili | Video | ✅ | ✅ |
|
||||
| X/Twitter | Video | ❌ | ✅ |
|
||||
| Xiaoyuzhou (小宇宙) | Podcast | ❌ | ✅ |
|
||||
| Apple Podcasts | Podcast | ❌ | ✅ |
|
||||
| Direct links (mp3/mp4/m3u8) | Any | ❌ | ✅ |
|
||||
|
||||
## Trigger
|
||||
|
||||
Auto-triggered when a media URL is detected:
|
||||
- YouTube: `youtube.com`, `youtu.be`
|
||||
- Bilibili: `bilibili.com`, `b23.tv`
|
||||
- X/Twitter: `x.com`, `twitter.com` (tweets with video)
|
||||
- Xiaoyuzhou: `xiaoyuzhoufm.com`
|
||||
- Apple Podcasts: `podcasts.apple.com`
|
||||
- Direct: `.mp3`, `.mp4`, `.m3u8`, `.m4a`, `.webm`
|
||||
|
||||
## Pipeline
|
||||
|
||||
### Step 0: Detect Media Type
|
||||
|
||||
| URL Pattern | Type | Pipeline |
|
||||
|-------------|------|----------|
|
||||
| `xiaoyuzhoufm.com/episode/` | Podcast | → Step 1b (Xiaoyuzhou) |
|
||||
| `podcasts.apple.com` | Podcast | → Step 1c (Apple) |
|
||||
| `bilibili.com`, `b23.tv` | Video | → Step 1d (Bilibili API) |
|
||||
| `.mp3`, `.m4a` direct link | Audio | → Step 2b (direct download) |
|
||||
| Other | Video | → Step 1a (subtitle extraction) |
|
||||
|
||||
### Step 1a: Video — Extract Subtitles
|
||||
|
||||
```bash
|
||||
# Clean up temp files
|
||||
rm -f /tmp/media_sub*.vtt /tmp/media_audio.mp3 /tmp/media_transcript*.json /tmp/media_segment_*.mp3 2>/dev/null || true
|
||||
|
||||
# YouTube (prefer English, fallback Chinese)
|
||||
yt-dlp --skip-download --write-auto-sub --sub-lang "en,zh-Hans" -o "/tmp/media_sub" "VIDEO_URL"
|
||||
|
||||
# Bilibili
|
||||
yt-dlp --skip-download --write-auto-sub --sub-lang "zh-Hans,zh" -o "/tmp/media_sub" "VIDEO_URL"
|
||||
```
|
||||
|
||||
Check for subtitles:
|
||||
```bash
|
||||
ls /tmp/media_sub*.vtt 2>/dev/null
|
||||
```
|
||||
- **Has subtitles** → Read VTT content, skip to Step 3
|
||||
- **No subtitles** → Step 2a (download audio)
|
||||
|
||||
### Step 1b: Xiaoyuzhou (小宇宙) — Extract Audio URL
|
||||
|
||||
```bash
|
||||
# Extract CDN direct link from __NEXT_DATA__
|
||||
# Xiaoyuzhou is a Next.js SPA, but initial HTML contains audio URL in __NEXT_DATA__
|
||||
AUDIO_URL=$(curl -sL -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" \
|
||||
"EPISODE_URL" \
|
||||
| grep -oE 'https://media\.xyzcdn\.net/[^"]+\.(m4a|mp3)' \
|
||||
| head -1)
|
||||
|
||||
echo "Audio URL: $AUDIO_URL"
|
||||
|
||||
# Download audio
|
||||
curl -L -o /tmp/media_audio.mp3 "$AUDIO_URL"
|
||||
```
|
||||
|
||||
> If curl extraction is empty (rare), fallback: use Puppeteer/browser to get rendered page and extract.
|
||||
|
||||
→ Step 2b (check size & transcribe)
|
||||
|
||||
### Step 1c: Apple Podcasts — via yt-dlp
|
||||
|
||||
```bash
|
||||
yt-dlp -f "ba[ext=m4a]/ba/b" --extract-audio --audio-format mp3 --audio-quality 5 \
|
||||
-o "/tmp/media_audio.%(ext)s" "APPLE_PODCAST_URL"
|
||||
```
|
||||
|
||||
→ Step 2b (check size & transcribe)
|
||||
|
||||
### Step 1d: Bilibili — API Direct Audio Stream
|
||||
|
||||
yt-dlp returns 412 for Bilibili even with cookies. Use Bilibili's API instead:
|
||||
|
||||
```bash
|
||||
# 1. Extract BV number from URL
|
||||
BV="BV1xxxxx" # Replace with actual BV number
|
||||
|
||||
# 2. Get video info (title, duration, CID)
|
||||
curl -s "https://api.bilibili.com/x/web-interface/view?bvid=$BV" \
|
||||
-H "User-Agent: Mozilla/5.0" -H "Referer: https://www.bilibili.com/" \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(f\"Title: {d['title']}\nDuration: {d['duration']}s\nCID: {d['cid']}\")"
|
||||
|
||||
# 3. Get audio stream URL
|
||||
CID=<CID from previous step>
|
||||
AUDIO_URL=$(curl -s "https://api.bilibili.com/x/player/playurl?bvid=$BV&cid=$CID&fnval=16&qn=64" \
|
||||
-H "User-Agent: Mozilla/5.0" -H "Referer: https://www.bilibili.com/" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['dash']['audio'][0]['baseUrl'])")
|
||||
|
||||
# 4. Download audio (Referer header required, otherwise 403)
|
||||
curl -L -o /tmp/media_audio.m4s \
|
||||
-H "User-Agent: Mozilla/5.0" -H "Referer: https://www.bilibili.com/" "$AUDIO_URL"
|
||||
|
||||
# 5. Convert to mp3
|
||||
ffmpeg -y -i /tmp/media_audio.m4s -acodec libmp3lame -q:a 5 /tmp/media_audio.mp3
|
||||
```
|
||||
|
||||
→ Step 2b (check size & transcribe)
|
||||
|
||||
### Step 2a: Video — Download Audio (when no subtitles)
|
||||
|
||||
```bash
|
||||
# YouTube may need --cookies-from-browser chrome to bypass bot detection
|
||||
yt-dlp --cookies-from-browser chrome -f "ba[ext=m4a]/ba/b" --extract-audio --audio-format mp3 --audio-quality 5 \
|
||||
-o "/tmp/media_audio.%(ext)s" "VIDEO_URL"
|
||||
```
|
||||
|
||||
### Step 2b: Check Audio Size & Segment
|
||||
|
||||
```bash
|
||||
FILE_SIZE=$(stat -f%z /tmp/media_audio.* 2>/dev/null || stat -c%s /tmp/media_audio.* 2>/dev/null)
|
||||
echo "File size: $FILE_SIZE bytes"
|
||||
```
|
||||
|
||||
- **≤ 25MB (25000000)** → Step 2c (transcribe directly)
|
||||
- **> 25MB** → Split first, then transcribe each segment
|
||||
|
||||
**Splitting large audio (>25MB)**:
|
||||
```bash
|
||||
# Get total duration
|
||||
DURATION=$(ffprobe -v error -show_entries format=duration -of csv=p=0 /tmp/media_audio.* | head -1)
|
||||
|
||||
# Split into 10-minute segments (keeps each under 25MB)
|
||||
SEGMENT_SEC=600
|
||||
SEGMENTS=$(python3 -c "import math; print(math.ceil(float('$DURATION')/$SEGMENT_SEC))")
|
||||
|
||||
# Cut segments
|
||||
for i in $(seq 0 $((SEGMENTS-1))); do
|
||||
START=$((i * SEGMENT_SEC))
|
||||
ffmpeg -y -i /tmp/media_audio.* -ss $START -t $SEGMENT_SEC -acodec libmp3lame -q:a 5 \
|
||||
"/tmp/media_segment_${i}.mp3" 2>/dev/null
|
||||
done
|
||||
```
|
||||
|
||||
→ Call Step 2c for each segment **sequentially** (parallel triggers Groq 524 timeout), concatenate results
|
||||
|
||||
### Step 2c: Whisper Transcription
|
||||
|
||||
**Prerequisite**: `GROQ_API_KEY` environment variable
|
||||
|
||||
```bash
|
||||
# Check API key
|
||||
if [ -z "$GROQ_API_KEY" ]; then
|
||||
echo "❌ GROQ_API_KEY not set. Get one at: https://console.groq.com/keys"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Transcribe single file (replace AUDIO_FILE with actual path)
|
||||
curl -s -X POST "https://api.groq.com/openai/v1/audio/transcriptions" \
|
||||
-H "Authorization: Bearer $GROQ_API_KEY" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "file=@AUDIO_FILE" \
|
||||
-F "model=whisper-large-v3-turbo" \
|
||||
-F "response_format=verbose_json" \
|
||||
-F "language=zh" \
|
||||
> /tmp/media_transcript.json
|
||||
|
||||
# Extract plain text
|
||||
python3 -c "import json; print(json.load(open('/tmp/media_transcript.json'))['text'])"
|
||||
```
|
||||
|
||||
**Whisper model options**:
|
||||
| Model | Speed | Accuracy | Use Case |
|
||||
|-------|-------|----------|----------|
|
||||
| `whisper-large-v3-turbo` | 10x realtime | High | Default choice |
|
||||
| `whisper-large-v3` | 5x realtime | Highest | Professional/noisy content |
|
||||
|
||||
**Language parameter**:
|
||||
- Chinese: `language=zh`
|
||||
- English: `language=en`
|
||||
- Auto-detect: omit language parameter
|
||||
|
||||
### Step 3: Structured Summary
|
||||
|
||||
Choose output format based on media type:
|
||||
|
||||
**Video (≤20 min)**:
|
||||
1. **Overview** (1-2 sentences)
|
||||
2. **Key Points** (3-5 bullet points)
|
||||
3. **Notable Quotes** (if any)
|
||||
4. **Action Items** (if applicable)
|
||||
|
||||
**Podcast (>20 min)**:
|
||||
1. **Overview** (2-3 sentences: who discussed what)
|
||||
2. **Chapter Summary** (segmented by topic, 2-3 sentences each)
|
||||
3. **Key Points** (5-8 bullet points)
|
||||
4. **Notable Quotes**
|
||||
5. **Action Items** (if applicable)
|
||||
|
||||
## Output Format
|
||||
|
||||
### Video
|
||||
|
||||
```
|
||||
## 📺 Video Digest
|
||||
|
||||
**Title**: [Video Title]
|
||||
**Duration**: [x minutes]
|
||||
**Language**: [Chinese/English]
|
||||
|
||||
### Overview
|
||||
[1-2 sentence summary]
|
||||
|
||||
### Key Points
|
||||
1. [Point 1]
|
||||
2. [Point 2]
|
||||
...
|
||||
|
||||
### Notable Quotes
|
||||
> "xxx" — [timestamp]
|
||||
|
||||
### Action Items
|
||||
- [if applicable]
|
||||
```
|
||||
|
||||
### Podcast
|
||||
|
||||
```
|
||||
## 🎙️ Podcast Digest
|
||||
|
||||
**Show**: [Podcast Name]
|
||||
**Episode**: [Episode Title]
|
||||
**Duration**: [x minutes]
|
||||
**Guests**: [if any]
|
||||
|
||||
### Overview
|
||||
[2-3 sentences: who discussed what, core conclusions]
|
||||
|
||||
### Chapter Summary
|
||||
#### 1. [Topic] (~xx:xx-xx:xx)
|
||||
[2-3 sentences of core content]
|
||||
|
||||
#### 2. [Topic] (~xx:xx-xx:xx)
|
||||
[2-3 sentences of core content]
|
||||
...
|
||||
|
||||
### Key Points
|
||||
1. [Point 1]
|
||||
2. [Point 2]
|
||||
...
|
||||
|
||||
### Notable Quotes
|
||||
> "xxx"
|
||||
|
||||
### Action Items
|
||||
- [if applicable]
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| No subtitles + no GROQ_API_KEY | Prompt user to set API key |
|
||||
| No subtitles + has API key | Auto Whisper transcription |
|
||||
| Xiaoyuzhou curl extraction empty | Use Puppeteer/browser to get rendered HTML |
|
||||
| Audio >25MB | ffmpeg segment (10min/segment), transcribe sequentially |
|
||||
| Podcast >2 hours | Warn user about duration, confirm before proceeding |
|
||||
| Groq 524 timeout | Do NOT parallelize — transcribe sequentially, sleep 5-8s between segments |
|
||||
| Groq 429 rate limit | 7200s/hour limit, wait for retry-after header, then retry |
|
||||
| yt-dlp Bilibili 412 | Use Bilibili API instead (Step 1d) |
|
||||
| yt-dlp YouTube bot detection | Add `--cookies-from-browser chrome` |
|
||||
| Network timeout | Retry once |
|
||||
| Spotify links | Inform user: not supported (DRM protected) |
|
||||
|
||||
## Groq Whisper Limits
|
||||
|
||||
- Max 25MB per request
|
||||
- Free tier: 7200 seconds of audio/hour (rolling window), ~20 hours/day
|
||||
- Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `yt-dlp`: video download + subtitle extraction
|
||||
- `ffmpeg`: audio conversion + segmentation
|
||||
- `curl`: Xiaoyuzhou audio download, Bilibili API
|
||||
- `GROQ_API_KEY`: Whisper transcription API (free at https://console.groq.com/keys)
|
||||
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Eyes CLI."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from agent_eyes.cli import main
|
||||
|
||||
|
||||
class TestCLI:
|
||||
def test_version(self, capsys):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch("sys.argv", ["agent-eyes", "version"]):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "Agent Eyes v" in captured.out
|
||||
|
||||
def test_no_command_shows_help(self, capsys):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch("sys.argv", ["agent-eyes"]):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
def test_doctor_runs(self, capsys):
|
||||
with patch("sys.argv", ["agent-eyes", "doctor"]):
|
||||
main()
|
||||
captured = capsys.readouterr()
|
||||
assert "Agent Eyes" in captured.out
|
||||
assert "✅" in captured.out
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Eyes config module."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from agent_eyes.config import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path):
|
||||
"""Create a Config with a temporary directory."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
return Config(config_path=config_file)
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_init_creates_dir(self, tmp_path):
|
||||
config_file = tmp_path / "subdir" / "config.yaml"
|
||||
config = Config(config_path=config_file)
|
||||
assert config_file.parent.exists()
|
||||
|
||||
def test_set_and_get(self, tmp_config):
|
||||
tmp_config.set("test_key", "test_value")
|
||||
assert tmp_config.get("test_key") == "test_value"
|
||||
|
||||
def test_get_default(self, tmp_config):
|
||||
assert tmp_config.get("nonexistent") is None
|
||||
assert tmp_config.get("nonexistent", "default") == "default"
|
||||
|
||||
def test_get_from_env(self, tmp_config, monkeypatch):
|
||||
monkeypatch.setenv("TEST_ENV_KEY", "env_value")
|
||||
assert tmp_config.get("test_env_key") == "env_value"
|
||||
|
||||
def test_config_file_priority_over_env(self, tmp_config, monkeypatch):
|
||||
monkeypatch.setenv("MY_KEY", "from_env")
|
||||
tmp_config.set("my_key", "from_config")
|
||||
assert tmp_config.get("my_key") == "from_config"
|
||||
|
||||
def test_save_and_load(self, tmp_config):
|
||||
tmp_config.set("key1", "value1")
|
||||
tmp_config.set("key2", 42)
|
||||
|
||||
# Create new config from same file
|
||||
config2 = Config(config_path=tmp_config.config_path)
|
||||
assert config2.get("key1") == "value1"
|
||||
assert config2.get("key2") == 42
|
||||
|
||||
def test_delete(self, tmp_config):
|
||||
tmp_config.set("to_delete", "value")
|
||||
assert tmp_config.get("to_delete") == "value"
|
||||
tmp_config.delete("to_delete")
|
||||
assert tmp_config.get("to_delete") is None
|
||||
|
||||
def test_is_configured(self, tmp_config):
|
||||
assert not tmp_config.is_configured("exa_search")
|
||||
tmp_config.set("exa_api_key", "test-key")
|
||||
assert tmp_config.is_configured("exa_search")
|
||||
|
||||
def test_is_configured_reddit(self, tmp_config):
|
||||
assert not tmp_config.is_configured("reddit_proxy")
|
||||
tmp_config.set("reddit_proxy", "http://user:pass@ip:port")
|
||||
assert tmp_config.is_configured("reddit_proxy")
|
||||
|
||||
def test_get_configured_features(self, tmp_config):
|
||||
features = tmp_config.get_configured_features()
|
||||
assert isinstance(features, dict)
|
||||
assert "exa_search" in features
|
||||
assert all(v is False for v in features.values())
|
||||
|
||||
def test_to_dict_masks_sensitive(self, tmp_config):
|
||||
tmp_config.set("exa_api_key", "super-secret-key-12345")
|
||||
tmp_config.set("normal_setting", "visible")
|
||||
masked = tmp_config.to_dict()
|
||||
assert masked["exa_api_key"] == "super-se..."
|
||||
assert masked["normal_setting"] == "visible"
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for AgentEyes core class."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from pathlib import Path
|
||||
|
||||
from agent_eyes.config import Config
|
||||
from agent_eyes.core import AgentEyes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def eyes(tmp_path):
|
||||
config = Config(config_path=tmp_path / "config.yaml")
|
||||
return AgentEyes(config=config)
|
||||
|
||||
|
||||
class TestAgentEyes:
|
||||
def test_init(self, eyes):
|
||||
assert eyes.config is not None
|
||||
assert eyes.reader is not None
|
||||
|
||||
def test_detect_platform(self, eyes):
|
||||
assert eyes.detect_platform("https://github.com/openai/gpt-4") == "github"
|
||||
assert eyes.detect_platform("https://reddit.com/r/test") == "reddit"
|
||||
assert eyes.detect_platform("https://x.com/elonmusk/status/123") == "twitter"
|
||||
assert eyes.detect_platform("https://youtube.com/watch?v=abc") == "youtube"
|
||||
assert eyes.detect_platform("https://bilibili.com/video/BV1xx") == "bilibili"
|
||||
assert eyes.detect_platform("https://mp.weixin.qq.com/s/abc") == "wechat"
|
||||
assert eyes.detect_platform("https://example.com") == "generic"
|
||||
|
||||
def test_doctor(self, eyes):
|
||||
results = eyes.doctor()
|
||||
assert isinstance(results, dict)
|
||||
assert "web" in results
|
||||
assert "github_read" in results
|
||||
|
||||
def test_doctor_report(self, eyes):
|
||||
report = eyes.doctor_report()
|
||||
assert isinstance(report, str)
|
||||
assert "Agent Eyes" in report
|
||||
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Eyes doctor module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from agent_eyes.config import Config
|
||||
from agent_eyes.doctor import check_all, format_report, STATUS_OK, STATUS_OFF
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path):
|
||||
return Config(config_path=tmp_path / "config.yaml")
|
||||
|
||||
|
||||
class TestDoctor:
|
||||
def test_zero_config_platforms_always_ok(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
assert results["web"]["status"] == STATUS_OK
|
||||
assert results["github_read"]["status"] == STATUS_OK
|
||||
assert results["bilibili"]["status"] == STATUS_OK
|
||||
assert results["rss"]["status"] == STATUS_OK
|
||||
assert results["tweet_read"]["status"] == STATUS_OK
|
||||
|
||||
def test_search_off_without_exa_key(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
assert results["search_web"]["status"] == STATUS_OFF
|
||||
assert results["search_reddit"]["status"] == STATUS_OFF
|
||||
assert results["search_twitter"]["status"] == STATUS_OFF
|
||||
|
||||
def test_search_on_with_exa_key(self, tmp_config):
|
||||
tmp_config.set("exa_api_key", "test-key")
|
||||
results = check_all(tmp_config)
|
||||
assert results["search_web"]["status"] == STATUS_OK
|
||||
assert results["search_reddit"]["status"] == STATUS_OK
|
||||
assert results["search_twitter"]["status"] == STATUS_OK
|
||||
|
||||
def test_github_search_always_on(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
assert results["search_github"]["status"] == STATUS_OK
|
||||
|
||||
def test_reddit_full_off_without_proxy(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
assert results["reddit_full"]["status"] == STATUS_OFF
|
||||
|
||||
def test_reddit_full_on_with_proxy(self, tmp_config):
|
||||
tmp_config.set("reddit_proxy", "http://user:pass@ip:port")
|
||||
results = check_all(tmp_config)
|
||||
assert results["reddit_full"]["status"] == STATUS_OK
|
||||
|
||||
@patch("agent_eyes.doctor._check_command")
|
||||
def test_birdx_detection(self, mock_cmd, tmp_config):
|
||||
mock_cmd.return_value = True
|
||||
results = check_all(tmp_config)
|
||||
assert results["twitter_advanced"]["status"] == STATUS_OK
|
||||
|
||||
def test_format_report_is_string(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
report = format_report(results)
|
||||
assert isinstance(report, str)
|
||||
assert "Agent Eyes" in report
|
||||
assert "✅" in report
|
||||
|
||||
def test_format_report_shows_count(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
report = format_report(results)
|
||||
assert "platforms active" in report
|
||||
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Eyes search modules."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from agent_eyes.config import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path):
|
||||
c = Config(config_path=tmp_path / "config.yaml")
|
||||
c.set("exa_api_key", "test-key")
|
||||
return c
|
||||
|
||||
|
||||
class TestExaSearch:
|
||||
@patch("agent_eyes.search.exa.requests.post")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_web(self, mock_post, tmp_config):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"title": "Test Result",
|
||||
"url": "https://example.com",
|
||||
"text": "This is a test snippet",
|
||||
"publishedDate": "2025-01-01",
|
||||
"score": 0.95,
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.exa import search_web
|
||||
results = await search_web("test query", config=tmp_config)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["title"] == "Test Result"
|
||||
assert results[0]["url"] == "https://example.com"
|
||||
assert results[0]["snippet"] == "This is a test snippet"
|
||||
|
||||
def test_search_web_requires_key(self):
|
||||
from agent_eyes.search.exa import _get_api_key
|
||||
with pytest.raises(ValueError, match="Exa API key"):
|
||||
_get_api_key(Config(config_path="/tmp/nonexistent/config.yaml"))
|
||||
|
||||
|
||||
class TestRedditSearch:
|
||||
@patch("agent_eyes.search.exa.requests.post")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_reddit(self, mock_post, tmp_config):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"results": []}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.reddit import search_reddit
|
||||
results = await search_reddit("test", config=tmp_config)
|
||||
|
||||
# Verify it searched site:reddit.com
|
||||
call_args = mock_post.call_args
|
||||
query = call_args[1]["json"]["query"]
|
||||
assert "site:reddit.com" in query
|
||||
|
||||
@patch("agent_eyes.search.exa.requests.post")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_reddit_with_sub(self, mock_post, tmp_config):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"results": []}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.reddit import search_reddit
|
||||
await search_reddit("test", subreddit="LocalLLaMA", config=tmp_config)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
query = call_args[1]["json"]["query"]
|
||||
assert "site:reddit.com/r/LocalLLaMA" in query
|
||||
|
||||
|
||||
class TestGitHubSearch:
|
||||
@patch("agent_eyes.search.github.requests.get")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_github(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"full_name": "owner/repo",
|
||||
"html_url": "https://github.com/owner/repo",
|
||||
"description": "A test repo",
|
||||
"stargazers_count": 100,
|
||||
"forks_count": 20,
|
||||
"language": "Python",
|
||||
"updated_at": "2025-01-01",
|
||||
"topics": ["ai"],
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.github import search_github
|
||||
results = await search_github("test")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["name"] == "owner/repo"
|
||||
assert results[0]["stars"] == 100
|
||||
|
||||
@patch("agent_eyes.search.github.requests.get")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_github_with_language(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"items": []}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.github import search_github
|
||||
await search_github("test", language="python")
|
||||
|
||||
call_args = mock_get.call_args
|
||||
assert "language:python" in call_args[1]["params"]["q"]
|
||||
|
||||
|
||||
class TestTwitterSearch:
|
||||
@patch("agent_eyes.search.twitter.shutil.which", return_value=None)
|
||||
@patch("agent_eyes.search.exa.requests.post")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_twitter_exa_fallback(self, mock_post, mock_which, tmp_config):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"results": []}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.twitter import search_twitter
|
||||
results = await search_twitter("test", config=tmp_config)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
query = call_args[1]["json"]["query"]
|
||||
assert "site:x.com" in query
|
||||
Reference in New Issue
Block a user