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:
Panniantong
2026-02-24 04:00:47 +01:00
parent 3a3a0101cf
commit 8eab038cb9
46 changed files with 2232 additions and 1054 deletions
+12 -11
View File
@@ -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
View File
@@ -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/
+201 -105
View File
@@ -2,24 +2,22 @@
**Give your AI Agent eyes to see the entire internet.**
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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
+8 -2
View File
@@ -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
View File
@@ -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__":
+96
View File
@@ -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
+133
View File
@@ -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))
+226
View File
@@ -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)
-94
View File
@@ -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)
+54
View File
@@ -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 了。"
+47
View File
@@ -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. 反馈:"✅ 语音转文字已开启!现在遇到没有字幕的视频,我也能帮你提取内容了。"
+60
View File
@@ -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,请检查代理是否有效,或换一个试试。"
+69
View File
@@ -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 无效或已过期,请重新导出。"
+40
View File
@@ -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)。"
+42
View File
@@ -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. 反馈:"✅ 小红书已配置!现在我可以读取小红书笔记了。"
+1
View File
@@ -0,0 +1 @@
# -*- coding: utf-8 -*-
+198
View File
@@ -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())
+65
View File
@@ -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())
```
-91
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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",
]
+79
View File
@@ -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
+118
View File
@@ -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
+30
View File
@@ -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)
+112
View File
@@ -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
View File
@@ -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
View File
@@ -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"
-131
View File
@@ -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
-292
View File
@@ -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)
+29
View File
@@ -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
+80
View File
@@ -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"
+41
View File
@@ -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
+67
View File
@@ -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
+142
View File
@@ -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