front/xhs-agent-desktop/engine/xhs_bridge/publish_history_store.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

263 lines
8.5 KiB
Python

from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from xhs_bridge.config import load_config
_TOOL_LABELS = {
"publish_content": "图文",
"publish_with_video": "视频",
}
STATUS_SCHEDULED = "scheduled"
STATUS_PUBLISHING = "publishing"
STATUS_PUBLISHED = "published"
STATUS_FAILED = "failed"
_STATUS_LABELS = {
STATUS_SCHEDULED: "待发布",
STATUS_PUBLISHING: "发布中",
STATUS_PUBLISHED: "已发布",
STATUS_FAILED: "失败",
}
@dataclass
class PublishRecord:
id: str
tool_name: str
title: str
content_preview: str
success: bool
result_summary: str
created_at: str
status: str = STATUS_PUBLISHED
scheduled_at: str = ""
arguments: dict[str, Any] = field(default_factory=dict)
@property
def type_label(self) -> str:
return _TOOL_LABELS.get(self.tool_name, self.tool_name)
@property
def status_label(self) -> str:
return _STATUS_LABELS.get(self.status, self.status)
@staticmethod
def from_dict(raw: dict[str, Any]) -> PublishRecord:
success = bool(raw.get("success", False))
scheduled_at = str(raw.get("scheduled_at") or "").strip()
status = str(raw.get("status") or "").strip()
if not status:
if scheduled_at:
status = STATUS_SCHEDULED
elif success:
status = STATUS_PUBLISHED
else:
status = STATUS_FAILED
args = raw.get("arguments")
if not isinstance(args, dict):
args = {}
return PublishRecord(
id=str(raw.get("id") or uuid.uuid4().hex[:12]),
tool_name=str(raw.get("tool_name") or "publish_content"),
title=str(raw.get("title") or ""),
content_preview=str(raw.get("content_preview") or ""),
success=success,
result_summary=str(raw.get("result_summary") or ""),
created_at=str(raw.get("created_at") or ""),
status=status,
scheduled_at=scheduled_at,
arguments=args,
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"tool_name": self.tool_name,
"title": self.title,
"content_preview": self.content_preview,
"success": self.success,
"result_summary": self.result_summary,
"created_at": self.created_at,
"status": self.status,
"scheduled_at": self.scheduled_at,
"arguments": self.arguments,
}
class PublishHistoryStore:
"""发布记录持久化(含定时待发布)。"""
def __init__(self, path: Path | None = None) -> None:
if path is None:
cfg = load_config()
path = cfg.state_dir / "publish_history.json"
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
self._records: list[PublishRecord] = []
self.load()
def load(self) -> list[PublishRecord]:
if not self.path.is_file() or self.path.stat().st_size == 0:
self._records = []
return list(self._records)
try:
with self.path.open("r", encoding="utf-8") as f:
data = json.load(f)
items = data.get("records") if isinstance(data, dict) else data
if not isinstance(items, list):
self._records = []
return list(self._records)
self._records = [
PublishRecord.from_dict(raw)
for raw in items
if isinstance(raw, dict)
]
except (json.JSONDecodeError, OSError):
self._records = []
self._records.sort(key=lambda r: (r.scheduled_at or r.created_at), reverse=True)
return list(self._records)
def save(self) -> None:
payload = {
"records": [r.to_dict() for r in self._records],
"updated_at": datetime.now().isoformat(timespec="seconds"),
}
with self.path.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def list_records(
self,
*,
tool_name: str | None = None,
success_only: bool = False,
include_scheduled: bool = True,
) -> list[PublishRecord]:
items = list(self._records)
if tool_name:
items = [r for r in items if r.tool_name == tool_name]
if success_only:
items = [r for r in items if r.success and r.status == STATUS_PUBLISHED]
elif not include_scheduled:
items = [r for r in items if r.status != STATUS_SCHEDULED]
return items
def get(self, record_id: str) -> PublishRecord | None:
for r in self._records:
if r.id == record_id:
return r
return None
def _preview_from_arguments(self, arguments: dict[str, Any]) -> tuple[str, str]:
title = str(arguments.get("title") or "").strip()
content = str(arguments.get("content") or "").strip()
preview = content[:80] + ("…" if len(content) > 80 else "")
return title or "(无标题)", preview
def add_record(
self,
tool_name: str,
arguments: dict[str, Any],
result_text: str,
*,
success: bool,
) -> PublishRecord:
title, preview = self._preview_from_arguments(arguments)
summary = (result_text or "").strip()
if len(summary) > 300:
summary = summary[:300] + "…"
record = PublishRecord(
id=uuid.uuid4().hex[:12],
tool_name=tool_name,
title=title,
content_preview=preview,
success=success,
result_summary=summary,
created_at=datetime.now().isoformat(timespec="seconds"),
status=STATUS_PUBLISHED if success else STATUS_FAILED,
arguments=dict(arguments),
)
self._records.insert(0, record)
self._records = self._records[:200]
self.save()
return record
def add_scheduled(
self,
tool_name: str,
arguments: dict[str, Any],
scheduled_at: datetime,
) -> PublishRecord:
title, preview = self._preview_from_arguments(arguments)
when = scheduled_at.isoformat(timespec="seconds")
record = PublishRecord(
id=uuid.uuid4().hex[:12],
tool_name=tool_name,
title=title,
content_preview=preview,
success=False,
result_summary="等待定时发布",
created_at=datetime.now().isoformat(timespec="seconds"),
status=STATUS_SCHEDULED,
scheduled_at=when,
arguments=dict(arguments),
)
self._records.insert(0, record)
self._records = self._records[:200]
self.save()
return record
def list_due_scheduled(self, now: datetime | None = None) -> list[PublishRecord]:
now = now or datetime.now()
due: list[PublishRecord] = []
for r in self._records:
if r.status != STATUS_SCHEDULED or not r.scheduled_at:
continue
try:
at = datetime.fromisoformat(r.scheduled_at)
except ValueError:
continue
if at <= now:
due.append(r)
return due
def mark_publishing(self, record_id: str) -> bool:
rec = self.get(record_id)
if not rec or rec.status != STATUS_SCHEDULED:
return False
rec.status = STATUS_PUBLISHING
rec.result_summary = "正在发布…"
self.save()
return True
def mark_published(self, record_id: str, result_text: str, *, success: bool) -> None:
rec = self.get(record_id)
if not rec:
return
summary = (result_text or "").strip()
if len(summary) > 300:
summary = summary[:300] + "…"
rec.success = success
rec.status = STATUS_PUBLISHED if success else STATUS_FAILED
rec.result_summary = summary or ("发布成功" if success else "发布失败")
rec.created_at = datetime.now().isoformat(timespec="seconds")
self.save()
def delete(self, record_id: str) -> bool:
before = len(self._records)
self._records = [r for r in self._records if r.id != record_id]
if len(self._records) < before:
self.save()
return True
return False
def clear(self) -> None:
self._records = []
self.save()