feat(xiaohongshu): multi-backend — OpenCLI / xiaohongshu-mcp / xhs-cli

- backends becomes the ordered candidate list [OpenCLI, xiaohongshu-mcp,
  xhs-cli]; probing order makes the desktop/server split automatic:
  OpenCLI never probes alive headless, so servers fall through to
  xiaohongshu-mcp; first fully-usable candidate wins, fixable (warn)
  candidates only win when nothing is fully usable
- xiaohongshu-mcp probing: HTTP reachability of localhost:18060
  (proxy-bypassed) + mcporter config presence; guides through
  `mcporter config add` when half-wired
- opencli backend: treat a sleeping extension service worker as ready —
  verified live that `daemon status` reports disconnected while any real
  command wakes it; disambiguate "sleeping" vs "never installed" via the
  Chrome Extensions directory on disk (fixes active_backend flapping
  between OpenCLI and xhs-cli across doctor runs)
- install: desktop installs OpenCLI; server prints the xiaohongshu-mcp
  guide (binary to ~/.agent-reach/tools/, QR login, mcporter add);
  xhs-cli is no longer installed by default (upstream unmaintained since
  2026-03) but existing installs keep working as the last candidate
- skill/references/social.md: xiaohongshu section rewritten as three
  backend command groups keyed off `doctor --json` active_backend,
  including the 120s-timeout and login-first caveats for the mcp path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Pnant
