32 Commits

Author SHA1 Message Date
Pnant ca29c4fee5 release: v1.3.0 (#70)
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
New:
- WeChat Official Account channel (search + read)
  - Search via miku_ai (Sogou WeChat search)
  - Read via Camoufox (stealth Firefox, bypasses anti-bot)
- 13 channels total

Fixed:
- Windows encoding: text=True → encoding='utf-8' in all subprocess calls (#64)
- Windows .cmd resolution: use shutil.which() paths (#64)

Docs:
- Clarified agent-reach has no read/search commands (#58, #59)
- Fixed xiaohongshu API function names in SKILL.md (#65)
- Added CONTRIBUTING.md (#62)
- Updated README (zh + en) with WeChat channel

Version bumps: pyproject.toml, __init__.py, test_cli.py

Co-authored-by: Panniantong <panniantong@users.noreply.github.com>
2026-03-04 18:18:18 +08:00
Pnant 49d05b7023 docs: add WeChat channel to README (zh + en) (#69)
Co-authored-by: Panniantong <panniantong@users.noreply.github.com>
2026-03-04 18:06:47 +08:00
Pnant 476935be92 feat: add WeChat search via miku_ai (sogou) (#68)
- wechat channel now supports both search and read
- Search: miku_ai (sogou weixin search) → article list with URLs
- Read: camoufox (stealth Firefox) → full Markdown content
- Doctor shows granular status (search only / read only / both)
- SKILL.md updated with search + read workflow examples

Co-authored-by: Panniantong <panniantong@users.noreply.github.com>
2026-03-04 18:02:05 +08:00
Pnant 4f4ad99d49 feat: add WeChat Official Account channel (#54) (#67)
New channel: wechat (微信公众号文章)
- Backend: wechat-article-for-ai (Camoufox stealth browser)
- Can read mp.weixin.qq.com article URLs → clean Markdown
- Bypasses WeChat anti-bot detection via stealth Firefox
- Doctor checks for camoufox package availability
- SKILL.md updated with usage examples

Co-authored-by: Panniantong <panniantong@users.noreply.github.com>
2026-03-04 17:53:39 +08:00
Sense_wang 561b248a2b docs: add CONTRIBUTING.md with development guidelines (#62)
Add contribution guidelines including:
- Development setup instructions
- Code style guidelines (ruff, mypy, pytest)
- Channel development guide
- PR guidelines
- Issue reporting template
- Bilingual (EN/CN) documentation

Co-authored-by: haosenwang1018 <haosenwang1018@users.noreply.github.com>
2026-03-04 17:30:15 +08:00
Pnant a91a74d880 fix: Windows encoding + .cmd subprocess resolution (#64) (#66)
- Replace text=True with encoding='utf-8', errors='replace' in all
  subprocess.run calls (channels + cli.py) to fix GBK decode errors
  on Chinese Windows systems
- Use shutil.which() resolved paths in channel subprocess calls to
  handle Windows .cmd wrappers (npm global installs)

Co-authored-by: Panniantong <panniantong@users.noreply.github.com>
2026-03-04 17:30:11 +08:00
Yves 7434c3cb9f docs: fix xiaohongshu API in SKILL.md — replace non-existent functions with correct ones (#65) 2026-03-04 17:18:13 +08:00
Pnant 1a61a2cc81 docs: clarify agent-reach has no read/search commands (#59)
- Remove dead '# ── read ──' placeholder comment in cli.py
- Reword SKILL.md to make it explicit that agent-reach is an
  installer/config tool, not a content fetcher
- Addresses confusion from issue #58

Co-authored-by: Panniantong <panniantong@users.noreply.github.com>
2026-03-04 14:33:38 +08:00
Pnant 932837ff9a Merge pull request #52 from Panniantong/fix/twitter-cookie-sync-issue50
fix: sync Twitter cookies to xreach session.json on configure
2026-02-28 20:29:38 +08:00
Panniantong 4f52942260 improve: add chmod 0o600 for session.json (from PR #51 suggestion) 2026-02-28 13:28:14 +01:00
Panniantong 3c1a04a587 fix: sync Twitter cookies to xreach session.json on configure
When running 'agent-reach configure twitter-cookies', credentials are now
automatically written to ~/.config/xfetch/session.json so that 'xreach auth
check' works without manual intervention.

Preserves existing session.json fields if the file already exists.
Falls back gracefully if the sync fails (still saves to agent-reach config).

Fixes #50
2026-02-28 11:11:55 +01:00
Pnant 6a28b215af Merge pull request #49 from Panniantong/fix/ci-test-github-check
fix: CI 测试修复 — gh CLI 在 CI 环境不存在
2026-02-27 20:41:30 +08:00
Panniantong a881db4261 fix: CI test — allow github channel 'warn' when gh CLI not installed
GitHub Actions runners don't have gh CLI pre-installed, so the doctor
check returns 'warn' instead of 'ok'. Allow both in the test assertion.
2026-02-27 13:39:34 +01:00
Pnant 875f0987a6 Merge pull request #48 from Panniantong/fix/yt-bilibili-cookie-docs
fix: YouTube 需要 JS runtime — install 自动配置 Node.js
2026-02-27 18:46:32 +08:00
Panniantong 0d1a33236e fix: YouTube requires JS runtime — auto-configure Node.js in install
yt-dlp now requires an external JS runtime (deno or Node.js) for YouTube.
Since agent-reach already installs Node.js, we configure yt-dlp to use it:

1. cli.py: install writes '--js-runtimes node' to ~/.config/yt-dlp/config
2. youtube.py: doctor checks JS runtime availability and config
3. SKILL.md: updated YouTube section with accurate troubleshooting

Bilibili note: 412 errors are IP-based (needs proxy), not cookie-related.

Fixes #47
2026-02-27 11:45:39 +01:00
Panniantong e620a1db8f docs: add cookie/proxy warnings for YouTube and Bilibili in SKILL.md
YouTube may trigger 'Sign in to confirm you're not a bot' on server IPs.
Bilibili may return 412 on non-residential IPs.

Added --cookies-from-browser fallback examples for both channels.

Fixes #47
2026-02-27 11:25:29 +01:00
Pnant 15a2961dfa Merge pull request #46 from Panniantong/migrate-bird-to-xreach
feat: 迁移 Twitter 后端 bird → xreach CLI
2026-02-27 15:21:25 +08:00
Pnant 4bc860c74d Merge pull request #45 from Panniantong/fix/xiaohongshu-arm64-support
fix: ARM64/Apple Silicon support for xiaohongshu-mcp Docker setup
2026-02-27 15:21:21 +08:00
Panniantong 62aacf38b5 feat: migrate Twitter backend from bird CLI to xreach CLI
bird CLI (@steipete/bird) is deprecated and no longer maintained.
xreach CLI (xreach-cli on npm) is our maintained fork with:
- Fixed SearchTimeline (POST + updated query ID)
- Built-in proxy rotation support
- Additional features (DMs, notifications, lists)

Changes across 11 files:
- channels/twitter.py: detect xreach instead of bird/birdx
- cli.py: install/doctor/uninstall all reference xreach-cli
- SKILL.md: updated command examples (bird read → xreach tweet)
- guides/setup-twitter.md: rewritten for xreach
- docs/troubleshooting.md: updated proxy guidance
- README.md + README_en.md: all references updated
- config.py: twitter_bird → twitter_xreach
- core.py, mcp_server.py: comment updates

npm package: https://www.npmjs.com/package/xreach-cli
Source: https://github.com/Panniantong/xfetch
2026-02-27 08:17:51 +01:00
Panniantong d4e437bf2c fix: add ARM64/Apple Silicon support for xiaohongshu-mcp Docker setup
- Detect ARM64 architecture and auto-add --platform linux/amd64 to
  docker run hints in doctor/check output
- Add ARM64 FAQ section to setup guide with two options:
  Rosetta emulation (--platform linux/amd64) or build from source
- Closes #44
2026-02-27 08:06:06 +01:00
Pnant dbcc668f47 Merge pull request #40 from peter941221/fix/security-hardening-split-v2
fix: split security hardening and setup mcporter config checks
2026-02-27 13:30:52 +08:00
Pnant 40f833d0d1 Merge pull request #41 from peter941221/chore/quality-infra-split-v3
chore: split quality infrastructure into focused PR
2026-02-27 13:26:49 +08:00
Pnant dc4d7cd7a5 Merge pull request #24 from Daiyimo/main
feat: add uninstall command; fix utf-8 encoding and minor cleanups
2026-02-27 13:24:21 +08:00
Pnant 60f5ddf4f0 Merge pull request #35 from Panniantong/fix/mcporter-list-hang
fix: replace 'mcporter list' with 'mcporter config list' to prevent hangs
2026-02-27 12:56:40 +08:00
Your Name a726aa7fe1 chore: add quality infra matrix, constraints, and test baseline fixes 2026-02-27 12:27:07 +08:00
Your Name 4b2e6f2ffb fix: split security hardening and setup mcporter config checks 2026-02-27 12:16:00 +08:00
Panniantong 39f76ee4dc fix: replace 'mcporter list' with 'mcporter config list' to prevent hangs
mcporter list probes all configured servers sequentially, which can hang
indefinitely when servers are slow/offline. mcporter config list reads
config without probing — sufficient for checking if a server is configured.

Affected: doctor checks (4 channels) and install command (2 calls).

Fixes #34
2026-02-27 03:06:27 +01:00
Pnant a5682716ec Merge pull request #31 from Panniantong/docs/cookie-ban-warning
docs: 添加 Cookie 登录封号风险提醒
2026-02-26 22:41:53 +08:00
Panniantong f269667a34 docs: 添加 Cookie 登录封号风险提醒,强调使用小号
- README: Cookie 安全建议新增封号风险说明
- install.md: Security tip 补充 account ban 风险
- SKILL.md: Cookie 导入段落加封号提醒
2026-02-26 15:13:25 +01:00
Pnant 090b01c909 Merge pull request #30 from Panniantong/fix/sync-version-string
fix: sync __version__ with pyproject.toml (1.0.0 → 1.2.0)
2026-02-26 22:08:35 +08:00
Panniantong 58b3a4dcb3 fix: sync __version__ with pyproject.toml (1.0.0 → 1.2.0)
Fixes #29 — __version__ in __init__.py was still '1.0.0' while pyproject.toml
had already been bumped to '1.2.0'. This caused misleading output in
'agent-reach --version' and 'agent-reach doctor'.
2026-02-26 15:07:24 +01:00
Daiyimo 9c6c04c800 feat: add uninstall command; fix utf-8 encoding and minor cleanups 2026-02-26 16:01:22 +08:00
33 changed files with 1178 additions and 452 deletions
+30
View File
@@ -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
View File
@@ -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. 更新文档
+36 -11
View File
@@ -50,7 +50,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|---|---| |---|---|
| 💰 **完全免费** | 所有工具开源、所有 API 免费。唯一可能花钱的是服务器代理($1/月),本地电脑不需要 | | 💰 **完全免费** | 所有工具开源、所有 API 免费。唯一可能花钱的是服务器代理($1/月),本地电脑不需要 |
| 🔒 **隐私安全** | Cookie 只存在你本地,不上传不外传。代码完全开源,随时可审查 | | 🔒 **隐私安全** | Cookie 只存在你本地,不上传不外传。代码完全开源,随时可审查 |
| 🔄 **持续更新** | 底层工具(yt-dlp、bird、Jina Reader 等)定期追踪更新到最新版,你不用自己盯 | | 🔄 **持续更新** | 底层工具(yt-dlp、xreach、Jina Reader 等)定期追踪更新到最新版,你不用自己盯 |
| 🤖 **兼容所有 Agent** | Claude Code、OpenClaw、Cursor、Windsurf……任何能跑命令行的 Agent 都能用 | | 🤖 **兼容所有 Agent** | Claude Code、OpenClaw、Cursor、Windsurf……任何能跑命令行的 Agent 都能用 |
| 🩺 **自带诊断** | `agent-reach doctor` 一条命令告诉你哪个通、哪个不通、怎么修 | | 🩺 **自带诊断** | `agent-reach doctor` 一条命令告诉你哪个通、哪个不通、怎么修 |
@@ -72,6 +72,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
| 🎵 **抖音** | — | 视频解析、无水印下载链接获取 | 告诉 Agent「帮我配抖音」 | | 🎵 **抖音** | — | 视频解析、无水印下载链接获取 | 告诉 Agent「帮我配抖音」 |
| 💼 **LinkedIn** | Jina Reader 读公开页面 | Profile 详情、公司页面、职位搜索 | 告诉 Agent「帮我配 LinkedIn」 | | 💼 **LinkedIn** | Jina Reader 读公开页面 | Profile 详情、公司页面、职位搜索 | 告诉 Agent「帮我配 LinkedIn」 |
| 🏢 **Boss直聘** | Jina Reader 读职位页 | 搜索职位、向 HR 打招呼 | 告诉 Agent「帮我配 Boss直聘」 | | 🏢 **Boss直聘** | Jina Reader 读职位页 | 搜索职位、向 HR 打招呼 | 告诉 Agent「帮我配 Boss直聘」 |
| 💬 **微信公众号** | — | 搜索 + 阅读公众号文章(全文 Markdown) | `pip install miku_ai camoufox[geoip]` |
> **不知道怎么配?不用查文档。** 直接告诉 Agent「帮我配 XXX」,它知道需要什么、会一步一步引导你。 > **不知道怎么配?不用查文档。** 直接告诉 Agent「帮我配 XXX」,它知道需要什么、会一步一步引导你。
> >
@@ -102,7 +103,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
<summary>它会做什么?(点击展开)</summary> <summary>它会做什么?(点击展开)</summary>
1. **安装 CLI 工具**`pip install` 装好 `agent-reach` 命令行 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 3. **配置搜索引擎** — 通过 MCP 接入 Exa(免费,无需 API Key
4. **检测环境** — 判断是本地电脑还是服务器,给出对应的配置建议 4. **检测环境** — 判断是本地电脑还是服务器,给出对应的配置建议
5. **注册 SKILL.md** — 在 Agent 的 skills 目录安装使用指南,以后 Agent 遇到"搜推特"、"看视频"这类需求,会自动知道该调哪个上游工具 5. **注册 SKILL.md** — 在 Agent 的 skills 目录安装使用指南,以后 Agent 遇到"搜推特"、"看视频"这类需求,会自动知道该调哪个上游工具
@@ -119,7 +120,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
- "帮我看看这个链接" → `curl https://r.jina.ai/URL` 读任意网页 - "帮我看看这个链接" → `curl https://r.jina.ai/URL` 读任意网页
- "这个 GitHub 仓库是做什么的" → `gh repo view owner/repo` - "这个 GitHub 仓库是做什么的" → `gh repo view owner/repo`
- "这个视频讲了什么" → `yt-dlp --dump-json URL` 提取字幕 - "这个视频讲了什么" → `yt-dlp --dump-json URL` 提取字幕
- "帮我看看这条推文" → `bird read URL --json` - "帮我看看这条推文" → `xreach tweet URL --json`
- "订阅这个 RSS" → `feedparser` 解析 - "订阅这个 RSS" → `feedparser` 解析
- "搜一下 GitHub 上有什么 LLM 框架" → `gh search repos "LLM framework"` - "搜一下 GitHub 上有什么 LLM 框架" → `gh search repos "LLM framework"`
@@ -135,7 +136,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
Agent Reach 做的事情很简单:**帮你把这些选型和配置的活儿做完了。** 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/ channels/
├── web.py → Jina Reader ← 可以换成 Firecrawl、Crawl4AI…… ├── web.py → Jina Reader ← 可以换成 Firecrawl、Crawl4AI……
├── twitter.py → bird ← 可以换成 Nitter、官方 API…… ├── twitter.py → xreach ← 可以换成 Nitter、官方 API……
├── youtube.py → yt-dlp ← 可以换成 YouTube API、Whisper…… ├── youtube.py → yt-dlp ← 可以换成 YouTube API、Whisper……
├── github.py → gh CLI ← 可以换成 REST API、PyGithub…… ├── github.py → gh CLI ← 可以换成 REST API、PyGithub……
├── bilibili.py → yt-dlp ← 可以换成 bilibili-api…… ├── bilibili.py → yt-dlp ← 可以换成 bilibili-api……
@@ -153,6 +154,7 @@ channels/
├── douyin.py → mcporter MCP ← 可以换成其他抖音工具…… ├── douyin.py → mcporter MCP ← 可以换成其他抖音工具……
├── linkedin.py → linkedin-mcp ← 可以换成 LinkedIn API…… ├── linkedin.py → linkedin-mcp ← 可以换成 LinkedIn API……
├── bosszhipin.py → mcp-bosszp ← 可以换成其他招聘工具…… ├── bosszhipin.py → mcp-bosszp ← 可以换成其他招聘工具……
├── wechat.py → camoufox+miku ← 搜索+阅读微信公众号文章
├── rss.py → feedparser ← 可以换成 atoma…… ├── rss.py → feedparser ← 可以换成 atoma……
├── exa_search.py → mcporter MCP ← 可以换成 Tavily、SerpAPI…… ├── exa_search.py → mcporter MCP ← 可以换成 Tavily、SerpAPI……
└── __init__.py → 渠道注册(doctor 检测用) └── __init__.py → 渠道注册(doctor 检测用)
@@ -165,7 +167,7 @@ channels/
| 场景 | 选型 | 为什么选它 | | 场景 | 选型 | 为什么选它 |
|------|------|-----------| |------|------|-----------|
| 读网页 | [Jina Reader](https://github.com/jina-ai/reader) | 9.8K Star,免费,不需要 API Key | | 读网页 | [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 StarYouTube + B站 + 1800 站通吃 | | 视频字幕 + 搜索 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 148K StarYouTube + B站 + 1800 站通吃 |
| 搜全网 | [Exa](https://exa.ai) via [mcporter](https://github.com/steipete/mcporter) | AI 语义搜索,MCP 接入免 Key | | 搜全网 | [Exa](https://exa.ai) via [mcporter](https://github.com/steipete/mcporter) | AI 语义搜索,MCP 接入免 Key |
| GitHub | [gh CLI](https://cli.github.com) | 官方工具,认证后完整 API 能力 | | GitHub | [gh CLI](https://cli.github.com) | 官方工具,认证后完整 API 能力 |
@@ -174,6 +176,7 @@ channels/
| 抖音 | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP 服务,无需登录,视频解析 + 无水印下载 | | 抖音 | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP 服务,无需登录,视频解析 + 无水印下载 |
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+,MCP 服务,浏览器自动化 | | LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+,MCP 服务,浏览器自动化 |
| Boss直聘 | [mcp-bosszp](https://github.com/mucsbr/mcp-bosszp) | 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 安全建议
需要 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 --safe` | 生产服务器、多人共用机器 |
| 仅预览 | `agent-reach install --env=auto --dry-run` | 先看看会做什么 | | 仅预览 | `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> <details>
<summary><strong>AI Agent 怎么搜索 Twitter / X?不想付 API 费用</strong></summary> <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>
<details> <details>
<summary><strong>How to search Twitter/X with AI agent for free (no API)?</strong></summary> <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>
<details> <details>
@@ -278,14 +303,14 @@ Yes! Agent Reach is an installer + configuration tool — any AI coding agent th
<details> <details>
<summary><strong>Is this free? Any API costs?</strong></summary> <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> </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 ## License
+1 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Agent Reach — Give your AI Agent eyes to see the entire internet.""" """Agent Reach — Give your AI Agent eyes to see the entire internet."""
__version__ = "1.0.0" __version__ = "1.3.0"
__author__ = "Neo Reid" __author__ = "Neo Reid"
from agent_reach.core import AgentReach from agent_reach.core import AgentReach
+2
View File
@@ -19,6 +19,7 @@ from .xiaohongshu import XiaoHongShuChannel
from .douyin import DouyinChannel from .douyin import DouyinChannel
from .linkedin import LinkedInChannel from .linkedin import LinkedInChannel
from .bosszhipin import BossZhipinChannel from .bosszhipin import BossZhipinChannel
from .wechat import WeChatChannel
# Channel registry # Channel registry
@@ -32,6 +33,7 @@ ALL_CHANNELS: List[Channel] = [
DouyinChannel(), DouyinChannel(),
LinkedInChannel(), LinkedInChannel(),
BossZhipinChannel(), BossZhipinChannel(),
WeChatChannel(),
RSSChannel(), RSSChannel(),
ExaSearchChannel(), ExaSearchChannel(),
WebChannel(), WebChannel(),
+4 -2
View File
@@ -18,7 +18,8 @@ class BossZhipinChannel(Channel):
return "zhipin.com" in domain or "boss.com" in domain return "zhipin.com" in domain or "boss.com" in domain
def check(self, config=None): def check(self, config=None):
if not shutil.which("mcporter"): mcporter = shutil.which("mcporter")
if not mcporter:
return "off", ( return "off", (
"可通过 Jina Reader 读取职位页面。完整功能需要:\n" "可通过 Jina Reader 读取职位页面。完整功能需要:\n"
" 1. git clone https://github.com/mucsbr/mcp-bosszp.git\n" " 1. git clone https://github.com/mucsbr/mcp-bosszp.git\n"
@@ -28,7 +29,8 @@ class BossZhipinChannel(Channel):
) )
try: try:
r = subprocess.run( 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() out = r.stdout.lower()
if "boss" in out or "zhipin" in out: if "boss" in out or "zhipin" in out:
+6 -4
View File
@@ -18,7 +18,8 @@ class DouyinChannel(Channel):
return "douyin.com" in d or "iesdouyin.com" in d return "douyin.com" in d or "iesdouyin.com" in d
def check(self, config=None): def check(self, config=None):
if not shutil.which("mcporter"): mcporter = shutil.which("mcporter")
if not mcporter:
return "off", ( return "off", (
"需要 mcporter + douyin-mcp-server。安装步骤:\n" "需要 mcporter + douyin-mcp-server。安装步骤:\n"
" 1. npm install -g mcporter\n" " 1. npm install -g mcporter\n"
@@ -29,7 +30,8 @@ class DouyinChannel(Channel):
) )
try: try:
r = subprocess.run( 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: if "douyin" not in r.stdout:
return "off", ( return "off", (
@@ -42,8 +44,8 @@ class DouyinChannel(Channel):
return "off", "mcporter 连接异常" return "off", "mcporter 连接异常"
try: try:
r = subprocess.run( r = subprocess.run(
["mcporter", "call", "douyin.parse_douyin_video_info(share_link: \"https://www.douyin.com\")"], [mcporter, "call", "douyin.parse_douyin_video_info(share_link: \"https://www.douyin.com\")"],
capture_output=True, text=True, timeout=15 capture_output=True, encoding="utf-8", errors="replace", timeout=15
) )
if r.returncode == 0: if r.returncode == 0:
return "ok", "完整可用(视频解析、下载链接获取)" return "ok", "完整可用(视频解析、下载链接获取)"
+4 -2
View File
@@ -16,7 +16,8 @@ class ExaSearchChannel(Channel):
return False # Search-only channel return False # Search-only channel
def check(self, config=None): def check(self, config=None):
if not shutil.which("mcporter"): mcporter = shutil.which("mcporter")
if not mcporter:
return "off", ( return "off", (
"需要 mcporter + Exa MCP。安装:\n" "需要 mcporter + Exa MCP。安装:\n"
" npm install -g mcporter\n" " npm install -g mcporter\n"
@@ -24,7 +25,8 @@ class ExaSearchChannel(Channel):
) )
try: try:
r = subprocess.run( 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(): if "exa" in r.stdout.lower():
return "ok", "全网语义搜索可用(免费,无需 API Key)" return "ok", "全网语义搜索可用(免费,无需 API Key)"
+9 -6
View File
@@ -17,13 +17,16 @@ class GitHubChannel(Channel):
return "github.com" in urlparse(url).netloc.lower() return "github.com" in urlparse(url).netloc.lower()
def check(self, config=None): 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" return "warn", "gh CLI 未安装。安装:https://cli.github.com"
try: try:
subprocess.run( r = subprocess.run(
["gh", "auth", "status"], [gh, "auth", "status"],
capture_output=True, text=True, timeout=5 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: except Exception:
return "ok", "gh CLI 已装但未认证。运行 gh auth login 可解锁完整功能" return "warn", "gh CLI 状态检查失败,运行 gh auth status 查看详情"
+4 -2
View File
@@ -17,7 +17,8 @@ class LinkedInChannel(Channel):
return "linkedin.com" in urlparse(url).netloc.lower() return "linkedin.com" in urlparse(url).netloc.lower()
def check(self, config=None): def check(self, config=None):
if not shutil.which("mcporter"): mcporter = shutil.which("mcporter")
if not mcporter:
return "off", ( return "off", (
"基本内容可通过 Jina Reader 读取。完整功能需要:\n" "基本内容可通过 Jina Reader 读取。完整功能需要:\n"
" pip install linkedin-scraper-mcp\n" " pip install linkedin-scraper-mcp\n"
@@ -26,7 +27,8 @@ class LinkedInChannel(Channel):
) )
try: try:
r = subprocess.run( 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(): if "linkedin" in r.stdout.lower():
return "ok", "完整可用(Profile、公司、职位搜索)" return "ok", "完整可用(Profile、公司、职位搜索)"
+10 -9
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Twitter/X — check if bird CLI is available.""" """Twitter/X — check if xreach CLI is available."""
import shutil import shutil
import subprocess import subprocess
@@ -9,7 +9,7 @@ from .base import Channel
class TwitterChannel(Channel): class TwitterChannel(Channel):
name = "twitter" name = "twitter"
description = "Twitter/X 推文" description = "Twitter/X 推文"
backends = ["bird CLI"] backends = ["xreach CLI"]
tier = 1 tier = 1
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
@@ -18,21 +18,22 @@ class TwitterChannel(Channel):
return "x.com" in d or "twitter.com" in d return "x.com" in d or "twitter.com" in d
def check(self, config=None): def check(self, config=None):
bird = shutil.which("bird") or shutil.which("birdx") xreach = shutil.which("xreach")
if not bird: if not xreach:
return "warn", ( return "warn", (
"bird CLI 未安装。搜索可通过 Exa 替代。安装:\n" "xreach CLI 未安装。搜索可通过 Exa 替代。安装:\n"
" npm install -g @steipete/bird" " npm install -g xreach-cli"
) )
try: try:
r = subprocess.run( 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: if r.returncode == 0:
return "ok", "完整可用(读取、搜索推文)" return "ok", "完整可用(读取、搜索推文)"
return "warn", ( return "warn", (
"bird CLI 已安装但未配置 Cookie。运行:\n" "xreach CLI 已安装但未配置 Cookie。运行:\n"
" agent-reach configure twitter-cookies \"auth_token=xxx; ct0=yyy\"" " agent-reach configure twitter-cookies \"auth_token=xxx; ct0=yyy\""
) )
except Exception: except Exception:
return "warn", "bird CLI 已安装但连接失败" return "warn", "xreach CLI 已安装但连接失败"
+57
View File
@@ -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"
)
+31 -7
View File
@@ -1,11 +1,33 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""XiaoHongShu check if mcporter + xiaohongshu MCP is available.""" """XiaoHongShu -- check if mcporter + xiaohongshu MCP is available."""
import platform
import shutil import shutil
import subprocess import subprocess
from .base import Channel 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): class XiaoHongShuChannel(Channel):
name = "xiaohongshu" name = "xiaohongshu"
description = "小红书笔记" description = "小红书笔记"
@@ -18,30 +40,32 @@ class XiaoHongShuChannel(Channel):
return "xiaohongshu.com" in d or "xhslink.com" in d return "xiaohongshu.com" in d or "xhslink.com" in d
def check(self, config=None): def check(self, config=None):
if not shutil.which("mcporter"): mcporter = shutil.which("mcporter")
if not mcporter:
return "off", ( return "off", (
"需要 mcporter + xiaohongshu-mcp。安装步骤:\n" "需要 mcporter + xiaohongshu-mcp。安装步骤:\n"
" 1. npm install -g mcporter\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" " 3. mcporter config add xiaohongshu http://localhost:18060/mcp\n"
" 详见 https://github.com/xpzouying/xiaohongshu-mcp" " 详见 https://github.com/xpzouying/xiaohongshu-mcp"
) )
try: try:
r = subprocess.run( 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: if "xiaohongshu" not in r.stdout:
return "off", ( return "off", (
"mcporter 已装但小红书 MCP 未配置。运行:\n" "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" " mcporter config add xiaohongshu http://localhost:18060/mcp"
) )
except Exception: except Exception:
return "off", "mcporter 连接异常" return "off", "mcporter 连接异常"
try: try:
r = subprocess.run( r = subprocess.run(
["mcporter", "call", "xiaohongshu.check_login_status()"], [mcporter, "call", "xiaohongshu.check_login_status()"],
capture_output=True, text=True, timeout=10 capture_output=True, encoding="utf-8", errors="replace", timeout=10
) )
if "已登录" in r.stdout or "logged" in r.stdout.lower(): if "已登录" in r.stdout or "logged" in r.stdout.lower():
return "ok", "完整可用(阅读、搜索、发帖、评论、点赞)" return "ok", "完整可用(阅读、搜索、发帖、评论、点赞)"
+22 -4
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""YouTube — check if yt-dlp is available.""" """YouTube — check if yt-dlp is available with JS runtime."""
import os
import shutil import shutil
from .base import Channel from .base import Channel
@@ -17,6 +18,23 @@ class YouTubeChannel(Channel):
return "youtube.com" in d or "youtu.be" in d return "youtube.com" in d or "youtu.be" in d
def check(self, config=None): def check(self, config=None):
if shutil.which("yt-dlp"): if not shutil.which("yt-dlp"):
return "ok", "可提取视频信息和字幕" return "off", "yt-dlp 未安装。安装:pip install 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 runtimeYouTube 必须)。\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
View File
@@ -13,18 +13,31 @@ import sys
import argparse import argparse
import json import json
import os import os
import time
# 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')
from agent_reach import __version__ 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): def _configure_logging(verbose: bool = False):
"""Suppress loguru output unless --verbose is set.""" """Suppress loguru output unless --verbose is set."""
from loguru import logger from loguru import logger
@@ -34,6 +47,8 @@ def _configure_logging(verbose: bool = False):
def main(): def main():
_ensure_utf8_console()
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="agent-reach", prog="agent-reach",
description="👁️ Give your AI Agent eyes to see the entire internet", 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__}") parser.add_argument("--version", action="version", version=f"Agent Reach v{__version__}")
sub = parser.add_subparsers(dest="command", help="Available commands") sub = parser.add_subparsers(dest="command", help="Available commands")
# ── read ──
# ── setup ── # ── setup ──
sub.add_parser("setup", help="Interactive configuration wizard") sub.add_parser("setup", help="Interactive configuration wizard")
@@ -71,6 +85,13 @@ def main():
# ── doctor ── # ── doctor ──
sub.add_parser("doctor", help="Check platform availability") 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 ── # ── check-update ──
sub.add_parser("check-update", help="Check for new versions and changes") sub.add_parser("check-update", help="Check for new versions and changes")
@@ -105,6 +126,8 @@ def main():
_cmd_install(args) _cmd_install(args)
elif args.command == "configure": elif args.command == "configure":
_cmd_configure(args) _cmd_configure(args)
elif args.command == "uninstall":
_cmd_uninstall(args)
# ── Command handlers ──────────────────────────────── # ── Command handlers ────────────────────────────────
@@ -279,6 +302,7 @@ def _install_system_deps():
import shutil import shutil
import subprocess import subprocess
import platform import platform
import tempfile
print("🔧 Checking system dependencies...") print("🔧 Checking system dependencies...")
@@ -290,15 +314,25 @@ def _install_system_deps():
os_type = platform.system().lower() os_type = platform.system().lower()
if os_type == "linux": if os_type == "linux":
try: try:
# Official GitHub method for Linux # Official GitHub apt source setup without invoking a shell.
cmds = [ keyring_path = "/usr/share/keyrings/githubcli-archive-keyring.gpg"
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 2>/dev/null", list_path = "/etc/apt/sources.list.d/github-cli.list"
'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', arch = subprocess.run(
"apt-get update -qq 2>/dev/null", ["dpkg", "--print-architecture"],
"apt-get install -y -qq gh 2>/dev/null", capture_output=True, encoding="utf-8", errors="replace", timeout=10,
] ).stdout.strip() or "amd64"
for cmd in cmds: subprocess.run(
subprocess.run(cmd, shell=True, capture_output=True, timeout=60) ["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"): if shutil.which("gh"):
print(" ✅ gh CLI installed") print(" ✅ gh CLI installed")
else: else:
@@ -326,10 +360,24 @@ def _install_system_deps():
else: else:
print(" 📥 Installing Node.js...") print(" 📥 Installing Node.js...")
try: 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( subprocess.run(
"curl -fsSL https://deb.nodesource.com/setup_22.x | bash - 2>/dev/null && apt-get install -y -qq nodejs 2>/dev/null", ["curl", "-fsSL", "https://deb.nodesource.com/setup_22.x", "-o", script_path],
shell=True, capture_output=True, timeout=120, 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"): if shutil.which("node"):
print(" ✅ Node.js installed") print(" ✅ Node.js installed")
@@ -338,37 +386,56 @@ def _install_system_deps():
except Exception: except Exception:
print(" ⚠️ Node.js install failed. Try: apt install nodejs npm, or nvm install 22, or download from https://nodejs.org") 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) ── # ── xreach CLI (for Twitter search) ──
if shutil.which("bird") or shutil.which("birdx"): if shutil.which("xreach"):
print("bird CLI already installed") print("xreach CLI already installed")
else: else:
if shutil.which("npm"): if shutil.which("npm"):
try: try:
subprocess.run( subprocess.run(
["npm", "install", "-g", "@steipete/bird"], ["npm", "install", "-g", "xreach-cli"],
capture_output=True, text=True, timeout=120, capture_output=True, encoding="utf-8", errors="replace", timeout=120,
) )
if shutil.which("bird"): if shutil.which("xreach"):
print("bird CLI installed (Twitter search + timeline)") print("xreach CLI installed (Twitter search + timeline)")
else: 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: 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: 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) ── # ── undici (proxy support for Node.js fetch) ──
if shutil.which("npm"): 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 "" undici_path = os.path.join(npm_root, "undici", "index.js") if npm_root else ""
if os.path.exists(undici_path): if os.path.exists(undici_path):
print(" ✅ undici already installed (Node.js proxy support)") print(" ✅ undici already installed (Node.js proxy support)")
else: else:
try: 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)") print(" ✅ undici installed (Node.js proxy support)")
except Exception: 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(): def _install_system_deps_safe():
@@ -380,7 +447,7 @@ def _install_system_deps_safe():
deps = [ deps = [
("gh", ["gh"], "GitHub CLI", "https://cli.github.com — or: apt install gh / brew install gh"), ("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"), ("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 = [] missing = []
@@ -410,7 +477,7 @@ def _install_system_deps_dryrun():
checks = [ checks = [
("gh CLI", ["gh"], "apt install gh / brew install gh"), ("gh CLI", ["gh"], "apt install gh / brew install gh"),
("Node.js", ["node"], "curl NodeSource setup | bash + apt install nodejs"), ("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: for label, binaries, method in checks:
@@ -439,7 +506,7 @@ def _install_mcporter():
try: try:
subprocess.run( subprocess.run(
["npm", "install", "-g", "mcporter"], ["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"): if shutil.which("mcporter"):
print(" ✅ mcporter installed") print(" ✅ mcporter installed")
@@ -453,12 +520,12 @@ def _install_mcporter():
# Configure Exa MCP (free, no key needed) # Configure Exa MCP (free, no key needed)
try: try:
r = subprocess.run( 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: if "exa" not in r.stdout:
subprocess.run( subprocess.run(
["mcporter", "config", "add", "exa", "https://mcp.exa.ai/mcp"], ["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)") print(" ✅ Exa search configured (free, no API key needed)")
else: else:
@@ -469,7 +536,7 @@ def _install_mcporter():
# Check XiaoHongShu MCP (only if server is running) # Check XiaoHongShu MCP (only if server is running)
try: try:
r = subprocess.run( 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: if "xiaohongshu" in r.stdout:
print(" ✅ XiaoHongShu MCP already configured") print(" ✅ XiaoHongShu MCP already configured")
@@ -480,7 +547,7 @@ def _install_mcporter():
requests.get("http://localhost:18060/", timeout=3) requests.get("http://localhost:18060/", timeout=3)
subprocess.run( subprocess.run(
["mcporter", "config", "add", "xiaohongshu", "http://localhost:18060/mcp"], ["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") print(" ✅ XiaoHongShu MCP auto-detected and configured")
except Exception: except Exception:
@@ -539,7 +606,7 @@ def _detect_environment():
# systemd-detect-virt # systemd-detect-virt
try: try:
import subprocess 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": if result.returncode == 0 and result.stdout.strip() != "none":
indicators += 1 indicators += 1
except: except:
@@ -636,22 +703,41 @@ def _cmd_configure(args):
if auth_token and ct0: if auth_token and ct0:
config.set("twitter_auth_token", auth_token) config.set("twitter_auth_token", auth_token)
config.set("twitter_ct0", ct0) 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=" ") print("Testing Twitter access...", end=" ")
try: try:
import subprocess import subprocess
bird = shutil.which("bird") or shutil.which("birdx") xreach = shutil.which("xreach")
if not bird: if not xreach:
print("⚠️ bird CLI not installed. Run: npm install -g @steipete/bird") print("⚠️ xreach CLI not installed. Run: npm install -g xreach-cli")
else: else:
import os import os
env = os.environ.copy() env = os.environ.copy()
env["AUTH_TOKEN"] = auth_token env["AUTH_TOKEN"] = auth_token
env["CT0"] = ct0 env["CT0"] = ct0
result = subprocess.run( result = subprocess.run(
[bird, "search", "test", "-n", "1"], [xreach, "search", "test", "-n", "1"],
capture_output=True, text=True, timeout=15, capture_output=True, encoding="utf-8", errors="replace", timeout=15,
env=env, env=env,
) )
if result.returncode == 0 and result.stdout.strip(): if result.returncode == 0 and result.stdout.strip():
@@ -680,6 +766,104 @@ def _cmd_configure(args):
print(f"✅ Groq key configured!") 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(): def _cmd_doctor():
from agent_reach.config import Config from agent_reach.config import Config
from agent_reach.doctor import check_all, format_report from agent_reach.doctor import check_all, format_report
@@ -688,19 +872,6 @@ def _cmd_doctor():
print(format_report(results)) 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(): def _cmd_setup():
from agent_reach.config import Config from agent_reach.config import Config
@@ -710,29 +881,41 @@ def _cmd_setup():
print("=" * 40) print("=" * 40)
print() print()
# Step 1: Exa # Step 1: Exa (via mcporter, no API key required)
print("【推荐】全网搜索 — Exa Search API") import shutil
print(" 免费 1000 次/月,注册地址: https://exa.ai") import subprocess
current = config.get("exa_api_key")
if current: print("【推荐】全网搜索 — Exa(通过 mcporter")
print(f" 当前状态: ✅ 已配置 ({current[:8]}...)") print(" 免费,无需 API Key")
change = input(" 要更换吗?[y/N]: ").strip().lower()
if change != "y": if not shutil.which("mcporter"):
print() print(" 当前状态: ⬜ mcporter 未安装")
else: print(" 安装:npm install -g mcporter")
key = input(" EXA_API_KEY: ").strip() print(" 然后:mcporter config add exa https://mcp.exa.ai/mcp")
if key: print()
config.set("exa_api_key", key)
print(" ✅ 已更新!")
print()
else: else:
print(" 当前状态: ⬜ 未配置") try:
key = input(" EXA_API_KEY (回车跳过): ").strip() r = subprocess.run(
if key: ["mcporter", "config", "list"], capture_output=True, encoding="utf-8", errors="replace", timeout=10
config.set("exa_api_key", key) )
print(" ✅ 全网搜索 + Reddit搜索 + Twitter搜索 已开启!") if "exa" in r.stdout.lower():
else: print(" 当前状态: ✅ 已配置")
print(" ℹ️ 跳过。稍后可运行 agent-reach setup 配置") 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() print()
# Step 2: GitHub token # Step 2: GitHub token
@@ -789,62 +972,160 @@ def _cmd_setup():
print() 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(): def _cmd_check_update():
"""Check for newer versions on GitHub.""" """Check for newer versions on GitHub."""
import requests
from agent_reach import __version__ from agent_reach import __version__
print(f"📦 当前版本: v{__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 release with retry/backoff.
# Fetch latest version from GitHub resp, err, attempts = _github_get_with_retry(release_url, timeout=10, retries=3)
resp = requests.get( if err:
"https://api.github.com/repos/Panniantong/Agent-Reach/releases/latest", print(f"⚠️ 无法检查更新({_update_error_text(err)},已重试 {attempts} 次)")
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}")
return "error" 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(): def _cmd_watch():
"""Quick health check + update check, designed for scheduled tasks. """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.config import Config
from agent_reach.doctor import check_all from agent_reach.doctor import check_all
import requests
from agent_reach import __version__ from agent_reach import __version__
config = Config() config = Config()
@@ -875,20 +1155,18 @@ def _cmd_watch():
update_available = False update_available = False
new_version = "" new_version = ""
release_body = "" release_body = ""
try: resp, err, _attempts = _github_get_with_retry(
resp = requests.get( "https://api.github.com/repos/Panniantong/Agent-Reach/releases/latest",
"https://api.github.com/repos/Panniantong/Agent-Reach/releases/latest", timeout=10,
timeout=10, retries=2,
) )
if resp.status_code == 200: if not err and resp and resp.status_code == 200:
data = resp.json() data = resp.json()
latest = data.get("tag_name", "").lstrip("v") latest = data.get("tag_name", "").lstrip("v")
if latest and latest != __version__: if latest and latest != __version__:
update_available = True update_available = True
new_version = latest new_version = latest
release_body = data.get("body", "") release_body = data.get("body", "")
except Exception:
pass
# Output # Output
if not issues and not update_available: if not issues and not update_available:
+3 -3
View File
@@ -22,7 +22,7 @@ class Config:
FEATURE_REQUIREMENTS = { FEATURE_REQUIREMENTS = {
"exa_search": ["exa_api_key"], "exa_search": ["exa_api_key"],
"reddit_proxy": ["reddit_proxy"], "reddit_proxy": ["reddit_proxy"],
"twitter_bird": ["twitter_auth_token", "twitter_ct0"], "twitter_xreach": ["twitter_auth_token", "twitter_ct0"],
"groq_whisper": ["groq_api_key"], "groq_whisper": ["groq_api_key"],
"github_token": ["github_token"], "github_token": ["github_token"],
} }
@@ -41,7 +41,7 @@ class Config:
def load(self): def load(self):
"""Load config from YAML file.""" """Load config from YAML file."""
if self.config_path.exists(): 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 {} self.data = yaml.safe_load(f) or {}
else: else:
self.data = {} self.data = {}
@@ -49,7 +49,7 @@ class Config:
def save(self): def save(self):
"""Save config to YAML file.""" """Save config to YAML file."""
self._ensure_dir() 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) yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True)
# Restrict permissions — config may contain credentials # Restrict permissions — config may contain credentials
try: try:
+1 -1
View File
@@ -3,7 +3,7 @@
AgentReach — installer, doctor, and configuration tool. AgentReach — installer, doctor, and configuration tool.
Agent Reach helps AI agents install and configure upstream platform tools 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. call the upstream tools directly — no wrapper layer needed.
Usage: Usage:
+3 -2
View File
@@ -74,11 +74,12 @@ def format_report(results: Dict[str, dict]) -> str:
if ok_count < total: if ok_count < total:
lines.append("运行 `agent-reach setup` 解锁更多渠道") lines.append("运行 `agent-reach setup` 解锁更多渠道")
# Security check: config file permissions # Security check: config file permissions (Unix only)
import os import os
import stat import stat
import sys
config_path = Config.CONFIG_DIR / "config.yaml" config_path = Config.CONFIG_DIR / "config.yaml"
if config_path.exists(): if config_path.exists() and sys.platform != "win32":
try: try:
mode = config_path.stat().st_mode mode = config_path.stat().st_mode
if mode & (stat.S_IRGRP | stat.S_IROTH): if mode & (stat.S_IRGRP | stat.S_IROTH):
+56 -46
View File
@@ -1,67 +1,77 @@
# Twitter 高级功能配置指南(bird CLI # Twitter 高级功能配置指南(xreach CLI
## 功能说明 Twitter 基础阅读通过 Jina Reader 免费可用,无需配置。
基础 Twitter 功能(搜索+读单条推文)无需配置,开箱即用。
高级功能需要 bird CLI 高级功能需要 xreach CLI
- 查看用户时间线
- 深度搜索(更精确、更多结果)
- 读取完整线程(thread
- 查看关注列表推文
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 ```bash
which bird && echo "installed" || echo "not installed" which xreach && echo "installed" || echo "not installed"
``` ```
2. 安装 bird 2. 安装 xreach
```bash ```bash
npm install -g @steipete/bird npm install -g xreach-cli
``` ```
3. 测试(需要先配置 cookie 3. 测试是否配置好
```bash ```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推荐) ## 获取 CookieCookie-Editor 方式,推荐)
1. 安装 [Cookie-Editor](https://cookie-editor.com/) 浏览器扩展
2. 登录 x.com
3. 点击 Cookie-Editor 图标 → Export → 复制全部
4. 运行配置命令:
```bash ```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(完全免费)。 如果你已经知道 `auth_token``ct0`
>
> **最简单的方式:**
> 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 个月会过期,届时需要重新导出。
## Agent 收到 cookie 后的操作 1. 安装 xreach(如果没装):`npm install -g xreach-cli`
1. 安装 bird(如果没装):`npm install -g @steipete/bird` 2. 设置环境变量:
2. 配置 cookie`agent-reach configure twitter-cookies "粘贴的内容"`
3. 测试:运行 `agent-reach doctor` 确认 Twitter 状态 ```bash
4. 反馈:"✅ Twitter 高级功能已开启!现在可以搜索推文、查看时间线了。" export AUTH_TOKEN="你的auth_token"
5. 如果失败:"❌ Cookie 无效或已过期,请重新导出。" 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
```
+20
View File
@@ -67,5 +67,25 @@ docker run -d \
**Q: 服务器上小红书提示 IP 风险?** **Q: 服务器上小红书提示 IP 风险?**
A: 加代理参数 `-e XHS_PROXY=http://user:pass@ip:port`,推荐住宅代理。 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** **Q: 我不想用 Docker**
A: 可以从源码编译:https://github.com/xpzouying/xiaohongshu-mcp A: 可以从源码编译:https://github.com/xpzouying/xiaohongshu-mcp
+1 -1
View File
@@ -5,7 +5,7 @@ Agent Reach MCP Server — expose doctor/status as MCP tool.
Run: python -m agent_reach.integrations.mcp_server Run: python -m agent_reach.integrations.mcp_server
Agent Reach is an installer + doctor tool. For actual reading/searching, 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 import asyncio
+70 -14
View File
@@ -3,7 +3,7 @@ name: agent-reach
description: > description: >
Give your AI agent eyes to see the entire internet. Install and configure Give your AI agent eyes to see the entire internet. Install and configure
upstream tools for Twitter/X, Reddit, YouTube, GitHub, Bilibili, XiaoHongShu, 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, Use when: (1) setting up platform access tools for the first time,
(2) checking which platforms are available, (2) checking which platforms are available,
(3) user asks to configure/enable a platform channel. (3) user asks to configure/enable a platform channel.
@@ -23,7 +23,7 @@ agent-reach install --env=auto
agent-reach doctor 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 ## Management
@@ -55,6 +55,8 @@ When a user asks to configure/enable any channel:
### Cookie 导入(所有需要登录的平台通用) ### Cookie 导入(所有需要登录的平台通用)
> ⚠️ **重要提醒:** 使用 Cookie 登录的平台存在封号风险,请提醒用户务必使用**专用小号**。
所有需要 Cookie 的平台(Twitter、小红书等),**优先使用 Cookie-Editor 导入** 所有需要 Cookie 的平台(Twitter、小红书等),**优先使用 Cookie-Editor 导入**
1. 用户在自己的浏览器登录对应平台 1. 用户在自己的浏览器登录对应平台
@@ -74,23 +76,28 @@ When a user asks to configure/enable any channel:
## Using Upstream Tools Directly ## 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 ```bash
# Search tweets # Search tweets
bird search "query" --json -n 10 xreach search "query" --json -n 10
# Read a specific tweet # 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 # Read a user's timeline
bird timeline @username --json -n 20 xreach tweets @username --json -n 20
``` ```
### YouTube (yt-dlp) ### 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 ```bash
# Get video metadata # Get video metadata
yt-dlp --dump-json "https://www.youtube.com/watch?v=xxx" 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) # Search (yt-dlp ytsearch)
yt-dlp --dump-json "ytsearch5:query" 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) ### Bilibili (yt-dlp)
> ⚠️ 服务器 IP 可能被 Bilibili 拦截(412 错误)。建议通过代理访问,或加 `--cookies-from-browser chrome`。
```bash ```bash
# Get video metadata # Get video metadata
yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx" yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx"
# Download subtitles # 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" 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) ### 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) ### 小红书 / XiaoHongShu (mcporter + xiaohongshu-mcp)
> ⚠️ 需要登录。使用 Cookie-Editor 导入 cookies 或扫码登录。
```bash ```bash
# Search notes # 搜索笔记
mcporter call 'xiaohongshu.search_feeds(keyword: "query")' mcporter call 'xiaohongshu.search_feeds(keyword: "query")'
# Read a note # 获取笔记详情(含评论)
mcporter call 'xiaohongshu.get_feed_detail(feed_id: "xxx", xsec_token: "yyy")' 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) ### 抖音 / Douyin (mcporter + douyin-mcp-server)
```bash ```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"` 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) ### RSS (feedparser)
```python ```python
@@ -246,7 +302,7 @@ for e in d.entries[:5]:
### Twitter "fetch failed" ### 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` 1. Ensure `undici` is installed: `npm install -g undici`
2. Configure proxy: `agent-reach configure proxy http://user:pass@ip:port` 2. Configure proxy: `agent-reach configure proxy http://user:pass@ip:port`
3. If still failing, use transparent proxy (Clash TUN, Proxifier) 3. If still failing, use transparent proxy (Clash TUN, Proxifier)
+17
View File
@@ -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
View File
@@ -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 | | 💰 **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 | | 🔒 **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 | | 🤖 **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 | | 🩺 **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 | | Platform | Capabilities | Setup | Notes |
|----------|-------------|:-----:|-------| |----------|-------------|:-----:|-------|
| 🌐 **Web** | Read | Zero config | Any URL → clean Markdown ([Jina Reader](https://github.com/jina-ai/reader) ⭐9.8K) | | 🌐 **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 | | 📕 **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 | | 🎵 **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" | | 💼 **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直聘" | | 🏢 **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)) | | 🔍 **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 | | 📦 **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) | | 📺 **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 - "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's this GitHub repo about?" → `gh repo view owner/repo`
- "What does this video cover?" → `yt-dlp --dump-json URL` for subtitles - "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 - "Subscribe to this RSS" → `feedparser` to parse feeds
- "Search GitHub for LLM frameworks" → `gh search repos "LLM framework"` - "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.** 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 ### 🔌 Every Channel is Pluggable
@@ -181,7 +182,7 @@ Each platform maps to an upstream tool. **Don't like one? Swap it out.**
``` ```
channels/ channels/
├── web.py → Jina Reader ← swap to Firecrawl, Crawl4AI… ├── 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… ├── youtube.py → yt-dlp ← swap to YouTube API, Whisper…
├── github.py → gh CLI ← swap to REST API, PyGithub… ├── github.py → gh CLI ← swap to REST API, PyGithub…
├── bilibili.py → yt-dlp ← swap to bilibili-api… ├── 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 | | Scenario | Tool | Why |
|----------|------|-----| |----------|------|-----|
| Read web pages | [Jina Reader](https://github.com/jina-ai/reader) | 9.8K stars, free, no API key needed | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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. > 📌 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> <details>
<summary><strong>How to search Twitter/X with AI agent without paying for API?</strong></summary> <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>
<details> <details>
@@ -257,13 +259,13 @@ Yes! Agent Reach is an installer + configuration tool. Any AI coding agent that
<details> <details>
<summary><strong>Is Agent Reach free? Any API costs?</strong></summary> <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>
<details> <details>
<summary><strong>Free alternative to Twitter API for web scraping?</strong></summary> <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>
<details> <details>
@@ -282,7 +284,7 @@ Install douyin-mcp-server, then your agent can use `mcporter call 'douyin.parse_
## Credits ## 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 ## License
+30
View File
@@ -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
View File
@@ -20,7 +20,7 @@ Copy this to your AI Agent:
### Goal ### 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 ### ⚠️ Boundaries
@@ -41,7 +41,7 @@ pip install https://github.com/Panniantong/agent-reach/archive/main.zip
agent-reach install --env=auto 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):** **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: 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 导入(所有需要登录的平台通用):** > 🍪 **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`), > Agent Reach 已内置解决方案:安装时会自动安装 `undici``npm install -g undici`),
> 运行 bird 时自动注入 `EnvHttpProxyAgent`,让 fetch 走用户配置的代理。 > 运行 xreach 时自动注入 `EnvHttpProxyAgent`,让 fetch 走用户配置的代理。
> >
> **你(Agent)需要做的:** > **你(Agent)需要做的:**
> 1. 确认 `undici` 已全局安装:`npm list -g undici`,没有就装:`npm install -g undici` > 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 | | 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` | | YouTube | `yt-dlp` | `yt-dlp --dump-json URL` |
| Bilibili | `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"` | | Reddit | `curl` | `curl -s "https://reddit.com/r/xxx.json"` |
+39 -64
View File
@@ -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 就连不上 **原因:** xreach CLI 使用 Node.js `undici` 发请求。如果你的网络环境需要代理才能访问 x.com,需要明确传入代理参数
**解决方案(按推荐顺序):**
### 方案 1:使用透明代理 / TUN 模式(推荐)
让代理工具接管所有网络流量,这样 bird 的 fetch 也会走代理:
- **Clash Verge / Clash for Windows** 开启 TUN 模式或系统代理
- **ProxifierWindows):** 添加规则让 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 库的特征与真实浏览器不同。
**解决方案:** **解决方案:**
- **本地电脑:** 正常使用,一般不会被拦
- **服务器:** 使用 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
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "agent-reach" 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." description = "Give your AI Agent eyes to see the entire internet. Search + Read 10+ platforms."
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}
@@ -41,6 +41,13 @@ dependencies = [
browser = ["playwright>=1.40"] browser = ["playwright>=1.40"]
cookies = ["browser-cookie3>=0.19"] cookies = ["browser-cookie3>=0.19"]
all = ["playwright>=1.40", "mcp[cli]>=1.0", "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] [project.scripts]
agent-reach = "agent_reach.cli:main" agent-reach = "agent_reach.cli:main"
@@ -60,3 +67,20 @@ packages = ["agent_reach"]
[tool.hatch.build.targets.wheel.force-include] [tool.hatch.build.targets.wheel.force-include]
"agent_reach/guides" = "agent_reach/guides" "agent_reach/guides" = "agent_reach/guides"
"agent_reach/skill" = "agent_reach/skill" "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/"]
+50
View File
@@ -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
View File
@@ -1,114 +1,21 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Tests for the channel system.""" """Tests for channel registry basics."""
import pytest from agent_reach.channels import get_all_channels, get_channel
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
class TestChannelRouting: class TestChannelRegistry:
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"
def test_get_channel_by_name(self): def test_get_channel_by_name(self):
ch = get_channel("github") ch = get_channel("github")
assert ch is not None assert ch is not None
assert ch.name == "github" 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): def test_all_channels_registered(self):
channels = get_all_channels() channels = get_all_channels()
names = [ch.name for ch in channels] names = [ch.name for ch in channels]
assert "web" in names assert "web" in names
assert "github" in names assert "github" in names
assert "twitter" 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"
+85
View File
@@ -2,7 +2,9 @@
"""Tests for Agent Reach CLI.""" """Tests for Agent Reach CLI."""
import pytest import pytest
import requests
from unittest.mock import patch from unittest.mock import patch
import agent_reach.cli as cli
from agent_reach.cli import main from agent_reach.cli import main
@@ -27,3 +29,86 @@ class TestCLI:
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Agent Reach" in captured.out assert "Agent Reach" in captured.out
assert "" 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
View File
@@ -2,6 +2,7 @@
"""Tests for AgentReach core class.""" """Tests for AgentReach core class."""
import pytest import pytest
from agent_reach.config import Config from agent_reach.config import Config
from agent_reach.core import AgentReach from agent_reach.core import AgentReach
@@ -16,14 +17,6 @@ class TestAgentReach:
def test_init(self, eyes): def test_init(self, eyes):
assert eyes.config is not None 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): def test_doctor(self, eyes):
results = eyes.doctor() results = eyes.doctor()
assert isinstance(results, dict) assert isinstance(results, dict)
+4 -3
View File
@@ -15,7 +15,7 @@ class TestDoctor:
def test_zero_config_channels_ok(self, tmp_config): def test_zero_config_channels_ok(self, tmp_config):
results = check_all(tmp_config) results = check_all(tmp_config)
assert results["web"]["status"] == "ok" 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["bilibili"]["status"] in ("ok", "warn") # warn on servers
assert results["rss"]["status"] == "ok" assert results["rss"]["status"] == "ok"
@@ -23,10 +23,11 @@ class TestDoctor:
results = check_all(tmp_config) results = check_all(tmp_config)
assert results["exa_search"]["status"] == "off" 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") tmp_config.set("exa_api_key", "test-key")
results = check_all(tmp_config) 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): def test_format_report(self, tmp_config):
results = check_all(tmp_config) results = check_all(tmp_config)