Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f65526cbaa | |||
| 5e6fc45785 | |||
| e570d08772 | |||
| cc90e10b19 | |||
| fc24fb699b | |||
| 380cfd565d | |||
| 2c766af477 | |||
| d7551bf19d | |||
| 3e5f9df5b8 | |||
| 0e8dd3f412 | |||
| 762824c590 | |||
| 447dc4acc4 | |||
| 373704a683 | |||
| 97e9e63f42 | |||
| 853e5190e4 | |||
| 7015b08063 | |||
| b0e010c6cc | |||
| 4ca570a39f | |||
| 9045fee67e | |||
| 5b07d95e7e | |||
| a827452881 | |||
| 781496540b | |||
| bdf692c391 | |||
| 40963eb6b7 | |||
| 6bc92a0ac7 | |||
| 5dfd29eafd | |||
| dd1af8fc17 | |||
| 7e264cbde6 | |||
| c2911cd4a2 | |||
| 84b474c79b | |||
| b6241e8e67 | |||
| 607f0d2c56 | |||
| 17624268a0 | |||
| 575cd3b479 | |||
| 0e21545bc5 | |||
| 28e6f07f8e | |||
| 0aee897ef4 | |||
| e36f737bb6 | |||
| a45147c53d | |||
| def2ce932e | |||
| a1cc229239 | |||
| e178470320 | |||
| 7acf3c1367 | |||
| 04fd645966 | |||
| d877c09cd1 | |||
| 65ff8d69d0 | |||
| 0f9cfe66ec | |||
| 23d4d5c611 | |||
| 75bc98a7eb | |||
| bc2d554e13 | |||
| 15f161e5b5 | |||
| 794455cc9f | |||
| 6548a50824 | |||
| 21214fd02a | |||
| c5a304dc20 | |||
| 66883060bc | |||
| f4b3758478 | |||
| d7c8ac81e9 | |||
| 1e0e6d1ae7 | |||
| e90fbfe194 | |||
| 3e90d51da9 | |||
| b82ab1ae10 | |||
| 9a8b245e39 | |||
| 5d046ecb61 | |||
| b4ed28e48f | |||
| f9b7241d47 | |||
| 526616e13f | |||
| e51bd91e82 | |||
| e575ddbcd1 | |||
| d793afaba6 | |||
| ca2e85520b | |||
| 3be2a64120 | |||
| 470c1288d0 | |||
| 881d9b96e2 | |||
| 15563051a1 | |||
| 0c6c45a010 | |||
| 7e66abe636 | |||
| e6406500f3 | |||
| afc8d0e3ee | |||
| 90bb4e0266 | |||
| 7ae0cd8c0a | |||
| c1af1cad14 | |||
| ded444000b | |||
| 5fb0e10167 | |||
| ef8134bba4 | |||
| be9f9ab838 | |||
| 8afd19f473 | |||
| 1f4154a897 | |||
| 1442852471 | |||
| 5461ca4030 | |||
| ab843071ef | |||
| 6c5346cfa4 | |||
| 77c62c76fb | |||
| 7c64579318 | |||
| 383004e06a | |||
| f52fb67232 | |||
| 59b594eccc | |||
| 3416c0f6d3 | |||
| 31f00b8d78 | |||
| ba565bd096 | |||
| 7018d77d58 | |||
| ce9b365491 | |||
| 70a0bb8af8 | |||
| 6737ba6dbd | |||
| f5f4dea075 | |||
| 233cc6fba8 | |||
| ae5812cf7f | |||
| 6af00ce4da | |||
| 3321bf7d34 | |||
| 00d80d2169 | |||
| 55efebd395 | |||
| 16733df4fc | |||
| f3daa5cf97 | |||
| 6ddcfe2b57 | |||
| 0d91d6834a | |||
| dc64f15839 | |||
| c912051173 | |||
| 22d7ad766a | |||
| 5895f3992d | |||
| 9129fc5f27 | |||
| 3fe100cd9a | |||
| c2cbccfed9 | |||
| 0a5c06633c | |||
| 7942f632e5 | |||
| fedbf95f61 | |||
| 1d3fd43fb3 | |||
| c02ffc59a5 | |||
| db20c5d5d3 | |||
| 3a3d38acce | |||
| 4b7d55111f | |||
| eda80b89b5 | |||
| 75a2213510 | |||
| 8a90e52fde | |||
| 9d0191d5eb | |||
| f3d11fe89d | |||
| df34436942 | |||
| cd94706d3b | |||
| 35073aed54 | |||
| 94b428b0b9 | |||
| 105b477a31 | |||
| 92963f0496 | |||
| e5e20a1154 | |||
| b4d189b536 | |||
| 27d3d545e6 |
@@ -13,3 +13,6 @@
|
|||||||
|
|
||||||
# Groq Whisper (optional, for video transcription) — https://console.groq.com
|
# Groq Whisper (optional, for video transcription) — https://console.groq.com
|
||||||
# GROQ_API_KEY=gsk_your_key_here
|
# GROQ_API_KEY=gsk_your_key_here
|
||||||
|
|
||||||
|
# OpenAI Whisper (optional fallback when Groq is rate-limited) — https://platform.openai.com
|
||||||
|
# OPENAI_API_KEY=sk-your_key_here
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12"]
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -28,3 +28,43 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
pytest -q
|
pytest -q
|
||||||
|
|
||||||
|
# Editable installs (-e) never exercise wheel packaging, so a broken wheel
|
||||||
|
# can pass tests and still fail every real `pip install` from source.
|
||||||
|
# This job builds the actual wheel and installs it into a clean venv.
|
||||||
|
wheel-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Build wheel
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip build
|
||||||
|
python -m build
|
||||||
|
|
||||||
|
- name: Verify wheel has no duplicate entries and ships data files
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import glob, zipfile, collections
|
||||||
|
whl = glob.glob("dist/*.whl")[0]
|
||||||
|
names = zipfile.ZipFile(whl).namelist()
|
||||||
|
dupes = [n for n, c in collections.Counter(names).items() if c > 1]
|
||||||
|
assert not dupes, f"duplicate entries in wheel: {dupes}"
|
||||||
|
assert "agent_reach/skill/SKILL.md" in names, "SKILL.md missing from wheel"
|
||||||
|
for prefix in ("agent_reach/guides/", "agent_reach/scripts/", "agent_reach/skill/references/"):
|
||||||
|
assert any(n.startswith(prefix) for n in names), f"{prefix} missing from wheel"
|
||||||
|
print(f"wheel OK: {len(names)} entries, no duplicates, data files present")
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Smoke-install wheel into clean venv
|
||||||
|
run: |
|
||||||
|
python -m venv /tmp/smoke
|
||||||
|
/tmp/smoke/bin/pip install --quiet dist/*.whl
|
||||||
|
/tmp/smoke/bin/agent-reach version
|
||||||
|
cd /tmp && /tmp/smoke/bin/python -c "import agent_reach; from importlib.resources import files; assert (files('agent_reach')/'skill'/'SKILL.md').is_file(); print('SKILL.md ships in site-packages OK')"
|
||||||
|
|||||||
@@ -8,3 +8,7 @@ build/
|
|||||||
.env
|
.env
|
||||||
.agent-reach/
|
.agent-reach/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Claude Code personal permission settings — local only, never commit
|
||||||
|
.claude/settings.local.json
|
||||||
|
uv.lock
|
||||||
|
|||||||
@@ -6,6 +6,41 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-03-27
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes / 修复
|
||||||
|
|
||||||
|
#### 📈 Xueqiu (雪球) — 全面修复
|
||||||
|
|
||||||
|
- **修复 400 错误根本原因:** `_ensure_cookies()` 仅访问首页只能获取 `acw_tc`(防 DDoS token),`xq_a_token` 由雪球前端 JS 动态生成,无法通过纯 HTTP 请求获取。新增三级 cookie 加载策略:① 读取 config 文件(`--from-browser` 保存的)→ ② 自动从本地 Chrome 浏览器提取(需安装 browser-cookie3)→ ③ homepage fallback
|
||||||
|
- **修复 User-Agent:** `"agent-reach/1.0"` 被雪球反爬系统识别拒绝,改为真实 Chrome UA
|
||||||
|
- **修复缺失 `Referer` 头:** 所有 API 请求加上 `Referer: https://xueqiu.com/`
|
||||||
|
- **修复 `get_hot_posts()` 端点:** 原端点 `/statuses/hot/listV3.json` 已废弃(返回空 body),改为 `/v4/statuses/public_timeline_by_category.json`,正确解析 `item.data` JSON 字符串获取 author/likes/text
|
||||||
|
- **修复 `urllib.request.quote` → `urllib.parse.quote`:** 明确使用正确模块
|
||||||
|
- **修复 `configure --from-browser` 不提取雪球 Cookie:** `PLATFORM_SPECS` 加入 Xueqiu,检测 `xq_a_token` 存在才保存
|
||||||
|
- **修正文档误导:** README/SKILL.md 中"无需配置"/"public API, no login required" → 准确描述需要 browser cookie
|
||||||
|
- **改善错误信息:** `check()` 失败时提示 `configure --from-browser chrome` 而非"可能需要代理"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-03-12
|
||||||
|
|
||||||
|
### 🆕 New Channels / 新增渠道
|
||||||
|
|
||||||
|
#### 💻 V2EX
|
||||||
|
- Hot topics, node topics, topic detail + replies, user profile via public JSON API
|
||||||
|
- Zero config — no auth, no proxy, no API key required
|
||||||
|
- `get_hot_topics(limit)`, `get_node_topics(node_name, limit)`, `get_topic(id)`, `get_user(username)`
|
||||||
|
- 通过公开 JSON API 获取热门帖子、节点帖子、帖子详情+回复、用户信息
|
||||||
|
- 零配置,无需认证、无需代理、无需 API Key
|
||||||
|
|
||||||
|
### 📈 Improvements / 改进
|
||||||
|
|
||||||
|
- Channel count: 14 → 15
|
||||||
|
- 渠道数量:14 → 15
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.1.0] - 2025-02-25
|
## [1.1.0] - 2025-02-25
|
||||||
|
|
||||||
### 🆕 New Channels / 新增渠道
|
### 🆕 New Channels / 新增渠道
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Project
|
||||||
|
Agent Reach — Python CLI + library that gives AI agents read/search access to 13 internet platforms.
|
||||||
|
Positioning: installer + doctor + config tool. NOT a wrapper — after install, agents call upstream tools directly.
|
||||||
|
Repo: github.com/Panniantong/Agent-Reach | License: MIT | Version: 1.5.0
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- `pip install -e .` — Dev install
|
||||||
|
- `pytest tests/ -v` — All tests
|
||||||
|
- `pytest tests/test_cli.py -v` — CLI tests only
|
||||||
|
- `bash test.sh` — Full integration test (creates venv, installs, runs doctor + channel tests)
|
||||||
|
- `python -m agent_reach.cli doctor` — Run diagnostics
|
||||||
|
- `python -m agent_reach.cli install --env=auto` — Auto-configure
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
- `agent_reach/cli.py` — CLI entry point (argparse)
|
||||||
|
- `agent_reach/core.py` — Core read/search routing logic
|
||||||
|
- `agent_reach/config.py` — Config management (YAML, env vars)
|
||||||
|
- `agent_reach/doctor.py` — Diagnostics engine
|
||||||
|
- `agent_reach/channels/` — One file per platform (twitter.py, reddit.py, youtube.py, etc.)
|
||||||
|
- `agent_reach/channels/base.py` — Base channel class (all channels inherit from this)
|
||||||
|
- `agent_reach/integrations/mcp_server.py` — MCP server integration
|
||||||
|
- `agent_reach/skill/` — OpenClaw skill files
|
||||||
|
- `agent_reach/guides/` — Usage guides
|
||||||
|
- `tests/` — pytest tests
|
||||||
|
- `config/mcporter.json` — MCP tool config
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- Python 3.10+ with type hints
|
||||||
|
- Each channel is a single file in `channels/`, inherits from `BaseChannel`
|
||||||
|
- Channel contract: must implement `can_handle(url)`, `read(url)`, `search(query)`, `check()` methods
|
||||||
|
- Use `loguru` for logging, `rich` for CLI output
|
||||||
|
- Commit format: `type(scope): message` (one commit = one thing)
|
||||||
|
- All upstream tool calls go through public API/CLI, never hack internals
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- NEVER modify upstream open source projects' source code
|
||||||
|
- Agent Reach is a "glue layer" — only route and call, don't reimagine
|
||||||
|
- Version in THREE places must match: `pyproject.toml`, `__init__.py`, `tests/test_cli.py`
|
||||||
|
- Always new branch for changes, PR to main, never push to main directly
|
||||||
|
- Run `pytest tests/ -v` before committing — all tests must pass
|
||||||
|
- Cookie-based auth (Twitter, XHS): use Cookie-Editor export method only, no QR scan
|
||||||
|
- XHS login: Cookie-Editor browser export only (QR will hang)
|
||||||
@@ -4,14 +4,18 @@
|
|||||||
<strong>给你的 AI Agent 一键装上互联网能力</strong>
|
<strong>给你的 AI Agent 一键装上互联网能力</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
当下最稳的接入方式,替你选好、装好、体检好——接入方式会换代,你不用操心
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.8+"></a>
|
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
|
||||||
<a href="https://github.com/Panniantong/agent-reach/stargazers"><img src="https://img.shields.io/github/stars/Panniantong/agent-reach?style=for-the-badge" alt="GitHub Stars"></a>
|
<a href="https://github.com/Panniantong/agent-reach/stargazers"><img src="https://img.shields.io/github/stars/Panniantong/agent-reach?style=for-the-badge" alt="GitHub Stars"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#快速上手">快速开始</a> · <a href="docs/README_en.md">English</a> · <a href="#支持的平台">支持平台</a> · <a href="#设计理念">设计理念</a>
|
<a href="#快速上手">快速开始</a> · <a href="docs/README_en.md">English</a> · <a href="docs/README_ja.md">日本語</a> · <a href="docs/README_ko.md">한국어</a> · <a href="#支持的平台">支持平台</a> · <a href="#设计理念">设计理念</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -24,7 +28,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
|||||||
- 🐦 "帮我搜一下推特上大家怎么评价这个产品" → **搜不了**,Twitter API 要付费
|
- 🐦 "帮我搜一下推特上大家怎么评价这个产品" → **搜不了**,Twitter API 要付费
|
||||||
- 📖 "去 Reddit 上看看有没有人遇到过同样的 bug" → **403 被封**,服务器 IP 被拒
|
- 📖 "去 Reddit 上看看有没有人遇到过同样的 bug" → **403 被封**,服务器 IP 被拒
|
||||||
- 📕 "帮我看看小红书上这个品的口碑" → **打不开**,必须登录才能看
|
- 📕 "帮我看看小红书上这个品的口碑" → **打不开**,必须登录才能看
|
||||||
- 📺 "B站上有个技术视频,帮我总结一下" → **连不上**,海外/服务器 IP 被屏蔽
|
- 📺 "B站上有个技术视频,帮我总结一下" → **拿不到**,通用下载工具被 B站风控全面拦截
|
||||||
- 🔍 "帮我在网上搜一下最新的 LLM 框架对比" → **没有好用的搜索**,要么付费要么质量差
|
- 🔍 "帮我在网上搜一下最新的 LLM 框架对比" → **没有好用的搜索**,要么付费要么质量差
|
||||||
- 🌐 "帮我看看这个网页写了啥" → **抓回来一堆 HTML 标签**,根本没法读
|
- 🌐 "帮我看看这个网页写了啥" → **抓回来一堆 HTML 标签**,根本没法读
|
||||||
- 📦 "这个 GitHub 仓库是干嘛的?Issue 里说了什么?" → 能用,但认证配置很麻烦
|
- 📦 "这个 GitHub 仓库是干嘛的?Issue 里说了什么?" → 能用,但认证配置很麻烦
|
||||||
@@ -42,6 +46,12 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
|||||||
|
|
||||||
复制给你的 Agent,几分钟后它就能读推特、搜 Reddit、看 YouTube、刷小红书了。
|
复制给你的 Agent,几分钟后它就能读推特、搜 Reddit、看 YouTube、刷小红书了。
|
||||||
|
|
||||||
|
**已经装过了?更新也是一句话:**
|
||||||
|
|
||||||
|
```
|
||||||
|
帮我更新 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
```
|
||||||
|
|
||||||
> ⭐ **Star 这个项目**,我们会持续追踪各平台的变化、接入新的渠道。你不用自己盯——平台封了我们修,有新渠道我们加。
|
> ⭐ **Star 这个项目**,我们会持续追踪各平台的变化、接入新的渠道。你不用自己盯——平台封了我们修,有新渠道我们加。
|
||||||
|
|
||||||
### ✅ 在你用之前,你可能想知道
|
### ✅ 在你用之前,你可能想知道
|
||||||
@@ -50,7 +60,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 💰 **完全免费** | 所有工具开源、所有 API 免费。唯一可能花钱的是服务器代理($1/月),本地电脑不需要 |
|
| 💰 **完全免费** | 所有工具开源、所有 API 免费。唯一可能花钱的是服务器代理($1/月),本地电脑不需要 |
|
||||||
| 🔒 **隐私安全** | Cookie 只存在你本地,不上传不外传。代码完全开源,随时可审查 |
|
| 🔒 **隐私安全** | Cookie 只存在你本地,不上传不外传。代码完全开源,随时可审查 |
|
||||||
| 🔄 **持续更新** | 底层工具(yt-dlp、xreach、Jina Reader 等)定期追踪更新到最新版,你不用自己盯 |
|
| 🔄 **持续换代** | 每个平台都是「首选 + 备选」多后端路由。某个接入方式失效了,我们换下一个,你无感(2026-06 实例:yt-dlp 被 B站风控封死 → 已切换 bili-cli,用户零操作) |
|
||||||
| 🤖 **兼容所有 Agent** | Claude Code、OpenClaw、Cursor、Windsurf……任何能跑命令行的 Agent 都能用 |
|
| 🤖 **兼容所有 Agent** | Claude Code、OpenClaw、Cursor、Windsurf……任何能跑命令行的 Agent 都能用 |
|
||||||
| 🩺 **自带诊断** | `agent-reach doctor` 一条命令告诉你哪个通、哪个不通、怎么修 |
|
| 🩺 **自带诊断** | `agent-reach doctor` 一条命令告诉你哪个通、哪个不通、怎么修 |
|
||||||
|
|
||||||
@@ -65,14 +75,14 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
|||||||
| 📡 **RSS** | 阅读任意 RSS/Atom 源 | — | 无需配置 |
|
| 📡 **RSS** | 阅读任意 RSS/Atom 源 | — | 无需配置 |
|
||||||
| 🔍 **全网搜索** | — | 全网语义搜索 | 自动配置(MCP 接入,免费无需 Key) |
|
| 🔍 **全网搜索** | — | 全网语义搜索 | 自动配置(MCP 接入,免费无需 Key) |
|
||||||
| 📦 **GitHub** | 读公开仓库 + 搜索 | 私有仓库、提 Issue/PR、Fork | 告诉 Agent「帮我登录 GitHub」 |
|
| 📦 **GitHub** | 读公开仓库 + 搜索 | 私有仓库、提 Issue/PR、Fork | 告诉 Agent「帮我登录 GitHub」 |
|
||||||
| 🐦 **Twitter/X** | 读单条推文 | 搜索推文、浏览时间线、发推 | 告诉 Agent「帮我配 Twitter」 |
|
| 🐦 **Twitter/X** | 读单条推文 | 搜索推文、浏览时间线、读长文 | 告诉 Agent「帮我配 Twitter」 |
|
||||||
| 📺 **B站** | 本地:字幕提取 + 搜索 | 服务器也能用 | 告诉 Agent「帮我配代理」 |
|
| 📺 **B站** | 搜索 + 视频详情(bili-cli,无需登录) | 字幕(OpenCLI) | 告诉 Agent「帮我配 B站」 |
|
||||||
| 📖 **Reddit** | 搜索(通过 Exa 免费) | 读帖子和评论 | 告诉 Agent「帮我配代理」 |
|
| 📖 **Reddit** | —(没有零配置路径:匿名接口已被封) | 搜索 + 读帖子和评论 | 桌面装 OpenCLI 用浏览器登录态;或 rdt-cli + Cookie |
|
||||||
| 📕 **小红书** | — | 阅读、搜索、发帖、评论、点赞 | 告诉 Agent「帮我配小红书」 |
|
| 📕 **小红书** | — | 搜索、阅读、评论 | 桌面装 OpenCLI(刷过小红书即可用);服务器用 xiaohongshu-mcp 扫码 |
|
||||||
| 🎵 **抖音** | — | 视频解析、无水印下载链接获取 | 告诉 Agent「帮我配抖音」 |
|
|
||||||
| 💼 **LinkedIn** | Jina Reader 读公开页面 | Profile 详情、公司页面、职位搜索 | 告诉 Agent「帮我配 LinkedIn」 |
|
| 💼 **LinkedIn** | Jina Reader 读公开页面 | Profile 详情、公司页面、职位搜索 | 告诉 Agent「帮我配 LinkedIn」 |
|
||||||
| 🏢 **Boss直聘** | Jina Reader 读职位页 | 搜索职位、向 HR 打招呼 | 告诉 Agent「帮我配 Boss直聘」 |
|
| 💻 **V2EX** | 热门帖子、节点帖子、帖子详情+回复、用户信息 | — | 无需配置 |
|
||||||
| 💬 **微信公众号** | — | 搜索 + 阅读公众号文章(全文 Markdown) | `pip install miku_ai camoufox[geoip]` |
|
| 📈 **雪球** | 股票行情、搜索股票、热门帖子、热门股票排行 | — | 告诉 Agent「帮我配雪球」 |
|
||||||
|
| 🎙️ **小宇宙播客** | — | 播客音频转文字(Whisper 转录,免费 Key) | 告诉 Agent「帮我配小宇宙播客」 |
|
||||||
|
|
||||||
> **不知道怎么配?不用查文档。** 直接告诉 Agent「帮我配 XXX」,它知道需要什么、会一步一步引导你。
|
> **不知道怎么配?不用查文档。** 直接告诉 Agent「帮我配 XXX」,它知道需要什么、会一步一步引导你。
|
||||||
>
|
>
|
||||||
@@ -85,6 +95,16 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
|||||||
|
|
||||||
## 快速上手
|
## 快速上手
|
||||||
|
|
||||||
|
> ⚠️ **OpenClaw 用户请先确认 exec 权限已开启**
|
||||||
|
>
|
||||||
|
> Agent Reach 依赖 Agent 执行 shell 命令(`pip install`、`mcporter`、`twitter` 等)。如果你的 OpenClaw 使用了默认的 `messaging` 工具配置,Agent 将无法执行命令。**安装前请先开启 exec 权限**:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> openclaw config set tools.profile "coding"
|
||||||
|
> ```
|
||||||
|
> 或在 `~/.openclaw/openclaw.json` 中设置 `"tools": { "profile": "coding" }`。
|
||||||
|
> 设置后重启 Gateway(`openclaw gateway restart`)并开启新对话即可。其他平台(Claude Code、Cursor、Windsurf 等)不受此限制。
|
||||||
|
|
||||||
复制这句话给你的 AI Agent(Claude Code、OpenClaw、Cursor 等):
|
复制这句话给你的 AI Agent(Claude Code、OpenClaw、Cursor 等):
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -93,6 +113,11 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
|||||||
|
|
||||||
就这一步。Agent 会自己完成剩下的所有事情。
|
就这一步。Agent 会自己完成剩下的所有事情。
|
||||||
|
|
||||||
|
> 🔄 **已安装过?** 更新也是一句话:
|
||||||
|
> ```
|
||||||
|
> 帮我更新 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
> ```
|
||||||
|
|
||||||
> 🛡️ **担心安全?** 可以用安全模式——不会自动装系统包,只告诉你需要什么:
|
> 🛡️ **担心安全?** 可以用安全模式——不会自动装系统包,只告诉你需要什么:
|
||||||
> ```
|
> ```
|
||||||
> 帮我安装 Agent Reach(安全模式):https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
|
> 帮我安装 Agent Reach(安全模式):https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
|
||||||
@@ -102,13 +127,14 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
|||||||
<details>
|
<details>
|
||||||
<summary>它会做什么?(点击展开)</summary>
|
<summary>它会做什么?(点击展开)</summary>
|
||||||
|
|
||||||
1. **安装 CLI 工具** — `pip install` 装好 `agent-reach` 命令行
|
1. **安装 CLI 工具** — `pip install` 装好 `agent-reach` 命令行(自带 yt-dlp、feedparser)
|
||||||
2. **安装系统依赖** — 自动检测并安装 Node.js、gh CLI、mcporter、xreach 等
|
2. **安装系统基建** — 自动检测并安装 Node.js、gh CLI、mcporter
|
||||||
3. **配置搜索引擎** — 通过 MCP 接入 Exa(免费,无需 API Key)
|
3. **配置搜索引擎** — 通过 MCP 接入 Exa(免费,无需 API Key)
|
||||||
4. **检测环境** — 判断是本地电脑还是服务器,给出对应的配置建议
|
4. **检测环境** — 判断是本地电脑还是服务器,给出对应的配置建议
|
||||||
5. **注册 SKILL.md** — 在 Agent 的 skills 目录安装使用指南,以后 Agent 遇到"搜推特"、"看视频"这类需求,会自动知道该调哪个上游工具
|
5. **注册 SKILL.md** — 在 Agent 的 skills 目录安装使用指南,以后 Agent 遇到"全网调研"、"搜推特"、"看视频"这类需求,会自动知道该调哪个上游工具
|
||||||
|
6. **问你要不要更多** — 默认只激活 6 个零配置渠道;小红书、Twitter、Reddit 这些需要登录的,Agent 会列菜单问你要哪些,点名才装
|
||||||
|
|
||||||
安装完之后,`agent-reach doctor` 一条命令告诉你每个渠道的状态。
|
安装完之后,`agent-reach doctor` 一条命令告诉你每个渠道的状态、当前走哪条路。
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -119,66 +145,60 @@ 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` 提取字幕
|
- "这个 YouTube 视频讲了什么" → `yt-dlp` 提取字幕
|
||||||
- "帮我看看这条推文" → `xreach tweet URL --json`
|
- "B站搜一下 AI 教程" → `bili search`(无需登录)
|
||||||
|
- "全网搜一下 LLM 框架对比" → Exa 语义搜索
|
||||||
- "订阅这个 RSS" → `feedparser` 解析
|
- "订阅这个 RSS" → `feedparser` 解析
|
||||||
- "搜一下 GitHub 上有什么 LLM 框架" → `gh search repos "LLM framework"`
|
|
||||||
|
|
||||||
**不需要记命令。** Agent 读了 SKILL.md 之后自己知道该调什么。
|
**不需要记命令。** Agent 读了 SKILL.md 之后自己知道该调什么。需要登录的平台(小红书、Twitter、Reddit),告诉 Agent「帮我配 XXX」即可解锁。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 设计理念
|
## 设计理念
|
||||||
|
|
||||||
**Agent Reach 是一个脚手架(scaffolding),不是框架。**
|
**Agent Reach 是一个能力层(capability layer),不是又一个工具。**
|
||||||
|
|
||||||
你给一个新 Agent 装环境的时候,总要花时间去找工具、装依赖、调配置——Twitter 用什么读?Reddit 怎么绕封?YouTube 字幕怎么提取?每次都要重新踩一遍。
|
它比任何具体实现高一层——负责**选型、安装、体检、路由**,不负责底层读取本身。读取由 Agent 直接调用上游工具完成,没有包装层。
|
||||||
|
|
||||||
Agent Reach 做的事情很简单:**帮你把这些选型和配置的活儿做完了。**
|
你给一个新 Agent 装环境的时候,总要花时间去找工具、装依赖、调配置——Twitter 用什么读?Reddit 怎么登录?小红书的 CLI 停更了换什么?每次都要重新踩一遍。Agent Reach 做的事情很简单:**当下最稳的接入方式,我们替你选好、装好、体检好。接入方式会换代(2026 年 3 月一批单平台 CLI 集体停更,我们换了路由),你不用操心。**
|
||||||
|
|
||||||
安装完成后,Agent 直接调用上游工具(xreach CLI、yt-dlp、mcporter、gh CLI 等),不需要经过 Agent Reach 的包装层。
|
### 🔌 每个平台 = 首选 + 备选的有序后端列表
|
||||||
|
|
||||||
### 🔌 每个渠道都是可插拔的
|
换接入方式 = 调整列表顺序,不是重写代码。`agent-reach doctor` 会告诉你每个平台**当前在用哪个后端**。
|
||||||
|
|
||||||
每个平台背后是一个独立的上游工具。**不满意?换掉就行。**
|
|
||||||
|
|
||||||
```
|
```
|
||||||
channels/
|
channels/
|
||||||
├── web.py → Jina Reader ← 可以换成 Firecrawl、Crawl4AI……
|
├── web.py → Jina Reader
|
||||||
├── twitter.py → xreach ← 可以换成 Nitter、官方 API……
|
├── twitter.py → twitter-cli ▸ OpenCLI ▸ bird
|
||||||
├── youtube.py → yt-dlp ← 可以换成 YouTube API、Whisper……
|
├── youtube.py → yt-dlp
|
||||||
├── github.py → gh CLI ← 可以换成 REST API、PyGithub……
|
├── github.py → gh CLI
|
||||||
├── bilibili.py → yt-dlp ← 可以换成 bilibili-api……
|
├── bilibili.py → bili-cli ▸ OpenCLI ▸ 搜索 API(yt-dlp 已被 B站风控封死,退役)
|
||||||
├── reddit.py → JSON API + Exa ← 可以换成 PRAW、Pushshift……
|
├── reddit.py → OpenCLI ▸ rdt-cli(无零配置路径,必须登录态)
|
||||||
├── xiaohongshu.py → mcporter MCP ← 可以换成其他 XHS 工具……
|
├── xiaohongshu.py → OpenCLI ▸ xiaohongshu-mcp ▸ xhs-cli
|
||||||
├── douyin.py → mcporter MCP ← 可以换成其他抖音工具……
|
├── linkedin.py → linkedin-mcp ▸ Jina Reader
|
||||||
├── linkedin.py → linkedin-mcp ← 可以换成 LinkedIn API……
|
├── rss.py → feedparser
|
||||||
├── bosszhipin.py → mcp-bosszp ← 可以换成其他招聘工具……
|
├── exa_search.py → Exa via mcporter
|
||||||
├── wechat.py → camoufox+miku ← 搜索+阅读微信公众号文章
|
|
||||||
├── rss.py → feedparser ← 可以换成 atoma……
|
|
||||||
├── exa_search.py → mcporter MCP ← 可以换成 Tavily、SerpAPI……
|
|
||||||
└── __init__.py → 渠道注册(doctor 检测用)
|
└── __init__.py → 渠道注册(doctor 检测用)
|
||||||
```
|
```
|
||||||
|
|
||||||
每个渠道文件只负责检测对应上游工具是否可用(`check()` 方法),给 `agent-reach doctor` 提供状态信息。实际的读取和搜索由 Agent 直接调用上游工具完成。
|
每个渠道文件按序**真实探测**各候选后端(不只是看命令存不存在),第一个完整可用的当选;坏掉的会给出修复处方。实际的读取和搜索由 Agent 直接调用上游工具完成。
|
||||||
|
|
||||||
### 当前选型
|
### 当前选型
|
||||||
|
|
||||||
| 场景 | 选型 | 为什么选它 |
|
| 场景 | 首选 | 备选 | 为什么这么选 |
|
||||||
|------|------|-----------|
|
|------|------|------|-----------|
|
||||||
| 读网页 | [Jina Reader](https://github.com/jina-ai/reader) | 9.8K Star,免费,不需要 API Key |
|
| 读网页 | [Jina Reader](https://github.com/jina-ai/reader) | — | 免费,不需要 API Key |
|
||||||
| 读推特 | [xreach](https://www.npmjs.com/package/xreach-cli) | Cookie 登录,免费。官方 API 按量付费(读一条 $0.005) |
|
| 读推特 | [twitter-cli](https://github.com/public-clis/twitter-cli) | [OpenCLI](https://github.com/jackwener/opencli) | 实测搜索稳定;OpenCLI 走浏览器登录态兜底 |
|
||||||
| 视频字幕 + 搜索 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 148K Star,YouTube + B站 + 1800 站通吃 |
|
| Reddit | [OpenCLI](https://github.com/jackwener/opencli)(桌面) | [rdt-cli](https://github.com/public-clis/rdt-cli) | 匿名接口已被封、官方 API 审批制——只剩登录态路线 |
|
||||||
| 搜全网 | [Exa](https://exa.ai) via [mcporter](https://github.com/steipete/mcporter) | AI 语义搜索,MCP 接入免 Key |
|
| YouTube 字幕 + 搜索 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | — | 154K Star,YouTube 仍是最佳(注意:不再用于 B站) |
|
||||||
| GitHub | [gh CLI](https://cli.github.com) | 官方工具,认证后完整 API 能力 |
|
| B站 | [bili-cli](https://github.com/public-clis/bilibili-cli) | OpenCLI ▸ 搜索 API | yt-dlp 被 B站风控 412 封死(2026-06 实测),bili-cli 无登录可搜可读 |
|
||||||
| 读 RSS | [feedparser](https://github.com/kurtmckee/feedparser) | Python 生态标准选择,2.3K Star |
|
| 搜全网 | [Exa](https://exa.ai) via [mcporter](https://github.com/nicobailon/mcporter) | — | AI 语义搜索,MCP 接入免 Key |
|
||||||
| 小红书 | [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) | ⭐9K+,Go 语言,Docker 一键部署 |
|
| GitHub | [gh CLI](https://cli.github.com) | — | 官方工具,认证后完整 API 能力 |
|
||||||
| 抖音 | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP 服务,无需登录,视频解析 + 无水印下载 |
|
| 读 RSS | [feedparser](https://github.com/kurtmckee/feedparser) | — | Python 生态标准选择 |
|
||||||
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+,MCP 服务,浏览器自动化 |
|
| 小红书 | [OpenCLI](https://github.com/jackwener/opencli)(桌面) | [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp)(服务器)▸ xhs-cli | xhs-cli 作者已转投 OpenCLI(24K Star);浏览器登录态零摩擦 |
|
||||||
| Boss直聘 | [mcp-bosszp](https://github.com/mucsbr/mcp-bosszp) | MCP 服务,支持职位搜索和打招呼 |
|
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP 服务,浏览器自动化 |
|
||||||
| 微信公众号 | [wechat-article-for-ai](https://github.com/bzd6661/wechat-article-for-ai) + [miku_ai](https://github.com/GobinFan/Miku_Spider) | Camoufox 隐身浏览器读全文 + 搜狗搜索 |
|
|
||||||
|
|
||||||
> 📌 这些都是「当前选型」。不满意?换掉对应文件就行。这正是脚手架的意义。
|
> 📌 这些都是「当前选型」,基于真机实测定期复核。某条路失效了我们换下一条——`agent-reach doctor` 永远告诉你现在走的是哪条。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -261,19 +281,19 @@ Star 一下,下次需要的时候能找到。⭐
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong>AI Agent 怎么搜索 Twitter / X?不想付 API 费用</strong></summary>
|
<summary><strong>AI Agent 怎么搜索 Twitter / X?不想付 API 费用</strong></summary>
|
||||||
|
|
||||||
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` 搜索推文了。
|
Agent Reach 使用 [twitter-cli](https://github.com/public-clis/twitter-cli) 通过 Cookie 认证访问 Twitter,完全免费。安装:`pipx install twitter-cli`,确保浏览器已登录 x.com,Agent 就可以用 `twitter search "关键词"` 搜索、`twitter tweet URL` 读推文了。
|
||||||
</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 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`.
|
Agent Reach uses twitter-cli with cookie auth — zero API fees. Install with `pipx install twitter-cli`, make sure you're logged into x.com in your browser, then your agent can search with `twitter search "query"` and read tweets with `twitter tweet URL`.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Reddit 返回 403 / 服务器 IP 被封怎么办?</strong></summary>
|
<summary><strong>Reddit 返回 403 怎么办?</strong></summary>
|
||||||
|
|
||||||
Reddit 封锁数据中心 IP。配置一个住宅代理即可解决:`agent-reach configure proxy http://user:pass@ip:port`。推荐 Webshare ($1/月)。本地电脑一般不会遇到这个问题。
|
Reddit 所有访问都需要登录态(匿名接口已被全面封锁,官方 API 需人工审批)。桌面首选 **OpenCLI**:浏览器里登录过 reddit.com 即可直接 `opencli reddit search "关键词"`。备选 [rdt-cli](https://github.com/public-clis/rdt-cli):`pipx install 'git+https://github.com/public-clis/rdt-cli.git@5e4fb3720d5c174e976cd425ccc3b879d52cac66'`(与代码同一钉定版本,PyPI 落后),然后 `rdt login`。中国大陆网络访问 Reddit 需要代理。
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -285,33 +305,50 @@ Reddit 封锁数据中心 IP。配置一个住宅代理即可解决:`agent-rea
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong>怎么让 AI Agent 读小红书?</strong></summary>
|
<summary><strong>怎么让 AI Agent 读小红书?</strong></summary>
|
||||||
|
|
||||||
小红书需要通过 Docker 运行一个 MCP 服务。安装 Docker 后,运行 `agent-reach install` 会自动配置。之后 Agent 就能用 `mcporter call 'xiaohongshu.get_feed_detail(...)'` 读取笔记或 `mcporter call 'xiaohongshu.search_feeds(keyword: "关键词")'` 搜索了。
|
桌面电脑首选 **OpenCLI**(`agent-reach install --channels opencli`)——它复用你浏览器里的登录态,平时刷过小红书就直接能用,零配置;装完在 Chrome 商店点一次「添加扩展」即可。之后 Agent 用 `opencli xiaohongshu search "关键词"` 搜索、`opencli xiaohongshu note URL` 读笔记。服务器上用 [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp)(自带无头浏览器,扫码登录)。已装过 xhs-cli 的老用户不受影响,它仍是备选后端(上游 2026-03 起停更,不推荐新装)。
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>怎么让 AI Agent 解析抖音视频?</strong></summary>
|
|
||||||
|
|
||||||
安装 douyin-mcp-server 后,Agent 就能用 `mcporter call 'douyin.parse_douyin_video_info(share_link: "分享链接")'` 解析视频信息、获取无水印下载链接。不需要登录,把抖音分享链接发给 Agent 就行。详见 https://github.com/yzfly/douyin-mcp-server
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Compatible with Claude Code / Cursor / OpenClaw / Windsurf?</strong></summary>
|
<summary><strong>Compatible with Claude Code / Cursor / OpenClaw / Windsurf?</strong></summary>
|
||||||
|
|
||||||
Yes! Agent Reach is an installer + configuration tool — any AI coding agent that can run shell commands can use it. Works with Claude Code, Cursor, OpenClaw, Windsurf, Codex, and more. Just `pip install agent-reach`, run `agent-reach install`, and the agent can start using the upstream tools immediately.
|
Yes! Agent Reach is an installer + configuration tool — any AI coding agent that can run shell commands can use it. Works with Claude Code, Cursor, OpenClaw, Windsurf, Codex, and more. Just `pip install agent-reach`, run `agent-reach install`, and the agent can start using the upstream tools immediately.
|
||||||
|
|
||||||
|
**OpenClaw note:** If your OpenClaw is using the default `messaging` tool profile, the agent won't be able to run shell commands. Enable exec first: `openclaw config set tools.profile "coding"` (or set `"tools": { "profile": "coding" }` in `~/.openclaw/openclaw.json`), then restart the Gateway and start a new conversation before installing.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<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 (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.
|
100% free. All backends are open-source tools (OpenCLI, twitter-cli, bili-cli, rdt-cli, yt-dlp, Jina Reader, Exa, xiaohongshu-mcp, etc.) that don't require paid API keys. The only optional cost is a residential proxy (~$1/month) if your network blocks Reddit/Twitter (e.g. mainland China).
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
[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)
|
[OpenCLI](https://github.com/jackwener/opencli) · [twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server)
|
||||||
|
|
||||||
|
## 联系
|
||||||
|
|
||||||
|
- 📧 **Email:** pnt01@foxmail.com
|
||||||
|
- 🐦 **Twitter/X:** [@Neo_Reidlab](https://x.com/Neo_Reidlab)
|
||||||
|
|
||||||
|
交流或合作可加微信,拉你进交流群:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/wechat-group-qr.jpg" width="280" alt="WeChat QR">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> Bug 反馈和功能请求请用 [GitHub Issues](https://github.com/Panniantong/Agent-Reach/issues),更容易跟踪。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|
||||||
|
## 友情链接
|
||||||
|
|
||||||
|
[腾讯云 OpenClaw](https://www.tencentcloud.com/act/pro/intl-openclaw?referral_code=G76Y819A&lang=zh&pg=) — 在腾讯云Lighthouse秒级部署OpenClaw全能助手,可通过对话丝滑接入Agent Reach,给你的OpenClaw一键装上互联网能力。
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#Panniantong/Agent-Reach&Date)
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---------|-----------|
|
||||||
|
| Latest | ✅ Yes |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you discover a security vulnerability in Agent-Reach, please report
|
||||||
|
it responsibly by using GitHub's private security advisory feature:
|
||||||
|
|
||||||
|
👉 **[Report a vulnerability](https://github.com/Panniantong/Agent-Reach/security/advisories/new)**
|
||||||
|
|
||||||
|
Please do NOT open a public GitHub issue for security vulnerabilities.
|
||||||
|
|
||||||
|
## What to Include
|
||||||
|
|
||||||
|
- Description of the vulnerability
|
||||||
|
- Steps to reproduce
|
||||||
|
- Affected versions
|
||||||
|
- Potential impact
|
||||||
|
- Suggested fix (if any)
|
||||||
|
|
||||||
|
## Response Timeline
|
||||||
|
|
||||||
|
- Acknowledgement within **48 hours**
|
||||||
|
- Status update within **7 days**
|
||||||
|
- Fix timeline communicated within **14 days**
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The following are considered in scope:
|
||||||
|
- Authentication and authorization bypass
|
||||||
|
- Remote code execution
|
||||||
|
- Path traversal / arbitrary file read
|
||||||
|
- Server-Side Request Forgery (SSRF)
|
||||||
|
- Injection vulnerabilities (SQL, command, prompt)
|
||||||
|
- Sensitive data exposure
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Vulnerabilities in dependencies (report to the dependency maintainer)
|
||||||
|
- Social engineering attacks
|
||||||
|
- Denial of service via resource exhaustion
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
We appreciate responsible disclosure and will credit researchers
|
||||||
|
in our release notes unless anonymity is requested.
|
||||||
@@ -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.3.0"
|
__version__ = "1.5.0"
|
||||||
__author__ = "Neo Reid"
|
__author__ = "Neo Reid"
|
||||||
|
|
||||||
from agent_reach.core import AgentReach
|
from agent_reach.core import AgentReach
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Cross-channel backends.
|
||||||
|
|
||||||
|
A backend here is an upstream runtime that serves MULTIPLE channels
|
||||||
|
(e.g. OpenCLI covers xiaohongshu/reddit/bilibili/twitter through one
|
||||||
|
browser session), as opposed to the per-platform tools probed inside
|
||||||
|
each channel file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .opencli import ( # noqa: F401
|
||||||
|
OPENCLI_EXTENSION_URL,
|
||||||
|
OPENCLI_PACKAGE,
|
||||||
|
OpenCLIStatus,
|
||||||
|
opencli_status,
|
||||||
|
opencli_summary,
|
||||||
|
)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""OpenCLI backend probing.
|
||||||
|
|
||||||
|
OpenCLI (github.com/jackwener/opencli) drives the user's real Chrome via a
|
||||||
|
browser-bridge extension + local daemon, reusing existing login sessions —
|
||||||
|
zero per-platform configuration, desktop-only (no headless).
|
||||||
|
|
||||||
|
Probing notes (verified live):
|
||||||
|
- `opencli doctor` AUTO-STARTS the daemon — a side effect, so health
|
||||||
|
checks must use `opencli daemon status` (pure query) instead.
|
||||||
|
- Exit codes are always 0; status must be parsed from text output.
|
||||||
|
- "Extension: disconnected" does NOT mean unusable: the extension's
|
||||||
|
service worker sleeps and any real opencli command wakes it up
|
||||||
|
(verified: status flips disconnected→connected after one call).
|
||||||
|
Since daemon status can't tell "sleeping" from "never installed",
|
||||||
|
we check Chrome's Extensions directory on disk to disambiguate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from agent_reach.probe import probe_command
|
||||||
|
|
||||||
|
OPENCLI_PACKAGE = "@jackwener/opencli"
|
||||||
|
OPENCLI_EXTENSION_ID = "ildkmabpimmkaediidaifkhjpohdnifk"
|
||||||
|
OPENCLI_EXTENSION_URL = (
|
||||||
|
f"https://chromewebstore.google.com/detail/opencli/{OPENCLI_EXTENSION_ID}"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Chrome-family profile roots that contain <Profile>/Extensions/<id>/
|
||||||
|
_CHROME_PROFILE_ROOTS = (
|
||||||
|
"~/Library/Application Support/Google/Chrome", # macOS Chrome
|
||||||
|
"~/Library/Application Support/Chromium", # macOS Chromium
|
||||||
|
"~/.config/google-chrome", # Linux Chrome
|
||||||
|
"~/.config/chromium", # Linux Chromium
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extension_installed_on_disk() -> bool:
|
||||||
|
"""True if the OpenCLI extension exists in any Chrome profile.
|
||||||
|
|
||||||
|
Store-installed extensions always live under
|
||||||
|
<profile>/Extensions/<extension id>/ — this disambiguates a sleeping
|
||||||
|
service worker from a never-installed extension. Dev installs via
|
||||||
|
"Load unpacked" are not covered (those users can read `opencli doctor`).
|
||||||
|
"""
|
||||||
|
roots = [os.path.expanduser(p) for p in _CHROME_PROFILE_ROOTS]
|
||||||
|
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||||
|
if local_app_data: # Windows
|
||||||
|
roots.append(os.path.join(local_app_data, "Google", "Chrome", "User Data"))
|
||||||
|
for root in roots:
|
||||||
|
if glob.glob(os.path.join(root, "*", "Extensions", OPENCLI_EXTENSION_ID)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OpenCLIStatus:
|
||||||
|
installed: bool = False
|
||||||
|
broken: bool = False
|
||||||
|
daemon_running: bool = False
|
||||||
|
extension_connected: bool = False
|
||||||
|
extension_installed: bool = False
|
||||||
|
version: str = ""
|
||||||
|
hint: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Usable now or on first call.
|
||||||
|
|
||||||
|
A live connection counts, and so does an installed-but-sleeping
|
||||||
|
extension: its service worker wakes on the first real command.
|
||||||
|
"""
|
||||||
|
return self.installed and not self.broken and (
|
||||||
|
self.extension_connected or self.extension_installed
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def opencli_status(timeout: int = 10) -> OpenCLIStatus:
|
||||||
|
"""Probe OpenCLI install + daemon/extension state without side effects."""
|
||||||
|
version_probe = probe_command(
|
||||||
|
"opencli", ["--version"], timeout=timeout, package=OPENCLI_PACKAGE
|
||||||
|
)
|
||||||
|
if version_probe.status == "missing":
|
||||||
|
return OpenCLIStatus(installed=False)
|
||||||
|
if not version_probe.ok:
|
||||||
|
return OpenCLIStatus(
|
||||||
|
installed=True,
|
||||||
|
broken=True,
|
||||||
|
hint=(
|
||||||
|
"opencli 命令存在但无法执行(node 环境损坏),重装:\n"
|
||||||
|
f" npm install -g {OPENCLI_PACKAGE}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
st = OpenCLIStatus(installed=True, version=version_probe.output.strip())
|
||||||
|
|
||||||
|
daemon_probe = probe_command(
|
||||||
|
"opencli", ["daemon", "status"], timeout=timeout, package=OPENCLI_PACKAGE
|
||||||
|
)
|
||||||
|
output = daemon_probe.output if daemon_probe.ok else ""
|
||||||
|
# `opencli daemon status` prints lines like:
|
||||||
|
# Daemon: running (PID 37389) / Daemon: not running
|
||||||
|
# Extension: connected / Extension: disconnected
|
||||||
|
for line in output.splitlines():
|
||||||
|
line = line.strip().lower()
|
||||||
|
if line.startswith("daemon:"):
|
||||||
|
st.daemon_running = "not running" not in line and "running" in line
|
||||||
|
elif line.startswith("extension:"):
|
||||||
|
st.extension_connected = "disconnected" not in line and "connected" in line
|
||||||
|
|
||||||
|
if not st.extension_connected:
|
||||||
|
st.extension_installed = _extension_installed_on_disk()
|
||||||
|
if not st.extension_installed:
|
||||||
|
st.hint = (
|
||||||
|
"OpenCLI 已安装,但 Chrome 扩展未安装。\n"
|
||||||
|
f" 1. 安装扩展(需手动点一次):{OPENCLI_EXTENSION_URL}\n"
|
||||||
|
" 2. 保持 Chrome 打开,运行 `opencli doctor` 验证"
|
||||||
|
)
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def opencli_summary(st: OpenCLIStatus) -> str:
|
||||||
|
"""One-line state description for channel messages / install output."""
|
||||||
|
if not st.installed:
|
||||||
|
return "OpenCLI 未安装"
|
||||||
|
if st.broken:
|
||||||
|
return "OpenCLI 无法执行(node 环境损坏)"
|
||||||
|
if st.extension_connected:
|
||||||
|
return f"OpenCLI 可用(浏览器登录态,v{st.version})"
|
||||||
|
if st.ready:
|
||||||
|
return "OpenCLI 可用(扩展睡眠中,调用时自动唤醒)"
|
||||||
|
if st.daemon_running:
|
||||||
|
return "OpenCLI 已安装,等待 Chrome 扩展安装"
|
||||||
|
return "OpenCLI 已安装(daemon 未运行,使用时自动启动;需 Chrome 扩展)"
|
||||||
@@ -16,13 +16,12 @@ from .rss import RSSChannel
|
|||||||
from .bilibili import BilibiliChannel
|
from .bilibili import BilibiliChannel
|
||||||
from .exa_search import ExaSearchChannel
|
from .exa_search import ExaSearchChannel
|
||||||
from .xiaohongshu import XiaoHongShuChannel
|
from .xiaohongshu import XiaoHongShuChannel
|
||||||
from .douyin import DouyinChannel
|
|
||||||
from .linkedin import LinkedInChannel
|
from .linkedin import LinkedInChannel
|
||||||
from .bosszhipin import BossZhipinChannel
|
from .xiaoyuzhou import XiaoyuzhouChannel
|
||||||
from .wechat import WeChatChannel
|
from .v2ex import V2EXChannel
|
||||||
|
from .xueqiu import XueqiuChannel
|
||||||
|
|
||||||
|
|
||||||
# Channel registry
|
|
||||||
ALL_CHANNELS: List[Channel] = [
|
ALL_CHANNELS: List[Channel] = [
|
||||||
GitHubChannel(),
|
GitHubChannel(),
|
||||||
TwitterChannel(),
|
TwitterChannel(),
|
||||||
@@ -30,10 +29,10 @@ ALL_CHANNELS: List[Channel] = [
|
|||||||
RedditChannel(),
|
RedditChannel(),
|
||||||
BilibiliChannel(),
|
BilibiliChannel(),
|
||||||
XiaoHongShuChannel(),
|
XiaoHongShuChannel(),
|
||||||
DouyinChannel(),
|
|
||||||
LinkedInChannel(),
|
LinkedInChannel(),
|
||||||
BossZhipinChannel(),
|
XiaoyuzhouChannel(),
|
||||||
WeChatChannel(),
|
V2EXChannel(),
|
||||||
|
XueqiuChannel(),
|
||||||
RSSChannel(),
|
RSSChannel(),
|
||||||
ExaSearchChannel(),
|
ExaSearchChannel(),
|
||||||
WebChannel(),
|
WebChannel(),
|
||||||
|
|||||||
@@ -8,11 +8,22 @@ and provides:
|
|||||||
- check(config) → is the upstream tool installed and configured?
|
- check(config) → is the upstream tool installed and configured?
|
||||||
|
|
||||||
After installation, agents call upstream tools directly.
|
After installation, agents call upstream tools directly.
|
||||||
|
|
||||||
|
Backend routing semantics:
|
||||||
|
- `backends` is an ORDERED candidate list: backends[0] is the preferred
|
||||||
|
backend, the rest are fallbacks. "Switching backends" for a platform
|
||||||
|
means reordering this list (or a user override) — not rewriting code.
|
||||||
|
- check() must set `self.active_backend` to the backend that is actually
|
||||||
|
serving the channel right now (None when nothing usable is found).
|
||||||
|
shutil.which() alone is NOT proof of health — a stale venv shim passes
|
||||||
|
which() but cannot execute (see agent_reach.probe). Channels should
|
||||||
|
really execute a lightweight command before claiming a backend active.
|
||||||
|
- Users can force a backend with config key `<channel>_backend`
|
||||||
|
(or env var `<CHANNEL>_BACKEND`); ordered_backends() applies it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shutil
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import List, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
class Channel(ABC):
|
class Channel(ABC):
|
||||||
@@ -20,17 +31,40 @@ class Channel(ABC):
|
|||||||
|
|
||||||
name: str = "" # e.g. "youtube"
|
name: str = "" # e.g. "youtube"
|
||||||
description: str = "" # e.g. "YouTube 视频和字幕"
|
description: str = "" # e.g. "YouTube 视频和字幕"
|
||||||
backends: List[str] = [] # e.g. ["yt-dlp"] — what upstream tool is used
|
backends: List[str] = [] # ordered candidates — backends[0] = preferred
|
||||||
tier: int = 0 # 0=zero-config, 1=needs free key, 2=needs setup
|
tier: int = 0 # 0=zero-config, 1=needs free key, 2=needs setup
|
||||||
|
|
||||||
|
#: Backend currently serving this channel; set by check(), None = unavailable.
|
||||||
|
active_backend: Optional[str] = None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
"""Check if this channel can handle this URL."""
|
"""Check if this channel can handle this URL."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def ordered_backends(self, config=None) -> List[str]:
|
||||||
|
"""Candidate backends in probe order, honoring the user override.
|
||||||
|
|
||||||
|
The config key `<channel>_backend` (env `<CHANNEL>_BACKEND`) moves the
|
||||||
|
named backend to the front of the list; unknown values are ignored so
|
||||||
|
a stale override can never hide working backends.
|
||||||
|
"""
|
||||||
|
candidates = list(self.backends)
|
||||||
|
override = config.get(f"{self.name}_backend") if config else None
|
||||||
|
if override:
|
||||||
|
for i, b in enumerate(candidates):
|
||||||
|
if b == override or b.startswith(override):
|
||||||
|
candidates.insert(0, candidates.pop(i))
|
||||||
|
break
|
||||||
|
return candidates
|
||||||
|
|
||||||
def check(self, config=None) -> Tuple[str, str]:
|
def check(self, config=None) -> Tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Check if this channel's upstream tool is available.
|
Check if this channel's upstream tool is available.
|
||||||
Returns (status, message) where status is 'ok'/'warn'/'off'/'error'.
|
Returns (status, message) where status is 'ok'/'warn'/'off'/'error'.
|
||||||
|
|
||||||
|
Subclasses with external backends must really probe them (see
|
||||||
|
agent_reach.probe.probe_command) and set self.active_backend.
|
||||||
"""
|
"""
|
||||||
|
self.active_backend = self.backends[0] if self.backends else "内置"
|
||||||
return "ok", f"{'、'.join(self.backends) if self.backends else '内置'}"
|
return "ok", f"{'、'.join(self.backends) if self.backends else '内置'}"
|
||||||
|
|||||||
@@ -1,15 +1,41 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Bilibili — check if yt-dlp is available."""
|
"""Bilibili — multi-backend: bili-cli / OpenCLI / search API.
|
||||||
|
|
||||||
|
yt-dlp was REMOVED from this channel (live-verified 2026-06): bilibili's
|
||||||
|
risk control 412-blocks yt-dlp's requests in every configuration we
|
||||||
|
tried — latest version, direct, proxied, with warmed cookies — while
|
||||||
|
bili-cli keeps working (search/hot/video detail without login) and
|
||||||
|
OpenCLI covers subtitles through the browser session. yt-dlp remains the
|
||||||
|
YouTube backend; it just no longer serves bilibili.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from agent_reach.probe import probe_command
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from .base import Channel
|
from .base import Channel
|
||||||
|
|
||||||
|
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
||||||
|
_TIMEOUT = 10
|
||||||
|
_SEARCH_API = "https://api.bilibili.com/x/web-interface/search/all/v2?keyword=test&page=1"
|
||||||
|
|
||||||
|
|
||||||
|
def _search_api_ok() -> bool:
|
||||||
|
"""Return True if Bilibili search API responds with code 0."""
|
||||||
|
req = urllib.request.Request(_SEARCH_API, headers={"User-Agent": _UA})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
return data.get("code") == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class BilibiliChannel(Channel):
|
class BilibiliChannel(Channel):
|
||||||
name = "bilibili"
|
name = "bilibili"
|
||||||
description = "B站视频和字幕"
|
description = "B站视频、字幕和搜索"
|
||||||
backends = ["yt-dlp"]
|
backends = ["bili-cli", "OpenCLI", "B站搜索 API"]
|
||||||
tier = 1
|
tier = 1
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
@@ -18,9 +44,76 @@ class BilibiliChannel(Channel):
|
|||||||
return "bilibili.com" in d or "b23.tv" in d
|
return "bilibili.com" in d or "b23.tv" in d
|
||||||
|
|
||||||
def check(self, config=None):
|
def check(self, config=None):
|
||||||
if not shutil.which("yt-dlp"):
|
"""Probe candidates in order; first fully-usable backend wins."""
|
||||||
return "off", "yt-dlp 未安装。安装:pip install yt-dlp"
|
self.active_backend = None
|
||||||
proxy = (config.get("bilibili_proxy") if config else None) or os.environ.get("BILIBILI_PROXY")
|
findings = []
|
||||||
if proxy:
|
|
||||||
return "ok", "可提取视频信息和字幕(代理已配置)"
|
for backend in self.ordered_backends(config):
|
||||||
return "ok", "可提取视频信息和字幕(本地环境)。服务器可能需要代理"
|
if backend == "bili-cli":
|
||||||
|
result = self._check_bili_cli()
|
||||||
|
elif backend == "OpenCLI":
|
||||||
|
result = self._check_opencli()
|
||||||
|
else:
|
||||||
|
result = self._check_search_api()
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
findings.append((backend, *result))
|
||||||
|
|
||||||
|
# 有后端断链时,即使别的候选兜底成功也要把处方带出来
|
||||||
|
broken_notes = [m for _, s, m in findings if s == "error"]
|
||||||
|
|
||||||
|
for wanted in ("ok", "warn"):
|
||||||
|
for backend, status, message in findings:
|
||||||
|
if status == wanted:
|
||||||
|
self.active_backend = backend
|
||||||
|
if broken_notes:
|
||||||
|
message += "\n[备选后端异常] " + ";".join(broken_notes)
|
||||||
|
return status, message
|
||||||
|
|
||||||
|
if findings:
|
||||||
|
return "error", "\n".join(m for _, _, m in findings)
|
||||||
|
|
||||||
|
return "off", (
|
||||||
|
"没有可用的 B站后端(搜索 API 也不可达,可能是网络问题)。推荐:\n"
|
||||||
|
" pipx install bilibili-cli(搜索/热门/视频详情,无需登录)\n"
|
||||||
|
" 或桌面装 OpenCLI(额外解锁字幕):agent-reach install --channels opencli"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_bili_cli(self):
|
||||||
|
"""bili-cli candidate. None = not installed."""
|
||||||
|
probe = probe_command("bili", ["--version"], timeout=10, package="bilibili-cli")
|
||||||
|
if probe.status == "missing":
|
||||||
|
return None
|
||||||
|
if probe.status == "broken":
|
||||||
|
return "error", "bili 命令存在但无法执行\n" + probe.hint
|
||||||
|
if not probe.ok:
|
||||||
|
return "warn", f"bili-cli 探测失败({probe.status}),运行 `bili status` 查看详情"
|
||||||
|
return "ok", (
|
||||||
|
"bili-cli 可用(搜索/热门/排行/视频详情/音频,无需登录;"
|
||||||
|
"字幕需 OpenCLI。上游 2026-03 起停更)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_opencli(self):
|
||||||
|
"""OpenCLI candidate. None = not installed."""
|
||||||
|
from agent_reach.backends import opencli_status
|
||||||
|
|
||||||
|
st = opencli_status()
|
||||||
|
if not st.installed:
|
||||||
|
return None
|
||||||
|
if st.broken:
|
||||||
|
return "error", st.hint
|
||||||
|
if st.ready:
|
||||||
|
return "ok", (
|
||||||
|
"OpenCLI 可用(复用浏览器登录态)。用法:"
|
||||||
|
"opencli bilibili search/video/subtitle/ranking -f yaml"
|
||||||
|
)
|
||||||
|
return "warn", st.hint
|
||||||
|
|
||||||
|
def _check_search_api(self):
|
||||||
|
"""Zero-dependency search API fallback. None = unreachable."""
|
||||||
|
if not _search_api_ok():
|
||||||
|
return None
|
||||||
|
return "ok", (
|
||||||
|
"B站搜索 API 可达(仅搜索,curl 直连)。"
|
||||||
|
"完整功能建议安装 bili-cli:pipx install bilibili-cli"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""Boss直聘 — check if mcp-bosszp is available."""
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from .base import Channel
|
|
||||||
|
|
||||||
|
|
||||||
class BossZhipinChannel(Channel):
|
|
||||||
name = "bosszhipin"
|
|
||||||
description = "Boss直聘职位搜索"
|
|
||||||
backends = ["mcp-bosszp", "Jina Reader"]
|
|
||||||
tier = 2
|
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
domain = urlparse(url).netloc.lower()
|
|
||||||
return "zhipin.com" in domain or "boss.com" in domain
|
|
||||||
|
|
||||||
def check(self, config=None):
|
|
||||||
mcporter = shutil.which("mcporter")
|
|
||||||
if not mcporter:
|
|
||||||
return "off", (
|
|
||||||
"可通过 Jina Reader 读取职位页面。完整功能需要:\n"
|
|
||||||
" 1. git clone https://github.com/mucsbr/mcp-bosszp.git\n"
|
|
||||||
" 2. cd mcp-bosszp && pip install -r requirements.txt && playwright install chromium\n"
|
|
||||||
" 3. python boss_zhipin_fastmcp_v2.py(启动后扫码登录)\n"
|
|
||||||
" 4. mcporter config add bosszhipin http://localhost:8000/mcp"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[mcporter, "list"], capture_output=True,
|
|
||||||
encoding="utf-8", errors="replace", timeout=10
|
|
||||||
)
|
|
||||||
out = r.stdout.lower()
|
|
||||||
if "boss" in out or "zhipin" in out:
|
|
||||||
return "ok", "可搜索职位、向 HR 打招呼"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "off", (
|
|
||||||
"mcporter 已装但 Boss直聘 MCP 未配置。\n"
|
|
||||||
" 详见 https://github.com/mucsbr/mcp-bosszp"
|
|
||||||
)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""Douyin (抖音) — check if mcporter + douyin-mcp-server is available."""
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from .base import Channel
|
|
||||||
|
|
||||||
|
|
||||||
class DouyinChannel(Channel):
|
|
||||||
name = "douyin"
|
|
||||||
description = "抖音短视频"
|
|
||||||
backends = ["douyin-mcp-server"]
|
|
||||||
tier = 2
|
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
d = urlparse(url).netloc.lower()
|
|
||||||
return "douyin.com" in d or "iesdouyin.com" in d
|
|
||||||
|
|
||||||
def check(self, config=None):
|
|
||||||
mcporter = shutil.which("mcporter")
|
|
||||||
if not mcporter:
|
|
||||||
return "off", (
|
|
||||||
"需要 mcporter + douyin-mcp-server。安装步骤:\n"
|
|
||||||
" 1. npm install -g mcporter\n"
|
|
||||||
" 2. pip install douyin-mcp-server\n"
|
|
||||||
" 3. 启动服务(见下方说明)\n"
|
|
||||||
" 4. mcporter config add douyin http://localhost:18070/mcp\n"
|
|
||||||
" 详见 https://github.com/yzfly/douyin-mcp-server"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[mcporter, "config", "list"], capture_output=True,
|
|
||||||
encoding="utf-8", errors="replace", timeout=5
|
|
||||||
)
|
|
||||||
if "douyin" not in r.stdout:
|
|
||||||
return "off", (
|
|
||||||
"mcporter 已装但抖音 MCP 未配置。运行:\n"
|
|
||||||
" pip install douyin-mcp-server\n"
|
|
||||||
" # 启动服务后:\n"
|
|
||||||
" mcporter config add douyin http://localhost:18070/mcp"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return "off", "mcporter 连接异常"
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[mcporter, "call", "douyin.parse_douyin_video_info(share_link: \"https://www.douyin.com\")"],
|
|
||||||
capture_output=True, encoding="utf-8", errors="replace", timeout=15
|
|
||||||
)
|
|
||||||
if r.returncode == 0:
|
|
||||||
return "ok", "完整可用(视频解析、下载链接获取)"
|
|
||||||
return "warn", "MCP 已连接但调用异常,检查 douyin-mcp-server 服务是否在运行"
|
|
||||||
except Exception:
|
|
||||||
return "warn", "MCP 连接异常,检查 douyin-mcp-server 服务是否在运行"
|
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Exa Search — check if mcporter + Exa MCP is available."""
|
"""Exa Search — check if mcporter + Exa MCP is available."""
|
||||||
|
|
||||||
import shutil
|
from agent_reach.probe import probe_command
|
||||||
import subprocess
|
|
||||||
from .base import Channel
|
from .base import Channel
|
||||||
|
|
||||||
|
#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同
|
||||||
|
_MCPORTER_BROKEN_HINT = "mcporter 无法执行(node 环境损坏),重装:\n npm install -g mcporter"
|
||||||
|
|
||||||
|
|
||||||
class ExaSearchChannel(Channel):
|
class ExaSearchChannel(Channel):
|
||||||
name = "exa_search"
|
name = "exa_search"
|
||||||
@@ -16,23 +19,22 @@ class ExaSearchChannel(Channel):
|
|||||||
return False # Search-only channel
|
return False # Search-only channel
|
||||||
|
|
||||||
def check(self, config=None):
|
def check(self, config=None):
|
||||||
mcporter = shutil.which("mcporter")
|
self.active_backend = None
|
||||||
if not mcporter:
|
probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter")
|
||||||
|
if probe.status == "missing":
|
||||||
return "off", (
|
return "off", (
|
||||||
"需要 mcporter + Exa MCP。安装:\n"
|
"需要 mcporter + Exa MCP。安装:\n"
|
||||||
" npm install -g mcporter\n"
|
" npm install -g mcporter\n"
|
||||||
" mcporter config add exa https://mcp.exa.ai/mcp"
|
" mcporter config add exa https://mcp.exa.ai/mcp"
|
||||||
)
|
)
|
||||||
try:
|
if probe.status == "broken":
|
||||||
r = subprocess.run(
|
return "error", _MCPORTER_BROKEN_HINT
|
||||||
[mcporter, "config", "list"], capture_output=True,
|
if not probe.ok: # timeout / error
|
||||||
encoding="utf-8", errors="replace", timeout=5
|
return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}"
|
||||||
)
|
if "exa" in probe.output.lower():
|
||||||
if "exa" in r.stdout.lower():
|
self.active_backend = self.backends[0]
|
||||||
return "ok", "全网语义搜索可用(免费,无需 API Key)"
|
return "ok", "全网语义搜索可用(免费,无需 API Key)"
|
||||||
return "off", (
|
return "off", (
|
||||||
"mcporter 已装但 Exa 未配置。运行:\n"
|
"mcporter 已装但 Exa 未配置。运行:\n"
|
||||||
" mcporter config add exa https://mcp.exa.ai/mcp"
|
" mcporter config add exa https://mcp.exa.ai/mcp"
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
return "off", "mcporter 连接异常"
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""GitHub — check if gh CLI is available."""
|
"""GitHub — check if gh CLI is available."""
|
||||||
|
|
||||||
import shutil
|
from agent_reach.probe import probe_command
|
||||||
import subprocess
|
|
||||||
from .base import Channel
|
from .base import Channel
|
||||||
|
|
||||||
|
|
||||||
@@ -17,16 +17,26 @@ 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):
|
||||||
gh = shutil.which("gh")
|
# 真跑 gh auth status 探活。注意:未登录时 rc!=0 是正常业务态(warn),不是 error。
|
||||||
if not gh:
|
probe = probe_command("gh", ["auth", "status"], timeout=10, package="gh")
|
||||||
|
if probe.status == "missing":
|
||||||
|
self.active_backend = None
|
||||||
return "warn", "gh CLI 未安装。安装:https://cli.github.com"
|
return "warn", "gh CLI 未安装。安装:https://cli.github.com"
|
||||||
try:
|
if probe.status == "broken":
|
||||||
r = subprocess.run(
|
# gh 是二进制安装(brew/官方包),不是 pip 包——处方不用 pipx/uv 文案
|
||||||
[gh, "auth", "status"],
|
self.active_backend = None
|
||||||
capture_output=True, encoding="utf-8", errors="replace", timeout=5
|
return "error", (
|
||||||
|
"gh 命令存在但无法执行——安装已损坏。重装即可修复:\n"
|
||||||
|
" brew reinstall gh\n"
|
||||||
|
"或从 https://cli.github.com 重新安装 gh CLI"
|
||||||
)
|
)
|
||||||
if r.returncode == 0:
|
if probe.status == "timeout":
|
||||||
|
# gh 本体能启动(工具是活的),只是状态检查超时
|
||||||
|
self.active_backend = "gh CLI"
|
||||||
|
return "warn", "gh CLI 状态检查超时,运行 gh auth status 查看详情"
|
||||||
|
if probe.ok:
|
||||||
|
self.active_backend = "gh CLI"
|
||||||
return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)"
|
return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)"
|
||||||
|
# rc != 0:gh 活着但未认证(gh auth status 的正常业务态)
|
||||||
|
self.active_backend = "gh CLI"
|
||||||
return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能"
|
return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能"
|
||||||
except Exception:
|
|
||||||
return "warn", "gh CLI 状态检查失败,运行 gh auth status 查看详情"
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""LinkedIn — check if linkedin-scraper-mcp is available."""
|
"""LinkedIn — check if linkedin-scraper-mcp is available."""
|
||||||
|
|
||||||
import shutil
|
from agent_reach.probe import probe_command
|
||||||
import subprocess
|
|
||||||
from .base import Channel
|
from .base import Channel
|
||||||
|
|
||||||
|
#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同
|
||||||
|
_MCPORTER_BROKEN_HINT = "mcporter 无法执行(node 环境损坏),重装:\n npm install -g mcporter"
|
||||||
|
|
||||||
|
|
||||||
class LinkedInChannel(Channel):
|
class LinkedInChannel(Channel):
|
||||||
name = "linkedin"
|
name = "linkedin"
|
||||||
@@ -17,23 +20,22 @@ 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):
|
||||||
mcporter = shutil.which("mcporter")
|
self.active_backend = None
|
||||||
if not mcporter:
|
probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter")
|
||||||
|
if probe.status == "missing":
|
||||||
return "off", (
|
return "off", (
|
||||||
"基本内容可通过 Jina Reader 读取。完整功能需要:\n"
|
"基本内容可通过 Jina Reader 读取。完整功能需要:\n"
|
||||||
" pip install linkedin-scraper-mcp\n"
|
" pip install linkedin-scraper-mcp\n"
|
||||||
" mcporter config add linkedin http://localhost:3000/mcp\n"
|
" mcporter config add linkedin http://localhost:3000/mcp\n"
|
||||||
" 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
|
" 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
|
||||||
)
|
)
|
||||||
try:
|
if probe.status == "broken":
|
||||||
r = subprocess.run(
|
return "error", _MCPORTER_BROKEN_HINT
|
||||||
[mcporter, "config", "list"], capture_output=True,
|
if not probe.ok: # timeout / error
|
||||||
encoding="utf-8", errors="replace", timeout=5
|
return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}"
|
||||||
)
|
if "linkedin" in probe.output.lower():
|
||||||
if "linkedin" in r.stdout.lower():
|
self.active_backend = "linkedin-scraper-mcp"
|
||||||
return "ok", "完整可用(Profile、公司、职位搜索)"
|
return "ok", "完整可用(Profile、公司、职位搜索)"
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "off", (
|
return "off", (
|
||||||
"mcporter 已装但 LinkedIn MCP 未配置。运行:\n"
|
"mcporter 已装但 LinkedIn MCP 未配置。运行:\n"
|
||||||
" pip install linkedin-scraper-mcp\n"
|
" pip install linkedin-scraper-mcp\n"
|
||||||
|
|||||||
+149
-10
@@ -1,26 +1,165 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Reddit — check if proxy and credentials are configured."""
|
"""Reddit — multi-backend: OpenCLI / rdt-cli. Login is mandatory.
|
||||||
|
|
||||||
|
Honest tiering (live-verified 2026-06): there is NO zero-config path.
|
||||||
|
Anonymous .json endpoints are blocked (403 anti-bot, all variants), and
|
||||||
|
the official API closed self-service registration in 2025-11 (manual
|
||||||
|
approval, individual scripts rarely granted — PRAW is only an option for
|
||||||
|
users who already hold credentials). Every working backend rides a
|
||||||
|
logged-in session: OpenCLI reuses the browser's, rdt-cli imports cookies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from agent_reach.utils.process import utf8_subprocess_env
|
||||||
|
|
||||||
import os
|
|
||||||
from .base import Channel
|
from .base import Channel
|
||||||
|
|
||||||
|
_CREDENTIAL_FILE = "~/.config/rdt-cli/credential.json"
|
||||||
|
# Pinned to the 0.4.2 state — PyPI still only has 0.4.1 (upstream issue #10).
|
||||||
|
_RDT_GIT_SOURCE = "git+https://github.com/public-clis/rdt-cli.git@5e4fb3720d5c174e976cd425ccc3b879d52cac66"
|
||||||
|
|
||||||
|
#: shell 对"找到但不可执行/找不到"使用的退出码(对齐 agent_reach.probe)
|
||||||
|
_BROKEN_EXIT_CODES = (126, 127)
|
||||||
|
|
||||||
|
#: rdt 应从固定 git 源安装(PyPI 落后),断链处方与 probe 默认的 pipx/uv 不同
|
||||||
|
_RDT_BROKEN_HINT = (
|
||||||
|
"rdt 命令存在但无法执行——通常是系统 Python 升级后 venv 解释器丢失。\n"
|
||||||
|
"PyPI 版本落后,推荐用固定 git 源强制重装:\n"
|
||||||
|
f" pipx install --force '{_RDT_GIT_SOURCE}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RedditChannel(Channel):
|
class RedditChannel(Channel):
|
||||||
name = "reddit"
|
name = "reddit"
|
||||||
description = "Reddit 帖子和评论"
|
description = "Reddit 帖子和评论"
|
||||||
backends = ["JSON API", "Exa"]
|
backends = ["OpenCLI", "rdt-cli"]
|
||||||
tier = 1
|
tier = 1 # no zero-config path exists — see module docstring
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
d = urlparse(url).netloc.lower()
|
d = urlparse(url).netloc.lower()
|
||||||
return "reddit.com" in d or "redd.it" in d
|
return "reddit.com" in d or "redd.it" in d
|
||||||
|
|
||||||
def check(self, config=None):
|
def check(self, config=None):
|
||||||
proxy = (config.get("reddit_proxy") if config else None) or os.environ.get("REDDIT_PROXY")
|
"""Probe candidates in order; first fully-usable backend wins."""
|
||||||
if proxy:
|
self.active_backend = None
|
||||||
return "ok", "代理已配置,可读取帖子。搜索走 Exa"
|
findings = []
|
||||||
return "warn", (
|
|
||||||
"无代理。服务器 IP 可能被 Reddit 封锁。配置代理:\n"
|
for backend in self.ordered_backends(config):
|
||||||
" agent-reach configure proxy http://user:pass@ip:port"
|
if backend == "OpenCLI":
|
||||||
|
result = self._check_opencli()
|
||||||
|
else:
|
||||||
|
result = self._check_rdt()
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
findings.append((backend, *result))
|
||||||
|
|
||||||
|
for wanted in ("ok", "warn"):
|
||||||
|
for backend, status, message in findings:
|
||||||
|
if status == wanted:
|
||||||
|
self.active_backend = backend
|
||||||
|
return status, message
|
||||||
|
|
||||||
|
if findings:
|
||||||
|
return "error", "\n".join(m for _, _, m in findings)
|
||||||
|
|
||||||
|
return "off", (
|
||||||
|
"未安装任何 Reddit 后端。注意:Reddit 没有零配置路径"
|
||||||
|
"(匿名 .json 已被封,官方 API 需人工审批),必须用登录态。推荐:\n"
|
||||||
|
" 桌面:agent-reach install --channels opencli\n"
|
||||||
|
" (复用 Chrome 登录态,登录过 reddit.com 即可用)\n"
|
||||||
|
f" 服务器/存量:pipx install '{_RDT_GIT_SOURCE}'\n"
|
||||||
|
" 然后 `rdt login` 或手动写入 Cookie(见 doctor 提示)\n"
|
||||||
|
"中国大陆访问 Reddit 需要代理"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_opencli(self):
|
||||||
|
"""OpenCLI candidate. None = not installed."""
|
||||||
|
from agent_reach.backends import opencli_status
|
||||||
|
|
||||||
|
st = opencli_status()
|
||||||
|
if not st.installed:
|
||||||
|
return None
|
||||||
|
if st.broken:
|
||||||
|
return "error", st.hint
|
||||||
|
if st.ready:
|
||||||
|
return "ok", (
|
||||||
|
"OpenCLI 可用(复用浏览器登录态)。用法:"
|
||||||
|
"opencli reddit search/read/subreddit/hot -f yaml"
|
||||||
|
)
|
||||||
|
return "warn", st.hint
|
||||||
|
|
||||||
|
def _check_rdt(self):
|
||||||
|
"""rdt-cli candidate. None = not installed."""
|
||||||
|
rdt = shutil.which("rdt")
|
||||||
|
if not rdt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 不走 probe_command:实测 `rdt status --json` 成功时(rc=0)也会向 stderr
|
||||||
|
# 打网络重试日志,probe 把 stdout+stderr 合并后 JSON 解析必炸。
|
||||||
|
# 故保留手写 subprocess(stdout 单独捕获),但异常分类对齐 probe 语义:
|
||||||
|
# exec 失败/126/127 → broken(venv 断链处方),TimeoutExpired → 超时。
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[rdt, "status", "--json"],
|
||||||
|
capture_output=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=10,
|
||||||
|
env=utf8_subprocess_env(),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "error", "rdt 响应超时(>10s),Reddit 状态未知。稍后重试或运行 `rdt status` 查看详情"
|
||||||
|
except OSError:
|
||||||
|
# 含 FileNotFoundError:which 命中但 exec 失败 = venv 断链(probe 的 broken)
|
||||||
|
return "error", _RDT_BROKEN_HINT
|
||||||
|
|
||||||
|
if r.returncode in _BROKEN_EXIT_CODES:
|
||||||
|
return "error", _RDT_BROKEN_HINT
|
||||||
|
|
||||||
|
if r.returncode != 0:
|
||||||
|
detail = (r.stderr or r.stdout or "").strip().splitlines()
|
||||||
|
tail = detail[-1] if detail else "无输出"
|
||||||
|
return "error", f"rdt 异常退出(exit {r.returncode}):{tail}。运行 `rdt status` 查看详情"
|
||||||
|
|
||||||
|
# 进程正常退出 → rdt 本身是活的(无论登录与否)
|
||||||
|
try:
|
||||||
|
data = json.loads(r.stdout or "")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return "warn", "rdt-cli 可用但状态输出无法解析,运行 `rdt status` 查看登录状态"
|
||||||
|
|
||||||
|
info = data.get("data")
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
info = {}
|
||||||
|
authenticated = info.get("authenticated", False)
|
||||||
|
username = info.get("username") or ""
|
||||||
|
|
||||||
|
if authenticated:
|
||||||
|
suffix = f"(已登录:{username})" if username else ""
|
||||||
|
return "ok", (
|
||||||
|
f"rdt-cli 可用{suffix}(搜索帖子、阅读全文、查看评论;"
|
||||||
|
"上游 2026-03 起停更,桌面用户建议迁移到 OpenCLI)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "warn", (
|
||||||
|
"rdt-cli 已安装但未登录。Reddit 自 2024 年起要求认证,"
|
||||||
|
"未登录时所有请求均返回 403。\n\n"
|
||||||
|
"方法一(自动):运行 `rdt login`\n"
|
||||||
|
" 先在浏览器登录 reddit.com,再运行此命令自动提取 Cookie。\n\n"
|
||||||
|
"方法二(手动,适用于 Chrome/Edge 127+ 无法自动提取时):\n"
|
||||||
|
" 1. Chrome 应用商店安装 Cookie-Editor 扩展:\n"
|
||||||
|
" https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm\n"
|
||||||
|
" 2. 在浏览器打开 reddit.com(确保已登录)\n"
|
||||||
|
" 3. 点击 Cookie-Editor 图标,找到 `reddit_session`,复制其 Value\n"
|
||||||
|
f" 4. 将以下内容写入 {_CREDENTIAL_FILE}:\n"
|
||||||
|
' {"cookies": {"reddit_session": "<粘贴 Value>"}, '
|
||||||
|
'"source": "manual", "username": "<你的用户名>", '
|
||||||
|
'"modhash": null, "saved_at": 0, "last_verified_at": null}\n\n'
|
||||||
|
"验证:`rdt status --json` 确认 authenticated: true"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ class RSSChannel(Channel):
|
|||||||
|
|
||||||
def check(self, config=None):
|
def check(self, config=None):
|
||||||
try:
|
try:
|
||||||
import feedparser
|
import feedparser # noqa: F401
|
||||||
return "ok", "可读取 RSS/Atom 源"
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
self.active_backend = None
|
||||||
return "off", "feedparser 未安装。安装:pip install feedparser"
|
return "off", "feedparser 未安装。安装:pip install feedparser"
|
||||||
|
except Exception as e:
|
||||||
|
# 已安装但导入期崩溃(半残安装/版本冲突)→ 重装处方
|
||||||
|
self.active_backend = None
|
||||||
|
return "error", f"feedparser 导入失败:{e}\n修复:pip install --force-reinstall feedparser"
|
||||||
|
self.active_backend = self.backends[0]
|
||||||
|
return "ok", "可读取 RSS/Atom 源"
|
||||||
|
|||||||
+124
-18
@@ -1,15 +1,14 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Twitter/X — check if xreach CLI is available."""
|
"""Twitter/X — check if twitter-cli or bird CLI is available."""
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from .base import Channel
|
from .base import Channel
|
||||||
|
from agent_reach.probe import probe_command
|
||||||
|
|
||||||
|
|
||||||
class TwitterChannel(Channel):
|
class TwitterChannel(Channel):
|
||||||
name = "twitter"
|
name = "twitter"
|
||||||
description = "Twitter/X 推文"
|
description = "Twitter/X 推文"
|
||||||
backends = ["xreach CLI"]
|
backends = ["twitter-cli", "OpenCLI", "bird CLI (legacy)"]
|
||||||
tier = 1
|
tier = 1
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
@@ -18,22 +17,129 @@ 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):
|
||||||
xreach = shutil.which("xreach")
|
"""Probe candidates in order; first fully-usable backend wins.
|
||||||
if not xreach:
|
|
||||||
|
与其他多后端渠道同一套两段式:先收集全部候选状态,第一个 ok 获胜;
|
||||||
|
没有 ok 才轮到第一个 warn——否则「装了但未登录」的 twitter-cli
|
||||||
|
会把排在后面、完整可用的 OpenCLI 挡在门外。
|
||||||
|
"""
|
||||||
|
self.active_backend = None
|
||||||
|
findings = []
|
||||||
|
|
||||||
|
for backend in self.ordered_backends(config):
|
||||||
|
if backend == "twitter-cli":
|
||||||
|
result = self._check_twitter_cli()
|
||||||
|
elif backend == "OpenCLI":
|
||||||
|
result = self._check_opencli()
|
||||||
|
elif backend == "bird CLI (legacy)":
|
||||||
|
result = self._check_bird()
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
continue # 未安装——不参与候选
|
||||||
|
findings.append((backend, *result))
|
||||||
|
|
||||||
|
for wanted in ("ok", "warn"):
|
||||||
|
for backend, status, message in findings:
|
||||||
|
if status == wanted:
|
||||||
|
self.active_backend = backend
|
||||||
|
return status, message
|
||||||
|
|
||||||
|
if findings: # 只剩 broken/timeout 候选
|
||||||
|
return "error", "\n".join(m for _, _, m in findings)
|
||||||
|
|
||||||
return "warn", (
|
return "warn", (
|
||||||
"xreach CLI 未安装。搜索可通过 Exa 替代。安装:\n"
|
"Twitter CLI 未安装。安装方式:\n"
|
||||||
" npm install -g xreach-cli"
|
" pipx install twitter-cli\n"
|
||||||
|
"或:\n"
|
||||||
|
" uv tool install twitter-cli"
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
def _check_twitter_cli(self):
|
||||||
[xreach, "auth", "check"], capture_output=True,
|
"""探测 twitter-cli。返回 None 表示未安装,否则返回 (status, message)。
|
||||||
encoding="utf-8", errors="replace", timeout=10
|
|
||||||
|
`twitter status` 才是健康信号:已登录时输出 "ok: true",
|
||||||
|
未登录时以非零退出码输出 "not_authenticated"——工具本身是活的,
|
||||||
|
所以 probe 的 error 状态也要看 output 内容再分类。
|
||||||
|
"""
|
||||||
|
probe = probe_command(
|
||||||
|
"twitter", ["status"], timeout=15, retries=1, package="twitter-cli"
|
||||||
)
|
)
|
||||||
if r.returncode == 0:
|
if probe.status == "missing":
|
||||||
return "ok", "完整可用(读取、搜索推文)"
|
return None
|
||||||
|
if probe.status == "broken":
|
||||||
|
return "error", "twitter-cli 命令存在但无法执行。\n" + probe.hint
|
||||||
|
if probe.status == "timeout":
|
||||||
|
return "error", "twitter-cli 健康检查超时(已重试 1 次)。\n" + probe.hint
|
||||||
|
|
||||||
|
output = probe.output
|
||||||
|
if "ok: true" in output:
|
||||||
|
return "ok", (
|
||||||
|
"twitter-cli 完整可用(搜索、读推文、时间线、长文/Article、"
|
||||||
|
"用户查询、Thread)"
|
||||||
|
)
|
||||||
|
if "not_authenticated" in output:
|
||||||
return "warn", (
|
return "warn", (
|
||||||
"xreach CLI 已安装但未配置 Cookie。运行:\n"
|
"twitter-cli 已安装但未认证。设置方式:\n"
|
||||||
" agent-reach configure twitter-cookies \"auth_token=xxx; ct0=yyy\""
|
" export TWITTER_AUTH_TOKEN=\"xxx\"\n"
|
||||||
|
" export TWITTER_CT0=\"yyy\"\n"
|
||||||
|
"或确保已在浏览器中登录 x.com"
|
||||||
)
|
)
|
||||||
except Exception:
|
return "warn", (
|
||||||
return "warn", "xreach CLI 已安装但连接失败"
|
"twitter-cli 已安装但认证检查失败。运行:\n"
|
||||||
|
" twitter -v status 查看详细信息"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_opencli(self):
|
||||||
|
"""OpenCLI candidate. None = not installed."""
|
||||||
|
from agent_reach.backends import opencli_status
|
||||||
|
|
||||||
|
st = opencli_status()
|
||||||
|
if not st.installed:
|
||||||
|
return None
|
||||||
|
if st.broken:
|
||||||
|
return "error", st.hint
|
||||||
|
if st.ready:
|
||||||
|
return "ok", (
|
||||||
|
"OpenCLI 可用(复用浏览器登录态)。用法:"
|
||||||
|
"opencli twitter search/article/user-posts -f yaml"
|
||||||
|
)
|
||||||
|
return "warn", st.hint
|
||||||
|
|
||||||
|
def _check_bird(self):
|
||||||
|
"""探测 bird/birdx(legacy 回退)。返回 None 表示均未安装,否则返回 (status, message)。"""
|
||||||
|
last_failure = None
|
||||||
|
for cmd in ("bird", "birdx"):
|
||||||
|
probe = probe_command(
|
||||||
|
cmd, ["check"], timeout=15, retries=1, package="@steipete/bird"
|
||||||
|
)
|
||||||
|
if probe.status == "missing":
|
||||||
|
continue
|
||||||
|
if probe.status == "broken":
|
||||||
|
last_failure = (
|
||||||
|
"error",
|
||||||
|
f"{cmd} 命令存在但无法执行(bird 是 npm 包,可用 "
|
||||||
|
"npm install -g @steipete/bird 重装)。\n" + probe.hint,
|
||||||
|
)
|
||||||
|
continue # bird 坏了再试 birdx
|
||||||
|
if probe.status == "timeout":
|
||||||
|
last_failure = (
|
||||||
|
"error",
|
||||||
|
f"{cmd} 健康检查超时(已重试 1 次)。\n" + probe.hint,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
output = probe.output
|
||||||
|
if probe.ok:
|
||||||
|
return "ok", "bird CLI 可用(读取、搜索推文,含长文/X Article)"
|
||||||
|
if "Missing credentials" in output or "missing" in output.lower():
|
||||||
|
return "warn", (
|
||||||
|
"bird CLI 已安装但未配置认证。设置环境变量:\n"
|
||||||
|
" export AUTH_TOKEN=\"xxx\"\n"
|
||||||
|
" export CT0=\"yyy\""
|
||||||
|
)
|
||||||
|
return "warn", (
|
||||||
|
"bird CLI 已安装但认证检查失败。"
|
||||||
|
)
|
||||||
|
return last_failure
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""V2EX — public API channel for topics, nodes, users, and replies."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
from .base import Channel
|
||||||
|
|
||||||
|
_UA = "agent-reach/1.0"
|
||||||
|
_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url: str) -> Any:
|
||||||
|
"""Fetch *url* and return parsed JSON. Raises on HTTP/network errors."""
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": _UA})
|
||||||
|
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class V2EXChannel(Channel):
|
||||||
|
name = "v2ex"
|
||||||
|
description = "V2EX 节点、主题与回复"
|
||||||
|
backends = ["V2EX API (public)"]
|
||||||
|
tier = 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# URL routing
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def can_handle(self, url: str) -> bool:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
d = urlparse(url).netloc.lower()
|
||||||
|
return "v2ex.com" in d
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Health check
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def check(self, config=None):
|
||||||
|
try:
|
||||||
|
_get_json(
|
||||||
|
"https://www.v2ex.com/api/topics/show.json?node_name=python&page=1"
|
||||||
|
)
|
||||||
|
self.active_backend = self.backends[0]
|
||||||
|
return "ok", "公开 API 可用(热门主题、节点浏览、主题详情、用户信息)"
|
||||||
|
except Exception as e:
|
||||||
|
self.active_backend = None
|
||||||
|
return "warn", f"V2EX API 连接失败(可能需要代理):{e}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Data-fetching methods
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def get_hot_topics(self, limit: int = 20) -> list:
|
||||||
|
"""获取热门帖子列表。
|
||||||
|
|
||||||
|
Returns a list of dicts with keys:
|
||||||
|
title, url, replies, node_name, node_title, content
|
||||||
|
"""
|
||||||
|
data = _get_json("https://www.v2ex.com/api/topics/hot.json")
|
||||||
|
results = []
|
||||||
|
for item in data[:limit]:
|
||||||
|
node = item.get("node") or {}
|
||||||
|
content = item.get("content", "") or ""
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": item.get("id", 0),
|
||||||
|
"title": item.get("title", ""),
|
||||||
|
"url": item.get("url", ""),
|
||||||
|
"replies": item.get("replies", 0),
|
||||||
|
"node_name": node.get("name", ""),
|
||||||
|
"node_title": node.get("title", ""),
|
||||||
|
"content": content[:200],
|
||||||
|
"created": item.get("created", 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_node_topics(self, node_name: str, limit: int = 20) -> list:
|
||||||
|
"""获取指定节点的最新帖子。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node_name: 节点名称,如 "python"、"tech"、"jobs"
|
||||||
|
limit: 最多返回条数
|
||||||
|
|
||||||
|
Returns a list of dicts with keys:
|
||||||
|
title, url, replies, node_name, node_title, content
|
||||||
|
"""
|
||||||
|
url = (
|
||||||
|
f"https://www.v2ex.com/api/topics/show.json"
|
||||||
|
f"?node_name={node_name}&page=1"
|
||||||
|
)
|
||||||
|
data = _get_json(url)
|
||||||
|
results = []
|
||||||
|
for item in data[:limit]:
|
||||||
|
node = item.get("node") or {}
|
||||||
|
content = item.get("content", "") or ""
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": item.get("id", 0),
|
||||||
|
"title": item.get("title", ""),
|
||||||
|
"url": item.get("url", ""),
|
||||||
|
"replies": item.get("replies", 0),
|
||||||
|
"node_name": node.get("name", node_name),
|
||||||
|
"node_title": node.get("title", ""),
|
||||||
|
"content": content[:200],
|
||||||
|
"created": item.get("created", 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_topic(self, topic_id: int) -> dict:
|
||||||
|
"""获取单个帖子详情和回复列表。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic_id: 帖子 ID(从 URL https://www.v2ex.com/t/<id> 中获取)
|
||||||
|
|
||||||
|
Returns a dict with keys:
|
||||||
|
id, title, url, content, replies_count, node_name, node_title,
|
||||||
|
author, created, replies (list of dicts with: author, content, created)
|
||||||
|
"""
|
||||||
|
topic_data = _get_json(
|
||||||
|
f"https://www.v2ex.com/api/topics/show.json?id={topic_id}"
|
||||||
|
)
|
||||||
|
# API returns a list even for single-ID queries
|
||||||
|
if isinstance(topic_data, list):
|
||||||
|
topic = topic_data[0] if topic_data else {}
|
||||||
|
else:
|
||||||
|
topic = topic_data
|
||||||
|
|
||||||
|
node = topic.get("node") or {}
|
||||||
|
member = topic.get("member") or {}
|
||||||
|
|
||||||
|
# Fetch replies (first page)
|
||||||
|
try:
|
||||||
|
replies_raw = _get_json(
|
||||||
|
f"https://www.v2ex.com/api/replies/show.json"
|
||||||
|
f"?topic_id={topic_id}&page=1"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
replies_raw = []
|
||||||
|
|
||||||
|
replies = [
|
||||||
|
{
|
||||||
|
"author": (r.get("member") or {}).get("username", ""),
|
||||||
|
"content": r.get("content", ""),
|
||||||
|
"created": r.get("created", 0),
|
||||||
|
}
|
||||||
|
for r in (replies_raw or [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": topic.get("id", topic_id),
|
||||||
|
"title": topic.get("title", ""),
|
||||||
|
"url": topic.get("url", f"https://www.v2ex.com/t/{topic_id}"),
|
||||||
|
"content": topic.get("content", ""),
|
||||||
|
"replies_count": topic.get("replies", 0),
|
||||||
|
"node_name": node.get("name", ""),
|
||||||
|
"node_title": node.get("title", ""),
|
||||||
|
"author": member.get("username", ""),
|
||||||
|
"created": topic.get("created", 0),
|
||||||
|
"replies": replies,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_user(self, username: str) -> dict:
|
||||||
|
"""获取用户信息。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: V2EX 用户名
|
||||||
|
|
||||||
|
Returns a dict with keys:
|
||||||
|
id, username, url, website, twitter, psn, github, btc,
|
||||||
|
location, bio, avatar, created
|
||||||
|
"""
|
||||||
|
data = _get_json(
|
||||||
|
f"https://www.v2ex.com/api/members/show.json?username={username}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": data.get("id", 0),
|
||||||
|
"username": data.get("username", username),
|
||||||
|
"url": data.get("url", f"https://www.v2ex.com/member/{username}"),
|
||||||
|
"website": data.get("website", ""),
|
||||||
|
"twitter": data.get("twitter", ""),
|
||||||
|
"psn": data.get("psn", ""),
|
||||||
|
"github": data.get("github", ""),
|
||||||
|
"btc": data.get("btc", ""),
|
||||||
|
"location": data.get("location", ""),
|
||||||
|
"bio": data.get("bio", ""),
|
||||||
|
"avatar": data.get("avatar_large", data.get("avatar_normal", "")),
|
||||||
|
"created": data.get("created", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def search(self, query: str, limit: int = 10) -> list:
|
||||||
|
"""搜索帖子。
|
||||||
|
|
||||||
|
注意:V2EX 公开 API 暂不支持全文搜索端点(/api/search.json 不可用)。
|
||||||
|
本方法通过 Jina Reader 代理 V2EX 站内搜索页面获取结果(纯文本,无结构化数据)。
|
||||||
|
|
||||||
|
如需精确搜索,建议直接访问 https://www.v2ex.com/?q=<query> 或
|
||||||
|
使用 Exa channel 的 site:v2ex.com 搜索。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of dicts with keys: title, url, snippet
|
||||||
|
如果搜索不可用,返回包含单条 {"error": str} 的列表。
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"error": (
|
||||||
|
"V2EX 公开 API 不提供搜索端点。"
|
||||||
|
f"建议改用:https://www.v2ex.com/?q={query} "
|
||||||
|
"或通过 Exa channel 使用 site:v2ex.com 搜索。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Web — any URL via Jina Reader. Always available."""
|
"""Web — any URL via Jina Reader. Always available."""
|
||||||
|
|
||||||
|
import urllib.request
|
||||||
from .base import Channel
|
from .base import Channel
|
||||||
|
|
||||||
|
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
||||||
|
|
||||||
|
|
||||||
class WebChannel(Channel):
|
class WebChannel(Channel):
|
||||||
name = "web"
|
name = "web"
|
||||||
@@ -14,4 +17,18 @@ class WebChannel(Channel):
|
|||||||
return True # Fallback — handles any URL
|
return True # Fallback — handles any URL
|
||||||
|
|
||||||
def check(self, config=None):
|
def check(self, config=None):
|
||||||
|
# 恒可用兜底渠道:无本地命令、不做网络探测(doctor 已有多个渠道触网),保持零开销
|
||||||
|
self.active_backend = self.backends[0]
|
||||||
return "ok", "通过 Jina Reader 读取任意网页(curl https://r.jina.ai/URL)"
|
return "ok", "通过 Jina Reader 读取任意网页(curl https://r.jina.ai/URL)"
|
||||||
|
|
||||||
|
def read(self, url: str) -> str:
|
||||||
|
"""通过 Jina Reader 读取网页,返回 Markdown 全文。"""
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
url = "https://" + url
|
||||||
|
jina_url = f"https://r.jina.ai/{url}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
jina_url,
|
||||||
|
headers={"User-Agent": _UA, "Accept": "text/plain"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return resp.read().decode("utf-8")
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""WeChat Official Account articles — read and search.
|
|
||||||
|
|
||||||
Read: wechat-article-for-ai (Camoufox stealth browser)
|
|
||||||
Search: miku_ai (Sogou WeChat search)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from .base import Channel
|
|
||||||
|
|
||||||
|
|
||||||
class WeChatChannel(Channel):
|
|
||||||
name = "wechat"
|
|
||||||
description = "微信公众号文章"
|
|
||||||
backends = ["wechat-article-for-ai (Camoufox)", "miku_ai (搜狗搜索)"]
|
|
||||||
tier = 2
|
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
d = urlparse(url).netloc.lower()
|
|
||||||
return "mp.weixin.qq.com" in d or "weixin.qq.com" in d
|
|
||||||
|
|
||||||
def check(self, config=None):
|
|
||||||
has_read = False
|
|
||||||
has_search = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
import camoufox # noqa: F401
|
|
||||||
has_read = True
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
import miku_ai # noqa: F401
|
|
||||||
has_search = True
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if has_read and has_search:
|
|
||||||
return "ok", "完整可用(搜索 + 阅读公众号文章)"
|
|
||||||
elif has_read:
|
|
||||||
return "ok", "可阅读公众号文章(URL → Markdown)。安装 miku_ai 可解锁搜索:pip install miku_ai"
|
|
||||||
elif has_search:
|
|
||||||
return "warn", (
|
|
||||||
"可搜索公众号文章但无法阅读全文。安装阅读工具:\n"
|
|
||||||
" pip install camoufox[geoip] markdownify beautifulsoup4 httpx mcp"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return "off", (
|
|
||||||
"需要安装微信公众号工具:\n"
|
|
||||||
" # 阅读(URL → Markdown):\n"
|
|
||||||
" pip install camoufox[geoip] markdownify beautifulsoup4 httpx mcp\n"
|
|
||||||
" # 搜索(关键词 → 文章列表):\n"
|
|
||||||
" pip install miku_ai\n"
|
|
||||||
" 详见 https://github.com/bzd6661/wechat-article-for-ai"
|
|
||||||
)
|
|
||||||
@@ -1,38 +1,156 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""XiaoHongShu -- check if mcporter + xiaohongshu MCP is available."""
|
"""XiaoHongShu — multi-backend: OpenCLI / xiaohongshu-mcp / xhs-cli.
|
||||||
|
|
||||||
|
Backend order encodes the recommendation, and probing order makes the
|
||||||
|
environment split automatic: OpenCLI needs a desktop Chrome so it simply
|
||||||
|
never probes alive on a server, where xiaohongshu-mcp (self-contained
|
||||||
|
headless browser) takes over. xhs-cli (upstream unmaintained since
|
||||||
|
2026-03) keeps working for existing installs as the last candidate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from agent_reach.probe import probe_command
|
||||||
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from .base import Channel
|
from .base import Channel
|
||||||
|
|
||||||
|
_MCP_ENDPOINT = "http://localhost:18060/mcp"
|
||||||
def _is_arm64() -> bool:
|
_MCP_INSTALL_URL = "https://github.com/xpzouying/xiaohongshu-mcp"
|
||||||
"""Detect ARM64 architecture (e.g. Apple Silicon)."""
|
|
||||||
machine = platform.machine().lower()
|
|
||||||
return machine in ("arm64", "aarch64")
|
|
||||||
|
|
||||||
|
|
||||||
def _docker_run_hint() -> str:
|
def _mcp_service_reachable(timeout: int = 3) -> bool:
|
||||||
"""Return the docker run command, with --platform flag for ARM64."""
|
"""True if the xiaohongshu-mcp HTTP service answers on localhost.
|
||||||
if _is_arm64():
|
|
||||||
return (
|
Any HTTP response counts (the MCP endpoint replies 405 to GET) —
|
||||||
" docker run -d --name xiaohongshu-mcp -p 18060:18060 "
|
we only care that the service is up. Proxies are bypassed explicitly:
|
||||||
"--platform linux/amd64 xpzouying/xiaohongshu-mcp\n"
|
localhost must never be routed through HTTP_PROXY.
|
||||||
" # ARM64 also: build from source: "
|
"""
|
||||||
"https://github.com/xpzouying/xiaohongshu-mcp"
|
req = urllib.request.Request(_MCP_ENDPOINT, method="GET")
|
||||||
)
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||||
return (
|
try:
|
||||||
" docker run -d --name xiaohongshu-mcp -p 18060:18060 "
|
opener.open(req, timeout=timeout)
|
||||||
"xpzouying/xiaohongshu-mcp"
|
return True
|
||||||
)
|
except urllib.error.HTTPError:
|
||||||
|
return True # 405/404 etc. — service is alive
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def format_xhs_result(data):
|
||||||
|
"""Clean XHS API response, keeping only useful fields.
|
||||||
|
|
||||||
|
Handles both single note objects and lists of notes (search results).
|
||||||
|
Drastically reduces token usage by stripping structural redundancy (#134).
|
||||||
|
"""
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [_clean_note(item) for item in data]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# Handle search_feeds wrapper: {"items": [...]} or {"data": {"items": [...]}}
|
||||||
|
items = None
|
||||||
|
if "items" in data:
|
||||||
|
items = data["items"]
|
||||||
|
elif "data" in data and isinstance(data.get("data"), dict):
|
||||||
|
items = data["data"].get("items") or data["data"].get("notes")
|
||||||
|
if items and isinstance(items, list):
|
||||||
|
return [_clean_note(item) for item in items]
|
||||||
|
# Single note
|
||||||
|
return _clean_note(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_note(note):
|
||||||
|
"""Extract useful fields from a single XHS note/feed item."""
|
||||||
|
if not isinstance(note, dict):
|
||||||
|
return note
|
||||||
|
|
||||||
|
# Some responses nest the note under "note_card" or "note"
|
||||||
|
inner = note.get("note_card") or note.get("note") or note
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Basic info
|
||||||
|
for key in ("id", "note_id", "xsec_token", "title", "desc", "type", "time"):
|
||||||
|
if key in inner:
|
||||||
|
result[key] = inner[key]
|
||||||
|
|
||||||
|
# Content (may be in desc or content)
|
||||||
|
if "content" in inner and "desc" not in result:
|
||||||
|
result["content"] = inner["content"]
|
||||||
|
|
||||||
|
# Author
|
||||||
|
user = inner.get("user") or inner.get("author")
|
||||||
|
if isinstance(user, dict):
|
||||||
|
result["user"] = {
|
||||||
|
k: user[k] for k in ("nickname", "user_id", "nick_name") if k in user
|
||||||
|
}
|
||||||
|
|
||||||
|
# Engagement metrics
|
||||||
|
interact = inner.get("interact_info") or inner.get("note_interact_info") or {}
|
||||||
|
if isinstance(interact, dict):
|
||||||
|
for key in ("liked_count", "collected_count", "comment_count", "share_count"):
|
||||||
|
if key in interact:
|
||||||
|
result[key] = interact[key]
|
||||||
|
# Also check top-level (some API formats)
|
||||||
|
for key in ("liked_count", "collected_count", "comment_count", "share_count"):
|
||||||
|
if key in inner and key not in result:
|
||||||
|
result[key] = inner[key]
|
||||||
|
|
||||||
|
# Images — just URLs
|
||||||
|
images = inner.get("image_list") or inner.get("images_list") or []
|
||||||
|
if isinstance(images, list):
|
||||||
|
urls = []
|
||||||
|
for img in images:
|
||||||
|
if isinstance(img, dict):
|
||||||
|
url = img.get("url") or img.get("url_default") or img.get("original")
|
||||||
|
if url:
|
||||||
|
urls.append(url)
|
||||||
|
elif isinstance(img, str):
|
||||||
|
urls.append(img)
|
||||||
|
if urls:
|
||||||
|
result["images"] = urls
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
tags = inner.get("tag_list") or inner.get("tags") or []
|
||||||
|
if isinstance(tags, list):
|
||||||
|
tag_names = []
|
||||||
|
for t in tags:
|
||||||
|
if isinstance(t, dict) and "name" in t:
|
||||||
|
tag_names.append(t["name"])
|
||||||
|
elif isinstance(t, str):
|
||||||
|
tag_names.append(t)
|
||||||
|
if tag_names:
|
||||||
|
result["tags"] = tag_names
|
||||||
|
|
||||||
|
# Comments (if present, e.g. from get_feed_detail with comments)
|
||||||
|
comments = inner.get("comments") or []
|
||||||
|
if isinstance(comments, list) and comments:
|
||||||
|
result["comments"] = [_clean_comment(c) for c in comments]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_comment(comment):
|
||||||
|
"""Extract useful fields from a comment."""
|
||||||
|
if not isinstance(comment, dict):
|
||||||
|
return comment
|
||||||
|
result = {}
|
||||||
|
if "content" in comment:
|
||||||
|
result["content"] = comment["content"]
|
||||||
|
user = comment.get("user_info") or comment.get("user")
|
||||||
|
if isinstance(user, dict):
|
||||||
|
result["user"] = user.get("nickname") or user.get("nick_name", "")
|
||||||
|
for key in ("like_count", "sub_comment_count"):
|
||||||
|
if key in comment:
|
||||||
|
result[key] = comment[key]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class XiaoHongShuChannel(Channel):
|
class XiaoHongShuChannel(Channel):
|
||||||
name = "xiaohongshu"
|
name = "xiaohongshu"
|
||||||
description = "小红书笔记"
|
description = "小红书笔记"
|
||||||
backends = ["xiaohongshu-mcp"]
|
backends = ["OpenCLI", "xiaohongshu-mcp", "xhs-cli (xiaohongshu-cli)"]
|
||||||
tier = 2
|
tier = 1
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -40,35 +158,101 @@ 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):
|
||||||
mcporter = shutil.which("mcporter")
|
"""Probe candidates in order; first fully-usable backend wins.
|
||||||
if not mcporter:
|
|
||||||
|
If none is fully usable, the first fixable candidate (warn) is
|
||||||
|
reported, so the user gets one actionable prescription instead
|
||||||
|
of three half-relevant ones.
|
||||||
|
"""
|
||||||
|
self.active_backend = None
|
||||||
|
findings = [] # (backend, status, message)
|
||||||
|
|
||||||
|
for backend in self.ordered_backends(config):
|
||||||
|
if backend == "OpenCLI":
|
||||||
|
result = self._check_opencli()
|
||||||
|
elif backend == "xiaohongshu-mcp":
|
||||||
|
result = self._check_mcp()
|
||||||
|
else:
|
||||||
|
result = self._check_xhs_cli()
|
||||||
|
if result is None:
|
||||||
|
continue # not installed — not a candidate right now
|
||||||
|
findings.append((backend, *result))
|
||||||
|
|
||||||
|
for wanted in ("ok", "warn"):
|
||||||
|
for backend, status, message in findings:
|
||||||
|
if status == wanted:
|
||||||
|
self.active_backend = backend
|
||||||
|
return status, message
|
||||||
|
|
||||||
|
if findings: # only broken candidates left
|
||||||
|
return "error", "\n".join(m for _, _, m in findings)
|
||||||
|
|
||||||
return "off", (
|
return "off", (
|
||||||
"需要 mcporter + xiaohongshu-mcp。安装步骤:\n"
|
"未安装任何小红书后端。推荐:\n"
|
||||||
" 1. npm install -g mcporter\n"
|
" 桌面:agent-reach install --channels opencli\n"
|
||||||
" 2. " + _docker_run_hint().strip() + "\n"
|
" (复用 Chrome 登录态,刷过小红书即零配置可用)\n"
|
||||||
" 3. mcporter config add xiaohongshu http://localhost:18060/mcp\n"
|
f" 服务器:xiaohongshu-mcp(自带无头浏览器+扫码登录):{_MCP_INSTALL_URL}"
|
||||||
" 详见 https://github.com/xpzouying/xiaohongshu-mcp"
|
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
def _check_opencli(self):
|
||||||
[mcporter, "config", "list"], capture_output=True,
|
"""OpenCLI candidate. None = not installed."""
|
||||||
encoding="utf-8", errors="replace", timeout=5
|
from agent_reach.backends import opencli_status
|
||||||
|
|
||||||
|
st = opencli_status()
|
||||||
|
if not st.installed:
|
||||||
|
return None
|
||||||
|
if st.broken:
|
||||||
|
return "error", st.hint
|
||||||
|
if st.ready:
|
||||||
|
return "ok", (
|
||||||
|
"OpenCLI 可用(复用浏览器登录态)。用法:"
|
||||||
|
"opencli xiaohongshu search/note/comments/feed -f yaml"
|
||||||
)
|
)
|
||||||
if "xiaohongshu" not in r.stdout:
|
return "warn", st.hint
|
||||||
return "off", (
|
|
||||||
"mcporter 已装但小红书 MCP 未配置。运行:\n"
|
def _check_mcp(self):
|
||||||
+ _docker_run_hint() + "\n"
|
"""xiaohongshu-mcp candidate. None = service not running."""
|
||||||
" mcporter config add xiaohongshu http://localhost:18060/mcp"
|
if not _mcp_service_reachable():
|
||||||
|
return None
|
||||||
|
mcporter = probe_command(
|
||||||
|
"mcporter", ["config", "list"], timeout=10, package="mcporter"
|
||||||
)
|
)
|
||||||
except Exception:
|
if mcporter.ok and "xiaohongshu" in mcporter.output:
|
||||||
return "off", "mcporter 连接异常"
|
return "ok", (
|
||||||
try:
|
"xiaohongshu-mcp 服务运行中"
|
||||||
r = subprocess.run(
|
"(mcporter call 'xiaohongshu.search_feeds(keyword: \"...\")')。"
|
||||||
[mcporter, "call", "xiaohongshu.check_login_status()"],
|
"若未登录,让 agent 调 get_login_qrcode 扫码"
|
||||||
capture_output=True, encoding="utf-8", errors="replace", timeout=10
|
)
|
||||||
|
return "warn", (
|
||||||
|
"xiaohongshu-mcp 服务在跑但 mcporter 未接入。运行:\n"
|
||||||
|
f" mcporter config add xiaohongshu {_MCP_ENDPOINT}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_xhs_cli(self):
|
||||||
|
"""Legacy xhs-cli candidate. None = not installed."""
|
||||||
|
probe = probe_command(
|
||||||
|
"xhs", ["status"], timeout=10, package="xiaohongshu-cli"
|
||||||
|
)
|
||||||
|
if probe.status == "missing":
|
||||||
|
return None
|
||||||
|
if probe.status == "broken":
|
||||||
|
return "error", "xhs 命令存在但无法执行\n" + probe.hint
|
||||||
|
if probe.status == "timeout":
|
||||||
|
return "warn", "xhs-cli 已安装但状态检测超时\n" + probe.hint
|
||||||
|
|
||||||
|
# 进程是活的(执行成功或运行后非零退出)——按输出内容分类
|
||||||
|
if probe.ok and "ok: true" in probe.output:
|
||||||
|
return "ok", (
|
||||||
|
"xhs-cli 可用(搜索、阅读、评论、热门;上游 2026-03 起停更,"
|
||||||
|
"桌面用户建议迁移到 OpenCLI)"
|
||||||
|
)
|
||||||
|
if "not_authenticated" in probe.output or "expired" in probe.output:
|
||||||
|
return "warn", (
|
||||||
|
"xhs-cli 已安装但未登录。运行:\n"
|
||||||
|
" xhs login\n"
|
||||||
|
"(自动从浏览器提取 Cookie,或扫码登录)"
|
||||||
|
)
|
||||||
|
return "warn", (
|
||||||
|
"xhs-cli 已安装但状态异常。运行:\n"
|
||||||
|
" xhs -v status 查看详细信息"
|
||||||
)
|
)
|
||||||
if "已登录" in r.stdout or "logged" in r.stdout.lower():
|
|
||||||
return "ok", "完整可用(阅读、搜索、发帖、评论、点赞)"
|
|
||||||
return "warn", "MCP 已连接但未登录,需扫码登录"
|
|
||||||
except Exception:
|
|
||||||
return "warn", "MCP 连接异常,检查 xiaohongshu-mcp 服务是否在运行"
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Xiaoyuzhou Podcast (小宇宙播客) — transcribe podcasts via Groq Whisper API."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from agent_reach.config import Config
|
||||||
|
from agent_reach.probe import probe_command
|
||||||
|
from .base import Channel
|
||||||
|
|
||||||
|
|
||||||
|
class XiaoyuzhouChannel(Channel):
|
||||||
|
name = "xiaoyuzhou"
|
||||||
|
description = "小宇宙播客转文字"
|
||||||
|
backends = ["groq-whisper", "ffmpeg"]
|
||||||
|
tier = 1
|
||||||
|
|
||||||
|
def can_handle(self, url: str) -> bool:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
d = urlparse(url).netloc.lower()
|
||||||
|
return "xiaoyuzhoufm.com" in d
|
||||||
|
|
||||||
|
def check(self, config=None):
|
||||||
|
self.active_backend = None
|
||||||
|
|
||||||
|
# Check ffmpeg — really execute it: a stale pip-installed ffmpeg shim
|
||||||
|
# passes shutil.which() but cannot run
|
||||||
|
probe = probe_command("ffmpeg", ["-version"], timeout=10, package="ffmpeg")
|
||||||
|
if probe.status == "missing":
|
||||||
|
return "off", (
|
||||||
|
"需要 ffmpeg(音频转码和切片)。安装:\n"
|
||||||
|
" Ubuntu/Debian: apt install -y ffmpeg\n"
|
||||||
|
" macOS: brew install ffmpeg"
|
||||||
|
)
|
||||||
|
if not probe.ok:
|
||||||
|
return "error", (
|
||||||
|
"ffmpeg 无法执行,重装:brew install ffmpeg(macOS)/ apt install ffmpeg(Linux)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check script exists
|
||||||
|
script = os.path.expanduser("~/.agent-reach/tools/xiaoyuzhou/transcribe.sh")
|
||||||
|
if not os.path.isfile(script):
|
||||||
|
return "off", (
|
||||||
|
"转录脚本未安装。运行:\n"
|
||||||
|
" agent-reach install --env=auto\n"
|
||||||
|
" 或手动复制 transcribe.sh 到 ~/.agent-reach/tools/xiaoyuzhou/"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check GROQ_API_KEY — prefer env var, fall back to Agent Reach config
|
||||||
|
has_key = bool(os.environ.get("GROQ_API_KEY"))
|
||||||
|
if not has_key:
|
||||||
|
try:
|
||||||
|
cfg = config if config is not None else Config()
|
||||||
|
has_key = bool(cfg.get("groq_api_key"))
|
||||||
|
except Exception:
|
||||||
|
has_key = False
|
||||||
|
if not has_key:
|
||||||
|
return "warn", (
|
||||||
|
"需要配置 Groq API Key(免费)。步骤:\n"
|
||||||
|
" 1. 注册 https://console.groq.com\n"
|
||||||
|
" 2. 运行: agent-reach configure groq-key gsk_xxxxx"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.active_backend = "groq-whisper"
|
||||||
|
return "ok", "完整可用(播客下载 + Whisper 转录)"
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Xueqiu (雪球) — stock quotes, search, trending posts & hot stocks."""
|
||||||
|
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .base import Channel
|
||||||
|
|
||||||
|
_UA = (
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
_REFERER = "https://xueqiu.com/"
|
||||||
|
_TIMEOUT = 10
|
||||||
|
_XUEQIU_HOME = "https://xueqiu.com"
|
||||||
|
|
||||||
|
# --------------- cookie-aware HTTP helpers --------------- #
|
||||||
|
|
||||||
|
_cookie_jar = http.cookiejar.CookieJar()
|
||||||
|
_opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(_cookie_jar),
|
||||||
|
)
|
||||||
|
_cookies_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_cookie_string(cookie_str: str) -> None:
|
||||||
|
"""Parse a 'name=value; name2=value2' string and inject into the cookie jar."""
|
||||||
|
for pair in cookie_str.split(";"):
|
||||||
|
pair = pair.strip()
|
||||||
|
if "=" not in pair:
|
||||||
|
continue
|
||||||
|
name, _, value = pair.partition("=")
|
||||||
|
cookie = http.cookiejar.Cookie(
|
||||||
|
version=0,
|
||||||
|
name=name.strip(),
|
||||||
|
value=value.strip(),
|
||||||
|
port=None,
|
||||||
|
port_specified=False,
|
||||||
|
domain=".xueqiu.com",
|
||||||
|
domain_specified=True,
|
||||||
|
domain_initial_dot=True,
|
||||||
|
path="/",
|
||||||
|
path_specified=True,
|
||||||
|
secure=True,
|
||||||
|
expires=None,
|
||||||
|
discard=True,
|
||||||
|
comment=None,
|
||||||
|
comment_url=None,
|
||||||
|
rest={},
|
||||||
|
)
|
||||||
|
_cookie_jar.set_cookie(cookie)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cookies_from_config() -> bool:
|
||||||
|
"""Try to load Xueqiu cookies from agent-reach config file (xueqiu_cookie key)."""
|
||||||
|
try:
|
||||||
|
from ..config import Config
|
||||||
|
|
||||||
|
cfg = Config()
|
||||||
|
cookie_str = cfg.get("xueqiu_cookie")
|
||||||
|
if not cookie_str:
|
||||||
|
return False
|
||||||
|
_inject_cookie_string(cookie_str)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cookies_from_browser() -> bool:
|
||||||
|
"""Try to silently load Xueqiu cookies from the local Chrome browser.
|
||||||
|
|
||||||
|
Only succeeds when browser_cookie3 is installed AND the user is logged in
|
||||||
|
(xq_a_token present). Failures are silently ignored so that agents without
|
||||||
|
a local browser keep working.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
import rookiepy
|
||||||
|
cookies = rookiepy.chrome([".xueqiu.com"])
|
||||||
|
if not any(c.get("name") == "xq_a_token" for c in cookies):
|
||||||
|
return False
|
||||||
|
for c in cookies:
|
||||||
|
_cookie_jar.set(c["name"], c["value"], domain=c.get("domain", ".xueqiu.com"))
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
import browser_cookie3
|
||||||
|
cookies = list(browser_cookie3.chrome(domain_name=".xueqiu.com"))
|
||||||
|
if not any(c.name == "xq_a_token" for c in cookies):
|
||||||
|
return False
|
||||||
|
for c in cookies:
|
||||||
|
_cookie_jar.set_cookie(c)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_cookies() -> None:
|
||||||
|
"""Populate session cookies using the best available source.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. Saved cookie string in ~/.agent-reach/config.yaml (set by configure --from-browser)
|
||||||
|
2. Live Chrome browser cookies via rookiepy/browser_cookie3 (if installed + logged in)
|
||||||
|
3. Homepage visit fallback (only yields anti-DDoS acw_tc,
|
||||||
|
not enough for stock APIs)
|
||||||
|
"""
|
||||||
|
global _cookies_initialized
|
||||||
|
if _cookies_initialized:
|
||||||
|
return
|
||||||
|
if _load_cookies_from_config():
|
||||||
|
_cookies_initialized = True
|
||||||
|
return
|
||||||
|
if _load_cookies_from_browser():
|
||||||
|
_cookies_initialized = True
|
||||||
|
return
|
||||||
|
# Fallback: visit homepage to pick up acw_tc anti-DDoS cookie.
|
||||||
|
# This is not sufficient for authenticated APIs but avoids hard failures
|
||||||
|
# on public endpoints that only need the session cookie.
|
||||||
|
req = urllib.request.Request(_XUEQIU_HOME, headers={"User-Agent": _UA})
|
||||||
|
_opener.open(req, timeout=_TIMEOUT)
|
||||||
|
_cookies_initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url: str) -> Any:
|
||||||
|
"""Fetch *url* with Xueqiu session cookies and return parsed JSON."""
|
||||||
|
_ensure_cookies()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, headers={"User-Agent": _UA, "Referer": _REFERER}
|
||||||
|
)
|
||||||
|
with _opener.open(req, timeout=_TIMEOUT) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_html(text: str) -> str:
|
||||||
|
"""Remove HTML tags and decode common entities."""
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
for entity, char in ((" ", " "), ("&", "&"), ("<", "<"), (">", ">")):
|
||||||
|
text = text.replace(entity, char)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class XueqiuChannel(Channel):
|
||||||
|
name = "xueqiu"
|
||||||
|
description = "雪球股票行情与社区动态"
|
||||||
|
backends = ["Xueqiu API (需要登录 Cookie)"]
|
||||||
|
tier = 1
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# URL routing
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def can_handle(self, url: str) -> bool:
|
||||||
|
d = urllib.parse.urlparse(url).netloc.lower()
|
||||||
|
return "xueqiu.com" in d
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Health check
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def check(self, config=None):
|
||||||
|
self.active_backend = None
|
||||||
|
try:
|
||||||
|
data = _get_json(
|
||||||
|
"https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH000001"
|
||||||
|
)
|
||||||
|
items = (data.get("data") or {}).get("items") or []
|
||||||
|
if items:
|
||||||
|
self.active_backend = self.backends[0]
|
||||||
|
return "ok", "公开 API 可用(行情、搜索、热帖、热股)"
|
||||||
|
return "warn", "API 响应异常(返回数据为空)"
|
||||||
|
except Exception as e:
|
||||||
|
return "warn", (
|
||||||
|
f"Xueqiu API 连接失败:{e}。"
|
||||||
|
"请先登录雪球后运行:agent-reach configure --from-browser chrome"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Data-fetching methods
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def get_stock_quote(self, symbol: str) -> dict:
|
||||||
|
"""获取实时股票行情。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: 股票代码,如 SH600519(沪)、SZ000858(深)、AAPL(美)、00700(港)
|
||||||
|
|
||||||
|
Returns a dict with keys:
|
||||||
|
symbol, name, current, percent, chg, high, low, open, last_close,
|
||||||
|
volume, amount, market_capital, turnover_rate, pe_ttm, timestamp
|
||||||
|
"""
|
||||||
|
data = _get_json(
|
||||||
|
f"https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol={symbol}"
|
||||||
|
)
|
||||||
|
items = (data.get("data") or {}).get("items") or []
|
||||||
|
q = (items[0].get("quote") or {}) if items else {}
|
||||||
|
return {
|
||||||
|
"symbol": q.get("symbol", symbol),
|
||||||
|
"name": q.get("name", ""),
|
||||||
|
"current": q.get("current"),
|
||||||
|
"percent": q.get("percent"),
|
||||||
|
"chg": q.get("chg"),
|
||||||
|
"high": q.get("high"),
|
||||||
|
"low": q.get("low"),
|
||||||
|
"open": q.get("open"),
|
||||||
|
"last_close": q.get("last_close"),
|
||||||
|
"volume": q.get("volume"),
|
||||||
|
"amount": q.get("amount"),
|
||||||
|
"market_capital": q.get("market_capital"),
|
||||||
|
"turnover_rate": q.get("turnover_rate"),
|
||||||
|
"pe_ttm": q.get("pe_ttm"),
|
||||||
|
"timestamp": q.get("timestamp"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def search_stock(self, query: str, limit: int = 10) -> list:
|
||||||
|
"""搜索股票。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: 股票代码或中文名称,如 "茅台"、"600519"
|
||||||
|
limit: 最多返回条数
|
||||||
|
|
||||||
|
Returns a list of dicts with keys:
|
||||||
|
symbol, name, exchange
|
||||||
|
"""
|
||||||
|
data = _get_json(
|
||||||
|
f"https://xueqiu.com/stock/search.json"
|
||||||
|
f"?code={urllib.parse.quote(query)}&size={limit}"
|
||||||
|
)
|
||||||
|
stocks = data.get("stocks") or []
|
||||||
|
results = []
|
||||||
|
for s in stocks[:limit]:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"symbol": s.get("code", ""),
|
||||||
|
"name": s.get("name", ""),
|
||||||
|
"exchange": s.get("exchange", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_hot_posts(self, limit: int = 20) -> list:
|
||||||
|
"""获取雪球热门帖子。
|
||||||
|
|
||||||
|
Uses the v4 public timeline endpoint which returns posts in a `list`
|
||||||
|
array. Each item carries a JSON-encoded `data` field containing the
|
||||||
|
actual post payload (title, description, user, like_count, target).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: 最多返回条数(上限 50)
|
||||||
|
|
||||||
|
Returns a list of dicts with keys:
|
||||||
|
id, title, text, author, likes, url
|
||||||
|
"""
|
||||||
|
data = _get_json(
|
||||||
|
"https://xueqiu.com/v4/statuses/public_timeline_by_category.json"
|
||||||
|
"?since_id=-1&max_id=-1&count=20&category=-1"
|
||||||
|
)
|
||||||
|
items = data.get("list") or []
|
||||||
|
results = []
|
||||||
|
for item in items[:limit]:
|
||||||
|
# Each item.data is a JSON string containing the real post payload
|
||||||
|
try:
|
||||||
|
post = (
|
||||||
|
json.loads(item["data"])
|
||||||
|
if isinstance(item.get("data"), str)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
post = {}
|
||||||
|
user = post.get("user") or {}
|
||||||
|
text = _strip_html(
|
||||||
|
post.get("text") or post.get("description") or ""
|
||||||
|
)
|
||||||
|
target = post.get("target", "")
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": post.get("id", 0),
|
||||||
|
"title": post.get("title") or "",
|
||||||
|
"text": text[:200],
|
||||||
|
"author": user.get("screen_name", ""),
|
||||||
|
"likes": post.get("like_count", 0),
|
||||||
|
"url": f"https://xueqiu.com{target}" if target else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_hot_stocks(self, limit: int = 10, stock_type: int = 10) -> list:
|
||||||
|
"""获取热门股票排行。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: 最多返回条数(上限 50)
|
||||||
|
stock_type: 10=人气榜(默认),12=关注榜
|
||||||
|
|
||||||
|
Returns a list of dicts with keys:
|
||||||
|
symbol, name, current, percent, rank
|
||||||
|
"""
|
||||||
|
data = _get_json(
|
||||||
|
f"https://stock.xueqiu.com/v5/stock/hot_stock/list.json"
|
||||||
|
f"?size={limit}&type={stock_type}"
|
||||||
|
)
|
||||||
|
items = (data.get("data") or {}).get("items") or []
|
||||||
|
results = []
|
||||||
|
for idx, item in enumerate(items[:limit], 1):
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"symbol": item.get("code") or item.get("symbol", ""),
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"current": item.get("current"),
|
||||||
|
"percent": item.get("percent"),
|
||||||
|
"rank": idx,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""YouTube — check if yt-dlp is available with JS runtime."""
|
"""YouTube — check if yt-dlp is available with JS runtime."""
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
from agent_reach.probe import probe_command
|
||||||
|
from agent_reach.utils.paths import get_ytdlp_config_path, render_ytdlp_fix_command
|
||||||
|
from agent_reach.utils.text import read_utf8_text
|
||||||
|
|
||||||
from .base import Channel
|
from .base import Channel
|
||||||
|
|
||||||
|
|
||||||
|
def _has_js_runtime_config(config_path) -> bool:
|
||||||
|
"""Return whether yt-dlp config explicitly enables a JS runtime."""
|
||||||
|
try:
|
||||||
|
if not config_path.exists():
|
||||||
|
return False
|
||||||
|
return "--js-runtimes" in read_utf8_text(config_path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class YouTubeChannel(Channel):
|
class YouTubeChannel(Channel):
|
||||||
name = "youtube"
|
name = "youtube"
|
||||||
description = "YouTube 视频和字幕"
|
description = "YouTube 视频和字幕"
|
||||||
@@ -14,12 +28,25 @@ class YouTubeChannel(Channel):
|
|||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
d = urlparse(url).netloc.lower()
|
d = urlparse(url).netloc.lower()
|
||||||
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 not shutil.which("yt-dlp"):
|
# 真跑 yt-dlp --version 探活,区分未装 / venv 断链 / 跑不动
|
||||||
|
probe = probe_command("yt-dlp", ["--version"], timeout=10, package="yt-dlp")
|
||||||
|
if probe.status == "missing":
|
||||||
|
self.active_backend = None
|
||||||
return "off", "yt-dlp 未安装。安装:pip install yt-dlp"
|
return "off", "yt-dlp 未安装。安装:pip install yt-dlp"
|
||||||
|
if probe.status == "broken":
|
||||||
|
self.active_backend = None
|
||||||
|
return "error", f"yt-dlp 已安装但无法执行\n{probe.hint}"
|
||||||
|
if not probe.ok: # timeout / error:装了但跑不动
|
||||||
|
self.active_backend = None
|
||||||
|
detail = probe.hint or probe.output or probe.status
|
||||||
|
return "error", f"yt-dlp 无法正常运行:{detail}"
|
||||||
|
# yt-dlp 本体是活的;后面的 JS runtime/转写检查只影响 ok/warn,不影响后端归属
|
||||||
|
self.active_backend = "yt-dlp"
|
||||||
# Check JS runtime
|
# Check JS runtime
|
||||||
has_js = shutil.which("deno") or shutil.which("node")
|
has_js = shutil.which("deno") or shutil.which("node")
|
||||||
if not has_js:
|
if not has_js:
|
||||||
@@ -28,13 +55,37 @@ class YouTubeChannel(Channel):
|
|||||||
" 安装 Node.js 或 deno,然后运行:agent-reach install"
|
" 安装 Node.js 或 deno,然后运行:agent-reach install"
|
||||||
)
|
)
|
||||||
# Check yt-dlp config for --js-runtimes
|
# Check yt-dlp config for --js-runtimes
|
||||||
ytdlp_config = os.path.expanduser("~/.config/yt-dlp/config")
|
# Deno works out of the box; Node.js requires explicit config
|
||||||
has_deno = shutil.which("deno")
|
has_deno = shutil.which("deno")
|
||||||
if not has_deno and os.path.exists(ytdlp_config):
|
if not has_deno:
|
||||||
with open(ytdlp_config, "r") as f:
|
ytdlp_config = get_ytdlp_config_path()
|
||||||
if "--js-runtimes" not in f.read():
|
if not _has_js_runtime_config(ytdlp_config):
|
||||||
return "warn", (
|
return "warn", (
|
||||||
"yt-dlp 已安装但未配置 JS runtime。运行:\n"
|
f"yt-dlp 已安装但未配置 JS runtime。运行:\n {render_ytdlp_fix_command()}"
|
||||||
" mkdir -p ~/.config/yt-dlp && echo '--js-runtimes node' >> ~/.config/yt-dlp/config"
|
|
||||||
)
|
)
|
||||||
return "ok", "可提取视频信息和字幕"
|
# Surface transcription readiness so `doctor` reports it.
|
||||||
|
msg = "可提取视频信息和字幕"
|
||||||
|
if config is not None:
|
||||||
|
providers = []
|
||||||
|
if config.is_configured("groq_whisper"):
|
||||||
|
providers.append("groq")
|
||||||
|
if config.is_configured("openai_whisper"):
|
||||||
|
providers.append("openai")
|
||||||
|
if providers:
|
||||||
|
if not shutil.which("ffmpeg"):
|
||||||
|
msg += "(音频转写需安装 ffmpeg)"
|
||||||
|
else:
|
||||||
|
msg += f",可转写音频({'→'.join(providers)})"
|
||||||
|
return "ok", msg
|
||||||
|
|
||||||
|
def transcribe(self, url: str, *, provider: str = "auto", config=None) -> str:
|
||||||
|
"""Download a YouTube video's audio and return its transcript.
|
||||||
|
|
||||||
|
Delegates to :func:`agent_reach.transcribe.transcribe`. Imported lazily
|
||||||
|
so the channel module stays cheap to import for users who never
|
||||||
|
transcribe.
|
||||||
|
"""
|
||||||
|
from agent_reach.transcribe import transcribe as _transcribe
|
||||||
|
|
||||||
|
return _transcribe(url, provider=provider, config=config)
|
||||||
|
|
||||||
|
|||||||
+856
-246
File diff suppressed because it is too large
Load Diff
+15
-7
@@ -21,9 +21,9 @@ class Config:
|
|||||||
# Feature → required config keys
|
# Feature → required config keys
|
||||||
FEATURE_REQUIREMENTS = {
|
FEATURE_REQUIREMENTS = {
|
||||||
"exa_search": ["exa_api_key"],
|
"exa_search": ["exa_api_key"],
|
||||||
"reddit_proxy": ["reddit_proxy"],
|
"twitter_xreach": ["twitter_auth_token", "twitter_ct0"], # legacy key name; used by twitter-cli
|
||||||
"twitter_xreach": ["twitter_auth_token", "twitter_ct0"],
|
|
||||||
"groq_whisper": ["groq_api_key"],
|
"groq_whisper": ["groq_api_key"],
|
||||||
|
"openai_whisper": ["openai_api_key"],
|
||||||
"github_token": ["github_token"],
|
"github_token": ["github_token"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,14 +49,22 @@ 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", encoding="utf-8") as f:
|
# Create file with restricted permissions from the start to avoid
|
||||||
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True)
|
# a race window where credentials are briefly world-readable.
|
||||||
# Restrict permissions — config may contain credentials
|
|
||||||
try:
|
try:
|
||||||
import stat
|
import stat
|
||||||
self.config_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
|
fd = os.open(
|
||||||
|
str(self.config_path),
|
||||||
|
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
||||||
|
stat.S_IRUSR | stat.S_IWUSR, # 0o600
|
||||||
|
)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Windows or permission edge cases
|
# Fallback for Windows or other edge cases where os.open flags
|
||||||
|
# are not fully supported.
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> Any:
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
"""Get a config value. Also checks environment variables (uppercase)."""
|
"""Get a config value. Also checks environment variables (uppercase)."""
|
||||||
|
|||||||
+135
-11
@@ -32,6 +32,12 @@ PLATFORM_SPECS = [
|
|||||||
"cookies": ["SESSDATA", "bili_jct"],
|
"cookies": ["SESSDATA", "bili_jct"],
|
||||||
"config_key": "bilibili",
|
"config_key": "bilibili",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Xueqiu",
|
||||||
|
"domains": [".xueqiu.com", "xueqiu.com"],
|
||||||
|
"cookies": None, # grab all — xq_a_token + session cookies required
|
||||||
|
"config_key": "xueqiu",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -46,14 +52,52 @@ def extract_all(browser: str = "chrome") -> Dict[str, dict]:
|
|||||||
"bilibili": {"SESSDATA": "xxx", "bili_jct": "yyy"},
|
"bilibili": {"SESSDATA": "xxx", "bili_jct": "yyy"},
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
# Try rookiepy first (Rust-based, more stable), fallback to browser_cookie3
|
||||||
|
use_rookiepy = False
|
||||||
|
try:
|
||||||
|
import rookiepy
|
||||||
|
use_rookiepy = True
|
||||||
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
import browser_cookie3
|
import browser_cookie3
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"browser_cookie3 not installed. Run: pip install browser-cookie3"
|
"Cookie extraction requires rookiepy or browser_cookie3.\n"
|
||||||
|
"Install: pip install rookiepy (recommended)\n"
|
||||||
|
" or: pip install browser-cookie3"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get browser cookie jar
|
browser = browser.lower()
|
||||||
|
supported = ["chrome", "firefox", "edge", "brave", "opera"]
|
||||||
|
if browser not in supported:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported browser: {browser}. Supported: {', '.join(supported)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_rookiepy:
|
||||||
|
# rookiepy returns list of dicts with name/value/domain/path keys
|
||||||
|
try:
|
||||||
|
browser_funcs = {
|
||||||
|
"chrome": rookiepy.chrome,
|
||||||
|
"firefox": rookiepy.firefox,
|
||||||
|
"edge": rookiepy.edge,
|
||||||
|
"brave": rookiepy.brave,
|
||||||
|
"opera": rookiepy.opera,
|
||||||
|
}
|
||||||
|
raw_cookies = browser_funcs[browser]()
|
||||||
|
# Wrap into objects with .name, .value, .domain for compatibility
|
||||||
|
class _Cookie:
|
||||||
|
def __init__(self, d):
|
||||||
|
self.name = d.get("name", "")
|
||||||
|
self.value = d.get("value", "")
|
||||||
|
self.domain = d.get("domain", "")
|
||||||
|
cookie_jar = [_Cookie(c) for c in raw_cookies]
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Could not read {browser} cookies via rookiepy: {e}\n"
|
||||||
|
f"Make sure {browser} is closed and you have permission."
|
||||||
|
)
|
||||||
|
else:
|
||||||
browser_funcs = {
|
browser_funcs = {
|
||||||
"chrome": browser_cookie3.chrome,
|
"chrome": browser_cookie3.chrome,
|
||||||
"firefox": browser_cookie3.firefox,
|
"firefox": browser_cookie3.firefox,
|
||||||
@@ -61,20 +105,12 @@ def extract_all(browser: str = "chrome") -> Dict[str, dict]:
|
|||||||
"brave": browser_cookie3.brave,
|
"brave": browser_cookie3.brave,
|
||||||
"opera": browser_cookie3.opera,
|
"opera": browser_cookie3.opera,
|
||||||
}
|
}
|
||||||
|
|
||||||
browser = browser.lower()
|
|
||||||
if browser not in browser_funcs:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unsupported browser: {browser}. "
|
|
||||||
f"Supported: {', '.join(browser_funcs.keys())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cookie_jar = browser_funcs[browser]()
|
cookie_jar = browser_funcs[browser]()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Could not read {browser} cookies: {e}\n"
|
f"Could not read {browser} cookies: {e}\n"
|
||||||
f"Make sure {browser} is closed and you have permission to read its data."
|
f"Make sure {browser} is closed and you have permission."
|
||||||
)
|
)
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
@@ -112,6 +148,80 @@ def extract_all(browser: str = "chrome") -> Dict[str, dict]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _open_owner_only(path: str):
|
||||||
|
"""Open *path* for writing, atomically creating it with mode 0o600.
|
||||||
|
|
||||||
|
Mirrors the pattern used by Config.save() in config.py: O_WRONLY|O_CREAT|
|
||||||
|
O_TRUNC + an explicit mode argument so the file is never briefly
|
||||||
|
world-readable between open() and a later os.chmod(). On Windows (or any
|
||||||
|
OS that rejects the open flags) we fall back to a plain open().
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd = os.open(
|
||||||
|
path,
|
||||||
|
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
||||||
|
stat.S_IRUSR | stat.S_IWUSR, # 0o600
|
||||||
|
)
|
||||||
|
return os.fdopen(fd, "w", encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return open(path, "w", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_xfetch_session(auth_token: str, ct0: str) -> None:
|
||||||
|
"""Sync Twitter credentials to ~/.config/xfetch/session.json (legacy xreach compat)."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
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: dict = {}
|
||||||
|
if os.path.exists(session_path):
|
||||||
|
try:
|
||||||
|
with open(session_path, "r", encoding="utf-8") as sf:
|
||||||
|
session_data = json.load(sf)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
session_data = {}
|
||||||
|
session_data["authToken"] = auth_token
|
||||||
|
session_data["ct0"] = ct0
|
||||||
|
with _open_owner_only(session_path) as sf:
|
||||||
|
json.dump(session_data, sf, indent=2)
|
||||||
|
except Exception:
|
||||||
|
# Non-fatal: agent-reach config is the source of truth, xfetch sync is best-effort
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_bird_env(auth_token: str, ct0: str) -> None:
|
||||||
|
"""Write Twitter credentials to ~/.config/bird/credentials.env for bird CLI.
|
||||||
|
|
||||||
|
bird reads AUTH_TOKEN and CT0 from environment variables. This writes a
|
||||||
|
shell-sourceable file so users can `source ~/.config/bird/credentials.env`.
|
||||||
|
Values are passed through shlex.quote so a token containing a quote, $, or
|
||||||
|
backtick cannot break out into shell syntax when the file is sourced.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
try:
|
||||||
|
bird_dir = os.path.join(os.path.expanduser("~"), ".config", "bird")
|
||||||
|
os.makedirs(bird_dir, exist_ok=True)
|
||||||
|
env_path = os.path.join(bird_dir, "credentials.env")
|
||||||
|
with _open_owner_only(env_path) as f:
|
||||||
|
f.write(f"AUTH_TOKEN={shlex.quote(auth_token)}\n")
|
||||||
|
f.write(f"CT0={shlex.quote(ct0)}\n")
|
||||||
|
except Exception:
|
||||||
|
# Non-fatal: agent-reach config is the source of truth, bird env sync is best-effort
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Alias for callers expecting the name _sync_bird_credentials
|
||||||
|
_sync_bird_credentials = _sync_bird_env
|
||||||
|
|
||||||
|
|
||||||
def configure_from_browser(browser: str, config) -> List[Tuple[str, bool, str]]:
|
def configure_from_browser(browser: str, config) -> List[Tuple[str, bool, str]]:
|
||||||
"""
|
"""
|
||||||
Extract cookies and configure all found platforms.
|
Extract cookies and configure all found platforms.
|
||||||
@@ -136,6 +246,8 @@ def configure_from_browser(browser: str, config) -> List[Tuple[str, bool, str]]:
|
|||||||
if "auth_token" in tc and "ct0" in tc:
|
if "auth_token" in tc and "ct0" in tc:
|
||||||
config.set("twitter_auth_token", tc["auth_token"])
|
config.set("twitter_auth_token", tc["auth_token"])
|
||||||
config.set("twitter_ct0", tc["ct0"])
|
config.set("twitter_ct0", tc["ct0"])
|
||||||
|
# Legacy sync (best-effort)
|
||||||
|
_sync_xfetch_session(tc["auth_token"], tc["ct0"])
|
||||||
results_list.append(("Twitter/X", True, "auth_token + ct0"))
|
results_list.append(("Twitter/X", True, "auth_token + ct0"))
|
||||||
else:
|
else:
|
||||||
found = ", ".join(tc.keys())
|
found = ", ".join(tc.keys())
|
||||||
@@ -163,4 +275,16 @@ def configure_from_browser(browser: str, config) -> List[Tuple[str, bool, str]]:
|
|||||||
results_list.append(("Bilibili", False,
|
results_list.append(("Bilibili", False,
|
||||||
f"No SESSDATA found. Make sure you're logged into bilibili.com in {browser}."))
|
f"No SESSDATA found. Make sure you're logged into bilibili.com in {browser}."))
|
||||||
|
|
||||||
|
if "xueqiu" in extracted:
|
||||||
|
cookie_str = extracted["xueqiu"].get("cookie_string", "")
|
||||||
|
# Only save if xq_a_token is present — anonymous cookies are useless
|
||||||
|
if cookie_str and "xq_a_token" in cookie_str:
|
||||||
|
config.set("xueqiu_cookie", cookie_str)
|
||||||
|
n_cookies = len(cookie_str.split(";"))
|
||||||
|
results_list.append(("Xueqiu", True, f"{n_cookies} cookies (含 xq_a_token)"))
|
||||||
|
elif cookie_str:
|
||||||
|
results_list.append(("Xueqiu", False,
|
||||||
|
f"找到 {len(cookie_str.split(';'))} 个 Cookie 但缺少 xq_a_token,"
|
||||||
|
f"请先在 {browser} 中登录 xueqiu.com"))
|
||||||
|
|
||||||
return results_list
|
return results_list
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
(xreach CLI, yt-dlp, mcporter, gh CLI, etc.). After installation, agents
|
(twitter-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:
|
||||||
|
|||||||
+65
-30
@@ -10,81 +10,116 @@ from agent_reach.channels import get_all_channels
|
|||||||
|
|
||||||
|
|
||||||
def check_all(config: Config) -> Dict[str, dict]:
|
def check_all(config: Config) -> Dict[str, dict]:
|
||||||
"""Check all channels and return status dict."""
|
"""Check all channels and return status dict.
|
||||||
|
|
||||||
|
A single misbehaving channel must never take the whole report down,
|
||||||
|
so per-channel exceptions degrade to status="error".
|
||||||
|
"""
|
||||||
results = {}
|
results = {}
|
||||||
for ch in get_all_channels():
|
for ch in get_all_channels():
|
||||||
|
try:
|
||||||
status, message = ch.check(config)
|
status, message = ch.check(config)
|
||||||
|
active = getattr(ch, "active_backend", None)
|
||||||
|
except Exception as e: # noqa: BLE001 — doctor must survive any channel
|
||||||
|
# Channels are registry singletons: a stale active_backend from a
|
||||||
|
# previous check must not leak into an errored result.
|
||||||
|
status, message, active = "error", f"体检异常:{e}", None
|
||||||
results[ch.name] = {
|
results[ch.name] = {
|
||||||
"status": status,
|
"status": status,
|
||||||
"name": ch.description,
|
"name": ch.description,
|
||||||
"message": message,
|
"message": message,
|
||||||
"tier": ch.tier,
|
"tier": ch.tier,
|
||||||
"backends": ch.backends,
|
"backends": ch.backends,
|
||||||
|
"active_backend": active,
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _name_msg(r: dict, escape) -> str:
|
||||||
|
"""Render one channel line; show the active backend when there is a choice."""
|
||||||
|
text = f"[bold]{escape(r['name'])}[/bold] — {escape(r['message'])}"
|
||||||
|
active = r.get("active_backend")
|
||||||
|
if active and len(r.get("backends", [])) > 1:
|
||||||
|
text += f" [dim](当前后端:{escape(active)})[/dim]"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def format_report(results: Dict[str, dict]) -> str:
|
def format_report(results: Dict[str, dict]) -> str:
|
||||||
"""Format results as a readable text report."""
|
"""Format results as a readable text report (with Rich markup)."""
|
||||||
|
try:
|
||||||
|
from rich.markup import escape
|
||||||
|
except ImportError:
|
||||||
|
escape = lambda x: x
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
lines.append("👁️ Agent Reach 状态")
|
lines.append("[bold cyan]Agent Reach 状态[/bold cyan]")
|
||||||
lines.append("=" * 40)
|
lines.append("[cyan]" + "=" * 40 + "[/cyan]")
|
||||||
|
lines.append("图例:[green]✅[/green] 可用 [yellow][!][/yellow] 已装但需配置/登录 [red][X][/red] 未安装")
|
||||||
|
|
||||||
ok_count = sum(1 for r in results.values() if r["status"] == "ok")
|
ok_count = sum(1 for r in results.values() if r["status"] == "ok")
|
||||||
total = len(results)
|
total = len(results)
|
||||||
|
|
||||||
# Tier 0 — zero config
|
# Tier 0 — zero config
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("✅ 装好即用:")
|
lines.append("[bold]✅ 装好即用:[/bold]")
|
||||||
for key, r in results.items():
|
for key, r in results.items():
|
||||||
if r["tier"] == 0:
|
if r["tier"] == 0:
|
||||||
|
name_msg = _name_msg(r, escape)
|
||||||
if r["status"] == "ok":
|
if r["status"] == "ok":
|
||||||
lines.append(f" ✅ {r['name']} — {r['message']}")
|
lines.append(f" [green]✅[/green] {name_msg}")
|
||||||
elif r["status"] == "warn":
|
elif r["status"] == "warn":
|
||||||
lines.append(f" ⚠️ {r['name']} — {r['message']}")
|
lines.append(f" [yellow][!][/yellow] {name_msg}")
|
||||||
elif r["status"] in ("off", "error"):
|
elif r["status"] in ("off", "error"):
|
||||||
lines.append(f" ❌ {r['name']} — {r['message']}")
|
lines.append(f" [red][X][/red] {name_msg}")
|
||||||
|
|
||||||
# Tier 1 — needs free key
|
# Tier 1 — needs free key / login
|
||||||
tier1 = {k: r for k, r in results.items() if r["tier"] == 1}
|
tier1 = {k: r for k, r in results.items() if r["tier"] == 1}
|
||||||
if tier1:
|
tier1_active = {k: r for k, r in tier1.items() if r["status"] == "ok"}
|
||||||
|
tier1_inactive = {k: r for k, r in tier1.items() if r["status"] != "ok"}
|
||||||
|
if tier1_active:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("🔍 搜索(mcporter 即可解锁):")
|
lines.append("[bold]可选渠道(已安装):[/bold]")
|
||||||
for key, r in tier1.items():
|
for key, r in tier1_active.items():
|
||||||
if r["status"] == "ok":
|
lines.append(f" [green]✅[/green] {_name_msg(r, escape)}")
|
||||||
lines.append(f" ✅ {r['name']} — {r['message']}")
|
|
||||||
else:
|
|
||||||
lines.append(f" ⬜ {r['name']} — {r['message']}")
|
|
||||||
|
|
||||||
# Tier 2 — optional setup
|
# Tier 2 — optional complex setup
|
||||||
tier2 = {k: r for k, r in results.items() if r["tier"] == 2}
|
tier2 = {k: r for k, r in results.items() if r["tier"] == 2}
|
||||||
if tier2:
|
tier2_active = {k: r for k, r in tier2.items() if r["status"] == "ok"}
|
||||||
|
tier2_inactive = {k: r for k, r in tier2.items() if r["status"] != "ok"}
|
||||||
|
if tier2_active:
|
||||||
|
if not tier1_active:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("🔧 配置后可用:")
|
lines.append("[bold]可选渠道(已安装):[/bold]")
|
||||||
for key, r in tier2.items():
|
for key, r in tier2_active.items():
|
||||||
if r["status"] == "ok":
|
lines.append(f" [green]✅[/green] {_name_msg(r, escape)}")
|
||||||
lines.append(f" ✅ {r['name']} — {r['message']}")
|
|
||||||
elif r["status"] == "warn":
|
|
||||||
lines.append(f" ⚠️ {r['name']} — {r['message']}")
|
|
||||||
else:
|
|
||||||
lines.append(f" ⬜ {r['name']} — {r['message']}")
|
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"状态:{ok_count}/{total} 个渠道可用")
|
status_color = "green" if ok_count == total else ("yellow" if ok_count > 0 else "red")
|
||||||
if ok_count < total:
|
lines.append(f"状态:[{status_color}]{ok_count}/{total}[/{status_color}] 个渠道可用")
|
||||||
lines.append("运行 `agent-reach setup` 解锁更多渠道")
|
|
||||||
|
# Summarize inactive optional channels in one line instead of listing each
|
||||||
|
all_inactive = list(tier1_inactive.values()) + list(tier2_inactive.values())
|
||||||
|
if all_inactive:
|
||||||
|
names = [r["name"] for r in all_inactive]
|
||||||
|
lines.append(
|
||||||
|
f"还有 {len(names)} 个可选渠道可以解锁({'、'.join(names)}),"
|
||||||
|
"告诉你的 Agent「帮我装 XXX」即可"
|
||||||
|
)
|
||||||
|
|
||||||
# Security check: config file permissions (Unix only)
|
# Security check: config file permissions (Unix only)
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
config_path = Config.CONFIG_DIR / "config.yaml"
|
config_path = Config.CONFIG_DIR / "config.yaml"
|
||||||
if config_path.exists() and sys.platform != "win32":
|
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):
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("⚠️ 安全提示:config.yaml 权限过宽(其他用户可读)")
|
lines.append(
|
||||||
|
"[bold red][!] 安全提示:config.yaml 权限过宽(其他用户可读)[/bold red]"
|
||||||
|
)
|
||||||
lines.append(" 修复:chmod 600 ~/.agent-reach/config.yaml")
|
lines.append(" 修复:chmod 600 ~/.agent-reach/config.yaml")
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,60 +1,54 @@
|
|||||||
# Reddit 代理配置指南
|
# Reddit 配置指南
|
||||||
|
|
||||||
## 功能说明
|
## 功能说明
|
||||||
Reddit 封锁了大量服务器 IP,直接访问会返回 403。需要 ISP 代理(Residential/ISP Proxy)才能读取完整的 Reddit 帖子和评论。
|
|
||||||
|
|
||||||
**注意**:即使没有代理,也可以通过 Exa 搜索找到 Reddit 内容(只需要配 Exa API key)。代理只是用于读取完整帖子+评论。
|
Reddit 封锁了几乎所有非浏览器的直接访问(包括数据中心和 ISP 代理 IP),JSON API 返回 403。
|
||||||
|
|
||||||
|
Agent Reach 通过 **rdt-cli** 实现 Reddit 的搜索和阅读功能:
|
||||||
|
- **搜索**:`rdt search "关键词"`
|
||||||
|
- **阅读完整帖子+评论**:`rdt read POST_ID`
|
||||||
|
|
||||||
|
免费,无需代理,无需 API Key。需要登录认证(`rdt login`,自动从浏览器提取 Cookie)。
|
||||||
|
|
||||||
## Agent 可自动完成的步骤
|
## Agent 可自动完成的步骤
|
||||||
|
|
||||||
1. 检查当前状态:
|
1. 检查 rdt-cli 是否可用:
|
||||||
```bash
|
```bash
|
||||||
agent-reach doctor | grep "Reddit"
|
which rdt && echo "installed" || echo "not installed"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 如果用户提供了代理,测试连通性:
|
2. 如果未安装,自动安装(PyPI 版本暂时落后,从 GitHub 安装最新版):
|
||||||
```bash
|
```bash
|
||||||
curl -s --proxy "用户提供的代理" \
|
pipx install 'git+https://github.com/public-clis/rdt-cli.git'
|
||||||
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" \
|
|
||||||
"https://www.reddit.com/r/test.json?limit=1" \
|
|
||||||
-o /dev/null -w "%{http_code}"
|
|
||||||
```
|
```
|
||||||
返回 200 = 可用,403 = 代理被封,其他 = 配置错误
|
|
||||||
|
|
||||||
3. 写入配置:
|
或一键安装:
|
||||||
```python
|
```bash
|
||||||
from agent_reach.config import Config
|
agent-reach install --env=auto --channels=reddit
|
||||||
c = Config()
|
```
|
||||||
c.set("reddit_proxy", "http://用户名:密码@IP:端口")
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
搜索 Reddit 内容:
|
||||||
|
```bash
|
||||||
|
rdt search "python best practices" -n 5
|
||||||
|
```
|
||||||
|
|
||||||
|
阅读完整帖子和评论:
|
||||||
|
```bash
|
||||||
|
rdt read POST_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
## 需要用户手动做的步骤
|
## 需要用户手动做的步骤
|
||||||
|
|
||||||
请告诉用户:
|
无。rdt-cli 通过 `agent-reach install --env=auto` 自动安装。
|
||||||
|
|
||||||
> 要完整阅读 Reddit 帖子和评论,需要一个 ISP 代理(约 $3-10/月)。
|
## Fallback:Exa 搜索
|
||||||
>
|
|
||||||
> 推荐代理服务商(选一个就行):
|
|
||||||
> 1. **Smartproxy** (https://smartproxy.com) — ISP 代理,按流量计费
|
|
||||||
> 2. **Bright Data** (https://brightdata.com) — 大厂,ISP 代理
|
|
||||||
> 3. **IPRoyal** (https://iproyal.com) — 便宜,适合入门
|
|
||||||
> 4. **ProxyEmpire** (https://proxyempire.io) — 有 Reddit 专用代理
|
|
||||||
>
|
|
||||||
> 购买时选择:
|
|
||||||
> - 类型:**ISP Proxy**(不要选 Datacenter,会被封)
|
|
||||||
> - 地区:**美国**
|
|
||||||
> - 协议:**HTTP**
|
|
||||||
>
|
|
||||||
> 购买后你会得到一个代理地址,格式类似:
|
|
||||||
> `http://用户名:密码@IP地址:端口号`
|
|
||||||
>
|
|
||||||
> 把这个地址发给我就行。
|
|
||||||
>
|
|
||||||
> ⚠️ 如果不想花钱,可以跳过。我仍然可以通过搜索引擎找到 Reddit 上的内容,只是不能读完整的帖子和评论。
|
|
||||||
|
|
||||||
## Agent 收到代理后的操作
|
如果你已经配置了 Exa(通过 mcporter),也可以通过 Exa 搜索 Reddit 内容:
|
||||||
|
|
||||||
1. 测试代理:用 curl 测试 reddit.com 是否返回 200
|
```bash
|
||||||
2. 如果成功,写入配置:`config.set("reddit_proxy", proxy_url)`
|
mcporter call 'exa.web_search_exa(query: "python best practices", numResults: 5, includeDomains: ["reddit.com"])'
|
||||||
3. 反馈:"✅ Reddit 完整阅读已开启!现在我可以读取 Reddit 帖子和所有评论了。"
|
```
|
||||||
4. 如果失败,告诉用户:"❌ 这个代理无法访问 Reddit,请检查代理是否有效,或换一个试试。"
|
|
||||||
|
rdt-cli 是当前推荐方案,无需额外配置即可使用。
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
# Twitter 高级功能配置指南(xreach CLI)
|
# Twitter 高级功能配置指南(twitter-cli)
|
||||||
|
|
||||||
Twitter 基础阅读通过 Jina Reader 免费可用,无需配置。
|
Twitter 基础阅读通过 Jina Reader 免费可用,无需配置。
|
||||||
|
|
||||||
高级功能需要 xreach CLI:
|
高级功能需要 twitter-cli(@public-clis/twitter-cli):
|
||||||
|
|
||||||
- 搜索推文(`xreach search`)
|
- 搜索推文(`twitter search`)
|
||||||
- 读取完整推文和对话链(`xreach tweet`、`xreach thread`)
|
- 读取完整推文和对话链(`twitter tweet`、`twitter thread`)
|
||||||
- 用户时间线(`xreach tweets`)
|
- 用户时间线(`twitter timeline`)
|
||||||
|
- 长文阅读(`twitter article`)
|
||||||
|
|
||||||
xreach 是免费开源工具(npm 包 xreach-cli),但需要你的 Twitter 账号 cookie。
|
twitter-cli 是免费开源工具(pipx 安装),但需要你的 Twitter 账号 cookie。
|
||||||
|
|
||||||
## 快速配置
|
## 快速配置
|
||||||
|
|
||||||
1. 检查 xreach 是否安装:
|
1. 检查 twitter-cli 是否安装:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
which xreach && echo "installed" || echo "not installed"
|
which twitter && echo "installed" || echo "not installed"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 安装 xreach:
|
2. 安装 twitter-cli:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g xreach-cli
|
pipx install twitter-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 测试是否配置好:
|
3. 测试是否配置好:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AUTH_TOKEN="xxx" CT0="yyy" xreach search "test" -n 1
|
twitter search "test" -n 1
|
||||||
```
|
```
|
||||||
|
|
||||||
## 获取 Cookie(Cookie-Editor 方式,推荐)
|
## 获取 Cookie(Cookie-Editor 方式,推荐)
|
||||||
@@ -47,7 +48,7 @@ agent-reach configure twitter-cookies "粘贴的 cookie JSON"
|
|||||||
|
|
||||||
如果你已经知道 `auth_token` 和 `ct0`:
|
如果你已经知道 `auth_token` 和 `ct0`:
|
||||||
|
|
||||||
1. 安装 xreach(如果没装):`npm install -g xreach-cli`
|
1. 安装 twitter-cli(如果没装):`pipx install twitter-cli`
|
||||||
|
|
||||||
2. 设置环境变量:
|
2. 设置环境变量:
|
||||||
|
|
||||||
@@ -59,19 +60,25 @@ export CT0="你的ct0"
|
|||||||
3. 测试:
|
3. 测试:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xreach search "test" --auth-token "$AUTH_TOKEN" --ct0 "$CT0" -n 1
|
twitter search "test" -n 1
|
||||||
```
|
```
|
||||||
|
|
||||||
## 代理配置
|
## 代理配置
|
||||||
|
|
||||||
> xreach CLI 内置代理支持,通过 `--proxy` 参数传入:
|
> twitter-cli 支持通过环境变量设置代理:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xreach search "test" --auth-token "$AUTH_TOKEN" --ct0 "$CT0" --proxy "http://user:pass@host:port"
|
export HTTP_PROXY="http://user:pass@host:port"
|
||||||
|
export HTTPS_PROXY="http://user:pass@host:port"
|
||||||
|
twitter search "test" -n 1
|
||||||
```
|
```
|
||||||
|
|
||||||
也支持代理轮换文件:
|
也可以使用全局代理工具:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xreach search "test" --auth-token "$AUTH_TOKEN" --ct0 "$CT0" --proxy-file proxies.txt
|
proxychains twitter search "test" -n 1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fallback:bird CLI
|
||||||
|
|
||||||
|
如果你已经安装了 [bird CLI](https://www.npmjs.com/package/@steipete/bird)(`npm install -g @steipete/bird`),它也能正常工作。Agent Reach 会自动检测并使用已安装的 bird。两者功能类似,twitter-cli 是当前推荐方案。
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# 微信公众号配置指南
|
|
||||||
|
|
||||||
## 功能说明
|
|
||||||
读取微信公众号文章。需要 Playwright 来处理微信的反爬机制。
|
|
||||||
|
|
||||||
## Agent 可自动完成的步骤
|
|
||||||
|
|
||||||
1. 检查 Playwright 是否安装:
|
|
||||||
```bash
|
|
||||||
python3 -c "import playwright; print('installed')" 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 安装 Playwright + 浏览器:
|
|
||||||
```bash
|
|
||||||
pip install playwright
|
|
||||||
playwright install chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 安装完成后测试:
|
|
||||||
```bash
|
|
||||||
curl -s "https://r.jina.ai/https://mp.weixin.qq.com/s/一个测试链接" -H "Accept: text/markdown"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 需要用户手动做的步骤
|
|
||||||
|
|
||||||
请告诉用户:
|
|
||||||
|
|
||||||
> 微信公众号的配置很简单,只需要安装一个浏览器组件(约 150MB)。
|
|
||||||
>
|
|
||||||
> 我来帮你安装,你不需要做任何事情。安装过程大约 1-2 分钟。
|
|
||||||
>
|
|
||||||
> 安装好之后就可以直接读取微信公众号文章了,不需要登录。
|
|
||||||
|
|
||||||
## Agent 操作流程
|
|
||||||
|
|
||||||
1. 安装 Playwright:`pip install playwright`
|
|
||||||
2. 安装 Chromium:`playwright install chromium`
|
|
||||||
3. 测试:读一篇微信文章
|
|
||||||
4. 反馈:"✅ 微信公众号已配置!发给我任何公众号文章链接,我都能读取。"
|
|
||||||
5. 如果安装失败(空间不足等):"❌ 浏览器组件安装失败。可能是磁盘空间不足(需要约 150MB)。"
|
|
||||||
@@ -1,91 +1,85 @@
|
|||||||
# 小红书配置指南
|
# 小红书配置指南
|
||||||
|
|
||||||
## 功能说明
|
## 功能说明
|
||||||
读取和搜索小红书笔记。通过 [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) 实现(⭐9K+,Go 语言,内置 Chrome 浏览器)。
|
读取和搜索小红书笔记。通过 [xhs-cli](https://github.com/jackwener/xiaohongshu-cli)(⭐1.5K,pipx 一行安装)实现。
|
||||||
|
|
||||||
## 前置条件
|
## 前置条件
|
||||||
- Docker(用来运行 xiaohongshu-mcp 服务)
|
- Python 3.10+(pipx 安装)
|
||||||
- mcporter CLI(MCP 协议桥接工具)
|
- 浏览器已登录 xiaohongshu.com(用于导出 Cookie)
|
||||||
|
|
||||||
## Agent 可自动完成的步骤
|
## Agent 可自动完成的步骤
|
||||||
|
|
||||||
### 1. 安装 mcporter
|
### 1. 安装 xhs-cli
|
||||||
```bash
|
```bash
|
||||||
npm install -g mcporter
|
pipx install xiaohongshu-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 启动 xiaohongshu-mcp 服务
|
### 2. 登录(从浏览器提取 Cookie)
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
xhs login
|
||||||
--name xiaohongshu-mcp \
|
|
||||||
-p 18060:18060 \
|
|
||||||
xpzouying/xiaohongshu-mcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 如需代理(服务器部署推荐):
|
> 这会自动从浏览器提取 Cookie。如果自动提取失败,可以手动导入(见下方)。
|
||||||
> ```bash
|
|
||||||
> docker run -d \
|
|
||||||
> --name xiaohongshu-mcp \
|
|
||||||
> -p 18060:18060 \
|
|
||||||
> -e XHS_PROXY=http://user:pass@ip:port \
|
|
||||||
> xpzouying/xiaohongshu-mcp
|
|
||||||
> ```
|
|
||||||
|
|
||||||
### 3. 注册到 mcporter
|
### 3. 验证
|
||||||
```bash
|
|
||||||
mcporter config add xiaohongshu http://localhost:18060/mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 验证
|
|
||||||
```bash
|
```bash
|
||||||
agent-reach doctor
|
agent-reach doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
应该看到小红书显示为 ✅ 或 ⚠️(MCP 已连接但未登录)。
|
应该看到小红书显示为 ✅。
|
||||||
|
|
||||||
## 需要用户手动做的步骤
|
## 需要用户手动做的步骤
|
||||||
|
|
||||||
如果 doctor 显示"MCP 已连接但未登录":
|
如果 `xhs login` 自动提取失败,需要手动导入 cookies:
|
||||||
|
|
||||||
> 小红书需要登录一次(之后会记住你的登录状态)。
|
> **推荐方式:Cookie-Editor 浏览器导出(最可靠)**
|
||||||
>
|
>
|
||||||
> 打开 http://localhost:18060 ,用手机小红书 App 扫描二维码登录。
|
> 1. 在 Chrome 中安装 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) 扩展
|
||||||
> 登录后 cookie 会自动保存在 Docker 容器内,大约 1-3 个月有效。
|
> 2. 浏览器登录 xiaohongshu.com
|
||||||
|
> 3. 点击 Cookie-Editor 图标 → Export → Header String
|
||||||
|
> 4. 把导出的字符串发给 Agent,运行:`agent-reach configure xhs-cookies "导出的cookie字符串"`
|
||||||
|
>
|
||||||
|
> **注意**:不要依赖 QR 扫码登录,Cookie-Editor 导出方式最简单可靠。
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
搜索笔记:
|
||||||
|
```bash
|
||||||
|
xhs search "关键词"
|
||||||
|
```
|
||||||
|
|
||||||
|
阅读笔记详情:
|
||||||
|
```bash
|
||||||
|
xhs read NOTE_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
查看评论:
|
||||||
|
```bash
|
||||||
|
xhs comments NOTE_ID
|
||||||
|
```
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
**Q: Docker 容器重启后 cookie 丢了?**
|
**Q: Cookie 过期了?**
|
||||||
A: 挂载数据卷持久化:
|
A: 重新运行 `xhs login` 或通过 Cookie-Editor 重新导出。
|
||||||
|
|
||||||
|
**Q: 小红书提示 IP 风险?**
|
||||||
|
A: 推荐使用住宅代理:`export HTTP_PROXY="http://user:pass@ip:port"`。
|
||||||
|
|
||||||
|
**Q: xhs-cli 不支持我的系统?**
|
||||||
|
A: 确保 Python 3.10+ 和 pipx 已安装。运行 `pipx install xiaohongshu-cli` 即可。
|
||||||
|
|
||||||
|
## 备选方案:Docker MCP
|
||||||
|
|
||||||
|
如果你已经在使用 [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) Docker 方案,它也能正常工作:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name xiaohongshu-mcp \
|
--name xiaohongshu-mcp \
|
||||||
-p 18060:18060 \
|
-p 18060:18060 \
|
||||||
-v xhs-data:/app/data \
|
|
||||||
xpzouying/xiaohongshu-mcp
|
xpzouying/xiaohongshu-mcp
|
||||||
|
|
||||||
|
mcporter config add xiaohongshu http://localhost:18060/mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
**Q: 服务器上小红书提示 IP 风险?**
|
xhs-cli 是当前推荐方案,不需要 Docker,安装更简单。
|
||||||
A: 加代理参数 `-e XHS_PROXY=http://user:pass@ip:port`,推荐住宅代理。
|
|
||||||
|
|
||||||
**Q: Docker 镜像不支持 ARM64 / Apple Silicon?**
|
|
||||||
A: 上游镜像暂无 ARM64 版本,两种解决办法:
|
|
||||||
|
|
||||||
方法一:使用 Rosetta 模拟运行(推荐,最简单)
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name xiaohongshu-mcp \
|
|
||||||
-p 18060:18060 \
|
|
||||||
--platform linux/amd64 \
|
|
||||||
xpzouying/xiaohongshu-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
方法二:从源码编译原生 ARM64 版本
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/xpzouying/xiaohongshu-mcp
|
|
||||||
cd xiaohongshu-mcp
|
|
||||||
docker build -t xiaohongshu-mcp .
|
|
||||||
docker run -d --name xiaohongshu-mcp -p 18060:18060 xiaohongshu-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
**Q: 我不想用 Docker?**
|
|
||||||
A: 可以从源码编译:https://github.com/xpzouying/xiaohongshu-mcp
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Agent Reach MCP Server — expose doctor/status as MCP tool.
|
|||||||
Run: python -m agent_reach.integrations.mcp_server
|
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 (xreach, yt-dlp, mcporter, etc.).
|
agents should call upstream tools directly (twitter-cli, yt-dlp, mcporter, etc.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Lightweight upstream command probing.
|
||||||
|
|
||||||
|
Distinguishes the three failure modes that look identical to shutil.which():
|
||||||
|
- missing: command not on PATH
|
||||||
|
- broken: command exists but cannot execute — most commonly a stale venv
|
||||||
|
shebang after a system Python upgrade (pipx/uv tool installs break this
|
||||||
|
way: which() finds the shim, but exec fails with FileNotFoundError
|
||||||
|
pointing at the shim itself)
|
||||||
|
- timeout/error: command runs but misbehaves
|
||||||
|
|
||||||
|
Channels use probe_command() inside check() so doctor reports real health,
|
||||||
|
not just file existence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
from agent_reach.utils.process import utf8_subprocess_env
|
||||||
|
|
||||||
|
#: Exit codes shells use for "found but not executable" / "not found".
|
||||||
|
_BROKEN_EXIT_CODES = (126, 127)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProbeResult:
|
||||||
|
status: str # "ok" | "missing" | "broken" | "timeout" | "error"
|
||||||
|
output: str = ""
|
||||||
|
hint: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def reinstall_hint(package: str) -> str:
|
||||||
|
"""Prescription for a broken (stale-venv) CLI install."""
|
||||||
|
return (
|
||||||
|
f"命令存在但无法执行——通常是系统 Python 升级后 venv 解释器丢失。重装即可修复:\n"
|
||||||
|
f" uv tool install --force {package}\n"
|
||||||
|
f"或:pipx reinstall {package}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_command(
|
||||||
|
cmd: str,
|
||||||
|
args: Sequence[str] = ("--version",),
|
||||||
|
timeout: int = 10,
|
||||||
|
retries: int = 0,
|
||||||
|
package: Optional[str] = None,
|
||||||
|
) -> ProbeResult:
|
||||||
|
"""Actually execute `cmd *args` and classify the result.
|
||||||
|
|
||||||
|
Intended for SIDE-EFFECT-FREE health probes only (version/status
|
||||||
|
commands): retries re-run the command verbatim with no backoff, so a
|
||||||
|
non-idempotent command would repeat its effect.
|
||||||
|
|
||||||
|
package: pip/pipx package name used in the broken-install hint
|
||||||
|
(defaults to cmd).
|
||||||
|
"""
|
||||||
|
path = shutil.which(cmd)
|
||||||
|
if not path:
|
||||||
|
return ProbeResult("missing")
|
||||||
|
|
||||||
|
last: Optional[ProbeResult] = None
|
||||||
|
for _ in range(retries + 1):
|
||||||
|
last = _run_once(path, args, timeout, package or cmd)
|
||||||
|
if last.ok:
|
||||||
|
return last
|
||||||
|
# missing/broken won't heal between retries — only transient
|
||||||
|
# failures (timeout/error) are worth a second attempt
|
||||||
|
if last.status in ("missing", "broken"):
|
||||||
|
return last
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def _run_once(path: str, args: Sequence[str], timeout: int, package: str) -> ProbeResult:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[path, *args],
|
||||||
|
capture_output=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=timeout,
|
||||||
|
env=utf8_subprocess_env(),
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# which() found it but exec failed: the shebang interpreter is gone
|
||||||
|
return ProbeResult("broken", hint=reinstall_hint(package))
|
||||||
|
except OSError:
|
||||||
|
return ProbeResult("broken", hint=reinstall_hint(package))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return ProbeResult("timeout", hint=f"`{path}` 响应超时(>{timeout}s)")
|
||||||
|
|
||||||
|
if r.returncode in _BROKEN_EXIT_CODES:
|
||||||
|
return ProbeResult("broken", hint=reinstall_hint(package))
|
||||||
|
|
||||||
|
output = (r.stdout or "") + (r.stderr or "")
|
||||||
|
if r.returncode != 0:
|
||||||
|
return ProbeResult("error", output=output.strip())
|
||||||
|
return ProbeResult("ok", output=output.strip())
|
||||||
Executable
+269
@@ -0,0 +1,269 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 小宇宙播客转文字脚本
|
||||||
|
# 用法: bash transcribe.sh [--polish] <小宇宙链接> [输出文件路径]
|
||||||
|
# 环境变量: GROQ_API_KEY (必须)
|
||||||
|
#
|
||||||
|
# --polish: 转录后调用 Groq Llama 3.3 70B 给文稿补中文标点+合理分段
|
||||||
|
# (Whisper 对中文标点支持较弱,开启后阅读体验显著更好)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
POLISH=0
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--polish) POLISH=1; shift ;;
|
||||||
|
--) shift; break ;;
|
||||||
|
-h|--help)
|
||||||
|
echo "用法: bash transcribe.sh [--polish] <小宇宙链接> [输出文件路径]"
|
||||||
|
exit 0 ;;
|
||||||
|
--*)
|
||||||
|
echo "未知选项: $1" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
URL="${1:?用法: bash transcribe.sh [--polish] <小宇宙链接> [输出文件路径]}"
|
||||||
|
OUTPUT="${2:-/tmp/podcast_transcript.txt}"
|
||||||
|
TMPDIR="/tmp/xiaoyuzhou_$$"
|
||||||
|
|
||||||
|
# Try env var first, then agent-reach config.yaml
|
||||||
|
if [ -z "$GROQ_API_KEY" ]; then
|
||||||
|
CONFIG_FILE="$HOME/.agent-reach/config.yaml"
|
||||||
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
|
GROQ_API_KEY=$(python3 -c "import yaml; print((yaml.safe_load(open('$CONFIG_FILE')) or {}).get('groq_api_key',''))" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
GROQ_API_KEY="${GROQ_API_KEY:?请设置 GROQ_API_KEY 环境变量或运行 agent-reach configure groq-key}"
|
||||||
|
|
||||||
|
# Groq API 限制: 25MB per file
|
||||||
|
MAX_CHUNK_SIZE_MB=20
|
||||||
|
AUDIO_BITRATE="64k"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
|
||||||
|
echo "📻 小宇宙播客转文字"
|
||||||
|
echo "===================="
|
||||||
|
|
||||||
|
# Step 1: 提取音频 URL 和标题
|
||||||
|
echo "🔍 正在解析页面..."
|
||||||
|
PAGE=$(curl -s "$URL")
|
||||||
|
AUDIO_URL=$(echo "$PAGE" | perl -ne 'while (/(https:\/\/media\.xyzcdn\.net\/[^"]*\.(?:m4a|mp3))/gi) { print "$1\n" }' | head -1)
|
||||||
|
TITLE=$(echo "$PAGE" | perl -ne 'if (/"title":"([^"]*)"/) { print "$1\n"; last }' | head -1)
|
||||||
|
|
||||||
|
if [ -z "$AUDIO_URL" ]; then
|
||||||
|
echo "❌ 无法从页面提取音频链接"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📝 标题: $TITLE"
|
||||||
|
echo "🔗 音频: $AUDIO_URL"
|
||||||
|
|
||||||
|
# Step 2: 下载音频
|
||||||
|
echo "⬇️ 正在下载音频..."
|
||||||
|
EXT="${AUDIO_URL##*.}"
|
||||||
|
curl -sL -o "$TMPDIR/original.$EXT" "$AUDIO_URL"
|
||||||
|
FILE_SIZE=$(ls -lh "$TMPDIR/original.$EXT" | awk '{print $5}')
|
||||||
|
echo "📦 文件大小: $FILE_SIZE"
|
||||||
|
|
||||||
|
# Step 3: 获取时长
|
||||||
|
DURATION=$(ffprobe -v quiet -show_entries format=duration -of csv=p=0 "$TMPDIR/original.$EXT" 2>/dev/null | cut -d. -f1)
|
||||||
|
DURATION_MIN=$((DURATION / 60))
|
||||||
|
DURATION_SEC=$((DURATION % 60))
|
||||||
|
echo "⏱️ 时长: ${DURATION_MIN}分${DURATION_SEC}秒"
|
||||||
|
|
||||||
|
# Step 4: 转为低码率单声道 MP3
|
||||||
|
echo "🔄 正在转码..."
|
||||||
|
ffmpeg -y -i "$TMPDIR/original.$EXT" -b:a "$AUDIO_BITRATE" -ac 1 "$TMPDIR/mono.mp3" 2>/dev/null
|
||||||
|
MONO_SIZE=$(stat -c%s "$TMPDIR/mono.mp3" 2>/dev/null || stat -f%z "$TMPDIR/mono.mp3")
|
||||||
|
echo "📦 转码后: $(echo "$MONO_SIZE / 1024 / 1024" | bc)MB"
|
||||||
|
|
||||||
|
# Step 5: 按大小切片
|
||||||
|
MAX_BYTES=$((MAX_CHUNK_SIZE_MB * 1024 * 1024))
|
||||||
|
|
||||||
|
if [ "$MONO_SIZE" -le "$MAX_BYTES" ]; then
|
||||||
|
# 不需要切片
|
||||||
|
cp "$TMPDIR/mono.mp3" "$TMPDIR/chunk_0.mp3"
|
||||||
|
NUM_CHUNKS=1
|
||||||
|
echo "📎 无需切片"
|
||||||
|
else
|
||||||
|
# 计算需要几个 chunk
|
||||||
|
NUM_CHUNKS=$(( (MONO_SIZE / MAX_BYTES) + 1 ))
|
||||||
|
CHUNK_DURATION=$(( DURATION / NUM_CHUNKS + 10 )) # 加 10 秒缓冲
|
||||||
|
echo "✂️ 切分为 $NUM_CHUNKS 段 (每段约 $((CHUNK_DURATION / 60)) 分钟)..."
|
||||||
|
|
||||||
|
for i in $(seq 0 $((NUM_CHUNKS - 1))); do
|
||||||
|
START=$((i * CHUNK_DURATION))
|
||||||
|
ffmpeg -y -i "$TMPDIR/mono.mp3" -ss "$START" -t "$CHUNK_DURATION" -c copy "$TMPDIR/chunk_${i}.mp3" 2>/dev/null
|
||||||
|
CHUNK_SIZE=$(ls -lh "$TMPDIR/chunk_${i}.mp3" | awk '{print $5}')
|
||||||
|
echo " 段 $((i+1))/$NUM_CHUNKS: $CHUNK_SIZE"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 6: 调用 Groq Whisper API 转录
|
||||||
|
echo "🎙️ 正在转录 (Groq Whisper large-v3)..."
|
||||||
|
|
||||||
|
for i in $(seq 0 $((NUM_CHUNKS - 1))); do
|
||||||
|
echo -n " 段 $((i+1))/$NUM_CHUNKS... "
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||||
|
https://api.groq.com/openai/v1/audio/transcriptions \
|
||||||
|
-H "Authorization: Bearer $GROQ_API_KEY" \
|
||||||
|
-F file="@$TMPDIR/chunk_${i}.mp3" \
|
||||||
|
-F model="whisper-large-v3" \
|
||||||
|
-F language="zh" \
|
||||||
|
-F prompt="以下是一段中文普通话播客录音,请输出包含完整中文标点(,。?!:;“”‘’)的转写文本。" \
|
||||||
|
-F response_format="text")
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "200" ]; then
|
||||||
|
echo "❌ API 错误 (HTTP $HTTP_CODE)"
|
||||||
|
echo "$BODY"
|
||||||
|
|
||||||
|
# 如果是速率限制,等待后重试
|
||||||
|
if [ "$HTTP_CODE" = "429" ]; then
|
||||||
|
# 从错误信息中提取等待时间,默认 120 秒
|
||||||
|
WAIT_SEC=$(echo "$BODY" | perl -ne 'if (/in (\d+)m/) { print "$1\n"; exit }')
|
||||||
|
WAIT_SEC=${WAIT_SEC:-2}
|
||||||
|
WAIT_SEC=$((WAIT_SEC * 60 + 30))
|
||||||
|
echo " ⏳ 速率限制,等待 ${WAIT_SEC} 秒后重试..."
|
||||||
|
sleep "$WAIT_SEC"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||||
|
https://api.groq.com/openai/v1/audio/transcriptions \
|
||||||
|
-H "Authorization: Bearer $GROQ_API_KEY" \
|
||||||
|
-F file="@$TMPDIR/chunk_${i}.mp3" \
|
||||||
|
-F model="whisper-large-v3" \
|
||||||
|
-F language="zh" \
|
||||||
|
-F prompt="以下是一段中文普通话播客录音,请输出包含完整中文标点(,。?!:;“”‘’)的转写文本。" \
|
||||||
|
-F response_format="text")
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "200" ]; then
|
||||||
|
echo " ❌ 重试失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$BODY" > "$TMPDIR/transcript_${i}.txt"
|
||||||
|
CHARS=$(wc -m < "$TMPDIR/transcript_${i}.txt")
|
||||||
|
echo "✅ ($CHARS 字)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Step 6.5 (可选): 用 Llama 3.3 70B 给文稿补标点+分段
|
||||||
|
if [ "$POLISH" = "1" ]; then
|
||||||
|
echo "✨ 正在润色(Llama 3.3 70B 加标点+分段)..."
|
||||||
|
for i in $(seq 0 $((NUM_CHUNKS - 1))); do
|
||||||
|
echo -n " 段 $((i+1))/$NUM_CHUNKS... "
|
||||||
|
IN_FILE="$TMPDIR/transcript_${i}.txt" \
|
||||||
|
OUT_FILE="$TMPDIR/polished_${i}.txt" \
|
||||||
|
GROQ_API_KEY="$GROQ_API_KEY" \
|
||||||
|
python3 <<'PY'
|
||||||
|
import json, os, sys, urllib.request, urllib.error
|
||||||
|
|
||||||
|
KEY = os.environ["GROQ_API_KEY"]
|
||||||
|
IN = os.environ["IN_FILE"]
|
||||||
|
OUT = os.environ["OUT_FILE"]
|
||||||
|
|
||||||
|
MODEL = "llama-3.3-70b-versatile"
|
||||||
|
MAX_DEPTH = 3
|
||||||
|
PROMPT_TMPL = (
|
||||||
|
"以下是一段中文普通话播客的语音转写片段,由于 Whisper 对中文标点支持较弱,"
|
||||||
|
"整段几乎没有标点。请你**只做一件事**:在合适位置补充中文标点(,。!?:;),"
|
||||||
|
"可以适度分段。\n\n"
|
||||||
|
"**严格要求**:\n"
|
||||||
|
"- 不得修改、删除、增加任何汉字或英文/数字\n"
|
||||||
|
"- 不得改写、润色、总结\n"
|
||||||
|
"- 不得添加任何解释、前言、后记\n"
|
||||||
|
"- 直接输出加好标点+合理分段后的全文\n\n"
|
||||||
|
"原文:\n{}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def call_groq(text):
|
||||||
|
body = json.dumps({
|
||||||
|
"model": MODEL,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_completion_tokens": 8192,
|
||||||
|
"messages": [{"role": "user", "content": PROMPT_TMPL.format(text)}],
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.groq.com/openai/v1/chat/completions",
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "agent-reach-xiaoyuzhou/1.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=180) as r:
|
||||||
|
resp = json.load(r)
|
||||||
|
return (
|
||||||
|
resp["choices"][0]["message"]["content"].strip(),
|
||||||
|
resp["choices"][0].get("finish_reason"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def polish(text, depth=0):
|
||||||
|
try:
|
||||||
|
out, fr = call_groq(text)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.stderr.write(f"polish HTTP {e.code}: {e.read().decode(errors='replace')[:200]}\n")
|
||||||
|
return text # fallback to raw
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"polish error: {e}\n")
|
||||||
|
return text
|
||||||
|
if fr != "length" or depth >= MAX_DEPTH:
|
||||||
|
return out
|
||||||
|
# 输出被截断:从中点切两半递归处理
|
||||||
|
mid = len(text) // 2
|
||||||
|
return polish(text[:mid], depth + 1) + polish(text[mid:], depth + 1)
|
||||||
|
|
||||||
|
content = open(IN, encoding="utf-8").read().strip()
|
||||||
|
result = polish(content)
|
||||||
|
open(OUT, "w", encoding="utf-8").write(result + "\n")
|
||||||
|
print(f"✅ ({len(result)} 字)")
|
||||||
|
PY
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 7: 合并输出
|
||||||
|
echo "📄 正在合并文字稿..."
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# $TITLE"
|
||||||
|
echo ""
|
||||||
|
echo "来源: $URL"
|
||||||
|
echo "时长: ${DURATION_MIN}分${DURATION_SEC}秒"
|
||||||
|
echo "转录时间: $(date '+%Y-%m-%d %H:%M')"
|
||||||
|
if [ "$POLISH" = "1" ]; then
|
||||||
|
echo "润色: Groq Llama 3.3 70B"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for i in $(seq 0 $((NUM_CHUNKS - 1))); do
|
||||||
|
if [ "$POLISH" = "1" ] && [ -f "$TMPDIR/polished_${i}.txt" ]; then
|
||||||
|
cat "$TMPDIR/polished_${i}.txt"
|
||||||
|
else
|
||||||
|
cat "$TMPDIR/transcript_${i}.txt"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
} > "$OUTPUT"
|
||||||
|
|
||||||
|
TOTAL_CHARS=$(wc -m < "$OUTPUT")
|
||||||
|
echo ""
|
||||||
|
echo "✅ 完成!"
|
||||||
|
echo "📄 输出: $OUTPUT"
|
||||||
|
echo "📊 总字数: $TOTAL_CHARS"
|
||||||
|
echo "===================="
|
||||||
+101
-281
@@ -1,312 +1,132 @@
|
|||||||
---
|
---
|
||||||
name: agent-reach
|
name: agent-reach
|
||||||
description: >
|
description: >
|
||||||
Give your AI agent eyes to see the entire internet. Install and configure
|
MUST USE when user wants to 调研/research/搜索/search/查/找/look up anything
|
||||||
upstream tools for Twitter/X, Reddit, YouTube, GitHub, Bilibili, XiaoHongShu,
|
on the internet — e.g. 全网调研 X / 帮我调研一下 X / 查一下 X / 搜搜 X /
|
||||||
Douyin, LinkedIn, Boss直聘, WeChat (微信公众号), RSS, and any web page — then call them directly.
|
看看大家怎么评价 X / X 上有什么讨论 / research this topic。
|
||||||
Use when: (1) setting up platform access tools for the first time,
|
|
||||||
(2) checking which platforms are available,
|
Also MUST USE when user mentions any platform or shares any URL/链接:
|
||||||
(3) user asks to configure/enable a platform channel.
|
小红书/xiaohongshu/xhs, Twitter/推特/X, B站/bilibili, Reddit, V2EX,
|
||||||
Triggers: "帮我配", "帮我添加", "帮我安装", "agent reach", "install channels",
|
LinkedIn/领英/招聘/求职/jobs, YouTube, GitHub code search, 小宇宙播客,
|
||||||
"configure twitter", "enable reddit".
|
雪球/股票行情, RSS feeds, or any web URL.
|
||||||
|
|
||||||
|
13 platforms, multi-backend routing (OpenCLI / per-platform CLIs / APIs).
|
||||||
|
Zero config for 6 channels. Run `agent-reach doctor --json` to see which
|
||||||
|
backend serves each platform right now.
|
||||||
|
|
||||||
|
NOT for: 写报告/数据分析/翻译等内容加工(本 skill 只负责从互联网获取内容);
|
||||||
|
发帖/评论/点赞等写操作;已有专门 skill 的平台(先用专门 skill)。
|
||||||
|
|
||||||
|
【路由方式】SKILL.md 包含路由表和常用命令,复杂场景需按需阅读对应分类的 references/*.md。
|
||||||
|
分类:search / social (小红书/推特/B站/V2EX/Reddit) / career(LinkedIn) / dev(github) / web(网页/文章/RSS) / video(YouTube/B站/播客)。
|
||||||
|
triggers:
|
||||||
|
- research: 调研/全网调研/帮我调研/研究一下/research/深入了解
|
||||||
|
- search: 搜/查/找/search/搜索/查一下/帮我搜/看看大家怎么说
|
||||||
|
- social:
|
||||||
|
- 小红书: xiaohongshu/xhs/小红书/红书
|
||||||
|
- Twitter: twitter/推特/x.com/推文
|
||||||
|
- B站: bilibili/b站/哔哩哔哩
|
||||||
|
- V2EX: v2ex
|
||||||
|
- Reddit: reddit
|
||||||
|
- career: 招聘/职位/求职/linkedin/领英/找工作
|
||||||
|
- dev: github/代码/仓库/gh/issue/pr/分支/commit
|
||||||
|
- web: 网页/链接/文章/rss/读一下/打开这个
|
||||||
|
- video: youtube/视频/播客/字幕/小宇宙/转录/yt
|
||||||
|
- finance: 雪球/股票/stock/xueqiu/行情/基金
|
||||||
|
metadata:
|
||||||
|
openclaw:
|
||||||
|
homepage: https://github.com/Panniantong/Agent-Reach
|
||||||
---
|
---
|
||||||
|
|
||||||
# Agent Reach
|
# Agent Reach — 互联网能力路由器
|
||||||
|
|
||||||
Install and configure upstream tools for 12+ platforms. After setup, call them directly — no wrapper layer.
|
13 平台、多后端。**本 skill 存在时必须用它访问这些平台,不要自己发明方案。**
|
||||||
|
|
||||||
## Setup
|
## 常驻规则(全程适用)
|
||||||
|
|
||||||
|
1. **动手前先体检**:多后端平台(小红书/Reddit/B站/Twitter)先跑
|
||||||
|
`agent-reach doctor --json`,按各平台 `active_backend` 字段选命令组。
|
||||||
|
2. **声明你在用什么**:开始干活前说一句「使用 agent-reach 的 X 平台 / Y 后端」。
|
||||||
|
3. **失败按 references 里的重试链处理**,不要瞎猜命令。
|
||||||
|
4. **全网调研类任务**:组合多平台(Exa 搜索 + Twitter/Reddit 看讨论 + 小红书/B站看中文场景),并行收集再汇总。
|
||||||
|
5. **替用户盯版本**:完成一次较大的调研/多平台任务后,顺手跑
|
||||||
|
`agent-reach check-update`(很快,一个 API 调用)。有新版就在收尾汇报里附一句:
|
||||||
|
「Agent Reach 有新版 vX.Y.Z,复制这句话给我即可更新:帮我更新 Agent Reach:
|
||||||
|
https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md」。
|
||||||
|
不要中断当前任务去更新,也不要重复提醒同一个版本。
|
||||||
|
|
||||||
|
## 路由表
|
||||||
|
|
||||||
|
| 用户意图 | 分类 | 详细文档 |
|
||||||
|
|---------|------|---------|
|
||||||
|
| 网页搜索/代码搜索 | search | [references/search.md](references/search.md) |
|
||||||
|
| 小红书/推特/B站/V2EX/Reddit | social | [references/social.md](references/social.md) |
|
||||||
|
| 招聘/职位/LinkedIn | career | [references/career.md](references/career.md) |
|
||||||
|
| GitHub/代码 | dev | [references/dev.md](references/dev.md) |
|
||||||
|
| 网页/文章/RSS | web | [references/web.md](references/web.md) |
|
||||||
|
| YouTube/B站/播客字幕 | video | [references/video.md](references/video.md) |
|
||||||
|
|
||||||
|
## 零配置快速命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install https://github.com/Panniantong/agent-reach/archive/main.zip
|
# Exa 网页搜索
|
||||||
agent-reach install --env=auto
|
|
||||||
agent-reach doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
`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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
agent-reach doctor # channel status overview
|
|
||||||
agent-reach watch # quick health + update check
|
|
||||||
agent-reach check-update # check for new versions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configure channels
|
|
||||||
|
|
||||||
```bash
|
|
||||||
agent-reach configure twitter-cookies "auth_token=xxx; ct0=yyy"
|
|
||||||
agent-reach configure proxy http://user:pass@ip:port
|
|
||||||
agent-reach configure --from-browser chrome # auto-extract cookies from local browser
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuring a channel ("帮我配 XXX")
|
|
||||||
|
|
||||||
When a user asks to configure/enable any channel:
|
|
||||||
|
|
||||||
1. Run `agent-reach doctor`
|
|
||||||
2. Find the channel — it shows status (✅/⚠️/⬜) and **what to do next**
|
|
||||||
3. Execute what you can automatically (install packages, start services)
|
|
||||||
4. For human-required steps (paste cookies), tell the user what to do
|
|
||||||
5. Run `agent-reach doctor` again to verify
|
|
||||||
|
|
||||||
**Do NOT memorize per-channel steps.** Always rely on `doctor` output.
|
|
||||||
|
|
||||||
### Cookie 导入(所有需要登录的平台通用)
|
|
||||||
|
|
||||||
> ⚠️ **重要提醒:** 使用 Cookie 登录的平台存在封号风险,请提醒用户务必使用**专用小号**。
|
|
||||||
|
|
||||||
所有需要 Cookie 的平台(Twitter、小红书等),**优先使用 Cookie-Editor 导入**:
|
|
||||||
|
|
||||||
1. 用户在自己的浏览器登录对应平台
|
|
||||||
2. 安装 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) Chrome 插件
|
|
||||||
3. 点击插件 → Export → Header String
|
|
||||||
4. 把导出的字符串发给 Agent
|
|
||||||
|
|
||||||
本地电脑用户也可以用 `agent-reach configure --from-browser chrome` 一键自动提取。
|
|
||||||
|
|
||||||
扫码登录是备选方案(仅限本地有浏览器的情况),Cookie-Editor 更简单可靠。
|
|
||||||
|
|
||||||
### Other human actions
|
|
||||||
|
|
||||||
- **Proxy:** Reddit/Bilibili/XiaoHongShu may block server IPs — suggest a residential proxy if on a server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Using Upstream Tools Directly
|
|
||||||
|
|
||||||
After `agent-reach install`, call the upstream tools directly.
|
|
||||||
|
|
||||||
> **Note:** `agent-reach` is an installer and config tool — it does NOT have `read`, `search`, or content-fetching commands. Use the upstream tools below instead.
|
|
||||||
|
|
||||||
### Twitter/X (xreach CLI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Search tweets
|
|
||||||
xreach search "query" --json -n 10
|
|
||||||
|
|
||||||
# Read a specific tweet
|
|
||||||
xreach tweet https://x.com/user/status/123 --json
|
|
||||||
|
|
||||||
# Read a user's timeline
|
|
||||||
xreach tweets @username --json -n 20
|
|
||||||
```
|
|
||||||
|
|
||||||
### YouTube (yt-dlp)
|
|
||||||
|
|
||||||
> ⚠️ yt-dlp 需要 JS runtime 才能下载 YouTube。`agent-reach install` 会自动配置 Node.js 作为 runtime。
|
|
||||||
> 如果遇到 "Sign in to confirm you're not a bot",是 IP 被 YouTube 反爬,换代理或加 cookies。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get video metadata
|
|
||||||
yt-dlp --dump-json "https://www.youtube.com/watch?v=xxx"
|
|
||||||
|
|
||||||
# Download subtitles only
|
|
||||||
yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --skip-download -o "/tmp/%(id)s" "URL"
|
|
||||||
# Then read the .vtt file
|
|
||||||
|
|
||||||
# Search (yt-dlp ytsearch)
|
|
||||||
yt-dlp --dump-json "ytsearch5:query"
|
|
||||||
|
|
||||||
# If "no JS runtime" warning: ensure Node.js is installed, then run:
|
|
||||||
# mkdir -p ~/.config/yt-dlp && echo "--js-runtimes node" >> ~/.config/yt-dlp/config
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bilibili (yt-dlp)
|
|
||||||
|
|
||||||
> ⚠️ 服务器 IP 可能被 Bilibili 拦截(412 错误)。建议通过代理访问,或加 `--cookies-from-browser chrome`。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get video metadata
|
|
||||||
yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx"
|
|
||||||
|
|
||||||
# Download subtitles
|
|
||||||
yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --convert-subs vtt --skip-download -o "/tmp/%(id)s" "URL"
|
|
||||||
|
|
||||||
# If blocked (412 / login required):
|
|
||||||
yt-dlp --cookies-from-browser chrome --dump-json "URL"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reddit (JSON API)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Read a subreddit
|
|
||||||
curl -s "https://www.reddit.com/r/python/hot.json?limit=10" -H "User-Agent: agent-reach/1.0"
|
|
||||||
|
|
||||||
# Read a post with comments
|
|
||||||
curl -s "https://www.reddit.com/r/python/comments/POST_ID.json" -H "User-Agent: agent-reach/1.0"
|
|
||||||
|
|
||||||
# Search
|
|
||||||
curl -s "https://www.reddit.com/search.json?q=query&limit=10" -H "User-Agent: agent-reach/1.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: On servers, Reddit may block your IP. Use proxy or search via Exa instead.
|
|
||||||
|
|
||||||
### 小红书 / XiaoHongShu (mcporter + xiaohongshu-mcp)
|
|
||||||
|
|
||||||
> ⚠️ 需要登录。使用 Cookie-Editor 导入 cookies 或扫码登录。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 搜索笔记
|
|
||||||
mcporter call 'xiaohongshu.search_feeds(keyword: "query")'
|
|
||||||
|
|
||||||
# 获取笔记详情(含评论)
|
|
||||||
mcporter call 'xiaohongshu.get_feed_detail(feed_id: "xxx", xsec_token: "yyy")'
|
|
||||||
|
|
||||||
# 获取全部评论
|
|
||||||
mcporter call 'xiaohongshu.get_feed_detail(feed_id: "xxx", xsec_token: "yyy", load_all_comments: true)'
|
|
||||||
|
|
||||||
# 发布图文笔记
|
|
||||||
mcporter call 'xiaohongshu.publish_content(title: "标题", content: "正文", images: ["/path/to/img.jpg"], tags: ["美食"])'
|
|
||||||
|
|
||||||
# 发布视频笔记
|
|
||||||
mcporter call 'xiaohongshu.publish_with_video(title: "标题", content: "正文", video: "/path/to/video.mp4", tags: ["vlog"])'
|
|
||||||
```
|
|
||||||
|
|
||||||
其他功能(点赞、收藏、评论、用户主页等):`npx mcporter list xiaohongshu`
|
|
||||||
|
|
||||||
### 抖音 / Douyin (mcporter + douyin-mcp-server)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 解析抖音视频信息(分享链接 → 标题、作者、无水印视频URL等)
|
|
||||||
mcporter call 'douyin.parse_douyin_video_info(share_link: "https://v.douyin.com/xxx/")'
|
|
||||||
|
|
||||||
# 获取无水印视频下载链接
|
|
||||||
mcporter call 'douyin.get_douyin_download_link(share_link: "https://v.douyin.com/xxx/")'
|
|
||||||
|
|
||||||
# AI 提取视频语音文案(需要配置硅基流动 API Key)
|
|
||||||
mcporter call 'douyin.extract_douyin_text(share_link: "https://v.douyin.com/xxx/")'
|
|
||||||
```
|
|
||||||
|
|
||||||
> 无需登录即可解析视频。支持抖音分享链接和直接链接。
|
|
||||||
|
|
||||||
### GitHub (gh CLI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Search repos
|
|
||||||
gh search repos "query" --sort stars --limit 10
|
|
||||||
|
|
||||||
# View a repo
|
|
||||||
gh repo view owner/repo
|
|
||||||
|
|
||||||
# Search code
|
|
||||||
gh search code "query" --language python
|
|
||||||
|
|
||||||
# List issues
|
|
||||||
gh issue list -R owner/repo --state open
|
|
||||||
|
|
||||||
# View a specific issue/PR
|
|
||||||
gh issue view 123 -R owner/repo
|
|
||||||
```
|
|
||||||
|
|
||||||
### Web — Any URL (Jina Reader)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Read any webpage as markdown
|
|
||||||
curl -s "https://r.jina.ai/URL" -H "Accept: text/markdown"
|
|
||||||
|
|
||||||
# Search the web
|
|
||||||
curl -s "https://s.jina.ai/query" -H "Accept: text/markdown"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Exa Search (mcporter + exa MCP)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Web search
|
|
||||||
mcporter call 'exa.web_search_exa(query: "query", numResults: 5)'
|
mcporter call 'exa.web_search_exa(query: "query", numResults: 5)'
|
||||||
|
|
||||||
# Code search (GitHub, StackOverflow, docs)
|
# 通用网页阅读
|
||||||
mcporter call 'exa.get_code_context_exa(query: "how to parse JSON in Python", tokensNum: 3000)'
|
curl -s "https://r.jina.ai/URL"
|
||||||
|
|
||||||
# Company research
|
# GitHub 搜索
|
||||||
mcporter call 'exa.company_research_exa(companyName: "OpenAI")'
|
gh search repos "query" --sort stars --limit 10
|
||||||
|
|
||||||
|
# YouTube 字幕(注意:B站不要用 yt-dlp,见 video.md)
|
||||||
|
yt-dlp --write-sub --skip-download -o "/tmp/%(id)s" "URL"
|
||||||
|
|
||||||
|
# V2EX 热门
|
||||||
|
curl -s "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: agent-reach/1.0"
|
||||||
|
|
||||||
|
# B站搜索(bili-cli,无需登录)
|
||||||
|
bili search "query" --type video -n 5
|
||||||
```
|
```
|
||||||
|
|
||||||
### LinkedIn (mcporter + linkedin-scraper-mcp)
|
## 需登录态的平台(按 doctor 的 active_backend 选命令)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View a profile
|
# Twitter 搜索(twitter-cli 首选;失败重试链见 social.md)
|
||||||
mcporter call 'linkedin.get_person_profile(linkedin_url: "https://linkedin.com/in/username")'
|
twitter search "query" -n 10
|
||||||
|
|
||||||
# Search people
|
# Reddit(无零配置路径:OpenCLI 或 rdt-cli,必须登录态)
|
||||||
mcporter call 'linkedin.search_people(keyword: "AI engineer", limit: 10)'
|
opencli reddit search "query" -f yaml # 桌面
|
||||||
|
rdt search "query" --limit 10 # 存量/服务器
|
||||||
|
|
||||||
# View company
|
# 小红书(桌面首选 OpenCLI)
|
||||||
mcporter call 'linkedin.get_company_profile(linkedin_url: "https://linkedin.com/company/xxx")'
|
opencli xiaohongshu search "query" -f yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Fallback: `curl -s "https://r.jina.ai/https://linkedin.com/in/username"`
|
## 环境检查
|
||||||
|
|
||||||
### Boss直聘 (mcporter + mcp-bosszp)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Browse recommended jobs
|
# 检查可用 channel 与每个平台当前激活的后端
|
||||||
mcporter call 'bosszhipin.get_recommend_jobs_tool(page: 1)'
|
agent-reach doctor --json
|
||||||
|
|
||||||
# Search jobs
|
|
||||||
mcporter call 'bosszhipin.search_jobs_tool(keyword: "Python", city: "北京", page: 1)'
|
|
||||||
|
|
||||||
# View job details
|
|
||||||
mcporter call 'bosszhipin.get_job_detail_tool(job_url: "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)
|
**不要在 agent workspace 创建文件。** 使用 `/tmp/` 存放临时输出,`~/.agent-reach/` 存放持久数据。
|
||||||
|
|
||||||
**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():
|
- [搜索工具](references/search.md) — Exa AI 搜索
|
||||||
articles = await get_wexin_article('AI Agent', 5)
|
- [社交媒体](references/social.md) — 小红书, Twitter, B站, V2EX, Reddit(多后端命令组)
|
||||||
for a in articles:
|
- [职场招聘](references/career.md) — LinkedIn
|
||||||
print(f'{a[\"title\"]} | {a[\"source\"]} | {a[\"date\"]}')
|
- [开发工具](references/dev.md) — GitHub CLI
|
||||||
print(f' {a[\"url\"]}')
|
- [网页阅读](references/web.md) — Jina Reader, RSS
|
||||||
|
- [视频播客](references/video.md) — YouTube, B站, 小宇宙
|
||||||
|
|
||||||
asyncio.run(search())
|
## 配置渠道
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Read** (Camoufox — stealth Firefox, bypasses WeChat anti-bot):
|
如果某个 channel 需要配置,获取安装指南:
|
||||||
|
https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
|
||||||
|
|
||||||
```bash
|
用户只需提供 cookies,其他配置由 agent 完成。
|
||||||
# Read a WeChat article (returns Markdown with images)
|
|
||||||
cd /path/to/wechat-article-for-ai && python3 main.py "https://mp.weixin.qq.com/s/ARTICLE_ID"
|
|
||||||
|
|
||||||
# Run as MCP server (for AI agent integration)
|
|
||||||
python3 mcp_server.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Typical agent workflow: search → get URLs → immediately read full content.
|
|
||||||
|
|
||||||
Note: WeChat articles require a real browser to render. Jina Reader and curl cannot read them.
|
|
||||||
|
|
||||||
### RSS (feedparser)
|
|
||||||
|
|
||||||
```python
|
|
||||||
python3 -c "
|
|
||||||
import feedparser
|
|
||||||
d = feedparser.parse('https://example.com/feed')
|
|
||||||
for e in d.entries[:5]:
|
|
||||||
print(f'{e.title} — {e.link}')
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Twitter "fetch failed"
|
|
||||||
|
|
||||||
xreach CLI uses Node.js `undici`, which doesn't respect `HTTP_PROXY`. Solutions:
|
|
||||||
1. Ensure `undici` is installed: `npm install -g undici`
|
|
||||||
2. Configure proxy: `agent-reach configure proxy http://user:pass@ip:port`
|
|
||||||
3. If still failing, use transparent proxy (Clash TUN, Proxifier)
|
|
||||||
|
|
||||||
### Channel broken?
|
|
||||||
|
|
||||||
Run `agent-reach doctor` — it shows what's wrong and how to fix it.
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
name: agent-reach
|
||||||
|
description: >
|
||||||
|
MUST USE when user wants to research/search/look up/find anything on the
|
||||||
|
internet — e.g. "research this topic", "do a deep dive on X", "search the
|
||||||
|
web for X", "see what people say about X", "look this up".
|
||||||
|
|
||||||
|
Also MUST USE when user mentions any platform or shares any URL/link:
|
||||||
|
Twitter/X, Reddit, YouTube, GitHub, Bilibili, XiaoHongShu,
|
||||||
|
Xiaoyuzhou Podcast, LinkedIn/jobs/recruiting, V2EX, Xueqiu (stocks), RSS.
|
||||||
|
|
||||||
|
13 platforms, multi-backend routing (OpenCLI / per-platform CLIs / APIs).
|
||||||
|
Zero config for 6 channels. Run `agent-reach doctor --json` to see which
|
||||||
|
backend serves each platform right now.
|
||||||
|
|
||||||
|
NOT for: writing reports/analysis/translation (this skill only FETCHES
|
||||||
|
internet content); posting/commenting/liking (write operations); platforms
|
||||||
|
that already have a dedicated skill installed (prefer that skill).
|
||||||
|
metadata:
|
||||||
|
openclaw:
|
||||||
|
homepage: https://github.com/Panniantong/Agent-Reach
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Reach — internet capability router
|
||||||
|
|
||||||
|
13 platforms, multiple backends each. **When this skill exists, use it for
|
||||||
|
these platforms — do not invent your own approach.**
|
||||||
|
|
||||||
|
## Standing rules (apply for the whole session)
|
||||||
|
|
||||||
|
1. **Health-check before acting**: for multi-backend platforms (XiaoHongShu /
|
||||||
|
Reddit / Bilibili / Twitter), run `agent-reach doctor --json` first and
|
||||||
|
pick the command group matching each platform's `active_backend`.
|
||||||
|
2. **Announce what you use**: say "using agent-reach, platform X via backend Y"
|
||||||
|
before starting.
|
||||||
|
3. **On failure, follow the retry chains in references/** — never guess
|
||||||
|
commands.
|
||||||
|
4. **For broad research tasks**: combine platforms (Exa for web search +
|
||||||
|
Twitter/Reddit for discussions + XiaoHongShu/Bilibili for Chinese
|
||||||
|
perspectives), collect in parallel, then synthesize.
|
||||||
|
5. **Watch versions for the user**: after finishing a substantial
|
||||||
|
multi-platform task, run `agent-reach check-update` (fast, one API call).
|
||||||
|
If a new version exists, append one line to your wrap-up: "Agent Reach
|
||||||
|
vX.Y.Z is available — paste this to me to update: 帮我更新 Agent Reach:
|
||||||
|
https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md".
|
||||||
|
Never interrupt the current task to update; never nag about the same version twice.
|
||||||
|
|
||||||
|
## Routing table
|
||||||
|
|
||||||
|
| User intent | Category | Details |
|
||||||
|
|---------|------|---------|
|
||||||
|
| Web / code search | search | [references/search.md](references/search.md) |
|
||||||
|
| XiaoHongShu / Twitter / Bilibili / V2EX / Reddit | social | [references/social.md](references/social.md) |
|
||||||
|
| Jobs / LinkedIn | career | [references/career.md](references/career.md) |
|
||||||
|
| GitHub / code | dev | [references/dev.md](references/dev.md) |
|
||||||
|
| Web pages / articles / RSS | web | [references/web.md](references/web.md) |
|
||||||
|
| YouTube / Bilibili / podcast transcripts | video | [references/video.md](references/video.md) |
|
||||||
|
|
||||||
|
## Zero-config quick commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exa web search
|
||||||
|
mcporter call 'exa.web_search_exa(query: "query", numResults: 5)'
|
||||||
|
|
||||||
|
# Read any web page
|
||||||
|
curl -s "https://r.jina.ai/URL"
|
||||||
|
|
||||||
|
# GitHub search
|
||||||
|
gh search repos "query" --sort stars --limit 10
|
||||||
|
|
||||||
|
# YouTube subtitles (NOTE: never use yt-dlp for Bilibili — see video.md)
|
||||||
|
yt-dlp --write-sub --skip-download -o "/tmp/%(id)s" "URL"
|
||||||
|
|
||||||
|
# V2EX hot topics
|
||||||
|
curl -s "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: agent-reach/1.0"
|
||||||
|
|
||||||
|
# Bilibili search (bili-cli, no login needed)
|
||||||
|
bili search "query" --type video -n 5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Login-backed platforms (pick by doctor's active_backend)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Twitter search (twitter-cli preferred; retry chain in social.md)
|
||||||
|
twitter search "query" -n 10
|
||||||
|
|
||||||
|
# Reddit (NO zero-config path — OpenCLI or rdt-cli, login required)
|
||||||
|
opencli reddit search "query" -f yaml # desktop
|
||||||
|
rdt search "query" --limit 10 # legacy/server
|
||||||
|
|
||||||
|
# XiaoHongShu (desktop prefers OpenCLI)
|
||||||
|
opencli xiaohongshu search "query" -f yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Channel availability + which backend serves each platform
|
||||||
|
agent-reach doctor --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workspace rules
|
||||||
|
|
||||||
|
**Never create files in the agent workspace.** Use `/tmp/` for temporary
|
||||||
|
output and `~/.agent-reach/` for persistent data.
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
Read the matching file when you need specifics (commands above cover the
|
||||||
|
common cases; references hold per-backend command groups, caveats, retry
|
||||||
|
chains — note: reference docs are written in Chinese, commands are universal):
|
||||||
|
|
||||||
|
- [Search](references/search.md) — Exa AI search
|
||||||
|
- [Social](references/social.md) — XiaoHongShu, Twitter, Bilibili, V2EX, Reddit (multi-backend groups)
|
||||||
|
- [Career](references/career.md) — LinkedIn
|
||||||
|
- [Dev](references/dev.md) — GitHub CLI
|
||||||
|
- [Web](references/web.md) — Jina Reader, RSS
|
||||||
|
- [Video](references/video.md) — YouTube, Bilibili, Xiaoyuzhou
|
||||||
|
|
||||||
|
## Configure a channel
|
||||||
|
|
||||||
|
If a channel needs setup, fetch the install guide:
|
||||||
|
https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
|
||||||
|
|
||||||
|
The user only provides cookies / one extension click; the agent does the rest.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 职场招聘
|
||||||
|
|
||||||
|
LinkedIn。
|
||||||
|
|
||||||
|
## LinkedIn
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 获取个人资料
|
||||||
|
mcporter call 'linkedin-scraper.get_person_profile(linkedin_url: "https://linkedin.com/in/username")'
|
||||||
|
|
||||||
|
# 搜索人才
|
||||||
|
mcporter call 'linkedin-scraper.search_people(keyword: "AI engineer", limit: 10)'
|
||||||
|
|
||||||
|
# 获取公司资料
|
||||||
|
mcporter call 'linkedin-scraper.get_company_profile(linkedin_url: "https://linkedin.com/company/xxx")'
|
||||||
|
|
||||||
|
# 搜索职位
|
||||||
|
mcporter call 'linkedin-scraper.search_jobs(keyword: "software engineer", limit: 10)'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **需要登录**: LinkedIn scraper 需要有效的登录态。
|
||||||
|
|
||||||
|
### Fallback 方案
|
||||||
|
|
||||||
|
如果 MCP 不可用,可以用 Jina Reader:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://r.jina.ai/https://linkedin.com/in/username"
|
||||||
|
```
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 开发工具
|
||||||
|
|
||||||
|
GitHub CLI
|
||||||
|
|
||||||
|
## GitHub (gh CLI)
|
||||||
|
|
||||||
|
GitHub 官方命令行工具,用于仓库、Issue、PR、Actions、Release 以及 API 访问。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 认证
|
||||||
|
gh auth login
|
||||||
|
gh auth status
|
||||||
|
|
||||||
|
# 搜索
|
||||||
|
gh search repos "query" --sort stars --limit 10
|
||||||
|
gh search code "query" --language python
|
||||||
|
|
||||||
|
# 仓库
|
||||||
|
gh repo view owner/repo
|
||||||
|
gh repo clone owner/repo
|
||||||
|
gh repo create my-repo --private
|
||||||
|
gh repo fork owner/repo
|
||||||
|
gh repo fork owner/repo --clone
|
||||||
|
gh repo sync owner/repo
|
||||||
|
|
||||||
|
# Issues
|
||||||
|
gh issue list -R owner/repo --state open
|
||||||
|
gh issue view 123 -R owner/repo
|
||||||
|
gh issue create -R owner/repo --title "Title" --body "Body"
|
||||||
|
|
||||||
|
# Pull Requests
|
||||||
|
gh pr list -R owner/repo --state open
|
||||||
|
gh pr view 123 -R owner/repo
|
||||||
|
gh pr create -R owner/repo --title "Title" --body "Body"
|
||||||
|
gh pr checks 123 --repo owner/repo
|
||||||
|
|
||||||
|
# Actions / CI
|
||||||
|
gh run list --repo owner/repo --limit 10
|
||||||
|
gh run view <run-id> --repo owner/repo
|
||||||
|
gh run view <run-id> --repo owner/repo --log-failed
|
||||||
|
gh workflow list --repo owner/repo
|
||||||
|
|
||||||
|
# Releases
|
||||||
|
gh release list -R owner/repo
|
||||||
|
gh release create v1.0.0
|
||||||
|
|
||||||
|
# API
|
||||||
|
gh api /user
|
||||||
|
gh api repos/owner/repo
|
||||||
|
|
||||||
|
# JSON 输出
|
||||||
|
gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 选择指南
|
||||||
|
|
||||||
|
| 工具 | 来源 | 用途 |
|
||||||
|
|-----|------|------|
|
||||||
|
| gh CLI | agent-reach | Git 操作 |
|
||||||
|
| zread | my-mcp-tools | 读仓库内容 |
|
||||||
|
| context7 | my-mcp-tools | 查技术文档 |
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 搜索工具
|
||||||
|
|
||||||
|
Exa AI 搜索引擎。
|
||||||
|
|
||||||
|
## Exa AI 搜索
|
||||||
|
|
||||||
|
高质量 AI 搜索引擎,擅长技术和代码搜索。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mcporter call 'exa.web_search_exa(query: "query", numResults: 5)'
|
||||||
|
mcporter call 'exa.get_code_context_exa(query: "code question", tokensNum: 3000)'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
| 场景 | 参数 |
|
||||||
|
|-----|------|
|
||||||
|
| 网页搜索 | `web_search_exa(query: "...", numResults: 5)` |
|
||||||
|
| 代码搜索 | `get_code_context_exa(query: "...", tokensNum: 3000)` |
|
||||||
|
|
||||||
|
### 特点
|
||||||
|
|
||||||
|
- 擅长英文内容和技术文档
|
||||||
|
- 支持代码上下文搜索
|
||||||
|
- 结果质量高
|
||||||
|
|
||||||
|
## 与其他搜索工具对比
|
||||||
|
|
||||||
|
| 工具 | 来源 | 适用场景 |
|
||||||
|
|-----|------|---------|
|
||||||
|
| Exa | agent-reach | 英文/技术/代码搜索 |
|
||||||
|
| 智谱搜索 | my-mcp-tools | 中文搜索 |
|
||||||
|
| GitHub 搜索 | agent-reach (dev.md) | 仓库/代码搜索 |
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# 社交媒体 & 社区
|
||||||
|
|
||||||
|
小红书、Twitter/X、B站、V2EX、Reddit。
|
||||||
|
|
||||||
|
## 小红书 / XiaoHongShu(多后端)
|
||||||
|
|
||||||
|
小红书有三个后端,**先跑 `agent-reach doctor --json` 看 xiaohongshu 的 `active_backend` 是哪个**,再用对应命令组。
|
||||||
|
|
||||||
|
### 后端 A:OpenCLI(桌面首选,复用浏览器登录态)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 搜索笔记
|
||||||
|
opencli xiaohongshu search "query" -f yaml
|
||||||
|
|
||||||
|
# 读笔记正文+互动数据(用搜索结果里的完整 URL,含 xsec_token)
|
||||||
|
opencli xiaohongshu note "NOTE_URL" -f yaml
|
||||||
|
|
||||||
|
# 评论(支持楼中楼)
|
||||||
|
opencli xiaohongshu comments NOTE_ID -f yaml
|
||||||
|
|
||||||
|
# 首页推荐 feed
|
||||||
|
opencli xiaohongshu feed -f yaml
|
||||||
|
|
||||||
|
# 用户主页公开笔记
|
||||||
|
opencli xiaohongshu user USER_ID -f yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
> 要求 Chrome 打开且装了 OpenCLI 扩展。报 AUTH_REQUIRED 说明浏览器里没登录小红书,让用户在 Chrome 里登录一次即可。
|
||||||
|
|
||||||
|
### 后端 B:xiaohongshu-mcp(服务器场景)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 未登录时:先查状态,再取二维码给用户扫
|
||||||
|
mcporter call 'xiaohongshu.check_login_status()' --timeout 120000
|
||||||
|
mcporter call 'xiaohongshu.get_login_qrcode()' --timeout 120000
|
||||||
|
|
||||||
|
# 搜索
|
||||||
|
mcporter call 'xiaohongshu.search_feeds(keyword: "query")' --timeout 120000
|
||||||
|
|
||||||
|
# 笔记详情+评论(feed_id 和 xsec_token 从搜索结果取)
|
||||||
|
mcporter call 'xiaohongshu.get_feed_detail(feed_id: "...", xsec_token: "...")' --timeout 120000
|
||||||
|
```
|
||||||
|
|
||||||
|
> 首次调用会自动下载约 150MB 无头浏览器,务必带 `--timeout 120000`。未登录时 search 会挂死,先 check_login_status。
|
||||||
|
|
||||||
|
### 后端 C:xhs-cli(存量备选,上游 2026-03 起停更)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xhs search "query" # 搜索
|
||||||
|
xhs read NOTE_ID_OR_URL # 读笔记(必须用搜索结果中的 URL/ID,不能裸 note_id)
|
||||||
|
xhs comments NOTE_ID_OR_URL # 评论
|
||||||
|
xhs hot # 热门
|
||||||
|
xhs feed # 推荐
|
||||||
|
```
|
||||||
|
|
||||||
|
> 已知不稳定:`xhs user` / `xhs user-posts` / `xhs favorites` 可能返回 API error(上游停更无人修)。新装用户建议直接走后端 A/B。
|
||||||
|
|
||||||
|
### 通用注意事项
|
||||||
|
|
||||||
|
> **xsec_token 限制**: 小红书强制 xsec_token 机制,**不能直接用裸 note_id 去读**。正确流程:先搜索/feed 拿结果,再用结果中的完整 URL/ID 去读。三个后端都一样。
|
||||||
|
>
|
||||||
|
> **频率控制**: 高频请求(批量搜索、深翻评论)会触发验证码,平台限制无法绕过。每次操作间隔 2-3 秒。
|
||||||
|
>
|
||||||
|
> **写操作(发帖/评论/点赞)**: 建议只读。xhs-cli v0.6.x 写操作可能因签名问题返回 406。
|
||||||
|
|
||||||
|
## Twitter/X (twitter-cli)
|
||||||
|
|
||||||
|
### 稳定命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 首页时间线(最稳定)
|
||||||
|
twitter feed -n 20
|
||||||
|
|
||||||
|
# 读取单条推文(含回复)
|
||||||
|
twitter tweet URL_OR_ID
|
||||||
|
|
||||||
|
# 读取长文 / X Article
|
||||||
|
twitter article URL_OR_ID
|
||||||
|
|
||||||
|
# 用户时间线
|
||||||
|
twitter user-posts @username -n 20
|
||||||
|
|
||||||
|
# 用户资料
|
||||||
|
twitter user @username
|
||||||
|
```
|
||||||
|
|
||||||
|
### 可能不稳定的命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 搜索推文(Twitter 频繁改 GraphQL 端点,可能 404)
|
||||||
|
twitter search "query" -n 10
|
||||||
|
|
||||||
|
# likes(2024 年后只能看自己的,平台限制)
|
||||||
|
twitter likes
|
||||||
|
```
|
||||||
|
|
||||||
|
### search 失败时的重试链(按序执行,成功即停)
|
||||||
|
|
||||||
|
1. 直接重试一次(偶发失败常见):`twitter search "query" -n 10`
|
||||||
|
2. 升级后再试:`pipx upgrade twitter-cli && twitter search "query" -n 10`
|
||||||
|
3. 换 OpenCLI 备选(桌面,复用浏览器登录态):`opencli twitter search "query" -f yaml`
|
||||||
|
4. 都不行就改用 `twitter feed` / `twitter user-posts @somebody` 等稳定命令绕路
|
||||||
|
|
||||||
|
### 重要注意事项
|
||||||
|
|
||||||
|
> **安装**: `pipx install twitter-cli`(确保 v0.8.5+)
|
||||||
|
>
|
||||||
|
> **认证**: 推荐用 Cookie-Editor 导出后设置环境变量 `TWITTER_AUTH_TOKEN` + `TWITTER_CT0`。自动提取在 SSH/Docker/无头环境不可用。
|
||||||
|
>
|
||||||
|
> **IP 风控**: 不要在 VPS/数据中心 IP 上频繁调用,尤其是 followers/following,有封号风险。使用住宅代理或本地环境。
|
||||||
|
>
|
||||||
|
> **OpenCLI 备选**: 桌面装了 OpenCLI 的话,`opencli twitter search/article/user-posts -f yaml` 全套可用(浏览器登录态,无需 cookie 环境变量)。
|
||||||
|
>
|
||||||
|
> **输出格式**: 建议用 `--yaml` 或 `--json` 获得结构化输出,对 AI agent 更友好。
|
||||||
|
|
||||||
|
## B站 / Bilibili
|
||||||
|
|
||||||
|
> ⚠️ **不要用 yt-dlp 读 B站**(风控已全面 412 拦截,实测无解)。用 bili-cli / OpenCLI。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 搜索 / 热门 / 视频详情(bili-cli,只读无需登录)
|
||||||
|
bili search "query" --type video -n 5
|
||||||
|
bili hot -n 10
|
||||||
|
bili video BVxxx
|
||||||
|
|
||||||
|
# 字幕(OpenCLI,需桌面 Chrome)
|
||||||
|
opencli bilibili subtitle BVxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
> 详细命令(音频转写、API 直连兜底)见 [references/video.md](video.md)。
|
||||||
|
|
||||||
|
## V2EX (公开 API)
|
||||||
|
|
||||||
|
无需认证,直接调用公开 API。
|
||||||
|
|
||||||
|
### 热门主题
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: agent-reach/1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 节点主题
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# node_name 如: python, tech, jobs, qna, programmers
|
||||||
|
curl -s "https://www.v2ex.com/api/topics/show.json?node_name=python&page=1" -H "User-Agent: agent-reach/1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 主题详情
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# topic_id 从 URL 获取,如 https://www.v2ex.com/t/1234567
|
||||||
|
curl -s "https://www.v2ex.com/api/topics/show.json?id=TOPIC_ID" -H "User-Agent: agent-reach/1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 主题回复
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://www.v2ex.com/api/replies/show.json?topic_id=TOPIC_ID&page=1" -H "User-Agent: agent-reach/1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户信息
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://www.v2ex.com/api/members/show.json?username=USERNAME" -H "User-Agent: agent-reach/1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python 调用示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from agent_reach.channels.v2ex import V2EXChannel
|
||||||
|
|
||||||
|
ch = V2EXChannel()
|
||||||
|
|
||||||
|
# 获取热门帖子
|
||||||
|
topics = ch.get_hot_topics(limit=10)
|
||||||
|
for t in topics:
|
||||||
|
print(f"[{t['node_title']}] {t['title']} ({t['replies']} 回复)")
|
||||||
|
|
||||||
|
# 获取节点帖子
|
||||||
|
node_topics = ch.get_node_topics("python", limit=5)
|
||||||
|
|
||||||
|
# 获取帖子详情 + 回复
|
||||||
|
topic = ch.get_topic(1234567)
|
||||||
|
print(topic["title"], "—", topic["author"])
|
||||||
|
|
||||||
|
# 获取用户信息
|
||||||
|
user = ch.get_user("Livid")
|
||||||
|
```
|
||||||
|
|
||||||
|
> **节点列表**: https://www.v2ex.com/planes
|
||||||
|
|
||||||
|
## Reddit(多后端,必须登录态)
|
||||||
|
|
||||||
|
**Reddit 没有零配置路径**:匿名 `.json` 端点已被封(403),官方 API 自 2025-11 起人工审批基本不批。两个后端都靠登录态,先跑 `agent-reach doctor --json` 看 reddit 的 `active_backend`。中国大陆访问需代理。
|
||||||
|
|
||||||
|
### 后端 A:OpenCLI(桌面首选,复用浏览器登录态)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 搜索帖子
|
||||||
|
opencli reddit search "query" -f yaml
|
||||||
|
|
||||||
|
# 读帖子全文 + 评论
|
||||||
|
opencli reddit read POST_ID -f yaml
|
||||||
|
|
||||||
|
# 浏览 subreddit / 热门 / Popular
|
||||||
|
opencli reddit subreddit LocalLLaMA -f yaml
|
||||||
|
opencli reddit hot -f yaml
|
||||||
|
opencli reddit popular -f yaml
|
||||||
|
|
||||||
|
# subreddit 元信息(订阅数、简介)
|
||||||
|
opencli reddit subreddit-info LocalLLaMA -f yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
> 要求 Chrome 打开且浏览器里登录过 reddit.com。
|
||||||
|
|
||||||
|
### 后端 B:rdt-cli(存量/服务器备选,上游 2026-03 起停更)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rdt search "query" --limit 10 # 搜索帖子
|
||||||
|
rdt read POST_ID # 读帖子全文 + 评论
|
||||||
|
rdt sub python --limit 20 # 浏览 subreddit
|
||||||
|
rdt popular --limit 10 # 浏览热门
|
||||||
|
rdt all --limit 10 # 浏览 /r/all
|
||||||
|
```
|
||||||
|
|
||||||
|
> **安装**: `pipx install 'git+https://github.com/public-clis/rdt-cli.git'`(PyPI 版本落后,需从 GitHub 装 v0.4.2+)。先 `rdt login` 才能搜索和阅读(服务器无浏览器时手动写 Cookie,见 doctor 提示)。
|
||||||
|
> 建议使用 `--yaml` 输出,对 AI agent 更友好。
|
||||||
|
|
||||||
|
### 高级选项:官方 API + PRAW(仅限已有凭证的用户)
|
||||||
|
|
||||||
|
2025-11 前注册过 Reddit script app(持有 client_id/client_secret)的用户可以用 PRAW 走官方 API(100 QPM 免费)。新申请需人工审批且个人项目基本不批,**不要推荐新用户走这条路**。
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 视频/播客
|
||||||
|
|
||||||
|
YouTube、B站、小宇宙播客的字幕和转录。
|
||||||
|
|
||||||
|
## YouTube (yt-dlp)
|
||||||
|
|
||||||
|
### 获取视频元数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yt-dlp --dump-json "URL"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 下载字幕
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 下载字幕 (不下载视频)
|
||||||
|
yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --skip-download -o "/tmp/%(id)s" "URL"
|
||||||
|
|
||||||
|
# 然后读取 .vtt 文件
|
||||||
|
cat /tmp/VIDEO_ID.*.vtt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取评论
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 提取评论(best-effort,不保证完整)
|
||||||
|
yt-dlp --write-comments --skip-download --write-info-json \
|
||||||
|
--extractor-args "youtube:max_comments=20" \
|
||||||
|
-o "/tmp/%(id)s" "URL"
|
||||||
|
# 评论在 .info.json 的 comments 字段中
|
||||||
|
```
|
||||||
|
|
||||||
|
### 搜索视频
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yt-dlp --dump-json "ytsearch5:query"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **字幕注意**: 手动上传的字幕提取可靠;自动生成字幕可能存在行间重复,需后处理。
|
||||||
|
> **评论注意**: `--write-comments` 基于网页抓取(非 YouTube Data API),部分评论可能丢失。
|
||||||
|
|
||||||
|
### 无字幕兜底:Whisper 音频转写
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 视频没有字幕时的兜底:下载音频并用 Whisper 转写(Groq 免费 key 即可)
|
||||||
|
agent-reach transcribe "https://www.youtube.com/watch?v=VIDEO_ID"
|
||||||
|
agent-reach transcribe ./local_audio.mp3 -o /tmp/transcript.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
> 需要先配置 key:`agent-reach configure groq-key gsk_xxx`(免费,console.groq.com)
|
||||||
|
> 或 `agent-reach configure openai-key sk-xxx`。默认 auto 模式:groq 失败自动降级 openai。
|
||||||
|
|
||||||
|
## B站 / Bilibili(bili-cli 为主,OpenCLI 补字幕)
|
||||||
|
|
||||||
|
> ⚠️ **不要用 yt-dlp 读 B站**:B站风控已全面 412 拦截 yt-dlp(实测最新版、直连/代理/带 Cookie 全部无效)。yt-dlp 只用于 YouTube。
|
||||||
|
|
||||||
|
### 视频详情/搜索/热门/排行 (bili-cli,只读无需登录)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 视频详情(标题/UP主/时长/播放互动数据/字幕可用性)
|
||||||
|
bili video BVxxx
|
||||||
|
|
||||||
|
# 搜索视频
|
||||||
|
bili search "query" --type video -n 5
|
||||||
|
|
||||||
|
# 热门视频 / 排行榜
|
||||||
|
bili hot -n 10
|
||||||
|
bili rank -n 10
|
||||||
|
|
||||||
|
# 下载音频并切分为 ASR-ready WAV(无字幕时配合 agent-reach transcribe 转写)
|
||||||
|
bili audio BVxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字幕 (OpenCLI,需要桌面 Chrome)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 字幕逐句带时间轴
|
||||||
|
opencli bilibili subtitle BVxxx
|
||||||
|
|
||||||
|
# OpenCLI 也能搜索/读视频元数据(备选)
|
||||||
|
opencli bilibili search "query" -f yaml
|
||||||
|
opencli bilibili video BVxxx -f yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 零配置兜底:搜索 API 直连
|
||||||
|
|
||||||
|
```bash
|
||||||
|
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
||||||
|
curl -s -c /tmp/bili_ck.txt -o /dev/null -A "$UA" "https://www.bilibili.com/"
|
||||||
|
curl -s -b /tmp/bili_ck.txt -A "$UA" -e "https://www.bilibili.com/" \
|
||||||
|
"https://api.bilibili.com/x/web-interface/search/all/v2?keyword=QUERY&page=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **安装 bili-cli**: `pipx install bilibili-cli`(上游 2026-03 起停更但实测健康;只读场景无需登录,`bili login` 扫码可解锁动态/收藏等个人功能)。
|
||||||
|
|
||||||
|
## 小宇宙播客 / Xiaoyuzhou Podcast
|
||||||
|
|
||||||
|
### 转录单集播客(可选 --polish 增强标点)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 输出 Markdown 文件到 /tmp/。--polish 让 Llama 3.3 70B 给文稿补中文标点+合理分段
|
||||||
|
~/.agent-reach/tools/xiaoyuzhou/transcribe.sh --polish "https://www.xiaoyuzhoufm.com/episode/EPISODE_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 转写 prompt 已要求 Whisper 输出中文标点;若标点效果仍不理想,可加 `--polish` 用 Groq 上免费的 Llama 3.3 70B 补标点+合理分段(9 分钟播客约多 ~7 秒)。每次转写多一轮 LLM 调用,按需使用。
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
1. **ffmpeg**: `brew install ffmpeg`
|
||||||
|
2. **Groq API Key** (免费): https://console.groq.com/keys
|
||||||
|
3. **配置 Key**: `agent-reach configure groq-key YOUR_KEY`
|
||||||
|
4. **首次运行**: `agent-reach install --env=auto` 安装工具
|
||||||
|
|
||||||
|
### 检查状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-reach doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
> 输出 Markdown 文件默认保存到 `/tmp/`。
|
||||||
|
|
||||||
|
## 选择指南
|
||||||
|
|
||||||
|
| 场景 | 推荐工具 |
|
||||||
|
|-----|---------|
|
||||||
|
| YouTube 字幕 | yt-dlp |
|
||||||
|
| B站视频详情/搜索 | bili-cli |
|
||||||
|
| B站字幕 | opencli bilibili subtitle |
|
||||||
|
| 播客转录 | 小宇宙 transcribe.sh |
|
||||||
|
| 无字幕音视频 | agent-reach transcribe(B站音频先 `bili audio`) |
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 网页阅读
|
||||||
|
|
||||||
|
通用网页、RSS。
|
||||||
|
|
||||||
|
## 通用网页 (Jina Reader)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 读取任意网页内容
|
||||||
|
curl -s "https://r.jina.ai/URL"
|
||||||
|
|
||||||
|
# 示例
|
||||||
|
curl -s "https://r.jina.ai/https://example.com/article"
|
||||||
|
```
|
||||||
|
|
||||||
|
**适用场景**: 大多数网页可以直接用 Jina Reader 读取。
|
||||||
|
|
||||||
|
## Web Reader (MCP)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 读取网页内容 (Markdown 格式)
|
||||||
|
mcporter call 'web-reader.webReader(url: "https://example.com")'
|
||||||
|
|
||||||
|
# 保留图片
|
||||||
|
mcporter call 'web-reader.webReader(url: "https://example.com", retain_images: true)'
|
||||||
|
|
||||||
|
# 纯文本格式
|
||||||
|
mcporter call 'web-reader.webReader(url: "https://example.com", return_format: "text")'
|
||||||
|
```
|
||||||
|
|
||||||
|
**适用场景**: 需要更精确控制输出格式时使用。
|
||||||
|
|
||||||
|
## RSS (feedparser)
|
||||||
|
|
||||||
|
```python
|
||||||
|
python3 -c "
|
||||||
|
import feedparser
|
||||||
|
for e in feedparser.parse('FEED_URL').entries[:5]:
|
||||||
|
print(f'{e.title} — {e.link}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**适用场景**: 订阅博客、新闻源、播客等 RSS feed。
|
||||||
|
|
||||||
|
## 选择指南
|
||||||
|
|
||||||
|
| 场景 | 推荐工具 |
|
||||||
|
|-----|---------|
|
||||||
|
| 通用网页 | Jina Reader (`curl r.jina.ai`) |
|
||||||
|
| 需要图片/格式控制 | web-reader MCP |
|
||||||
|
| RSS 订阅 | feedparser |
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Whisper audio transcription with Groq → OpenAI fallback.
|
||||||
|
|
||||||
|
Downloads audio (yt-dlp), compresses + chunks (ffmpeg), and posts to a
|
||||||
|
Whisper-compatible API. Defaults to Groq's free `whisper-large-v3` and falls
|
||||||
|
back to OpenAI's `whisper-1` on HTTP error.
|
||||||
|
|
||||||
|
Public entry point:
|
||||||
|
transcribe(source, *, provider="auto", out_dir=None, config=None) -> str
|
||||||
|
|
||||||
|
Designed to be importable from channels (e.g. YouTubeChannel.transcribe).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from agent_reach.config import Config
|
||||||
|
|
||||||
|
# Whisper API limit is 25MB; leave headroom for multipart overhead.
|
||||||
|
SIZE_LIMIT_BYTES = 24 * 1024 * 1024
|
||||||
|
CHUNK_SECONDS = 600 # 10 min — small enough that boundary cuts rarely lose meaning
|
||||||
|
|
||||||
|
PROVIDERS = {
|
||||||
|
"groq": {
|
||||||
|
"endpoint": "https://api.groq.com/openai/v1/audio/transcriptions",
|
||||||
|
"model": "whisper-large-v3",
|
||||||
|
"key_field": "groq_api_key",
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"endpoint": "https://api.openai.com/v1/audio/transcriptions",
|
||||||
|
"model": "whisper-1",
|
||||||
|
"key_field": "openai_api_key",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeError(RuntimeError):
|
||||||
|
"""Raised when transcription cannot complete."""
|
||||||
|
|
||||||
|
|
||||||
|
class MissingDependency(TranscribeError):
|
||||||
|
"""Raised when a required external binary is missing."""
|
||||||
|
|
||||||
|
|
||||||
|
class NoProviderConfigured(TranscribeError):
|
||||||
|
"""Raised when no provider has an API key configured."""
|
||||||
|
|
||||||
|
|
||||||
|
def _require(binary: str) -> None:
|
||||||
|
if not shutil.which(binary):
|
||||||
|
raise MissingDependency(f"{binary} not found in PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: List[str], timeout: int = 600) -> None:
|
||||||
|
"""Run a subprocess, raising TranscribeError on nonzero exit or timeout.
|
||||||
|
|
||||||
|
cmd carries user-supplied URLs/paths into yt-dlp/ffmpeg — a stalled
|
||||||
|
network read or a hung probe must not block the CLI forever.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise TranscribeError(f"{cmd[0]} timed out after {timeout}s")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise TranscribeError(
|
||||||
|
f"{cmd[0]} failed (exit {proc.returncode}): {proc.stderr.strip()[:300]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_audio(url: str, out_dir: Path) -> Path:
|
||||||
|
"""Download audio with yt-dlp into out_dir; return the resulting file path."""
|
||||||
|
_require("yt-dlp")
|
||||||
|
template = out_dir / "source.%(ext)s"
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"yt-dlp",
|
||||||
|
"-x",
|
||||||
|
"--audio-format",
|
||||||
|
"m4a",
|
||||||
|
"--audio-quality",
|
||||||
|
"0",
|
||||||
|
"-o",
|
||||||
|
str(template),
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
timeout=1800, # long podcasts over slow networks — generous but bounded
|
||||||
|
)
|
||||||
|
files = sorted(out_dir.glob("source.*"))
|
||||||
|
if not files:
|
||||||
|
raise TranscribeError("yt-dlp produced no output file")
|
||||||
|
return files[0]
|
||||||
|
|
||||||
|
|
||||||
|
def compress_audio(src: Path, out_dir: Path) -> Path:
|
||||||
|
"""Re-encode to mono / 16kHz / 32kbps m4a — keeps most content under 25MB."""
|
||||||
|
_require("ffmpeg")
|
||||||
|
dst = out_dir / "compressed.m4a"
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-vn",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-b:a",
|
||||||
|
"32k",
|
||||||
|
str(dst),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_audio(src: Path, out_dir: Path, segment_seconds: int = CHUNK_SECONDS) -> List[Path]:
|
||||||
|
"""Split src into segments. Re-encodes each segment so cuts align to keyframes."""
|
||||||
|
_require("ffmpeg")
|
||||||
|
pattern = out_dir / "chunk_%03d.m4a"
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-f",
|
||||||
|
"segment",
|
||||||
|
"-segment_time",
|
||||||
|
str(segment_seconds),
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-b:a",
|
||||||
|
"32k",
|
||||||
|
str(pattern),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
chunks = sorted(out_dir.glob("chunk_*.m4a"))
|
||||||
|
if not chunks:
|
||||||
|
raise TranscribeError("ffmpeg produced no chunks")
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_key(provider: str, config: Config) -> Optional[str]:
|
||||||
|
field = PROVIDERS[provider]["key_field"]
|
||||||
|
val = config.get(field)
|
||||||
|
return val or None
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_chunk(
|
||||||
|
chunk: Path,
|
||||||
|
provider: str,
|
||||||
|
*,
|
||||||
|
config: Optional[Config] = None,
|
||||||
|
timeout: int = 120,
|
||||||
|
) -> str:
|
||||||
|
"""Transcribe one chunk via the named provider. Raises TranscribeError on failure."""
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
raise TranscribeError(f"unknown provider: {provider}")
|
||||||
|
cfg = config or Config()
|
||||||
|
key = _provider_key(provider, cfg)
|
||||||
|
if not key:
|
||||||
|
raise NoProviderConfigured(
|
||||||
|
f"{provider}: missing {PROVIDERS[provider]['key_field']} "
|
||||||
|
f"(configure with `agent-reach configure {provider}-key ...`)"
|
||||||
|
)
|
||||||
|
|
||||||
|
info = PROVIDERS[provider]
|
||||||
|
with chunk.open("rb") as fh:
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
info["endpoint"],
|
||||||
|
headers={"Authorization": f"Bearer {key}"},
|
||||||
|
files={"file": (chunk.name, fh, "audio/m4a")},
|
||||||
|
data={"model": info["model"], "response_format": "text"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise TranscribeError(f"{provider}: network error: {e}") from e
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
raise TranscribeError(f"{provider}: HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_order(provider: str) -> List[str]:
|
||||||
|
if provider == "auto":
|
||||||
|
return ["groq", "openai"]
|
||||||
|
if provider in PROVIDERS:
|
||||||
|
return [provider]
|
||||||
|
raise TranscribeError(f"unknown provider: {provider} (use groq|openai|auto)")
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe(
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
provider: str = "auto",
|
||||||
|
out_dir: Optional[Path] = None,
|
||||||
|
config: Optional[Config] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Transcribe a URL or local file path. Returns the joined transcript text.
|
||||||
|
|
||||||
|
`provider` is one of `auto` (groq → openai), `groq`, or `openai`.
|
||||||
|
`out_dir` defaults to a fresh temp directory; intermediate files stay there.
|
||||||
|
"""
|
||||||
|
cfg = config or Config()
|
||||||
|
order = _provider_order(provider)
|
||||||
|
|
||||||
|
# Validate at least one provider is configured before doing expensive work.
|
||||||
|
if not any(_provider_key(p, cfg) for p in order):
|
||||||
|
names = ", ".join(PROVIDERS[p]["key_field"] for p in order)
|
||||||
|
raise NoProviderConfigured(f"no provider key configured (need one of: {names})")
|
||||||
|
|
||||||
|
work_dir = Path(out_dir) if out_dir else Path(tempfile.mkdtemp(prefix="transcribe-"))
|
||||||
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
src_path = Path(source)
|
||||||
|
if src_path.is_file():
|
||||||
|
audio = src_path
|
||||||
|
else:
|
||||||
|
audio = download_audio(source, work_dir)
|
||||||
|
|
||||||
|
compressed = compress_audio(audio, work_dir)
|
||||||
|
if compressed.stat().st_size <= SIZE_LIMIT_BYTES:
|
||||||
|
chunks = [compressed]
|
||||||
|
else:
|
||||||
|
chunks = chunk_audio(compressed, work_dir)
|
||||||
|
|
||||||
|
pieces: List[str] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
text = _transcribe_with_fallback(chunk, order, cfg)
|
||||||
|
pieces.append(text.strip())
|
||||||
|
return "\n".join(p for p in pieces if p)
|
||||||
|
|
||||||
|
|
||||||
|
def _transcribe_with_fallback(chunk: Path, order: List[str], config: Config) -> str:
|
||||||
|
"""Try each provider in order; return first success or raise the last error."""
|
||||||
|
last_err: Optional[Exception] = None
|
||||||
|
for p in order:
|
||||||
|
if not _provider_key(p, config):
|
||||||
|
# Skip silently — caller already validated at least one is configured.
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return transcribe_chunk(chunk, p, config=config)
|
||||||
|
except TranscribeError as e:
|
||||||
|
last_err = e
|
||||||
|
continue
|
||||||
|
raise TranscribeError(f"all providers failed for {chunk.name}: {last_err}")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Cross-platform path and remediation helpers for yt-dlp."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_ytdlp_config_dir() -> Path:
|
||||||
|
"""Return the recommended yt-dlp user config directory for this OS."""
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
appdata = os.environ.get("APPDATA")
|
||||||
|
if appdata:
|
||||||
|
return Path(appdata) / "yt-dlp"
|
||||||
|
return Path.home() / "AppData" / "Roaming" / "yt-dlp"
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return Path.home() / "Library" / "Application Support" / "yt-dlp"
|
||||||
|
return Path.home() / ".config" / "yt-dlp"
|
||||||
|
|
||||||
|
|
||||||
|
def get_ytdlp_config_path() -> Path:
|
||||||
|
"""Return the yt-dlp user config file path for this OS."""
|
||||||
|
|
||||||
|
return get_ytdlp_config_dir() / "config"
|
||||||
|
|
||||||
|
|
||||||
|
def render_ytdlp_fix_command() -> str:
|
||||||
|
"""Return an OS-appropriate command to enable Node.js as yt-dlp JS runtime."""
|
||||||
|
|
||||||
|
config_path = get_ytdlp_config_path()
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return (
|
||||||
|
f"$cfg = '{config_path}'\n"
|
||||||
|
"New-Item -ItemType Directory -Force -Path (Split-Path $cfg) | Out-Null\n"
|
||||||
|
"if (-not (Test-Path $cfg) -or -not (Select-String -Path $cfg -Pattern '--js-runtimes' -Quiet)) {\n"
|
||||||
|
" Add-Content -Path $cfg -Value '--js-runtimes node'\n"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"mkdir -p '{config_path.parent}' && "
|
||||||
|
f"grep -qxF -- '--js-runtimes node' '{config_path}' 2>/dev/null || "
|
||||||
|
f"printf '%s\n' '--js-runtimes node' >> '{config_path}'"
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Subprocess helpers for consistent cross-platform text handling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
UTF8_ENV = {
|
||||||
|
"PYTHONUTF8": "1",
|
||||||
|
"PYTHONIOENCODING": "utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def utf8_subprocess_env(base: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||||
|
"""Return an environment that forces Python child processes into UTF-8 mode."""
|
||||||
|
env = dict(base or os.environ)
|
||||||
|
env.update(UTF8_ENV)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def mcporter_utf8_env_args() -> list[str]:
|
||||||
|
"""Return mcporter --env arguments for UTF-8 Python stdio servers."""
|
||||||
|
args = []
|
||||||
|
for key, value in UTF8_ENV.items():
|
||||||
|
args.extend(["--env", f"{key}={value}"])
|
||||||
|
return args
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""UTF-8-safe text helpers for cross-platform file operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def read_utf8_text(path: str | Path, default: str = "") -> str:
|
||||||
|
"""Read text as UTF-8 with replacement semantics."""
|
||||||
|
|
||||||
|
target = Path(path)
|
||||||
|
if not target.exists():
|
||||||
|
return default
|
||||||
|
return target.read_text(encoding="utf-8", errors="replace")
|
||||||
+108
-62
@@ -4,6 +4,10 @@
|
|||||||
<strong>Give your AI Agent one-click access to the entire internet</strong>
|
<strong>Give your AI Agent one-click access to the entire internet</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
The most reliable access path for each platform — chosen, installed, and health-checked for you. Backends come and go; you won't notice.
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="../LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
<a href="../LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
|
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">Quick Start</a> · <a href="../README.md">中文</a> · <a href="#supported-platforms">Platforms</a> · <a href="#design-philosophy">Philosophy</a>
|
<a href="#quick-start">Quick Start</a> · <a href="../README.md">中文</a> · <a href="README_ja.md">日本語</a> · <a href="README_ko.md">한국어</a> · <a href="#supported-platforms">Platforms</a> · <a href="#design-philosophy">Philosophy</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -39,13 +43,19 @@ Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/m
|
|||||||
|
|
||||||
Copy that to your Agent. A few minutes later, it can read tweets, search Reddit, and watch Bilibili.
|
Copy that to your Agent. A few minutes later, it can read tweets, search Reddit, and watch Bilibili.
|
||||||
|
|
||||||
|
**Already installed? Update in one command:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
```
|
||||||
|
|
||||||
### ✅ Before you start, you might want to know
|
### ✅ Before you start, you might want to know
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 💰 **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, xreach, Jina Reader, etc.) are tracked and updated regularly |
|
| 🔄 **Kept up to date** | Every platform routes through a primary + fallback backend list. When an access path dies, we switch to the next — you won't notice (June 2026: Bilibili 412-blocked yt-dlp → switched to bili-cli, zero action on your side) |
|
||||||
| 🤖 **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,18 +66,18 @@ 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 ([xreach](https://www.npmjs.com/package/xreach-cli)) |
|
| 🐦 **Twitter/X** | Read · Search | Cookie | Cookie unlocks search, timeline, tweet reading, articles ([twitter-cli](https://github.com/public-clis/twitter-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 · Comments | OpenCLI / MCP | Desktop: [OpenCLI](https://github.com/jackwener/opencli) (reuses browser session); Server: [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (QR login); legacy xhs-cli still works |
|
||||||
| 🎵 **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直聘" |
|
| 💻 **V2EX** | Hot topics · Node topics · Topic detail + replies · User profile | Zero config | Public JSON API, no auth required. Great for tech community content |
|
||||||
| 💬 **WeChat Articles** | — | Search + read WeChat Official Account articles (full Markdown) | `pip install miku_ai camoufox[geoip]` |
|
| 📈 **Xueqiu (雪球)** | Stock quotes · Search · Hot posts · Hot stocks | Browser cookie | Tell your Agent "help me set up Xueqiu" |
|
||||||
|
| 🎙️ **Xiaoyuzhou Podcast** | Transcription | Free API key | Podcast audio → full text transcript via Groq Whisper (free) |
|
||||||
| 🔍 **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) |
|
||||||
| 📺 **Bilibili** | Read · **Search** | Zero config / Proxy | Video info + subtitles + search. Local works directly, servers need a proxy ([yt-dlp](https://github.com/yt-dlp/yt-dlp)) |
|
| 📺 **Bilibili** | Read · **Search** | Zero config | Search + video detail via [bili-cli](https://github.com/public-clis/bilibili-cli) (no login needed); subtitles via [OpenCLI](https://github.com/jackwener/opencli). yt-dlp is 412-blocked by Bilibili and no longer used here |
|
||||||
| 📡 **RSS** | Read | Zero config | Any RSS/Atom feed ([feedparser](https://github.com/kurtmckee/feedparser) ⭐2.3K) |
|
| 📡 **RSS** | Read | Zero config | Any RSS/Atom feed ([feedparser](https://github.com/kurtmckee/feedparser) ⭐2.3K) |
|
||||||
| 📖 **Reddit** | Search · Read | Free / Proxy | Search via Exa (free). Reading posts needs a proxy on servers |
|
| 📖 **Reddit** | Search · Read | OpenCLI / Cookie | No zero-config path (anonymous endpoints blocked). Desktop: [OpenCLI](https://github.com/jackwener/opencli) via browser session; or [rdt-cli](https://github.com/public-clis/rdt-cli) + cookie |
|
||||||
|
|
||||||
> **Setup levels:** Zero config = install and go · Auto-configured = handled during install · mcporter = needs MCP service · Cookie = export from browser · Proxy = $1/month
|
> **Setup levels:** Zero config = install and go · Auto-configured = handled during install · mcporter = needs MCP service · Cookie = export from browser · Proxy = $1/month
|
||||||
|
|
||||||
@@ -75,6 +85,15 @@ Copy that to your Agent. A few minutes later, it can read tweets, search Reddit,
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
> ⚠️ **OpenClaw users: enable `exec` permission first**
|
||||||
|
>
|
||||||
|
> Agent Reach relies on the Agent running shell commands (`pip install`, `mcporter`, `twitter`, etc.). If your OpenClaw uses the default `messaging` tool profile, the Agent won't be able to run them. **Enable `exec` before installing:**
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> openclaw config set tools.profile "coding"
|
||||||
|
> ```
|
||||||
|
> Or set `"tools": { "profile": "coding" }` in `~/.openclaw/openclaw.json`. After changing it, restart the Gateway (`openclaw gateway restart`) and start a new conversation. Other platforms (Claude Code, Cursor, Windsurf, etc.) are not affected.
|
||||||
|
|
||||||
Copy this to your AI Agent (Claude Code, OpenClaw, Cursor, etc.):
|
Copy this to your AI Agent (Claude Code, OpenClaw, Cursor, etc.):
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -83,6 +102,17 @@ Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/m
|
|||||||
|
|
||||||
The Agent auto-installs, detects your environment, and tells you what's ready.
|
The Agent auto-installs, detects your environment, and tells you what's ready.
|
||||||
|
|
||||||
|
> 🔄 **Already installed?** Update in one command:
|
||||||
|
> ```
|
||||||
|
> Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
> ```
|
||||||
|
|
||||||
|
> 🛡️ **Worried about security?** Use safe mode — it won't auto-install system packages, it only tells you what you need:
|
||||||
|
> ```
|
||||||
|
> Install Agent Reach (safe mode): https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
|
||||||
|
> Use the --safe flag during install
|
||||||
|
> ```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Manual install</summary>
|
<summary>Manual install</summary>
|
||||||
|
|
||||||
@@ -102,6 +132,11 @@ npx skills add Panniantong/Agent-Reach@agent-reach
|
|||||||
After the Skill is installed, the Agent will auto-detect whether `agent-reach` CLI is available and install it if needed.
|
After the Skill is installed, the Agent will auto-detect whether `agent-reach` CLI is available and install it if needed.
|
||||||
|
|
||||||
> If you install via `agent-reach install`, the skill is registered automatically — no extra steps needed.
|
> If you install via `agent-reach install`, the skill is registered automatically — no extra steps needed.
|
||||||
|
>
|
||||||
|
> Prefer an English-only skill file? Set an English locale or export `AGENT_REACH_LANG=en`
|
||||||
|
> before running `agent-reach install --env=auto` or `agent-reach skill --install`.
|
||||||
|
> The installed file is always written as `SKILL.md`, so switching languages means rerunning
|
||||||
|
> the install command with the new locale and replacing the previously installed skill file.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -113,7 +148,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" → `xreach tweet URL --json`
|
- "Read this tweet" → `twitter tweet URL`
|
||||||
- "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"`
|
||||||
|
|
||||||
@@ -129,11 +164,11 @@ Don't use it? Don't configure it. Every step is optional.
|
|||||||
|
|
||||||
Tell your Agent "help me configure Twitter cookies" — it'll guide you through exporting from your browser. Local computers can auto-import.
|
Tell your Agent "help me configure Twitter cookies" — it'll guide you through exporting from your browser. Local computers can auto-import.
|
||||||
|
|
||||||
### 🌐 Proxy — $1/month, servers only
|
### 🌐 Proxy — $1/month, restricted networks only
|
||||||
|
|
||||||
Reddit and Bilibili block server IPs. Get a proxy ([Webshare](https://webshare.io) recommended, $1/month) and send the address to your Agent.
|
Most users need no proxy. If your network blocks Reddit/Twitter (e.g. mainland China) get one ([Webshare](https://webshare.io) recommended, $1/month) and send the address to your Agent — it saves it and exports HTTP(S)_PROXY when calling those tools.
|
||||||
|
|
||||||
> Local computers don't need a proxy. Reddit search works free via Exa even without one.
|
> Reddit needs a logged-in session either way — OpenCLI rides your browser session, or rdt-cli after `rdt login`. Bilibili works via bili-cli without a proxy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +184,7 @@ $ agent-reach doctor
|
|||||||
✅ GitHub repos and code — public repos readable and searchable
|
✅ GitHub repos and code — public repos readable and searchable
|
||||||
✅ Twitter/X tweets — readable. Cookie unlocks search and posting
|
✅ Twitter/X tweets — readable. Cookie unlocks search and posting
|
||||||
✅ YouTube video subtitles — yt-dlp
|
✅ YouTube video subtitles — yt-dlp
|
||||||
⚠️ Bilibili video info — server IPs may be blocked, configure proxy
|
✅ Bilibili search & video detail — bili-cli (subtitles via OpenCLI)
|
||||||
✅ RSS/Atom feeds — feedparser
|
✅ RSS/Atom feeds — feedparser
|
||||||
✅ Web pages (any URL) — Jina Reader API
|
✅ Web pages (any URL) — Jina Reader API
|
||||||
|
|
||||||
@@ -157,8 +192,8 @@ $ agent-reach doctor
|
|||||||
⬜ Web semantic search — sign up at exa.ai for free key
|
⬜ Web semantic search — sign up at exa.ai for free key
|
||||||
|
|
||||||
🔧 Configurable:
|
🔧 Configurable:
|
||||||
⬜ Reddit posts and comments — search via Exa (free). Reading needs proxy
|
⬜ Reddit posts and comments — needs login: rdt-cli after `rdt login`, or OpenCLI browser session
|
||||||
⬜ XiaoHongShu notes — needs cookie. Export from browser
|
⬜ XiaoHongShu notes — desktop: OpenCLI (browser session); server: xiaohongshu-mcp (QR)
|
||||||
|
|
||||||
Status: 6/9 channels available
|
Status: 6/9 channels available
|
||||||
```
|
```
|
||||||
@@ -167,54 +202,50 @@ Status: 6/9 channels available
|
|||||||
|
|
||||||
## Design Philosophy
|
## Design Philosophy
|
||||||
|
|
||||||
**Agent Reach is a scaffolding tool, not a framework.**
|
**Agent Reach is a capability layer, not yet another tool.**
|
||||||
|
|
||||||
Every time you spin up a new Agent, you spend time finding tools, installing deps, and debugging configs — what reads Twitter? How do you bypass Reddit blocks? How do you extract YouTube subtitles? Every time, you re-do the same work.
|
It sits one level above any specific implementation — it handles **selection, installation, health checks, and routing**, not the reading itself. Reading is done by your Agent calling upstream tools directly; there is no wrapper layer.
|
||||||
|
|
||||||
Agent Reach does one simple thing: **it makes those tool selection and configuration decisions for you.**
|
Every time you spin up a new Agent, you spend time finding tools, installing deps, and debugging configs — what reads Twitter? How do you log into Reddit? What replaces a discontinued XiaoHongShu CLI? Every time, you re-do the same work. Agent Reach does one simple thing: **the most reliable access path for each platform, chosen, installed, and health-checked for you. Access paths come and go (in March 2026 a batch of single-platform CLIs went unmaintained — we re-routed), so you don't have to care.**
|
||||||
|
|
||||||
After installation, your Agent calls the upstream tools directly (xreach CLI, yt-dlp, mcporter, gh CLI, etc.) — no wrapper layer in between.
|
### 🔌 Every platform = an ordered backend list (primary + fallbacks)
|
||||||
|
|
||||||
### 🔌 Every Channel is Pluggable
|
Switching access paths means reordering the list, not rewriting code. `agent-reach doctor` tells you **which backend each platform is currently using**.
|
||||||
|
|
||||||
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
|
||||||
├── twitter.py → xreach ← swap to Nitter, official API…
|
├── twitter.py → twitter-cli ▸ OpenCLI ▸ bird
|
||||||
├── youtube.py → yt-dlp ← swap to YouTube API, Whisper…
|
├── youtube.py → yt-dlp
|
||||||
├── github.py → gh CLI ← swap to REST API, PyGithub…
|
├── github.py → gh CLI
|
||||||
├── bilibili.py → yt-dlp ← swap to bilibili-api…
|
├── bilibili.py → bili-cli ▸ OpenCLI ▸ search API (yt-dlp retired, 412-blocked)
|
||||||
├── reddit.py → JSON API + Exa ← swap to PRAW, Pushshift…
|
├── reddit.py → OpenCLI ▸ rdt-cli (no zero-config path, login required)
|
||||||
├── xiaohongshu.py → mcporter MCP ← swap to other XHS tools…
|
├── xiaohongshu.py → OpenCLI ▸ xiaohongshu-mcp ▸ xhs-cli
|
||||||
├── douyin.py → mcporter MCP ← swap to other Douyin tools…
|
├── linkedin.py → linkedin-mcp ▸ Jina Reader
|
||||||
├── linkedin.py → linkedin-mcp ← swap to LinkedIn API…
|
├── rss.py → feedparser
|
||||||
├── bosszhipin.py → mcp-bosszp ← swap to other job tools…
|
├── exa_search.py → Exa via mcporter
|
||||||
├── rss.py → feedparser ← swap to atoma…
|
|
||||||
├── exa_search.py → mcporter MCP ← swap to Tavily, SerpAPI…
|
|
||||||
└── __init__.py → Channel registry (for doctor checks)
|
└── __init__.py → Channel registry (for doctor checks)
|
||||||
```
|
```
|
||||||
|
|
||||||
Each channel file only checks whether its upstream tool is installed and working (`check()` method for `agent-reach doctor`). The actual reading and searching is done by calling the upstream tools directly.
|
Each channel file **actually probes** its candidate backends in order (not just checking that a command exists) — the first fully working one becomes the active backend, and broken ones come with a fix prescription. The actual reading and searching is done by the Agent calling the upstream tools directly.
|
||||||
|
|
||||||
### Current Tool Choices
|
### Current Tool Choices
|
||||||
|
|
||||||
| Scenario | Tool | Why |
|
| Scenario | Primary | Fallback | 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) | — | Free, no API key needed |
|
||||||
| Read tweets | [xreach](https://www.npmjs.com/package/xreach-cli) | Cookie auth, free. Official API is pay-per-use ($0.005/post read) |
|
| Read tweets | [twitter-cli](https://github.com/public-clis/twitter-cli) | [OpenCLI](https://github.com/jackwener/opencli) | Reliable search in real-world tests; OpenCLI falls back on your browser session |
|
||||||
| Video subtitles + search | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 148K stars, YouTube + Bilibili + 1800 sites |
|
| Reddit | [OpenCLI](https://github.com/jackwener/opencli) (desktop) | [rdt-cli](https://github.com/public-clis/rdt-cli) | Anonymous endpoints blocked, official API gated — logged-in sessions are the only route left |
|
||||||
| Search the web | [Exa](https://exa.ai) via [mcporter](https://github.com/nicepkg/mcporter) | AI semantic search, MCP integration, no API key |
|
| YouTube subtitles + search | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | — | 154K stars, still the best for YouTube (no longer used for Bilibili) |
|
||||||
| GitHub | [gh CLI](https://cli.github.com) | Official tool, full API after auth |
|
| Bilibili | [bili-cli](https://github.com/public-clis/bilibili-cli) | OpenCLI ▸ search API | yt-dlp is 412-blocked by Bilibili (verified June 2026); bili-cli searches and reads without login |
|
||||||
| Read RSS | [feedparser](https://github.com/kurtmckee/feedparser) | Python ecosystem standard, 2.3K stars |
|
| Search the web | [Exa](https://exa.ai) via [mcporter](https://github.com/nicobailon/mcporter) | — | AI semantic search, MCP integration, no API key |
|
||||||
| XiaoHongShu | [xiaohongshu-mcp](https://github.com/user/xiaohongshu-mcp) | Internal API, bypasses anti-bot |
|
| GitHub | [gh CLI](https://cli.github.com) | — | Official tool, full API after auth |
|
||||||
| Douyin | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP server, no login needed, video parsing + watermark-free download |
|
| Read RSS | [feedparser](https://github.com/kurtmckee/feedparser) | — | Python ecosystem standard |
|
||||||
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | 900+ stars, MCP server, browser automation |
|
| XiaoHongShu | [OpenCLI](https://github.com/jackwener/opencli) (desktop) | [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (server) ▸ xhs-cli | The xhs-cli author moved to OpenCLI (24K stars); browser sessions mean zero friction |
|
||||||
| Boss直聘 | [mcp-bosszp](https://github.com/mucsbr/mcp-bosszp) | MCP server, job search + recruiter greeting |
|
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP server, browser automation |
|
||||||
| 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 |
|
| Xiaoyuzhou Podcast | `transcribe.sh` | — | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` |
|
||||||
|
|
||||||
> 📌 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, re-verified regularly on real machines. When a path dies we switch to the next — `agent-reach doctor` always tells you which one is active.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -235,7 +266,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 [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`.
|
Agent Reach uses [twitter-cli](https://github.com/public-clis/twitter-cli) with cookie-based authentication — completely free, no Twitter API subscription needed. Install with `pipx install twitter-cli`, make sure you're logged into x.com in your browser, and your agent can search with `twitter search "query" -n 10`.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -247,7 +278,7 @@ Agent Reach uses the [xreach CLI](https://www.npmjs.com/package/xreach-cli) with
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong>Reddit returns 403 from server / datacenter IP blocked?</strong></summary>
|
<summary><strong>Reddit returns 403 from server / datacenter IP blocked?</strong></summary>
|
||||||
|
|
||||||
Reddit blocks datacenter IPs. Configure a residential proxy: `agent-reach configure proxy http://user:pass@ip:port`. Recommended: Webshare (~$1/month). Local machines typically don't have this issue.
|
Reddit requires a logged-in session for everything (anonymous endpoints are blocked, and official API registration has been approval-gated since 2025-11). On desktop, the preferred path is OpenCLI riding your browser's reddit.com session. Otherwise install rdt-cli from the pinned git source (`pipx install 'git+https://github.com/public-clis/rdt-cli.git'` — PyPI lags), then `rdt login`. Your agent can then search with `rdt search "query"` and read full posts + comments with `rdt read POST_ID`.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -259,33 +290,48 @@ 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 (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.
|
100% free and open source. All backends (twitter-cli, rdt-cli, xhs-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) for some server scenarios. Reddit costs nothing but needs a logged-in session (rdt-cli after `rdt login`, or OpenCLI reusing your browser session).
|
||||||
</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 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.
|
Agent Reach uses twitter-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>
|
||||||
<summary><strong>How to read XiaoHongShu / 小红书 content programmatically?</strong></summary>
|
<summary><strong>How to read XiaoHongShu / 小红书 content programmatically?</strong></summary>
|
||||||
|
|
||||||
Agent Reach integrates with xiaohongshu-mcp (runs in Docker). After setup, use `mcporter call 'xiaohongshu.get_feed_detail(...)'` to read notes or `mcporter call 'xiaohongshu.search_feeds(keyword: "query")'` to search.
|
On desktop, prefer **OpenCLI** (`agent-reach install --channels opencli`) — it reuses your browser's logged-in session, so if you've browsed XiaoHongShu you're set; one Chrome Web Store click installs the extension. Then `opencli xiaohongshu search "query"` / `opencli xiaohongshu note URL`. On servers use [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (bundled headless browser, QR login). Existing xhs-cli installs keep working as a fallback backend (upstream unmaintained since 2026-03, not recommended for new setups).
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>How to parse Douyin / 抖音 videos with AI agent?</strong></summary>
|
|
||||||
|
|
||||||
Install douyin-mcp-server, then your agent can use `mcporter call 'douyin.parse_douyin_video_info(share_link: "share_url")'` to parse video info and get watermark-free download links. No login required — just share the Douyin link. See https://github.com/yzfly/douyin-mcp-server
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
[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)
|
[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server)
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- 📧 **Email:** pnt01@foxmail.com
|
||||||
|
- 🐦 **Twitter/X:** [@Neo_Reidlab](https://x.com/Neo_Reidlab)
|
||||||
|
|
||||||
|
For collaboration or questions, add me on WeChat — I'll invite you to the community group:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="wechat-group-qr.jpg" width="280" alt="WeChat QR">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> For bug reports and feature requests, please use [GitHub Issues](https://github.com/Panniantong/Agent-Reach/issues) — easier to track.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](../LICENSE)
|
[MIT](../LICENSE)
|
||||||
|
|
||||||
|
## Friends
|
||||||
|
|
||||||
|
[OpenClaw on Tencent Cloud](https://www.tencentcloud.com/act/pro/intl-openclaw?referral_code=G76Y819A&lang=en&pg=) — One-click OpenClaw on Tencent Cloud: chat to connect Agent Reach & unlock internet power.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#Panniantong/Agent-Reach&Date)
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
<h1 align="center">👁️ Agent Reach</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>AIエージェントにワンクリックでインターネット全体へのアクセスを</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="../LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
|
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
|
||||||
|
<a href="https://github.com/Panniantong/agent-reach/stargazers"><img src="https://img.shields.io/github/stars/Panniantong/agent-reach?style=for-the-badge" alt="GitHub Stars"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#クイックスタート">クイックスタート</a> · <a href="../README.md">中文</a> · <a href="README_en.md">English</a> · <a href="README_ko.md">한국어</a> · <a href="#対応プラットフォーム">プラットフォーム</a> · <a href="#設計思想">設計思想</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## なぜ Agent Reach?
|
||||||
|
|
||||||
|
AIエージェントはすでにインターネットにアクセスできます。しかし「ネットに繋がる」はほんの始まりに過ぎません。
|
||||||
|
|
||||||
|
最も価値のある情報は、さまざまなSNSやニッチなプラットフォームに散らばっています:Twitterの議論、Redditのフィードバック、YouTubeのチュートリアル、小紅書のレビュー、Bilibiliの動画、GitHubのアクティビティ… **これらこそ情報密度が最も高い場所です**。しかし、各プラットフォームにはそれぞれ障壁があります:
|
||||||
|
|
||||||
|
| 課題 | 現実 |
|
||||||
|
|------|------|
|
||||||
|
| Twitter API | 従量課金、中程度の利用で月額約$215 |
|
||||||
|
| Reddit | サーバーIPが403でブロックされる |
|
||||||
|
| 小紅書 | 閲覧にログインが必要 |
|
||||||
|
| Bilibili | 海外/サーバーIPをブロック |
|
||||||
|
|
||||||
|
エージェントをこれらのプラットフォームに接続するには、ツールを探し、依存関係をインストールし、設定をデバッグする必要があります — ひとつずつ。
|
||||||
|
|
||||||
|
**Agent Reach はこれを1つのコマンドにまとめます:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
|
||||||
|
```
|
||||||
|
|
||||||
|
これをエージェントにコピーするだけ。数分後には、ツイートの閲覧、Redditの検索、Bilibiliの視聴が可能になります。
|
||||||
|
|
||||||
|
**すでにインストール済み?1コマンドでアップデート:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 始める前に知っておきたいこと
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 💰 **完全無料** | すべてのツールはオープンソース、すべてのAPIは無料。唯一のコストはサーバープロキシ(月額$1)の可能性のみ — ローカルPCでは不要 |
|
||||||
|
| 🔒 **プライバシー安全** | Cookieはローカルに保存。アップロードされることはありません。完全オープンソース — いつでも監査可能 |
|
||||||
|
| 🔄 **常に最新** | 上流ツール(yt-dlp、twitter-cli、rdt-cli、Jina Reader等)を定期的に追跡・更新 |
|
||||||
|
| 🤖 **あらゆるエージェントに対応** | Claude Code、OpenClaw、Cursor、Windsurf… コマンドを実行できるすべてのエージェント |
|
||||||
|
| 🩺 **組み込み診断** | `agent-reach doctor` — 1コマンドで何が動き、何が動かないか、どう修正するかを表示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 対応プラットフォーム
|
||||||
|
|
||||||
|
| プラットフォーム | 機能 | セットアップ | 備考 |
|
||||||
|
|-----------------|------|:----------:|------|
|
||||||
|
| 🌐 **Web** | 閲覧 | 設定不要 | 任意のURL → クリーンなMarkdown([Jina Reader](https://github.com/jina-ai/reader) ⭐9.8K) |
|
||||||
|
| 🐦 **Twitter/X** | 閲覧・検索 | 設定不要 / Cookie | 単一ツイートはすぐに閲覧可能。Cookieで検索、タイムライン、投稿が解放([twitter-cli](https://github.com/public-clis/twitter-cli)) |
|
||||||
|
| 📕 **小紅書** | 閲覧・検索・**投稿・コメント・いいね** | Cookie | `pipx install xiaohongshu-cli` + `xhs login`([xhs-cli](https://github.com/jackwener/xiaohongshu-cli)) |
|
||||||
|
| 🎵 **抖音** | 動画解析・ウォーターマークなしダウンロード | mcporter | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server)、ログイン不要 |
|
||||||
|
| 💼 **LinkedIn** | Jina Reader(公開ページ) | プロフィール、企業、求人検索 | エージェントに「LinkedInの設定を手伝って」と伝えてください |
|
||||||
|
| 💬 **WeChat記事** | 検索 + 閲覧 | 設定不要 | WeChat公式アカウント記事の検索+閲覧(完全Markdown)([Exa](https://exa.ai) + [Camoufox](https://github.com/daijro/camoufox)(オプション)) |
|
||||||
|
| 📰 **Weibo** | トレンド・検索・フィード・コメント | 設定不要 | ホット検索、コンテンツ/ユーザー/トピック検索、フィード、コメント([mcp-server-weibo](https://github.com/Panniantong/mcp-server-weibo)) |
|
||||||
|
| 💻 **V2EX** | 人気トピック・ノードトピック・トピック詳細+返信・ユーザープロフィール | 設定不要 | 公開JSON API、認証不要。技術コミュニティのコンテンツに最適 |
|
||||||
|
| 📈 **雪球(Xueqiu)** | 株価・検索・人気投稿・人気銘柄 | 設定不要 | 公開APIで自動セッションCookie、ログイン不要 |
|
||||||
|
| 🎙️ **小宇宙Podcast** | 文字起こし | 無料APIキー | Podcast音声 → Groq Whisper(無料)による完全テキスト文字起こし |
|
||||||
|
| 🔍 **Web検索** | 検索 | 自動設定 | インストール時に自動設定、無料、APIキー不要([Exa](https://exa.ai)、[mcporter](https://github.com/nicepkg/mcporter)経由) |
|
||||||
|
| 📦 **GitHub** | 閲覧・検索 | 設定不要 | [gh CLI](https://cli.github.com) 搭載。公開リポジトリはすぐ使える。`gh auth login`でFork、Issue、PRが解放 |
|
||||||
|
| 📺 **YouTube** | 閲覧・**検索** | 設定不要 | 字幕 + 1800以上の動画サイトでの検索([yt-dlp](https://github.com/yt-dlp/yt-dlp) ⭐148K) |
|
||||||
|
| 📺 **Bilibili** | 閲覧・**検索** | 設定不要 / プロキシ | 動画情報 + 字幕 + 検索。ローカルはそのまま動作、サーバーはプロキシが必要([yt-dlp](https://github.com/yt-dlp/yt-dlp)) |
|
||||||
|
| 📡 **RSS** | 閲覧 | 設定不要 | 任意のRSS/Atomフィード([feedparser](https://github.com/kurtmckee/feedparser) ⭐2.3K) |
|
||||||
|
| 📖 **Reddit** | 検索・閲覧 | Cookie | 2024年以降認証が必要 — インストール後 `rdt login` を実行([rdt-cli](https://github.com/public-clis/rdt-cli)) |
|
||||||
|
|
||||||
|
> **セットアップレベル:** 設定不要 = インストールしてすぐ使える · 自動設定 = インストール時に処理 · mcporter = MCPサービスが必要 · Cookie = ブラウザからエクスポート · プロキシ = 月額$1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## クイックスタート
|
||||||
|
|
||||||
|
以下をAIエージェント(Claude Code、OpenClaw、Cursor等)にコピーしてください:
|
||||||
|
|
||||||
|
```
|
||||||
|
Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
|
||||||
|
```
|
||||||
|
|
||||||
|
エージェントが自動でインストールし、環境を検出し、何が使えるかを教えてくれます。
|
||||||
|
|
||||||
|
> 🔄 **すでにインストール済み?** 1コマンドでアップデート:
|
||||||
|
> ```
|
||||||
|
> Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
> ```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>手動インストール</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install https://github.com/Panniantong/agent-reach/archive/main.zip
|
||||||
|
agent-reach install --env=auto
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Skillとしてインストール(Claude Code / OpenClaw / Skills対応の任意のエージェント)</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add Panniantong/Agent-Reach@agent-reach
|
||||||
|
```
|
||||||
|
|
||||||
|
Skillインストール後、エージェントは`agent-reach` CLIが利用可能かを自動検出し、必要に応じてインストールします。
|
||||||
|
|
||||||
|
> `agent-reach install` でインストールした場合、Skillは自動的に登録されます — 追加の手順は不要です。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## すぐに使える機能
|
||||||
|
|
||||||
|
設定不要 — エージェントに伝えるだけ:
|
||||||
|
|
||||||
|
- 「このリンクを読んで」→ `curl https://r.jina.ai/URL` で任意のWebページ
|
||||||
|
- 「このGitHubリポジトリは何?」→ `gh repo view owner/repo`
|
||||||
|
- 「この動画の内容は?」→ `yt-dlp --dump-json URL` で字幕取得
|
||||||
|
- 「このツイートを読んで」→ `twitter tweet URL`
|
||||||
|
- 「このRSSを購読して」→ `feedparser` でフィード解析
|
||||||
|
- 「GitHubでLLMフレームワークを検索して」→ `gh search repos "LLM framework"`
|
||||||
|
|
||||||
|
**コマンドを覚える必要はありません。** エージェントがSKILL.mdを読み、何を呼び出すべきか理解します。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 必要に応じてアンロック
|
||||||
|
|
||||||
|
使わない?設定しなくてOK。すべてのステップはオプションです。
|
||||||
|
|
||||||
|
### 🍪 Cookie — 無料、2分
|
||||||
|
|
||||||
|
エージェントに「Twitterのクッキーの設定を手伝って」と伝えてください — ブラウザからのエクスポート手順を案内してくれます。ローカルPCなら自動インポートも可能です。
|
||||||
|
|
||||||
|
### 🌐 プロキシ — 月額$1、サーバーのみ
|
||||||
|
|
||||||
|
RedditとBilibiliはサーバーIPをブロックします。プロキシを取得し([Webshare](https://webshare.io) 推奨、月額$1)、アドレスをエージェントに伝えてください。
|
||||||
|
|
||||||
|
> ローカルPCではプロキシは不要です。Reddit検索はプロキシなしでもrdt-cliで無料で動作します。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一目でわかるステータス
|
||||||
|
|
||||||
|
```
|
||||||
|
$ agent-reach doctor
|
||||||
|
|
||||||
|
👁️ Agent Reach ステータス
|
||||||
|
========================================
|
||||||
|
|
||||||
|
✅ 利用可能:
|
||||||
|
✅ GitHubリポジトリとコード — 公開リポジトリの閲覧・検索可能
|
||||||
|
✅ Twitter/Xツイート — 閲覧可能。Cookieで検索・投稿が解放
|
||||||
|
✅ YouTube動画字幕 — yt-dlp
|
||||||
|
⚠️ Bilibili動画情報 — サーバーIPがブロックされる可能性あり、プロキシを設定してください
|
||||||
|
✅ RSS/Atomフィード — feedparser
|
||||||
|
✅ Webページ(任意のURL) — Jina Reader API
|
||||||
|
|
||||||
|
🔍 検索(無料Exaキーで解放):
|
||||||
|
⬜ Webセマンティック検索 — exa.aiで無料キーを取得
|
||||||
|
|
||||||
|
🔧 設定可能:
|
||||||
|
✅ Reddit投稿とコメント — rdt-cliで検索+閲覧(無料、プロキシ不要)
|
||||||
|
⬜ 小紅書ノート — Cookieが必要。ブラウザからエクスポート
|
||||||
|
|
||||||
|
ステータス: 9チャンネル中6チャンネルが利用可能
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 設計思想
|
||||||
|
|
||||||
|
**Agent Reach はスキャフォールディングツールであり、フレームワークではありません。**
|
||||||
|
|
||||||
|
新しいエージェントを立ち上げるたびに、ツールを探し、依存関係をインストールし、設定をデバッグする時間がかかります — Twitterを読むには何を使う?Redditのブロックをどう回避する?YouTubeの字幕をどう抽出する?毎回、同じ作業を繰り返すことになります。
|
||||||
|
|
||||||
|
Agent Reach はシンプルなことを1つだけ行います:**ツールの選定と設定の判断をあなたの代わりに行います。**
|
||||||
|
|
||||||
|
インストール後、エージェントは上流ツール(twitter-cli、rdt-cli、xhs-cli、yt-dlp、mcporter、gh CLI等)を直接呼び出します — 間にラッパーレイヤーはありません。
|
||||||
|
|
||||||
|
### 🔌 すべてのチャンネルはプラグ可能
|
||||||
|
|
||||||
|
各プラットフォームは上流ツールに対応しています。**気に入らなければ差し替えるだけ。**
|
||||||
|
|
||||||
|
```
|
||||||
|
channels/
|
||||||
|
├── web.py → Jina Reader ← Firecrawl、Crawl4AIなどに差し替え可能…
|
||||||
|
├── twitter.py → twitter-cli ← 公式APIなどに差し替え可能…
|
||||||
|
├── youtube.py → yt-dlp ← YouTube API、Whisperなどに差し替え可能…
|
||||||
|
├── github.py → gh CLI ← REST API、PyGithubなどに差し替え可能…
|
||||||
|
├── bilibili.py → yt-dlp ← bilibili-apiなどに差し替え可能…
|
||||||
|
├── reddit.py → rdt-cli ← 検索+閲覧、Cookie認証が必要
|
||||||
|
├── xiaohongshu.py → xhs-cli ← 他のXHSツールに差し替え可能…
|
||||||
|
├── douyin.py → mcporter MCP ← 他の抖音ツールに差し替え可能…
|
||||||
|
├── linkedin.py → linkedin-mcp ← LinkedIn APIに差し替え可能…
|
||||||
|
├── rss.py → feedparser ← atomaなどに差し替え可能…
|
||||||
|
├── exa_search.py → mcporter MCP ← Tavily、SerpAPIなどに差し替え可能…
|
||||||
|
└── __init__.py → チャンネルレジストリ(doctor チェック用)
|
||||||
|
```
|
||||||
|
|
||||||
|
各チャンネルファイルは、上流ツールがインストールされ動作しているかをチェックするだけです(`agent-reach doctor` 用の `check()` メソッド)。実際の閲覧や検索は上流ツールを直接呼び出して行います。
|
||||||
|
|
||||||
|
### 現在のツール選定
|
||||||
|
|
||||||
|
| シナリオ | ツール | 理由 |
|
||||||
|
|----------|--------|------|
|
||||||
|
| Webページ閲覧 | [Jina Reader](https://github.com/jina-ai/reader) | ⭐9.8K、無料、APIキー不要 |
|
||||||
|
| ツイート閲覧 | [twitter-cli](https://github.com/public-clis/twitter-cli) | 2.1K Star、Cookie認証、検索/閲覧/タイムライン/長文 |
|
||||||
|
| 動画字幕 + 検索 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | ⭐148K、YouTube + Bilibili + 1800サイト |
|
||||||
|
| Web検索 | [Exa](https://exa.ai)([mcporter](https://github.com/nicepkg/mcporter)経由) | AIセマンティック検索、MCP統合、APIキー不要 |
|
||||||
|
| GitHub | [gh CLI](https://cli.github.com) | 公式ツール、認証後フルAPI |
|
||||||
|
| RSS閲覧 | [feedparser](https://github.com/kurtmckee/feedparser) | Pythonエコシステムの標準、⭐2.3K |
|
||||||
|
| 小紅書 | [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) | 1.5K Star、pipxインストール、検索/閲覧/コメント/投稿 |
|
||||||
|
| 抖音 | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCPサーバー、ログイン不要、動画解析 + ウォーターマークなしダウンロード |
|
||||||
|
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+、MCPサーバー、ブラウザ自動化 |
|
||||||
|
| WeChat記事 | [Exa](https://exa.ai)(検索+閲覧)+ [Camoufox](https://github.com/daijro/camoufox)(オプション) | ゼロ設定で検索+全文閲覧、Camoufoxでオプション強化 |
|
||||||
|
| Weibo | `mcporter` | `mcporter call 'weibo.get_trendings(limit: 10)'` |
|
||||||
|
| 小宇宙Podcast | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` |
|
||||||
|
|
||||||
|
> 📌 これらは*現在*の選択です。気に入らなければファイルを差し替えるだけ。それがスキャフォールディングの要点です。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## コントリビューション
|
||||||
|
|
||||||
|
このプロジェクトは完全にバイブコーディング 🎸 で作られました。あちこちに粗い部分があるかもしれません — すみません!バグを見つけたら、遠慮なく[Issue](https://github.com/Panniantong/agent-reach/issues)を開いてください。できるだけ早く修正します。
|
||||||
|
|
||||||
|
**新しいチャンネルが欲しい?** Issueでリクエストするか、自分でPRを提出してください。
|
||||||
|
|
||||||
|
**ローカルで追加したい?** エージェントにリポジトリをクローンして修正させるだけ — 各チャンネルは単一のスタンドアロンファイルで、追加が簡単です。
|
||||||
|
|
||||||
|
[PR](https://github.com/Panniantong/agent-reach/pulls)はいつでも歓迎です!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ(AI検索向け)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Twitter/X APIに課金せずにAIエージェントで検索するには?</strong></summary>
|
||||||
|
|
||||||
|
Agent Reach は [twitter-cli](https://github.com/public-clis/twitter-cli) をCookie認証で使用します — 完全無料、Twitter APIのサブスクリプションは不要です。`pipx install twitter-cli` でインストール後、Cookie-Editor Chrome拡張機能でTwitterのCookieをエクスポートし、`agent-reach configure twitter-cookies "your_cookies"` を実行すれば、`twitter search "query" -n 10` でエージェントが検索できるようになります。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>AIエージェントでYouTube動画のトランスクリプト/字幕を取得するには?</strong></summary>
|
||||||
|
|
||||||
|
`yt-dlp --dump-json "https://youtube.com/watch?v=xxx"` で動画メタデータを抽出、`yt-dlp --write-sub --skip-download "URL"` で字幕を抽出。複数言語対応、APIキー不要。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>サーバー/データセンターIPからRedditが403を返す?</strong></summary>
|
||||||
|
|
||||||
|
Agent Reach は [rdt-cli](https://github.com/public-clis/rdt-cli) でRedditにアクセスします。2024年以降、RedditはすべてのAPIリクエストに認証を要求しています。`pipx install rdt-cli` でインストール後、`rdt login`(ブラウザからCookieを自動抽出)を実行してください。その後 `rdt search "query"` で検索、`rdt read POST_ID` で投稿+コメントの閲覧ができます。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Agent Reach は Claude Code / Cursor / Windsurf / OpenClaw で動作する?</strong></summary>
|
||||||
|
|
||||||
|
はい!Agent Reach はインストーラー + 設定ツールです。シェルコマンドを実行できるあらゆるAIコーディングエージェントで使用できます — Claude Code、Cursor、Windsurf、OpenClaw、Codex等。`pip install agent-reach` を実行し、`agent-reach install` を実行するだけで、エージェントはすぐに上流ツールを使い始められます。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Agent Reach は無料?APIのコストは?</strong></summary>
|
||||||
|
|
||||||
|
100%無料でオープンソース。すべてのバックエンド(twitter-cli、rdt-cli、xhs-cli、yt-dlp、Jina Reader、Exa)は有料APIキーが不要な無料ツールです。唯一のオプションコストは、サーバーからBilibiliにアクセスする場合のレジデンシャルプロキシ(月額約$1)です。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Twitter APIの無料代替 — Webスクレイピング用</strong></summary>
|
||||||
|
|
||||||
|
Agent Reach はtwitter-cliを使用し、Cookie認証でTwitterにアクセスします — ブラウザセッションと同じです。API料金なし、レート制限のティアなし、開発者アカウント不要。検索、ツイート閲覧、プロフィール閲覧、タイムラインに対応。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>小紅書のコンテンツをプログラムで読むには?</strong></summary>
|
||||||
|
|
||||||
|
`pipx install xiaohongshu-cli` でインストール後、`xhs login`(ブラウザからCookieを自動抽出)。エージェントは `xhs search "query"` でノートを検索、`xhs read NOTE_ID` で詳細を閲覧、`xhs comments NOTE_ID` でコメントを表示できます。Dockerは不要です。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>AIエージェントで抖音の動画を解析するには?</strong></summary>
|
||||||
|
|
||||||
|
douyin-mcp-serverをインストールすれば、`mcporter call 'douyin.parse_douyin_video_info(share_link: "share_url")'` で動画情報を解析し、ウォーターマークなしのダウンロードリンクを取得できます。ログイン不要 — 抖音のリンクを共有するだけ。詳細は https://github.com/yzfly/douyin-mcp-server を参照。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## クレジット
|
||||||
|
|
||||||
|
[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [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)
|
||||||
|
|
||||||
|
## お問い合わせ
|
||||||
|
|
||||||
|
- 📧 **メール:** pnt01@foxmail.com
|
||||||
|
- 🐦 **Twitter/X:** [@Neo_Reidlab](https://x.com/Neo_Reidlab)
|
||||||
|
|
||||||
|
コラボレーションやご質問は、WeChatで追加してください — コミュニティグループにご招待します:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="wechat-group-qr.jpg" width="280" alt="WeChat QR">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> バグ報告や機能リクエストは [GitHub Issues](https://github.com/Panniantong/Agent-Reach/issues) をご利用ください — 追跡が容易です。
|
||||||
|
|
||||||
|
## ライセンス
|
||||||
|
|
||||||
|
[MIT](../LICENSE)
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#Panniantong/Agent-Reach&Date)
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<h1 align="center">👁️ Agent Reach</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>AI 에이전트가 인터넷 전체에 접근할 수 있도록 한 번에 설정해 드립니다</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="../LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
|
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
|
||||||
|
<a href="https://github.com/Panniantong/agent-reach/stargazers"><img src="https://img.shields.io/github/stars/Panniantong/agent-reach?style=for-the-badge" alt="GitHub Stars"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#빠른-시작">빠른 시작</a> · 한국어 · <a href="../README.md">中文</a> · <a href="README_en.md">English</a> · <a href="README_ja.md">日本語</a> · <a href="#지원-플랫폼">지원 플랫폼</a> · <a href="#설계-철학">설계 철학</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Reach가 필요한 이유
|
||||||
|
|
||||||
|
AI 에이전트는 이미 인터넷에 접근할 수 있습니다 — 하지만 "인터넷에 접속할 수 있다"는 것은 시작에 불과합니다.
|
||||||
|
|
||||||
|
가장 가치 있는 정보는 소셜 미디어와 특화된 플랫폼에 분포되어 있습니다: Twitter 토론, Reddit 피드백, YouTube 튜토리얼, XiaoHongShu 리뷰, Bilibili 비디오, GitHub 활동... **여기가 정보 밀도가 가장 높은 곳**이지만, 각 플랫폼은 고유한 진입장벽이 있습니다:
|
||||||
|
|
||||||
|
| 문제점 | 현실 |
|
||||||
|
|------------|---------|
|
||||||
|
| Twitter API | 유료 사용, 중간 정도 사용량 ~월 $215 |
|
||||||
|
| Reddit | 서버 IP가 403 오류 발생 |
|
||||||
|
| XiaoHongShu | 둘러보기 위해 로그인 필요 |
|
||||||
|
| Bilibili | 해외/서버 IP 차단 |
|
||||||
|
|
||||||
|
에이전트를 이 플랫폼에 연결하려면 도구를 찾고, 의존성을 설치하고, 설정을 디버깅해야 합니다 — 하나씩 직접.
|
||||||
|
|
||||||
|
**Agent Reach는 이를 하나의 명령으로 바꿉니다:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
|
||||||
|
```
|
||||||
|
|
||||||
|
이 명령을 에이전트에 복사해서 붙여넣으세요. 몇 분 뒤에는 트윗을 읽고, Reddit을 검색하고, Bilibili를 볼 수 있게 됩니다.
|
||||||
|
|
||||||
|
**이미 설치하셨나요? 한 번에 업데이트하세요:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 시작하기 전에 알면 좋은 것들
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 💰 **완전 무료** | 모든 도구는 오픈 소스, 모든 API는 무료입니다. 유일한 비용은 서버 프록시(월 $1)일 수 있습니다 — 로컬 컴퓨터에서는 불필요 |
|
||||||
|
| 🔒 **프라이버시 안전** | Cookie는 로컬에 유지됩니다. 업로드되지 않습니다. 완전 오픈 소스 — 언제든지 감사 가능 |
|
||||||
|
| 🔄 **최신 상태 유지** | 업스트림 도구(yt-dlp, twitter-cli, rdt-cli, Jina Reader 등)를 추적하고 정기적으로 업데이트 |
|
||||||
|
| 🤖 **모든 에이전트와 호환** | Claude Code, OpenClaw, Cursor, Windsurf... 명령을 실행할 수 있는 모든 에이전트 |
|
||||||
|
| 🩺 **내장 진단 도구** | `agent-reach doctor` — 하나의 명령으로 작동 항목, 작동하지 않는 항목, 수정 방법 표시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 지원 플랫폼
|
||||||
|
|
||||||
|
| 플랫폼 | 기능 | 설정 | 참고 |
|
||||||
|
|----------|-------------|:-----:|-------|
|
||||||
|
| 🌐 **Web** | 읽기 | 없음 | 모든 URL → 깨끗한 Markdown ([Jina Reader](https://github.com/jina-ai/reader) ⭐9.8K) |
|
||||||
|
| 🐦 **Twitter/X** | 읽기 · 검색 | Cookie | Cookie로 검색, 타임라인, 트윗 읽기, 아티클 읽기 가능 ([twitter-cli](https://github.com/public-clis/twitter-cli)) |
|
||||||
|
| 📕 **XiaoHongShu** | 읽기 · 검색 · **게시글 작성 · 댓글 · 좋아요** | Cookie | `pipx install xiaohongshu-cli` + `xhs login` ([xhs-cli](https://github.com/jackwener/xiaohongshu-cli)) |
|
||||||
|
| 🎵 **Douyin** | 비디오 파싱 · 워터마크 없는 다운로드 | mcporter | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) 통해, 로그인 불필요 |
|
||||||
|
| 💼 **LinkedIn** | Jina Reader (공개 페이지) | Cookie | 전체 프로필, 회사, 채용 공고 검색 가능. 에이전트에 "LinkedIn 설정 도와줘"라고 말하세요 |
|
||||||
|
| 💬 **WeChat Articles** | 검색 + 읽기 | 없음 | Exa를 통한 WeChat 공식 계정 게시글 검색 + 읽기 (설정 없음) + 선택적 [Camoufox](https://github.com/daijro/camoufox) |
|
||||||
|
| 📰 **Weibo** | 인기 · 검색 · 피드 · 댓글 | 없음 | 핫 검색, 콘텐츠/사용자/주제 검색, 피드, 댓글 ([mcp-server-weibo](https://github.com/Panniantong/mcp-server-weibo)) |
|
||||||
|
| 💻 **V2EX** | 인기 주제 · 노드 주제 · 주제 상세 + 답글 · 사용자 프로필 | 없음 | 공개 JSON API, 인증 없음. 기술 커뮤니티 콘텐츠에 적합 |
|
||||||
|
| 📈 **Xueqiu (雪球)** | 주식 시세 · 검색 · 인기 글 · 인기 종목 | 브라우저 Cookie | 에이전트에 "Xueqiu 설정 도와줘"라고 말하세요 |
|
||||||
|
| 🎙️ **Xiaoyuzhou Podcast** | 음성 변환 | 무료 API key | Groq Whisper를 통한 팟캐스트 오디오 → 전체 텍스트 변환 (무료) |
|
||||||
|
| 🔍 **Web Search** | 검색 | 자동 설정 | 설치 시 자동 설정, 무료, API key 불필요 ([Exa](https://exa.ai) via [mcporter](https://github.com/nicepkg/mcporter)) |
|
||||||
|
| 📦 **GitHub** | 읽기 · 검색 | 없음 | [gh CLI](https://cli.github.com) 기반. 공개 저장소는 즉시 사용 가능. `gh auth login`으로 Fork, Issue, PR 기능 활성화 |
|
||||||
|
| 📺 **YouTube** | 읽기 · **검색** | 없음 | 자막 + 1800+ 비디오 사이트 검색 ([yt-dlp](https://github.com/yt-dlp/yt-dlp) ⭐148K) |
|
||||||
|
| 📺 **Bilibili** | 읽기 · **검색** | 없음 / 프록시 | 비디오 정보 + 자막 + 검색. 로컬은 바로 작동, 서버는 프록시 필요 ([yt-dlp](https://github.com/yt-dlp/yt-dlp)) |
|
||||||
|
| 📡 **RSS** | 읽기 | 없음 | 모든 RSS/Atom 피드 ([feedparser](https://github.com/kurtmckee/feedparser) ⭐2.3K) |
|
||||||
|
| 📖 **Reddit** | 검색 · 읽기 | Cookie | 2024년부터 인증 필요 — 설치 후 `rdt login` 실행 ([rdt-cli](https://github.com/public-clis/rdt-cli)) |
|
||||||
|
|
||||||
|
> **설정 단계:** 없음 = 설치 후 바로 사용 · 자동 = 설치 시 처리 · mcporter = MCP 서비스 필요 · Cookie = 브라우저에서 내보내기 · 프록시 = 월 $1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 빠른 시작
|
||||||
|
|
||||||
|
이 명령을 AI 에이전트(Claude Code, OpenClaw, Cursor 등)에 입력하세요:
|
||||||
|
|
||||||
|
```
|
||||||
|
Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
|
||||||
|
```
|
||||||
|
|
||||||
|
에이전트가 자동으로 설치하고, 환경을 감지하고, 준비된 항목을 알려줍니다.
|
||||||
|
|
||||||
|
> 🔄 **이미 설치하셨나요?** 한 번에 업데이트:
|
||||||
|
> ```
|
||||||
|
> Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
> ```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>수동 설치</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install https://github.com/Panniantong/agent-reach/archive/main.zip
|
||||||
|
agent-reach install --env=auto
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Skill로 설치 (Claude Code / OpenClaw / Skill을 지원하는 모든 에이전트)</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add Panniantong/Agent-Reach@agent-reach
|
||||||
|
```
|
||||||
|
|
||||||
|
Skill이 설치된 후, 에이전트는 `agent-reach` CLI 사용 가능 여부를 자동 감지하고 필요한 경우 설치합니다.
|
||||||
|
|
||||||
|
> `agent-reach install`을 통해 설치하면 Skill이 자동으로 등록됩니다 — 추가 단계 불필요.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 별도 설정 없이 바로 사용
|
||||||
|
|
||||||
|
별도의 설정이 필요 없습니다. 에이전트에게 요청하기만 하면 됩니다:
|
||||||
|
|
||||||
|
- "이 링크 읽어줘" → 모든 웹 페이지에 대해 `curl https://r.jina.ai/URL`
|
||||||
|
- "이 GitHub 저장소는 무엇인가요?" → `gh repo view owner/repo`
|
||||||
|
- "이 비디오는 무엇을 다루나요?" → 자막을 위해 `yt-dlp --dump-json URL`
|
||||||
|
- "이 트윗 읽어줘" → `twitter tweet URL`
|
||||||
|
- "이 RSS 구독해줘" → 피드 파싱을 위해 `feedparser`
|
||||||
|
- "GitHub에서 LLM 프레임워크 검색" → `gh search repos "LLM framework"`
|
||||||
|
|
||||||
|
**기억할 명령이 없습니다.** 에이전트가 SKILL.md를 읽고 무엇을 호출할지 알고 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 필요할 때 설정
|
||||||
|
|
||||||
|
사용하지 않나요? 설정하지 마세요. 모든 단계는 선택 사항입니다.
|
||||||
|
|
||||||
|
### 🍪 Cookies — 무료, 2분
|
||||||
|
|
||||||
|
에이전트에 "Twitter 쿠키 설정 도와줘"라고 말하세요 — 브라우저에서 내보내는 과정을 안내해 줍니다. 로컬 컴퓨터는 자동으로 가져올 수 있습니다.
|
||||||
|
|
||||||
|
### 🌐 Proxy — 월 $1, 서버 전용
|
||||||
|
|
||||||
|
Bilibili은 서버 IP를 차단합니다. 프록시를 가져오세요([Webshare](https://webshare.io) 추천, 월 $1)하고 주소를 에이전트에 보내세요.
|
||||||
|
|
||||||
|
> Reddit은 이제 프록시 없이 rdt-cli를 통해 무료로 작동합니다. 로컬 컴퓨터는 Bilibili에도 프록시가 필요 없습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 한눈에 보는 상태
|
||||||
|
|
||||||
|
```
|
||||||
|
$ agent-reach doctor
|
||||||
|
|
||||||
|
👁️ Agent Reach 상태
|
||||||
|
========================================
|
||||||
|
|
||||||
|
✅ 사용 가능:
|
||||||
|
✅ GitHub 저장소 및 코드 — 공개 저장소 읽기 및 검색 가능
|
||||||
|
✅ Twitter/X 트윗 — 읽기 가능. Cookie로 검색 및 게시 가능
|
||||||
|
✅ YouTube 비디오 자막 — yt-dlp
|
||||||
|
⚠️ Bilibili 비디오 정보 — 서버 IP가 차단될 수 있음, 프록시 설정
|
||||||
|
✅ RSS/Atom 피드 — feedparser
|
||||||
|
✅ 웹 페이지 (모든 URL) — Jina Reader API
|
||||||
|
|
||||||
|
🔍 검색 (무료 Exa key로 잠금 해제):
|
||||||
|
⬜ 웹 시맨틱 검색 — exa.ai에서 무료 key 발급
|
||||||
|
|
||||||
|
🔧 설정 가능:
|
||||||
|
✅ Reddit 글 및 댓글 — rdt-cli를 통한 검색 및 읽기 (무료, 프록시 없음)
|
||||||
|
⬜ XiaoHongShu 노트 — 쿠키 필요. 브라우저에서 내보내기
|
||||||
|
|
||||||
|
상태: 6/9 채널 사용 가능
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 설계 철학
|
||||||
|
|
||||||
|
**Agent Reach는 스캐폴딩(scaffolding) 도구이지, 프레임워크가 아닙니다.**
|
||||||
|
|
||||||
|
새 에이전트를 실행할 때마다 도구를 찾고, 의존성을 설치하고, 설정을 디버깅하는 데 시간을 보내게 됩니다 — Twitter는 무엇으로 읽나요? Reddit 차단을 어떻게 우회하나요? YouTube 자막은 어떻게 추출하나요? 매번 동일한 작업을 반복해야 합니다.
|
||||||
|
|
||||||
|
Agent Reach는 한 가지 간단한 작업을 수행합니다: **도구 선택 및 설정 결정을 대신 해줍니다.**
|
||||||
|
|
||||||
|
설치 후, 에이전트는 업스트림 도구(twitter-cli, rdt-cli, xhs-cli, yt-dlp, mcporter, gh CLI 등)를 직접 호출합니다 — 중간에 래퍼 계층이 없습니다.
|
||||||
|
|
||||||
|
### 🔌 모든 채널은 플러그인 가능
|
||||||
|
|
||||||
|
각 플랫폼은 업스트림 도구에 매핑됩니다. **마음에 안 드나요? 교체하세요.**
|
||||||
|
|
||||||
|
```
|
||||||
|
channels/
|
||||||
|
├── web.py → Jina Reader ← Firecrawl, Crawl4AI로 교체...
|
||||||
|
├── twitter.py → twitter-cli ← 공식 API로 교체...
|
||||||
|
├── youtube.py → yt-dlp ← YouTube API, Whisper로 교체...
|
||||||
|
├── github.py → gh CLI → REST API, PyGithub로 교체...
|
||||||
|
├── bilibili.py → yt-dlp → bilibili-api로 교체...
|
||||||
|
├── reddit.py → rdt-cli → 검색 + 읽기, cookie 인증 필요
|
||||||
|
├── xiaohongshu.py → mcporter MCP ← 다른 XHS 도구로 교체...
|
||||||
|
├── douyin.py → mcporter MCP ← 다른 Douyin 도구로 교체...
|
||||||
|
├── linkedin.py → linkedin-mcp ← LinkedIn API로 교체...
|
||||||
|
├── rss.py → feedparser ← atoma로 교체...
|
||||||
|
├── exa_search.py → mcporter MCP ← Tavily, SerpAPI로 교체...
|
||||||
|
└── __init__.py → 채널 레지스트리 (doctor 검사용)
|
||||||
|
```
|
||||||
|
|
||||||
|
각 채널 파일은 업스트림 도구가 설치되어 작동하는지만 확인합니다(`agent-reach doctor`용 `check()` 메서드). 실제 읽기 및 검색은 업스트림 도구를 직접 호출하여 수행합니다.
|
||||||
|
|
||||||
|
### 현재 도구 선택
|
||||||
|
|
||||||
|
| 시나리오 | 도구 | 이유 |
|
||||||
|
|----------|------|-----|
|
||||||
|
| 웹 페이지 읽기 | [Jina Reader](https://github.com/jina-ai/reader) | 9.8K stars, 무료, API key 불필요 |
|
||||||
|
| 트윗 읽기 | [twitter-cli](https://github.com/public-clis/twitter-cli) | 2.1K stars, cookie 인증, 검색/읽기/타임라인/글 |
|
||||||
|
| Reddit | [rdt-cli](https://github.com/public-clis/rdt-cli) | 304 stars, cookie 인증, 검색 + 전체 글 + 댓글 |
|
||||||
|
| 비디오 자막 + 검색 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 154K stars, YouTube + Bilibili + 1800 사이트 |
|
||||||
|
| Bilibili 향상 | [bili-cli](https://github.com/public-clis/bilibili-cli) | 590 stars, 인기/순위/검색/피드 |
|
||||||
|
| 웹 검색 | [Exa](https://exa.ai) via [mcporter](https://github.com/nicobailon/mcporter) | AI 시맨틱 검색, MCP 통합, API key 불필요 |
|
||||||
|
| GitHub | [gh CLI](https://cli.github.com) | 공식 도구, 인증 후 전체 API |
|
||||||
|
| RSS 읽기 | [feedparser](https://github.com/kurtmckee/feedparser) | Python 생태계 표준, 2.3K stars |
|
||||||
|
| XiaoHongShu | [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) | 1.5K stars, pipx 설치, 검색/읽기/댓글/게시 |
|
||||||
|
| Douyin | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP 서버, 로그인 불필요, 비디오 파싱 + 워터마크 없는 다운로드 |
|
||||||
|
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | 1.2K stars, MCP 서버, 브라우저 자동화 |
|
||||||
|
| WeChat Articles | [Exa](https://exa.ai) (검색 + 읽기) + [Camoufox](https://github.com/daijro/camoufox) (선택) | 설정 없이 검색 + 전체 글 읽기 |
|
||||||
|
| Weibo | `mcporter` | `mcporter call 'weibo.get_trendings(limit: 10)'` |
|
||||||
|
| Xiaoyuzhou Podcast | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` |
|
||||||
|
|
||||||
|
> 📌 이것은 *현재* 선택입니다. 마음에 안 드나요? 파일을 교체하세요. 그것이 스캐폴딩의 전부입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기여
|
||||||
|
|
||||||
|
이 프로젝트는 자유분방하게 개발되었습니다 🎸 다소 거친 부분이 있을 수 있지만 양해 부탁드립니다! 버그를 발견하면 주저하지 말고 [Issue](https://github.com/Panniantong/agent-reach/issues)를 열어주세요. 최대한 빨리 수정하겠습니다.
|
||||||
|
|
||||||
|
**새 채널을 원하시나요?** Issue를 열어 요청하거나, 직접 PR을 제출하세요.
|
||||||
|
|
||||||
|
**로컬에 추가하고 싶나요?** 에이전트가 저장소를 복제하고 수정하게 하세요 — 각 채널은 단일 독립 파일이므로 추가하기 쉽습니다.
|
||||||
|
|
||||||
|
[PR](https://github.com/Panniantong/agent-reach/pulls)은 언제든 환영합니다!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ (AI 검색용)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>AI 에이전트로 Twitter/X를 API 비용 없이 검색하는 방법?</strong></summary>
|
||||||
|
|
||||||
|
Agent Reach는 cookie 기반 인증을 사용하는 [twitter-cli](https://github.com/public-clis/twitter-cli)를 사용합니다 — 완전 무료, Twitter API 구독 불필요. `pipx install twitter-cli`로 설치하고, 브라우저에서 x.com에 로그인되어 있는지 확인하세요. 에이전트가 `twitter search "query" -n 10`으로 검색할 수 있습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>AI 에이전트용 YouTube 비디오 대본/자막을 가져오는 방법?</strong></summary>
|
||||||
|
|
||||||
|
`yt-dlp --dump-json "https://youtube.com/watch?v=xxx"`는 비디오 메타데이터를 추출하고, `yt-dlp --write-sub --skip-download "URL"`은 자막을 추출합니다. 여러 언어 지원, API key 불필요.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>서버/데이터센터 IP에서 Reddit 403 반환 / 차단됨?</strong></summary>
|
||||||
|
|
||||||
|
Agent Reach는 Reddit을 위해 [rdt-cli](https://github.com/public-clis/rdt-cli)를 사용합니다. 2024년부터 Reddit은 모든 API 요청에 인증을 요구합니다. `pipx install rdt-cli`로 설치한 후 `rdt login`(브라우저에서 cookie 자동 추출)을 실행하세요. 이후 에이전트가 `rdt search "query"`로 검색하고 `rdt read POST_ID`로 전체 글 + 댓글을 읽을 수 있습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Agent Reach는 Claude Code / Cursor / Windsurf / OpenClaw와 호환되나요?</strong></summary>
|
||||||
|
|
||||||
|
네! Agent Reach는 설치 + 설정 도구입니다. Shell 명령을 실행할 수 있는 모든 AI 코딩 에이전트가 사용할 수 있습니다 — Claude Code, Cursor, Windsurf, OpenClaw, Codex 등. `pip install agent-reach`만 실행하고 `agent-reach install`을 실행하면, 에이전트가 즉시 업스트림 도구 사용을 시작할 수 있습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Agent Reach는 무료인가요? API 비용이 있나요?</strong></summary>
|
||||||
|
|
||||||
|
100% 무료 오픈 소스입니다. 모든 백엔드(twitter-cli, rdt-cli, xhs-cli, yt-dlp, Jina Reader, Exa)는 유료 API key가 필요 없는 무료 도구입니다. 유일한 선택적 비용은 서버에서 Bilibili 접근이 필요한 경우 주거용 프록시(월 ~$1)입니다. Reddit은 프록시 없이 rdt-cli를 통해 무료로 작동합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>웹 스크래핑용 Twitter API의 무료 대안?</strong></summary>
|
||||||
|
|
||||||
|
Agent Reach는 cookie 인증을 통해 Twitter에 접근하는 twitter-cli를 사용합니다 — 브라우저 세션과 동일. API 요금 없음, 속도 제한 등급 없음, 개발자 계정 불필요. 검색, 트윗 읽기, 프로필 읽기, 타임라인 지원.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>XiaoHongShu / 小红书 콘텐츠를 프로그래밍 방식으로 읽는 방법?</strong></summary>
|
||||||
|
|
||||||
|
`pipx install xiaohongshu-cli`를 설치한 다음 `xhs login`(브라우저에서 cookie 자동 추출)을 실행하세요. 에이전트가 `xhs search "query"`로 노트를 검색하고, `xhs read NOTE_ID`로 상세 정보를 읽고, `xhs comments NOTE_ID`로 댓글을 볼 수 있습니다. Docker 불필요.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>AI 에이전트로 Douyin / 抖音 비디오를 파싱하는 방법?</strong></summary>
|
||||||
|
|
||||||
|
douyin-mcp-server를 설치한 다음, 에이전트가 `mcporter call 'douyin.parse_douyin_video_info(share_link: "share_url")'`를 사용하여 비디오 정보를 파싱하고 워터마크 없는 다운로드 링크를 가져올 수 있습니다. 로그인 불필요 — Douyin 링크를 공유하기만 하면 됩니다. https://github.com/yzfly/douyin-mcp-server 참조
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>하나의 MCP로 Douyin과 XiaoHongShu 모두에서 대본을 추출하는 방법?</strong></summary>
|
||||||
|
|
||||||
|
다음을 처리할 수 있는 하나의 MCP 서버가 필요한 경우:
|
||||||
|
|
||||||
|
- Douyin 비디오
|
||||||
|
- XiaoHongShu 비디오 노트
|
||||||
|
- XiaoHongShu 이미지 노트
|
||||||
|
|
||||||
|
그리고 직접 `script.md` + `info.json`을 작성하려면, 기존 `douyin` mcporter 별칭을 다음으로 변경할 수 있습니다:
|
||||||
|
|
||||||
|
- https://github.com/JNHFlow21/social-post-extractor-mcp
|
||||||
|
|
||||||
|
다음과 호환성을 유지합니다:
|
||||||
|
|
||||||
|
- `parse_douyin_video_info`
|
||||||
|
- `get_douyin_download_link`
|
||||||
|
- `extract_douyin_text`
|
||||||
|
|
||||||
|
그리고 통합 도구를 추가합니다:
|
||||||
|
|
||||||
|
- `parse_social_post_info`
|
||||||
|
- `extract_social_post_script`
|
||||||
|
|
||||||
|
이것은 에이전트 워크플로우가 "링크를 붙여넣고, 스크립트 파일을 받음"일 때 유용합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 크레딧
|
||||||
|
|
||||||
|
[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [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)
|
||||||
|
|
||||||
|
## 연락처
|
||||||
|
|
||||||
|
- 📧 **이메일:** pnt01@foxmail.com
|
||||||
|
- 🐦 **Twitter/X:** [@Neo_Reidlab](https://x.com/Neo_Reidlab)
|
||||||
|
|
||||||
|
협력이나 질문은 WeChat에 추가해주세요 — 커뮤니티 그룹에 초대해 드리겠습니다:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="wechat-group-qr.jpg" width="280" alt="WeChat QR">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> 버그 보고 및 기능 요청은 [GitHub Issues](https://github.com/Panniantong/Agent-Reach/issues)를 이용해주세요 — 추적이 더 수월합니다.
|
||||||
|
|
||||||
|
## 라이선스
|
||||||
|
|
||||||
|
[MIT](../LICENSE)
|
||||||
|
|
||||||
|
## 관련 프로젝트
|
||||||
|
|
||||||
|
[OpenClaw on Tencent Cloud](https://www.tencentcloud.com/act/pro/intl-openclaw?referral_code=G76Y819A&lang=en&pg=) — Tencent Cloud에서 원클릭 OpenClaw: 채팅으로 Agent Reach를 연결하고 인터넷 기능을 활성화하세요.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#Panniantong/Agent-Reach&Date)
|
||||||
+147
-103
@@ -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 (xreach 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 (OpenCLI, twitter-cli, bili-cli, rdt-cli, yt-dlp, mcporter, gh CLI, etc.) — Agent Reach is the selector, installer, health checker and router, never a wrapper.
|
||||||
|
|
||||||
### ⚠️ Boundaries
|
### ⚠️ Boundaries
|
||||||
|
|
||||||
@@ -30,47 +30,95 @@ When fixing issues, stay within these limits:
|
|||||||
- **DO NOT** modify system files outside `~/.agent-reach/`
|
- **DO NOT** modify system files outside `~/.agent-reach/`
|
||||||
- **DO NOT** install packages not listed in this guide
|
- **DO NOT** install packages not listed in this guide
|
||||||
- **DO NOT** disable firewalls, security settings, or system protections
|
- **DO NOT** disable firewalls, security settings, or system protections
|
||||||
|
- **DO NOT** clone repos, create files, or run commands inside the agent workspace / working directory
|
||||||
- If something requires elevated permissions, **tell the user** what's needed and let them decide
|
- If something requires elevated permissions, **tell the user** what's needed and let them decide
|
||||||
|
|
||||||
### Step 1: Install
|
### 📁 Directory Rules
|
||||||
|
|
||||||
**Default (fully automatic):**
|
All Agent Reach files go in dedicated directories — **never in the agent workspace**:
|
||||||
|
|
||||||
|
| Purpose | Directory | Example |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| Config & tokens | `~/.agent-reach/` | `~/.agent-reach/config.json` |
|
||||||
|
| Upstream tool repos | `~/.agent-reach/tools/` | `~/.agent-reach/tools/xiaoyuzhou/` |
|
||||||
|
| Temporary files | `/tmp/` | `/tmp/yt-dlp-output/` |
|
||||||
|
| Skills | `~/.openclaw/skills/agent-reach/` | SKILL.md |
|
||||||
|
|
||||||
|
**Why?** If you clone repos or create files in the workspace, it pollutes the user's project directory and can break their agent over time. Keep the workspace clean.
|
||||||
|
|
||||||
|
### Step 1: Install the basics
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 推荐:pipx(最省心)
|
||||||
|
pipx install https://github.com/Panniantong/agent-reach/archive/main.zip
|
||||||
|
agent-reach install --env=auto
|
||||||
|
|
||||||
|
# 如果你的 Python 来自 Homebrew / 遇到 PEP 668(externally-managed-environment)
|
||||||
|
# 用虚拟环境安装:
|
||||||
|
python3 -m venv ~/.agent-reach-venv
|
||||||
|
source ~/.agent-reach-venv/bin/activate
|
||||||
pip install https://github.com/Panniantong/agent-reach/archive/main.zip
|
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, xreach), configures Exa search, detects environment, and tests all channels.
|
> 💡 **Windows / Microsoft Store Python alias?**
|
||||||
|
> 如果 `python3 --version` 打开 Microsoft Store,或 `where python3` 指向
|
||||||
|
> `...\AppData\Local\Microsoft\WindowsApps\python3.exe`,说明 `python3` 是 Windows
|
||||||
|
> 的 Store alias,不是可用的 Python 安装。请改用 Python Launcher `py -3`,或实际安装目录里的 `python.exe`。
|
||||||
|
>
|
||||||
|
> PowerShell 示例:
|
||||||
|
> ```powershell
|
||||||
|
> py -3 -m venv $env:USERPROFILE\.agent-reach-venv
|
||||||
|
> $env:USERPROFILE\.agent-reach-venv\Scripts\Activate.ps1
|
||||||
|
> python -m pip install https://github.com/Panniantong/agent-reach/archive/main.zip
|
||||||
|
> agent-reach install --env=auto
|
||||||
|
> ```
|
||||||
|
|
||||||
**Safe mode (no automatic system changes):**
|
This installs core infrastructure (gh CLI, Node.js, mcporter, Exa search, yt-dlp config) and activates these zero-config channels:
|
||||||
|
|
||||||
If the user asked for safe mode, or you're on a production machine:
|
- Web (Jina Reader), YouTube, GitHub, RSS, Exa Search, V2EX, Bilibili (basic)
|
||||||
|
|
||||||
|
> 💡 **macOS / Homebrew Python 提示 `externally-managed-environment`?**
|
||||||
|
> 这是 PEP 668 保护,不是 Agent Reach 本身的问题。优先用 `pipx install ...`,或先创建 `venv` 再安装。
|
||||||
|
|
||||||
|
**Safe mode / Dry run:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install https://github.com/Panniantong/agent-reach/archive/main.zip
|
agent-reach install --env=auto --safe # Check only, no auto-install
|
||||||
agent-reach install --env=auto --safe
|
agent-reach install --env=auto --dry-run # Preview what would be done
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe mode checks what's installed and tells you what's missing, but doesn't auto-install system packages. You can then install each dependency manually.
|
### Step 2: Ask the user which optional channels they want
|
||||||
|
|
||||||
**Dry run (preview only):**
|
After installing the basics, **ask the user** which additional channels they need. Present this list:
|
||||||
|
|
||||||
|
> 基础渠道装好了!你现在可以让我搜网页、看 YouTube、读 GitHub 等。
|
||||||
|
>
|
||||||
|
> 还有这些可选渠道,你需要哪些?
|
||||||
|
>
|
||||||
|
> - 🌟 **OpenCLI**(桌面推荐)— 一次安装,小红书/Reddit/B站字幕/Twitter 备选全解锁(复用浏览器登录态,零配置;只需在 Chrome 商店点一次"添加扩展")
|
||||||
|
> - 🐦 **Twitter/X** — 搜推文、看时间线(需要登录 Cookie)
|
||||||
|
> - 📈 **雪球** — 股票行情、热门帖子(需要登录 Cookie)
|
||||||
|
> - 🎙️ **小宇宙播客** — 音频转文字(需要免费 Groq Key)
|
||||||
|
> - 📕 **小红书** — 搜索、阅读、评论(桌面走 OpenCLI;服务器用 xiaohongshu-mcp 扫码)
|
||||||
|
> - 📖 **Reddit** — 搜索和阅读帖子(必须登录态:桌面 OpenCLI 或 rdt-cli + Cookie)
|
||||||
|
> - 📺 **B站完整版** — 热门、排行、搜索、视频详情(bili-cli,无需登录)
|
||||||
|
> - 💼 **LinkedIn** — Profile、职位搜索
|
||||||
|
>
|
||||||
|
> 告诉我你要哪些,比如"帮我装小红书和 Twitter"。或者说"全部装"。
|
||||||
|
|
||||||
|
Based on the user's choice, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-reach install --env=auto --dry-run
|
agent-reach install --env=auto --channels=opencli,xiaohongshu # Example: desktop user chose XHS (OpenCLI-backed)
|
||||||
|
agent-reach install --env=auto --channels=all # User wants everything
|
||||||
```
|
```
|
||||||
|
|
||||||
Shows what would be done without making any changes.
|
Supported channel names: `opencli`, `twitter`, `xiaoyuzhou`, `xueqiu`, `xiaohongshu`, `reddit`, `bilibili`, `linkedin`, `all`
|
||||||
|
|
||||||
**Read the output carefully**, then run:
|
### Step 3: Fix what's broken
|
||||||
|
|
||||||
```bash
|
Run `agent-reach doctor` and check the output.
|
||||||
agent-reach doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Fix what's broken
|
|
||||||
|
|
||||||
`agent-reach doctor` shows you which channels are active and which aren't.
|
|
||||||
|
|
||||||
Try to get as many channels to ✅ as possible. If something failed during install or shows as ❌/⚠️ in doctor, diagnose the problem and try to fix it — but stay within the boundaries above. If a fix requires elevated permissions or system changes, ask the user first.
|
Try to get as many channels to ✅ as possible. If something failed during install or shows as ❌/⚠️ in doctor, diagnose the problem and try to fix it — but stay within the boundaries above. If a fix requires elevated permissions or system changes, ask the user first.
|
||||||
|
|
||||||
@@ -86,13 +134,13 @@ Some channels need credentials only the user can provide. Based on the doctor ou
|
|||||||
|
|
||||||
> 🍪 **Cookie 导入(所有需要登录的平台通用):**
|
> 🍪 **Cookie 导入(所有需要登录的平台通用):**
|
||||||
>
|
>
|
||||||
> 所有需要 Cookie 的平台(Twitter、小红书等),**优先使用 Cookie-Editor 导入**,这是最简单最可靠的方式:
|
> 所有需要 Cookie 的平台(Twitter、小红书、雪球等),**优先使用 Cookie-Editor 导入**,这是最简单最可靠的方式:
|
||||||
> 1. 用户在自己的浏览器上登录对应平台
|
> 1. 用户在自己的浏览器上登录对应平台
|
||||||
> 2. 安装 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) Chrome 插件
|
> 2. 安装 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) Chrome 插件
|
||||||
> 3. 点击插件 → Export → Header String
|
> 3. 点击插件 → Export → Header String
|
||||||
> 4. 把导出的字符串发给 Agent
|
> 4. 把导出的字符串发给 Agent
|
||||||
>
|
>
|
||||||
> **本地电脑用户**也可以用 `agent-reach configure --from-browser chrome` 一键自动提取(支持 Twitter + 小红书)。
|
> **本地电脑用户**也可以用 `agent-reach configure --from-browser chrome` 一键自动提取(支持 Twitter + 小红书 + 雪球)。
|
||||||
|
|
||||||
**Twitter search & posting:**
|
**Twitter search & posting:**
|
||||||
> "To unlock Twitter search, I need your Twitter cookies. Install the Cookie-Editor Chrome extension, go to x.com/twitter.com, click the extension → Export → Header String, and paste it to me."
|
> "To unlock Twitter search, I need your Twitter cookies. Install the Cookie-Editor Chrome extension, go to x.com/twitter.com, click the extension → Export → Header String, and paste it to me."
|
||||||
@@ -103,71 +151,92 @@ agent-reach configure twitter-cookies "PASTED_STRING"
|
|||||||
|
|
||||||
> **代理说明(中国大陆等需要翻墙的网络环境):**
|
> **代理说明(中国大陆等需要翻墙的网络环境):**
|
||||||
>
|
>
|
||||||
> xreach CLI 使用 Node.js 原生 `fetch()`,默认不走系统代理(`HTTP_PROXY`/`HTTPS_PROXY`)。
|
> twitter-cli 和 rdt-cli 使用 Python,在需要代理的网络环境下可通过环境变量配置代理。
|
||||||
> Agent Reach 已内置解决方案:安装时会自动安装 `undici`(`npm install -g undici`),
|
|
||||||
> 运行 xreach 时自动注入 `EnvHttpProxyAgent`,让 fetch 走用户配置的代理。
|
|
||||||
>
|
>
|
||||||
> **你(Agent)需要做的:**
|
> **你(Agent)需要做的:**
|
||||||
> 1. 确认 `undici` 已全局安装:`npm list -g undici`,没有就装:`npm install -g undici`
|
> 1. 确认用户配了代理:`agent-reach configure proxy http://user:pass@ip:port`
|
||||||
> 2. 确认用户配了代理:`agent-reach configure proxy http://user:pass@ip:port`
|
> 2. 设置环境变量:`export HTTP_PROXY="..." HTTPS_PROXY="..."`
|
||||||
> 3. Agent Reach 会自动处理剩下的,不需要用户做额外操作
|
> 3. Agent Reach 会自动处理剩下的,不需要用户做额外操作
|
||||||
>
|
>
|
||||||
> 如果用户报告 "fetch failed",参考 [troubleshooting.md](troubleshooting.md)
|
> 如果用户报告 "fetch failed",参考 [troubleshooting.md](troubleshooting.md)
|
||||||
|
|
||||||
**Reddit & Bilibili full access (server users):**
|
**Reddit (login is mandatory — no zero-config path):**
|
||||||
> "Reddit and Bilibili block server IPs. To unlock full access, I need a residential proxy. You can get one at https://webshare.io ($1/month). Send me the proxy address."
|
> Reddit 的匿名接口已被封、官方 API 需人工审批。桌面用户首选 OpenCLI(浏览器里登录过 reddit.com 即可用);服务器/存量用户用 rdt-cli:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-reach configure proxy http://user:pass@ip:port
|
# PyPI 落后,从 GitHub 装(与代码内 _RDT_GIT_SOURCE 同一钉定版本)
|
||||||
|
pipx install 'git+https://github.com/public-clis/rdt-cli.git@5e4fb3720d5c174e976cd425ccc3b879d52cac66'
|
||||||
|
rdt login # 自动提取浏览器 Cookie;服务器无浏览器时按 doctor 提示手动写 Cookie
|
||||||
```
|
```
|
||||||
|
|
||||||
**XiaoHongShu / 小红书 (需要 Docker):**
|
> 中国大陆访问 Reddit 需要代理;服务器 IP 被风控时可配住宅代理(如 https://webshare.io,约 $1/月):
|
||||||
> "小红书需要一个 MCP 服务。需要你的机器上有 Docker。安装好 Docker 后我来搞定剩下的。"
|
> ```bash
|
||||||
|
> agent-reach configure proxy http://user:pass@ip:port
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**XiaoHongShu / 小红书(多后端,按环境选):**
|
||||||
|
|
||||||
|
> **桌面电脑(推荐 OpenCLI):**
|
||||||
|
> "小红书走 OpenCLI——复用你浏览器里的登录态,平时刷过小红书就直接能用,零配置。"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name xiaohongshu-mcp -p 18060:18060 xpzouying/xiaohongshu-mcp
|
agent-reach install --channels opencli
|
||||||
mcporter config add xiaohongshu http://localhost:18060/mcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 如果在服务器上,建议加代理避免 IP 风控:
|
> 装完后引导用户做唯一一步手动操作(Chrome 安全限制,无法代劳):
|
||||||
> `docker run -d --name xiaohongshu-mcp -p 18060:18060 -e XHS_PROXY=http://user:pass@ip:port xpzouying/xiaohongshu-mcp`
|
> 1. 打开 https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk
|
||||||
|
> 2. 点「添加至 Chrome」
|
||||||
|
> 3. 运行 `opencli doctor` 验证(显示 Extension: connected 即成功)
|
||||||
>
|
>
|
||||||
> **登录方式(优先用 Cookie-Editor,最简单):**
|
> **服务器 / 无桌面环境(xiaohongshu-mcp):**
|
||||||
> 1. 用户在自己的浏览器登录小红书 (xiaohongshu.com)
|
> 1. 从 https://github.com/xpzouying/xiaohongshu-mcp/releases 下载对应平台 binary 到 `~/.agent-reach/tools/`
|
||||||
> 2. 用 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) 插件导出 Cookie(Header String 格式)
|
> 2. 启动服务(首次运行会自动下载约 150MB 无头浏览器,耐心等完成)
|
||||||
> 3. 把 Cookie 字符串发给 Agent
|
> 3. 让用户扫码登录(agent 调 `get_login_qrcode` 工具取二维码)
|
||||||
> 4. Agent 将 Cookie 写入 MCP 服务的 cookie 文件完成登录
|
> 4. 接入:`mcporter config add xiaohongshu http://localhost:18060/mcp`
|
||||||
|
> 5. 调用时务必带 `--timeout 120000`
|
||||||
>
|
>
|
||||||
> **备选:** 本地电脑如果有浏览器,也可以打开 http://localhost:18060 扫码登录。
|
> **存量用户(xhs-cli):** 已装好的 xhs-cli 继续作为备选后端工作(上游 2026-03 起停更,不推荐新装)。`xhs login` 自动提取浏览器 Cookie;失败时用 Cookie-Editor 导出后:
|
||||||
|
> ```bash
|
||||||
|
> agent-reach configure xhs-cookies "key1=val1; key2=val2; ..."
|
||||||
|
> ```
|
||||||
|
|
||||||
**抖音 / Douyin (douyin-mcp-server):**
|
**雪球 / Xueqiu (股票行情 + 热门帖子):**
|
||||||
> "抖音视频解析需要一个 MCP 服务。安装 douyin-mcp-server 后即可解析视频、获取无水印下载链接。"
|
> "雪球需要登录后的 Cookie。请先在 Chrome 里登录 xueqiu.com,然后运行:"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 安装
|
agent-reach configure --from-browser chrome
|
||||||
pip install douyin-mcp-server
|
|
||||||
|
|
||||||
# 2. 启动 HTTP 服务(端口 18070)
|
|
||||||
# 方式一:用 uv(推荐)
|
|
||||||
git clone https://github.com/yzfly/douyin-mcp-server.git && cd douyin-mcp-server
|
|
||||||
uv sync && uv run python run_http.py
|
|
||||||
|
|
||||||
# 方式二:直接用 Python 启动
|
|
||||||
python -c "
|
|
||||||
from douyin_mcp_server.server import mcp
|
|
||||||
mcp.settings.host = '127.0.0.1'
|
|
||||||
mcp.settings.port = 18070
|
|
||||||
mcp.run(transport='streamable-http')
|
|
||||||
"
|
|
||||||
|
|
||||||
# 3. 注册到 mcporter
|
|
||||||
mcporter config add douyin http://localhost:18070/mcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 无需认证即可解析视频信息和获取下载链接。
|
> Cookie 会随其他平台一起自动提取。
|
||||||
> 如需 AI 语音识别提取文案功能,需要配置硅基流动 API Key(`export API_KEY="sk-xxx"`)。
|
|
||||||
|
**小宇宙播客 / Xiaoyuzhou Podcast (Groq Whisper):**
|
||||||
|
> "小宇宙播客转文字已默认安装,只需要一个免费的 Groq API Key。"
|
||||||
|
|
||||||
|
脚本已随 Agent Reach 自动安装,用户只需提供 Key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-reach configure groq-key gsk_xxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
> **获取 Groq API Key(免费、无需信用卡、30 秒搞定):**
|
||||||
|
> 1. 打开 https://console.groq.com
|
||||||
|
> 2. 用 Google/GitHub 账号登录(或注册)
|
||||||
|
> 3. 左侧菜单 → API Keys → Create API Key
|
||||||
|
> 4. 复制 Key(以 `gsk_` 开头),发给 Agent 即可
|
||||||
>
|
>
|
||||||
> 详见 https://github.com/yzfly/douyin-mcp-server
|
> **使用方式:**
|
||||||
|
> 用户发一个小宇宙链接给 Agent,Agent 自动调用:
|
||||||
|
> ```bash
|
||||||
|
> bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh https://www.xiaoyuzhoufm.com/episode/xxxxx
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 自动下载音频 → 转码切片 → Groq Whisper 转录 → 输出完整中文文字稿。
|
||||||
|
>
|
||||||
|
> **免费额度和限制:**
|
||||||
|
> - 每小时约 2 小时音频(7200 秒),超出后等 15 分钟自动恢复
|
||||||
|
> - 日常听几期播客完全够用
|
||||||
|
> - 转录质量高(Whisper large-v3),但不区分说话人
|
||||||
|
> - 2 小时以上的播客建议分批处理
|
||||||
|
|
||||||
**LinkedIn (可选 — linkedin-scraper-mcp):**
|
**LinkedIn (可选 — linkedin-scraper-mcp):**
|
||||||
> "LinkedIn 基本内容可通过 Jina Reader 读取。完整功能(Profile 详情、职位搜索)需要 linkedin-scraper-mcp。"
|
> "LinkedIn 基本内容可通过 Jina Reader 读取。完整功能(Profile 详情、职位搜索)需要 linkedin-scraper-mcp。"
|
||||||
@@ -208,35 +277,6 @@ pip install linkedin-scraper-mcp
|
|||||||
>
|
>
|
||||||
> 详见 https://github.com/stickerdaniel/linkedin-mcp-server
|
> 详见 https://github.com/stickerdaniel/linkedin-mcp-server
|
||||||
|
|
||||||
**Boss直聘 (可选 — mcp-bosszp):**
|
|
||||||
> "Boss直聘职位页面可直接读取。完整搜索和打招呼功能需要 mcp-bosszp。"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/mucsbr/mcp-bosszp.git && cd mcp-bosszp
|
|
||||||
pip install -r requirements.txt && playwright install chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
> **登录方式(手机 App 扫码):**
|
|
||||||
>
|
|
||||||
> mcp-bosszp 使用二维码登录,需要 Boss直聘 App 扫码。
|
|
||||||
>
|
|
||||||
> ```bash
|
|
||||||
> # 1. 启动 MCP 服务
|
|
||||||
> python boss_zhipin_fastmcp_v2.py
|
|
||||||
>
|
|
||||||
> # 2. 通过 MCP 触发登录(会生成二维码图片)
|
|
||||||
> mcporter call 'bosszhipin.start_login()'
|
|
||||||
>
|
|
||||||
> # 3. 浏览器打开二维码图片(服务器上用 http://服务器IP:8000/static/qrcode_xxx.png)
|
|
||||||
> # 4. 用 Boss直聘 App 扫码确认
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> 扫码成功后 session 自动保存,之后可以搜索职位和自动打招呼。
|
|
||||||
>
|
|
||||||
> 或用 Docker:`docker-compose up -d`
|
|
||||||
>
|
|
||||||
> 详见 https://github.com/mucsbr/mcp-bosszp
|
|
||||||
|
|
||||||
### Step 4: Final check
|
### Step 4: Final check
|
||||||
|
|
||||||
Run `agent-reach doctor` one final time and report the results to your user.
|
Run `agent-reach doctor` one final time and report the results to your user.
|
||||||
@@ -253,7 +293,7 @@ If the user agrees, create a **cron job** (daily, `sessionTarget: "isolated"`, `
|
|||||||
运行 agent-reach watch 命令。
|
运行 agent-reach watch 命令。
|
||||||
如果输出包含"全部正常",不需要通知用户,静默结束。
|
如果输出包含"全部正常",不需要通知用户,静默结束。
|
||||||
如果输出包含问题(❌ ⚠️)或新版本(🆕),把完整报告发给用户,并建议修复方案。
|
如果输出包含问题(❌ ⚠️)或新版本(🆕),把完整报告发给用户,并建议修复方案。
|
||||||
如果有新版本可用,问用户是否要升级(升级命令:pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip)。
|
如果有新版本可用,问用户是否要升级(把这句话发给用户的 Agent 即可完整更新:帮我更新 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md)。
|
||||||
```
|
```
|
||||||
|
|
||||||
If the user wants a different agent to handle it, let them choose.
|
If the user wants a different agent to handle it, let them choose.
|
||||||
@@ -264,28 +304,32 @@ If the user wants a different agent to handle it, let them choose.
|
|||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `agent-reach install --env=auto` | Full auto-setup (installs deps + configures) |
|
| `agent-reach install --env=auto` | Install core channels (lightweight, zero-config) |
|
||||||
|
| `agent-reach install --env=auto --channels=twitter,xiaohongshu` | Install core + optional channels |
|
||||||
|
| `agent-reach install --env=auto --channels=all` | Install everything |
|
||||||
| `agent-reach install --env=auto --safe` | Safe setup (no auto system changes) |
|
| `agent-reach install --env=auto --safe` | Safe setup (no auto system changes) |
|
||||||
| `agent-reach install --env=auto --dry-run` | Preview what would be done |
|
| `agent-reach install --env=auto --dry-run` | Preview what would be done |
|
||||||
| `agent-reach doctor` | Show channel status |
|
| `agent-reach doctor` | Show channel status |
|
||||||
| `agent-reach watch` | Quick health + update check (for scheduled tasks) |
|
| `agent-reach watch` | Quick health + update check (for scheduled tasks) |
|
||||||
| `agent-reach check-update` | Check for new versions |
|
| `agent-reach check-update` | Check for new versions |
|
||||||
| `agent-reach configure twitter-cookies "..."` | Unlock Twitter search + posting |
|
| `agent-reach configure twitter-cookies "..."` | Unlock Twitter search + posting |
|
||||||
| `agent-reach configure proxy URL` | Unlock Reddit + Bilibili on servers |
|
| `agent-reach configure proxy URL` | 保存代理地址(Agent 访问 Reddit/Twitter 等受限网络时读取它设置 HTTP_PROXY/HTTPS_PROXY,不是自动解锁开关) |
|
||||||
|
| `agent-reach configure groq-key gsk_xxx` | Unlock Xiaoyuzhou podcast transcription |
|
||||||
|
|
||||||
After installation, use upstream tools directly. See SKILL.md for the full command reference:
|
After installation, use upstream tools directly. See SKILL.md for the full command reference:
|
||||||
|
|
||||||
| Platform | Upstream Tool | Example |
|
| Platform | Upstream Tool | Example |
|
||||||
|----------|--------------|---------|
|
|----------|--------------|---------|
|
||||||
| Twitter/X | `xreach` | `xreach search "query" --json` |
|
| Twitter/X | `twitter`(备选 `opencli`) | `twitter search "query" -n 10` |
|
||||||
| 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 | `bili`(字幕走 `opencli`) | `bili search "query" --type video` / `opencli bilibili subtitle BVxxx` |
|
||||||
| Reddit | `curl` | `curl -s "https://reddit.com/r/xxx.json"` |
|
| Reddit | `opencli`(备选 `rdt`) | `opencli reddit search "query" -f yaml` / `rdt read POST_ID` |
|
||||||
| GitHub | `gh` | `gh search repos "query"` |
|
| GitHub | `gh` | `gh search repos "query"` |
|
||||||
| Web | `curl` + Jina | `curl -s "https://r.jina.ai/URL"` |
|
| Web | `curl` + Jina | `curl -s "https://r.jina.ai/URL"` |
|
||||||
| Exa Search | `mcporter` | `mcporter call 'exa.web_search_exa(...)'` |
|
| Exa Search | `mcporter` | `mcporter call 'exa.web_search_exa(...)'` |
|
||||||
| 小红书 | `mcporter` | `mcporter call 'xiaohongshu.search_feeds(...)'` |
|
| 小红书 | `opencli`(服务器 `mcporter`) | `opencli xiaohongshu search "query" -f yaml` |
|
||||||
| 抖音 | `mcporter` | `mcporter call 'douyin.parse_douyin_video_info(...)'` |
|
| 小宇宙播客 | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` |
|
||||||
| LinkedIn | `mcporter` | `mcporter call 'linkedin.get_person_profile(...)'` |
|
| LinkedIn | `mcporter` | `mcporter call 'linkedin.get_person_profile(...)'` |
|
||||||
| Boss直聘 | `mcporter` | `mcporter call 'bosszhipin.search_jobs_tool(...)'` |
|
|
||||||
| RSS | `feedparser` | `python3 -c "import feedparser; ..."` |
|
| RSS | `feedparser` | `python3 -c "import feedparser; ..."` |
|
||||||
|
|
||||||
|
> 多后端平台以 `agent-reach doctor --json` 的 `active_backend` 为准。
|
||||||
|
|||||||
+32
-15
@@ -1,44 +1,61 @@
|
|||||||
# 常见问题排查
|
# 常见问题排查
|
||||||
|
|
||||||
## Twitter/X: xreach CLI "fetch failed"
|
## 雪球 / Xueqiu: API 返回 400
|
||||||
|
|
||||||
**症状:** `xreach search` 或其他命令返回 "fetch failed"
|
**症状:** `agent-reach doctor` 显示雪球 ⚠️,报 `HTTP Error 400`
|
||||||
|
|
||||||
**原因:** xreach CLI 使用 Node.js 的 `undici` 库发请求。如果你的网络环境需要代理才能访问 x.com,需要明确传入代理参数。
|
**原因:** 雪球 API 需要登录 Cookie,无法通过匿名访问获取。
|
||||||
|
|
||||||
|
**解决方案:** 在 Chrome 里登录 xueqiu.com,然后运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-reach configure --from-browser chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
再次运行 `agent-reach doctor` 确认恢复 ✅。Cookie 过期后重新运行即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Twitter/X: twitter-cli 连接失败
|
||||||
|
|
||||||
|
**症状:** `twitter search` 或其他命令返回错误
|
||||||
|
|
||||||
|
**原因:** twitter-cli 需要 AUTH_TOKEN 和 CT0 环境变量才能访问 Twitter API。如果你的网络环境需要代理才能访问 x.com,需要配置代理。
|
||||||
|
|
||||||
**解决方案:**
|
**解决方案:**
|
||||||
|
|
||||||
### 方案 1:使用 --proxy 参数
|
### 方案 1:设置环境变量代理
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xreach search "test" --auth-token "$AUTH_TOKEN" --ct0 "$CT0" --proxy "http://user:pass@host:port"
|
export HTTP_PROXY="http://user:pass@host:port"
|
||||||
|
export HTTPS_PROXY="http://user:pass@host:port"
|
||||||
|
twitter search "test" -n 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方案 2:使用全局代理工具
|
### 方案 2:使用全局代理工具
|
||||||
|
|
||||||
让代理工具接管所有网络流量,这样 xreach 的请求也会走代理:
|
让代理工具接管所有网络流量,这样 twitter-cli 的请求也会走代理:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS — ClashX / Surge 开启"增强模式"
|
# macOS — ClashX / Surge 开启"增强模式"
|
||||||
# Linux — proxychains 或 tun2socks
|
# Linux — proxychains 或 tun2socks
|
||||||
proxychains xreach search "test" -n 1
|
proxychains twitter search "test" -n 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方案 3:不用 xreach,用 Exa 搜索替代
|
### 方案 3:不用 twitter-cli,用 Exa 搜索替代
|
||||||
|
|
||||||
xreach 不可用时,可以直接用 Exa 搜索 Twitter 内容:
|
twitter-cli 不可用时,可以直接用 Exa 搜索 Twitter 内容:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mcporter call 'exa.web_search_exa(query: "site:x.com 搜索词", numResults: 5)'
|
mcporter call 'exa.web_search_exa(query: "site:x.com 搜索词", numResults: 5)'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方案 4:设置 HTTP_PROXY 环境变量
|
### 方案 4:检查认证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export HTTP_PROXY="http://127.0.0.1:7890"
|
twitter check
|
||||||
export HTTPS_PROXY="http://127.0.0.1:7890"
|
|
||||||
|
|
||||||
xreach search "test"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ 注意:Node.js 原生 fetch 不一定读取这些环境变量,推荐用方案 1 的 --proxy 参数。
|
> 如果返回 "Missing credentials",需要设置 AUTH_TOKEN 和 CT0 环境变量。
|
||||||
|
>
|
||||||
|
> **Fallback:** 如果你已经安装了 bird CLI(`npm install -g @steipete/bird`),它也能正常工作。Agent Reach 会自动检测已安装的工具。
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
# Agent Reach — Update Guide
|
||||||
|
|
||||||
|
## For Humans
|
||||||
|
|
||||||
|
Copy this to your AI Agent:
|
||||||
|
|
||||||
|
```
|
||||||
|
帮我更新 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in English:
|
||||||
|
|
||||||
|
```
|
||||||
|
Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
|
||||||
|
### ⚠️ Workspace Rules
|
||||||
|
|
||||||
|
**Never create files, clone repos, or run commands in the agent workspace.** Use `/tmp/` for temporary work and `~/.agent-reach/` for persistent data.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Update Agent Reach to the latest version, refresh upstream tools, migrate from retired backends, and verify everything works. The user should not need to do anything manually (except things only a human can do, like clicking a browser-extension install button).
|
||||||
|
|
||||||
|
### Step 1: Check current version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-reach check-update
|
||||||
|
```
|
||||||
|
|
||||||
|
If it says "已是最新版本", skip to Step 4 (verify). Otherwise continue.
|
||||||
|
|
||||||
|
### Step 2: Update Agent Reach package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
> If pip complains about `externally-managed-environment` (PEP 668), the user
|
||||||
|
> originally installed via pipx or a venv — use the matching command:
|
||||||
|
> `pipx install --force https://github.com/Panniantong/agent-reach/archive/main.zip`
|
||||||
|
> or activate `~/.agent-reach-venv` first.
|
||||||
|
|
||||||
|
### Step 3: Refresh upstream tools
|
||||||
|
|
||||||
|
Run these to keep installed tools current. **Only upgrade what is already
|
||||||
|
installed — do not install new tools the user never asked for** (the one
|
||||||
|
exception: OpenCLI on desktop, see below).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python-based CLIs the user already has (upgrade keeps signatures fresh)
|
||||||
|
which twitter >/dev/null 2>&1 && { pipx upgrade twitter-cli 2>/dev/null || uv tool upgrade twitter-cli 2>/dev/null; }
|
||||||
|
which bili >/dev/null 2>&1 && { pipx upgrade bilibili-cli 2>/dev/null || uv tool upgrade bilibili-cli 2>/dev/null; }
|
||||||
|
which xhs >/dev/null 2>&1 && { pipx upgrade xiaohongshu-cli 2>/dev/null || uv tool upgrade xiaohongshu-cli 2>/dev/null; }
|
||||||
|
which yt-dlp >/dev/null 2>&1 && { pipx upgrade yt-dlp 2>/dev/null || uv tool upgrade yt-dlp 2>/dev/null || pip install -U yt-dlp 2>/dev/null; }
|
||||||
|
|
||||||
|
# rdt-cli is pinned to a git source (PyPI lags upstream) — same pin as the code's _RDT_GIT_SOURCE
|
||||||
|
which rdt >/dev/null 2>&1 && pipx install --force 'git+https://github.com/public-clis/rdt-cli.git@5e4fb3720d5c174e976cd425ccc3b879d52cac66' 2>/dev/null
|
||||||
|
|
||||||
|
# npm-based
|
||||||
|
which mcporter >/dev/null 2>&1 && npm update -g mcporter 2>/dev/null
|
||||||
|
which opencli >/dev/null 2>&1 && npm update -g @jackwener/opencli 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**Desktop users without OpenCLI**: since v1.5.0 OpenCLI is the preferred
|
||||||
|
backend for 小红书/Reddit (and adds B站 subtitles) by riding the user's
|
||||||
|
browser session. Offer it once:
|
||||||
|
|
||||||
|
> "这次更新引入了 OpenCLI 后端(复用你的浏览器登录态,小红书/Reddit 零配置)。要装吗?装完只需你在 Chrome 商店点一次『添加扩展』。"
|
||||||
|
|
||||||
|
If yes: `agent-reach install --channels opencli` and guide them through the
|
||||||
|
extension click. If no, everything keeps working on existing backends.
|
||||||
|
|
||||||
|
### Step 4: Coexistence (DO NOT uninstall old tools)
|
||||||
|
|
||||||
|
**Never uninstall tools the user already has.** Retired backends (e.g. yt-dlp
|
||||||
|
no longer serves Bilibili; xhs-cli is no longer installed by default) keep
|
||||||
|
working as fallbacks where they still function. Agent Reach routes around
|
||||||
|
them automatically — removal is the user's call, not yours.
|
||||||
|
|
||||||
|
### Step 5: Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-reach version
|
||||||
|
agent-reach doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Running `agent-reach doctor` (text mode) also auto-syncs the bundled skill
|
||||||
|
(SKILL.md + references) into every detected agent skill directory — no
|
||||||
|
separate skill-update step is needed.
|
||||||
|
|
||||||
|
Check the doctor output:
|
||||||
|
|
||||||
|
- Every channel shows ✅ / [!] with a clear message, and multi-backend
|
||||||
|
channels (小红书/Reddit/B站/Twitter) report `当前后端:…`
|
||||||
|
- If a previously-working channel now shows [X]/error, the message contains
|
||||||
|
the exact fix (e.g. a venv-reinstall prescription) — run it, then re-check
|
||||||
|
- `--json` gives the same data machine-readably (`active_backend` per channel)
|
||||||
|
|
||||||
|
### Step 6: Report to user
|
||||||
|
|
||||||
|
Tell the user:
|
||||||
|
|
||||||
|
1. What version they're on now (`agent-reach version`)
|
||||||
|
2. How many channels are available, and which backend each multi-backend
|
||||||
|
platform is using (from doctor)
|
||||||
|
3. Anything that needs their action (e.g. Chrome extension click, `xhs login`,
|
||||||
|
QR scan for xiaohongshu-mcp on servers)
|
||||||
|
4. What changed in this update (release notes shown by `check-update`)
|
||||||
|
|
||||||
|
Done.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
+1
-5
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "agent-reach"
|
name = "agent-reach"
|
||||||
version = "1.3.0"
|
version = "1.5.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"}
|
||||||
@@ -64,10 +64,6 @@ build-backend = "hatchling.build"
|
|||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["agent_reach"]
|
packages = ["agent_reach"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.force-include]
|
|
||||||
"agent_reach/guides" = "agent_reach/guides"
|
|
||||||
"agent_reach/skill" = "agent_reach/skill"
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Contract tests for channel adapters."""
|
"""Contract tests for channel adapters."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from agent_reach.channels import get_all_channels
|
from agent_reach.channels import get_all_channels
|
||||||
from agent_reach.config import Config
|
from agent_reach.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_run_ok(cmd, **kwargs):
|
||||||
|
"""Pretend any probed CLI executes fine and prints a version."""
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "")
|
||||||
|
|
||||||
|
|
||||||
def test_channel_registry_contract():
|
def test_channel_registry_contract():
|
||||||
channels = get_all_channels()
|
channels = get_all_channels()
|
||||||
assert channels, "channel registry must not be empty"
|
assert channels, "channel registry must not be empty"
|
||||||
@@ -29,6 +36,133 @@ def test_channel_check_contract_with_minimal_runtime(monkeypatch, tmp_path):
|
|||||||
assert isinstance(message, str) and message.strip()
|
assert isinstance(message, str) and message.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_active_backend_attribute_contract():
|
||||||
|
"""Every channel exposes active_backend (default None, or str once set)."""
|
||||||
|
for ch in get_all_channels():
|
||||||
|
assert hasattr(ch, "active_backend")
|
||||||
|
# Fresh instances must default to None / str (class attribute on base)
|
||||||
|
fresh = type(ch)()
|
||||||
|
assert fresh.active_backend is None or isinstance(fresh.active_backend, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_active_backend_set_by_check(monkeypatch, tmp_path):
|
||||||
|
"""After check(), active_backend is None or a str — never anything else."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
||||||
|
|
||||||
|
# Keep the network-based channels (V2EX/Xueqiu/Bilibili API) deterministic.
|
||||||
|
import urllib.request
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
|
def _no_net(*_a, **_k):
|
||||||
|
raise URLError("offline")
|
||||||
|
|
||||||
|
monkeypatch.setattr(urllib.request, "urlopen", _no_net)
|
||||||
|
import agent_reach.channels.xueqiu as xueqiu_mod
|
||||||
|
monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True)
|
||||||
|
monkeypatch.setattr(xueqiu_mod._opener, "open", _no_net)
|
||||||
|
|
||||||
|
config = Config(config_path=tmp_path / "config.yaml")
|
||||||
|
for ch in get_all_channels():
|
||||||
|
ch.check(config)
|
||||||
|
assert ch.active_backend is None or isinstance(ch.active_backend, str), (
|
||||||
|
f"{ch.name}: active_backend must be None or str after check()"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordered_backends_contract(tmp_path):
|
||||||
|
"""ordered_backends(config) is a reordering (same multiset) of backends."""
|
||||||
|
config = Config(config_path=tmp_path / "config.yaml")
|
||||||
|
for ch in get_all_channels():
|
||||||
|
ordered = ch.ordered_backends(config)
|
||||||
|
assert isinstance(ordered, list)
|
||||||
|
assert sorted(ordered) == sorted(ch.backends), (
|
||||||
|
f"{ch.name}: ordered_backends must be a permutation of backends"
|
||||||
|
)
|
||||||
|
# And without any config at all
|
||||||
|
ordered_none = ch.ordered_backends(None)
|
||||||
|
assert sorted(ordered_none) == sorted(ch.backends)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordered_backends_override_moves_backend_to_front():
|
||||||
|
"""Config key <channel>_backend promotes the named backend to front."""
|
||||||
|
from agent_reach.channels.twitter import TwitterChannel
|
||||||
|
|
||||||
|
ch = TwitterChannel()
|
||||||
|
ordered = ch.ordered_backends({"twitter_backend": "bird"})
|
||||||
|
assert ordered[0] == "bird CLI (legacy)"
|
||||||
|
assert sorted(ordered) == sorted(ch.backends)
|
||||||
|
|
||||||
|
# Unknown override is ignored — never hides working backends
|
||||||
|
ordered_unknown = ch.ordered_backends({"twitter_backend": "no-such-tool"})
|
||||||
|
assert ordered_unknown == list(ch.backends)
|
||||||
|
|
||||||
|
|
||||||
|
def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
|
||||||
|
"""YouTube should warn when only Node.js is installed but no yt-dlp config exists."""
|
||||||
|
from agent_reach.channels.youtube import YouTubeChannel
|
||||||
|
|
||||||
|
def fake_which(cmd):
|
||||||
|
if cmd == "yt-dlp":
|
||||||
|
return "/usr/bin/yt-dlp"
|
||||||
|
if cmd == "node":
|
||||||
|
return "/usr/bin/node"
|
||||||
|
return None # deno not installed
|
||||||
|
|
||||||
|
monkeypatch.setattr("shutil.which", fake_which)
|
||||||
|
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
|
||||||
|
# Point to a non-existent config file
|
||||||
|
monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / ".config/yt-dlp/config"))
|
||||||
|
|
||||||
|
ch = YouTubeChannel()
|
||||||
|
status, message = ch.check()
|
||||||
|
assert status == "warn"
|
||||||
|
assert "--js-runtimes" in message
|
||||||
|
assert ch.active_backend == "yt-dlp" # 本体活着,warn 只关乎 JS runtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path):
|
||||||
|
"""Windows guidance should use a PowerShell-style yt-dlp config command."""
|
||||||
|
from agent_reach.channels.youtube import YouTubeChannel
|
||||||
|
|
||||||
|
def fake_which(cmd):
|
||||||
|
if cmd == "yt-dlp":
|
||||||
|
return "C:/yt-dlp.exe"
|
||||||
|
if cmd == "node":
|
||||||
|
return "C:/node.exe"
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("shutil.which", fake_which)
|
||||||
|
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
|
||||||
|
monkeypatch.setattr("agent_reach.utils.paths.sys.platform", "win32")
|
||||||
|
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||||
|
|
||||||
|
ch = YouTubeChannel()
|
||||||
|
status, message = ch.check()
|
||||||
|
assert status == "warn"
|
||||||
|
assert "Select-String" in message
|
||||||
|
assert "--js-runtimes node" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_youtube_ok_when_deno_installed(monkeypatch):
|
||||||
|
"""YouTube should return ok when Deno is installed (no config needed)."""
|
||||||
|
from agent_reach.channels.youtube import YouTubeChannel
|
||||||
|
|
||||||
|
def fake_which(cmd):
|
||||||
|
if cmd == "yt-dlp":
|
||||||
|
return "/usr/bin/yt-dlp"
|
||||||
|
if cmd == "deno":
|
||||||
|
return "/usr/bin/deno"
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("shutil.which", fake_which)
|
||||||
|
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
|
||||||
|
|
||||||
|
ch = YouTubeChannel()
|
||||||
|
status, _msg = ch.check()
|
||||||
|
assert status == "ok"
|
||||||
|
assert ch.active_backend == "yt-dlp"
|
||||||
|
|
||||||
|
|
||||||
def test_channel_can_handle_contract():
|
def test_channel_can_handle_contract():
|
||||||
url_samples = {
|
url_samples = {
|
||||||
"github": "https://github.com/panniantong/agent-reach",
|
"github": "https://github.com/panniantong/agent-reach",
|
||||||
@@ -37,10 +171,9 @@ def test_channel_can_handle_contract():
|
|||||||
"reddit": "https://reddit.com/r/python",
|
"reddit": "https://reddit.com/r/python",
|
||||||
"bilibili": "https://www.bilibili.com/video/BV1xx411",
|
"bilibili": "https://www.bilibili.com/video/BV1xx411",
|
||||||
"xiaohongshu": "https://www.xiaohongshu.com/explore/123",
|
"xiaohongshu": "https://www.xiaohongshu.com/explore/123",
|
||||||
"douyin": "https://www.douyin.com/video/123",
|
|
||||||
"linkedin": "https://www.linkedin.com/in/test",
|
"linkedin": "https://www.linkedin.com/in/test",
|
||||||
"bosszhipin": "https://www.zhipin.com/web/geek/job?query=python",
|
|
||||||
"rss": "https://example.com/feed.xml",
|
"rss": "https://example.com/feed.xml",
|
||||||
|
"xueqiu": "https://xueqiu.com/S/SH600519",
|
||||||
"exa_search": "https://example.com",
|
"exa_search": "https://example.com",
|
||||||
"web": "https://example.com",
|
"web": "https://example.com",
|
||||||
}
|
}
|
||||||
|
|||||||
+1191
-1
File diff suppressed because it is too large
Load Diff
+112
-2
@@ -1,9 +1,12 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Tests for Agent Reach CLI."""
|
"""Tests for Agent Reach CLI."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from unittest.mock import patch
|
|
||||||
import agent_reach.cli as cli
|
import agent_reach.cli as cli
|
||||||
from agent_reach.cli import main
|
from agent_reach.cli import main
|
||||||
|
|
||||||
@@ -30,6 +33,74 @@ class TestCLI:
|
|||||||
assert "Agent Reach" in captured.out
|
assert "Agent Reach" in captured.out
|
||||||
assert "✅" in captured.out
|
assert "✅" in captured.out
|
||||||
|
|
||||||
|
def test_transcribe_command_prints_text(self, capsys):
|
||||||
|
with patch("agent_reach.transcribe.transcribe", return_value="hello transcript"):
|
||||||
|
with patch("sys.argv", ["agent-reach", "transcribe", "audio.mp3"]):
|
||||||
|
main()
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "hello transcript" in captured.out
|
||||||
|
|
||||||
|
def test_transcribe_command_writes_output_file(self, capsys, tmp_path):
|
||||||
|
out_file = tmp_path / "t.txt"
|
||||||
|
with patch("agent_reach.transcribe.transcribe", return_value="saved text"):
|
||||||
|
with patch("sys.argv", ["agent-reach", "transcribe", "audio.mp3", "-o", str(out_file)]):
|
||||||
|
main()
|
||||||
|
assert out_file.read_text(encoding="utf-8").strip() == "saved text"
|
||||||
|
assert "Transcript written" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_parse_twitter_cookie_input_separate_values(self):
|
||||||
|
auth_token, ct0 = cli._parse_twitter_cookie_input("token123 ct0abc")
|
||||||
|
assert auth_token == "token123"
|
||||||
|
assert ct0 == "ct0abc"
|
||||||
|
|
||||||
|
def test_parse_twitter_cookie_input_cookie_header(self):
|
||||||
|
auth_token, ct0 = cli._parse_twitter_cookie_input(
|
||||||
|
"auth_token=token123; ct0=ct0abc; other=value"
|
||||||
|
)
|
||||||
|
assert auth_token == "token123"
|
||||||
|
assert ct0 == "ct0abc"
|
||||||
|
|
||||||
|
def test_install_rdt_cli_prefers_github_source(self, monkeypatch, capsys):
|
||||||
|
state = {"rdt_installed": False}
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
def fake_which(name):
|
||||||
|
if name == "rdt":
|
||||||
|
return "/usr/local/bin/rdt" if state["rdt_installed"] else None
|
||||||
|
if name == "pipx":
|
||||||
|
return "/usr/local/bin/pipx"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
commands.append(cmd)
|
||||||
|
state["rdt_installed"] = True
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(shutil, "which", fake_which)
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
cli._install_rdt_cli()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert commands == [["pipx", "install", cli._RDT_GIT_SOURCE]]
|
||||||
|
assert "✅ rdt-cli installed" in out
|
||||||
|
|
||||||
|
def test_install_reddit_deps_routes_by_environment(self, monkeypatch):
|
||||||
|
"""桌面 → OpenCLI;服务器 → rdt-cli(钉 git 源)。"""
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(cli, "_install_opencli_deps", lambda: calls.append("opencli"))
|
||||||
|
monkeypatch.setattr(cli, "_install_rdt_cli", lambda: calls.append("rdt"))
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda _: None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_detect_environment", lambda: "local")
|
||||||
|
cli._install_reddit_deps()
|
||||||
|
assert calls == ["opencli"]
|
||||||
|
|
||||||
|
calls.clear()
|
||||||
|
monkeypatch.setattr(cli, "_detect_environment", lambda: "server")
|
||||||
|
cli._install_reddit_deps()
|
||||||
|
assert calls == ["rdt"]
|
||||||
|
|
||||||
|
|
||||||
class TestCheckUpdateRetry:
|
class TestCheckUpdateRetry:
|
||||||
def test_retry_timeout_classification(self):
|
def test_retry_timeout_classification(self):
|
||||||
@@ -77,7 +148,7 @@ class TestCheckUpdateRetry:
|
|||||||
|
|
||||||
sequence = [
|
sequence = [
|
||||||
R(429, headers={"Retry-After": "3"}),
|
R(429, headers={"Retry-After": "3"}),
|
||||||
R(200, payload={"tag_name": "v1.3.0"}),
|
R(200, payload={"tag_name": "v1.5.0"}),
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch("requests.get", side_effect=sequence):
|
with patch("requests.get", side_effect=sequence):
|
||||||
@@ -112,3 +183,42 @@ class TestCheckUpdateRetry:
|
|||||||
assert result == "error"
|
assert result == "error"
|
||||||
assert "网络超时" in captured.out
|
assert "网络超时" in captured.out
|
||||||
assert "已重试 3 次" in captured.out
|
assert "已重试 3 次" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionCompare:
|
||||||
|
def test_newer_remote_triggers_update(self):
|
||||||
|
assert cli._is_newer_version("1.5.0", "1.4.2") is True
|
||||||
|
|
||||||
|
def test_equal_versions_no_update(self):
|
||||||
|
assert cli._is_newer_version("1.5.0", "1.5.0") is False
|
||||||
|
|
||||||
|
def test_local_ahead_of_release_no_downgrade_prompt(self):
|
||||||
|
"""发版窗口期本地装了 main(更新)时,不能提示"有更新"诱导降级。"""
|
||||||
|
assert cli._is_newer_version("1.4.2", "1.5.0") is False
|
||||||
|
|
||||||
|
def test_unparseable_falls_back_to_inequality(self):
|
||||||
|
assert cli._is_newer_version("2026.06-beta", "1.5.0") is True
|
||||||
|
assert cli._is_newer_version("1.5.0", "1.5.0-dev") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestWatchVersionCompare:
|
||||||
|
def test_watch_does_not_prompt_downgrade(self, monkeypatch, capsys):
|
||||||
|
"""watch 与 check-update 同语义:本地领先远端 release 时不提示更新。"""
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def json():
|
||||||
|
return {"tag_name": "v1.4.2", "body": ""}
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_github_get_with_retry", lambda *a, **k: (R(), None, 1))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agent_reach.doctor.check_all",
|
||||||
|
lambda config: {"web": {"status": "ok", "name": "任意网页", "message": "ok",
|
||||||
|
"tier": 0, "backends": ["Jina Reader"], "active_backend": "Jina Reader"}},
|
||||||
|
)
|
||||||
|
cli._cmd_watch()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "新版本可用" not in out
|
||||||
|
assert "全部正常" in out
|
||||||
|
|||||||
+13
-5
@@ -61,11 +61,6 @@ class TestConfig:
|
|||||||
tmp_config.set("exa_api_key", "test-key")
|
tmp_config.set("exa_api_key", "test-key")
|
||||||
assert tmp_config.is_configured("exa_search")
|
assert tmp_config.is_configured("exa_search")
|
||||||
|
|
||||||
def test_is_configured_reddit(self, tmp_config):
|
|
||||||
assert not tmp_config.is_configured("reddit_proxy")
|
|
||||||
tmp_config.set("reddit_proxy", "http://user:pass@ip:port")
|
|
||||||
assert tmp_config.is_configured("reddit_proxy")
|
|
||||||
|
|
||||||
def test_get_configured_features(self, tmp_config):
|
def test_get_configured_features(self, tmp_config):
|
||||||
features = tmp_config.get_configured_features()
|
features = tmp_config.get_configured_features()
|
||||||
assert isinstance(features, dict)
|
assert isinstance(features, dict)
|
||||||
@@ -78,3 +73,16 @@ class TestConfig:
|
|||||||
masked = tmp_config.to_dict()
|
masked = tmp_config.to_dict()
|
||||||
assert masked["exa_api_key"] == "super-se..."
|
assert masked["exa_api_key"] == "super-se..."
|
||||||
assert masked["normal_setting"] == "visible"
|
assert masked["normal_setting"] == "visible"
|
||||||
|
|
||||||
|
def test_save_creates_file_with_restricted_permissions(self, tmp_path):
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
config_file = tmp_path / "secure_config.yaml"
|
||||||
|
config = Config(config_path=config_file)
|
||||||
|
config.set("secret_key", "my-secret")
|
||||||
|
|
||||||
|
if sys.platform != "win32":
|
||||||
|
mode = config_file.stat().st_mode
|
||||||
|
# File should be owner-only read/write (0o600)
|
||||||
|
assert not (mode & stat.S_IRGRP), "group read should not be set"
|
||||||
|
assert not (mode & stat.S_IROTH), "other read should not be set"
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Verify credential files written by cookie sync helpers and CLI helpers
|
||||||
|
are owner-only (0o600) and that values containing shell metacharacters do
|
||||||
|
not break the shell-sourceable env file produced by _sync_bird_env().
|
||||||
|
|
||||||
|
Companion to tests/test_config.py::test_save_creates_file_with_restricted_permissions —
|
||||||
|
the same threat-model claim ("Cookie/Token only stored locally, 600
|
||||||
|
permissions") covers these paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent_reach.cookie_extract import _sync_bird_env, _sync_xfetch_session
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_only(path: str) -> bool:
|
||||||
|
mode = os.stat(path).st_mode
|
||||||
|
return not (mode & (stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perm semantics only")
|
||||||
|
def test_sync_xfetch_session_writes_0600(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
_sync_xfetch_session("auth_xxx", "ct0_yyy")
|
||||||
|
session_path = tmp_path / ".config" / "xfetch" / "session.json"
|
||||||
|
assert session_path.exists(), "expected ~/.config/xfetch/session.json"
|
||||||
|
assert _owner_only(str(session_path)), "session.json must be 0o600"
|
||||||
|
# Round-trip the content so we know we didn't accidentally corrupt JSON.
|
||||||
|
data = json.loads(session_path.read_text(encoding="utf-8"))
|
||||||
|
assert data["authToken"] == "auth_xxx"
|
||||||
|
assert data["ct0"] == "ct0_yyy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perm semantics only")
|
||||||
|
def test_sync_bird_env_writes_0600(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
_sync_bird_env("auth_xxx", "ct0_yyy")
|
||||||
|
env_path = tmp_path / ".config" / "bird" / "credentials.env"
|
||||||
|
assert env_path.exists(), "expected ~/.config/bird/credentials.env"
|
||||||
|
assert _owner_only(str(env_path)), "credentials.env must be 0o600"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX sh needed for sourcing")
|
||||||
|
def test_sync_bird_env_quotes_shell_metachars(tmp_path, monkeypatch):
|
||||||
|
"""Tokens containing ", $, `, ; etc. must not break out of the assignment.
|
||||||
|
|
||||||
|
Prior implementation used `f'AUTH_TOKEN="{auth_token}"'` which an attacker-
|
||||||
|
controlled cookie containing a literal `"` could break out of, turning a
|
||||||
|
later `source ~/.config/bird/credentials.env` into arbitrary shell.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
# Side-effect markers live under tmp_path (auto-cleaned by pytest) rather
|
||||||
|
# than a shared absolute /tmp path — otherwise one vulnerable run leaves a
|
||||||
|
# marker behind that fails every later run on the same machine/CI runner.
|
||||||
|
pwn_auth = tmp_path / "pwn-auth"
|
||||||
|
pwn_ct0 = tmp_path / "pwn-ct0"
|
||||||
|
hostile_auth = f'inj"; touch {pwn_auth}; #'
|
||||||
|
hostile_ct0 = f"ct0_$(touch {pwn_ct0})"
|
||||||
|
_sync_bird_env(hostile_auth, hostile_ct0)
|
||||||
|
env_path = tmp_path / ".config" / "bird" / "credentials.env"
|
||||||
|
|
||||||
|
# Sourcing the file must NOT execute the injected payload. Read back the
|
||||||
|
# exported values from a subshell instead — they should equal the originals.
|
||||||
|
probe = (
|
||||||
|
f". {env_path}; "
|
||||||
|
f'printf "AUTH=%s\\nCT0=%s\\n" "$AUTH_TOKEN" "$CT0"'
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["sh", "-c", probe],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
lines = dict(
|
||||||
|
line.split("=", 1) for line in result.stdout.strip().splitlines() if "=" in line
|
||||||
|
)
|
||||||
|
assert lines["AUTH"] == hostile_auth, "auth_token round-trip broke — injection possible"
|
||||||
|
assert lines["CT0"] == hostile_ct0, "ct0 round-trip broke — injection possible"
|
||||||
|
# And no side-effect files materialised.
|
||||||
|
assert not pwn_auth.exists()
|
||||||
|
assert not pwn_ct0.exists()
|
||||||
+110
-21
@@ -2,8 +2,24 @@
|
|||||||
"""Tests for doctor module."""
|
"""Tests for doctor module."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import agent_reach.doctor as doctor
|
||||||
from agent_reach.config import Config
|
from agent_reach.config import Config
|
||||||
from agent_reach.doctor import check_all, format_report
|
|
||||||
|
|
||||||
|
class _StubChannel:
|
||||||
|
def __init__(self, name, description, tier, status, message, backends=None,
|
||||||
|
active_backend=None):
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.tier = tier
|
||||||
|
self._status = status
|
||||||
|
self._message = message
|
||||||
|
self.backends = backends or []
|
||||||
|
self.active_backend = active_backend
|
||||||
|
|
||||||
|
def check(self, config=None):
|
||||||
|
return self._status, self._message
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -12,26 +28,99 @@ def tmp_config(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
class TestDoctor:
|
class TestDoctor:
|
||||||
def test_zero_config_channels_ok(self, tmp_config):
|
def test_check_all_collects_channel_results(self, tmp_config, monkeypatch):
|
||||||
results = check_all(tmp_config)
|
monkeypatch.setattr(
|
||||||
assert results["web"]["status"] == "ok"
|
doctor,
|
||||||
assert results["github"]["status"] in ("ok", "warn") # warn if gh CLI not installed
|
"get_all_channels",
|
||||||
assert results["bilibili"]["status"] in ("ok", "warn") # warn on servers
|
lambda: [
|
||||||
assert results["rss"]["status"] == "ok"
|
_StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"],
|
||||||
|
active_backend="requests"),
|
||||||
|
_StubChannel("github", "GitHub", 0, "warn", "gh 未安装", ["gh"]),
|
||||||
|
_StubChannel("exa_search", "全网语义搜索", 1, "off", "mcporter 未配置", ["Exa"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_exa_off_without_key(self, tmp_config):
|
results = doctor.check_all(tmp_config)
|
||||||
results = check_all(tmp_config)
|
|
||||||
assert results["exa_search"]["status"] == "off"
|
|
||||||
|
|
||||||
def test_exa_key_does_not_force_enabled(self, tmp_config):
|
assert results == {
|
||||||
# Exa availability is determined by mcporter runtime/config state.
|
"web": {
|
||||||
tmp_config.set("exa_api_key", "test-key")
|
"status": "ok",
|
||||||
results = check_all(tmp_config)
|
"name": "网页",
|
||||||
assert results["exa_search"]["status"] in ("off", "ok")
|
"message": "可抓取网页",
|
||||||
|
"tier": 0,
|
||||||
|
"backends": ["requests"],
|
||||||
|
"active_backend": "requests",
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"status": "warn",
|
||||||
|
"name": "GitHub",
|
||||||
|
"message": "gh 未安装",
|
||||||
|
"tier": 0,
|
||||||
|
"backends": ["gh"],
|
||||||
|
"active_backend": None,
|
||||||
|
},
|
||||||
|
"exa_search": {
|
||||||
|
"status": "off",
|
||||||
|
"name": "全网语义搜索",
|
||||||
|
"message": "mcporter 未配置",
|
||||||
|
"tier": 1,
|
||||||
|
"backends": ["Exa"],
|
||||||
|
"active_backend": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def test_format_report(self, tmp_config):
|
def test_format_report(self):
|
||||||
results = check_all(tmp_config)
|
report = doctor.format_report(
|
||||||
report = format_report(results)
|
{
|
||||||
assert "Agent Reach" in report
|
"web": {
|
||||||
assert "✅" in report
|
"status": "ok",
|
||||||
assert "渠道可用" in report
|
"name": "网页",
|
||||||
|
"message": "可抓取网页",
|
||||||
|
"tier": 0,
|
||||||
|
"backends": ["requests"],
|
||||||
|
},
|
||||||
|
"exa_search": {
|
||||||
|
"status": "off",
|
||||||
|
"name": "全网语义搜索",
|
||||||
|
"message": "mcporter 未配置",
|
||||||
|
"tier": 1,
|
||||||
|
"backends": ["Exa"],
|
||||||
|
},
|
||||||
|
"xiaohongshu": {
|
||||||
|
"status": "warn",
|
||||||
|
"name": "小红书",
|
||||||
|
"message": "MCP 已配置,但健康检查超时",
|
||||||
|
"tier": 2,
|
||||||
|
"backends": ["mcporter"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Strip Rich markup tags for assertion (PR #170 added [bold], [yellow] etc.)
|
||||||
|
import re
|
||||||
|
plain = re.sub(r"\[[^\]]*\]", "", report)
|
||||||
|
assert "Agent Reach" in plain
|
||||||
|
assert "装好即用:" in plain
|
||||||
|
assert "1/3 个渠道可用" in plain
|
||||||
|
# Inactive optional channels should be summarized in one line
|
||||||
|
assert "可选渠道可以解锁" in plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_active_backend_does_not_leak_into_errored_result(monkeypatch):
|
||||||
|
"""渠道单例上一轮的 active_backend 不得泄漏进本轮异常结果(Codex review 发现)。"""
|
||||||
|
from agent_reach import doctor
|
||||||
|
|
||||||
|
class _ExplodingChannel:
|
||||||
|
name = "boom"
|
||||||
|
description = "爆炸渠道"
|
||||||
|
tier = 0
|
||||||
|
backends = ["a", "b"]
|
||||||
|
active_backend = "a" # 上一轮成功的残留
|
||||||
|
|
||||||
|
def check(self, config=None):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(doctor, "get_all_channels", lambda: [_ExplodingChannel()])
|
||||||
|
results = doctor.check_all(config=None)
|
||||||
|
assert results["boom"]["status"] == "error"
|
||||||
|
assert results["boom"]["active_backend"] is None
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for the OpenCLI cross-channel backend probing."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from agent_reach.backends import opencli_status, opencli_summary
|
||||||
|
from agent_reach.probe import ProbeResult
|
||||||
|
|
||||||
|
|
||||||
|
def _status_with(version_probe, daemon_probe=None, ext_on_disk=False):
|
||||||
|
"""Run opencli_status with probe_command and disk check patched."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_probe(cmd, args=("--version",), **kwargs):
|
||||||
|
calls.append(list(args))
|
||||||
|
if list(args) == ["--version"]:
|
||||||
|
return version_probe
|
||||||
|
return daemon_probe or ProbeResult("missing")
|
||||||
|
|
||||||
|
with patch("agent_reach.backends.opencli.probe_command", side_effect=fake_probe), \
|
||||||
|
patch(
|
||||||
|
"agent_reach.backends.opencli._extension_installed_on_disk",
|
||||||
|
return_value=ext_on_disk,
|
||||||
|
):
|
||||||
|
return opencli_status(), calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_installed():
|
||||||
|
st, _ = _status_with(ProbeResult("missing"))
|
||||||
|
assert not st.installed
|
||||||
|
assert not st.ready
|
||||||
|
assert "未安装" in opencli_summary(st)
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_node_env_gives_npm_hint():
|
||||||
|
st, _ = _status_with(ProbeResult("broken", hint="x"))
|
||||||
|
assert st.installed and st.broken
|
||||||
|
assert "npm install -g @jackwener/opencli" in st.hint
|
||||||
|
assert not st.ready
|
||||||
|
|
||||||
|
|
||||||
|
def test_daemon_running_extension_connected_is_ready():
|
||||||
|
daemon_out = "Daemon: running (PID 37389)\nVersion: v1.8.3\nExtension: connected\n"
|
||||||
|
st, _ = _status_with(
|
||||||
|
ProbeResult("ok", output="1.8.3"),
|
||||||
|
ProbeResult("ok", output=daemon_out),
|
||||||
|
)
|
||||||
|
assert st.installed and st.daemon_running and st.extension_connected
|
||||||
|
assert st.ready
|
||||||
|
assert "1.8.3" in opencli_summary(st)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_never_installed_not_ready_with_store_guide():
|
||||||
|
daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n"
|
||||||
|
st, _ = _status_with(
|
||||||
|
ProbeResult("ok", output="1.8.3"),
|
||||||
|
ProbeResult("ok", output=daemon_out),
|
||||||
|
ext_on_disk=False,
|
||||||
|
)
|
||||||
|
assert st.daemon_running and not st.extension_connected
|
||||||
|
assert not st.ready
|
||||||
|
assert "chromewebstore.google.com" in st.hint
|
||||||
|
|
||||||
|
|
||||||
|
def test_sleeping_extension_counts_as_ready():
|
||||||
|
"""实测:扩展 service worker 睡眠时 daemon status 报 disconnected,
|
||||||
|
但任何真实命令会唤醒它——装在磁盘上即视为可用。"""
|
||||||
|
daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n"
|
||||||
|
st, _ = _status_with(
|
||||||
|
ProbeResult("ok", output="1.8.3"),
|
||||||
|
ProbeResult("ok", output=daemon_out),
|
||||||
|
ext_on_disk=True,
|
||||||
|
)
|
||||||
|
assert not st.extension_connected
|
||||||
|
assert st.extension_installed
|
||||||
|
assert st.ready
|
||||||
|
assert "唤醒" in opencli_summary(st)
|
||||||
|
assert st.hint == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_daemon_not_running_parsed_correctly():
|
||||||
|
st, _ = _status_with(
|
||||||
|
ProbeResult("ok", output="1.8.3"),
|
||||||
|
ProbeResult("ok", output="Daemon: not running\n"),
|
||||||
|
)
|
||||||
|
assert st.installed
|
||||||
|
assert not st.daemon_running
|
||||||
|
assert not st.extension_connected
|
||||||
|
assert "自动启动" in opencli_summary(st)
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_uses_daemon_status_not_doctor():
|
||||||
|
"""`opencli doctor` auto-starts the daemon (side effect) — health checks
|
||||||
|
must only ever call `daemon status`."""
|
||||||
|
_, calls = _status_with(
|
||||||
|
ProbeResult("ok", output="1.8.3"),
|
||||||
|
ProbeResult("ok", output="Daemon: not running\n"),
|
||||||
|
)
|
||||||
|
assert ["daemon", "status"] in calls
|
||||||
|
assert ["doctor"] not in calls
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for agent_reach.probe — real-execution probing and failure classification."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent_reach.probe import ProbeResult, probe_command, reinstall_hint
|
||||||
|
|
||||||
|
|
||||||
|
def _make_executable(path, content):
|
||||||
|
path.write_text(content)
|
||||||
|
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_command():
|
||||||
|
r = probe_command("definitely-not-a-real-command-xyz")
|
||||||
|
assert r.status == "missing"
|
||||||
|
assert not r.ok
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="shebang semantics are POSIX-only")
|
||||||
|
def test_broken_shebang_detected_as_broken(tmp_path, monkeypatch):
|
||||||
|
"""A stale venv shim: which() finds it, exec raises FileNotFoundError."""
|
||||||
|
script = _make_executable(
|
||||||
|
tmp_path / "stale-tool", "#!/nonexistent/venv/bin/python\nprint('hi')\n"
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||||
|
|
||||||
|
r = probe_command("stale-tool", package="stale-tool-pkg")
|
||||||
|
assert r.status == "broken"
|
||||||
|
assert "uv tool install --force stale-tool-pkg" in r.hint
|
||||||
|
assert "pipx reinstall stale-tool-pkg" in r.hint
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
|
||||||
|
def test_healthy_command_returns_ok_with_output(tmp_path, monkeypatch):
|
||||||
|
script = _make_executable(
|
||||||
|
tmp_path / "healthy-tool", "#!/bin/sh\necho 'healthy-tool 1.2.3'\n"
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||||
|
|
||||||
|
r = probe_command("healthy-tool")
|
||||||
|
assert r.ok
|
||||||
|
assert "1.2.3" in r.output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
|
||||||
|
def test_nonzero_exit_classified_as_error(tmp_path, monkeypatch):
|
||||||
|
script = _make_executable(
|
||||||
|
tmp_path / "failing-tool", "#!/bin/sh\necho 'boom' >&2\nexit 3\n"
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||||
|
|
||||||
|
r = probe_command("failing-tool")
|
||||||
|
assert r.status == "error"
|
||||||
|
assert "boom" in r.output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
|
||||||
|
def test_exit_127_classified_as_broken(tmp_path, monkeypatch):
|
||||||
|
script = _make_executable(tmp_path / "wrapper-tool", "#!/bin/sh\nexit 127\n")
|
||||||
|
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||||
|
|
||||||
|
r = probe_command("wrapper-tool", package="wrapper-pkg")
|
||||||
|
assert r.status == "broken"
|
||||||
|
assert "wrapper-pkg" in r.hint
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
|
||||||
|
def test_retries_help_transient_failures(tmp_path, monkeypatch):
|
||||||
|
"""First call fails (exit 1), second succeeds — retries=1 should return ok."""
|
||||||
|
marker = tmp_path / "ran-once"
|
||||||
|
script = _make_executable(
|
||||||
|
tmp_path / "flaky-tool",
|
||||||
|
f"#!/bin/sh\nif [ -f {marker} ]; then echo ok; exit 0; fi\ntouch {marker}\nexit 1\n",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||||
|
|
||||||
|
r = probe_command("flaky-tool", retries=1)
|
||||||
|
assert r.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_reinstall_hint_mentions_both_installers():
|
||||||
|
hint = reinstall_hint("some-pkg")
|
||||||
|
assert "uv tool install --force some-pkg" in hint
|
||||||
|
assert "pipx reinstall some-pkg" in hint
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from agent_reach.utils.process import mcporter_utf8_env_args, utf8_subprocess_env
|
||||||
|
|
||||||
|
|
||||||
|
def test_utf8_subprocess_env_forces_python_utf8():
|
||||||
|
env = utf8_subprocess_env({"PYTHONUTF8": "0", "OTHER": "value"})
|
||||||
|
|
||||||
|
assert env["PYTHONUTF8"] == "1"
|
||||||
|
assert env["PYTHONIOENCODING"] == "utf-8"
|
||||||
|
assert env["OTHER"] == "value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcporter_utf8_env_args():
|
||||||
|
assert mcporter_utf8_env_args() == [
|
||||||
|
"--env",
|
||||||
|
"PYTHONUTF8=1",
|
||||||
|
"--env",
|
||||||
|
"PYTHONIOENCODING=utf-8",
|
||||||
|
]
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for 'agent-reach skill' command and _install_skill / _uninstall_skill."""
|
||||||
|
|
||||||
|
import importlib.resources
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from agent_reach.cli import _install_skill, _uninstall_skill
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillCommand(unittest.TestCase):
|
||||||
|
"""Test skill install and uninstall via CLI helpers."""
|
||||||
|
|
||||||
|
def test_skill_resources_include_both_locales(self):
|
||||||
|
"""Package resources should expose both default and English skill markdown files."""
|
||||||
|
skill_dir = importlib.resources.files("agent_reach").joinpath("skill")
|
||||||
|
|
||||||
|
default_skill = skill_dir.joinpath("SKILL.md").read_text(encoding="utf-8")
|
||||||
|
english_skill = skill_dir.joinpath("SKILL_en.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertTrue(default_skill.strip())
|
||||||
|
self.assertTrue(english_skill.strip())
|
||||||
|
|
||||||
|
def test_install_skill_creates_skill_md(self):
|
||||||
|
"""_install_skill should create SKILL.md in the first available skill dir."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
skill_dir = os.path.join(tmpdir, "skills")
|
||||||
|
os.makedirs(skill_dir)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"agent_reach.cli.os.path.expanduser",
|
||||||
|
side_effect=lambda p: p.replace("~", tmpdir),
|
||||||
|
), patch.dict(os.environ, {}, clear=False):
|
||||||
|
# Remove OPENCLAW_HOME to avoid interference
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("OPENCLAW_HOME", None)
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
_install_skill()
|
||||||
|
|
||||||
|
# Check at least one known skill dir pattern
|
||||||
|
for dirpath, _, filenames in os.walk(tmpdir):
|
||||||
|
if "SKILL.md" in filenames:
|
||||||
|
# Verify content is non-empty
|
||||||
|
with open(os.path.join(dirpath, "SKILL.md"), encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
self.assertIn("Agent Reach", content)
|
||||||
|
# _install_skill may or may not find dirs depending on mock; just ensure no crash
|
||||||
|
# The important test is that the function runs without error
|
||||||
|
|
||||||
|
def test_uninstall_skill_removes_dir(self):
|
||||||
|
"""_uninstall_skill should remove skill directories."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create a fake skill installation
|
||||||
|
skill_path = os.path.join(tmpdir, ".openclaw", "skills", "agent-reach")
|
||||||
|
os.makedirs(skill_path)
|
||||||
|
with open(os.path.join(skill_path, "SKILL.md"), "w", encoding="utf-8") as f:
|
||||||
|
f.write("test")
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists(skill_path))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"agent_reach.cli.os.path.expanduser",
|
||||||
|
side_effect=lambda p: p.replace("~", tmpdir),
|
||||||
|
), patch.dict(os.environ, {}, clear=False):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("OPENCLAW_HOME", None)
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
_uninstall_skill()
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists(skill_path))
|
||||||
|
|
||||||
|
def test_install_creates_dir_if_parent_exists(self):
|
||||||
|
"""_install_skill should create agent-reach dir inside existing skill dir."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create the .openclaw/skills parent but not agent-reach subdir
|
||||||
|
skill_parent = os.path.join(tmpdir, ".openclaw", "skills")
|
||||||
|
os.makedirs(skill_parent)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"agent_reach.cli.os.path.expanduser",
|
||||||
|
side_effect=lambda p: p.replace("~", tmpdir),
|
||||||
|
), patch.dict(os.environ, {}, clear=False):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("OPENCLAW_HOME", None)
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
_install_skill()
|
||||||
|
|
||||||
|
target = os.path.join(skill_parent, "agent-reach", "SKILL.md")
|
||||||
|
self.assertTrue(os.path.exists(target))
|
||||||
|
with open(target, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
self.assertIn("Agent Reach", content)
|
||||||
|
|
||||||
|
def test_install_uses_english_skill_for_english_locale(self):
|
||||||
|
"""_install_skill should install the English skill file for English locales."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
skill_parent = os.path.join(tmpdir, ".openclaw", "skills")
|
||||||
|
os.makedirs(skill_parent)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"agent_reach.cli.os.path.expanduser",
|
||||||
|
side_effect=lambda p: p.replace("~", tmpdir),
|
||||||
|
):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("OPENCLAW_HOME", None)
|
||||||
|
env["LANG"] = "en_US.UTF-8"
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
_install_skill()
|
||||||
|
|
||||||
|
target = os.path.join(skill_parent, "agent-reach", "SKILL.md")
|
||||||
|
self.assertTrue(os.path.exists(target))
|
||||||
|
with open(target, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
self.assertTrue(content.strip())
|
||||||
|
self.assertIn("Xiaoyuzhou Podcast, LinkedIn", content)
|
||||||
|
self.assertNotIn("搜推特", content)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.exists(os.path.join(skill_parent, "agent-reach", "references"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for agent_reach.transcribe — provider routing, fallback, and errors."""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent_reach import transcribe as tr
|
||||||
|
from agent_reach.config import Config
|
||||||
|
|
||||||
|
# --- Fixtures ----------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_config(tmp_path, monkeypatch):
|
||||||
|
"""A Config that writes to a temp dir and never touches the user's HOME."""
|
||||||
|
cfg_path = tmp_path / "config.yaml"
|
||||||
|
monkeypatch.setattr(Config, "CONFIG_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(Config, "CONFIG_FILE", cfg_path)
|
||||||
|
cfg = Config(config_path=cfg_path)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chunk_file(tmp_path):
|
||||||
|
p = tmp_path / "chunk.m4a"
|
||||||
|
p.write_bytes(b"\x00fake-m4a-bytes")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, status_code: int, text: str = ""):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return 200 <= self.status_code < 300
|
||||||
|
|
||||||
|
|
||||||
|
# --- transcribe_chunk: provider routing -------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranscribeChunk:
|
||||||
|
def test_routes_to_groq_endpoint(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["headers"] = headers
|
||||||
|
captured["model"] = data["model"]
|
||||||
|
return FakeResponse(200, "hello world")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
|
||||||
|
assert text == "hello world"
|
||||||
|
assert captured["url"] == tr.PROVIDERS["groq"]["endpoint"]
|
||||||
|
assert captured["model"] == "whisper-large-v3"
|
||||||
|
assert captured["headers"]["Authorization"] == "Bearer gsk_test"
|
||||||
|
|
||||||
|
def test_routes_to_openai_endpoint(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["model"] = data["model"]
|
||||||
|
return FakeResponse(200, "openai output")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr.transcribe_chunk(chunk_file, "openai", config=fake_config)
|
||||||
|
assert text == "openai output"
|
||||||
|
assert captured["url"] == tr.PROVIDERS["openai"]["endpoint"]
|
||||||
|
assert captured["model"] == "whisper-1"
|
||||||
|
|
||||||
|
def test_raises_when_key_missing(self, fake_config, chunk_file):
|
||||||
|
with pytest.raises(tr.NoProviderConfigured):
|
||||||
|
tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
|
||||||
|
|
||||||
|
def test_raises_on_http_error(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tr.requests,
|
||||||
|
"post",
|
||||||
|
lambda *a, **k: FakeResponse(429, "rate limited"),
|
||||||
|
)
|
||||||
|
with pytest.raises(tr.TranscribeError, match="HTTP 429"):
|
||||||
|
tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
|
||||||
|
|
||||||
|
def test_unknown_provider(self, fake_config, chunk_file):
|
||||||
|
with pytest.raises(tr.TranscribeError, match="unknown provider"):
|
||||||
|
tr.transcribe_chunk(chunk_file, "azure", config=fake_config)
|
||||||
|
|
||||||
|
|
||||||
|
# --- _transcribe_with_fallback ----------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestFallback:
|
||||||
|
def test_groq_succeeds_no_openai_call(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
calls: List[str] = []
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
calls.append(url)
|
||||||
|
return FakeResponse(200, "from-groq")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||||
|
assert text == "from-groq"
|
||||||
|
assert calls == [tr.PROVIDERS["groq"]["endpoint"]]
|
||||||
|
|
||||||
|
def test_groq_429_falls_back_to_openai(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
calls: List[str] = []
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
calls.append(url)
|
||||||
|
if url == tr.PROVIDERS["groq"]["endpoint"]:
|
||||||
|
return FakeResponse(429, "rate limited")
|
||||||
|
return FakeResponse(200, "from-openai")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||||
|
assert text == "from-openai"
|
||||||
|
assert calls == [
|
||||||
|
tr.PROVIDERS["groq"]["endpoint"],
|
||||||
|
tr.PROVIDERS["openai"]["endpoint"],
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_skip_unconfigured_provider(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
# Only openai key configured — fallback should skip groq silently.
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
calls: List[str] = []
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
calls.append(url)
|
||||||
|
return FakeResponse(200, "via-openai")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||||
|
assert text == "via-openai"
|
||||||
|
assert calls == [tr.PROVIDERS["openai"]["endpoint"]]
|
||||||
|
|
||||||
|
def test_all_fail_raises_with_last_error(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tr.requests,
|
||||||
|
"post",
|
||||||
|
lambda *a, **k: FakeResponse(500, "boom"),
|
||||||
|
)
|
||||||
|
with pytest.raises(tr.TranscribeError, match="all providers failed"):
|
||||||
|
tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||||
|
|
||||||
|
|
||||||
|
# --- transcribe (orchestrator) ---------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrchestrator:
|
||||||
|
def test_local_file_skips_yt_dlp(self, monkeypatch, fake_config, tmp_path, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
|
||||||
|
def boom_download(*a, **k):
|
||||||
|
raise AssertionError("yt-dlp must not be called for local files")
|
||||||
|
|
||||||
|
# Stub heavy external steps to no-ops that keep file paths valid.
|
||||||
|
compressed = tmp_path / "compressed.m4a"
|
||||||
|
compressed.write_bytes(b"x" * 1024)
|
||||||
|
|
||||||
|
def fake_compress(src, out_dir):
|
||||||
|
return compressed
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr, "download_audio", boom_download)
|
||||||
|
monkeypatch.setattr(tr, "compress_audio", fake_compress)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tr.requests,
|
||||||
|
"post",
|
||||||
|
lambda *a, **k: FakeResponse(200, "transcript text"),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = tr.transcribe(
|
||||||
|
str(chunk_file),
|
||||||
|
out_dir=tmp_path / "work",
|
||||||
|
config=fake_config,
|
||||||
|
)
|
||||||
|
assert text == "transcript text"
|
||||||
|
|
||||||
|
def test_chunks_concatenated_with_newlines(
|
||||||
|
self, monkeypatch, fake_config, tmp_path, chunk_file
|
||||||
|
):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
# Force the "needs chunking" path by writing a file above the size limit.
|
||||||
|
big = tmp_path / "compressed.m4a"
|
||||||
|
big.write_bytes(b"x" * (tr.SIZE_LIMIT_BYTES + 1))
|
||||||
|
monkeypatch.setattr(tr, "compress_audio", lambda src, out_dir: big)
|
||||||
|
c1 = tmp_path / "chunk_001.m4a"
|
||||||
|
c2 = tmp_path / "chunk_002.m4a"
|
||||||
|
c1.write_bytes(b"a")
|
||||||
|
c2.write_bytes(b"b")
|
||||||
|
monkeypatch.setattr(tr, "chunk_audio", lambda src, out_dir: [c1, c2])
|
||||||
|
|
||||||
|
responses = iter(["part one ", "part two "])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tr.requests,
|
||||||
|
"post",
|
||||||
|
lambda *a, **k: FakeResponse(200, next(responses)),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = tr.transcribe(
|
||||||
|
str(chunk_file),
|
||||||
|
out_dir=tmp_path / "work",
|
||||||
|
config=fake_config,
|
||||||
|
)
|
||||||
|
assert text == "part one\npart two"
|
||||||
|
|
||||||
|
def test_no_provider_configured_fails_fast(self, fake_config, chunk_file):
|
||||||
|
with pytest.raises(tr.NoProviderConfigured):
|
||||||
|
tr.transcribe(str(chunk_file), config=fake_config)
|
||||||
|
|
||||||
|
def test_invalid_provider_string(self, fake_config, chunk_file):
|
||||||
|
with pytest.raises(tr.TranscribeError, match="unknown provider"):
|
||||||
|
tr.transcribe(str(chunk_file), provider="azure", config=fake_config)
|
||||||
|
|
||||||
|
|
||||||
|
# --- YouTubeChannel integration --------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestYouTubeChannelTranscribe:
|
||||||
|
def test_delegates_to_transcribe(self, monkeypatch, fake_config):
|
||||||
|
from agent_reach.channels.youtube import YouTubeChannel
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_transcribe(source, *, provider="auto", out_dir=None, config=None):
|
||||||
|
captured["source"] = source
|
||||||
|
captured["provider"] = provider
|
||||||
|
captured["config"] = config
|
||||||
|
return "delegated text"
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr, "transcribe", fake_transcribe)
|
||||||
|
out = YouTubeChannel().transcribe(
|
||||||
|
"https://youtu.be/abc", provider="groq", config=fake_config
|
||||||
|
)
|
||||||
|
assert out == "delegated text"
|
||||||
|
assert captured["source"] == "https://youtu.be/abc"
|
||||||
|
assert captured["provider"] == "groq"
|
||||||
|
assert captured["config"] is fake_config
|
||||||
|
|
||||||
|
|
||||||
|
# --- Config feature requirement --------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigOpenAIWhisper:
|
||||||
|
def test_openai_whisper_feature_registered(self, fake_config):
|
||||||
|
assert "openai_whisper" in Config.FEATURE_REQUIREMENTS
|
||||||
|
assert Config.FEATURE_REQUIREMENTS["openai_whisper"] == ["openai_api_key"]
|
||||||
|
assert not fake_config.is_configured("openai_whisper")
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
assert fake_config.is_configured("openai_whisper")
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from unittest.mock import patch, Mock
|
||||||
|
|
||||||
|
from agent_reach.channels.twitter import TwitterChannel
|
||||||
|
|
||||||
|
|
||||||
|
def _cp(stdout="", stderr="", returncode=0):
|
||||||
|
m = Mock()
|
||||||
|
m.stdout = stdout
|
||||||
|
m.stderr = stderr
|
||||||
|
m.returncode = returncode
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# --- twitter-cli tests ---
|
||||||
|
|
||||||
|
def test_check_twitter_cli_found_and_auth_ok():
|
||||||
|
"""twitter-cli found + twitter status ok → ok."""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch(
|
||||||
|
"subprocess.run",
|
||||||
|
return_value=_cp(stdout="ok: true\nusername: testuser\n", returncode=0),
|
||||||
|
):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "ok"
|
||||||
|
assert "twitter-cli" in message
|
||||||
|
assert "完整可用" in message
|
||||||
|
assert channel.active_backend == "twitter-cli"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_twitter_cli_found_auth_missing():
|
||||||
|
"""twitter-cli found + not_authenticated → warn about auth."""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch(
|
||||||
|
"subprocess.run",
|
||||||
|
return_value=_cp(
|
||||||
|
stderr="ok: false\nerror:\n code: not_authenticated\n",
|
||||||
|
returncode=1,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "warn"
|
||||||
|
assert "未认证" in message
|
||||||
|
# 未认证是业务态:工具进程活着,后端仍可用
|
||||||
|
assert channel.active_backend == "twitter-cli"
|
||||||
|
|
||||||
|
|
||||||
|
# --- bird CLI fallback tests ---
|
||||||
|
|
||||||
|
def test_check_bird_fallback_auth_ok():
|
||||||
|
"""No twitter-cli, but bird found + bird check ok → ok."""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
def which_side_effect(name):
|
||||||
|
if name == "bird":
|
||||||
|
return "/usr/local/bin/bird"
|
||||||
|
return None
|
||||||
|
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||||
|
"subprocess.run",
|
||||||
|
return_value=_cp(stdout="Authenticated as @user\n", returncode=0),
|
||||||
|
):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "ok"
|
||||||
|
assert "bird" in message
|
||||||
|
assert channel.active_backend == "bird CLI (legacy)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_bird_fallback_auth_missing():
|
||||||
|
"""No twitter-cli, bird found but Missing credentials → warn."""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
def which_side_effect(name):
|
||||||
|
if name == "bird":
|
||||||
|
return "/usr/local/bin/bird"
|
||||||
|
return None
|
||||||
|
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||||
|
"subprocess.run",
|
||||||
|
return_value=_cp(stderr="Missing credentials\n", returncode=1),
|
||||||
|
):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "warn"
|
||||||
|
assert "未配置认证" in message
|
||||||
|
|
||||||
|
|
||||||
|
# --- neither installed ---
|
||||||
|
|
||||||
|
def test_check_nothing_installed():
|
||||||
|
"""Neither twitter-cli nor bird → warn with install hint."""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
with patch("shutil.which", return_value=None):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "warn"
|
||||||
|
assert "twitter-cli" in message
|
||||||
|
assert channel.active_backend is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- twitter-cli preferred over bird ---
|
||||||
|
|
||||||
|
def test_twitter_cli_preferred_over_bird():
|
||||||
|
"""When both are installed, twitter-cli is used."""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
def which_side_effect(name):
|
||||||
|
if name == "twitter":
|
||||||
|
return "/usr/local/bin/twitter"
|
||||||
|
if name == "bird":
|
||||||
|
return "/usr/local/bin/bird"
|
||||||
|
return None
|
||||||
|
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||||
|
"subprocess.run",
|
||||||
|
return_value=_cp(stdout="ok: true\n", returncode=0),
|
||||||
|
):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "ok"
|
||||||
|
assert "twitter-cli" in message
|
||||||
|
assert channel.active_backend == "twitter-cli"
|
||||||
|
|
||||||
|
|
||||||
|
# --- broken install (stale venv shim) ---
|
||||||
|
|
||||||
|
def test_check_twitter_cli_broken_reports_error_with_reinstall_hint():
|
||||||
|
"""which 命中但 exec 抛 FileNotFoundError(venv 断链)→ error + 重装处方。"""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
with patch(
|
||||||
|
"shutil.which",
|
||||||
|
side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None,
|
||||||
|
), patch("subprocess.run", side_effect=FileNotFoundError("/usr/local/bin/twitter")):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "error"
|
||||||
|
assert "无法执行" in message
|
||||||
|
assert "uv tool install --force twitter-cli" in message
|
||||||
|
assert "pipx reinstall twitter-cli" in message
|
||||||
|
assert channel.active_backend is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_twitter_cli_broken_falls_back_to_bird():
|
||||||
|
"""twitter-cli 断链但 bird 健康 → 回退到 bird,后端正确归属。"""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
|
||||||
|
def which_side_effect(name):
|
||||||
|
if name in ("twitter", "bird"):
|
||||||
|
return f"/usr/local/bin/{name}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run_side_effect(cmd, **kwargs):
|
||||||
|
if "twitter" in cmd[0]:
|
||||||
|
raise FileNotFoundError(cmd[0])
|
||||||
|
return _cp(stdout="Authenticated as @user\n", returncode=0)
|
||||||
|
|
||||||
|
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||||
|
"subprocess.run", side_effect=run_side_effect
|
||||||
|
):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "ok"
|
||||||
|
assert "bird" in message
|
||||||
|
assert channel.active_backend == "bird CLI (legacy)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_twitter_cli_does_not_block_working_opencli():
|
||||||
|
"""warn 候选不得屏蔽排在后面的 ok 候选(Codex review 发现)。"""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
with patch.object(
|
||||||
|
TwitterChannel, "_check_twitter_cli",
|
||||||
|
return_value=("warn", "twitter-cli 已安装但未认证"),
|
||||||
|
), patch.object(
|
||||||
|
TwitterChannel, "_check_opencli",
|
||||||
|
return_value=("ok", "OpenCLI 可用(复用浏览器登录态)"),
|
||||||
|
), patch.object(TwitterChannel, "_check_bird", return_value=None):
|
||||||
|
status, msg = channel.check()
|
||||||
|
assert status == "ok"
|
||||||
|
assert channel.active_backend == "OpenCLI"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_warn_falls_back_to_first_warn():
|
||||||
|
channel = TwitterChannel()
|
||||||
|
with patch.object(
|
||||||
|
TwitterChannel, "_check_twitter_cli",
|
||||||
|
return_value=("warn", "twitter-cli 未认证"),
|
||||||
|
), patch.object(
|
||||||
|
TwitterChannel, "_check_opencli",
|
||||||
|
return_value=("warn", "扩展未连接"),
|
||||||
|
), patch.object(TwitterChannel, "_check_bird", return_value=None):
|
||||||
|
status, msg = channel.check()
|
||||||
|
assert status == "warn"
|
||||||
|
assert channel.active_backend == "twitter-cli"
|
||||||
|
assert "未认证" in msg
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for XiaoHongShu output formatter (issue #134)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from agent_reach.channels.xiaohongshu import format_xhs_result
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatXhsResult(unittest.TestCase):
|
||||||
|
"""Test format_xhs_result strips redundant fields."""
|
||||||
|
|
||||||
|
SAMPLE_NOTE = {
|
||||||
|
"id": "abc123",
|
||||||
|
"title": "测试笔记",
|
||||||
|
"desc": "这是正文内容",
|
||||||
|
"type": "normal",
|
||||||
|
"xsec_token": "tok_xxx",
|
||||||
|
"user": {
|
||||||
|
"nickname": "小红",
|
||||||
|
"user_id": "u123",
|
||||||
|
"avatar": "https://example.com/avatar.jpg",
|
||||||
|
"extra_field": "should be dropped",
|
||||||
|
},
|
||||||
|
"interact_info": {
|
||||||
|
"liked_count": "100",
|
||||||
|
"collected_count": "50",
|
||||||
|
"comment_count": "20",
|
||||||
|
"share_count": "10",
|
||||||
|
"sticky_count": "0",
|
||||||
|
"relation": "none",
|
||||||
|
},
|
||||||
|
"image_list": [
|
||||||
|
{
|
||||||
|
"url": "https://img.example.com/1.jpg",
|
||||||
|
"info_list": [{"url": "https://img.example.com/1_small.jpg", "image_scene": "WB_DFT"}],
|
||||||
|
"width": 1080,
|
||||||
|
"height": 1440,
|
||||||
|
"trace_id": "tr_123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://img.example.com/2.jpg",
|
||||||
|
"info_list": [{"url": "https://img.example.com/2_small.jpg"}],
|
||||||
|
"width": 1080,
|
||||||
|
"height": 1080,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"tag_list": [
|
||||||
|
{"id": "t1", "name": "旅行", "type": "topic"},
|
||||||
|
{"id": "t2", "name": "美食", "type": "topic"},
|
||||||
|
],
|
||||||
|
"at_user_list": [],
|
||||||
|
"geo_info": {"latitude": 0, "longitude": 0},
|
||||||
|
"audit_info": {"audit_status": 0},
|
||||||
|
"model_type": None,
|
||||||
|
"note_flow_source": "search",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_single_note_keeps_useful_fields(self):
|
||||||
|
result = format_xhs_result(self.SAMPLE_NOTE)
|
||||||
|
self.assertEqual(result["id"], "abc123")
|
||||||
|
self.assertEqual(result["title"], "测试笔记")
|
||||||
|
self.assertEqual(result["desc"], "这是正文内容")
|
||||||
|
self.assertEqual(result["type"], "normal")
|
||||||
|
self.assertEqual(result["user"]["nickname"], "小红")
|
||||||
|
self.assertEqual(result["liked_count"], "100")
|
||||||
|
self.assertEqual(result["collected_count"], "50")
|
||||||
|
self.assertEqual(result["images"], [
|
||||||
|
"https://img.example.com/1.jpg",
|
||||||
|
"https://img.example.com/2.jpg",
|
||||||
|
])
|
||||||
|
self.assertEqual(result["tags"], ["旅行", "美食"])
|
||||||
|
|
||||||
|
def test_single_note_drops_useless_fields(self):
|
||||||
|
result = format_xhs_result(self.SAMPLE_NOTE)
|
||||||
|
self.assertNotIn("at_user_list", result)
|
||||||
|
self.assertNotIn("geo_info", result)
|
||||||
|
self.assertNotIn("audit_info", result)
|
||||||
|
self.assertNotIn("model_type", result)
|
||||||
|
self.assertNotIn("note_flow_source", result)
|
||||||
|
# User should not have extra fields
|
||||||
|
self.assertNotIn("avatar", result.get("user", {}))
|
||||||
|
self.assertNotIn("extra_field", result.get("user", {}))
|
||||||
|
|
||||||
|
def test_search_results_wrapper(self):
|
||||||
|
"""Handle {"items": [...]} wrapper from search_feeds."""
|
||||||
|
wrapped = {"items": [self.SAMPLE_NOTE, self.SAMPLE_NOTE]}
|
||||||
|
result = format_xhs_result(wrapped)
|
||||||
|
self.assertIsInstance(result, list)
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertEqual(result[0]["title"], "测试笔记")
|
||||||
|
|
||||||
|
def test_list_input(self):
|
||||||
|
result = format_xhs_result([self.SAMPLE_NOTE])
|
||||||
|
self.assertIsInstance(result, list)
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]["title"], "测试笔记")
|
||||||
|
|
||||||
|
def test_note_card_wrapper(self):
|
||||||
|
"""Handle notes nested under 'note_card'."""
|
||||||
|
wrapped = {"note_card": self.SAMPLE_NOTE}
|
||||||
|
result = format_xhs_result(wrapped)
|
||||||
|
self.assertEqual(result["title"], "测试笔记")
|
||||||
|
|
||||||
|
def test_with_comments(self):
|
||||||
|
note = dict(self.SAMPLE_NOTE)
|
||||||
|
note["comments"] = [
|
||||||
|
{
|
||||||
|
"content": "写得好!",
|
||||||
|
"user_info": {"nickname": "路人甲", "user_id": "u456"},
|
||||||
|
"like_count": 5,
|
||||||
|
"sub_comment_count": 1,
|
||||||
|
"ip_location": "上海",
|
||||||
|
"status": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = format_xhs_result(note)
|
||||||
|
self.assertEqual(len(result["comments"]), 1)
|
||||||
|
self.assertEqual(result["comments"][0]["content"], "写得好!")
|
||||||
|
self.assertEqual(result["comments"][0]["user"], "路人甲")
|
||||||
|
self.assertEqual(result["comments"][0]["like_count"], 5)
|
||||||
|
self.assertNotIn("ip_location", result["comments"][0])
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
self.assertEqual(format_xhs_result({}), {})
|
||||||
|
self.assertEqual(format_xhs_result([]), [])
|
||||||
|
|
||||||
|
def test_non_dict_passthrough(self):
|
||||||
|
self.assertEqual(format_xhs_result("hello"), "hello")
|
||||||
|
self.assertIsNone(format_xhs_result(None))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import agent_reach.cli as cli
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyConfig:
|
||||||
|
def get(self, _key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_xiaoyuzhou_deps_does_not_raise_when_no_groq_key(capsys):
|
||||||
|
with patch("agent_reach.config.Config", return_value=_DummyConfig()), \
|
||||||
|
patch("os.path.isfile", side_effect=lambda p: True if str(p).endswith("transcribe.sh") else False), \
|
||||||
|
patch("shutil.which", return_value=None):
|
||||||
|
cli._install_xiaoyuzhou_deps()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Xiaoyuzhou" in out
|
||||||
|
assert "Groq API key not set" in out
|
||||||
Reference in New Issue
Block a user