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()