2026-06-11 16:05:00 +08:00
parent 0e8dd3f412
commit 3e5f9df5b8
6 changed files with 366 additions and 76 deletions
+55 -13
View File
@@ -9,20 +9,51 @@ Probing notes (verified live):
- `opencli doctor` AUTO-STARTS the daemon — a side effect, so health - `opencli doctor` AUTO-STARTS the daemon — a side effect, so health
checks must use `opencli daemon status` (pure query) instead. checks must use `opencli daemon status` (pure query) instead.
- Exit codes are always 0; status must be parsed from text output. - Exit codes are always 0; status must be parsed from text output.
- Extension connectivity is volatile (drops when Chrome restarts and - "Extension: disconnected" does NOT mean unusable: the extension's
reconnects on demand) — report it, but treat "installed + daemon service worker sleeps and any real opencli command wakes it up
reachable" as the stable signal. (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 dataclasses import dataclass
from agent_reach.probe import probe_command from agent_reach.probe import probe_command
OPENCLI_PACKAGE = "@jackwener/opencli" OPENCLI_PACKAGE = "@jackwener/opencli"
OPENCLI_EXTENSION_ID = "ildkmabpimmkaediidaifkhjpohdnifk"
OPENCLI_EXTENSION_URL = ( OPENCLI_EXTENSION_URL = (
"https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk" 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 @dataclass
class OpenCLIStatus: class OpenCLIStatus:
@@ -30,13 +61,20 @@ class OpenCLIStatus:
broken: bool = False broken: bool = False
daemon_running: bool = False daemon_running: bool = False
extension_connected: bool = False extension_connected: bool = False
extension_installed: bool = False
version: str = "" version: str = ""
hint: str = "" hint: str = ""
@property @property
def ready(self) -> bool: def ready(self) -> bool:
"""Fully usable right now: extension connected to the daemon.""" """Usable now or on first call.
return self.installed and not self.broken and self.extension_connected
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: def opencli_status(timeout: int = 10) -> OpenCLIStatus:
@@ -73,11 +111,13 @@ def opencli_status(timeout: int = 10) -> OpenCLIStatus:
st.extension_connected = "disconnected" not in line and "connected" in line st.extension_connected = "disconnected" not in line and "connected" in line
if not st.extension_connected: if not st.extension_connected:
st.hint = ( st.extension_installed = _extension_installed_on_disk()
"OpenCLI 已安装,但 Chrome 扩展未连接。\n" if not st.extension_installed:
f" 1. 安装扩展(需手动点一次):{OPENCLI_EXTENSION_URL}\n" st.hint = (
" 2. 保持 Chrome 打开,运行 `opencli doctor` 验证" "OpenCLI 已安装,但 Chrome 扩展未安装。\n"
) f" 1. 安装扩展(需手动点一次):{OPENCLI_EXTENSION_URL}\n"
" 2. 保持 Chrome 打开,运行 `opencli doctor` 验证"
)
return st return st
@@ -87,8 +127,10 @@ def opencli_summary(st: OpenCLIStatus) -> str:
return "OpenCLI 未安装" return "OpenCLI 未安装"
if st.broken: if st.broken:
return "OpenCLI 无法执行(node 环境损坏)" return "OpenCLI 无法执行(node 环境损坏)"
if st.ready: if st.extension_connected:
return f"OpenCLI 可用(浏览器登录态,v{st.version}" return f"OpenCLI 可用(浏览器登录态,v{st.version}"
if st.ready:
return "OpenCLI 可用(扩展睡眠中,调用时自动唤醒)"
if st.daemon_running: if st.daemon_running:
return "OpenCLI 已安装,等待 Chrome 扩展连接" return "OpenCLI 已安装,等待 Chrome 扩展安装"
return "OpenCLI 已安装(daemon 未运行,使用时自动启动;需 Chrome 扩展)" return "OpenCLI 已安装(daemon 未运行,使用时自动启动;需 Chrome 扩展)"
+107 -15
View File
@@ -1,10 +1,41 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""XiaoHongShu — check if xhs-cli (xiaohongshu-cli) 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 from agent_reach.probe import probe_command
from .base import Channel from .base import Channel
_MCP_ENDPOINT = "http://localhost:18060/mcp"
_MCP_INSTALL_URL = "https://github.com/xpzouying/xiaohongshu-mcp"
def _mcp_service_reachable(timeout: int = 3) -> bool:
"""True if the xiaohongshu-mcp HTTP service answers on localhost.
Any HTTP response counts (the MCP endpoint replies 405 to GET) —
we only care that the service is up. Proxies are bypassed explicitly:
localhost must never be routed through HTTP_PROXY.
"""
req = urllib.request.Request(_MCP_ENDPOINT, method="GET")
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
opener.open(req, timeout=timeout)
return True
except urllib.error.HTTPError:
return True # 405/404 etc. — service is alive
except Exception:
return False
def format_xhs_result(data): def format_xhs_result(data):
"""Clean XHS API response, keeping only useful fields. """Clean XHS API response, keeping only useful fields.
@@ -118,7 +149,7 @@ def _clean_comment(comment):
class XiaoHongShuChannel(Channel): class XiaoHongShuChannel(Channel):
name = "xiaohongshu" name = "xiaohongshu"
description = "小红书笔记" description = "小红书笔记"
backends = ["xhs-cli (xiaohongshu-cli)"] backends = ["OpenCLI", "xiaohongshu-mcp", "xhs-cli (xiaohongshu-cli)"]
tier = 1 tier = 1
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
@@ -127,19 +158,83 @@ 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):
"""Probe candidates in order; first fully-usable backend wins.
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 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", (
"未安装任何小红书后端。推荐:\n"
" 桌面:agent-reach install --channels opencli\n"
" (复用 Chrome 登录态,刷过小红书即零配置可用)\n"
f" 服务器:xiaohongshu-mcp(自带无头浏览器+扫码登录):{_MCP_INSTALL_URL}"
)
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 xiaohongshu search/note/comments/feed -f yaml"
)
return "warn", st.hint
def _check_mcp(self):
"""xiaohongshu-mcp candidate. None = service not running."""
if not _mcp_service_reachable():
return None
mcporter = probe_command(
"mcporter", ["config", "list"], timeout=10, package="mcporter"
)
if mcporter.ok and "xiaohongshu" in mcporter.output:
return "ok", (
"xiaohongshu-mcp 服务运行中"
"mcporter call 'xiaohongshu.search_feeds(keyword: \"...\")')。"
"若未登录,让 agent 调 get_login_qrcode 扫码"
)
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( probe = probe_command(
"xhs", ["status"], timeout=10, package="xiaohongshu-cli" "xhs", ["status"], timeout=10, package="xiaohongshu-cli"
) )
if probe.status == "missing": if probe.status == "missing":
return "off", ( return None
"需要安装 xhs-cli\n"
" pipx install xiaohongshu-cli\n"
"或:\n"
" uv tool install xiaohongshu-cli\n"
"安装后运行 `xhs login` 登录"
)
if probe.status == "broken": if probe.status == "broken":
return "error", "xhs 命令存在但无法执行\n" + probe.hint return "error", "xhs 命令存在但无法执行\n" + probe.hint
if probe.status == "timeout": if probe.status == "timeout":
@@ -147,19 +242,16 @@ class XiaoHongShuChannel(Channel):
# 进程是活的(执行成功或运行后非零退出)——按输出内容分类 # 进程是活的(执行成功或运行后非零退出)——按输出内容分类
if probe.ok and "ok: true" in probe.output: if probe.ok and "ok: true" in probe.output:
self.active_backend = self.backends[0]
return "ok", ( return "ok", (
"完整可用(搜索、阅读、评论、发帖、热门、" "xhs-cli 可用(搜索、阅读、评论、热门;上游 2026-03 起停更,"
"收藏、关注、用户查询" "桌面用户建议迁移到 OpenCLI"
) )
if "not_authenticated" in probe.output or "expired" in probe.output: if "not_authenticated" in probe.output or "expired" in probe.output:
self.active_backend = self.backends[0]
return "warn", ( return "warn", (
"xhs-cli 已安装但未登录。运行:\n" "xhs-cli 已安装但未登录。运行:\n"
" xhs login\n" " xhs login\n"
"(自动从浏览器提取 Cookie,或扫码登录)" "(自动从浏览器提取 Cookie,或扫码登录)"
) )
self.active_backend = self.backends[0]
return "warn", ( return "warn", (
"xhs-cli 已安装但状态异常。运行:\n" "xhs-cli 已安装但状态异常。运行:\n"
" xhs -v status 查看详细信息" " xhs -v status 查看详细信息"
+21 -18
View File
@@ -699,26 +699,29 @@ def _install_twitter_deps():
def _install_xhs_deps(): def _install_xhs_deps():
"""Install xhs-cli (xiaohongshu-cli) for XiaoHongShu.""" """Set up XiaoHongShu — backend depends on environment.
import shutil
import subprocess
print("Setting up XiaoHongShu (xhs-cli)...") Desktop: OpenCLI (reuses the browser session, zero config).
if shutil.which("xhs"): Server: xiaohongshu-mcp guide (self-contained headless browser + QR
print(" ✅ xhs-cli already installed") login; we don't manage long-running services, so guide only).
xhs-cli is no longer installed by default — upstream unmaintained
since 2026-03; existing installs keep working as a fallback backend.
"""
import shutil
print("Setting up XiaoHongShu...")
if _detect_environment() == "server":
print(" 服务器环境推荐 xiaohongshu-mcp(自带无头浏览器,扫码登录):")
print(" 1. 下载 binaryhttps://github.com/xpzouying/xiaohongshu-mcp/releases")
print(" (建议放到 ~/.agent-reach/tools/ 下)")
print(" 2. 启动服务(首次运行会下载约 150MB 浏览器,请等待完成)")
print(" 3. 扫码登录后接入:mcporter config add xiaohongshu http://localhost:18060/mcp")
print(" 4. 验证:agent-reach doctor")
return return
for tool, cmd in [("pipx", ["pipx", "install", "xiaohongshu-cli"]),
("uv", ["uv", "tool", "install", "xiaohongshu-cli"])]: _install_opencli_deps()
if shutil.which(tool): if shutil.which("xhs"):
try: print(" ✅ 检测到存量 xhs-cli,将作为备选后端继续可用")
subprocess.run(cmd, capture_output=True, encoding="utf-8",
errors="replace", timeout=120)
if shutil.which("xhs"):
print(" ✅ xhs-cli installed (run `xhs login` to authenticate)")
return
except Exception:
pass
print(" [!] xhs-cli install failed. Run: pipx install xiaohongshu-cli")
def _install_opencli_deps(): def _install_opencli_deps():
+44 -23
View File
@@ -2,45 +2,66 @@
小红书、Twitter/X、B站、V2EX、Reddit。 小红书、Twitter/X、B站、V2EX、Reddit。
## 小红书 / XiaoHongShu (xhs-cli) ## 小红书 / XiaoHongShu(多后端)
### 稳定可用的命令 小红书有三个后端,**先跑 `agent-reach doctor --json` 看 xiaohongshu 的 `active_backend` 是哪个**,再用对应命令组。
### 后端 A:OpenCLI(桌面首选,复用浏览器登录态)
```bash ```bash
# 搜索笔记(推荐入口) # 搜索笔记
xhs search "query" opencli xiaohongshu search "query" -f yaml
# 读笔记详情(必须用搜索结果中的 URL 或 ID,不能裸 note_id # 读笔记正文+互动数据(用搜索结果里的完整 URL,含 xsec_token
xhs read NOTE_ID_OR_URL opencli xiaohongshu note "NOTE_URL" -f yaml
# 查看评论 # 评论(支持楼中楼)
xhs comments NOTE_ID_OR_URL opencli xiaohongshu comments NOTE_ID -f yaml
# 浏览热门 # 首页推荐 feed
xhs hot opencli xiaohongshu feed -f yaml
# 推荐 feed # 用户主页公开笔记
xhs feed opencli xiaohongshu user USER_ID -f yaml
``` ```
### 已知不稳定的命令(v0.6.4) > 要求 Chrome 打开且装了 OpenCLI 扩展。报 AUTH_REQUIRED 说明浏览器里没登录小红书,让用户在 Chrome 里登录一次即可。
### 后端 Bxiaohongshu-mcp(服务器场景)
```bash ```bash
# 以下命令当前可能返回 API error,谨慎使用: # 未登录时:先查状态,再取二维码给用户扫
xhs user USER_ID # 可能返回 {code: -1} mcporter call 'xiaohongshu.check_login_status()' --timeout 120000
xhs user-posts USER_ID # 可能返回 {code: -1} mcporter call 'xiaohongshu.get_login_qrcode()' --timeout 120000
xhs favorites # 可能返回 API error
# 搜索
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。
> **安装**: `pipx install xiaohongshu-cli`,然后 `xhs login`(自动从浏览器提取 Cookie)。 ### 后端 Cxhs-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 去读。三个后端都一样。
> >
> **xsec_token 限制**: 小红书强制 xsec_token 机制,**不能直接用裸 note_id 去读**。正确流程是:先 `xhs search` 或 `xhs feed` 获取结果,再用结果中的 URL/ID 去 `xhs read`。直接构造 note_id 会被拦截 > **频率控制**: 高频请求(批量搜索、深翻评论)会触发验证码,平台限制无法绕过。每次操作间隔 2-3 秒
> >
> **频率控制**: 高频请求(批量搜索、深翻评论)会触发验证码,这是平台限制无法绕过。建议每次操作间隔 2-3 秒 > **写操作(发帖/评论/点赞)**: 建议只读。xhs-cli v0.6.x 写操作可能因签名问题返回 406
>
> **POST 操作风险**: 发帖(post)、评论(comment)、点赞(like) 等写操作在 v0.6.x 可能因签名问题返回 406。如需使用,建议降级到 v0.3.5 (`pipx install xiaohongshu-cli==0.3.5`)。
## Twitter/X (twitter-cli) ## Twitter/X (twitter-cli)
+114 -3
View File
@@ -756,7 +756,25 @@ class TestRedditChannel:
class TestXiaoHongShuChannel: class TestXiaoHongShuChannel:
"""多后端选择逻辑:OpenCLI > xiaohongshu-mcp > xhs-cli,第一个完整可用者获胜。"""
@staticmethod
def _isolate(monkeypatch, opencli=None, mcp_reachable=False):
"""隔离 OpenCLI / mcp 候选,让测试聚焦目标后端。
opencli: None 表示未安装;否则传入 (status, message) 二元组。
"""
import agent_reach.channels.xiaohongshu as xhs_mod
monkeypatch.setattr(
XiaoHongShuChannel, "_check_opencli", lambda self: opencli
)
monkeypatch.setattr(
xhs_mod, "_mcp_service_reachable", lambda timeout=3: mcp_reachable
)
def test_reports_ok_when_cli_authenticated(self, monkeypatch): def test_reports_ok_when_cli_authenticated(self, monkeypatch):
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs): def fake_run(cmd, **kwargs):
@@ -767,10 +785,11 @@ class TestXiaoHongShuChannel:
ch = XiaoHongShuChannel() ch = XiaoHongShuChannel()
status, msg = ch.check() status, msg = ch.check()
assert status == "ok" assert status == "ok"
assert "完整可用" in msg assert "xhs-cli 可用" in msg
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)" assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
def test_reports_warn_when_not_authenticated(self, monkeypatch): def test_reports_warn_when_not_authenticated(self, monkeypatch):
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs): def fake_run(cmd, **kwargs):
@@ -785,16 +804,20 @@ class TestXiaoHongShuChannel:
# 未登录是业务态:工具进程活着,后端仍可用 # 未登录是业务态:工具进程活着,后端仍可用
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)" assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
def test_reports_off_when_not_installed(self, monkeypatch): def test_reports_off_when_nothing_installed(self, monkeypatch):
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: None) monkeypatch.setattr(shutil, "which", lambda _: None)
ch = XiaoHongShuChannel() ch = XiaoHongShuChannel()
status, msg = ch.check() status, msg = ch.check()
assert status == "off" assert status == "off"
assert "xiaohongshu-cli" in msg # off 指引推荐当代后端,而非停更的 xhs-cli
assert "opencli" in msg
assert "xiaohongshu-mcp" in msg
assert ch.active_backend is None assert ch.active_backend is None
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch): def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
"""which 命中但 exec 抛 FileNotFoundErrorvenv 断链)→ error + 重装处方。""" """which 命中但 exec 抛 FileNotFoundErrorvenv 断链)→ error + 重装处方。"""
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs): def fake_run(cmd, **kwargs):
@@ -810,6 +833,94 @@ class TestXiaoHongShuChannel:
assert "pipx reinstall xiaohongshu-cli" in msg assert "pipx reinstall xiaohongshu-cli" in msg
assert ch.active_backend is None assert ch.active_backend is None
def test_opencli_ready_wins_over_cli(self, monkeypatch):
"""OpenCLI 完整可用时按序获胜,即使 xhs-cli 也完整可用。"""
self._isolate(monkeypatch, opencli=("ok", "OpenCLI 可用(复用浏览器登录态)"))
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "ok: true\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "OpenCLI"
def test_opencli_warn_loses_to_usable_cli(self, monkeypatch):
"""OpenCLI 装了但扩展未连(warn)时,完整可用的 xhs-cli 获胜。"""
self._isolate(monkeypatch, opencli=("warn", "扩展未连接"))
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "ok: true\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
def test_mcp_service_wins_when_opencli_absent(self, monkeypatch):
"""服务器场景:OpenCLI 未装、mcp 服务可达且 mcporter 已接入 → mcp 获胜。"""
self._isolate(monkeypatch, mcp_reachable=True)
def fake_which(name):
return "/usr/local/bin/mcporter" if name == "mcporter" else None
monkeypatch.setattr(shutil, "which", fake_which)
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "exa\nxiaohongshu\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "xiaohongshu-mcp"
assert "search_feeds" in msg
def test_mcp_reachable_but_mcporter_unconfigured_warns(self, monkeypatch):
self._isolate(monkeypatch, mcp_reachable=True)
def fake_which(name):
return "/usr/local/bin/mcporter" if name == "mcporter" else None
monkeypatch.setattr(shutil, "which", fake_which)
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "exa\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "warn"
assert "mcporter config add xiaohongshu" in msg
assert ch.active_backend == "xiaohongshu-mcp"
def test_backend_override_prefers_cli(self, monkeypatch):
"""config xiaohongshu_backend=xhs-cli 时,即使 OpenCLI ready 也用 xhs-cli。"""
self._isolate(monkeypatch, opencli=("ok", "OpenCLI 可用"))
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "ok: true\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
class _Cfg:
def get(self, key, default=None):
return "xhs-cli" if key == "xiaohongshu_backend" else default
ch = XiaoHongShuChannel()
status, _ = ch.check(_Cfg())
assert status == "ok"
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
class TestBilibiliChannel: class TestBilibiliChannel:
def test_reports_error_with_reinstall_hint_when_ytdlp_broken(self, monkeypatch): def test_reports_error_with_reinstall_hint_when_ytdlp_broken(self, monkeypatch):
+25 -4
View File
@@ -7,8 +7,8 @@ from agent_reach.backends import opencli_status, opencli_summary
from agent_reach.probe import ProbeResult from agent_reach.probe import ProbeResult
def _status_with(version_probe, daemon_probe=None): def _status_with(version_probe, daemon_probe=None, ext_on_disk=False):
"""Run opencli_status with probe_command patched per call.""" """Run opencli_status with probe_command and disk check patched."""
calls = [] calls = []
def fake_probe(cmd, args=("--version",), **kwargs): def fake_probe(cmd, args=("--version",), **kwargs):
@@ -17,7 +17,11 @@ def _status_with(version_probe, daemon_probe=None):
return version_probe return version_probe
return daemon_probe or ProbeResult("missing") return daemon_probe or ProbeResult("missing")
with patch("agent_reach.backends.opencli.probe_command", side_effect=fake_probe): 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 return opencli_status(), calls
@@ -46,17 +50,34 @@ def test_daemon_running_extension_connected_is_ready():
assert "1.8.3" in opencli_summary(st) assert "1.8.3" in opencli_summary(st)
def test_extension_disconnected_not_ready_with_store_guide(): def test_extension_never_installed_not_ready_with_store_guide():
daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n" daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n"
st, _ = _status_with( st, _ = _status_with(
ProbeResult("ok", output="1.8.3"), ProbeResult("ok", output="1.8.3"),
ProbeResult("ok", output=daemon_out), ProbeResult("ok", output=daemon_out),
ext_on_disk=False,
) )
assert st.daemon_running and not st.extension_connected assert st.daemon_running and not st.extension_connected
assert not st.ready assert not st.ready
assert "chromewebstore.google.com" in st.hint 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(): def test_daemon_not_running_parsed_correctly():
st, _ = _status_with( st, _ = _status_with(
ProbeResult("ok", output="1.8.3"), ProbeResult("ok", output="1.8.3"),