front/xhs-agent-desktop/engine/xhs_bridge/skills_client.py
zzh 5a26ae0048 feat: 小红书模块迁移、登录重构与 xhs-agent-desktop 桌面 Agent
- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手)

- 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度

- 登录页重构,整合微信登录与 OMS JWT 认证

- 订单、渠道、用户中心等页面与 API 增强

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:13:13 +08:00

350 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
import re
import subprocess
import sys
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
from xhs_bridge.config import AppConfig
from xhs_bridge.exceptions import SkillsError
from xhs_bridge.human_behavior import human_behavior_json_path
from xhs_bridge.subprocess_win import popen_kwargs_hide_console
from xhs_bridge.tools_catalog import XHS_TOOLS
from xhs_bridge.skills_tool_adapter import build_cli_argv, format_tool_result_text
ProgressFn = Callable[[str], None] | None
class SkillsClient:
"""通过子进程调用内嵌 xiaohongshu-skills CLI。"""
_CANCELLABLE_COMMANDS = frozenset({
"list-feeds",
"search-feeds",
"my-account-feeds",
})
def __init__(self, config: AppConfig) -> None:
self.config = config
self._active_proc: subprocess.Popen[str] | None = None
self._cancel_requested = False
self._browse_targets: dict[str, float | int] | None = None
def _cli_script(self) -> Path:
return self.config.skills.cli_script()
def _browse_cancel_file(self) -> Path:
return self.config.state_dir / "browse.cancel"
def _browse_partial_file(self) -> Path:
return self.config.state_dir / "browse.partial.json"
def begin_browse_task(
self, *, browse_targets: dict[str, float | int] | None = None
) -> None:
"""新一次首页/搜索采集开始前调用,清除上一轮取消状态。"""
self._browse_targets = browse_targets
self._cancel_requested = False
self.config.state_dir.mkdir(parents=True, exist_ok=True)
cancel = self._browse_cancel_file()
if cancel.exists():
cancel.unlink()
partial = self._browse_partial_file()
partial.write_text(
json.dumps({"feeds": []}, ensure_ascii=False),
encoding="utf-8",
)
def is_browse_cancel_requested(self) -> bool:
return self._cancel_requested
def browse_cancelled_payload(self) -> dict[str, Any]:
"""用户停止或子进程被中断时返回的最小结果。"""
partial = self._load_partial_payload()
if partial:
return partial
return {"feeds": [], "count": 0, "cancelled": True}
def _prepare_browse_session(self, env: dict[str, str]) -> None:
self.config.state_dir.mkdir(parents=True, exist_ok=True)
cancel = self._browse_cancel_file()
partial = self._browse_partial_file()
if not self._cancel_requested and cancel.exists():
cancel.unlink()
if not partial.exists():
partial.write_text(
json.dumps({"feeds": []}, ensure_ascii=False),
encoding="utf-8",
)
env["XHS_CANCEL_FILE"] = str(cancel)
env["XHS_PARTIAL_FEEDS_FILE"] = str(partial)
env["XHS_STATE_DIR"] = str(self.config.state_dir)
targets = getattr(self, "_browse_targets", None)
if targets:
env["XHS_BROWSE_TARGETS"] = json.dumps(targets, ensure_ascii=False)
def _load_partial_payload(self) -> dict[str, Any] | None:
partial = self._browse_partial_file()
if not partial.is_file():
return None
try:
data = json.loads(partial.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
if not isinstance(data, dict):
return None
feeds = data.get("feeds")
if not isinstance(feeds, list) or not feeds:
return None
return {"feeds": feeds, "count": len(feeds), "cancelled": True}
def request_cancel_browse(self) -> None:
"""停止当前 list-feeds / search-feeds 子进程并保留已落盘数据。"""
self._cancel_requested = True
cancel = self._browse_cancel_file()
cancel.parent.mkdir(parents=True, exist_ok=True)
cancel.write_text("1", encoding="utf-8")
proc = self._active_proc
if proc is not None and proc.poll() is None:
proc.kill()
def _run_cli(
self,
argv: list[str],
*,
timeout: int = 300,
on_progress: ProgressFn = None,
) -> tuple[dict[str, Any], int]:
env = dict(**__import__("os").environ)
if not getattr(sys, "frozen", False):
cli = self._cli_script()
if not cli.is_file():
raise SkillsError(f"CLI 脚本不存在: {cli}")
env["PYTHONPATH"] = str(self.config.skills.scripts_dir())
env["PYTHONUTF8"] = "1"
hb_json = human_behavior_json_path(self.config.state_dir)
if hb_json.is_file():
env["XHS_HUMAN_BEHAVIOR_FILE"] = str(hb_json)
cancellable = bool(argv) and argv[0] in self._CANCELLABLE_COMMANDS
if cancellable:
self._prepare_browse_session(env)
if self._cancel_requested:
return self.browse_cancelled_payload(), 0
cmd = self.config.skills.cli_launcher(argv)
if cmd and cmd[0].endswith(".py") and not Path(cmd[0]).is_file():
raise SkillsError(f"CLI 脚本不存在: {cmd[0]}")
if cmd and cmd[0].endswith(".exe") and not Path(cmd[0]).is_file():
raise SkillsError(f"CLI 引擎不存在: {cmd[0]}")
try:
proc = subprocess.Popen(
cmd,
cwd=str(self.config.skills.project_dir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env=env,
**popen_kwargs_hide_console(),
)
except OSError as exc:
raise SkillsError(f"无法启动 CLI: {exc}") from exc
stderr_chunks: list[str] = []
stdout_chunks: list[str] = []
def _read_stderr() -> None:
stream = proc.stderr
if stream is None:
return
try:
for line in stream:
stderr_chunks.append(line)
stripped = line.strip()
if stripped.startswith("@@PROGRESS@@") and on_progress:
on_progress(stripped[len("@@PROGRESS@@") :])
except (ValueError, OSError):
# communicate / wait 结束后管道可能已关闭
pass
def _read_stdout() -> None:
stream = proc.stdout
if stream is None:
return
try:
stdout_chunks.append(stream.read())
except (ValueError, OSError):
pass
stderr_reader = threading.Thread(target=_read_stderr, daemon=True)
stdout_reader = threading.Thread(target=_read_stdout, daemon=True)
stderr_reader.start()
stdout_reader.start()
self._active_proc = proc
try:
deadline = time.monotonic() + timeout
while proc.poll() is None:
if self._cancel_requested:
proc.kill()
break
remaining = deadline - time.monotonic()
if remaining <= 0:
proc.kill()
proc.wait(timeout=5)
raise SkillsError(f"CLI 执行超时({timeout}s): {' '.join(argv)}")
try:
proc.wait(timeout=min(0.5, remaining))
except subprocess.TimeoutExpired:
continue
finally:
self._active_proc = None
stderr_reader.join(timeout=2)
stdout_reader.join(timeout=2)
stdout = stdout_chunks[0] if stdout_chunks else ""
stderr = "".join(stderr_chunks)
data = self._parse_json_output(stdout or "")
if data is None and cancellable and (self._cancel_requested or proc.returncode != 0):
partial = self._load_partial_payload()
if partial:
return partial, 0
if self._cancel_requested:
return self.browse_cancelled_payload(), 0
if data is None and proc.returncode != 0:
err = stderr.strip() or (stdout or "").strip() or f"退出码 {proc.returncode}"
raise SkillsError(err)
if data is None:
data = {"success": proc.returncode == 0, "raw": (stdout or "").strip()}
return data, proc.returncode
@staticmethod
def _parse_json_output(stdout: str) -> dict[str, Any] | None:
text = stdout.strip()
if not text:
return None
for candidate in (text, text.splitlines()[-1] if text else ""):
candidate = candidate.strip()
if not candidate.startswith("{"):
continue
try:
parsed = json.loads(candidate)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
continue
match = re.search(r"\{[\s\S]*\}\s*$", text)
if match:
try:
parsed = json.loads(match.group(0))
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return None
def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
timeout: int = 300,
on_progress: ProgressFn = None,
) -> str:
argv = build_cli_argv(name, arguments)
data, _code = self._run_cli(argv, timeout=timeout, on_progress=on_progress)
return format_tool_result_text(name, data)
def call_tool_json(
self,
name: str,
arguments: dict[str, Any] | None = None,
timeout: int = 300,
on_progress: ProgressFn = None,
) -> dict[str, Any]:
argv = build_cli_argv(name, arguments)
data, _code = self._run_cli(argv, timeout=timeout, on_progress=on_progress)
return data
def list_tools(self) -> list[dict[str, Any]]:
return [
{
"name": t["name"],
"description": t.get("description", ""),
"inputSchema": {"type": "object", "properties": {}},
}
for t in XHS_TOOLS
]
@staticmethod
def is_success_text(text: str) -> bool:
lowered = text.lower()
if "❌" in text or "失败" in text or "错误" in text:
return False
if "✅" in text or "成功" in text or "已登录" in text:
return True
return "未登录" not in text
def check_login_status(self) -> str:
return self.call_tool("check_login_status", timeout=90)
def list_feeds(self, *, on_progress: ProgressFn = None) -> dict[str, Any]:
return self.call_tool_json("list_feeds", timeout=300, on_progress=on_progress)
def list_my_account_feeds(self, *, on_progress: ProgressFn = None) -> dict[str, Any]:
return self.call_tool_json(
"my_account_feeds", timeout=360, on_progress=on_progress
)
def search_feeds(
self,
keyword: str,
filters: dict[str, Any] | None = None,
*,
on_progress: ProgressFn = None,
) -> dict[str, Any]:
args: dict[str, Any] = {"keyword": keyword}
if filters:
args["filters"] = filters
return self.call_tool_json("search_feeds", args, timeout=360, on_progress=on_progress)
def feed_detail(
self,
payload: dict[str, Any],
*,
on_progress: ProgressFn = None,
) -> dict[str, Any]:
return self.call_tool_json(
"get_feed_detail",
payload,
timeout=360,
on_progress=on_progress,
)
def login_status(self) -> dict[str, Any]:
data = self.call_tool_json("check_login_status", timeout=90)
logged_in = bool(data.get("logged_in"))
return {
"is_logged_in": logged_in,
"logged_in": logged_in,
"message": "已登录" if logged_in else (data.get("hint") or data.get("message") or "未登录"),
}
def browser_login(self, timeout: int = 180) -> dict[str, Any]:
"""在 Chrome 中打开小红书登录页并等待扫码(阻塞至完成或超时)。"""
data, _code = self._run_cli(["login"], timeout=timeout)
return data
def delete_cookies(self) -> dict[str, Any]:
return self.call_tool_json("delete_cookies", timeout=90)
def close(self) -> None:
if self._active_proc is not None and self._active_proc.poll() is None:
self._active_proc.kill()
self._active_proc = None