diff --git a/agent_reach/channels/reddit.py b/agent_reach/channels/reddit.py index 025b339..cece520 100644 --- a/agent_reach/channels/reddit.py +++ b/agent_reach/channels/reddit.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- -"""Reddit — search and read via rdt-cli (public-clis/rdt-cli). +"""Reddit — multi-backend: OpenCLI / rdt-cli. Login is mandatory. -NOTE: Reddit requires authentication since 2024. All API requests -(including public subreddit reads) return HTTP 403 without a valid -session cookie. Run `rdt login` after installation to authenticate. +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 @@ -32,8 +35,8 @@ _RDT_BROKEN_HINT = ( class RedditChannel(Channel): name = "reddit" description = "Reddit 帖子和评论" - backends = ["rdt-cli"] - tier = 1 # Reddit requires login since 2024 (rdt login) — not zero-config + backends = ["OpenCLI", "rdt-cli"] + tier = 1 # no zero-config path exists — see module docstring def can_handle(self, url: str) -> bool: from urllib.parse import urlparse @@ -42,19 +45,59 @@ class RedditChannel(Channel): return "reddit.com" in d or "redd.it" in d def check(self, config=None): + """Probe candidates in order; first fully-usable backend wins.""" self.active_backend = None + findings = [] + for backend in self.ordered_backends(config): + 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 "off", ( - "需要安装 rdt-cli。PyPI 版本可能暂时落后,推荐直接从 GitHub 安装:\n" - f" pipx install '{_RDT_GIT_SOURCE}'\n" - "如已确认 PyPI 版本已更新,也可使用:\n" - " pipx install rdt-cli\n" - " uv tool install rdt-cli\n" - "最新源码:https://github.com/public-clis/rdt-cli\n" - "安装后运行 `rdt login` 登录(需先在浏览器登录 reddit.com)" - ) + return None # 不走 probe_command:实测 `rdt status --json` 成功时(rc=0)也会向 stderr # 打网络重试日志,probe 把 stdout+stderr 合并后 JSON 解析必炸。 @@ -83,9 +126,7 @@ class RedditChannel(Channel): tail = detail[-1] if detail else "无输出" return "error", f"rdt 异常退出(exit {r.returncode}):{tail}。运行 `rdt status` 查看详情" - # 进程正常退出 → rdt 本身是活的(无论登录与否),后端即为可用 - self.active_backend = "rdt-cli" - + # 进程正常退出 → rdt 本身是活的(无论登录与否) try: data = json.loads(r.stdout or "") except json.JSONDecodeError: @@ -101,7 +142,10 @@ class RedditChannel(Channel): if authenticated: suffix = f"(已登录:{username})" if username else "" - return "ok", (f"rdt-cli 可用{suffix}(搜索帖子、阅读全文、查看评论)") + return "ok", ( + f"rdt-cli 可用{suffix}(搜索帖子、阅读全文、查看评论;" + "上游 2026-03 起停更,桌面用户建议迁移到 OpenCLI)" + ) return "warn", ( "rdt-cli 已安装但未登录。Reddit 自 2024 年起要求认证," diff --git a/agent_reach/cli.py b/agent_reach/cli.py index e9e8bec..51c999d 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -774,7 +774,24 @@ def _install_opencli_deps(): def _install_reddit_deps(): - """Install rdt-cli for Reddit search + reading.""" + """Set up Reddit — desktop prefers OpenCLI, rdt-cli for servers/legacy. + + No zero-config path exists (anonymous .json blocked, official API + approval-gated since 2025-11) — every backend needs a logged-in session. + """ + if _detect_environment() != "server": + _install_opencli_deps() + print(" Reddit 走 OpenCLI(浏览器里登录过 reddit.com 即可用)") + import shutil + if shutil.which("rdt"): + print(" ✅ 检测到存量 rdt-cli,将作为备选后端继续可用") + return + + _install_rdt_cli() + + +def _install_rdt_cli(): + """Install rdt-cli (pinned git source — PyPI lags upstream).""" import shutil import subprocess diff --git a/agent_reach/skill/references/social.md b/agent_reach/skill/references/social.md index 66bdfdc..351b505 100644 --- a/agent_reach/skill/references/social.md +++ b/agent_reach/skill/references/social.md @@ -180,25 +180,43 @@ user = ch.get_user("Livid") > **节点列表**: https://www.v2ex.com/planes -## Reddit (rdt-cli) +## Reddit(多后端,必须登录态) + +**Reddit 没有零配置路径**:匿名 `.json` 端点已被封(403),官方 API 自 2025-11 起人工审批基本不批。两个后端都靠登录态,先跑 `agent-reach doctor --json` 看 reddit 的 `active_backend`。中国大陆访问需代理。 + +### 后端 A:OpenCLI(桌面首选,复用浏览器登录态) ```bash # 搜索帖子 -rdt search "query" --limit 10 +opencli reddit search "query" -f yaml # 读帖子全文 + 评论 -rdt read POST_ID +opencli reddit read POST_ID -f yaml -# 浏览 subreddit -rdt sub python --limit 20 +# 浏览 subreddit / 热门 / Popular +opencli reddit subreddit LocalLLaMA -f yaml +opencli reddit hot -f yaml +opencli reddit popular -f yaml -# 浏览热门 -rdt popular --limit 10 - -# 浏览 /r/all -rdt all --limit 10 +# subreddit 元信息(订阅数、简介) +opencli reddit subreddit-info LocalLLaMA -f yaml ``` -> **安装**: `pipx install 'git+https://github.com/public-clis/rdt-cli.git'`(PyPI 版本暂时落后,需从 GitHub 装 v0.4.2+)。需要先登录(`rdt login`)才能搜索和阅读。 -> 需要登录的功能:`rdt feed --subs-only`(订阅列表)、`rdt saved`(收藏)。 +> 要求 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 免费)。新申请需人工审批且个人项目基本不批,**不要推荐新用户走这条路**。 diff --git a/tests/test_channels.py b/tests/test_channels.py index 249d26f..8a7cc44 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -647,17 +647,36 @@ class TestXueqiuChannel: class TestRedditChannel: - def test_reports_off_when_not_installed(self, monkeypatch): + """多后端:OpenCLI > rdt-cli,没有零配置路径。""" + + @staticmethod + def _isolate(monkeypatch, opencli=None): + """隔离 OpenCLI 候选(None = 未安装),聚焦 rdt-cli 路径。""" + from agent_reach.channels.reddit import RedditChannel + monkeypatch.setattr(RedditChannel, "_check_opencli", lambda self: opencli) + + def test_reports_off_when_nothing_installed(self, monkeypatch): + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: None) from agent_reach.channels.reddit import RedditChannel status, msg = RedditChannel().check() assert status == "off" - assert "rdt-cli" in msg - assert "public-clis/rdt-cli" in msg + # 诚实口径:明说没有零配置路径,推荐 OpenCLI + rdt git 源 + assert "零配置" in msg + assert "opencli" in msg assert "git+https://github.com/public-clis/rdt-cli.git" in msg - assert "rdt-cli>=0.4.2" not in msg + + def test_opencli_ready_wins(self, monkeypatch): + self._isolate(monkeypatch, opencli=("ok", "OpenCLI 可用(复用浏览器登录态)")) + monkeypatch.setattr(shutil, "which", lambda _: None) + from agent_reach.channels.reddit import RedditChannel + ch = RedditChannel() + status, msg = ch.check() + assert status == "ok" + assert ch.active_backend == "OpenCLI" def test_reports_ok_when_authenticated(self, monkeypatch): + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") fake_output = json.dumps({ "ok": True, @@ -677,6 +696,7 @@ class TestRedditChannel: assert ch.active_backend == "rdt-cli" def test_reports_warn_when_not_authenticated(self, monkeypatch): + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") fake_output = json.dumps({ "ok": True, @@ -701,6 +721,7 @@ class TestRedditChannel: def test_reports_error_when_status_check_fails(self, monkeypatch): """rdt 非零退出且输出不可解析 → 工具异常(error),不再算 warn。""" + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") def fake_run(cmd, **kwargs): @@ -716,6 +737,7 @@ class TestRedditChannel: def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch): """which 命中但 exec 抛 FileNotFoundError(venv 断链)→ error + 重装处方。""" + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") def fake_run(cmd, **kwargs): @@ -733,6 +755,7 @@ class TestRedditChannel: def test_reports_error_with_reinstall_hint_on_exit_127(self, monkeypatch): """退出码 127(找到但跑不动)同样按断链处理。""" + self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") def fake_run(cmd, **kwargs): diff --git a/tests/test_cli.py b/tests/test_cli.py index 5cd67d8..bb8c71e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -60,7 +60,7 @@ class TestCLI: assert auth_token == "token123" assert ct0 == "ct0abc" - def test_install_reddit_deps_prefers_github_source(self, monkeypatch, capsys): + def test_install_rdt_cli_prefers_github_source(self, monkeypatch, capsys): state = {"rdt_installed": False} commands = [] @@ -79,12 +79,28 @@ class TestCLI: monkeypatch.setattr(shutil, "which", fake_which) monkeypatch.setattr(subprocess, "run", fake_run) - cli._install_reddit_deps() + 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: def test_retry_timeout_classification(self):