rename: Agent Eyes → Agent Reach

全局重命名:
- 包名: agent_eyes → agent_reach
- CLI: agent-eyes → agent-reach
- 类名: AgentEyes → AgentReach
- 显示名: Agent Eyes → Agent Reach
- GitHub: Panniantong/agent-eyes → Panniantong/Agent-Reach

所有 36 个测试通过,CLI/doctor/read/search 全部正常。
This commit is contained in:
Panniantong
2026-02-24 10:25:46 +01:00
parent 4b0ae20fd7
commit 5c62a21f32
38 changed files with 251 additions and 251 deletions
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""Agent Reach — Give your AI Agent eyes to see the entire internet."""
__version__ = "1.0.0"
__author__ = "Neo Reid"
from agent_reach.core import AgentReach
__all__ = ["AgentReach"]
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""
Channel registry — routes URLs to the right channel.
This is the core of Agent Reach' pluggable architecture.
Add a new channel: just create a file and register it here.
Swap a backend: just change the implementation inside the channel file.
"""
from typing import Dict, List, Optional
from .base import Channel, ReadResult, SearchResult
# Import all channels
from .web import WebChannel
from .github import GitHubChannel
from .twitter import TwitterChannel
from .youtube import YouTubeChannel
from .reddit import RedditChannel
from .rss import RSSChannel
from .bilibili import BilibiliChannel
from .exa_search import ExaSearchChannel
from .xiaohongshu import XiaoHongShuChannel
# Channel registry — order matters (first match wins, web is last as fallback)
ALL_CHANNELS: List[Channel] = [
GitHubChannel(),
TwitterChannel(),
YouTubeChannel(),
RedditChannel(),
BilibiliChannel(),
XiaoHongShuChannel(),
RSSChannel(),
ExaSearchChannel(),
WebChannel(), # Fallback — handles any URL
]
# Search-capable channels
SEARCH_CHANNELS: Dict[str, Channel] = {
ch.name: ch for ch in ALL_CHANNELS if ch.can_search()
}
def get_channel_for_url(url: str) -> Channel:
"""Find the right channel for a URL."""
for channel in ALL_CHANNELS:
if channel.can_handle(url):
return channel
return WebChannel() # Should never reach here, but just in case
def get_channel(name: str) -> Optional[Channel]:
"""Get a channel by name."""
for ch in ALL_CHANNELS:
if ch.name == name:
return ch
return None
def get_all_channels() -> List[Channel]:
"""Get all registered channels."""
return ALL_CHANNELS
__all__ = [
"Channel", "ReadResult", "SearchResult",
"ALL_CHANNELS", "SEARCH_CHANNELS",
"get_channel_for_url", "get_channel", "get_all_channels",
]
+140
View File
@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
"""
Channel base class — the universal interface for all platforms.
Every channel (YouTube, Twitter, GitHub, etc.) implements this interface.
The backend tool can be swapped anytime without changing anything else.
Example:
class YouTubeChannel(Channel):
name = "youtube"
backends = ["yt-dlp"] # current backend, can be swapped
async def read(self, url, config):
# Just call yt-dlp, return standardized dict
...
"""
import shutil
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
@dataclass
class ReadResult:
"""Standardized read result. Every channel returns this."""
title: str
content: str
url: str
author: str = ""
date: str = ""
platform: str = ""
extra: dict = None
def __post_init__(self):
self.extra = self.extra or {}
def to_dict(self) -> dict:
d = {
"title": self.title,
"content": self.content,
"url": self.url,
"platform": self.platform,
}
if self.author:
d["author"] = self.author
if self.date:
d["date"] = self.date
if self.extra:
d["extra"] = self.extra
return d
@dataclass
class SearchResult:
"""Standardized search result."""
title: str
url: str
snippet: str = ""
author: str = ""
date: str = ""
score: float = 0
extra: dict = None
def __post_init__(self):
self.extra = self.extra or {}
def to_dict(self) -> dict:
d = {
"title": self.title,
"url": self.url,
"snippet": self.snippet,
}
if self.author:
d["author"] = self.author
if self.date:
d["date"] = self.date
if self.extra:
d["extra"] = self.extra
return d
class Channel(ABC):
"""
Base class for all channels.
Subclasses just need to implement:
- read(url, config) → ReadResult
- can_handle(url) → bool
- check(config) → (status, message)
Optionally:
- search(query, config, **kwargs) → list[SearchResult]
"""
name: str = "" # e.g. "youtube"
description: str = "" # e.g. "YouTube video transcripts"
backends: List[str] = [] # e.g. ["yt-dlp"] — what external tool is used
requires_config: List[str] = [] # e.g. ["reddit_proxy"]
requires_tools: List[str] = [] # e.g. ["yt-dlp"]
tier: int = 0 # 0=zero-config, 1=needs free key, 2=needs setup
@abstractmethod
async def read(self, url: str, config=None) -> ReadResult:
"""Read content from a URL. Must return ReadResult."""
...
@abstractmethod
def can_handle(self, url: str) -> bool:
"""Check if this channel can handle this URL."""
...
def check(self, config=None) -> Tuple[str, str]:
"""
Check if this channel is available.
Returns (status, message) where status is 'ok'/'warn'/'off'/'error'.
"""
# Check required tools
for tool in self.requires_tools:
if not shutil.which(tool):
return "off", f"需要安装:pip install {tool}"
# Check required config
for key in self.requires_config:
if config and not config.get(key):
return "off", f"需要配置 {key},运行 agent-reach setup"
return "ok", f"{''.join(self.backends) if self.backends else '内置'}"
async def search(self, query: str, config=None, **kwargs) -> List[SearchResult]:
"""Search this platform. Override if supported."""
raise NotImplementedError(f"{self.name} does not support search")
def can_search(self) -> bool:
"""Whether this channel supports search."""
try:
# Check if search is overridden
return type(self).search is not Channel.search
except:
return False
+121
View File
@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
"""Bilibili — via public API (free, no config needed).
Backend: Bilibili public API
Swap to: any Bilibili access method
"""
import requests
from urllib.parse import urlparse, parse_qs
from .base import Channel, ReadResult
class BilibiliChannel(Channel):
name = "bilibili"
description = "B站视频信息和字幕"
backends = ["Bilibili API"]
tier = 0
def can_handle(self, url: str) -> bool:
domain = urlparse(url).netloc.lower()
return "bilibili.com" in domain or "b23.tv" in domain
def check(self, config=None):
proxy = config.get("bilibili_proxy") if config else None
if proxy:
return "ok", "已配置代理,完整可用"
# Detect if we're on a server (same logic as cli._detect_environment)
import os
indicators = [
os.path.exists("/var/run/docker.sock"),
os.path.exists("/etc/cloud"),
"SSH_CONNECTION" in os.environ,
"container" in os.environ.get("container", ""),
]
is_server = any(indicators)
if is_server:
return "warn", "服务器 IP 可能被封,配置代理即可解决:agent-reach configure proxy URL"
return "ok", "本地直连可用"
async def read(self, url: str, config=None) -> ReadResult:
# Proxy support (Bilibili blocks server IPs)
proxy = config.get("bilibili_proxy") if config else None
proxies = {"http": proxy, "https": proxy} if proxy else None
# Extract BV id from URL
path = urlparse(url).path
bv_id = ""
for part in path.split("/"):
if part.startswith("BV"):
bv_id = part
break
if not bv_id:
# Fallback to Jina Reader
from agent_reach.channels.web import WebChannel
return await WebChannel().read(url, config)
# Get video info
resp = requests.get(
"https://api.bilibili.com/x/web-interface/view",
params={"bvid": bv_id},
headers={"User-Agent": "Mozilla/5.0"},
proxies=proxies,
timeout=15,
)
resp.raise_for_status()
api_data = resp.json()
# Check for API errors (IP blocked, video not found, etc.)
if api_data.get("code") != 0:
msg = api_data.get("message", "Unknown error")
# Bilibili returns -404 when server IP is blocked
if api_data.get("code") in (-404, -403, -412):
return ReadResult(
title=f"Bilibili: {bv_id}",
content=f"⚠️ Bilibili blocked this request ({msg}). "
f"This usually means the server IP is blocked. "
f"Try: agent-reach configure proxy http://user:pass@ip:port",
url=url,
platform="bilibili",
)
return ReadResult(
title=f"Bilibili: {bv_id}",
content=f"Bilibili API error: {msg} (code: {api_data.get('code')})",
url=url,
platform="bilibili",
)
data = api_data.get("data", {})
title = data.get("title", "")
desc = data.get("desc", "")
author = data.get("owner", {}).get("name", "")
# Try to get subtitles
subtitle_text = ""
subtitle_list = data.get("subtitle", {}).get("list", [])
if subtitle_list:
sub_url = subtitle_list[0].get("subtitle_url", "")
if sub_url:
if sub_url.startswith("//"):
sub_url = "https:" + sub_url
sr = requests.get(sub_url, timeout=10)
if sr.ok:
sub_data = sr.json()
lines = [item.get("content", "") for item in sub_data.get("body", [])]
subtitle_text = "\n".join(lines)
content = desc
if subtitle_text:
content += f"\n\n## Transcript\n{subtitle_text}"
return ReadResult(
title=title,
content=content,
url=url,
author=author,
platform="bilibili",
extra={"view": data.get("stat", {}).get("view", 0),
"like": data.get("stat", {}).get("like", 0)},
)
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""Exa semantic search — the search backbone for Agent Reach.
Backend: Exa API (https://exa.ai) — free 1000 searches/month
Swap to: Tavily, SerpAPI, or any search API
"""
import os
import requests
from .base import Channel, SearchResult
from typing import List
class ExaSearchChannel(Channel):
name = "exa_search"
description = "全网语义搜索(同时支持 Reddit/Twitter 搜索)"
backends = ["Exa API"]
requires_config = ["exa_api_key"]
tier = 1
API_URL = "https://api.exa.ai/search"
def can_handle(self, url: str) -> bool:
return False # Search-only channel, doesn't read URLs
async def read(self, url: str, config=None) -> None:
raise NotImplementedError("Exa is a search engine, not a reader")
def _get_key(self, config=None) -> str:
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-reach setup"
)
async def search(self, query: str, config=None, **kwargs) -> List[SearchResult]:
api_key = self._get_key(config)
limit = kwargs.get("limit", 5)
resp = requests.post(
self.API_URL,
headers={"Content-Type": "application/json", "x-api-key": api_key},
json={
"query": query,
"numResults": min(limit, 10),
"type": "auto",
"contents": {"text": {"maxCharacters": 500}},
},
timeout=15,
)
resp.raise_for_status()
results = []
for item in resp.json().get("results", []):
results.append(SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
snippet=item.get("text", ""),
date=item.get("publishedDate", ""),
score=item.get("score", 0),
))
return results
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""GitHub — via GitHub REST API (free, no config needed).
Backend: GitHub API v3
Swap to: gh CLI, or any GitHub API wrapper
"""
import requests
from urllib.parse import urlparse
from .base import Channel, ReadResult, SearchResult
from typing import List
class GitHubChannel(Channel):
name = "github"
description = "GitHub 仓库和代码"
backends = ["GitHub API"]
tier = 0
API = "https://api.github.com"
def _headers(self, config=None):
h = {"Accept": "application/vnd.github+json"}
token = config.get("github_token") if config else None
if token:
h["Authorization"] = f"Bearer {token}"
return h
def check(self, config=None):
import shutil
token = config.get("github_token") if config else None
has_gh = shutil.which("gh")
if token or has_gh:
return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)"
return "ok", "公开仓库可读可搜。配置 gh CLI 或 github_token 可解锁 Fork、Issue、PR 等操作"
def can_handle(self, url: str) -> bool:
domain = urlparse(url).netloc.lower()
return "github.com" in domain
async def read(self, url: str, config=None) -> ReadResult:
path = urlparse(url).path.strip("/").split("/")
if len(path) < 2:
raise ValueError(f"Invalid GitHub URL: {url}")
owner, repo = path[0], path[1]
headers = self._headers(config)
# Issues/PRs
if len(path) >= 4 and path[2] in ("issues", "pull"):
num = path[3]
resp = requests.get(f"{self.API}/repos/{owner}/{repo}/issues/{num}", headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
# Get comments
comments_text = ""
if data.get("comments", 0) > 0:
cr = requests.get(f"{self.API}/repos/{owner}/{repo}/issues/{num}/comments",
headers=headers, params={"per_page": 20}, timeout=15)
if cr.ok:
for c in cr.json():
comments_text += f"\n\n---\n**{c.get('user', {}).get('login', '')}** ({c.get('created_at', '')}):\n{c.get('body', '')}"
return ReadResult(
title=data.get("title", ""),
content=(data.get("body", "") or "") + comments_text,
url=url,
author=data.get("user", {}).get("login", ""),
date=data.get("created_at", ""),
platform="github",
extra={"state": data.get("state"), "comments": data.get("comments", 0),
"reactions": data.get("reactions", {}).get("total_count", 0)},
)
# Repo
resp = requests.get(f"{self.API}/repos/{owner}/{repo}", headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
# Get README
readme_text = ""
rr = requests.get(f"{self.API}/repos/{owner}/{repo}/readme", headers=headers, timeout=15)
if rr.ok:
import base64
readme_data = rr.json()
if readme_data.get("encoding") == "base64":
readme_text = base64.b64decode(readme_data["content"]).decode("utf-8", errors="replace")
return ReadResult(
title=f"{owner}/{repo}",
content=readme_text or data.get("description", ""),
url=url,
author=owner,
platform="github",
extra={"stars": data.get("stargazers_count", 0), "forks": data.get("forks_count", 0),
"language": data.get("language", ""), "description": data.get("description", "")},
)
async def search(self, query: str, config=None, **kwargs) -> List[SearchResult]:
language = kwargs.get("language")
limit = kwargs.get("limit", 5)
q = query
if language:
q += f" language:{language}"
resp = requests.get(
f"{self.API}/search/repositories",
headers=self._headers(config),
params={"q": q, "sort": "stars", "per_page": min(limit, 30)},
timeout=15,
)
resp.raise_for_status()
results = []
for repo in resp.json().get("items", []):
results.append(SearchResult(
title=repo.get("full_name", ""),
url=repo.get("html_url", ""),
snippet=repo.get("description", ""),
date=repo.get("updated_at", ""),
extra={"stars": repo.get("stargazers_count", 0),
"forks": repo.get("forks_count", 0),
"language": repo.get("language", "")},
))
return results
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""Reddit — via Reddit JSON API + optional proxy.
Backend: Reddit public JSON API (append .json to any URL)
Swap to: any Reddit access method
"""
import os
import requests
from urllib.parse import urlparse
from .base import Channel, ReadResult
class RedditChannel(Channel):
name = "reddit"
description = "Reddit 帖子和评论"
backends = ["Reddit JSON API"]
tier = 2
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
def can_handle(self, url: str) -> bool:
domain = urlparse(url).netloc.lower()
return "reddit.com" in domain or "redd.it" in domain
def check(self, config=None):
proxy = config.get("reddit_proxy") if config else None
has_bot = bool(os.environ.get("REDDIT_CLIENT_ID"))
if proxy and has_bot:
return "ok", "完整可用(代理 + OAuth Bot"
elif proxy:
return "ok", "代理已配置,可读取帖子。配置 REDDIT_CLIENT_ID/SECRET 可解锁高级搜索和发帖"
elif has_bot:
return "warn", "OAuth Bot 已配置,但服务器直连可能被封。配个代理更稳定:agent-reach configure proxy URL"
else:
return "off", "搜索用 Exa 免费可用。读帖子需配个代理:agent-reach configure proxy URL"
async def read(self, url: str, config=None) -> ReadResult:
proxy = config.get("reddit_proxy") if config else None
proxies = {"http": proxy, "https": proxy} if proxy else None
# Clean URL: remove query params, trailing slash, then add .json
parsed = urlparse(url)
clean_path = parsed.path.rstrip("/")
# Remove trailing .json if already present (avoid double .json)
if clean_path.endswith(".json"):
clean_path = clean_path[:-5]
json_url = f"https://www.reddit.com{clean_path}.json"
try:
resp = requests.get(
json_url,
headers={"User-Agent": self.USER_AGENT},
proxies=proxies,
params={"limit": 50},
timeout=15,
)
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else 0
if status in (403, 429):
return ReadResult(
title="Reddit",
content="⚠️ Reddit blocked this request (403 Forbidden). "
"Reddit blocks most server IPs.\n"
"Fix: agent-reach configure proxy http://user:pass@ip:port\n"
"Cheap option: https://www.webshare.io ($1/month)\n\n"
"Alternatively, search Reddit via Exa (free, no proxy needed): "
"agent-reach search-reddit \"your query\"",
url=url,
platform="reddit",
)
raise
data = resp.json()
if isinstance(data, list) and len(data) >= 1:
# Post page: [post_listing, comments_listing]
post = data[0]["data"]["children"][0]["data"]
title = post.get("title", "")
author = post.get("author", "")
selftext = post.get("selftext", "")
score = post.get("score", 0)
subreddit = post.get("subreddit", "")
# Extract comments
comments_text = ""
if len(data) >= 2:
comments_text = self._extract_comments(data[1])
content = selftext
if comments_text:
content += f"\n\n---\n## Comments\n{comments_text}"
return ReadResult(
title=title,
content=content,
url=url,
author=f"u/{author}",
platform="reddit",
extra={"subreddit": subreddit, "score": score},
)
raise ValueError(f"Could not parse Reddit response for: {url}")
def _extract_comments(self, comments_data: dict, depth: int = 0, max_depth: int = 3) -> str:
"""Recursively extract comments."""
lines = []
children = comments_data.get("data", {}).get("children", [])
for child in children:
if child.get("kind") != "t1":
continue
data = child.get("data", {})
author = data.get("author", "[deleted]")
body = data.get("body", "")
score = data.get("score", 0)
indent = " " * depth
lines.append(f"{indent}**u/{author}** ({score} points):")
lines.append(f"{indent}{body}")
lines.append("")
# Recurse into replies
if depth < max_depth and data.get("replies") and isinstance(data["replies"], dict):
lines.append(self._extract_comments(data["replies"], depth + 1, max_depth))
return "\n".join(lines)
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""RSS feeds — via feedparser (free, pip dependency).
Backend: feedparser (https://github.com/kurtmckee/feedparser)
Swap to: any RSS parser
"""
import feedparser
from urllib.parse import urlparse
from .base import Channel, ReadResult
class RSSChannel(Channel):
name = "rss"
description = "RSS/Atom 订阅源"
backends = ["feedparser"]
tier = 0
def can_handle(self, url: str) -> bool:
lower = url.lower()
domain = urlparse(url).netloc.lower()
return (lower.endswith(".xml") or "/rss" in lower or "/feed" in lower
or "/atom" in lower or "rss" in domain)
async def read(self, url: str, config=None) -> ReadResult:
feed = feedparser.parse(url)
if feed.bozo and not feed.entries:
raise ValueError(f"Failed to parse RSS feed: {url}")
if not feed.entries:
raise ValueError(f"No entries in RSS feed: {url}")
# Return latest entry
entry = feed.entries[0]
content = entry.get("summary", "") or entry.get("description", "")
# If multiple entries, summarize all
if len(feed.entries) > 1:
lines = [f"# {feed.feed.get('title', 'RSS Feed')}\n"]
for i, e in enumerate(feed.entries[:20], 1):
title = e.get("title", "Untitled")
link = e.get("link", "")
summary = e.get("summary", "")[:200]
lines.append(f"## {i}. {title}")
lines.append(f"🔗 {link}")
if summary:
lines.append(summary)
lines.append("")
content = "\n".join(lines)
return ReadResult(
title=feed.feed.get("title", entry.get("title", url)),
content=content,
url=url,
platform="rss",
)
+150
View File
@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""Twitter/X — via birdx CLI (free) or Jina Reader fallback.
Backend: birdx (https://github.com/runesleo/birdx) for search/timeline
Jina Reader for single tweets
Swap to: any Twitter access tool
"""
import shutil
import subprocess
from urllib.parse import urlparse
from .base import Channel, ReadResult, SearchResult
from typing import List
import requests
class TwitterChannel(Channel):
name = "twitter"
description = "Twitter/X 推文"
backends = ["birdx", "Jina Reader"]
tier = 0 # Single tweet reading is zero-config
def can_handle(self, url: str) -> bool:
domain = urlparse(url).netloc.lower()
return "x.com" in domain or "twitter.com" in domain
def check(self, config=None):
# Basic reading always works (Jina fallback)
if shutil.which("birdx"):
return "ok", "搜索、时间线、发推全部可用"
return "ok", "可读取推文。安装 birdx + 配置 Cookie 可解锁搜索和发推"
return "ok", "Jina Reader (single tweets only)"
async def read(self, url: str, config=None) -> ReadResult:
# Try birdx first
if shutil.which("birdx"):
return await self._read_birdx(url)
# Fallback: Jina Reader
return await self._read_jina(url)
async def _read_birdx(self, url: str) -> ReadResult:
result = subprocess.run(
["birdx", "read", url],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
return await self._read_jina(url)
text = result.stdout.strip()
# Extract author from first line
author = ""
lines = text.split("\n")
if lines and lines[0].startswith("@"):
author = lines[0].split()[0]
return ReadResult(
title=text[:100],
content=text,
url=url,
author=author,
platform="twitter",
)
async def _read_jina(self, url: str) -> ReadResult:
resp = requests.get(
f"https://r.jina.ai/{url}",
headers={"Accept": "text/markdown"},
timeout=15,
)
resp.raise_for_status()
text = resp.text
title = text[:100] if text else url
return ReadResult(
title=title,
content=text,
url=url,
platform="twitter",
)
async def search(self, query: str, config=None, **kwargs) -> List[SearchResult]:
limit = kwargs.get("limit", 10)
if shutil.which("birdx"):
return await self._search_birdx(query, limit)
# Fallback to Exa
return await self._search_exa(query, limit, config)
async def _search_birdx(self, query: str, limit: int) -> List[SearchResult]:
try:
result = subprocess.run(
["birdx", "search", query, "-n", str(limit)],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
return []
return self._parse_birdx_output(result.stdout)
except (subprocess.TimeoutExpired, FileNotFoundError):
return []
def _parse_birdx_output(self, text: str) -> List[SearchResult]:
"""Parse birdx text output into SearchResults."""
results = []
current = {}
text_lines = []
for line in text.strip().split("\n"):
line = line.strip()
if line.startswith(""):
if current:
current["text"] = "\n".join(text_lines).strip()
results.append(SearchResult(
title=current.get("text", "")[:80],
url=current.get("url", ""),
snippet=current.get("text", ""),
author=current.get("author", ""),
date=current.get("date", ""),
))
current = {}
text_lines = []
continue
if line.startswith("@") and line.endswith(":") and "(" in line:
current["author"] = line.split()[0]
continue
if line.startswith("date:"):
current["date"] = line[5:].strip()
continue
if line.startswith("url:"):
current["url"] = line[4:].strip()
continue
if current is not None:
text_lines.append(line)
if current and text_lines:
current["text"] = "\n".join(text_lines).strip()
results.append(SearchResult(
title=current.get("text", "")[:80],
url=current.get("url", ""),
snippet=current.get("text", ""),
author=current.get("author", ""),
date=current.get("date", ""),
))
return results
async def _search_exa(self, query: str, limit: int, config=None) -> List[SearchResult]:
from agent_reach.channels.exa_search import ExaSearchChannel
exa = ExaSearchChannel()
return await exa.search(f"site:x.com {query}", config=config, limit=limit)
+49
View File
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""Web pages — via Jina Reader API (free, no config needed).
Backend: Jina Reader (https://r.jina.ai)
Swap to: Firecrawl, Trafilatura, or any other reader API
"""
import requests
from .base import Channel, ReadResult
class WebChannel(Channel):
name = "web"
description = "网页(任意 URL"
backends = ["Jina Reader API"]
tier = 0
JINA_URL = "https://r.jina.ai/"
def can_handle(self, url: str) -> bool:
# Fallback — handles any URL not matched by other channels
return True
async def read(self, url: str, config=None) -> ReadResult:
resp = requests.get(
f"{self.JINA_URL}{url}",
headers={"Accept": "text/markdown"},
timeout=15,
)
resp.raise_for_status()
text = resp.text
# Extract title from first markdown heading
title = url
for line in text.split("\n"):
line = line.strip()
if line.startswith("# "):
title = line[2:].strip()
break
if line.startswith("Title:"):
title = line[6:].strip()
break
return ReadResult(
title=title,
content=text,
url=url,
platform="web",
)
+120
View File
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""XiaoHongShu (小红书) — via cookie-based API access.
Backend: XHS web API + cookies
Swap to: any XHS access method
"""
import re
import json
import requests
from urllib.parse import urlparse
from .base import Channel, ReadResult
class XiaoHongShuChannel(Channel):
name = "xiaohongshu"
description = "小红书笔记"
backends = ["XHS Web API"]
tier = 2
def can_handle(self, url: str) -> bool:
domain = urlparse(url).netloc.lower()
return "xiaohongshu.com" in domain or "xhslink.com" in domain
def check(self, config=None):
cookie = config.get("xhs_cookie") if config else None
if cookie:
return "ok", "Cookie 已配置,完整可用"
return "off", "需要配置 Cookie 才能访问。导入浏览器 Cookie 即可:agent-reach configure --from-browser chrome"
async def read(self, url: str, config=None) -> ReadResult:
cookie = config.get("xhs_cookie") if config else None
if not cookie:
return ReadResult(
title="XiaoHongShu",
content="⚠️ XiaoHongShu requires cookies to access.\n"
"Set up: agent-reach configure xhs-cookie \"YOUR_COOKIE_STRING\"\n"
"How to get it: install Cookie-Editor extension → go to xiaohongshu.com → Export → Header String",
url=url,
platform="xiaohongshu",
)
# Extract note ID from URL
note_id = self._extract_note_id(url)
if not note_id:
from agent_reach.channels.web import WebChannel
return await WebChannel().read(url, config)
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Cookie": cookie,
"Referer": "https://www.xiaohongshu.com/",
}
# Fetch note page
resp = requests.get(
f"https://www.xiaohongshu.com/explore/{note_id}",
headers=headers,
timeout=15,
)
resp.raise_for_status()
html = resp.text
# Extract note data from HTML
title, content, author = self._parse_html(html)
return ReadResult(
title=title or f"XHS Note {note_id}",
content=content or "Could not extract content. Cookie may be expired.",
url=url,
author=author,
platform="xiaohongshu",
)
def _extract_note_id(self, url: str) -> str:
"""Extract note ID from various XHS URL formats."""
# https://www.xiaohongshu.com/explore/xxxxx
# https://www.xiaohongshu.com/discovery/item/xxxxx
# https://xhslink.com/xxxxx
path = urlparse(url).path
parts = path.strip("/").split("/")
if parts:
return parts[-1]
return ""
def _parse_html(self, html: str):
"""Extract title, content, author from XHS HTML."""
title = ""
content = ""
author = ""
# Try to find JSON data in page
match = re.search(r'window\.__INITIAL_STATE__\s*=\s*({.*?})\s*</script>', html, re.DOTALL)
if match:
try:
# XHS embeds note data in initial state
state = json.loads(match.group(1).replace('undefined', 'null'))
note_data = state.get("note", {}).get("noteDetailMap", {})
if note_data:
first_note = list(note_data.values())[0]
note = first_note.get("note", {})
title = note.get("title", "")
content = note.get("desc", "")
author = note.get("user", {}).get("nickname", "")
except (json.JSONDecodeError, KeyError, IndexError):
pass
# Fallback: extract from meta tags
if not title:
m = re.search(r'<title>(.*?)</title>', html)
if m:
title = m.group(1)
if not content:
m = re.search(r'<meta name="description" content="(.*?)"', html)
if m:
content = m.group(1)
return title, content, author
+94
View File
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""YouTube — via yt-dlp (free, pip install yt-dlp).
Backend: yt-dlp (https://github.com/yt-dlp/yt-dlp)
Swap to: any YouTube subtitle extractor
"""
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from .base import Channel, ReadResult
class YouTubeChannel(Channel):
name = "youtube"
description = "YouTube 视频字幕"
backends = ["yt-dlp"]
requires_tools = ["yt-dlp"]
tier = 0
def can_handle(self, url: str) -> bool:
domain = urlparse(url).netloc.lower()
return "youtube.com" in domain or "youtu.be" in domain
async def read(self, url: str, config=None) -> ReadResult:
if not shutil.which("yt-dlp"):
raise RuntimeError("yt-dlp not installed. Install: pip install yt-dlp")
with tempfile.TemporaryDirectory() as tmpdir:
# Get video info
info = self._get_info(url)
title = info.get("title", url)
author = info.get("uploader", "")
# Try to get subtitles
transcript = self._get_subtitles(url, tmpdir)
if not transcript:
transcript = f"[Video: {title}]\n[No subtitles available. Use Groq Whisper for transcription.]"
return ReadResult(
title=title,
content=transcript,
url=url,
author=author,
platform="youtube",
extra={
"duration": info.get("duration"),
"view_count": info.get("view_count"),
"upload_date": info.get("upload_date"),
},
)
def _get_info(self, url: str) -> dict:
try:
result = subprocess.run(
["yt-dlp", "--dump-json", "--no-download", url],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError):
pass
return {}
def _get_subtitles(self, url: str, tmpdir: str) -> str:
"""Extract subtitles using yt-dlp."""
try:
subprocess.run(
["yt-dlp", "--write-auto-sub", "--write-sub",
"--sub-lang", "en,zh-Hans,zh",
"--skip-download", "--sub-format", "vtt",
"-o", f"{tmpdir}/%(id)s.%(ext)s", url],
capture_output=True, text=True, timeout=30,
)
# Find and read subtitle file
for f in Path(tmpdir).glob("*.vtt"):
text = f.read_text(errors="replace")
# Strip VTT headers and timestamps
lines = []
for line in text.split("\n"):
line = line.strip()
if not line or line.startswith("WEBVTT") or "-->" in line or line.isdigit():
continue
if line not in lines[-1:]: # deduplicate
lines.append(line)
return "\n".join(lines)
except subprocess.TimeoutExpired:
pass
return ""
+596
View File
@@ -0,0 +1,596 @@
# -*- coding: utf-8 -*-
"""
Agent Reach CLI — command-line interface.
Usage:
agent-reach read <url>
agent-reach search <query>
agent-reach search-reddit <query> [--sub <subreddit>]
agent-reach search-github <query> [--lang <language>]
agent-reach search-twitter <query>
agent-reach setup
agent-reach doctor
agent-reach version
"""
import sys
import asyncio
import argparse
import json
import os
from agent_reach import __version__
def _configure_logging(verbose: bool = False):
"""Suppress loguru output unless --verbose is set."""
from loguru import logger
logger.remove() # Remove default stderr handler
if verbose:
logger.add(sys.stderr, level="INFO")
def main():
parser = argparse.ArgumentParser(
prog="agent-reach",
description="👁️ Give your AI Agent eyes to see the entire internet",
)
parser.add_argument("-v", "--verbose", action="store_true", help="Show debug logs")
sub = parser.add_subparsers(dest="command", help="Available commands")
# ── 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")
# ── 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")
# ── 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")
# ── 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")
# ── 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")
# ── install ──
p_install = sub.add_parser("install", help="One-shot installer with flags")
p_install.add_argument("--env", choices=["local", "server", "auto"], default="auto",
help="Environment: local, server, or auto-detect")
p_install.add_argument("--search", choices=["yes", "no"], default="yes",
help="Enable web search (needs free Exa API key)")
p_install.add_argument("--proxy", default="",
help="Residential proxy for Reddit/Bilibili (http://user:pass@ip:port)")
p_install.add_argument("--exa-key", default="",
help="Exa API key (get free at https://exa.ai)")
# ── configure ──
p_conf = sub.add_parser("configure", help="Set a config value or auto-extract from browser")
p_conf.add_argument("key", nargs="?", default=None,
choices=["exa-key", "proxy", "github-token", "groq-key",
"twitter-cookies", "xhs-cookie", "youtube-cookies"],
help="What to configure (omit if using --from-browser)")
p_conf.add_argument("value", nargs="*", help="The value(s) to set")
p_conf.add_argument("--from-browser", metavar="BROWSER",
choices=["chrome", "firefox", "edge", "brave", "opera"],
help="Auto-extract ALL platform cookies from browser (chrome/firefox/edge/brave/opera)")
# ── doctor ──
sub.add_parser("doctor", help="Check platform availability")
# ── version ──
sub.add_parser("version", help="Show version")
args = parser.parse_args()
# Suppress loguru noise unless --verbose
_configure_logging(getattr(args, "verbose", False))
if not args.command:
parser.print_help()
sys.exit(0)
if args.command == "version":
print(f"Agent Reach v{__version__}")
sys.exit(0)
if args.command == "doctor":
_cmd_doctor()
elif args.command == "setup":
_cmd_setup()
elif args.command == "install":
_cmd_install(args)
elif args.command == "configure":
_cmd_configure(args)
elif args.command == "read":
asyncio.run(_cmd_read(args))
elif args.command.startswith("search"):
asyncio.run(_cmd_search(args))
# ── Command handlers ────────────────────────────────
def _cmd_install(args):
"""One-shot deterministic installer."""
import os
from agent_reach.config import Config
from agent_reach.doctor import check_all, format_report
config = Config()
print()
print("👁️ Agent Reach Installer")
print("=" * 40)
# Auto-detect environment
env = args.env
if env == "auto":
env = _detect_environment()
if env == "server":
print(f"📡 Environment: Server/VPS (auto-detected)")
else:
print(f"💻 Environment: Local computer (auto-detected)")
# Apply explicit flags
if args.exa_key:
config.set("exa_api_key", args.exa_key)
print(f"✅ Exa search key configured")
if args.proxy:
config.set("reddit_proxy", args.proxy)
config.set("bilibili_proxy", args.proxy)
print(f"✅ Proxy configured for Reddit + Bilibili")
# Auto-detect Exa key from environment
if not config.get("exa_api_key") and not args.exa_key:
env_key = os.environ.get("EXA_API_KEY") or os.environ.get("exa_api_key")
if env_key:
config.set("exa_api_key", env_key)
print(f"✅ Exa key auto-detected from environment")
# Auto-import cookies on local computers
if env == "local":
print()
print("🍪 Trying to import cookies from browser...")
try:
from agent_reach.cookie_extract import configure_from_browser
results = configure_from_browser("chrome", config)
found = False
for platform, success, message in results:
if success:
print(f"{platform}: {message}")
found = True
if not found:
# Try firefox
results = configure_from_browser("firefox", config)
for platform, success, message in results:
if success:
print(f"{platform}: {message}")
found = True
if not found:
print(" ⬜ No cookies found (normal if you haven't logged into these sites)")
except Exception:
print(" ⬜ Could not read browser cookies (browser might be open)")
# Environment-specific advice
if env == "server":
print()
print("💡 Tip: Reddit and Bilibili block server IPs.")
print(" Reddit search still works via Exa (free).")
print(" For full access: agent-reach configure proxy http://user:pass@ip:port")
print(" Cheap option: https://www.webshare.io ($1/month)")
# Test channels
print()
print("Testing channels...")
results = check_all(config)
ok = sum(1 for r in results.values() if r["status"] == "ok")
total = len(results)
# What's missing — only mention Exa if not configured
if not config.get("exa_api_key"):
print()
print("🔍 Recommended: unlock search with a free Exa API key")
print(" agent-reach configure exa-key YOUR_KEY")
print(" Get free key: https://exa.ai")
# Final status
print()
print(format_report(results))
print()
print(f"✅ Installation complete! {ok}/{total} channels active.")
def _detect_environment():
"""Auto-detect if running on local computer or server."""
import os
# Check common server indicators
indicators = 0
# SSH session
if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT"):
indicators += 2
# Docker / container
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
indicators += 2
# No display (headless)
if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
indicators += 1
# Cloud VM identifiers
for cloud_file in ["/sys/hypervisor/uuid", "/sys/class/dmi/id/product_name"]:
if os.path.exists(cloud_file):
try:
content = open(cloud_file).read().lower()
if any(x in content for x in ["amazon", "google", "microsoft", "digitalocean", "linode", "vultr", "hetzner"]):
indicators += 2
except:
pass
# systemd-detect-virt
try:
import subprocess
result = subprocess.run(["systemd-detect-virt"], capture_output=True, text=True, timeout=3)
if result.returncode == 0 and result.stdout.strip() != "none":
indicators += 1
except:
pass
return "server" if indicators >= 2 else "local"
def _cmd_configure(args):
"""Set a config value and test it, or auto-extract from browser."""
from agent_reach.config import Config
config = Config()
# ── Auto-extract from browser ──
if args.from_browser:
from agent_reach.cookie_extract import configure_from_browser
browser = args.from_browser
print(f"🔍 Extracting cookies from {browser}...")
print()
results = configure_from_browser(browser, config)
found_any = False
for platform, success, message in results:
if success:
print(f"{platform}: {message}")
found_any = True
else:
print(f"{platform}: {message}")
print()
if found_any:
print("✅ Cookies configured! Run `agent-reach doctor` to see updated status.")
else:
print(f"No cookies found. Make sure you're logged into the platforms in {browser}.")
return
# ── Manual configure ──
if not args.key:
print("Usage: agent-reach configure <key> <value>")
print(" or: agent-reach configure --from-browser chrome")
return
value = " ".join(args.value) if args.value else ""
if not value:
print(f"Missing value for {args.key}")
return
if args.key == "proxy":
config.set("reddit_proxy", value)
config.set("bilibili_proxy", value)
print(f"✅ Proxy configured for Reddit + Bilibili!")
# Auto-test
print("Testing Reddit access...", end=" ")
try:
import requests
resp = requests.get(
"https://www.reddit.com/r/test.json?limit=1",
headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"},
proxies={"http": value, "https": value},
timeout=10,
)
if resp.status_code == 200:
print("✅ Reddit works!")
else:
print(f"⚠️ Reddit returned {resp.status_code}")
except Exception as e:
print(f"❌ Failed: {e}")
elif args.key == "exa-key":
config.set("exa_api_key", value)
print(f"✅ Exa key configured!")
print("Testing search...", end=" ")
try:
import asyncio
from agent_reach.core import AgentReach
eyes = AgentReach(config)
results = asyncio.run(eyes.search("test", num_results=1))
if results:
print("✅ Search works!")
else:
print("⚠️ No results, but API connected.")
except Exception as e:
print(f"❌ Failed: {e}")
elif args.key == "twitter-cookies":
# Accept two formats:
# 1. auth_token ct0 (two separate values)
# 2. Full cookie header string: "auth_token=xxx; ct0=yyy; ..."
auth_token = None
ct0 = None
if "auth_token=" in value and "ct0=" in value:
# Full cookie string — parse it
for part in value.replace(";", " ").split():
if part.startswith("auth_token="):
auth_token = part.split("=", 1)[1]
elif part.startswith("ct0="):
ct0 = part.split("=", 1)[1]
elif len(value.split()) == 2 and "=" not in value:
# Two separate values: AUTH_TOKEN CT0
parts = value.split()
auth_token = parts[0]
ct0 = parts[1]
if auth_token and ct0:
config.set("twitter_auth_token", auth_token)
config.set("twitter_ct0", ct0)
print(f"✅ Twitter cookies configured!")
print("Testing Twitter access...", end=" ")
try:
import subprocess
result = subprocess.run(
["birdx", "search", "test", "-n", "1",
"--auth-token", auth_token, "--ct0", ct0],
capture_output=True, text=True, timeout=15,
)
if result.returncode == 0 and result.stdout.strip():
print("✅ Twitter Advanced works!")
else:
print(f"⚠️ Test returned no results (cookies might be wrong)")
except FileNotFoundError:
print("⚠️ birdx not installed. Run: pip install birdx")
except Exception as e:
print(f"❌ Failed: {e}")
else:
print("❌ Could not find auth_token and ct0 in your input.")
print(" Accepted formats:")
print(" 1. agent-reach configure twitter-cookies AUTH_TOKEN CT0")
print(' 2. agent-reach configure twitter-cookies "auth_token=xxx; ct0=yyy; ..."')
elif args.key == "xhs-cookie":
config.set("xhs_cookie", value)
print(f"✅ XiaoHongShu cookie configured!")
print("Testing XHS access...", end=" ")
try:
import requests
resp = requests.get(
"https://www.xiaohongshu.com/",
headers={
"User-Agent": "Mozilla/5.0",
"Cookie": value,
},
timeout=10,
)
if resp.status_code == 200 and "xiaohongshu" in resp.text.lower():
print("✅ XiaoHongShu works!")
else:
print(f"⚠️ Got status {resp.status_code}, cookie might be expired")
except Exception as e:
print(f"❌ Failed: {e}")
elif args.key == "youtube-cookies":
config.set("youtube_cookies_from", value)
print(f"✅ YouTube cookie source configured: {value}")
print(" yt-dlp will use cookies from this browser for age-restricted/member videos.")
elif args.key == "github-token":
config.set("github_token", value)
print(f"✅ GitHub token configured!")
elif args.key == "groq-key":
config.set("groq_api_key", value)
print(f"✅ Groq key configured!")
def _cmd_doctor():
from agent_reach.config import Config
from agent_reach.doctor import check_all, format_report
config = Config()
results = check_all(config)
print(format_report(results))
def _cmd_setup():
from agent_reach.config import Config
config = Config()
print()
print("👁️ Agent Reach 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(" 当前状态: ⬜ 未配置")
key = input(" EXA_API_KEY (回车跳过): ").strip()
if key:
config.set("exa_api_key", key)
print(" ✅ 全网搜索 + Reddit搜索 + Twitter搜索 已开启!")
else:
print(" ℹ️ 跳过。稍后可运行 agent-reach 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-reach doctor 查看完整状态")
print()
async def _cmd_read(args):
from agent_reach.core import AgentReach
eyes = AgentReach()
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_reach.core import AgentReach
eyes = AgentReach()
query = " ".join(args.query).strip()
num = args.num
if not query:
print("Please provide a search query.", file=sys.stderr)
sys.exit(1)
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)
except Exception as e:
error_str = str(e)
if "401" in error_str or "Unauthorized" in error_str:
print("⚠️ Exa API key not configured or invalid.")
print("Get a free key at https://exa.ai (1000 searches/month free)")
print("Then run: agent-reach configure exa-key YOUR_KEY")
sys.exit(1)
elif "exa" in error_str.lower() or "api_key" in error_str.lower():
print("⚠️ Exa API key not configured.")
print("Get a free key at https://exa.ai")
print("Then run: agent-reach configure exa-key YOUR_KEY")
sys.exit(1)
else:
print(f"❌ Error: {e}", 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
extra = r.get("extra", {})
if extra.get("stars"):
print(f"{extra['stars']} 🍴 {extra.get('forks', 0)} 📝 {extra.get('language', '')}")
if __name__ == "__main__":
main()
+96
View File
@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
"""Configuration management for Agent Reach.
Stores settings in ~/.agent-reach/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 Reach configuration."""
CONFIG_DIR = Path.home() / ".agent-reach"
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
+166
View File
@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""Auto-extract cookies from local browsers for all supported platforms.
Supports: Chrome, Firefox, Edge, Brave, Opera
Extracts: Twitter, XiaoHongShu, Bilibili cookies in one shot.
Usage:
agent-reach configure --from-browser chrome
"""
import sys
from typing import Dict, List, Optional, Tuple
# Platform cookie specs: (platform_name, domain_pattern, needed_cookies)
PLATFORM_SPECS = [
{
"name": "Twitter/X",
"domains": [".x.com", ".twitter.com"],
"cookies": ["auth_token", "ct0"],
"config_key": "twitter",
},
{
"name": "XiaoHongShu",
"domains": [".xiaohongshu.com"],
"cookies": None, # None = grab all cookies as header string
"config_key": "xhs",
},
{
"name": "Bilibili",
"domains": [".bilibili.com"],
"cookies": ["SESSDATA", "bili_jct"],
"config_key": "bilibili",
},
]
def extract_all(browser: str = "chrome") -> Dict[str, dict]:
"""
Extract cookies for all supported platforms from the specified browser.
Returns:
{
"twitter": {"auth_token": "xxx", "ct0": "yyy"},
"xhs": {"cookie_string": "a=1; b=2; ..."},
"bilibili": {"SESSDATA": "xxx", "bili_jct": "yyy"},
}
"""
try:
import browser_cookie3
except ImportError:
raise RuntimeError(
"browser_cookie3 not installed. Run: pip install browser-cookie3"
)
# Get browser cookie jar
browser_funcs = {
"chrome": browser_cookie3.chrome,
"firefox": browser_cookie3.firefox,
"edge": browser_cookie3.edge,
"brave": browser_cookie3.brave,
"opera": browser_cookie3.opera,
}
browser = browser.lower()
if browser not in browser_funcs:
raise ValueError(
f"Unsupported browser: {browser}. "
f"Supported: {', '.join(browser_funcs.keys())}"
)
try:
cookie_jar = browser_funcs[browser]()
except Exception as e:
raise RuntimeError(
f"Could not read {browser} cookies: {e}\n"
f"Make sure {browser} is closed and you have permission to read its data."
)
results = {}
for spec in PLATFORM_SPECS:
platform_cookies = {}
all_cookies_for_domain = []
for cookie in cookie_jar:
# Check if cookie belongs to this platform
domain_match = any(
cookie.domain.endswith(d) or cookie.domain == d.lstrip(".")
for d in spec["domains"]
)
if not domain_match:
continue
all_cookies_for_domain.append(cookie)
if spec["cookies"] is not None:
if cookie.name in spec["cookies"]:
platform_cookies[cookie.name] = cookie.value
if spec["cookies"] is None:
# Grab all as header string
if all_cookies_for_domain:
cookie_str = "; ".join(
f"{c.name}={c.value}" for c in all_cookies_for_domain
)
results[spec["config_key"]] = {"cookie_string": cookie_str}
else:
if platform_cookies:
results[spec["config_key"]] = platform_cookies
return results
def configure_from_browser(browser: str, config) -> List[Tuple[str, bool, str]]:
"""
Extract cookies and configure all found platforms.
Returns list of (platform_name, success, message) tuples.
"""
results_list = []
try:
extracted = extract_all(browser)
except Exception as e:
return [("Browser", False, str(e))]
if not extracted:
return [("All platforms", False,
f"No platform cookies found in {browser}. "
f"Make sure you're logged into Twitter, XiaoHongShu, etc. in {browser}.")]
# Configure each found platform
if "twitter" in extracted:
tc = extracted["twitter"]
if "auth_token" in tc and "ct0" in tc:
config.set("twitter_auth_token", tc["auth_token"])
config.set("twitter_ct0", tc["ct0"])
results_list.append(("Twitter/X", True, "auth_token + ct0"))
else:
found = ", ".join(tc.keys())
missing = [k for k in ["auth_token", "ct0"] if k not in tc]
results_list.append(("Twitter/X", False,
f"Found {found}, but missing: {', '.join(missing)}. "
f"Make sure you're logged into x.com in {browser}."))
if "xhs" in extracted:
cookie_str = extracted["xhs"].get("cookie_string", "")
if cookie_str:
config.set("xhs_cookie", cookie_str)
n_cookies = len(cookie_str.split(";"))
results_list.append(("XiaoHongShu", True, f"{n_cookies} cookies"))
if "bilibili" in extracted:
bc = extracted["bilibili"]
if "SESSDATA" in bc:
config.set("bilibili_sessdata", bc["SESSDATA"])
if "bili_jct" in bc:
config.set("bilibili_csrf", bc["bili_jct"])
results_list.append(("Bilibili", True, "SESSDATA" +
(" + bili_jct" if "bili_jct" in bc else "")))
else:
results_list.append(("Bilibili", False,
f"No SESSDATA found. Make sure you're logged into bilibili.com in {browser}."))
return results_list
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""
AgentReach — the unified entry point.
Pure glue: routes URLs to the right channel, routes searches to the right engine.
Every channel is a thin wrapper around an external tool. Swap any backend anytime.
Usage:
from agent_reach import AgentReach
eyes = AgentReach()
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_reach.config import Config
from agent_reach.channels import get_channel_for_url, get_channel, get_all_channels
class AgentReach:
"""Give your AI Agent eyes to see the entire internet."""
def __init__(self, config: Optional[Config] = None):
self.config = config or Config()
# ── 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, RSS, and more.
Returns:
Dict with title, content, url, author, platform, etc.
"""
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
channel = get_channel_for_url(url)
result = await channel.read(url, config=self.config)
return result.to_dict()
async def read_batch(self, urls: List[str]) -> List[Dict[str, Any]]:
"""Read multiple URLs concurrently."""
tasks = [self.read(url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [r for r in results if not isinstance(r, Exception)]
def detect_platform(self, url: str) -> str:
"""Detect what platform a URL belongs to."""
channel = get_channel_for_url(url)
return channel.name
# ── Searching ───────────────────────────────────────
async def search(self, query: str, num_results: int = 5) -> List[Dict[str, Any]]:
"""Semantic web search via Exa."""
ch = get_channel("exa_search")
results = await ch.search(query, config=self.config, limit=num_results)
return [r.to_dict() for r in results]
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)."""
ch = get_channel("exa_search")
q = f"site:reddit.com/r/{subreddit} {query}" if subreddit else f"site:reddit.com {query}"
results = await ch.search(q, config=self.config, limit=limit)
return [r.to_dict() for r in results]
async def search_github(self, query: str, language: Optional[str] = None, limit: int = 5) -> List[Dict[str, Any]]:
"""Search GitHub repositories."""
ch = get_channel("github")
results = await ch.search(query, config=self.config, language=language, limit=limit)
return [r.to_dict() for r in results]
async def search_twitter(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Search Twitter. Uses birdx if available, else Exa."""
ch = get_channel("twitter")
results = await ch.search(query, config=self.config, limit=limit)
return [r.to_dict() for r in results]
# ── Health ──────────────────────────────────────────
def doctor(self) -> Dict[str, dict]:
"""Check all channel availability."""
from agent_reach.doctor import check_all
return check_all(self.config)
def doctor_report(self) -> str:
"""Get formatted health report."""
from agent_reach.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))
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""Environment health checker — powered by channels.
Each channel knows how to check itself. Doctor just collects the results.
"""
from typing import Dict
from agent_reach.config import Config
from agent_reach.channels import get_all_channels
def check_all(config: Config) -> Dict[str, dict]:
"""Check all channels and return status dict."""
results = {}
for ch in get_all_channels():
status, message = ch.check(config)
results[ch.name] = {
"status": status,
"name": ch.description,
"message": message,
"tier": ch.tier,
"backends": ch.backends,
}
return results
def format_report(results: Dict[str, dict]) -> str:
"""Format results as a readable text report."""
lines = []
lines.append("👁️ Agent Reach 状态")
lines.append("=" * 40)
ok_count = sum(1 for r in results.values() if r["status"] == "ok")
total = len(results)
# Tier 0 — zero config
lines.append("")
lines.append("✅ 装好即用:")
for key, r in results.items():
if r["tier"] == 0:
if r["status"] == "ok":
lines.append(f"{r['name']}{r['message']}")
elif r["status"] == "warn":
lines.append(f" ⚠️ {r['name']}{r['message']}")
elif r["status"] in ("off", "error"):
lines.append(f"{r['name']}{r['message']}")
# Tier 1 — needs free key
tier1 = {k: r for k, r in results.items() if r["tier"] == 1}
if tier1:
lines.append("")
lines.append("🔍 搜索(免费 Exa Key 即可解锁):")
for key, r in tier1.items():
if r["status"] == "ok":
lines.append(f"{r['name']}")
else:
lines.append(f"{r['name']} — 注册 exa.ai 获取免费 Key,配置一下就能用")
# Tier 2 — optional setup
tier2 = {k: r for k, r in results.items() if r["tier"] == 2}
if tier2:
lines.append("")
lines.append("🔧 配置后可用:")
for key, r in tier2.items():
if r["status"] == "ok":
lines.append(f"{r['name']}{r['message']}")
elif r["status"] == "warn":
lines.append(f" ⚠️ {r['name']}{r['message']}")
else:
lines.append(f"{r['name']}{r['message']}")
lines.append("")
lines.append(f"状态:{ok_count}/{total} 个渠道可用")
if ok_count < total:
lines.append("运行 `agent-reach setup` 解锁更多渠道")
return "\n".join(lines)
+54
View File
@@ -0,0 +1,54 @@
# Exa Search 配置指南
## 功能说明
Exa 是一个语义搜索引擎,免费提供 1000 次/月搜索。配置后解锁:
- 全网搜索
- Reddit 搜索(通过 site:reddit.com
- Twitter 搜索(通过 site:x.com
## Agent 可自动完成的步骤
1. 检查是否已配置:
```bash
agent-reach doctor | grep "Web Search"
```
2. 如果用户提供了 key,写入配置:
```bash
# 方法1: 通过 setup 命令
agent-reach setup
# 方法2: 直接写入配置文件
python3 -c "
from agent_reach.config import Config
c = Config()
c.set('exa_api_key', '用户提供的KEY')
print('✅ Exa API key saved')
"
```
3. 测试是否有效:
```bash
agent-reach 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-reach doctor | grep -i "groq\|whisper"
```
2. 如果用户提供了 key,写入配置:
```python
from agent_reach.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-reach 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_reach.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-reach 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-reach/xhs_cookies.json 2>/dev/null
```
## 需要用户手动做的步骤
请告诉用户:
> 小红书需要登录一次(之后会记住你的登录状态)。
>
> 我现在会打开一个浏览器窗口,显示小红书登录页面。你需要:
> 1. 用手机小红书 App 扫描屏幕上的二维码
> 2. 在手机上确认登录
> 3. 看到首页后告诉我"登录好了"
>
> 之后就不需要再登录了(除非 cookie 过期,大约 1-3 个月)。
## Agent 收到确认后的操作
1. 保存浏览器 cookie 到 `~/.agent-reach/xhs_cookies.json`
2. 测试:读取一条小红书笔记
3. 反馈:"✅ 小红书已配置!现在我可以读取小红书笔记了。"
+1
View File
@@ -0,0 +1 @@
# -*- coding: utf-8 -*-
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""
Agent Reach MCP Server — expose all capabilities as MCP tools.
Run: python -m agent_reach.integrations.mcp_server
8 tools for any MCP-compatible AI Agent.
"""
import asyncio
import json
import sys
from agent_reach.config import Config
from agent_reach.core import AgentReach
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():
if not HAS_MCP:
print("MCP not installed. Install: pip install agent-reach[mcp]", file=sys.stderr)
sys.exit(1)
server = Server("agent-reach")
config = Config()
eyes = AgentReach(config)
@server.list_tools()
async def list_tools():
return [
Tool(name="read_url",
description="Read content from any URL. Supports: web, GitHub, Reddit, Twitter, YouTube, Bilibili, RSS.",
inputSchema={"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}),
Tool(name="read_batch",
description="Read multiple URLs concurrently.",
inputSchema={"type": "object", "properties": {"urls": {"type": "array", "items": {"type": "string"}}}, "required": ["urls"]}),
Tool(name="detect_platform",
description="Detect what platform a URL belongs to.",
inputSchema={"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}),
Tool(name="search",
description="Semantic web search via Exa.",
inputSchema={"type": "object", "properties": {"query": {"type": "string"}, "num_results": {"type": "integer", "default": 5}}, "required": ["query"]}),
Tool(name="search_reddit",
description="Search Reddit posts.",
inputSchema={"type": "object", "properties": {"query": {"type": "string"}, "subreddit": {"type": "string"}, "limit": {"type": "integer", "default": 10}}, "required": ["query"]}),
Tool(name="search_github",
description="Search GitHub repositories.",
inputSchema={"type": "object", "properties": {"query": {"type": "string"}, "language": {"type": "string"}, "limit": {"type": "integer", "default": 5}}, "required": ["query"]}),
Tool(name="search_twitter",
description="Search Twitter/X posts.",
inputSchema={"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer", "default": 10}}, "required": ["query"]}),
Tool(name="get_status",
description="Get Agent Reach status: which channels are active.",
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"])
elif name == "read_batch":
result = await eyes.read_batch(arguments["urls"])
elif name == "detect_platform":
result = eyes.detect_platform(arguments["url"])
elif name == "search":
result = await eyes.search(arguments["query"], arguments.get("num_results", 5))
elif name == "search_reddit":
result = await eyes.search_reddit(arguments["query"], arguments.get("subreddit"), arguments.get("limit", 10))
elif name == "search_github":
result = await eyes.search_github(arguments["query"], arguments.get("language"), arguments.get("limit", 5))
elif name == "search_twitter":
result = await eyes.search_twitter(arguments["query"], arguments.get("limit", 10))
elif name == "get_status":
result = eyes.doctor_report()
else:
result = f"Unknown tool: {name}"
text = json.dumps(result, ensure_ascii=False, indent=2) if isinstance(result, (dict, list)) else str(result)
return [TextContent(type="text", text=text)]
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-reach
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-reach.git
---
# Agent Reach — 给 Agent 装上眼睛 👁️
安装后你就能搜索和阅读全网内容。
## 安装
```bash
pip install git+https://github.com/Panniantong/agent-reach.git
```
## 装完直接能用的(零配置)
- `agent-reach read <URL>` — 读取任意网页、GitHub、B站、YouTube、RSS
- `agent-reach search-github "query"` — 搜索 GitHub 仓库
- `agent-reach doctor` — 查看所有平台状态
## 解锁更多功能
运行 `agent-reach setup` 进行交互式配置,或者读取以下指南:
| 功能 | 指南文件 |
|------|---------|
| 全网搜索 + Reddit搜索 + Twitter搜索 | `agent_reach/guides/setup-exa.md` |
| Reddit 完整阅读 | `agent_reach/guides/setup-reddit.md` |
| Twitter 高级(时间线/线程) | `agent_reach/guides/setup-twitter.md` |
| 小红书 | `agent_reach/guides/setup-xiaohongshu.md` |
| 微信公众号 | `agent_reach/guides/setup-wechat.md` |
| 视频语音转文字 | `agent_reach/guides/setup-groq.md` |
每个指南里有详细的步骤说明,你(Agent)可以照着做,只有需要人类操作的部分(登录、复制 key)才需要问用户。
## MCP Server
如果你的 Agent 平台支持 MCP:
```bash
pip install agent-reach[mcp]
python -m agent_reach.integrations.mcp_server
```
提供 8 个工具:read_url, read_batch, detect_platform, search, search_reddit, search_github, search_twitter, get_status
## Python API
```python
from agent_reach import AgentReach
import asyncio
eyes = AgentReach()
# 读取
result = asyncio.run(eyes.read("https://github.com/openai/gpt-4"))
# 搜索
results = asyncio.run(eyes.search("AI agent framework"))
# 健康检查
print(eyes.doctor_report())
```