Skip to content
Merged
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,20 +153,20 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
> 帮我更新 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
> ```

> 🛡️ **担心安全?** 可以用安全模式——不会自动装系统包,只告诉你需要什么
> 🛡️ **默认安全:** `agent-reach install` 默认只检查环境,不会自动装系统包或写入配置
> ```
> 帮我安装 Agent Reach(安全模式):https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
> 安装时使用 --safe 参数
> 帮我安全检查并安装 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
> ```
> 只有在你明确允许修改系统后,才使用 `agent-reach install --system`。

<details>
<summary>它会做什么?(点击展开)</summary>

1. **安装 CLI 工具** — 从本仓库安装 `agent-reach` 命令行(自带 yt-dlp、feedparser;不要从 PyPI 安装同名包,它不是本项目)
2. **安装系统基建** — 自动检测并安装 Node.js、gh CLI、mcporter
3. **配置搜索引擎** — 通过 MCP 接入 Exa(免费,无需 API Key)
2. **检查系统基建** — 检查 Node.js、gh CLI、mcporter,并给出缺失项的安装方式
3. **按授权安装与配置** — 仅在显式传入 `--system` 时安装依赖并通过 MCP 接入 Exa
4. **检测环境** — 判断是本地电脑还是服务器,给出对应的配置建议
5. **注册 SKILL.md** — Agent 的 skills 目录安装使用指南,以后 Agent 遇到"全网调研"、"搜推特"、"看视频"这类需求,会自动知道该调哪个上游工具
5. **按授权注册 SKILL.md** — 仅在显式 `--system` 时写入 Agent 的 skills 目录;默认检查不改文件
6. **问你要不要更多** — 默认只激活 6 个零配置渠道;小红书、Twitter、Reddit、Facebook、Instagram 这些需要登录态的,Agent 会列菜单问你要哪些,点名才装

安装完之后,`agent-reach doctor` 一条命令告诉你每个渠道的状态、当前走哪条路。
Expand Down Expand Up @@ -212,7 +212,7 @@ channels/
├── facebook.py → OpenCLI(桌面浏览器登录态)
├── instagram.py → OpenCLI(桌面浏览器登录态)
├── xiaohongshu.py → OpenCLI ▸ xiaohongshu-mcp ▸ xhs-cli
├── linkedin.py → linkedin-mcp ▸ Jina Reader
├── linkedin.py → mcp-server-linkedin ▸ Jina Reader
├── rss.py → feedparser
├── exa_search.py → Exa via mcporter
└── __init__.py → 渠道注册(doctor 检测用)
Expand All @@ -235,7 +235,7 @@ channels/
| GitHub | [gh CLI](https://cli.github.com) | — | 官方工具,认证后完整 API 能力 |
| 读 RSS | [feedparser](https://github.com/kurtmckee/feedparser) | — | Python 生态标准选择 |
| 小红书 | [OpenCLI](https://github.com/jackwener/opencli)(桌面) | [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp)(服务器)▸ xhs-cli | OpenCLI 只用用户已有会话;其余后端用 Cookie-Editor 手工导出 |
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP 服务,浏览器自动化 |
| LinkedIn | [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP 服务,浏览器自动化 |

> 📌 这些都是「当前选型」,基于真机实测定期复核。某条路失效了我们换下一条——`agent-reach doctor` 永远告诉你现在走的是哪条。

Expand All @@ -248,7 +248,7 @@ Agent Reach 在设计上重视安全:
| 措施 | 说明 |
|------|------|
| 🔒 **凭据本地存储** | Cookie、Token 只存在你本机 `~/.agent-reach/config.yaml`,文件权限 600(仅所有者可读写),不上传不外传 |
| 🛡️ **安全模式** | `agent-reach install --safe` 不会自动修改系统,只列出需要什么,由你决定装不装 |
| 🛡️ **默认安全** | `agent-reach install` 默认不修改系统;只有显式 `--system` 才安装外部工具和写入配置 |
| 👀 **完全开源** | 代码透明,随时可审查。所有依赖工具也是开源项目 |
| 🔍 **Dry Run** | `agent-reach install --dry-run` 预览所有操作,不做任何改动 |
| 🧩 **可插拔架构** | 不信任某个组件?换掉对应的 channel 文件即可,不影响其他 |
Expand All @@ -265,8 +265,9 @@ Agent Reach 在设计上重视安全:

| 方式 | 命令 | 适合场景 |
|------|------|---------|
| 一键全自动(默认) | `agent-reach install --env=auto` | 个人电脑、开发环境 |
| 安全模式 | `agent-reach install --env=auto --safe` | 生产服务器、多人共用机器 |
| 默认安全检查 | `agent-reach install --env=auto` | 所有环境;只读检查并列出缺失项 |
| 显式安装系统依赖 | `agent-reach install --env=auto --system` | 你明确允许修改当前机器时 |
| 兼容安全参数 | `agent-reach install --env=auto --safe` | 与默认行为相同 |
| 仅预览 | `agent-reach install --env=auto --dry-run` | 先看看会做什么 |

### 🗑️ 卸载
Expand Down Expand Up @@ -305,7 +306,7 @@ Star 一下,下次需要的时候能找到。⭐

## 致谢

[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)
[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) · [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server)

## 联系

Expand Down
2 changes: 1 addition & 1 deletion agent_reach/channels/_opencli_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def check(self, config=None):
if not st.installed:
return "off", (
f"未安装 {self.description} 后端。安装:\n"
" agent-reach install --channels opencli\n"
" agent-reach install --system --channels opencli\n"
f"然后在 Chrome 里登录 {self.login_hint}"
)
if st.broken:
Expand Down
2 changes: 1 addition & 1 deletion agent_reach/channels/bilibili.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def check(self, config=None):
return "off", (
"没有可用的 B站后端(搜索 API 也不可达,可能是网络问题)。推荐:\n"
" pipx install bilibili-cli(搜索/热门/视频详情,无需登录)\n"
" 或桌面装 OpenCLI(额外解锁字幕):agent-reach install --channels opencli"
" 或桌面装 OpenCLI(额外解锁字幕):agent-reach install --system --channels opencli"
)

def _check_bili_cli(self):
Expand Down
35 changes: 26 additions & 9 deletions agent_reach/channels/linkedin.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
# -*- coding: utf-8 -*-
"""LinkedIn — check if linkedin-scraper-mcp is available."""
"""LinkedIn — check if mcp-server-linkedin is configured."""

import shutil

from .base import Channel
from .mcporter import McporterConfigError, inspect_mcporter_config

_LINKEDIN_SERVER_NAMES = {"linkedin", "linkedin-scraper", "linkedin-scraper-mcp"}
_LINKEDIN_SERVER_NAMES = {
"linkedin",
"linkedin-scraper",
"linkedin-scraper-mcp",
"mcp-server-linkedin",
}
_LOGIN_COMMAND = "uvx mcp-server-linkedin@latest --login"
_UV_INSTALL_URL = "https://docs.astral.sh/uv/getting-started/installation/"
_CONFIG_COMMAND = (
"mcporter config add linkedin --command uvx "
"--arg mcp-server-linkedin@latest --env UV_HTTP_TIMEOUT=300 --scope home"
)


class LinkedInChannel(Channel):
name = "linkedin"
description = "LinkedIn 职业社交"
backends = ["linkedin-scraper-mcp", "Jina Reader"]
backends = ["mcp-server-linkedin", "Jina Reader"]
tier = 2

def can_handle(self, url: str) -> bool:
Expand All @@ -25,16 +36,22 @@ def check(self, config=None):
if not shutil.which("mcporter"):
return "off", (
"基本内容可通过 Jina Reader 读取。完整功能需要:\n"
" pip install linkedin-scraper-mcp\n"
" mcporter config add linkedin http://localhost:3000/mcp "
"--scope home\n"
f" 先安装 uv/uvx:{_UV_INSTALL_URL}\n"
f" {_LOGIN_COMMAND}\n"
f" {_CONFIG_COMMAND}\n"
" 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
)
try:
inspection = inspect_mcporter_config()
except McporterConfigError as exc:
return "error", f"mcporter 配置检查失败:{exc}"
if inspection.server_names & _LINKEDIN_SERVER_NAMES:
if not shutil.which("uvx"):
return "warn", (
"LinkedIn MCP 已写入 mcporter 配置,但 uvx 未安装,"
"当前无法启动服务。安装:\n"
f" {_UV_INSTALL_URL}"
)
return "warn", (
"LinkedIn MCP 已写入 mcporter 配置,但 Doctor 未启动本地"
"服务做连通验证,不能仅凭配置宣称完整可用。"
Expand All @@ -46,7 +63,7 @@ def check(self, config=None):
)
return "off", (
"mcporter 已装但 LinkedIn MCP 未配置。运行:\n"
" pip install linkedin-scraper-mcp\n"
" mcporter config add linkedin http://localhost:3000/mcp "
"--scope home"
f" 先安装 uv/uvx:{_UV_INSTALL_URL}\n"
f" {_LOGIN_COMMAND}\n"
f" {_CONFIG_COMMAND}"
)
2 changes: 1 addition & 1 deletion agent_reach/channels/reddit.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def check(self, config=None):
return "off", (
"未安装任何 Reddit 后端。注意:Reddit 没有零配置路径"
"(匿名 .json 已被封,官方 API 需人工审批),必须用登录态。推荐:\n"
" 桌面:agent-reach install --channels opencli\n"
" 桌面:agent-reach install --system --channels opencli\n"
" (复用 Chrome 登录态,登录过 reddit.com 即可用)\n"
f" 服务器/存量:pipx install '{_RDT_GIT_SOURCE}'\n"
" 然后 `rdt login` 或手动写入 Cookie(见 doctor 提示)\n"
Expand Down
3 changes: 1 addition & 2 deletions agent_reach/channels/twitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,7 @@ def _check_twitter_cli(self, config=None):
return "warn", (
"twitter-cli 已安装但没有完整的显式凭据。请用 Cookie-Editor "
"从 x.com 导出后运行:\n"
" agent-reach configure twitter-cookies "
"'<Cookie-Editor Header String>'\n"
" agent-reach configure twitter-cookies\n"
"Doctor 不会自动读取浏览器 Cookie。"
)

Expand Down
119 changes: 115 additions & 4 deletions agent_reach/channels/v2ex.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,133 @@
"""V2EX — public API channel for topics, nodes, users, and replies."""

import json
import shutil
import ssl
import subprocess
import urllib.request
from typing import Any
from urllib.parse import urlsplit

from agent_reach.utils.process import utf8_subprocess_env
from agent_reach.utils.text import scrub_url_credentials

from .base import Channel

_UA = "agent-reach/1.0"
_TIMEOUT = 10
_MAX_RESPONSE_BYTES = 1024 * 1024


def _validate_api_url(url: str) -> None:
"""Allow only the public V2EX HTTPS JSON API."""
try:
parsed = urlsplit(url)
port = parsed.port
except ValueError as exc:
raise ValueError("invalid V2EX API URL") from exc
if (
parsed.scheme.lower() != "https"
or (parsed.hostname or "").lower() not in {"v2ex.com", "www.v2ex.com"}
or port not in {None, 443}
or parsed.username is not None
or parsed.password is not None
or not parsed.path.startswith("/api/")
):
raise ValueError("only the V2EX HTTPS API is allowed")


def _get_json_with_urllib(url: str) -> Any:
"""Fetch JSON with Python's standard HTTP stack."""
_validate_api_url(url)
req = urllib.request.Request(url, headers={"User-Agent": _UA})
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
raw = resp.read(_MAX_RESPONSE_BYTES + 1)
if len(raw) > _MAX_RESPONSE_BYTES:
raise ValueError("V2EX API response exceeds the 1 MiB safety limit")
return json.loads(raw.decode("utf-8"))


def _is_unexpected_tls_eof(error: BaseException) -> bool:
"""Return whether an exception chain contains the retryable TLS EOF."""
pending: list[BaseException] = [error]
seen: set[int] = set()
while pending:
current = pending.pop()
if id(current) in seen:
continue
seen.add(id(current))
if isinstance(current, ssl.SSLError) and not isinstance(
current, ssl.SSLCertVerificationError
):
text = str(current).casefold()
if (
"unexpected_eof_while_reading" in text
or "eof occurred in violation of protocol" in text
):
return True
for nested in (
getattr(current, "reason", None),
current.__cause__,
current.__context__,
):
if isinstance(nested, BaseException):
pending.append(nested)
return False


def _get_json_with_curl(url: str) -> Any:
"""Fetch bounded JSON with the OS curl TLS stack."""
_validate_api_url(url)
curl = shutil.which("curl")
if not curl:
raise RuntimeError("curl is unavailable for the V2EX TLS fallback")

command = [
curl,
"--fail",
"--silent",
"--show-error",
"--proto",
"=https",
"--connect-timeout",
"5",
"--max-time",
str(_TIMEOUT),
"--max-filesize",
str(_MAX_RESPONSE_BYTES),
"--header",
f"User-Agent: {_UA}",
"--url",
url,
]
try:
result = subprocess.run(
command,
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=_TIMEOUT + 2,
env=utf8_subprocess_env(),
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError("curl could not complete the V2EX TLS fallback") from exc
if result.returncode != 0:
raise RuntimeError("curl could not complete the V2EX TLS fallback")
if len(result.stdout.encode("utf-8")) > _MAX_RESPONSE_BYTES:
raise ValueError("V2EX API response exceeds the 1 MiB safety limit")
return json.loads(result.stdout)


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"))
"""Fetch JSON, retrying only Python's known TLS EOF via native curl."""
try:
return _get_json_with_urllib(url)
except Exception as exc:
if isinstance(exc, ssl.SSLCertVerificationError):
raise
if not _is_unexpected_tls_eof(exc):
raise
return _get_json_with_curl(url)


class V2EXChannel(Channel):
Expand Down
4 changes: 2 additions & 2 deletions agent_reach/channels/xiaohongshu.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,11 @@ def check(self, config=None):

return "off", (
"未安装任何小红书后端。推荐:\n"
" 桌面:agent-reach install --channels opencli\n"
" 桌面:agent-reach install --system --channels opencli\n"
" (复用 Chrome 登录态,刷过小红书即零配置可用)\n"
f" 服务器:xiaohongshu-mcp:{_MCP_INSTALL_URL}\n"
" 登录只使用 Cookie-Editor 明确导出:\n"
" agent-reach configure xhs-cookies '<Cookie-Editor export>'"
" agent-reach configure xhs-cookies(隐藏输入)"
)

def _check_opencli(self):
Expand Down
4 changes: 2 additions & 2 deletions agent_reach/channels/xiaoyuzhou.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def check(self, config=None):
if not os.path.isfile(script):
return "off", (
"转录脚本未安装。运行:\n"
" agent-reach install --env=auto\n"
" agent-reach install --env=auto --system --channels=xiaoyuzhou\n"
" 或手动复制 transcribe.sh 到 ~/.agent-reach/tools/xiaoyuzhou/"
)

Expand All @@ -58,7 +58,7 @@ def check(self, config=None):
return "warn", (
"需要配置 Groq API Key(免费)。步骤:\n"
" 1. 注册 https://console.groq.com\n"
" 2. 运行: agent-reach configure groq-key gsk_xxxxx"
" 2. 运行: agent-reach configure groq-key(隐藏输入)"
)

self.active_backend = "groq-whisper"
Expand Down
20 changes: 16 additions & 4 deletions agent_reach/channels/youtube.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def check(self, config=None):
if not has_js:
return "warn", (
"yt-dlp 已安装但缺少 JS runtime(YouTube 必须)。\n"
" 安装 Node.js 或 deno,然后运行:agent-reach install"
" 安装 Node.js 或 deno,然后运行:agent-reach install --system"
)
# Check yt-dlp config for --js-runtimes
# Deno works out of the box; Node.js requires explicit config
Expand Down Expand Up @@ -114,10 +114,17 @@ def check(self, config=None):
+ ")"
)
else:
msg += f",可转写音频({''.join(providers)})"
msg += f",可转写音频({'/'.join(providers)})"
return "ok", msg

def transcribe(self, url: str, *, provider: str = "auto", config=None) -> str:
def transcribe(
self,
url: str,
*,
provider: str = "auto",
config=None,
allow_provider_fallback: bool = False,
) -> str:
"""Download a YouTube video's audio and return its transcript.

Delegates to :func:`agent_reach.transcribe.transcribe`. Imported lazily
Expand All @@ -126,4 +133,9 @@ def transcribe(self, url: str, *, provider: str = "auto", config=None) -> str:
"""
from agent_reach.transcribe import transcribe as _transcribe

return _transcribe(url, provider=provider, config=config)
return _transcribe(
url,
provider=provider,
config=config,
allow_provider_fallback=allow_provider_fallback,
)
Loading
Loading