- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手) - 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度 - 登录页重构,整合微信登录与 OMS JWT 认证 - 订单、渠道、用户中心等页面与 API 增强 Co-authored-by: Cursor <cursoragent@cursor.com>
263 lines
8.5 KiB
Python
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()
|