Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca29c4fee5 | |||
| 49d05b7023 | |||
| 476935be92 | |||
| 4f4ad99d49 | |||
| 561b248a2b | |||
| a91a74d880 | |||
| 7434c3cb9f | |||
| 1a61a2cc81 | |||
| 932837ff9a | |||
| 4f52942260 | |||
| 3c1a04a587 | |||
| 6a28b215af | |||
| a881db4261 | |||
| 875f0987a6 | |||
| 0d1a33236e | |||
| e620a1db8f | |||
| 15a2961dfa | |||
| 4bc860c74d | |||
| 62aacf38b5 | |||
| d4e437bf2c | |||
| dbcc668f47 | |||
| 40f833d0d1 | |||
| dc4d7cd7a5 | |||
| 60f5ddf4f0 | |||
| a726aa7fe1 | |||
| 4b2e6f2ffb | |||
| 39f76ee4dc | |||
| a5682716ec | |||
| f269667a34 | |||
| 090b01c909 | |||
| 58b3a4dcb3 | |||
| 9c6c04c800 |
@@ -0,0 +1,30 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install package and test deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -c constraints.txt -e .[dev]
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest -q
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# Contributing to Agent Reach
|
||||
|
||||
Thank you for your interest in contributing to Agent Reach! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork locally
|
||||
3. Create a new branch for your contribution
|
||||
4. Make your changes
|
||||
5. Run tests and linting
|
||||
6. Submit a pull request
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
# Clone your fork
|
||||
git clone https://github.com/YOUR_USERNAME/Agent-Reach.git
|
||||
cd Agent-Reach
|
||||
|
||||
# Install in development mode
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Install pre-commit hooks (optional but recommended)
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
We use the following tools to maintain code quality:
|
||||
|
||||
- **ruff**: Linting and import sorting
|
||||
- **mypy**: Type checking
|
||||
- **pytest**: Testing
|
||||
|
||||
Run all checks before submitting a PR:
|
||||
|
||||
```bash
|
||||
# Linting
|
||||
ruff check agent_reach tests
|
||||
ruff format agent_reach tests
|
||||
|
||||
# Type checking
|
||||
mypy agent_reach
|
||||
|
||||
# Tests
|
||||
pytest
|
||||
```
|
||||
|
||||
## Adding New Channels
|
||||
|
||||
Agent Reach uses a unified channel interface. To add a new platform:
|
||||
|
||||
1. Create a new file in `agent_reach/channels/`
|
||||
2. Implement the channel contract (see existing channels for examples)
|
||||
3. Add tests in `tests/test_channels.py`
|
||||
4. Update `agent_reach/doctor.py` to include the new channel
|
||||
5. Update documentation
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- **Small, focused changes** are preferred over large refactors
|
||||
- Include tests for new functionality
|
||||
- Update documentation if needed
|
||||
- Follow existing code style
|
||||
- Reference any related issues
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting bugs, please include:
|
||||
|
||||
- Python version
|
||||
- Operating system
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Any error messages
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to open an issue for questions or join discussions.
|
||||
|
||||
---
|
||||
|
||||
感谢您对 Agent Reach 的贡献!本文档提供了贡献指南。
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 在 GitHub 上 fork 仓库
|
||||
2. 本地 clone 您的 fork
|
||||
3. 创建新分支
|
||||
4. 提交更改
|
||||
5. 运行测试和 lint
|
||||
6. 提交 pull request
|
||||
|
||||
## 代码规范
|
||||
|
||||
- 使用 **ruff** 进行代码检查
|
||||
- 使用 **mypy** 进行类型检查
|
||||
- 使用 **pytest** 运行测试
|
||||
|
||||
## 添加新渠道
|
||||
|
||||
1. 在 `agent_reach/channels/` 创建新文件
|
||||
2. 实现渠道接口
|
||||
3. 添加测试
|
||||
4. 更新 doctor 检测
|
||||
5. 更新文档
|
||||
@@ -50,7 +50,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
||||
|---|---|
|
||||
| 💰 **完全免费** | 所有工具开源、所有 API 免费。唯一可能花钱的是服务器代理($1/月),本地电脑不需要 |
|
||||
| 🔒 **隐私安全** | Cookie 只存在你本地,不上传不外传。代码完全开源,随时可审查 |
|
||||
| 🔄 **持续更新** | 底层工具(yt-dlp、bird、Jina Reader 等)定期追踪更新到最新版,你不用自己盯 |
|
||||
| 🔄 **持续更新** | 底层工具(yt-dlp、xreach、Jina Reader 等)定期追踪更新到最新版,你不用自己盯 |
|
||||
| 🤖 **兼容所有 Agent** | Claude Code、OpenClaw、Cursor、Windsurf……任何能跑命令行的 Agent 都能用 |
|
||||
| 🩺 **自带诊断** | `agent-reach doctor` 一条命令告诉你哪个通、哪个不通、怎么修 |
|
||||
|
||||
@@ -72,6 +72,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
||||
| 🎵 **抖音** | — | 视频解析、无水印下载链接获取 | 告诉 Agent「帮我配抖音」 |
|
||||
| 💼 **LinkedIn** | Jina Reader 读公开页面 | Profile 详情、公司页面、职位搜索 | 告诉 Agent「帮我配 LinkedIn」 |
|
||||
| 🏢 **Boss直聘** | Jina Reader 读职位页 | 搜索职位、向 HR 打招呼 | 告诉 Agent「帮我配 Boss直聘」 |
|
||||
| 💬 **微信公众号** | — | 搜索 + 阅读公众号文章(全文 Markdown) | `pip install miku_ai camoufox[geoip]` |
|
||||
|
||||
> **不知道怎么配?不用查文档。** 直接告诉 Agent「帮我配 XXX」,它知道需要什么、会一步一步引导你。
|
||||
>
|
||||
@@ -102,7 +103,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
||||
<summary>它会做什么?(点击展开)</summary>
|
||||
|
||||
1. **安装 CLI 工具** — `pip install` 装好 `agent-reach` 命令行
|
||||
2. **安装系统依赖** — 自动检测并安装 Node.js、gh CLI、mcporter、bird 等
|
||||
2. **安装系统依赖** — 自动检测并安装 Node.js、gh CLI、mcporter、xreach 等
|
||||
3. **配置搜索引擎** — 通过 MCP 接入 Exa(免费,无需 API Key)
|
||||
4. **检测环境** — 判断是本地电脑还是服务器,给出对应的配置建议
|
||||
5. **注册 SKILL.md** — 在 Agent 的 skills 目录安装使用指南,以后 Agent 遇到"搜推特"、"看视频"这类需求,会自动知道该调哪个上游工具
|
||||
@@ -119,7 +120,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
||||
- "帮我看看这个链接" → `curl https://r.jina.ai/URL` 读任意网页
|
||||
- "这个 GitHub 仓库是做什么的" → `gh repo view owner/repo`
|
||||
- "这个视频讲了什么" → `yt-dlp --dump-json URL` 提取字幕
|
||||
- "帮我看看这条推文" → `bird read URL --json`
|
||||
- "帮我看看这条推文" → `xreach tweet URL --json`
|
||||
- "订阅这个 RSS" → `feedparser` 解析
|
||||
- "搜一下 GitHub 上有什么 LLM 框架" → `gh search repos "LLM framework"`
|
||||
|
||||
@@ -135,7 +136,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
||||
|
||||
Agent Reach 做的事情很简单:**帮你把这些选型和配置的活儿做完了。**
|
||||
|
||||
安装完成后,Agent 直接调用上游工具(bird CLI、yt-dlp、mcporter、gh CLI 等),不需要经过 Agent Reach 的包装层。
|
||||
安装完成后,Agent 直接调用上游工具(xreach CLI、yt-dlp、mcporter、gh CLI 等),不需要经过 Agent Reach 的包装层。
|
||||
|
||||
### 🔌 每个渠道都是可插拔的
|
||||
|
||||
@@ -144,7 +145,7 @@ Agent Reach 做的事情很简单:**帮你把这些选型和配置的活儿做
|
||||
```
|
||||
channels/
|
||||
├── web.py → Jina Reader ← 可以换成 Firecrawl、Crawl4AI……
|
||||
├── twitter.py → bird ← 可以换成 Nitter、官方 API……
|
||||
├── twitter.py → xreach ← 可以换成 Nitter、官方 API……
|
||||
├── youtube.py → yt-dlp ← 可以换成 YouTube API、Whisper……
|
||||
├── github.py → gh CLI ← 可以换成 REST API、PyGithub……
|
||||
├── bilibili.py → yt-dlp ← 可以换成 bilibili-api……
|
||||
@@ -153,6 +154,7 @@ channels/
|
||||
├── douyin.py → mcporter MCP ← 可以换成其他抖音工具……
|
||||
├── linkedin.py → linkedin-mcp ← 可以换成 LinkedIn API……
|
||||
├── bosszhipin.py → mcp-bosszp ← 可以换成其他招聘工具……
|
||||
├── wechat.py → camoufox+miku ← 搜索+阅读微信公众号文章
|
||||
├── rss.py → feedparser ← 可以换成 atoma……
|
||||
├── exa_search.py → mcporter MCP ← 可以换成 Tavily、SerpAPI……
|
||||
└── __init__.py → 渠道注册(doctor 检测用)
|
||||
@@ -165,7 +167,7 @@ channels/
|
||||
| 场景 | 选型 | 为什么选它 |
|
||||
|------|------|-----------|
|
||||
| 读网页 | [Jina Reader](https://github.com/jina-ai/reader) | 9.8K Star,免费,不需要 API Key |
|
||||
| 读推特 | [bird](https://www.npmjs.com/package/@steipete/bird) | Cookie 登录,免费。官方 API 按量付费(读一条 $0.005) |
|
||||
| 读推特 | [xreach](https://www.npmjs.com/package/xreach-cli) | Cookie 登录,免费。官方 API 按量付费(读一条 $0.005) |
|
||||
| 视频字幕 + 搜索 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 148K Star,YouTube + B站 + 1800 站通吃 |
|
||||
| 搜全网 | [Exa](https://exa.ai) via [mcporter](https://github.com/steipete/mcporter) | AI 语义搜索,MCP 接入免 Key |
|
||||
| GitHub | [gh CLI](https://cli.github.com) | 官方工具,认证后完整 API 能力 |
|
||||
@@ -174,6 +176,7 @@ channels/
|
||||
| 抖音 | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP 服务,无需登录,视频解析 + 无水印下载 |
|
||||
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+,MCP 服务,浏览器自动化 |
|
||||
| Boss直聘 | [mcp-bosszp](https://github.com/mucsbr/mcp-bosszp) | MCP 服务,支持职位搜索和打招呼 |
|
||||
| 微信公众号 | [wechat-article-for-ai](https://github.com/bzd6661/wechat-article-for-ai) + [miku_ai](https://github.com/GobinFan/Miku_Spider) | Camoufox 隐身浏览器读全文 + 搜狗搜索 |
|
||||
|
||||
> 📌 这些都是「当前选型」。不满意?换掉对应文件就行。这正是脚手架的意义。
|
||||
|
||||
@@ -193,7 +196,11 @@ Agent Reach 在设计上重视安全:
|
||||
|
||||
### 🍪 Cookie 安全建议
|
||||
|
||||
需要 Cookie 的平台(Twitter、小红书)建议使用**专用小号**,不要用主账号。Cookie 等同于完整登录权限,用小号可以在凭据泄露时限制影响范围。
|
||||
> ⚠️ **封号风险提醒:** 使用 Cookie 登录的平台(Twitter、小红书等),通过脚本/API 调用**存在被平台检测并封号的风险**。请务必使用**专用小号**,不要用你的主账号。
|
||||
|
||||
需要 Cookie 的平台(Twitter、小红书)建议使用**专用小号**,不要用主账号。原因有二:
|
||||
1. **封号风险** — 平台可能检测到非正常浏览器的 API 调用行为,导致账号被限制或封禁
|
||||
2. **安全风险** — Cookie 等同于完整登录权限,用小号可以在凭据泄露时限制影响范围
|
||||
|
||||
### 📦 安装方式
|
||||
|
||||
@@ -203,6 +210,24 @@ Agent Reach 在设计上重视安全:
|
||||
| 安全模式 | `agent-reach install --env=auto --safe` | 生产服务器、多人共用机器 |
|
||||
| 仅预览 | `agent-reach install --env=auto --dry-run` | 先看看会做什么 |
|
||||
|
||||
### 🗑️ 卸载
|
||||
|
||||
```bash
|
||||
agent-reach uninstall
|
||||
```
|
||||
|
||||
会清除:`~/.agent-reach/`(含所有 token/cookie)、各 Agent 的 skill 文件、mcporter 中的 MCP 配置。
|
||||
|
||||
```bash
|
||||
# 只预览,不实际删除
|
||||
agent-reach uninstall --dry-run
|
||||
|
||||
# 只删 skill 文件,保留 token 配置(重装时用)
|
||||
agent-reach uninstall --keep-config
|
||||
```
|
||||
|
||||
卸载 Python 包本身:`pip uninstall agent-reach`
|
||||
|
||||
---
|
||||
|
||||
## 贡献
|
||||
@@ -236,13 +261,13 @@ Star 一下,下次需要的时候能找到。⭐
|
||||
<details>
|
||||
<summary><strong>AI Agent 怎么搜索 Twitter / X?不想付 API 费用</strong></summary>
|
||||
|
||||
Agent Reach 使用 [bird CLI](https://www.npmjs.com/package/@steipete/bird) 通过 Cookie 认证访问 Twitter,完全免费。安装 Agent Reach 后,用 Cookie-Editor 导出你的 Twitter Cookie,运行 `agent-reach configure twitter-cookies "your_cookies"` 即可。之后 Agent 就可以用 `bird search "关键词" --json` 搜索推文了。
|
||||
Agent Reach 使用 [xreach CLI](https://www.npmjs.com/package/xreach-cli) 通过 Cookie 认证访问 Twitter,完全免费。安装 Agent Reach 后,用 Cookie-Editor 导出你的 Twitter Cookie,运行 `agent-reach configure twitter-cookies "your_cookies"` 即可。之后 Agent 就可以用 `xreach search "关键词" --json` 搜索推文了。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How to search Twitter/X with AI agent for free (no API)?</strong></summary>
|
||||
|
||||
Agent Reach uses the bird CLI with cookie auth — zero API fees. After installing, export your Twitter cookies with the Cookie-Editor extension, run `agent-reach configure twitter-cookies "your_cookies"`, then your agent can search with `bird search "query" --json`.
|
||||
Agent Reach uses the xreach CLI with cookie auth — zero API fees. After installing, export your Twitter cookies with the Cookie-Editor extension, run `agent-reach configure twitter-cookies "your_cookies"`, then your agent can search with `xreach search "query" --json`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -278,14 +303,14 @@ Yes! Agent Reach is an installer + configuration tool — any AI coding agent th
|
||||
<details>
|
||||
<summary><strong>Is this free? Any API costs?</strong></summary>
|
||||
|
||||
100% free. All backends are open-source tools (bird CLI, yt-dlp, Jina Reader, Exa, etc.) that don't require paid API keys. The only optional cost is a residential proxy (~$1/month) if you need Reddit/Bilibili access from a server.
|
||||
100% free. All backends are open-source tools (xreach CLI, yt-dlp, Jina Reader, Exa, etc.) that don't require paid API keys. The only optional cost is a residential proxy (~$1/month) if you need Reddit/Bilibili access from a server.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
[Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [bird](https://www.npmjs.com/package/@steipete/bird) · [Exa](https://exa.ai) · [mcporter](https://github.com/steipete/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) · [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) · [mcp-bosszp](https://github.com/mucsbr/mcp-bosszp)
|
||||
[Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [xreach](https://www.npmjs.com/package/xreach-cli) · [Exa](https://exa.ai) · [mcporter](https://github.com/steipete/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) · [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) · [mcp-bosszp](https://github.com/mucsbr/mcp-bosszp)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Agent Reach — Give your AI Agent eyes to see the entire internet."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.3.0"
|
||||
__author__ = "Neo Reid"
|
||||
|
||||
from agent_reach.core import AgentReach
|
||||
|
||||
@@ -19,6 +19,7 @@ from .xiaohongshu import XiaoHongShuChannel
|
||||
from .douyin import DouyinChannel
|
||||
from .linkedin import LinkedInChannel
|
||||
from .bosszhipin import BossZhipinChannel
|
||||
from .wechat import WeChatChannel
|
||||
|
||||
|
||||
# Channel registry
|
||||
@@ -32,6 +33,7 @@ ALL_CHANNELS: List[Channel] = [
|
||||
DouyinChannel(),
|
||||
LinkedInChannel(),
|
||||
BossZhipinChannel(),
|
||||
WeChatChannel(),
|
||||
RSSChannel(),
|
||||
ExaSearchChannel(),
|
||||
WebChannel(),
|
||||
|
||||
@@ -18,7 +18,8 @@ class BossZhipinChannel(Channel):
|
||||
return "zhipin.com" in domain or "boss.com" in domain
|
||||
|
||||
def check(self, config=None):
|
||||
if not shutil.which("mcporter"):
|
||||
mcporter = shutil.which("mcporter")
|
||||
if not mcporter:
|
||||
return "off", (
|
||||
"可通过 Jina Reader 读取职位页面。完整功能需要:\n"
|
||||
" 1. git clone https://github.com/mucsbr/mcp-bosszp.git\n"
|
||||
@@ -28,7 +29,8 @@ class BossZhipinChannel(Channel):
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, text=True, timeout=10
|
||||
[mcporter, "list"], capture_output=True,
|
||||
encoding="utf-8", errors="replace", timeout=10
|
||||
)
|
||||
out = r.stdout.lower()
|
||||
if "boss" in out or "zhipin" in out:
|
||||
|
||||
@@ -18,7 +18,8 @@ class DouyinChannel(Channel):
|
||||
return "douyin.com" in d or "iesdouyin.com" in d
|
||||
|
||||
def check(self, config=None):
|
||||
if not shutil.which("mcporter"):
|
||||
mcporter = shutil.which("mcporter")
|
||||
if not mcporter:
|
||||
return "off", (
|
||||
"需要 mcporter + douyin-mcp-server。安装步骤:\n"
|
||||
" 1. npm install -g mcporter\n"
|
||||
@@ -29,7 +30,8 @@ class DouyinChannel(Channel):
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, text=True, timeout=10
|
||||
[mcporter, "config", "list"], capture_output=True,
|
||||
encoding="utf-8", errors="replace", timeout=5
|
||||
)
|
||||
if "douyin" not in r.stdout:
|
||||
return "off", (
|
||||
@@ -42,8 +44,8 @@ class DouyinChannel(Channel):
|
||||
return "off", "mcporter 连接异常"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "call", "douyin.parse_douyin_video_info(share_link: \"https://www.douyin.com\")"],
|
||||
capture_output=True, text=True, timeout=15
|
||||
[mcporter, "call", "douyin.parse_douyin_video_info(share_link: \"https://www.douyin.com\")"],
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=15
|
||||
)
|
||||
if r.returncode == 0:
|
||||
return "ok", "完整可用(视频解析、下载链接获取)"
|
||||
|
||||
@@ -16,7 +16,8 @@ class ExaSearchChannel(Channel):
|
||||
return False # Search-only channel
|
||||
|
||||
def check(self, config=None):
|
||||
if not shutil.which("mcporter"):
|
||||
mcporter = shutil.which("mcporter")
|
||||
if not mcporter:
|
||||
return "off", (
|
||||
"需要 mcporter + Exa MCP。安装:\n"
|
||||
" npm install -g mcporter\n"
|
||||
@@ -24,7 +25,8 @@ class ExaSearchChannel(Channel):
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, text=True, timeout=10
|
||||
[mcporter, "config", "list"], capture_output=True,
|
||||
encoding="utf-8", errors="replace", timeout=5
|
||||
)
|
||||
if "exa" in r.stdout.lower():
|
||||
return "ok", "全网语义搜索可用(免费,无需 API Key)"
|
||||
|
||||
@@ -17,13 +17,16 @@ class GitHubChannel(Channel):
|
||||
return "github.com" in urlparse(url).netloc.lower()
|
||||
|
||||
def check(self, config=None):
|
||||
if not shutil.which("gh"):
|
||||
gh = shutil.which("gh")
|
||||
if not gh:
|
||||
return "warn", "gh CLI 未安装。安装:https://cli.github.com"
|
||||
try:
|
||||
subprocess.run(
|
||||
["gh", "auth", "status"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
r = subprocess.run(
|
||||
[gh, "auth", "status"],
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=5
|
||||
)
|
||||
return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)"
|
||||
if r.returncode == 0:
|
||||
return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)"
|
||||
return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能"
|
||||
except Exception:
|
||||
return "ok", "gh CLI 已装但未认证。运行 gh auth login 可解锁完整功能"
|
||||
return "warn", "gh CLI 状态检查失败,运行 gh auth status 查看详情"
|
||||
|
||||
@@ -17,7 +17,8 @@ class LinkedInChannel(Channel):
|
||||
return "linkedin.com" in urlparse(url).netloc.lower()
|
||||
|
||||
def check(self, config=None):
|
||||
if not shutil.which("mcporter"):
|
||||
mcporter = shutil.which("mcporter")
|
||||
if not mcporter:
|
||||
return "off", (
|
||||
"基本内容可通过 Jina Reader 读取。完整功能需要:\n"
|
||||
" pip install linkedin-scraper-mcp\n"
|
||||
@@ -26,7 +27,8 @@ class LinkedInChannel(Channel):
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, text=True, timeout=10
|
||||
[mcporter, "config", "list"], capture_output=True,
|
||||
encoding="utf-8", errors="replace", timeout=5
|
||||
)
|
||||
if "linkedin" in r.stdout.lower():
|
||||
return "ok", "完整可用(Profile、公司、职位搜索)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Twitter/X — check if bird CLI is available."""
|
||||
"""Twitter/X — check if xreach CLI is available."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -9,7 +9,7 @@ from .base import Channel
|
||||
class TwitterChannel(Channel):
|
||||
name = "twitter"
|
||||
description = "Twitter/X 推文"
|
||||
backends = ["bird CLI"]
|
||||
backends = ["xreach CLI"]
|
||||
tier = 1
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
@@ -18,21 +18,22 @@ class TwitterChannel(Channel):
|
||||
return "x.com" in d or "twitter.com" in d
|
||||
|
||||
def check(self, config=None):
|
||||
bird = shutil.which("bird") or shutil.which("birdx")
|
||||
if not bird:
|
||||
xreach = shutil.which("xreach")
|
||||
if not xreach:
|
||||
return "warn", (
|
||||
"bird CLI 未安装。搜索可通过 Exa 替代。安装:\n"
|
||||
" npm install -g @steipete/bird"
|
||||
"xreach CLI 未安装。搜索可通过 Exa 替代。安装:\n"
|
||||
" npm install -g xreach-cli"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[bird, "whoami"], capture_output=True, text=True, timeout=10
|
||||
[xreach, "auth", "check"], capture_output=True,
|
||||
encoding="utf-8", errors="replace", timeout=10
|
||||
)
|
||||
if r.returncode == 0:
|
||||
return "ok", "完整可用(读取、搜索推文)"
|
||||
return "warn", (
|
||||
"bird CLI 已安装但未配置 Cookie。运行:\n"
|
||||
"xreach CLI 已安装但未配置 Cookie。运行:\n"
|
||||
" agent-reach configure twitter-cookies \"auth_token=xxx; ct0=yyy\""
|
||||
)
|
||||
except Exception:
|
||||
return "warn", "bird CLI 已安装但连接失败"
|
||||
return "warn", "xreach CLI 已安装但连接失败"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""WeChat Official Account articles — read and search.
|
||||
|
||||
Read: wechat-article-for-ai (Camoufox stealth browser)
|
||||
Search: miku_ai (Sogou WeChat search)
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from .base import Channel
|
||||
|
||||
|
||||
class WeChatChannel(Channel):
|
||||
name = "wechat"
|
||||
description = "微信公众号文章"
|
||||
backends = ["wechat-article-for-ai (Camoufox)", "miku_ai (搜狗搜索)"]
|
||||
tier = 2
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
from urllib.parse import urlparse
|
||||
d = urlparse(url).netloc.lower()
|
||||
return "mp.weixin.qq.com" in d or "weixin.qq.com" in d
|
||||
|
||||
def check(self, config=None):
|
||||
has_read = False
|
||||
has_search = False
|
||||
|
||||
try:
|
||||
import camoufox # noqa: F401
|
||||
has_read = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import miku_ai # noqa: F401
|
||||
has_search = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if has_read and has_search:
|
||||
return "ok", "完整可用(搜索 + 阅读公众号文章)"
|
||||
elif has_read:
|
||||
return "ok", "可阅读公众号文章(URL → Markdown)。安装 miku_ai 可解锁搜索:pip install miku_ai"
|
||||
elif has_search:
|
||||
return "warn", (
|
||||
"可搜索公众号文章但无法阅读全文。安装阅读工具:\n"
|
||||
" pip install camoufox[geoip] markdownify beautifulsoup4 httpx mcp"
|
||||
)
|
||||
else:
|
||||
return "off", (
|
||||
"需要安装微信公众号工具:\n"
|
||||
" # 阅读(URL → Markdown):\n"
|
||||
" pip install camoufox[geoip] markdownify beautifulsoup4 httpx mcp\n"
|
||||
" # 搜索(关键词 → 文章列表):\n"
|
||||
" pip install miku_ai\n"
|
||||
" 详见 https://github.com/bzd6661/wechat-article-for-ai"
|
||||
)
|
||||
@@ -1,11 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""XiaoHongShu — check if mcporter + xiaohongshu MCP is available."""
|
||||
"""XiaoHongShu -- check if mcporter + xiaohongshu MCP is available."""
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from .base import Channel
|
||||
|
||||
|
||||
def _is_arm64() -> bool:
|
||||
"""Detect ARM64 architecture (e.g. Apple Silicon)."""
|
||||
machine = platform.machine().lower()
|
||||
return machine in ("arm64", "aarch64")
|
||||
|
||||
|
||||
def _docker_run_hint() -> str:
|
||||
"""Return the docker run command, with --platform flag for ARM64."""
|
||||
if _is_arm64():
|
||||
return (
|
||||
" docker run -d --name xiaohongshu-mcp -p 18060:18060 "
|
||||
"--platform linux/amd64 xpzouying/xiaohongshu-mcp\n"
|
||||
" # ARM64 also: build from source: "
|
||||
"https://github.com/xpzouying/xiaohongshu-mcp"
|
||||
)
|
||||
return (
|
||||
" docker run -d --name xiaohongshu-mcp -p 18060:18060 "
|
||||
"xpzouying/xiaohongshu-mcp"
|
||||
)
|
||||
|
||||
|
||||
class XiaoHongShuChannel(Channel):
|
||||
name = "xiaohongshu"
|
||||
description = "小红书笔记"
|
||||
@@ -18,30 +40,32 @@ class XiaoHongShuChannel(Channel):
|
||||
return "xiaohongshu.com" in d or "xhslink.com" in d
|
||||
|
||||
def check(self, config=None):
|
||||
if not shutil.which("mcporter"):
|
||||
mcporter = shutil.which("mcporter")
|
||||
if not mcporter:
|
||||
return "off", (
|
||||
"需要 mcporter + xiaohongshu-mcp。安装步骤:\n"
|
||||
" 1. npm install -g mcporter\n"
|
||||
" 2. docker run -d --name xiaohongshu-mcp -p 18060:18060 xpzouying/xiaohongshu-mcp\n"
|
||||
" 2. " + _docker_run_hint().strip() + "\n"
|
||||
" 3. mcporter config add xiaohongshu http://localhost:18060/mcp\n"
|
||||
" 详见 https://github.com/xpzouying/xiaohongshu-mcp"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, text=True, timeout=10
|
||||
[mcporter, "config", "list"], capture_output=True,
|
||||
encoding="utf-8", errors="replace", timeout=5
|
||||
)
|
||||
if "xiaohongshu" not in r.stdout:
|
||||
return "off", (
|
||||
"mcporter 已装但小红书 MCP 未配置。运行:\n"
|
||||
" docker run -d --name xiaohongshu-mcp -p 18060:18060 xpzouying/xiaohongshu-mcp\n"
|
||||
+ _docker_run_hint() + "\n"
|
||||
" mcporter config add xiaohongshu http://localhost:18060/mcp"
|
||||
)
|
||||
except Exception:
|
||||
return "off", "mcporter 连接异常"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "call", "xiaohongshu.check_login_status()"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
[mcporter, "call", "xiaohongshu.check_login_status()"],
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=10
|
||||
)
|
||||
if "已登录" in r.stdout or "logged" in r.stdout.lower():
|
||||
return "ok", "完整可用(阅读、搜索、发帖、评论、点赞)"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""YouTube — check if yt-dlp is available."""
|
||||
"""YouTube — check if yt-dlp is available with JS runtime."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from .base import Channel
|
||||
|
||||
@@ -17,6 +18,23 @@ class YouTubeChannel(Channel):
|
||||
return "youtube.com" in d or "youtu.be" in d
|
||||
|
||||
def check(self, config=None):
|
||||
if shutil.which("yt-dlp"):
|
||||
return "ok", "可提取视频信息和字幕"
|
||||
return "off", "yt-dlp 未安装。安装:pip install yt-dlp"
|
||||
if not shutil.which("yt-dlp"):
|
||||
return "off", "yt-dlp 未安装。安装:pip install yt-dlp"
|
||||
# Check JS runtime
|
||||
has_js = shutil.which("deno") or shutil.which("node")
|
||||
if not has_js:
|
||||
return "warn", (
|
||||
"yt-dlp 已安装但缺少 JS runtime(YouTube 必须)。\n"
|
||||
" 安装 Node.js 或 deno,然后运行:agent-reach install"
|
||||
)
|
||||
# Check yt-dlp config for --js-runtimes
|
||||
ytdlp_config = os.path.expanduser("~/.config/yt-dlp/config")
|
||||
has_deno = shutil.which("deno")
|
||||
if not has_deno and os.path.exists(ytdlp_config):
|
||||
with open(ytdlp_config, "r") as f:
|
||||
if "--js-runtimes" not in f.read():
|
||||
return "warn", (
|
||||
"yt-dlp 已安装但未配置 JS runtime。运行:\n"
|
||||
" mkdir -p ~/.config/yt-dlp && echo '--js-runtimes node' >> ~/.config/yt-dlp/config"
|
||||
)
|
||||
return "ok", "可提取视频信息和字幕"
|
||||
|
||||
+424
-146
@@ -13,18 +13,31 @@ import sys
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
# Fix Windows console encoding — emoji/CJK characters crash on cp936/cp1252
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
if hasattr(sys.stdout, 'buffer'):
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
if hasattr(sys.stderr, 'buffer'):
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
import time
|
||||
|
||||
from agent_reach import __version__
|
||||
|
||||
|
||||
def _ensure_utf8_console():
|
||||
"""Best-effort Windows console UTF-8 setup for CLI runtime only."""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
# Avoid interfering with pytest/captured streams.
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
return
|
||||
if not getattr(sys.stdout, "isatty", lambda: False)():
|
||||
return
|
||||
try:
|
||||
import io
|
||||
if hasattr(sys.stdout, "buffer"):
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stderr, "buffer"):
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
# Do not crash CLI just because encoding patch failed.
|
||||
pass
|
||||
|
||||
|
||||
def _configure_logging(verbose: bool = False):
|
||||
"""Suppress loguru output unless --verbose is set."""
|
||||
from loguru import logger
|
||||
@@ -34,6 +47,8 @@ def _configure_logging(verbose: bool = False):
|
||||
|
||||
|
||||
def main():
|
||||
_ensure_utf8_console()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="agent-reach",
|
||||
description="👁️ Give your AI Agent eyes to see the entire internet",
|
||||
@@ -42,7 +57,6 @@ def main():
|
||||
parser.add_argument("--version", action="version", version=f"Agent Reach v{__version__}")
|
||||
sub = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# ── read ──
|
||||
# ── setup ──
|
||||
sub.add_parser("setup", help="Interactive configuration wizard")
|
||||
|
||||
@@ -71,6 +85,13 @@ def main():
|
||||
# ── doctor ──
|
||||
sub.add_parser("doctor", help="Check platform availability")
|
||||
|
||||
# ── uninstall ──
|
||||
p_uninstall = sub.add_parser("uninstall", help="Remove all Agent Reach config, tokens, and skill files")
|
||||
p_uninstall.add_argument("--dry-run", action="store_true",
|
||||
help="Show what would be removed without making any changes")
|
||||
p_uninstall.add_argument("--keep-config", action="store_true",
|
||||
help="Remove skill files only, keep ~/.agent-reach/ config and tokens")
|
||||
|
||||
# ── check-update ──
|
||||
sub.add_parser("check-update", help="Check for new versions and changes")
|
||||
|
||||
@@ -105,6 +126,8 @@ def main():
|
||||
_cmd_install(args)
|
||||
elif args.command == "configure":
|
||||
_cmd_configure(args)
|
||||
elif args.command == "uninstall":
|
||||
_cmd_uninstall(args)
|
||||
|
||||
|
||||
# ── Command handlers ────────────────────────────────
|
||||
@@ -279,6 +302,7 @@ def _install_system_deps():
|
||||
import shutil
|
||||
import subprocess
|
||||
import platform
|
||||
import tempfile
|
||||
|
||||
print("🔧 Checking system dependencies...")
|
||||
|
||||
@@ -290,15 +314,25 @@ def _install_system_deps():
|
||||
os_type = platform.system().lower()
|
||||
if os_type == "linux":
|
||||
try:
|
||||
# Official GitHub method for Linux
|
||||
cmds = [
|
||||
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 2>/dev/null",
|
||||
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null',
|
||||
"apt-get update -qq 2>/dev/null",
|
||||
"apt-get install -y -qq gh 2>/dev/null",
|
||||
]
|
||||
for cmd in cmds:
|
||||
subprocess.run(cmd, shell=True, capture_output=True, timeout=60)
|
||||
# Official GitHub apt source setup without invoking a shell.
|
||||
keyring_path = "/usr/share/keyrings/githubcli-archive-keyring.gpg"
|
||||
list_path = "/etc/apt/sources.list.d/github-cli.list"
|
||||
arch = subprocess.run(
|
||||
["dpkg", "--print-architecture"],
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
||||
).stdout.strip() or "amd64"
|
||||
subprocess.run(
|
||||
["curl", "-fsSL", "https://cli.github.com/packages/githubcli-archive-keyring.gpg", "-o", keyring_path],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
repo_line = (
|
||||
f"deb [arch={arch} signed-by={keyring_path}] "
|
||||
"https://cli.github.com/packages stable main\n"
|
||||
)
|
||||
with open(list_path, "w", encoding="utf-8") as f:
|
||||
f.write(repo_line)
|
||||
subprocess.run(["apt-get", "update", "-qq"], capture_output=True, timeout=60)
|
||||
subprocess.run(["apt-get", "install", "-y", "-qq", "gh"], capture_output=True, timeout=60)
|
||||
if shutil.which("gh"):
|
||||
print(" ✅ gh CLI installed")
|
||||
else:
|
||||
@@ -326,10 +360,24 @@ def _install_system_deps():
|
||||
else:
|
||||
print(" 📥 Installing Node.js...")
|
||||
try:
|
||||
# Use NodeSource for quick install
|
||||
# Use NodeSource setup script without invoking a shell pipeline.
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".sh") as tf:
|
||||
script_path = tf.name
|
||||
subprocess.run(
|
||||
"curl -fsSL https://deb.nodesource.com/setup_22.x | bash - 2>/dev/null && apt-get install -y -qq nodejs 2>/dev/null",
|
||||
shell=True, capture_output=True, timeout=120,
|
||||
["curl", "-fsSL", "https://deb.nodesource.com/setup_22.x", "-o", script_path],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
subprocess.run(
|
||||
["bash", script_path],
|
||||
capture_output=True, timeout=120,
|
||||
)
|
||||
try:
|
||||
os.unlink(script_path)
|
||||
except Exception:
|
||||
pass
|
||||
subprocess.run(
|
||||
["apt-get", "install", "-y", "-qq", "nodejs"],
|
||||
capture_output=True, timeout=120,
|
||||
)
|
||||
if shutil.which("node"):
|
||||
print(" ✅ Node.js installed")
|
||||
@@ -338,37 +386,56 @@ def _install_system_deps():
|
||||
except Exception:
|
||||
print(" ⚠️ Node.js install failed. Try: apt install nodejs npm, or nvm install 22, or download from https://nodejs.org")
|
||||
|
||||
# ── bird CLI (for Twitter search) ──
|
||||
if shutil.which("bird") or shutil.which("birdx"):
|
||||
print(" ✅ bird CLI already installed")
|
||||
# ── xreach CLI (for Twitter search) ──
|
||||
if shutil.which("xreach"):
|
||||
print(" ✅ xreach CLI already installed")
|
||||
else:
|
||||
if shutil.which("npm"):
|
||||
try:
|
||||
subprocess.run(
|
||||
["npm", "install", "-g", "@steipete/bird"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
["npm", "install", "-g", "xreach-cli"],
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=120,
|
||||
)
|
||||
if shutil.which("bird"):
|
||||
print(" ✅ bird CLI installed (Twitter search + timeline)")
|
||||
if shutil.which("xreach"):
|
||||
print(" ✅ xreach CLI installed (Twitter search + timeline)")
|
||||
else:
|
||||
print(" ⬜ bird CLI install failed (optional — Twitter reading still works via Jina)")
|
||||
print(" ⬜ xreach CLI install failed (optional — Twitter reading still works via Jina)")
|
||||
except Exception:
|
||||
print(" ⬜ bird CLI install failed (optional — Twitter reading still works via Jina)")
|
||||
print(" ⬜ xreach CLI install failed (optional — Twitter reading still works via Jina)")
|
||||
else:
|
||||
print(" ⬜ bird CLI requires Node.js (optional — Twitter reading still works via Jina)")
|
||||
print(" ⬜ xreach CLI requires Node.js (optional — Twitter reading still works via Jina)")
|
||||
|
||||
# ── undici (proxy support for Node.js fetch) ──
|
||||
if shutil.which("npm"):
|
||||
npm_root = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, timeout=5).stdout.strip()
|
||||
npm_root = subprocess.run(["npm", "root", "-g"], capture_output=True, encoding="utf-8", errors="replace", timeout=5).stdout.strip()
|
||||
undici_path = os.path.join(npm_root, "undici", "index.js") if npm_root else ""
|
||||
if os.path.exists(undici_path):
|
||||
print(" ✅ undici already installed (Node.js proxy support)")
|
||||
else:
|
||||
try:
|
||||
subprocess.run(["npm", "install", "-g", "undici"], capture_output=True, text=True, timeout=60)
|
||||
subprocess.run(["npm", "install", "-g", "undici"], capture_output=True, encoding="utf-8", errors="replace", timeout=60)
|
||||
print(" ✅ undici installed (Node.js proxy support)")
|
||||
except Exception:
|
||||
print(" ⬜ undici install failed (optional — bird may not work behind proxies)")
|
||||
print(" ⬜ undici install failed (optional — xreach may not work behind proxies)")
|
||||
|
||||
# ── yt-dlp JS runtime config (YouTube requires external JS runtime) ──
|
||||
if shutil.which("node"):
|
||||
ytdlp_config_dir = os.path.expanduser("~/.config/yt-dlp")
|
||||
ytdlp_config = os.path.join(ytdlp_config_dir, "config")
|
||||
needs_config = True
|
||||
if os.path.exists(ytdlp_config):
|
||||
with open(ytdlp_config, "r") as f:
|
||||
if "--js-runtimes" in f.read():
|
||||
needs_config = False
|
||||
print(" ✅ yt-dlp JS runtime already configured")
|
||||
if needs_config:
|
||||
try:
|
||||
os.makedirs(ytdlp_config_dir, exist_ok=True)
|
||||
with open(ytdlp_config, "a") as f:
|
||||
f.write("--js-runtimes node\n")
|
||||
print(" ✅ yt-dlp configured to use Node.js as JS runtime (YouTube)")
|
||||
except Exception:
|
||||
print(" ⬜ Could not configure yt-dlp JS runtime (YouTube may not work)")
|
||||
|
||||
|
||||
def _install_system_deps_safe():
|
||||
@@ -380,7 +447,7 @@ def _install_system_deps_safe():
|
||||
deps = [
|
||||
("gh", ["gh"], "GitHub CLI", "https://cli.github.com — or: apt install gh / brew install gh"),
|
||||
("node", ["node", "npm"], "Node.js", "https://nodejs.org — or: apt install nodejs npm"),
|
||||
("bird", ["bird", "birdx"], "bird CLI (Twitter)", "npm install -g @steipete/bird"),
|
||||
("xreach", ["xreach"], "xreach CLI (Twitter)", "npm install -g xreach-cli"),
|
||||
]
|
||||
|
||||
missing = []
|
||||
@@ -410,7 +477,7 @@ def _install_system_deps_dryrun():
|
||||
checks = [
|
||||
("gh CLI", ["gh"], "apt install gh / brew install gh"),
|
||||
("Node.js", ["node"], "curl NodeSource setup | bash + apt install nodejs"),
|
||||
("bird CLI", ["bird", "birdx"], "npm install -g @steipete/bird"),
|
||||
("xreach CLI", ["xreach"], "npm install -g xreach-cli"),
|
||||
]
|
||||
|
||||
for label, binaries, method in checks:
|
||||
@@ -439,7 +506,7 @@ def _install_mcporter():
|
||||
try:
|
||||
subprocess.run(
|
||||
["npm", "install", "-g", "mcporter"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=120,
|
||||
)
|
||||
if shutil.which("mcporter"):
|
||||
print(" ✅ mcporter installed")
|
||||
@@ -453,12 +520,12 @@ def _install_mcporter():
|
||||
# Configure Exa MCP (free, no key needed)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, text=True, timeout=10
|
||||
["mcporter", "config", "list"], capture_output=True, encoding="utf-8", errors="replace", timeout=5
|
||||
)
|
||||
if "exa" not in r.stdout:
|
||||
subprocess.run(
|
||||
["mcporter", "config", "add", "exa", "https://mcp.exa.ai/mcp"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
||||
)
|
||||
print(" ✅ Exa search configured (free, no API key needed)")
|
||||
else:
|
||||
@@ -469,7 +536,7 @@ def _install_mcporter():
|
||||
# Check XiaoHongShu MCP (only if server is running)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, text=True, timeout=10
|
||||
["mcporter", "config", "list"], capture_output=True, encoding="utf-8", errors="replace", timeout=5
|
||||
)
|
||||
if "xiaohongshu" in r.stdout:
|
||||
print(" ✅ XiaoHongShu MCP already configured")
|
||||
@@ -480,7 +547,7 @@ def _install_mcporter():
|
||||
requests.get("http://localhost:18060/", timeout=3)
|
||||
subprocess.run(
|
||||
["mcporter", "config", "add", "xiaohongshu", "http://localhost:18060/mcp"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
||||
)
|
||||
print(" ✅ XiaoHongShu MCP auto-detected and configured")
|
||||
except Exception:
|
||||
@@ -539,7 +606,7 @@ def _detect_environment():
|
||||
# systemd-detect-virt
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(["systemd-detect-virt"], capture_output=True, text=True, timeout=3)
|
||||
result = subprocess.run(["systemd-detect-virt"], capture_output=True, encoding="utf-8", errors="replace", timeout=3)
|
||||
if result.returncode == 0 and result.stdout.strip() != "none":
|
||||
indicators += 1
|
||||
except:
|
||||
@@ -636,22 +703,41 @@ def _cmd_configure(args):
|
||||
if auth_token and ct0:
|
||||
config.set("twitter_auth_token", auth_token)
|
||||
config.set("twitter_ct0", ct0)
|
||||
print(f"✅ Twitter cookies configured!")
|
||||
|
||||
# Sync credentials to xreach's session.json so xreach auth check works
|
||||
try:
|
||||
import json
|
||||
xfetch_dir = os.path.join(os.path.expanduser("~"), ".config", "xfetch")
|
||||
os.makedirs(xfetch_dir, exist_ok=True)
|
||||
session_path = os.path.join(xfetch_dir, "session.json")
|
||||
session_data = {}
|
||||
if os.path.exists(session_path):
|
||||
with open(session_path, "r", encoding="utf-8") as sf:
|
||||
session_data = json.load(sf)
|
||||
session_data["authToken"] = auth_token
|
||||
session_data["ct0"] = ct0
|
||||
with open(session_path, "w", encoding="utf-8") as sf:
|
||||
json.dump(session_data, sf, indent=2)
|
||||
os.chmod(session_path, 0o600)
|
||||
print("✅ Twitter cookies configured (synced to xreach)!")
|
||||
except Exception as e:
|
||||
print("✅ Twitter cookies configured!")
|
||||
print(f"⚠️ Could not sync to xreach session.json: {e}")
|
||||
|
||||
print("Testing Twitter access...", end=" ")
|
||||
try:
|
||||
import subprocess
|
||||
bird = shutil.which("bird") or shutil.which("birdx")
|
||||
if not bird:
|
||||
print("⚠️ bird CLI not installed. Run: npm install -g @steipete/bird")
|
||||
xreach = shutil.which("xreach")
|
||||
if not xreach:
|
||||
print("⚠️ xreach CLI not installed. Run: npm install -g xreach-cli")
|
||||
else:
|
||||
import os
|
||||
env = os.environ.copy()
|
||||
env["AUTH_TOKEN"] = auth_token
|
||||
env["CT0"] = ct0
|
||||
result = subprocess.run(
|
||||
[bird, "search", "test", "-n", "1"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
[xreach, "search", "test", "-n", "1"],
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=15,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
@@ -680,6 +766,104 @@ def _cmd_configure(args):
|
||||
print(f"✅ Groq key configured!")
|
||||
|
||||
|
||||
def _cmd_uninstall(args):
|
||||
"""Remove all Agent Reach config, tokens, and skill files."""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
dry_run = args.dry_run
|
||||
keep_config = args.keep_config
|
||||
|
||||
print()
|
||||
print("Agent Reach Uninstaller")
|
||||
print("=" * 40)
|
||||
|
||||
if dry_run:
|
||||
print("DRY RUN — showing what would be removed (no changes)")
|
||||
print()
|
||||
|
||||
removed_any = False
|
||||
|
||||
# ── 1. Config directory (~/.agent-reach/) ──
|
||||
config_dir = os.path.expanduser("~/.agent-reach")
|
||||
if not keep_config:
|
||||
if os.path.isdir(config_dir):
|
||||
if dry_run:
|
||||
print(f"[dry-run] Would remove config directory: {config_dir}")
|
||||
print(" (contains config.yaml with all tokens/cookies/API keys)")
|
||||
else:
|
||||
try:
|
||||
shutil.rmtree(config_dir)
|
||||
print(f" Removed config directory: {config_dir}")
|
||||
removed_any = True
|
||||
except Exception as e:
|
||||
print(f" Could not remove {config_dir}: {e}")
|
||||
else:
|
||||
print(f" Config directory not found (already clean): {config_dir}")
|
||||
else:
|
||||
print(f" Skipping config directory (--keep-config): {config_dir}")
|
||||
|
||||
# ── 2. Skill files ──
|
||||
skill_dirs = [
|
||||
("~/.openclaw/skills/agent-reach", "OpenClaw"),
|
||||
("~/.claude/skills/agent-reach", "Claude Code"),
|
||||
("~/.agents/skills/agent-reach", "Agent"),
|
||||
]
|
||||
|
||||
for skill_path_template, platform_name in skill_dirs:
|
||||
skill_path = os.path.expanduser(skill_path_template)
|
||||
if os.path.isdir(skill_path):
|
||||
if dry_run:
|
||||
print(f"[dry-run] Would remove {platform_name} skill: {skill_path}")
|
||||
else:
|
||||
try:
|
||||
shutil.rmtree(skill_path)
|
||||
print(f" Removed {platform_name} skill: {skill_path}")
|
||||
removed_any = True
|
||||
except Exception as e:
|
||||
print(f" Could not remove {skill_path}: {e}")
|
||||
|
||||
# ── 3. mcporter MCP entries ──
|
||||
if shutil.which("mcporter"):
|
||||
for mcp_name in ("exa", "xiaohongshu"):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, encoding="utf-8", errors="replace", timeout=10
|
||||
)
|
||||
if mcp_name in r.stdout:
|
||||
if dry_run:
|
||||
print(f"[dry-run] Would remove mcporter entry: {mcp_name}")
|
||||
else:
|
||||
subprocess.run(
|
||||
["mcporter", "config", "remove", mcp_name],
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
||||
)
|
||||
print(f" Removed mcporter entry: {mcp_name}")
|
||||
removed_any = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 4. Summary and optional steps ──
|
||||
print()
|
||||
if dry_run:
|
||||
print("Dry run complete. No changes were made.")
|
||||
print("Run without --dry-run to actually remove the above.")
|
||||
else:
|
||||
if removed_any:
|
||||
print("Agent Reach data removed.")
|
||||
else:
|
||||
print("Nothing to remove — already clean.")
|
||||
|
||||
print()
|
||||
print("Optional: remove the Agent Reach Python package itself:")
|
||||
print(" pip uninstall agent-reach")
|
||||
print()
|
||||
print("Optional: remove tools installed by Agent Reach:")
|
||||
print(" npm uninstall -g mcporter")
|
||||
print(" npm uninstall -g xreach-cli")
|
||||
print(" npm uninstall -g undici")
|
||||
|
||||
|
||||
def _cmd_doctor():
|
||||
from agent_reach.config import Config
|
||||
from agent_reach.doctor import check_all, format_report
|
||||
@@ -688,19 +872,6 @@ def _cmd_doctor():
|
||||
print(format_report(results))
|
||||
|
||||
|
||||
def _parse_cookie_header(cookie_str: str) -> dict:
|
||||
"""Parse Cookie-Editor 'Header String' format into a dict."""
|
||||
cookies = {}
|
||||
for part in cookie_str.split(";"):
|
||||
part = part.strip()
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
cookies[k.strip()] = v.strip()
|
||||
return cookies
|
||||
|
||||
|
||||
|
||||
|
||||
def _cmd_setup():
|
||||
from agent_reach.config import Config
|
||||
|
||||
@@ -710,29 +881,41 @@ def _cmd_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()
|
||||
# Step 1: Exa (via mcporter, no API key required)
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
print("【推荐】全网搜索 — Exa(通过 mcporter)")
|
||||
print(" 免费,无需 API Key")
|
||||
|
||||
if not shutil.which("mcporter"):
|
||||
print(" 当前状态: ⬜ mcporter 未安装")
|
||||
print(" 安装:npm install -g mcporter")
|
||||
print(" 然后:mcporter config add exa https://mcp.exa.ai/mcp")
|
||||
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 配置")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "config", "list"], capture_output=True, encoding="utf-8", errors="replace", timeout=10
|
||||
)
|
||||
if "exa" in r.stdout.lower():
|
||||
print(" 当前状态: ✅ 已配置")
|
||||
else:
|
||||
print(" 当前状态: ⬜ 未配置")
|
||||
setup_now = input(" 现在自动配置 Exa 吗?[Y/n]: ").strip().lower()
|
||||
if setup_now in ("", "y", "yes"):
|
||||
add_r = subprocess.run(
|
||||
["mcporter", "config", "add", "exa", "https://mcp.exa.ai/mcp"],
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
||||
)
|
||||
if add_r.returncode == 0:
|
||||
print(" ✅ Exa 已配置")
|
||||
else:
|
||||
print(" ⚠️ 自动配置失败,请手动执行:")
|
||||
print(" mcporter config add exa https://mcp.exa.ai/mcp")
|
||||
except Exception:
|
||||
print(" ⚠️ 无法检查 Exa 配置,请手动执行:")
|
||||
print(" mcporter config add exa https://mcp.exa.ai/mcp")
|
||||
print()
|
||||
|
||||
# Step 2: GitHub token
|
||||
@@ -789,62 +972,160 @@ def _cmd_setup():
|
||||
print()
|
||||
|
||||
|
||||
def _classify_update_error(exc):
|
||||
"""Classify update-check errors for user-friendly diagnostics."""
|
||||
import requests
|
||||
|
||||
if isinstance(exc, requests.exceptions.Timeout):
|
||||
return "timeout"
|
||||
if isinstance(exc, requests.exceptions.ConnectionError):
|
||||
msg = str(exc).lower()
|
||||
dns_markers = [
|
||||
"name or service not known",
|
||||
"temporary failure in name resolution",
|
||||
"nodename nor servname",
|
||||
"getaddrinfo failed",
|
||||
"name resolution",
|
||||
"dns",
|
||||
]
|
||||
if any(marker in msg for marker in dns_markers):
|
||||
return "dns"
|
||||
return "connection"
|
||||
if isinstance(exc, requests.exceptions.HTTPError):
|
||||
return "http"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _update_error_text(kind):
|
||||
"""Map internal error kinds to user-facing text."""
|
||||
mapping = {
|
||||
"timeout": "网络超时",
|
||||
"dns": "DNS 解析失败",
|
||||
"rate_limit": "GitHub API 速率限制",
|
||||
"connection": "网络连接失败",
|
||||
"server_error": "GitHub 服务暂时不可用",
|
||||
"http": "HTTP 请求失败",
|
||||
"unknown": "未知网络错误",
|
||||
}
|
||||
return mapping.get(kind, "请求失败")
|
||||
|
||||
|
||||
def _classify_github_response_error(resp):
|
||||
"""Classify non-200 GitHub responses that merit special handling."""
|
||||
if resp is None:
|
||||
return "unknown"
|
||||
if resp.status_code == 429:
|
||||
return "rate_limit"
|
||||
if resp.status_code == 403:
|
||||
remaining = resp.headers.get("X-RateLimit-Remaining", "")
|
||||
if remaining == "0":
|
||||
return "rate_limit"
|
||||
try:
|
||||
message = resp.json().get("message", "").lower()
|
||||
if "rate limit" in message:
|
||||
return "rate_limit"
|
||||
except Exception:
|
||||
pass
|
||||
if 500 <= resp.status_code < 600:
|
||||
return "server_error"
|
||||
return None
|
||||
|
||||
|
||||
def _github_get_with_retry(url, timeout=10, retries=3, sleeper=time.sleep):
|
||||
"""GET GitHub API with retry/backoff and basic error classification."""
|
||||
import requests
|
||||
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
resp = requests.get(url, timeout=timeout)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
if attempt >= retries:
|
||||
return None, _classify_update_error(exc), attempt
|
||||
sleeper(2 ** (attempt - 1))
|
||||
continue
|
||||
|
||||
err_kind = _classify_github_response_error(resp)
|
||||
if err_kind in ("rate_limit", "server_error"):
|
||||
if attempt >= retries:
|
||||
return None, err_kind, attempt
|
||||
delay = 2 ** (attempt - 1)
|
||||
retry_after = resp.headers.get("Retry-After")
|
||||
if err_kind == "rate_limit" and retry_after:
|
||||
try:
|
||||
delay = max(delay, float(retry_after))
|
||||
except Exception:
|
||||
pass
|
||||
sleeper(delay)
|
||||
continue
|
||||
|
||||
return resp, None, attempt
|
||||
|
||||
return None, "unknown", retries
|
||||
|
||||
|
||||
def _cmd_check_update():
|
||||
"""Check for newer versions on GitHub."""
|
||||
import requests
|
||||
from agent_reach import __version__
|
||||
|
||||
print(f"📦 当前版本: v{__version__}")
|
||||
release_url = "https://api.github.com/repos/Panniantong/Agent-Reach/releases/latest"
|
||||
commit_url = "https://api.github.com/repos/Panniantong/Agent-Reach/commits/main"
|
||||
|
||||
try:
|
||||
# Fetch latest version from GitHub
|
||||
resp = requests.get(
|
||||
"https://api.github.com/repos/Panniantong/Agent-Reach/releases/latest",
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
latest = data.get("tag_name", "").lstrip("v")
|
||||
body = data.get("body", "")
|
||||
|
||||
if latest and latest != __version__:
|
||||
print(f"🆕 最新版本: v{latest} ← 有更新!")
|
||||
if body:
|
||||
print()
|
||||
print("更新内容:")
|
||||
# Show first 20 lines of release notes
|
||||
for line in body.strip().split("\n")[:20]:
|
||||
print(f" {line}")
|
||||
print()
|
||||
print("更新命令:")
|
||||
print(" pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip")
|
||||
return "update_available"
|
||||
else:
|
||||
print(f"✅ 已是最新版本")
|
||||
return "up_to_date"
|
||||
else:
|
||||
# No releases yet, fall back to comparing commit
|
||||
resp2 = requests.get(
|
||||
"https://api.github.com/repos/Panniantong/Agent-Reach/commits/main",
|
||||
timeout=10,
|
||||
)
|
||||
if resp2.status_code == 200:
|
||||
commit = resp2.json()
|
||||
sha = commit.get("sha", "")[:7]
|
||||
msg = commit.get("commit", {}).get("message", "").split("\n")[0]
|
||||
date = commit.get("commit", {}).get("committer", {}).get("date", "")[:10]
|
||||
print(f"🔍 最新提交: {sha} ({date}) {msg}")
|
||||
print()
|
||||
print("更新命令:")
|
||||
print(" pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip")
|
||||
return "unknown"
|
||||
else:
|
||||
print("⚠️ 无法检查更新(网络问题)")
|
||||
return "error"
|
||||
except Exception as e:
|
||||
print(f"⚠️ 无法检查更新: {e}")
|
||||
# Fetch latest release with retry/backoff.
|
||||
resp, err, attempts = _github_get_with_retry(release_url, timeout=10, retries=3)
|
||||
if err:
|
||||
print(f"⚠️ 无法检查更新({_update_error_text(err)},已重试 {attempts} 次)")
|
||||
return "error"
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
latest = data.get("tag_name", "").lstrip("v")
|
||||
body = data.get("body", "")
|
||||
|
||||
if latest and latest != __version__:
|
||||
print(f"🆕 最新版本: v{latest} ← 有更新!")
|
||||
if body:
|
||||
print()
|
||||
print("更新内容:")
|
||||
# Show first 20 lines of release notes
|
||||
for line in body.strip().split("\n")[:20]:
|
||||
print(f" {line}")
|
||||
print()
|
||||
print("更新命令:")
|
||||
print(" pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip")
|
||||
return "update_available"
|
||||
print(f"✅ 已是最新版本")
|
||||
return "up_to_date"
|
||||
|
||||
release_err = _classify_github_response_error(resp)
|
||||
if release_err == "rate_limit":
|
||||
print("⚠️ 无法检查更新(GitHub API 速率限制,请稍后重试)")
|
||||
return "error"
|
||||
|
||||
# No releases yet, fall back to latest main commit.
|
||||
resp2, err2, attempts2 = _github_get_with_retry(commit_url, timeout=10, retries=2)
|
||||
if err2:
|
||||
print(f"⚠️ 无法检查更新({_update_error_text(err2)},已重试 {attempts + attempts2} 次)")
|
||||
return "error"
|
||||
if resp2.status_code == 200:
|
||||
commit = resp2.json()
|
||||
sha = commit.get("sha", "")[:7]
|
||||
msg = commit.get("commit", {}).get("message", "").split("\n")[0]
|
||||
date = commit.get("commit", {}).get("committer", {}).get("date", "")[:10]
|
||||
print(f"🔍 最新提交: {sha} ({date}) {msg}")
|
||||
print()
|
||||
print("更新命令:")
|
||||
print(" pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip")
|
||||
return "unknown"
|
||||
|
||||
commit_err = _classify_github_response_error(resp2)
|
||||
if commit_err == "rate_limit":
|
||||
print("⚠️ 无法检查更新(GitHub API 速率限制,请稍后重试)")
|
||||
return "error"
|
||||
|
||||
print(f"⚠️ 无法检查更新(GitHub 返回 {resp2.status_code})")
|
||||
return "error"
|
||||
|
||||
|
||||
def _cmd_watch():
|
||||
"""Quick health check + update check, designed for scheduled tasks.
|
||||
@@ -853,7 +1134,6 @@ def _cmd_watch():
|
||||
"""
|
||||
from agent_reach.config import Config
|
||||
from agent_reach.doctor import check_all
|
||||
import requests
|
||||
from agent_reach import __version__
|
||||
|
||||
config = Config()
|
||||
@@ -875,20 +1155,18 @@ def _cmd_watch():
|
||||
update_available = False
|
||||
new_version = ""
|
||||
release_body = ""
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://api.github.com/repos/Panniantong/Agent-Reach/releases/latest",
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
latest = data.get("tag_name", "").lstrip("v")
|
||||
if latest and latest != __version__:
|
||||
update_available = True
|
||||
new_version = latest
|
||||
release_body = data.get("body", "")
|
||||
except Exception:
|
||||
pass
|
||||
resp, err, _attempts = _github_get_with_retry(
|
||||
"https://api.github.com/repos/Panniantong/Agent-Reach/releases/latest",
|
||||
timeout=10,
|
||||
retries=2,
|
||||
)
|
||||
if not err and resp and resp.status_code == 200:
|
||||
data = resp.json()
|
||||
latest = data.get("tag_name", "").lstrip("v")
|
||||
if latest and latest != __version__:
|
||||
update_available = True
|
||||
new_version = latest
|
||||
release_body = data.get("body", "")
|
||||
|
||||
# Output
|
||||
if not issues and not update_available:
|
||||
|
||||
@@ -22,7 +22,7 @@ class Config:
|
||||
FEATURE_REQUIREMENTS = {
|
||||
"exa_search": ["exa_api_key"],
|
||||
"reddit_proxy": ["reddit_proxy"],
|
||||
"twitter_bird": ["twitter_auth_token", "twitter_ct0"],
|
||||
"twitter_xreach": ["twitter_auth_token", "twitter_ct0"],
|
||||
"groq_whisper": ["groq_api_key"],
|
||||
"github_token": ["github_token"],
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class Config:
|
||||
def load(self):
|
||||
"""Load config from YAML file."""
|
||||
if self.config_path.exists():
|
||||
with open(self.config_path, "r") as f:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
self.data = yaml.safe_load(f) or {}
|
||||
else:
|
||||
self.data = {}
|
||||
@@ -49,7 +49,7 @@ class Config:
|
||||
def save(self):
|
||||
"""Save config to YAML file."""
|
||||
self._ensure_dir()
|
||||
with open(self.config_path, "w") as f:
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True)
|
||||
# Restrict permissions — config may contain credentials
|
||||
try:
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
AgentReach — installer, doctor, and configuration tool.
|
||||
|
||||
Agent Reach helps AI agents install and configure upstream platform tools
|
||||
(bird CLI, yt-dlp, mcporter, gh CLI, etc.). After installation, agents
|
||||
(xreach CLI, yt-dlp, mcporter, gh CLI, etc.). After installation, agents
|
||||
call the upstream tools directly — no wrapper layer needed.
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -74,11 +74,12 @@ def format_report(results: Dict[str, dict]) -> str:
|
||||
if ok_count < total:
|
||||
lines.append("运行 `agent-reach setup` 解锁更多渠道")
|
||||
|
||||
# Security check: config file permissions
|
||||
# Security check: config file permissions (Unix only)
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
config_path = Config.CONFIG_DIR / "config.yaml"
|
||||
if config_path.exists():
|
||||
if config_path.exists() and sys.platform != "win32":
|
||||
try:
|
||||
mode = config_path.stat().st_mode
|
||||
if mode & (stat.S_IRGRP | stat.S_IROTH):
|
||||
|
||||
@@ -1,67 +1,77 @@
|
||||
# Twitter 高级功能配置指南(bird CLI)
|
||||
# Twitter 高级功能配置指南(xreach CLI)
|
||||
|
||||
## 功能说明
|
||||
基础 Twitter 功能(搜索+读单条推文)无需配置,开箱即用。
|
||||
Twitter 基础阅读通过 Jina Reader 免费可用,无需配置。
|
||||
|
||||
高级功能需要 bird CLI:
|
||||
- 查看用户时间线
|
||||
- 深度搜索(更精确、更多结果)
|
||||
- 读取完整线程(thread)
|
||||
- 查看关注列表推文
|
||||
高级功能需要 xreach CLI:
|
||||
|
||||
bird 是免费开源工具(npm 包 @steipete/bird),但需要你的 Twitter 账号 cookie。
|
||||
- 搜索推文(`xreach search`)
|
||||
- 读取完整推文和对话链(`xreach tweet`、`xreach thread`)
|
||||
- 用户时间线(`xreach tweets`)
|
||||
|
||||
## Agent 可自动完成的步骤
|
||||
xreach 是免费开源工具(npm 包 xreach-cli),但需要你的 Twitter 账号 cookie。
|
||||
|
||||
## 快速配置
|
||||
|
||||
1. 检查 xreach 是否安装:
|
||||
|
||||
1. 检查 bird 是否安装:
|
||||
```bash
|
||||
which bird && echo "installed" || echo "not installed"
|
||||
which xreach && echo "installed" || echo "not installed"
|
||||
```
|
||||
|
||||
2. 安装 bird:
|
||||
2. 安装 xreach:
|
||||
|
||||
```bash
|
||||
npm install -g @steipete/bird
|
||||
npm install -g xreach-cli
|
||||
```
|
||||
|
||||
3. 测试(需要先配置 cookie):
|
||||
3. 测试是否配置好:
|
||||
|
||||
```bash
|
||||
AUTH_TOKEN="xxx" CT0="yyy" bird search "test" -n 1
|
||||
AUTH_TOKEN="xxx" CT0="yyy" xreach search "test" -n 1
|
||||
```
|
||||
|
||||
4. 用 agent-reach 配置 cookie(推荐):
|
||||
## 获取 Cookie(Cookie-Editor 方式,推荐)
|
||||
|
||||
1. 安装 [Cookie-Editor](https://cookie-editor.com/) 浏览器扩展
|
||||
2. 登录 x.com
|
||||
3. 点击 Cookie-Editor 图标 → Export → 复制全部
|
||||
4. 运行配置命令:
|
||||
|
||||
```bash
|
||||
agent-reach configure twitter-cookies "auth_token=xxx; ct0=yyy"
|
||||
agent-reach configure twitter-cookies "粘贴的 cookie JSON"
|
||||
```
|
||||
|
||||
## 需要用户手动做的步骤
|
||||
这会自动提取 `auth_token` 和 `ct0`,并写入环境变量。
|
||||
|
||||
请告诉用户:
|
||||
## 手动设置 Cookie
|
||||
|
||||
> Twitter 高级功能需要你的 Twitter 账号 cookie(完全免费)。
|
||||
>
|
||||
> **最简单的方式:**
|
||||
> 1. 安装 Chrome 插件 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)
|
||||
> 2. 打开 https://x.com 并确保已登录
|
||||
> 3. 点击 Cookie-Editor 插件图标 → Export → Header String
|
||||
> 4. 把导出的内容发给我
|
||||
>
|
||||
> **手动方式:**
|
||||
> 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 个月会过期,届时需要重新导出。
|
||||
如果你已经知道 `auth_token` 和 `ct0`:
|
||||
|
||||
## Agent 收到 cookie 后的操作
|
||||
1. 安装 xreach(如果没装):`npm install -g xreach-cli`
|
||||
|
||||
1. 安装 bird(如果没装):`npm install -g @steipete/bird`
|
||||
2. 配置 cookie:`agent-reach configure twitter-cookies "粘贴的内容"`
|
||||
3. 测试:运行 `agent-reach doctor` 确认 Twitter 状态
|
||||
4. 反馈:"✅ Twitter 高级功能已开启!现在可以搜索推文、查看时间线了。"
|
||||
5. 如果失败:"❌ Cookie 无效或已过期,请重新导出。"
|
||||
2. 设置环境变量:
|
||||
|
||||
```bash
|
||||
export AUTH_TOKEN="你的auth_token"
|
||||
export CT0="你的ct0"
|
||||
```
|
||||
|
||||
3. 测试:
|
||||
|
||||
```bash
|
||||
xreach search "test" --auth-token "$AUTH_TOKEN" --ct0 "$CT0" -n 1
|
||||
```
|
||||
|
||||
## 代理配置
|
||||
|
||||
> xreach CLI 内置代理支持,通过 `--proxy` 参数传入:
|
||||
|
||||
```bash
|
||||
xreach search "test" --auth-token "$AUTH_TOKEN" --ct0 "$CT0" --proxy "http://user:pass@host:port"
|
||||
```
|
||||
|
||||
也支持代理轮换文件:
|
||||
|
||||
```bash
|
||||
xreach search "test" --auth-token "$AUTH_TOKEN" --ct0 "$CT0" --proxy-file proxies.txt
|
||||
```
|
||||
|
||||
@@ -67,5 +67,25 @@ docker run -d \
|
||||
**Q: 服务器上小红书提示 IP 风险?**
|
||||
A: 加代理参数 `-e XHS_PROXY=http://user:pass@ip:port`,推荐住宅代理。
|
||||
|
||||
**Q: Docker 镜像不支持 ARM64 / Apple Silicon?**
|
||||
A: 上游镜像暂无 ARM64 版本,两种解决办法:
|
||||
|
||||
方法一:使用 Rosetta 模拟运行(推荐,最简单)
|
||||
```bash
|
||||
docker run -d \
|
||||
--name xiaohongshu-mcp \
|
||||
-p 18060:18060 \
|
||||
--platform linux/amd64 \
|
||||
xpzouying/xiaohongshu-mcp
|
||||
```
|
||||
|
||||
方法二:从源码编译原生 ARM64 版本
|
||||
```bash
|
||||
git clone https://github.com/xpzouying/xiaohongshu-mcp
|
||||
cd xiaohongshu-mcp
|
||||
docker build -t xiaohongshu-mcp .
|
||||
docker run -d --name xiaohongshu-mcp -p 18060:18060 xiaohongshu-mcp
|
||||
```
|
||||
|
||||
**Q: 我不想用 Docker?**
|
||||
A: 可以从源码编译:https://github.com/xpzouying/xiaohongshu-mcp
|
||||
|
||||
@@ -5,7 +5,7 @@ Agent Reach MCP Server — expose doctor/status as MCP tool.
|
||||
Run: python -m agent_reach.integrations.mcp_server
|
||||
|
||||
Agent Reach is an installer + doctor tool. For actual reading/searching,
|
||||
agents should call upstream tools directly (bird, yt-dlp, mcporter, etc.).
|
||||
agents should call upstream tools directly (xreach, yt-dlp, mcporter, etc.).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
+70
-14
@@ -3,7 +3,7 @@ name: agent-reach
|
||||
description: >
|
||||
Give your AI agent eyes to see the entire internet. Install and configure
|
||||
upstream tools for Twitter/X, Reddit, YouTube, GitHub, Bilibili, XiaoHongShu,
|
||||
Douyin, LinkedIn, Boss直聘, RSS, and any web page — then call them directly.
|
||||
Douyin, LinkedIn, Boss直聘, WeChat (微信公众号), RSS, and any web page — then call them directly.
|
||||
Use when: (1) setting up platform access tools for the first time,
|
||||
(2) checking which platforms are available,
|
||||
(3) user asks to configure/enable a platform channel.
|
||||
@@ -23,7 +23,7 @@ agent-reach install --env=auto
|
||||
agent-reach doctor
|
||||
```
|
||||
|
||||
`install` auto-detects your environment and installs core dependencies (Node.js, mcporter, bird CLI, gh CLI, yt-dlp, feedparser). Run `doctor` to see what's active.
|
||||
`install` auto-detects your environment and installs core dependencies (Node.js, mcporter, xreach CLI, gh CLI, yt-dlp, feedparser). Run `doctor` to see what's active.
|
||||
|
||||
## Management
|
||||
|
||||
@@ -55,6 +55,8 @@ When a user asks to configure/enable any channel:
|
||||
|
||||
### Cookie 导入(所有需要登录的平台通用)
|
||||
|
||||
> ⚠️ **重要提醒:** 使用 Cookie 登录的平台存在封号风险,请提醒用户务必使用**专用小号**。
|
||||
|
||||
所有需要 Cookie 的平台(Twitter、小红书等),**优先使用 Cookie-Editor 导入**:
|
||||
|
||||
1. 用户在自己的浏览器登录对应平台
|
||||
@@ -74,23 +76,28 @@ When a user asks to configure/enable any channel:
|
||||
|
||||
## Using Upstream Tools Directly
|
||||
|
||||
After `agent-reach install`, call the upstream tools directly. No need for `agent-reach read` or `agent-reach search`.
|
||||
After `agent-reach install`, call the upstream tools directly.
|
||||
|
||||
### Twitter/X (bird CLI)
|
||||
> **Note:** `agent-reach` is an installer and config tool — it does NOT have `read`, `search`, or content-fetching commands. Use the upstream tools below instead.
|
||||
|
||||
### Twitter/X (xreach CLI)
|
||||
|
||||
```bash
|
||||
# Search tweets
|
||||
bird search "query" --json -n 10
|
||||
xreach search "query" --json -n 10
|
||||
|
||||
# Read a specific tweet
|
||||
bird read https://x.com/user/status/123 --json
|
||||
xreach tweet https://x.com/user/status/123 --json
|
||||
|
||||
# Read a user's timeline
|
||||
bird timeline @username --json -n 20
|
||||
xreach tweets @username --json -n 20
|
||||
```
|
||||
|
||||
### YouTube (yt-dlp)
|
||||
|
||||
> ⚠️ yt-dlp 需要 JS runtime 才能下载 YouTube。`agent-reach install` 会自动配置 Node.js 作为 runtime。
|
||||
> 如果遇到 "Sign in to confirm you're not a bot",是 IP 被 YouTube 反爬,换代理或加 cookies。
|
||||
|
||||
```bash
|
||||
# Get video metadata
|
||||
yt-dlp --dump-json "https://www.youtube.com/watch?v=xxx"
|
||||
@@ -101,16 +108,24 @@ yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --skip-download -
|
||||
|
||||
# Search (yt-dlp ytsearch)
|
||||
yt-dlp --dump-json "ytsearch5:query"
|
||||
|
||||
# If "no JS runtime" warning: ensure Node.js is installed, then run:
|
||||
# mkdir -p ~/.config/yt-dlp && echo "--js-runtimes node" >> ~/.config/yt-dlp/config
|
||||
```
|
||||
|
||||
### Bilibili (yt-dlp)
|
||||
|
||||
> ⚠️ 服务器 IP 可能被 Bilibili 拦截(412 错误)。建议通过代理访问,或加 `--cookies-from-browser chrome`。
|
||||
|
||||
```bash
|
||||
# Get video metadata
|
||||
yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx"
|
||||
|
||||
# Download subtitles
|
||||
yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --convert-subs vtt --skip-download -o "/tmp/%(id)s" "URL"
|
||||
|
||||
# If blocked (412 / login required):
|
||||
yt-dlp --cookies-from-browser chrome --dump-json "URL"
|
||||
```
|
||||
|
||||
### Reddit (JSON API)
|
||||
@@ -130,20 +145,27 @@ Note: On servers, Reddit may block your IP. Use proxy or search via Exa instead.
|
||||
|
||||
### 小红书 / XiaoHongShu (mcporter + xiaohongshu-mcp)
|
||||
|
||||
> ⚠️ 需要登录。使用 Cookie-Editor 导入 cookies 或扫码登录。
|
||||
|
||||
```bash
|
||||
# Search notes
|
||||
# 搜索笔记
|
||||
mcporter call 'xiaohongshu.search_feeds(keyword: "query")'
|
||||
|
||||
# Read a note
|
||||
# 获取笔记详情(含评论)
|
||||
mcporter call 'xiaohongshu.get_feed_detail(feed_id: "xxx", xsec_token: "yyy")'
|
||||
|
||||
# Get comments
|
||||
mcporter call 'xiaohongshu.get_feed_comments(feed_id: "xxx", xsec_token: "yyy")'
|
||||
# 获取全部评论
|
||||
mcporter call 'xiaohongshu.get_feed_detail(feed_id: "xxx", xsec_token: "yyy", load_all_comments: true)'
|
||||
|
||||
# Post a note
|
||||
mcporter call 'xiaohongshu.create_image_feed(title: "标题", desc: "内容", image_paths: ["/path/to/img.jpg"])'
|
||||
# 发布图文笔记
|
||||
mcporter call 'xiaohongshu.publish_content(title: "标题", content: "正文", images: ["/path/to/img.jpg"], tags: ["美食"])'
|
||||
|
||||
# 发布视频笔记
|
||||
mcporter call 'xiaohongshu.publish_with_video(title: "标题", content: "正文", video: "/path/to/video.mp4", tags: ["vlog"])'
|
||||
```
|
||||
|
||||
其他功能(点赞、收藏、评论、用户主页等):`npx mcporter list xiaohongshu`
|
||||
|
||||
### 抖音 / Douyin (mcporter + douyin-mcp-server)
|
||||
|
||||
```bash
|
||||
@@ -231,6 +253,40 @@ mcporter call 'bosszhipin.get_job_detail_tool(job_url: "https://www.zhipin.com/j
|
||||
|
||||
Fallback: `curl -s "https://r.jina.ai/https://www.zhipin.com/job_detail/xxx"`
|
||||
|
||||
### 微信公众号 (wechat-article-for-ai + miku_ai)
|
||||
|
||||
**Search** (miku_ai — Sogou WeChat search):
|
||||
|
||||
```python
|
||||
# Search WeChat articles by keyword
|
||||
python3 -c "
|
||||
import asyncio
|
||||
from miku_ai import get_wexin_article
|
||||
|
||||
async def search():
|
||||
articles = await get_wexin_article('AI Agent', 5)
|
||||
for a in articles:
|
||||
print(f'{a[\"title\"]} | {a[\"source\"]} | {a[\"date\"]}')
|
||||
print(f' {a[\"url\"]}')
|
||||
|
||||
asyncio.run(search())
|
||||
"
|
||||
```
|
||||
|
||||
**Read** (Camoufox — stealth Firefox, bypasses WeChat anti-bot):
|
||||
|
||||
```bash
|
||||
# Read a WeChat article (returns Markdown with images)
|
||||
cd /path/to/wechat-article-for-ai && python3 main.py "https://mp.weixin.qq.com/s/ARTICLE_ID"
|
||||
|
||||
# Run as MCP server (for AI agent integration)
|
||||
python3 mcp_server.py
|
||||
```
|
||||
|
||||
Typical agent workflow: search → get URLs → immediately read full content.
|
||||
|
||||
Note: WeChat articles require a real browser to render. Jina Reader and curl cannot read them.
|
||||
|
||||
### RSS (feedparser)
|
||||
|
||||
```python
|
||||
@@ -246,7 +302,7 @@ for e in d.entries[:5]:
|
||||
|
||||
### Twitter "fetch failed"
|
||||
|
||||
bird CLI uses Node.js native `fetch()`, which doesn't respect `HTTP_PROXY`. Solutions:
|
||||
xreach CLI uses Node.js `undici`, which doesn't respect `HTTP_PROXY`. Solutions:
|
||||
1. Ensure `undici` is installed: `npm install -g undici`
|
||||
2. Configure proxy: `agent-reach configure proxy http://user:pass@ip:port`
|
||||
3. If still failing, use transparent proxy (Clash TUN, Proxifier)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Agent Reach tested dependency set
|
||||
# Usage:
|
||||
# pip install -c constraints.txt -e .[dev]
|
||||
|
||||
requests==2.32.5
|
||||
feedparser==6.0.12
|
||||
python-dotenv==1.2.1
|
||||
loguru==0.7.3
|
||||
PyYAML==6.0.3
|
||||
rich==14.3.2
|
||||
yt-dlp==2025.5.22
|
||||
|
||||
pytest==8.0.0
|
||||
ruff==0.15.1
|
||||
mypy==1.19.1
|
||||
types-requests==2.32.4.20260107
|
||||
types-PyYAML==6.0.12.20250915
|
||||
+12
-10
@@ -45,7 +45,7 @@ Copy that to your Agent. A few minutes later, it can read tweets, search Reddit,
|
||||
|---|---|
|
||||
| 💰 **Completely free** | All tools are open source, all APIs are free. The only possible cost is a server proxy ($1/month) — local computers don't need one |
|
||||
| 🔒 **Privacy safe** | Cookies stay local. Never uploaded. Fully open source — audit anytime |
|
||||
| 🔄 **Kept up to date** | Upstream tools (yt-dlp, bird, Jina Reader, etc.) are tracked and updated regularly |
|
||||
| 🔄 **Kept up to date** | Upstream tools (yt-dlp, xreach, Jina Reader, etc.) are tracked and updated regularly |
|
||||
| 🤖 **Works with any Agent** | Claude Code, OpenClaw, Cursor, Windsurf… any Agent that can run commands |
|
||||
| 🩺 **Built-in diagnostics** | `agent-reach doctor` — one command shows what works, what doesn't, and how to fix it |
|
||||
|
||||
@@ -56,11 +56,12 @@ Copy that to your Agent. A few minutes later, it can read tweets, search Reddit,
|
||||
| Platform | Capabilities | Setup | Notes |
|
||||
|----------|-------------|:-----:|-------|
|
||||
| 🌐 **Web** | Read | Zero config | Any URL → clean Markdown ([Jina Reader](https://github.com/jina-ai/reader) ⭐9.8K) |
|
||||
| 🐦 **Twitter/X** | Read · Search | Zero config / Cookie | Single tweets readable out of the box. Cookie unlocks search, timeline, posting ([bird](https://github.com/steipete/bird)) |
|
||||
| 🐦 **Twitter/X** | Read · Search | Zero config / Cookie | Single tweets readable out of the box. Cookie unlocks search, timeline, posting ([xreach](https://www.npmjs.com/package/xreach-cli)) |
|
||||
| 📕 **XiaoHongShu** | Read · Search · **Post · Comment · Like** | mcporter | Via [xiaohongshu-mcp](https://github.com/user/xiaohongshu-mcp) internal API, install and go |
|
||||
| 🎵 **Douyin** | Video parsing · Watermark-free download | mcporter | Via [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server), no login needed |
|
||||
| 💼 **LinkedIn** | Jina Reader (public pages) | Full profiles, companies, job search | Tell your Agent "help me set up LinkedIn" |
|
||||
| 🏢 **Boss直聘** | Jina Reader (job pages) | Job search, greet recruiters | Tell your Agent "help me set up Boss直聘" |
|
||||
| 💬 **WeChat Articles** | — | Search + read WeChat Official Account articles (full Markdown) | `pip install miku_ai camoufox[geoip]` |
|
||||
| 🔍 **Web Search** | Search | Auto-configured | Auto-configured during install, free, no API key ([Exa](https://exa.ai) via [mcporter](https://github.com/nicepkg/mcporter)) |
|
||||
| 📦 **GitHub** | Read · Search | Zero config | [gh CLI](https://cli.github.com) powered. Public repos work immediately. `gh auth login` unlocks Fork, Issue, PR |
|
||||
| 📺 **YouTube** | Read · **Search** | Zero config | Subtitles + search across 1800+ video sites ([yt-dlp](https://github.com/yt-dlp/yt-dlp) ⭐148K) |
|
||||
@@ -112,7 +113,7 @@ No configuration needed — just tell your Agent:
|
||||
- "Read this link" → `curl https://r.jina.ai/URL` for any web page
|
||||
- "What's this GitHub repo about?" → `gh repo view owner/repo`
|
||||
- "What does this video cover?" → `yt-dlp --dump-json URL` for subtitles
|
||||
- "Read this tweet" → `bird read URL --json`
|
||||
- "Read this tweet" → `xreach tweet URL --json`
|
||||
- "Subscribe to this RSS" → `feedparser` to parse feeds
|
||||
- "Search GitHub for LLM frameworks" → `gh search repos "LLM framework"`
|
||||
|
||||
@@ -172,7 +173,7 @@ Every time you spin up a new Agent, you spend time finding tools, installing dep
|
||||
|
||||
Agent Reach does one simple thing: **it makes those tool selection and configuration decisions for you.**
|
||||
|
||||
After installation, your Agent calls the upstream tools directly (bird CLI, yt-dlp, mcporter, gh CLI, etc.) — no wrapper layer in between.
|
||||
After installation, your Agent calls the upstream tools directly (xreach CLI, yt-dlp, mcporter, gh CLI, etc.) — no wrapper layer in between.
|
||||
|
||||
### 🔌 Every Channel is Pluggable
|
||||
|
||||
@@ -181,7 +182,7 @@ Each platform maps to an upstream tool. **Don't like one? Swap it out.**
|
||||
```
|
||||
channels/
|
||||
├── web.py → Jina Reader ← swap to Firecrawl, Crawl4AI…
|
||||
├── twitter.py → bird ← swap to Nitter, official API…
|
||||
├── twitter.py → xreach ← swap to Nitter, official API…
|
||||
├── youtube.py → yt-dlp ← swap to YouTube API, Whisper…
|
||||
├── github.py → gh CLI ← swap to REST API, PyGithub…
|
||||
├── bilibili.py → yt-dlp ← swap to bilibili-api…
|
||||
@@ -202,7 +203,7 @@ Each channel file only checks whether its upstream tool is installed and working
|
||||
| Scenario | Tool | Why |
|
||||
|----------|------|-----|
|
||||
| Read web pages | [Jina Reader](https://github.com/jina-ai/reader) | 9.8K stars, free, no API key needed |
|
||||
| Read tweets | [bird](https://github.com/steipete/bird) | Cookie auth, free. Official API is pay-per-use ($0.005/post read) |
|
||||
| Read tweets | [xreach](https://www.npmjs.com/package/xreach-cli) | Cookie auth, free. Official API is pay-per-use ($0.005/post read) |
|
||||
| Video subtitles + search | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 148K stars, YouTube + Bilibili + 1800 sites |
|
||||
| Search the web | [Exa](https://exa.ai) via [mcporter](https://github.com/nicepkg/mcporter) | AI semantic search, MCP integration, no API key |
|
||||
| GitHub | [gh CLI](https://cli.github.com) | Official tool, full API after auth |
|
||||
@@ -211,6 +212,7 @@ Each channel file only checks whether its upstream tool is installed and working
|
||||
| Douyin | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP server, no login needed, video parsing + watermark-free download |
|
||||
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | 900+ stars, MCP server, browser automation |
|
||||
| Boss直聘 | [mcp-bosszp](https://github.com/mucsbr/mcp-bosszp) | MCP server, job search + recruiter greeting |
|
||||
| WeChat Articles | [wechat-article-for-ai](https://github.com/bzd6661/wechat-article-for-ai) + [miku_ai](https://github.com/GobinFan/Miku_Spider) | Stealth browser for full article reading + Sogou search |
|
||||
|
||||
> 📌 These are the *current* choices. Don't like one? Swap out the file. That's the whole point of scaffolding.
|
||||
|
||||
@@ -233,7 +235,7 @@ This project was entirely vibe-coded 🎸 There might be rough edges here and th
|
||||
<details>
|
||||
<summary><strong>How to search Twitter/X with AI agent without paying for API?</strong></summary>
|
||||
|
||||
Agent Reach uses the [bird CLI](https://www.npmjs.com/package/@steipete/bird) with cookie-based authentication — completely free, no Twitter API subscription needed. After installing Agent Reach, export your Twitter cookies using the Cookie-Editor Chrome extension, run `agent-reach configure twitter-cookies "your_cookies"`, and your agent can search with `bird search "query" --json`.
|
||||
Agent Reach uses the [xreach CLI](https://www.npmjs.com/package/xreach-cli) with cookie-based authentication — completely free, no Twitter API subscription needed. After installing Agent Reach, export your Twitter cookies using the Cookie-Editor Chrome extension, run `agent-reach configure twitter-cookies "your_cookies"`, and your agent can search with `xreach search "query" --json`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -257,13 +259,13 @@ Yes! Agent Reach is an installer + configuration tool. Any AI coding agent that
|
||||
<details>
|
||||
<summary><strong>Is Agent Reach free? Any API costs?</strong></summary>
|
||||
|
||||
100% free and open source. All backends (bird CLI, yt-dlp, Jina Reader, Exa) are free tools that don't require paid API keys. The only optional cost is a residential proxy (~$1/month) if you need Reddit/Bilibili access from a server.
|
||||
100% free and open source. All backends (xreach CLI, yt-dlp, Jina Reader, Exa) are free tools that don't require paid API keys. The only optional cost is a residential proxy (~$1/month) if you need Reddit/Bilibili access from a server.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Free alternative to Twitter API for web scraping?</strong></summary>
|
||||
|
||||
Agent Reach uses bird CLI which accesses Twitter via cookie auth — same as your browser session. No API fees, no rate limit tiers, no developer account needed. Supports search, read tweets, read profiles, and timelines.
|
||||
Agent Reach uses xreach CLI which accesses Twitter via cookie auth — same as your browser session. No API fees, no rate limit tiers, no developer account needed. Supports search, read tweets, read profiles, and timelines.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -282,7 +284,7 @@ Install douyin-mcp-server, then your agent can use `mcporter call 'douyin.parse_
|
||||
|
||||
## Credits
|
||||
|
||||
[Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [bird](https://github.com/steipete/bird) · [Exa](https://exa.ai) · [feedparser](https://github.com/kurtmckee/feedparser) · [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) · [mcp-bosszp](https://github.com/mucsbr/mcp-bosszp)
|
||||
[Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [xreach](https://www.npmjs.com/package/xreach-cli) · [Exa](https://exa.ai) · [feedparser](https://github.com/kurtmckee/feedparser) · [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) · [mcp-bosszp](https://github.com/mucsbr/mcp-bosszp)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Dependency Locking Guide
|
||||
|
||||
Agent Reach uses `constraints.txt` as a reproducible dependency baseline.
|
||||
|
||||
## Why
|
||||
|
||||
- Keep local/CI dependency graph stable
|
||||
- Reduce "works on my machine" drift
|
||||
- Make regression results easier to compare
|
||||
|
||||
## Install with constraints
|
||||
|
||||
```bash
|
||||
pip install -c constraints.txt -e .[dev]
|
||||
```
|
||||
|
||||
## Update workflow
|
||||
|
||||
1. Update `pyproject.toml` dependency ranges as needed.
|
||||
2. Validate against latest compatible versions locally.
|
||||
3. Update pinned versions in `constraints.txt`.
|
||||
4. Run validation:
|
||||
|
||||
```bash
|
||||
pytest -q
|
||||
ruff check agent_reach tests
|
||||
mypy agent_reach
|
||||
```
|
||||
|
||||
5. Open PR with dependency and validation notes.
|
||||
+8
-6
@@ -20,7 +20,7 @@ Copy this to your AI Agent:
|
||||
|
||||
### Goal
|
||||
|
||||
Install Agent Reach and all upstream tools so your user's agent has full internet access. After installation, you'll use the upstream tools directly (bird CLI, yt-dlp, mcporter, gh CLI, etc.) — Agent Reach is just the installer and health checker.
|
||||
Install Agent Reach and all upstream tools so your user's agent has full internet access. After installation, you'll use the upstream tools directly (xreach CLI, yt-dlp, mcporter, gh CLI, etc.) — Agent Reach is just the installer and health checker.
|
||||
|
||||
### ⚠️ Boundaries
|
||||
|
||||
@@ -41,7 +41,7 @@ pip install https://github.com/Panniantong/agent-reach/archive/main.zip
|
||||
agent-reach install --env=auto
|
||||
```
|
||||
|
||||
This auto-installs system dependencies (gh CLI, Node.js, mcporter, bird), configures Exa search, detects environment, and tests all channels.
|
||||
This auto-installs system dependencies (gh CLI, Node.js, mcporter, xreach), configures Exa search, detects environment, and tests all channels.
|
||||
|
||||
**Safe mode (no automatic system changes):**
|
||||
|
||||
@@ -80,7 +80,9 @@ Only ask the user when you genuinely need their input (credentials, permissions,
|
||||
|
||||
Some channels need credentials only the user can provide. Based on the doctor output, ask for what's missing:
|
||||
|
||||
> 🔒 **Security tip:** For platforms that need cookies (Twitter, XiaoHongShu), we recommend using a **dedicated/secondary account** rather than your main account. Cookie-based auth grants full account access — using a separate account limits the blast radius if credentials are ever compromised.
|
||||
> 🔒 **Security tip:** For platforms that need cookies (Twitter, XiaoHongShu), we recommend using a **dedicated/secondary account** rather than your main account. Cookie-based auth carries two risks:
|
||||
> 1. **Account ban** — platforms may detect non-browser API calls and restrict or ban the account
|
||||
> 2. **Credential exposure** — cookies grant full account access; using a secondary account limits the blast radius if credentials are ever compromised
|
||||
|
||||
> 🍪 **Cookie 导入(所有需要登录的平台通用):**
|
||||
>
|
||||
@@ -101,9 +103,9 @@ agent-reach configure twitter-cookies "PASTED_STRING"
|
||||
|
||||
> **代理说明(中国大陆等需要翻墙的网络环境):**
|
||||
>
|
||||
> bird CLI 使用 Node.js 原生 `fetch()`,默认不走系统代理(`HTTP_PROXY`/`HTTPS_PROXY`)。
|
||||
> xreach CLI 使用 Node.js 原生 `fetch()`,默认不走系统代理(`HTTP_PROXY`/`HTTPS_PROXY`)。
|
||||
> Agent Reach 已内置解决方案:安装时会自动安装 `undici`(`npm install -g undici`),
|
||||
> 运行 bird 时自动注入 `EnvHttpProxyAgent`,让 fetch 走用户配置的代理。
|
||||
> 运行 xreach 时自动注入 `EnvHttpProxyAgent`,让 fetch 走用户配置的代理。
|
||||
>
|
||||
> **你(Agent)需要做的:**
|
||||
> 1. 确认 `undici` 已全局安装:`npm list -g undici`,没有就装:`npm install -g undici`
|
||||
@@ -275,7 +277,7 @@ After installation, use upstream tools directly. See SKILL.md for the full comma
|
||||
|
||||
| Platform | Upstream Tool | Example |
|
||||
|----------|--------------|---------|
|
||||
| Twitter/X | `bird` | `bird search "query" --json` |
|
||||
| Twitter/X | `xreach` | `xreach search "query" --json` |
|
||||
| YouTube | `yt-dlp` | `yt-dlp --dump-json URL` |
|
||||
| Bilibili | `yt-dlp` | `yt-dlp --dump-json URL` |
|
||||
| Reddit | `curl` | `curl -s "https://reddit.com/r/xxx.json"` |
|
||||
|
||||
+39
-64
@@ -1,69 +1,44 @@
|
||||
# Troubleshooting / 常见问题
|
||||
# 常见问题排查
|
||||
|
||||
## Twitter/X: bird CLI "fetch failed"
|
||||
## Twitter/X: xreach CLI "fetch failed"
|
||||
|
||||
**症状:** `bird whoami` 或 `bird search` 返回 "fetch failed"
|
||||
**症状:** `xreach search` 或其他命令返回 "fetch failed"
|
||||
|
||||
**原因:** bird CLI 使用 Node.js 原生 `fetch()` 发请求,而 Node.js 的 fetch **不走系统代理**(不读取 `HTTP_PROXY`/`HTTPS_PROXY` 环境变量)。如果你的网络环境需要代理才能访问 x.com,bird 就连不上。
|
||||
|
||||
**解决方案(按推荐顺序):**
|
||||
|
||||
### 方案 1:使用透明代理 / TUN 模式(推荐)
|
||||
|
||||
让代理工具接管所有网络流量,这样 bird 的 fetch 也会走代理:
|
||||
|
||||
- **Clash Verge / Clash for Windows:** 开启 TUN 模式或系统代理
|
||||
- **Proxifier(Windows):** 添加规则让 Node.js 进程走代理
|
||||
- **macOS:** 在 Surge/ClashX Pro 中开启增强模式
|
||||
|
||||
### 方案 2:验证 Cookie 有效性
|
||||
|
||||
确认 Cookie 没过期:
|
||||
|
||||
1. 在浏览器里正常登录 x.com
|
||||
2. 用 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) 重新导出 Header String
|
||||
3. 重新配置:`agent-reach configure twitter-cookies "新的Cookie"`
|
||||
|
||||
### 方案 3:不用 bird,用 Exa 搜索替代
|
||||
|
||||
bird 不可用时,可以直接用 Exa 搜索 Twitter 内容:
|
||||
|
||||
```bash
|
||||
mcporter call 'exa.web_search_exa(query: "site:x.com query", numResults: 10)'
|
||||
```
|
||||
|
||||
### 方案 4:配置 Node.js 全局代理(高级)
|
||||
|
||||
安装 `global-agent` 让 Node.js 的 fetch 走代理:
|
||||
|
||||
```bash
|
||||
npm install -g global-agent
|
||||
```
|
||||
|
||||
然后在运行 bird 前设置环境变量:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
export GLOBAL_AGENT_HTTP_PROXY=http://127.0.0.1:7890
|
||||
export NODE_OPTIONS="--require global-agent/bootstrap"
|
||||
bird search "test"
|
||||
|
||||
# Windows (PowerShell)
|
||||
$env:GLOBAL_AGENT_HTTP_PROXY = "http://127.0.0.1:7890"
|
||||
$env:NODE_OPTIONS = "--require global-agent/bootstrap"
|
||||
bird search "test"
|
||||
```
|
||||
|
||||
> ⚠️ 注意:这个方案需要每次运行 bird 前都设置环境变量,不太方便。推荐用方案 1。
|
||||
|
||||
---
|
||||
|
||||
## Boss直聘: "访问行为异常"
|
||||
|
||||
**症状:** mcp-bosszp 登录成功,但 API 请求返回"您的访问行为异常"
|
||||
|
||||
**原因:** Boss直聘的反爬机制会检测请求指纹(不只是 IP),Python requests 库的特征与真实浏览器不同。
|
||||
**原因:** xreach CLI 使用 Node.js 的 `undici` 库发请求。如果你的网络环境需要代理才能访问 x.com,需要明确传入代理参数。
|
||||
|
||||
**解决方案:**
|
||||
- **本地电脑:** 正常使用,一般不会被拦
|
||||
- **服务器:** 使用 Jina Reader 读取职位页面 + Exa 搜索职位信息作为替代
|
||||
|
||||
### 方案 1:使用 --proxy 参数
|
||||
|
||||
```bash
|
||||
xreach search "test" --auth-token "$AUTH_TOKEN" --ct0 "$CT0" --proxy "http://user:pass@host:port"
|
||||
```
|
||||
|
||||
### 方案 2:使用全局代理工具
|
||||
|
||||
让代理工具接管所有网络流量,这样 xreach 的请求也会走代理:
|
||||
|
||||
```bash
|
||||
# macOS — ClashX / Surge 开启"增强模式"
|
||||
# Linux — proxychains 或 tun2socks
|
||||
proxychains xreach search "test" -n 1
|
||||
```
|
||||
|
||||
### 方案 3:不用 xreach,用 Exa 搜索替代
|
||||
|
||||
xreach 不可用时,可以直接用 Exa 搜索 Twitter 内容:
|
||||
|
||||
```bash
|
||||
mcporter call 'exa.web_search_exa(query: "site:x.com 搜索词", numResults: 5)'
|
||||
```
|
||||
|
||||
### 方案 4:设置 HTTP_PROXY 环境变量
|
||||
|
||||
```bash
|
||||
export HTTP_PROXY="http://127.0.0.1:7890"
|
||||
export HTTPS_PROXY="http://127.0.0.1:7890"
|
||||
|
||||
xreach search "test"
|
||||
```
|
||||
|
||||
> ⚠️ 注意:Node.js 原生 fetch 不一定读取这些环境变量,推荐用方案 1 的 --proxy 参数。
|
||||
|
||||
+25
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-reach"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
description = "Give your AI Agent eyes to see the entire internet. Search + Read 10+ platforms."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
@@ -41,6 +41,13 @@ dependencies = [
|
||||
browser = ["playwright>=1.40"]
|
||||
cookies = ["browser-cookie3>=0.19"]
|
||||
all = ["playwright>=1.40", "mcp[cli]>=1.0", "browser-cookie3>=0.19"]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.8",
|
||||
"mypy>=1.12",
|
||||
"types-requests>=2.32",
|
||||
"types-PyYAML>=6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
agent-reach = "agent_reach.cli:main"
|
||||
@@ -60,3 +67,20 @@ packages = ["agent_reach"]
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"agent_reach/guides" = "agent_reach/guides"
|
||||
"agent_reach/skill" = "agent_reach/skill"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_unused_configs = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
check_untyped_defs = true
|
||||
ignore_missing_imports = true
|
||||
exclude = ["^tests/"]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Contract tests for channel adapters."""
|
||||
|
||||
from agent_reach.channels import get_all_channels
|
||||
from agent_reach.config import Config
|
||||
|
||||
|
||||
def test_channel_registry_contract():
|
||||
channels = get_all_channels()
|
||||
assert channels, "channel registry must not be empty"
|
||||
names = [ch.name for ch in channels]
|
||||
assert len(names) == len(set(names)), "channel names must be unique"
|
||||
|
||||
for ch in channels:
|
||||
assert isinstance(ch.name, str) and ch.name
|
||||
assert isinstance(ch.description, str) and ch.description
|
||||
assert isinstance(ch.backends, list)
|
||||
assert ch.tier in {0, 1, 2}
|
||||
|
||||
|
||||
def test_channel_check_contract_with_minimal_runtime(monkeypatch, tmp_path):
|
||||
# Keep contract tests deterministic by simulating "deps mostly absent".
|
||||
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
||||
config = Config(config_path=tmp_path / "config.yaml")
|
||||
|
||||
for ch in get_all_channels():
|
||||
status, message = ch.check(config)
|
||||
assert status in {"ok", "warn", "off", "error"}
|
||||
assert isinstance(message, str) and message.strip()
|
||||
|
||||
|
||||
def test_channel_can_handle_contract():
|
||||
url_samples = {
|
||||
"github": "https://github.com/panniantong/agent-reach",
|
||||
"twitter": "https://x.com/user/status/1",
|
||||
"youtube": "https://youtube.com/watch?v=abc",
|
||||
"reddit": "https://reddit.com/r/python",
|
||||
"bilibili": "https://www.bilibili.com/video/BV1xx411",
|
||||
"xiaohongshu": "https://www.xiaohongshu.com/explore/123",
|
||||
"douyin": "https://www.douyin.com/video/123",
|
||||
"linkedin": "https://www.linkedin.com/in/test",
|
||||
"bosszhipin": "https://www.zhipin.com/web/geek/job?query=python",
|
||||
"rss": "https://example.com/feed.xml",
|
||||
"exa_search": "https://example.com",
|
||||
"web": "https://example.com",
|
||||
}
|
||||
for ch in get_all_channels():
|
||||
sample = url_samples.get(ch.name, "https://example.com")
|
||||
result = ch.can_handle(sample)
|
||||
assert isinstance(result, bool)
|
||||
+6
-99
@@ -1,114 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the channel system."""
|
||||
"""Tests for channel registry basics."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from agent_reach.channels import get_channel_for_url, get_channel, get_all_channels
|
||||
from agent_reach.channels.base import ReadResult, SearchResult
|
||||
from agent_reach.channels import get_all_channels, get_channel
|
||||
|
||||
|
||||
class TestChannelRouting:
|
||||
def test_github_url(self):
|
||||
ch = get_channel_for_url("https://github.com/openai/gpt-4")
|
||||
assert ch.name == "github"
|
||||
|
||||
def test_twitter_url(self):
|
||||
ch = get_channel_for_url("https://x.com/elonmusk/status/123")
|
||||
assert ch.name == "twitter"
|
||||
|
||||
def test_youtube_url(self):
|
||||
ch = get_channel_for_url("https://youtube.com/watch?v=abc")
|
||||
assert ch.name == "youtube"
|
||||
|
||||
def test_reddit_url(self):
|
||||
ch = get_channel_for_url("https://reddit.com/r/test")
|
||||
assert ch.name == "reddit"
|
||||
|
||||
def test_bilibili_url(self):
|
||||
ch = get_channel_for_url("https://bilibili.com/video/BV1xx")
|
||||
assert ch.name == "bilibili"
|
||||
|
||||
def test_rss_url(self):
|
||||
ch = get_channel_for_url("https://example.com/feed.xml")
|
||||
assert ch.name == "rss"
|
||||
|
||||
def test_generic_url_fallback(self):
|
||||
ch = get_channel_for_url("https://example.com")
|
||||
assert ch.name == "web"
|
||||
|
||||
class TestChannelRegistry:
|
||||
def test_get_channel_by_name(self):
|
||||
ch = get_channel("github")
|
||||
assert ch is not None
|
||||
assert ch.name == "github"
|
||||
|
||||
def test_get_unknown_channel_returns_none(self):
|
||||
assert get_channel("not-exists") is None
|
||||
|
||||
def test_all_channels_registered(self):
|
||||
channels = get_all_channels()
|
||||
names = [ch.name for ch in channels]
|
||||
assert "web" in names
|
||||
assert "github" in names
|
||||
assert "twitter" in names
|
||||
|
||||
|
||||
class TestReadResult:
|
||||
def test_to_dict(self):
|
||||
r = ReadResult(title="Test", content="Body", url="https://example.com", platform="web")
|
||||
d = r.to_dict()
|
||||
assert d["title"] == "Test"
|
||||
assert d["content"] == "Body"
|
||||
assert d["platform"] == "web"
|
||||
|
||||
def test_to_dict_optional_fields(self):
|
||||
r = ReadResult(title="T", content="C", url="u", author="A", date="2025-01-01")
|
||||
d = r.to_dict()
|
||||
assert d["author"] == "A"
|
||||
assert d["date"] == "2025-01-01"
|
||||
|
||||
|
||||
class TestSearchResult:
|
||||
def test_to_dict(self):
|
||||
r = SearchResult(title="Test", url="https://example.com", snippet="A snippet")
|
||||
d = r.to_dict()
|
||||
assert d["title"] == "Test"
|
||||
assert d["snippet"] == "A snippet"
|
||||
|
||||
|
||||
class TestGitHubChannel:
|
||||
@patch("agent_reach.channels.github.requests.get")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"items": [{"full_name": "test/repo", "html_url": "https://github.com/test/repo",
|
||||
"description": "A test", "stargazers_count": 100, "forks_count": 10,
|
||||
"language": "Python", "updated_at": "2025-01-01"}]
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
ch = get_channel("github")
|
||||
results = await ch.search("test query")
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "test/repo"
|
||||
|
||||
|
||||
class TestExaSearch:
|
||||
@patch("agent_reach.channels.exa_search.requests.post")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search(self, mock_post):
|
||||
from agent_reach.config import Config
|
||||
config = Config(config_path="/tmp/test-exa-config.yaml")
|
||||
config.set("exa_api_key", "test-key")
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"results": [{"title": "Result", "url": "https://example.com",
|
||||
"text": "snippet", "publishedDate": "", "score": 0.9}]
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
ch = get_channel("exa_search")
|
||||
results = await ch.search("test", config=config)
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "Result"
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"""Tests for Agent Reach CLI."""
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import patch
|
||||
import agent_reach.cli as cli
|
||||
from agent_reach.cli import main
|
||||
|
||||
|
||||
@@ -27,3 +29,86 @@ class TestCLI:
|
||||
captured = capsys.readouterr()
|
||||
assert "Agent Reach" in captured.out
|
||||
assert "✅" in captured.out
|
||||
|
||||
|
||||
class TestCheckUpdateRetry:
|
||||
def test_retry_timeout_classification(self):
|
||||
sleeps = []
|
||||
|
||||
def fake_sleep(seconds):
|
||||
sleeps.append(seconds)
|
||||
|
||||
with patch("requests.get", side_effect=requests.exceptions.Timeout("timed out")):
|
||||
resp, err, attempts = cli._github_get_with_retry(
|
||||
"https://api.github.com/test",
|
||||
timeout=1,
|
||||
retries=3,
|
||||
sleeper=fake_sleep,
|
||||
)
|
||||
|
||||
assert resp is None
|
||||
assert err == "timeout"
|
||||
assert attempts == 3
|
||||
assert sleeps == [1, 2]
|
||||
|
||||
def test_retry_dns_classification(self):
|
||||
error = requests.exceptions.ConnectionError("getaddrinfo failed for api.github.com")
|
||||
with patch("requests.get", side_effect=error):
|
||||
resp, err, attempts = cli._github_get_with_retry(
|
||||
"https://api.github.com/test",
|
||||
retries=1,
|
||||
sleeper=lambda _x: None,
|
||||
)
|
||||
assert resp is None
|
||||
assert err == "dns"
|
||||
assert attempts == 1
|
||||
|
||||
def test_retry_rate_limit_then_success(self):
|
||||
sleeps = []
|
||||
|
||||
class R:
|
||||
def __init__(self, code, payload=None, headers=None):
|
||||
self.status_code = code
|
||||
self._payload = payload or {}
|
||||
self.headers = headers or {}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
sequence = [
|
||||
R(429, headers={"Retry-After": "3"}),
|
||||
R(200, payload={"tag_name": "v1.3.0"}),
|
||||
]
|
||||
|
||||
with patch("requests.get", side_effect=sequence):
|
||||
resp, err, attempts = cli._github_get_with_retry(
|
||||
"https://api.github.com/test",
|
||||
retries=3,
|
||||
sleeper=lambda s: sleeps.append(s),
|
||||
)
|
||||
|
||||
assert err is None
|
||||
assert resp is not None
|
||||
assert resp.status_code == 200
|
||||
assert attempts == 2
|
||||
assert sleeps == [3.0]
|
||||
|
||||
def test_classify_rate_limit_from_403(self):
|
||||
class R:
|
||||
status_code = 403
|
||||
headers = {"X-RateLimit-Remaining": "0"}
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {"message": "API rate limit exceeded"}
|
||||
|
||||
assert cli._classify_github_response_error(R()) == "rate_limit"
|
||||
|
||||
def test_check_update_reports_classified_error(self, capsys):
|
||||
with patch("agent_reach.cli._github_get_with_retry", return_value=(None, "timeout", 3)):
|
||||
result = cli._cmd_check_update()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert result == "error"
|
||||
assert "网络超时" in captured.out
|
||||
assert "已重试 3 次" in captured.out
|
||||
|
||||
+1
-8
@@ -2,6 +2,7 @@
|
||||
"""Tests for AgentReach core class."""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_reach.config import Config
|
||||
from agent_reach.core import AgentReach
|
||||
|
||||
@@ -16,14 +17,6 @@ class TestAgentReach:
|
||||
def test_init(self, eyes):
|
||||
assert eyes.config is not None
|
||||
|
||||
def test_detect_platform(self, eyes):
|
||||
assert eyes.detect_platform("https://github.com/test/repo") == "github"
|
||||
assert eyes.detect_platform("https://reddit.com/r/test") == "reddit"
|
||||
assert eyes.detect_platform("https://x.com/user/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://example.com") == "web"
|
||||
|
||||
def test_doctor(self, eyes):
|
||||
results = eyes.doctor()
|
||||
assert isinstance(results, dict)
|
||||
|
||||
@@ -15,7 +15,7 @@ class TestDoctor:
|
||||
def test_zero_config_channels_ok(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
assert results["web"]["status"] == "ok"
|
||||
assert results["github"]["status"] == "ok"
|
||||
assert results["github"]["status"] in ("ok", "warn") # warn if gh CLI not installed
|
||||
assert results["bilibili"]["status"] in ("ok", "warn") # warn on servers
|
||||
assert results["rss"]["status"] == "ok"
|
||||
|
||||
@@ -23,10 +23,11 @@ class TestDoctor:
|
||||
results = check_all(tmp_config)
|
||||
assert results["exa_search"]["status"] == "off"
|
||||
|
||||
def test_exa_on_with_key(self, tmp_config):
|
||||
def test_exa_key_does_not_force_enabled(self, tmp_config):
|
||||
# Exa availability is determined by mcporter runtime/config state.
|
||||
tmp_config.set("exa_api_key", "test-key")
|
||||
results = check_all(tmp_config)
|
||||
assert results["exa_search"]["status"] == "ok"
|
||||
assert results["exa_search"]["status"] in ("off", "ok")
|
||||
|
||||
def test_format_report(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
|
||||
Reference in New Issue
Block a user