- 新增热点自动采集后台线程,支持定时搜索关键词并执行 AI 分析,结果缓存至结构化状态 - 新增热点分析状态管理接口,提供线程安全的 `get_last_analysis` 和 `set_last_analysis` 方法 - 新增热点数据桥接函数 `feed_hotspot_to_engine`,将分析结果注入 TopicEngine 实现热点加权推荐 - 新增热点选题下拉组件,分析完成后自动填充推荐选题,选中后自动写入选题输入框 - 优化 `generate_from_hotspot` 函数,自动获取结构化分析摘要并增强生成上下文 - 新增热点自动采集配置节点,支持通过 `config.json` 管理关键词和采集间隔 ♻️ refactor(queue): 实现智能排期引擎并统一发布路径 - 新增智能排期引擎,基于 `AnalyticsService` 的 `time_weights` 自动计算最优发布时段 - 新增 `PublishQueue.suggest_schedule_time` 和 `auto_schedule_item` 方法,支持时段冲突检测和内容分布控制 - 修改 `generate_to_queue` 函数,新增 `auto_schedule` 和 `auto_approve` 参数,支持自动排期和自动审核 - 重构 `_scheduler_loop` 的自动发布分支,改为调用 `generate_to_queue` 通过队列发布,统一发布路径 - 重构 `auto_publish_once` 函数,移除直接发布逻辑,改为生成内容入队并返回队列信息 - 新增队列时段使用情况查询方法 `get_slot_usage`,支持 UI 热力图展示 📝 docs(openspec): 新增内容排期优化和热点探测优化规范文档 - 新增 `smart-schedule-engine` 规范,定义智能排期引擎的功能需求和场景 - 新增 `unified-publish-path` 规范,定义统一发布路径的改造方案 - 新增 `hotspot-analysis-state` 规范,定义热点分析状态存储的线程安全接口 - 新增 `hotspot-auto-collector` 规范,定义定时热点自动采集的任务流程 - 新增 `hotspot-engine-bridge` 规范,定义热点数据注入 TopicEngine 的桥接机制 - 新增 `hotspot-topic-selector` 规范,定义热点选题下拉组件的交互行为 - 更新 `services-queue`、`services-scheduler` 和 `services-hotspot` 规范,反映功能修改和新增参数 🔧 chore(config): 新增热点自动采集默认配置 - 在 `DEFAULT_CONFIG` 中新增 `hotspot_auto_collect` 配置节点,包含 `enabled`、`keywords` 和 `interval_hours` 字段 - 提供默认关键词列表 `["穿搭", "美妆", "好物"]` 和默认采集间隔 4 小时 🐛 fix(llm): 增强 JSON 解析容错能力 - 新增 `_try_fix_truncated_json` 方法,尝试修复被 token 限制截断的 JSON 输出 - 支持多种截断场景的自动补全,包括字符串值、数组和嵌套对象的截断修复 - 提高 LLM 分析热点等返回 JSON 的函数的稳定性 💄 style(ui): 优化队列管理和热点探测界面 - 在队列生成区域新增自动排期复选框,勾选后隐藏手动排期输入框 - 在日历视图旁新增推荐时段 Markdown 面板,展示各时段权重和建议热力图 - 在热点探测 Tab 新增推荐选题下拉组件,分析完成后动态填充选项 - 在热点探测 Tab 新增热点自动采集控制区域,支持启动、停止和配置采集参数
284 lines
10 KiB
Python
284 lines
10 KiB
Python
"""
|
||
配置管理模块
|
||
支持多配置项、默认值回退、自动保存
|
||
"""
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 敏感字段 keyring 存储常量
|
||
_KEYRING_PLACEHOLDER = "[keyring]"
|
||
_KEYRING_SERVICE = "autobot_xhs"
|
||
|
||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
CONFIG_FILE = os.path.join(_PROJECT_ROOT, "config", "config.json")
|
||
OUTPUT_DIR = "xhs_workspace"
|
||
|
||
DEFAULT_CONFIG = {
|
||
"api_key": "",
|
||
"base_url": "https://api.openai.com/v1",
|
||
"sd_url": "http://127.0.0.1:7860",
|
||
"mcp_url": "http://localhost:18060/mcp",
|
||
"model": "gpt-3.5-turbo",
|
||
"persona": "性感福利主播,身材火辣衣着大胆,专注分享穿衣显身材和私房写真风穿搭",
|
||
"auto_reply_enabled": False,
|
||
"schedule_enabled": False,
|
||
"my_user_id": "",
|
||
"active_llm": "",
|
||
"llm_providers": [],
|
||
"use_smart_weights": True,
|
||
# SD 图片生成参数
|
||
"quality_mode": "标准 (约1分钟)",
|
||
"sd_steps": 20,
|
||
"sd_cfg_scale": 5.5,
|
||
"sd_negative_prompt": "",
|
||
# 自动运营调度参数
|
||
"sched_comment_on": True,
|
||
"sched_like_on": True,
|
||
"sched_fav_on": True,
|
||
"sched_reply_on": True,
|
||
"sched_publish_on": True,
|
||
"sched_c_min": 15,
|
||
"sched_c_max": 45,
|
||
"sched_l_min": 10,
|
||
"sched_l_max": 30,
|
||
"sched_like_count": 5,
|
||
"sched_fav_min": 12,
|
||
"sched_fav_max": 35,
|
||
"sched_fav_count": 3,
|
||
"sched_r_min": 20,
|
||
"sched_r_max": 60,
|
||
"sched_reply_max": 3,
|
||
"sched_p_min": 60,
|
||
"sched_p_max": 180,
|
||
"sched_start_hour": 8,
|
||
"sched_end_hour": 23,
|
||
"auto_like_count": 5,
|
||
"auto_fav_count": 3,
|
||
"auto_reply_max": 5,
|
||
# 智能学习参数
|
||
"learn_interval": 6,
|
||
# 内容排期参数
|
||
"queue_gen_count": 3,
|
||
# 热点自动采集参数
|
||
"hotspot_auto_collect": {
|
||
"enabled": False,
|
||
"keywords": ["穿搭", "美妆", "好物"],
|
||
"interval_hours": 4,
|
||
},
|
||
}
|
||
|
||
|
||
class ConfigManager:
|
||
"""配置管理器 - 单例模式"""
|
||
|
||
_instance = None
|
||
_config = None
|
||
|
||
def __new__(cls):
|
||
if cls._instance is None:
|
||
cls._instance = super().__new__(cls)
|
||
return cls._instance
|
||
|
||
def __init__(self):
|
||
if self._config is None:
|
||
self._config = self._load()
|
||
|
||
def _load(self) -> dict:
|
||
"""从文件加载配置,缺失项用默认值填充"""
|
||
config = DEFAULT_CONFIG.copy()
|
||
if os.path.exists(CONFIG_FILE):
|
||
try:
|
||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||
saved = json.load(f)
|
||
config.update(saved)
|
||
except (json.JSONDecodeError, IOError) as e:
|
||
logger.warning("配置文件读取失败,使用默认值: %s", e)
|
||
return config
|
||
|
||
def save(self):
|
||
"""原子写:临时文件 + os.replace,防止写中断导致数据损坏"""
|
||
import time
|
||
config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE)) or "."
|
||
try:
|
||
fd, tmp_path = tempfile.mkstemp(dir=config_dir, suffix=".tmp", prefix="config_")
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||
json.dump(self._config, f, indent=4, ensure_ascii=False)
|
||
# Windows 下目标文件被占用时 os.replace 会抛 PermissionError(WinError 5),重试最多 3 次
|
||
for attempt in range(3):
|
||
try:
|
||
os.replace(tmp_path, CONFIG_FILE)
|
||
break
|
||
except PermissionError:
|
||
if attempt == 2:
|
||
raise
|
||
time.sleep(0.1)
|
||
except Exception:
|
||
try:
|
||
os.remove(tmp_path)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
except IOError as e:
|
||
logger.error("配置保存失败: %s", e)
|
||
|
||
def get_secure(self, key: str, default: str = "") -> str:
|
||
"""读取敏感配置,优先级: 环境变量 AUTOBOT_<KEY> > keyring > config.json 明文(自动迁移)"""
|
||
env_val = os.environ.get(f"AUTOBOT_{key.upper()}")
|
||
if env_val:
|
||
return env_val
|
||
try:
|
||
import keyring
|
||
kr_val = keyring.get_password(_KEYRING_SERVICE, key)
|
||
if kr_val is not None:
|
||
return kr_val
|
||
except Exception as e:
|
||
logger.warning("keyring 读取失败,使用明文回退: %s", e)
|
||
current = self._config.get(key, default)
|
||
if current and current != _KEYRING_PLACEHOLDER:
|
||
# 有明文值,尝试自动迁移到 keyring
|
||
try:
|
||
import keyring
|
||
keyring.set_password(_KEYRING_SERVICE, key, current)
|
||
self._config[key] = _KEYRING_PLACEHOLDER
|
||
self.save()
|
||
logger.info("已将 '%s' 自动迁移至系统 keyring", key)
|
||
except Exception as e:
|
||
logger.warning("keyring 迁移失败,继续使用明文: %s", e)
|
||
return current
|
||
return default
|
||
|
||
def set_secure(self, key: str, value: str):
|
||
"""将敏感值存入 keyring(不可用时降级为明文),config.json 中写入占位符"""
|
||
try:
|
||
import keyring
|
||
keyring.set_password(_KEYRING_SERVICE, key, value)
|
||
self._config[key] = _KEYRING_PLACEHOLDER
|
||
self.save()
|
||
except Exception as e:
|
||
logger.warning("keyring 不可用,明文保存 '%s': %s", key, e)
|
||
self._config[key] = value
|
||
self.save()
|
||
|
||
def get(self, key: str, default=None):
|
||
"""获取配置项"""
|
||
return self._config.get(key, default)
|
||
|
||
def set(self, key: str, value):
|
||
"""设置配置项并自动保存"""
|
||
self._config[key] = value
|
||
self.save()
|
||
|
||
def update(self, data: dict):
|
||
"""批量更新配置"""
|
||
self._config.update(data)
|
||
self.save()
|
||
|
||
@property
|
||
def all(self) -> dict:
|
||
"""返回全部配置(副本)"""
|
||
return self._config.copy()
|
||
|
||
def ensure_workspace(self):
|
||
"""确保工作空间目录存在"""
|
||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||
|
||
# ---------- 多 LLM 提供商管理 ----------
|
||
|
||
def get_llm_providers(self) -> list[dict]:
|
||
"""获取所有 LLM 提供商配置"""
|
||
providers = self._config.get("llm_providers", [])
|
||
# 兼容旧配置: 如果 providers 为空但有 api_key,自动迁移
|
||
if not providers and self._config.get("api_key"):
|
||
default_provider = {
|
||
"name": "默认",
|
||
"api_key": self._config["api_key"],
|
||
"base_url": self._config.get("base_url", "https://api.openai.com/v1"),
|
||
}
|
||
providers = [default_provider]
|
||
self._config["llm_providers"] = providers
|
||
self._config["active_llm"] = "默认"
|
||
self.save()
|
||
return providers
|
||
|
||
def get_llm_provider_names(self) -> list[str]:
|
||
"""获取所有提供商名称列表"""
|
||
return [p["name"] for p in self.get_llm_providers()]
|
||
|
||
def get_active_llm(self) -> dict | None:
|
||
"""获取当前激活的 LLM 提供商配置(自动解析 keyring 中的 api_key)"""
|
||
active_name = self._config.get("active_llm", "")
|
||
providers = self.get_llm_providers()
|
||
provider = None
|
||
for p in providers:
|
||
if p["name"] == active_name:
|
||
provider = p
|
||
break
|
||
if provider is None:
|
||
provider = providers[0] if providers else None
|
||
if provider is None:
|
||
return None
|
||
# 解析 api_key:占位符时从 keyring 读取真实值
|
||
api_key = provider.get("api_key", "")
|
||
if api_key == _KEYRING_PLACEHOLDER:
|
||
secure_key = f"provider_{provider['name']}_api_key"
|
||
api_key = self.get_secure(secure_key, "")
|
||
return {**provider, "api_key": api_key}
|
||
|
||
def add_llm_provider(self, name: str, api_key: str, base_url: str) -> str:
|
||
"""添加一个 LLM 提供商,返回状态消息"""
|
||
name = name.strip()
|
||
if not name:
|
||
return "❌ 名称不能为空"
|
||
if not api_key.strip():
|
||
return "❌ API Key 不能为空"
|
||
providers = self.get_llm_providers()
|
||
for p in providers:
|
||
if p["name"] == name:
|
||
return f"❌ 名称「{name}」已存在,请换一个"
|
||
# 安全存储 api_key
|
||
stored_key = api_key.strip()
|
||
secure_key = f"provider_{name}_api_key"
|
||
try:
|
||
import keyring
|
||
keyring.set_password(_KEYRING_SERVICE, secure_key, stored_key)
|
||
stored_key = _KEYRING_PLACEHOLDER
|
||
except Exception as e:
|
||
logger.warning("keyring 不可用,api_key 明文存储: %s", e)
|
||
providers.append({
|
||
"name": name,
|
||
"api_key": stored_key,
|
||
"base_url": (base_url or "https://api.openai.com/v1").strip().rstrip("/"),
|
||
})
|
||
self._config["llm_providers"] = providers
|
||
if not self._config.get("active_llm"):
|
||
self._config["active_llm"] = name
|
||
self.save()
|
||
return f"✅ 已添加「{name}」"
|
||
|
||
def remove_llm_provider(self, name: str) -> str:
|
||
"""删除一个 LLM 提供商"""
|
||
providers = self.get_llm_providers()
|
||
new_providers = [p for p in providers if p["name"] != name]
|
||
if len(new_providers) == len(providers):
|
||
return f"⚠️ 未找到「{name}」"
|
||
self._config["llm_providers"] = new_providers
|
||
if self._config.get("active_llm") == name:
|
||
self._config["active_llm"] = new_providers[0]["name"] if new_providers else ""
|
||
self.save()
|
||
return f"✅ 已删除「{name}」"
|
||
|
||
def set_active_llm(self, name: str):
|
||
"""切换当前激活的 LLM 提供商"""
|
||
self._config["active_llm"] = name
|
||
# 同步到兼容字段
|
||
p = self.get_active_llm()
|
||
if p:
|
||
self._config["api_key"] = p["api_key"]
|
||
self._config["base_url"] = p["base_url"]
|
||
self.save()
|