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:
@@ -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 扩展)"
|
||||||
|
|||||||
@@ -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
@@ -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. 下载 binary:https://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():
|
||||||
|
|||||||
@@ -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 里登录一次即可。
|
||||||
|
|
||||||
|
### 后端 B:xiaohongshu-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)。
|
### 后端 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 去读。三个后端都一样。
|
||||||
>
|
>
|
||||||
> **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
@@ -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 抛 FileNotFoundError(venv 断链)→ error + 重装处方。"""
|
"""which 命中但 exec 抛 FileNotFoundError(venv 断链)→ 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):
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user