Compare commits

..

No commits in common. "a75d6ea4225ba43432c7e4fba3cc6e1f7fbdc340" and "087d23f3fbb15ea88d7490504966c2721e6639b4" have entirely different histories.

17 changed files with 337 additions and 5218 deletions

View File

@ -2,111 +2,6 @@
本项目遵循 [Semantic Versioning](https://semver.org/) 语义化版本规范。
## [2.5.0] - 2026-02-10
### 🚀 新功能
- **人设专属 SD 视觉优化**
- 新增 `PERSONA_SD_PROFILES` 引擎9 种人设独立视觉方案
- 每个方案含 `prompt_boost`(前置增强词)、`prompt_style`(风格后缀)、`negative_extra`(负面补充)、`llm_guide`LLM 视觉指导)
- `txt2img()` 自动注入人设视觉方案,生图风格与人设严格匹配
- `get_sd_prompt_guide()` 融入人设专属 LLM 指导词
- 赛博AI虚拟博主特殊处理豁免反 AI 检测(拥抱 AI 身份)
- 全链路贯通Tab 1 手动生图、自动发布、队列生成均传入人设
- **新增人设赛博AI虚拟博主**
- 住在2077年的数码女孩AI 生成高颜值写真 + 全球场景打卡
- 20 个专属主题 + 18 个关键词,设为默认人设
- **新增人设:性感福利主播**
- 身材火辣衣着大胆,专注分享穿衣显身材和私房写真风穿搭
- 20 个专属主题 + 18 个关键词
### 支持的人设视觉方案
| 人设 | 风格关键词 |
|------|-----------|
| 赛博AI虚拟博主 | perfect face, vibrant colors, fantasy, dramatic lighting |
| 性感福利主播 | glamour photography, seductive, warm golden tones, boudoir |
| 身材管理健身美女 | fit body, athletic, gym environment, energetic |
| 温柔知性时尚博主 | elegant, fashion editorial, french style, magazine quality |
| 文艺青年摄影师 | film grain, vintage tones, kodak portra, nostalgic |
| 二次元coser | cosplay, vibrant colors, anime inspired, dynamic pose |
| 汉服爱好者 | traditional chinese, hanfu, ink painting aesthetic |
| 独居女孩 | cozy atmosphere, warm lighting, hygge, candle light |
| 资深美妆博主 | flawless makeup, beauty close-up, ring light, studio beauty |
## [2.4.0] - 2026-02-10
### 🚀 新功能
- **数据看板 Bug 修复**
- 修复 MCP 响应解析:`raw["raw"]["content"]` 多层嵌套正确处理
- 修复笔记 ID 字段:`f["noteId"]``f["id"]` 匹配实际 API 返回
## [2.3.0] - 2026-02-10
### 🚀 新功能
- **内容排期系统Tab 8: 📅 内容排期)**
- 新增 `publish_queue.py`SQLite 队列管理,支持 草稿→审核→排期→发布中→已发布/失败 全流程
- 批量生成:一键生成多篇内容(文案 + 图片)加入队列
- 队列处理器:`QueuePublisher` 后台线程自动轮询定时发布
- 队列管理 UI预览、审核、驳回、设定时间、启动/停止处理器
- **反 AI 检测增强**
- 8 层文案人格化口语化改写、随机错别字、方言词汇、不规律标点、emoji 混入、段落随机化
- 7 步图片后处理微旋转裁剪、色偏、噪点、JPEG 二次压缩(质量 82-92、EXIF 清除、局部模糊/锐化
- 所有图片保存为 JPEG 格式(非 PNG模拟手机拍摄
- **全局参数传递审计修复**
- `persona` 参数贯通 `generate_copy` / `generate_from_hotspot` / `auto_publish_once` 全链路
- `quality_mode` 从 UI 传入替代硬编码,所有生图调用统一使用用户选择的质量档位
## [2.2.0] - 2026-02-10
### 🚀 新功能
- **多 SD 模型智能适配**
- 支持 3 款模型档案majicmixRealistic东亚网红风、Realistic Vision纪实摄影风、Juggernaut XL电影大片风
- 自动检测当前模型,匹配提示词前缀/后缀、反向提示词、分辨率、CFG 参数
- LLM 生成 SD 提示词时自动注入模型专属指南(语法、风格、禁忌词)
- UI 选模型实时显示模型档案信息卡(架构、分辨率、风格说明)
- 未知模型自动回退到 Juggernaut XL 默认档案并提示
- **身材管理健身美女人设**
- 新增默认人设20 个主题 + 18 个关键词库
- 覆盖健身打卡、穿搭显瘦、饮食管理、身材对比等高互动方向
### ⚙️ 改进
- `sd_service.py` 重构:`SD_MODEL_PROFILES` 配置体系替代旧硬编码预设
- `llm_service.py`:三套文案 Prompt 支持 `{sd_prompt_guide}` 动态占位符
- `main.py`:所有文案/图片生成链路传递 `sd_model_name` 参数
- 自动运营调度链路完整传递 SD 模型参数
## [2.1.0] - 2026-02-10
### 🚀 新功能
- **智能学习引擎** (新 Tab: 🧠 智能学习)
- 自动采集已发布笔记的互动数据 (点赞、评论、收藏)
- 多维度权重计算:主题权重、风格权重、标签权重、标题模式权重
- AI 深度分析LLM 分析笔记表现规律,生成内容策略建议
- 定时自动学习可配置间隔1-48小时后台自动采集 + 分析
- 可视化报告:权重排行、模式分析、智能建议
- 加权主题预览:实时查看权重最高的主题
- **智能加权发布**
- 自动发布时根据笔记表现权重选择主题(高权重主题优先)
- 智能加权文案生成:融入权重洞察生成高互动潜力内容
- 自动补充高权重标签到发布内容
- 一键开关:可在智能学习 Tab 启用/关闭
### 📁 新文件
- `analytics_service.py` - 笔记数据分析 & 权重学习服务模块
- `xhs_workspace/analytics_data.json` - 笔记表现数据存储
- `xhs_workspace/content_weights.json` - 内容权重数据存储
## [2.0.0] - 2026-02-08
### 🚀 新功能

View File

@ -92,13 +92,11 @@ refactor(mcp): 重构评论解析逻辑
## 项目架构
```
main.py # 主程序Gradio UI (8 Tabs) + 业务逻辑 + 自动化调度
main.py # 主程序Gradio UI + 业务逻辑 + 自动化调度
├─ config_manager.py # 配置管理:单例模式,多 LLM 提供商
├─ llm_service.py # LLM 封装文案生成、热点分析、评论回复、SD Prompt 指南
├─ sd_service.py # SD 封装3 模型适配 + 9 人设视觉方案 + 换脸 + 反AI后处理
├─ mcp_client.py # MCP 客户端:小红书搜索、发布、评论、点赞
├─ analytics_service.py # 笔记数据分析 & 权重学习服务
└─ publish_queue.py # 内容排期队列SQLite + 后台 Publisher
├─ llm_service.py # LLM 封装:文案生成、热点分析、评论回复
├─ sd_service.py # SD 封装txt2img、img2imgJuggernautXL 优化)
└─ mcp_client.py # MCP 客户端:小红书搜索、发布、评论、点赞
```
### 核心设计原则
@ -107,8 +105,6 @@ main.py # 主程序Gradio UI (8 Tabs) + 业务逻辑 + 自动化
- **MCP 协议** — 通过 JSON-RPC 与小红书 MCP 服务通信,不直接操作浏览器
- **LLM 无关** — 支持所有 OpenAI 兼容 API不绑定特定提供商
- **UI 逻辑分离** — 业务函数与 Gradio UI 组件分开定义
- **人设驱动** — 从文案风格到图片视觉,人设参数贯穿全链路
- **防风控** — 每日操作限额、随机间隔、错误冷却、反 AI 检测多重保护
## 代码风格

142
README.md
View File

@ -1,15 +1,14 @@
<p align="center">
<h1 align="center">🍒 小红书 AI 爆文生产工坊 V2.5</h1>
<h1 align="center">🍒 小红书 AI 爆文生产工坊</h1>
<p align="center">
<strong>全自动小红书内容创作 & 智能运营工具</strong><br>
灵感 → 文案 → 绘图 → 排期 → 发布 → 运营 → 学习,全闭环 AI 驱动
<strong>全自动小红书内容创作 & 运营工具</strong><br>
灵感 → 文案 → 绘图 → 发布 → 运营,一站式全闭环
</p>
<p align="center">
<a href="#功能特性">功能特性</a>
<a href="#快速开始">快速开始</a>
<a href="#使用指南">使用指南</a>
<a href="#配置说明">配置说明</a>
<a href="#人设系统">人设系统</a>
<a href="#常见问题">FAQ</a>
<a href="#贡献指南">贡献</a>
</p>
@ -19,54 +18,34 @@
## ✨ 功能特性
### 📝 内容创作Tab 1
### 📝 内容创作
- **AI 文案生成** — 输入主题即可生成小红书爆款标题、正文、话题标签
- **AI 绘图** — 集成 Stable Diffusion WebUI支持 3 款模型智能适配
- **人设视觉优化** — 9 种人设专属视觉方案,自动注入风格提示词
- **ReActor 换脸** — 上传头像一键换脸,保持角色一致性
- **质量模式** — 快速/标准/精细三档,灵活平衡速度与画质
- **反 AI 检测** — 8 层文案人格化 + 7 步图片后处理,绕过 AI 内容检测
- **AI 绘图** — 集成 Stable Diffusion WebUI自动生成配图针对 JuggernautXL 优化)
- **一键导出** — 文案 + 图片打包导出到本地文件夹
### 🔥 热点探测Tab 2
### 🔥 热点探测
- **关键词搜索** — 搜索小红书热门笔记,支持多维度排序
- **AI 趋势分析** — 分析热门标题套路、内容结构,给出模仿建议
- **一键借鉴创作** — 参考热门笔记风格生成原创内容
### 💬 评论管家Tab 3
### 💬 评论管家
- **主动评论引流** — 浏览笔记 → AI 智能生成评论 → 一键发送
- **自动回复粉丝** — 加载笔记评论 → AI 生成回复 → 发送
### 🔐 账号管理Tab 4
- **扫码登录** — 小红书二维码登录,自动获取 Token
- **多 LLM 提供商** — 支持 DeepSeek、OpenAI、通义千问等所有兼容接口
### 📊 数据看板Tab 5
- **账号概览** — 粉丝数、获赞数等核心指标可视化
- **笔记排行** — 点赞排行图表分析
- **笔记详情** — 全部笔记数据明细表
### 🧠 智能学习Tab 6
- **数据采集** — 自动采集已发布笔记的互动数据(点赞、评论、收藏)
- **多维权重** — 主题权重、风格权重、标签权重、标题模式权重
- **AI 深度分析** — LLM 分析笔记表现规律,生成内容策略建议
- **定时学习** — 后台自动采集 + 分析1-48 小时可配置)
- **加权发布** — 自动发布时根据笔记表现权重智能选择高互动主题
### 🤖 自动运营Tab 7无人值守
### 🤖 自动运营(无人值守)
- **一键评论** — 自动搜索高赞笔记 + AI 生成评论 + 发送
- **一键点赞** — 批量随机点赞,提升账号活跃度
- **一键回复** — 自动扫描我的笔记 + AI 回复粉丝评论
- **一键发布** — 自动生成文案 + SD 生图 + 发布到小红书
- **随机定时** — 评论/点赞/回复/发布全自动定时执行,随机间隔模拟真人
- **每日限额** — 评论/点赞/收藏/发布/回复独立限额,防封号
- **错误冷却** — 连续错误自动暂停,避免异常操作
### 📅 内容排期Tab 8
- **批量生成** — 一键批量生成多篇内容(文案 + 图片)加入队列
- **队列管理** — 草稿 → 审核 → 排期 → 发布,全流程状态管理
- **定时发布** — SQLite 队列 + 后台 Publisher 线程自动定时发布
- **队列处理器** — 启动/停止后台自动发布引擎
### 📊 数据看板
- **账号概览** — 粉丝数、获赞数等核心指标可视化
- **笔记排行** — 点赞排行图表分析
### 🔐 账号管理
- **扫码登录** — 小红书二维码登录,自动获取 Token
- **多 LLM 提供商** — 支持 DeepSeek、OpenAI、通义千问等所有兼容接口
---
@ -191,13 +170,7 @@ npx xiaohongshu-mcp
python launch.py --api
```
推荐模型:
| 模型 | 架构 | 风格 | 推荐场景 |
|------|------|------|---------|
| [JuggernautXL](https://civitai.com/models/133005) | SDXL | 电影大片风 | 通用首选,高质量写真 |
| [majicmixRealistic](https://civitai.com/models/43331) | SD 1.5 | 东亚网红风 | 亚洲博主、日韩风 |
| [Realistic Vision](https://civitai.com/models/4201) | SD 1.5 | 纪实摄影风 | 生活场景、街拍 |
推荐模型:[JuggernautXL](https://civitai.com/models/133005)(参数已优化适配)。
---
@ -209,8 +182,7 @@ python launch.py --api
2. **连接 SD**(可选)— 填写 SD WebUI URL点击「连接 SD」
3. **检查 MCP** — 点击「检查 MCP」确认小红书服务正常
4. **登录小红书** — 切换到「🔐 账号登录」Tab扫码登录
5. **选择人设** — 在人设下拉框选择博主人设(影响文案风格 + 图片视觉)
6. **开始创作** — 切换到「✨ 内容创作」Tab输入主题一键生成
5. **开始创作** — 切换到「✨ 内容创作」Tab输入主题一键生成
### 自动化运营
@ -219,25 +191,6 @@ python launch.py --api
- **一键操作** — 手动触发单次评论/点赞/回复/发布
- **定时调度** — 勾选需要的功能,设置间隔时间,点击「▶️ 启动定时」
- **查看日志** — 点击「🔄 刷新日志」查看实时执行记录
- **每日统计** — 自动跟踪今日操作量,超限自动暂停
### 内容排期
切换到「📅 内容排期」Tab
1. **批量生成** — 点击「📋 批量生成 → 加入队列」,自动生成多篇内容存入队列
2. **审核内容** — 在队列列表中预览、编辑或驳回草稿
3. **设定时间** — 为已审核内容设置发布时间
4. **启动发布** — 点击「▶ 启动队列处理」,后台自动在预定时间发布
### 智能学习
切换到「🧠 智能学习」Tab
1. **采集数据** — 点击「📊 立即采集」收集笔记互动数据
2. **分析权重** — 系统自动计算主题/风格/标签权重
3. **启用加权** — 开启后自动发布时优先选择高互动主题
4. **定时学习** — 设置自动采集间隔,持续优化内容策略
---
@ -252,7 +205,7 @@ python launch.py --api
"sd_url": "http://127.0.0.1:7860",
"mcp_url": "http://localhost:18060/mcp",
"model": "deepseek-chat",
"persona": "赛博AI虚拟博主住在2077年的数码女孩...",
"persona": "温柔知性的时尚博主",
"my_user_id": "你的小红书userId(24位)"
}
```
@ -264,7 +217,7 @@ python launch.py --api
| `sd_url` | Stable Diffusion WebUI 地址 | 绘图需要 |
| `mcp_url` | xiaohongshu-mcp 服务地址 | ✅ |
| `model` | 默认使用的 LLM 模型名 | ✅ |
| `persona` | 博主人设(影响文案风格 + SD 视觉风格) | 可选 |
| `persona` | AI 评论回复的人设 | 可选 |
| `my_user_id` | 你的小红书 userId24 位十六进制) | 数据看板/自动回复需要 |
| `llm_providers` | 多 LLM 提供商配置数组 | 通过 UI 管理 |
@ -280,13 +233,11 @@ python launch.py --api
```
xhs-autobot/
├── main.py # 主程序入口 (Gradio UI + 业务逻辑 + 8 个 Tab)
├── main.py # 主程序入口 (Gradio UI + 业务逻辑)
├── config_manager.py # 配置管理模块 (单例、自动保存)
├── llm_service.py # LLM 服务封装 (文案生成、热点分析、评论回复、SD Prompt 指南)
├── sd_service.py # Stable Diffusion 服务封装 (3 模型适配、9 人设视觉方案)
├── llm_service.py # LLM 服务封装 (文案生成、热点分析、评论回复)
├── sd_service.py # Stable Diffusion 服务封装 (txt2img、img2img)
├── mcp_client.py # 小红书 MCP 客户端 (搜索、发布、评论、点赞)
├── analytics_service.py # 笔记数据分析 & 权重学习服务
├── publish_queue.py # 内容排期队列 (SQLite + 后台 Publisher)
├── config.json # 运行时配置 (gitignore)
├── config.example.json # 配置模板
├── requirements.txt # Python 依赖
@ -294,40 +245,11 @@ xhs-autobot/
├── docker-compose.yml # Docker Compose 编排
├── .dockerignore # Docker 构建排除规则
├── xhs_workspace/ # 导出的文案和图片 (gitignore)
│ ├── publish_queue.db # 排期队列数据库
│ ├── analytics_data.json # 笔记表现数据
│ └── content_weights.json # 内容权重数据
└── autobot.log # 运行日志 (gitignore)
```
---
## 🎭 人设系统
项目内置 **28 种博主人设**,每种人设拥有:
- **专属主题池** — 匹配人设的内容方向(如健身博主自动抽健身主题)
- **评论关键词** — 搜索和评论时使用与人设相关的关键词
- **SD 视觉方案** — 9 种人设拥有专属视觉优化prompt_boost + 风格词 + 负面词)
- **LLM 指导词** — LLM 生成 SD Prompt 时收到人设专属的视觉风格引导
### 内置人设视觉方案
| 人设 | 视觉风格 |
|------|----------|
| 赛博AI虚拟博主 | 科幻/赛博朋克,发光特效,极致完美面容 |
| 性感福利主播 | 暖金色调,闺房/泳池,魅力摄影 |
| 身材管理健身美女 | 健身房/运动场,活力运动风 |
| 温柔知性时尚博主 | 法式优雅,时装杂志质感 |
| 文艺青年摄影师 | 胶片颗粒Kodak Portra 怀旧色调 |
| 二次元coser | 动漫灵感Cosplay鲜艳色彩 |
| 汉服爱好者 | 水墨画风,丝绸流动,古典意境 |
| 独居女孩 | 温馨烛光Hygge 风格 |
| 资深美妆博主 | 环形灯棚拍,完美妆容特写 |
选择「🎲 随机人设」可每次自动切换,增加账号内容多样性。
---
## ❓ 常见问题
<details>
@ -360,26 +282,6 @@ xhs-autobot/
支持所有 OpenAI 兼容接口包括但不限于DeepSeek、GPT-4o、通义千问、Gemini通过中转、Claude通过中转等。
</details>
<details>
<summary><b>Q: 反 AI 检测是怎么实现的?</b></summary>
**文案层面**8 层人格化口语化改写、插入不完美表达、随机错别字、方言词汇、不规律标点、emoji 混入、段落长度随机化、句式打乱。
**图片层面**7 步后处理微旋转裁剪、轻微色偏、随机噪点、JPEG 二次压缩(质量 82-92、EXIF 清除、局部模糊/锐化、微位移。
</details>
<details>
<summary><b>Q: 如何添加自定义人设?</b></summary>
在人设下拉框中直接输入自定义人设描述即可(支持自由输入)。如需配套主题池和关键词,需在 `main.py``PERSONA_POOL_MAP` 中添加对应条目。如需配套 SD 视觉方案,需在 `sd_service.py``PERSONA_SD_PROFILES` 中添加。
</details>
<details>
<summary><b>Q: 支持哪些 SD 模型?</b></summary>
内置适配 3 款模型majicmixRealisticSD 1.5、Realistic VisionSD 1.5、Juggernaut XLSDXL。系统会自动检测当前模型并匹配最佳参数。未知模型自动回退到 SDXL 默认档案。
</details>
---
## 🤝 贡献指南

View File

@ -1,3 +0,0 @@
@echo off
cd /d "F:\3_Personal\AI\xhs_bot\autobot"
"F:\3_Personal\AI\xhs_bot\autobot\.venv\Scripts\pythonw.exe" "F:\3_Personal\AI\xhs_bot\autobot\main.py"

View File

@ -1,3 +0,0 @@
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run chr(34) & "F:\3_Personal\AI\xhs_bot\autobot\_autostart.bat" & chr(34), 0
Set WshShell = Nothing

View File

@ -1,642 +0,0 @@
"""
笔记数据分析 & 智能权重学习模块
定时抓取已发布笔记的互动数据自动学习哪些内容受欢迎生成加权主题池
"""
import json
import os
import re
import time
import logging
import math
from datetime import datetime, timedelta
from collections import defaultdict
logger = logging.getLogger(__name__)
ANALYTICS_FILE = "analytics_data.json"
WEIGHTS_FILE = "content_weights.json"
def _safe_int(val) -> int:
"""'1.2万' / '1234' / 1234 等格式转为整数"""
if isinstance(val, (int, float)):
return int(val)
if not val:
return 0
s = str(val).strip()
if "" in s:
try:
return int(float(s.replace("", "")) * 10000)
except ValueError:
return 0
try:
return int(float(s))
except ValueError:
return 0
class AnalyticsService:
"""笔记表现分析 & 权重学习引擎"""
def __init__(self, workspace_dir: str = "xhs_workspace"):
self.workspace_dir = workspace_dir
self.analytics_path = os.path.join(workspace_dir, ANALYTICS_FILE)
self.weights_path = os.path.join(workspace_dir, WEIGHTS_FILE)
self._analytics_data = self._load_json(self.analytics_path, {"notes": {}, "last_analysis": ""})
self._weights = self._load_json(self.weights_path, {
"topic_weights": {},
"style_weights": {},
"tag_weights": {},
"title_pattern_weights": {},
"time_weights": {},
"last_updated": "",
"analysis_history": [],
})
# ========== 持久化 ==========
@staticmethod
def _load_json(path: str, default: dict) -> dict:
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.warning("加载 %s 失败: %s,使用默认值", path, e)
return default.copy()
def _save_analytics(self):
os.makedirs(self.workspace_dir, exist_ok=True)
with open(self.analytics_path, "w", encoding="utf-8") as f:
json.dump(self._analytics_data, f, ensure_ascii=False, indent=2)
def _save_weights(self):
os.makedirs(self.workspace_dir, exist_ok=True)
with open(self.weights_path, "w", encoding="utf-8") as f:
json.dump(self._weights, f, ensure_ascii=False, indent=2)
# ========== 数据采集 ==========
def collect_note_performance(self, mcp_client, user_id: str, xsec_token: str) -> dict:
"""
通过 MCP 获取我的所有笔记及其互动数据存入 analytics_data.json
返回 {"total": N, "updated": M, "notes": [...]}
"""
logger.info("开始采集笔记表现数据 (user_id=%s)", user_id)
raw = mcp_client.get_user_profile(user_id, xsec_token)
text = ""
if isinstance(raw, dict):
# _call_tool 返回 {"success": True, "text": "...", "raw": <mcp原始响应>}
# 优先从 raw["raw"]["content"] 提取,兼容直接 content
inner_raw = raw.get("raw", {})
content_list = []
if isinstance(inner_raw, dict):
content_list = inner_raw.get("content", [])
if not content_list:
content_list = raw.get("content", [])
for item in content_list:
if isinstance(item, dict) and item.get("type") == "text":
text = item.get("text", "")
break
if not text:
text = raw.get("text", "")
# 解析 JSON
data = None
for attempt_fn in [
lambda t: json.loads(t),
lambda t: json.loads(re.search(r'```(?:json)?\s*\n([\s\S]+?)\n```', t).group(1)),
lambda t: json.loads(re.search(r'(\{[\s\S]*\})', t).group(1)),
]:
try:
data = attempt_fn(text)
if data:
break
except Exception:
continue
if not data:
return {"total": 0, "updated": 0, "error": "无法解析用户数据"}
feeds = data.get("feeds", [])
if not feeds:
return {"total": 0, "updated": 0, "error": "未找到笔记数据"}
notes_dict = self._analytics_data.get("notes", {})
updated = 0
note_summaries = []
for f in feeds:
nc = f.get("noteCard") or {}
# MCP 用户主页 feeds 中,笔记 ID 在 f["id"] 而非 nc["noteId"]
note_id = nc.get("noteId") or f.get("id", "") or f.get("noteId", "")
if not note_id:
logger.warning("跳过无 ID 的笔记条目: keys=%s", list(f.keys()))
continue
interact = nc.get("interactInfo") or {}
liked = _safe_int(interact.get("likedCount", 0))
# MCP 返回的用户主页笔记列表通常只有 likedCount
# 详情页才有评论数和收藏数,先用点赞数作为主指标
title = nc.get("displayTitle", "") or ""
note_type = nc.get("type", "normal") # normal / video
# 从本地备份的文案中提取主题、风格、标签
local_meta = self._find_local_meta(title)
note_data = {
"note_id": note_id,
"title": title,
"type": note_type,
"likes": liked,
"topic": local_meta.get("topic", ""),
"style": local_meta.get("style", ""),
"tags": local_meta.get("tags", []),
"sd_prompt": local_meta.get("sd_prompt", ""),
"collected_at": datetime.now().isoformat(),
}
# 更新或新增
old = notes_dict.get(note_id, {})
if old.get("likes", 0) != liked or not old:
updated += 1
notes_dict[note_id] = {**old, **note_data}
note_summaries.append(note_data)
self._analytics_data["notes"] = notes_dict
self._analytics_data["last_analysis"] = datetime.now().isoformat()
self._save_analytics()
logger.info("采集完成: 共 %d 篇笔记, 更新 %d", len(feeds), updated)
return {"total": len(feeds), "updated": updated, "notes": note_summaries}
def collect_note_details(self, mcp_client, note_id: str, xsec_token: str):
"""获取单篇笔记的详细数据(点赞、评论数、收藏等)"""
try:
result = mcp_client.get_feed_detail(note_id, xsec_token, load_all_comments=False)
text = ""
if isinstance(result, dict):
# 兼容 _call_tool 包装格式
inner_raw = result.get("raw", {})
content_list = []
if isinstance(inner_raw, dict):
content_list = inner_raw.get("content", [])
if not content_list:
content_list = result.get("content", [])
for item in content_list:
if isinstance(item, dict) and item.get("type") == "text":
text = item.get("text", "")
break
if not text:
text = result.get("text", "")
if text:
data = None
try:
data = json.loads(text)
except Exception:
m = re.search(r'(\{[\s\S]*\})', text)
if m:
try:
data = json.loads(m.group(1))
except Exception:
pass
if data:
interact = data.get("interactInfo") or {}
comments = data.get("comments", [])
return {
"likes": _safe_int(interact.get("likedCount", 0)),
"comments_count": _safe_int(interact.get("commentCount", len(comments))),
"collects": _safe_int(interact.get("collectedCount", 0)),
"shares": _safe_int(interact.get("shareCount", 0)),
}
except Exception as e:
logger.warning("获取笔记 %s 详情失败: %s", note_id, e)
return None
def _find_local_meta(self, title: str) -> dict:
"""从本地 xhs_workspace 中查找匹配标题的备份文案,提取 topic/style/tags"""
result = {"topic": "", "style": "", "tags": [], "sd_prompt": ""}
if not title:
return result
# 搜索备份目录
try:
for dirname in os.listdir(self.workspace_dir):
dir_path = os.path.join(self.workspace_dir, dirname)
if not os.path.isdir(dir_path) or dirname.startswith("_"):
continue
txt_path = os.path.join(dir_path, "文案.txt")
if not os.path.exists(txt_path):
continue
try:
with open(txt_path, "r", encoding="utf-8") as f:
content = f.read()
# 检查标题是否匹配
if title[:10] in content or title in dirname:
# 提取元数据
for line in content.split("\n"):
if line.startswith("风格:"):
result["style"] = line.split(":", 1)[1].strip()
elif line.startswith("主题:"):
result["topic"] = line.split(":", 1)[1].strip()
elif line.startswith("标签:"):
tags_str = line.split(":", 1)[1].strip()
result["tags"] = [t.strip() for t in tags_str.split(",") if t.strip()]
elif line.startswith("SD Prompt:"):
result["sd_prompt"] = line.split(":", 1)[1].strip()
break
except Exception:
continue
except Exception:
pass
return result
# ========== 权重计算 ==========
def calculate_weights(self) -> dict:
"""
根据已采集的笔记表现数据计算各维度权重
使用 互动得分 = likes * 1.0 + comments * 2.0 + collects * 1.5 加权
返回权重摘要
"""
notes = self._analytics_data.get("notes", {})
if not notes:
return {"error": "暂无笔记数据,请先采集"}
# 计算每篇笔记的综合得分
scored_notes = []
for nid, note in notes.items():
likes = note.get("likes", 0)
comments_count = note.get("comments_count", 0)
collects = note.get("collects", 0)
# 综合得分: 点赞权重 1.0, 评论权重 2.0(评论代表深度互动), 收藏权重 1.5
score = likes * 1.0 + comments_count * 2.0 + collects * 1.5
# 至少用点赞数保底
if score == 0:
score = likes
scored_notes.append({**note, "score": score, "note_id": nid})
if not scored_notes:
return {"error": "没有可分析的笔记"}
# 按得分排序
scored_notes.sort(key=lambda x: x["score"], reverse=True)
max_score = scored_notes[0]["score"] if scored_notes[0]["score"] > 0 else 1
# ---- 主题权重 ----
topic_scores = defaultdict(float)
topic_counts = defaultdict(int)
for note in scored_notes:
topic = note.get("topic", "").strip()
if topic:
topic_scores[topic] += note["score"]
topic_counts[topic] += 1
topic_weights = {}
for topic, total_score in topic_scores.items():
avg_score = total_score / topic_counts[topic]
# 归一化到 0-100
weight = min(100, int((avg_score / max_score) * 100)) if max_score > 0 else 50
# 多篇验证的加分
if topic_counts[topic] >= 3:
weight = min(100, weight + 10)
elif topic_counts[topic] >= 2:
weight = min(100, weight + 5)
topic_weights[topic] = {
"weight": weight,
"count": topic_counts[topic],
"avg_score": round(avg_score, 1),
"total_score": round(total_score, 1),
}
# ---- 风格权重 ----
style_scores = defaultdict(float)
style_counts = defaultdict(int)
for note in scored_notes:
style = note.get("style", "").strip()
if style:
style_scores[style] += note["score"]
style_counts[style] += 1
style_weights = {}
for style, total_score in style_scores.items():
avg = total_score / style_counts[style]
weight = min(100, int((avg / max_score) * 100)) if max_score > 0 else 50
style_weights[style] = {
"weight": weight,
"count": style_counts[style],
"avg_score": round(avg, 1),
}
# ---- 标签权重 ----
tag_scores = defaultdict(float)
tag_counts = defaultdict(int)
for note in scored_notes:
for tag in note.get("tags", []):
tag = tag.strip().lstrip("#")
if tag:
tag_scores[tag] += note["score"]
tag_counts[tag] += 1
tag_weights = {}
for tag, total_score in tag_scores.items():
avg = total_score / tag_counts[tag]
weight = min(100, int((avg / max_score) * 100)) if max_score > 0 else 50
tag_weights[tag] = {"weight": weight, "count": tag_counts[tag]}
# 排序后取 Top
tag_weights = dict(sorted(tag_weights.items(), key=lambda x: x[1]["weight"], reverse=True)[:30])
# ---- 标题模式权重 (提取 emoji/句式/长度特征) ----
title_patterns = defaultdict(list)
for note in scored_notes:
title = note.get("title", "")
if not title:
continue
# 检测标题特征
has_emoji = bool(re.search(r'[\U0001F600-\U0001F9FF\u2600-\u27BF]', title))
has_question = "" in title or "?" in title
has_exclaim = "" in title or "!" in title
has_ellipsis = "..." in title or "" in title
length_bucket = "短(≤10)" if len(title) <= 10 else ("中(11-15)" if len(title) <= 15 else "长(16-20)")
for feature, val in [
("含emoji", has_emoji), ("疑问句式", has_question),
("感叹句式", has_exclaim), ("省略句式", has_ellipsis),
]:
if val:
title_patterns[feature].append(note["score"])
title_patterns[f"长度:{length_bucket}"].append(note["score"])
title_pattern_weights = {}
for pattern, scores in title_patterns.items():
avg = sum(scores) / len(scores) if scores else 0
title_pattern_weights[pattern] = {
"weight": min(100, int((avg / max_score) * 100)) if max_score > 0 else 50,
"count": len(scores),
"avg_score": round(avg, 1),
}
# ---- 发布时间权重 ----
time_scores = defaultdict(list)
for note in scored_notes:
collected = note.get("collected_at", "")
if collected:
try:
dt = datetime.fromisoformat(collected)
hour_bucket = f"{(dt.hour // 3) * 3:02d}-{(dt.hour // 3) * 3 + 3:02d}"
time_scores[hour_bucket].append(note["score"])
except Exception:
pass
time_weights = {}
for bucket, scores in time_scores.items():
avg = sum(scores) / len(scores) if scores else 0
time_weights[bucket] = {
"weight": min(100, int((avg / max_score) * 100)) if max_score > 0 else 50,
"count": len(scores),
}
# ---- 保存权重 ----
self._weights.update({
"topic_weights": dict(sorted(topic_weights.items(), key=lambda x: x[1]["weight"], reverse=True)),
"style_weights": dict(sorted(style_weights.items(), key=lambda x: x[1]["weight"], reverse=True)),
"tag_weights": tag_weights,
"title_pattern_weights": title_pattern_weights,
"time_weights": time_weights,
"last_updated": datetime.now().isoformat(),
"total_notes_analyzed": len(scored_notes),
"top_note": {
"title": scored_notes[0].get("title", ""),
"score": scored_notes[0].get("score", 0),
"likes": scored_notes[0].get("likes", 0),
} if scored_notes else {},
})
# 追加分析历史
history = self._weights.get("analysis_history", [])
history.append({
"time": datetime.now().isoformat(),
"total_notes": len(scored_notes),
"avg_score": round(sum(n["score"] for n in scored_notes) / len(scored_notes), 1),
"top_topic": list(topic_weights.keys())[0] if topic_weights else "",
})
# 只保留最近 50 条
self._weights["analysis_history"] = history[-50:]
self._save_weights()
return {
"total_notes": len(scored_notes),
"top_topics": list(topic_weights.items())[:10],
"top_styles": list(style_weights.items())[:5],
"top_tags": list(tag_weights.items())[:10],
"title_patterns": title_pattern_weights,
"top_note": scored_notes[0] if scored_notes else None,
}
# ========== 加权主题选择 ==========
def get_weighted_topic(self, base_topics: list[str] = None) -> str:
"""
根据权重从主题池中加权随机选择一个主题
如果没有权重数据, 退回均匀随机
"""
import random
topic_weights = self._weights.get("topic_weights", {})
if not topic_weights:
# 无权重数据,从基础池中随机
return random.choice(base_topics) if base_topics else "日常分享"
# 合并: 已有权重的主题 + base_topics 中新的主题
all_topics = {}
for topic, info in topic_weights.items():
all_topics[topic] = info.get("weight", 50)
if base_topics:
for t in base_topics:
if t not in all_topics:
all_topics[t] = 30 # 新主题给一个基础权重
# 加权随机选择
topics = list(all_topics.keys())
weights = [max(1, all_topics[t]) for t in topics] # 确保权重 >= 1
chosen = random.choices(topics, weights=weights, k=1)[0]
logger.info("加权选题: %s (权重: %s)", chosen, all_topics.get(chosen, "?"))
return chosen
def get_weighted_style(self, base_styles: list[str] = None) -> str:
"""根据权重选择风格"""
import random
style_weights = self._weights.get("style_weights", {})
if not style_weights:
return random.choice(base_styles) if base_styles else "真实分享"
all_styles = {}
for style, info in style_weights.items():
all_styles[style] = info.get("weight", 50)
if base_styles:
for s in base_styles:
if s not in all_styles:
all_styles[s] = 30
styles = list(all_styles.keys())
weights = [max(1, all_styles[s]) for s in styles]
return random.choices(styles, weights=weights, k=1)[0]
def get_top_tags(self, n: int = 8) -> list[str]:
"""获取权重最高的 N 个标签"""
tag_weights = self._weights.get("tag_weights", {})
if not tag_weights:
return []
sorted_tags = sorted(tag_weights.items(), key=lambda x: x[1].get("weight", 0), reverse=True)
return [t[0] for t in sorted_tags[:n]]
def get_title_advice(self) -> str:
"""根据标题模式权重生成建议"""
patterns = self._weights.get("title_pattern_weights", {})
if not patterns:
return "暂无标题分析数据"
sorted_p = sorted(patterns.items(), key=lambda x: x[1].get("weight", 0), reverse=True)
advice_parts = []
for p_name, p_info in sorted_p[:5]:
advice_parts.append(f"{p_name}: 权重 {p_info['weight']}分 (出现{p_info['count']}次)")
return "\n".join(advice_parts)
# ========== LLM 深度分析 ==========
def generate_llm_analysis_prompt(self) -> str:
"""生成给 LLM 分析笔记表现的 prompt 数据部分"""
notes = self._analytics_data.get("notes", {})
if not notes:
return ""
# 按点赞排序
sorted_notes = sorted(notes.values(), key=lambda x: x.get("likes", 0), reverse=True)
lines = []
for i, note in enumerate(sorted_notes[:20]):
lines.append(
f"#{i+1}{note.get('title', '无标题')}\n"
f" 点赞: {note.get('likes', 0)} | 主题: {note.get('topic', '未知')} | "
f"风格: {note.get('style', '未知')}\n"
f" 标签: {', '.join(note.get('tags', []))}"
)
return "\n".join(lines)
# ========== 报告生成 ==========
def generate_report(self) -> str:
"""生成 Markdown 格式的分析报告"""
weights = self._weights
notes = self._analytics_data.get("notes", {})
if not notes:
return "## 📊 暂无分析数据\n\n请先点击「采集数据」获取笔记表现数据,再点击「计算权重」。"
total = len(notes)
last_updated = weights.get("last_updated", "未知")
# Top Note
top_note = weights.get("top_note", {})
top_note_str = f"**{top_note.get('title', '')}** (❤️ {top_note.get('likes', 0)})" if top_note else "暂无"
lines = [
f"## 📊 智能内容学习报告",
f"",
f"🕐 最后更新: {last_updated[:19] if last_updated else '从未'}",
f"📝 分析笔记数: **{total}** 篇",
f"🏆 最佳笔记: {top_note_str}",
"",
"---",
"",
]
# 主题权重
topic_w = weights.get("topic_weights", {})
if topic_w:
lines.append("### 🎯 主题权重排行")
lines.append("| 排名 | 主题 | 权重 | 笔记数 | 平均得分 |")
lines.append("|:---:|------|:---:|:---:|:---:|")
for idx, (topic, info) in enumerate(list(topic_w.items())[:10]):
bar = "" * (info["weight"] // 10) + "" * (10 - info["weight"] // 10)
lines.append(
f"| {idx+1} | {topic} | {bar} {info['weight']} | {info['count']} | {info['avg_score']} |"
)
lines.append("")
# 风格权重
style_w = weights.get("style_weights", {})
if style_w:
lines.append("### 🎨 风格权重排行")
for style, info in list(style_w.items())[:5]:
bar = "" * (info["weight"] // 10) + "" * (10 - info["weight"] // 10)
lines.append(f"- **{style}**: {bar} {info['weight']}分 ({info['count']}篇)")
lines.append("")
# 标签权重
tag_w = weights.get("tag_weights", {})
if tag_w:
lines.append("### 🏷️ 高权重标签 (Top 10)")
top_tags = list(tag_w.items())[:10]
tag_strs = [f"`#{t}` ({info['weight']})" for t, info in top_tags]
lines.append(" | ".join(tag_strs))
lines.append("")
# 标题模式
title_p = weights.get("title_pattern_weights", {})
if title_p:
lines.append("### ✏️ 标题模式分析")
sorted_p = sorted(title_p.items(), key=lambda x: x[1].get("weight", 0), reverse=True)
for p_name, p_info in sorted_p[:6]:
lines.append(f"- **{p_name}**: 权重 {p_info['weight']} (出现 {p_info['count']} 次)")
lines.append("")
# 建议
lines.append("---")
lines.append("### 💡 智能建议")
if topic_w:
top_3 = list(topic_w.keys())[:3]
lines.append(f"- 📌 **高权重主题**: 优先创作 → {', '.join(top_3)}")
if tag_w:
hot_tags = [f"#{t}" for t in list(tag_w.keys())[:5]]
lines.append(f"- 🏷️ **推荐标签**: {' '.join(hot_tags)}")
if title_p:
best_pattern = max(title_p.items(), key=lambda x: x[1].get("weight", 0))
lines.append(f"- ✏️ **标题建议**: 多用「{best_pattern[0]}」(权重{best_pattern[1]['weight']})")
lines.append("")
lines.append(f"> 💡 启用「智能加权发布」后,自动发布将按权重倾斜生成高表现内容")
return "\n".join(lines)
def get_weighted_topics_display(self) -> str:
"""获取加权后的主题列表(用于UI显示)"""
topic_w = self._weights.get("topic_weights", {})
if not topic_w:
return ""
# 按权重排序,返回逗号分隔
sorted_topics = sorted(topic_w.items(), key=lambda x: x[1].get("weight", 0), reverse=True)
return ", ".join([t[0] for t in sorted_topics[:15]])
@property
def has_weights(self) -> bool:
"""是否已有权重数据"""
return bool(self._weights.get("topic_weights"))
@property
def weights_summary(self) -> str:
"""一行权重摘要"""
tw = self._weights.get("topic_weights", {})
total = self._weights.get("total_notes_analyzed", 0)
if not tw:
return "暂无权重数据"
top = list(tw.keys())[:3]
return f"{total}篇笔记 | 热门: {', '.join(top)}"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 MiB

View File

@ -4,7 +4,7 @@
"sd_url": "http://127.0.0.1:7861",
"mcp_url": "http://localhost:18060/mcp",
"model": "gemini-3-flash-preview",
"persona": "性感福利主播,身材火辣衣着大胆,专注分享穿衣显身材和私房写真风穿搭",
"persona": "温柔知性的时尚博主",
"auto_reply_enabled": false,
"schedule_enabled": false,
"my_user_id": "69872540000000002303cc42",
@ -21,6 +21,5 @@
"base_url": "https://wolfai.top/v1"
}
],
"use_smart_weights": false,
"xsec_token": "AB1StlX7ffxsEkfyNuTFDesPlV2g1haPcYuh1-AkYcQxo="
"xsec_token": "ABdAEbqP9ScgelmyolJxsnpCr_e645SCpnub2dLZJc4Ck="
}

View File

@ -17,13 +17,12 @@ DEFAULT_CONFIG = {
"sd_url": "http://127.0.0.1:7860",
"mcp_url": "http://localhost:18060/mcp",
"model": "gpt-3.5-turbo",
"persona": "性感福利主播,身材火辣衣着大胆,专注分享穿衣显身材和私房写真风穿搭",
"persona": "温柔知性的时尚博主",
"auto_reply_enabled": False,
"schedule_enabled": False,
"my_user_id": "",
"active_llm": "",
"llm_providers": [],
"use_smart_weights": True,
}

View File

@ -5,7 +5,6 @@ LLM 服务模块
import requests
import json
import re
import random
import logging
logger = logging.getLogger(__name__)
@ -13,233 +12,88 @@ logger = logging.getLogger(__name__)
# ================= Prompt 模板 =================
PROMPT_COPYWRITING = """
你是一个真实的小红书博主正在用手机编辑一篇笔记你不是内容专家你只是一个想认真分享的普通人
你的写作状态
想象你刚体验完某件事试了一个产品/去了一个地方/学到一个技巧打开小红书想跟朋友们聊聊你不会字字斟酌就是把感受写出来
你是一个小红书爆款内容专家请根据用户主题生成内容
标题规则(严格执行)
1. 长度限制必须控制在 18 字以内含Emoji绝对不能超过 20
2. 像你发朋友圈的语气口语化有情绪感可以用疑问句感叹句省略句
3. 可以加1-2个emoji但不要堆砌
4. 禁止广告法违禁词"第一" "" "顶级"
5. 好的标题示例"后悔没早买!这个真的绝了" "姐妹们被我找到了" "求求你们别再踩这个坑了"
6. 避免AI感标题不要用"震惊!" "必看!" "干货"这种过于营销的开头
2. 格式要求Emoji + 爆点关键词 + 核心痛点
3. 禁忌禁止使用"第一""""顶级"等绝对化广告法违禁词
4. 风格二极管标题震惊/后悔/必看/避雷/哭了具有强烈的点击欲望
正文规则像说话一样写
1. 想象你在跟闺蜜/朋友面对面聊天把她说的话打下来就对了
2. 正文控制在 400-600
3. 不要像写作文一样"首先、其次、最后"用碎碎念的方式自然展开
4. 可以有小情绪吐槽感叹自嘲开心炸裂都行
5. emoji不要每句话都有穿插在情绪高点就好一段文字2-4个emoji足够
6. 真人笔记特征
- 会有"话说" "对了" "哦对" 这种口语转折
- 会有"不是我说" "真的会谢" "笑不活了"这种网络表达
- 会有不完整的句子省略号波浪号
- 段落长短不一有的段就一句话有的段会稍长
7. 绝对禁止
"值得一提的是" "需要注意的是" "总的来说" "综上所述"
"作为一个xxx" "在这里给大家分享"
排比句对仗工整的总结
每段都很整齐的1234结构
"小伙伴们" "宝子们" 等过度热情的称呼偶尔一次可以
8. 结尾加 5-8 个相关话题标签(#)
!!极其重要 反AI检测规则!!
你的文案会被AI检测工具审查必须通过以下防检测措施
1. 句子长度必须参差不齐有的句子就3-5个字有的20多字绝对不能每句都差不多长
2. 不要所有段落逻辑都很通顺真人写东西会突然跑题突然想起另一件事
3. 偶尔犯一点小"不规范"比如某句话没写完就换话题了用了个不太准确的词
4. 标点符号不要太规范真人用手机打字经常漏掉逗号句号不加偶尔连续用两个问号??
5. 用词不要太"正确"真人会说"巨好用" "绝绝子" "无敌了" 而不是"非常好用" "十分出色"
6. 避免完美的逻辑链条不要每段都工工整整地推进论点真人笔记是跳跃式的
7. 偶尔口语化到"学渣"程度"就 很那个 你懂的" "属于是" "多少有点" "怎么说呢"
8. 绝对不要用"然而" "此外" "因此" "尽管" "虽然...但是..."这些书面连接词
正文规则
1. 口语化多用Emoji分段清晰不堆砌长句
2. 正文控制在 600 字以内小红书限制 1000
3. 结尾必须有 5 个以上相关话题标签(#)。
绘图 Prompt
{sd_prompt_guide}
生成对应的 Stable Diffusion 英文提示词适配 JuggernautXL 模型强调
- 质量词masterpiece, best quality, ultra detailed, 8k uhd, high resolution
- 光影natural lighting, soft shadows, studio lighting, golden hour 根据场景选择
- 风格photorealistic, cinematic, editorial photography, ins style
- 构图dynamic angle, depth of field, bokeh
- 细节detailed skin texture, sharp focus, vivid colors
注意不要使用括号权重语法直接用英文逗号分隔描述
返回 JSON 格式
{{"title": "...", "content": "...", "sd_prompt": "...", "tags": ["标签1", "标签2", ...]}}
"""
PROMPT_PERFORMANCE_ANALYSIS = """
你是一个有实战经验的小红书运营数据分析师下面是一个博主已发布的笔记数据按互动量从高到低排列
{note_data}
权重学习分析任务
请深度分析这些笔记的互动数据找出什么样的内容最受欢迎的规律
请分析以下维度
1. **高表现内容特征**表现好的笔记有什么共同特征主题标题套路风格标签越具体越好
2. **低表现内容反思**表现差的笔记问题出在哪是选题不行标题没吸引力还是其他原因
3. **用户偏好画像**从数据反推关注这个账号的用户最喜欢什么样的内容
4. **内容优化建议**给出 5 个具体的下一步内容方向每个都要说清楚为什么推荐
5. **标题优化建议**总结 3 个高互动标题的写法模板直接给出可套用的句式
6. **最佳实践标签**推荐 10 个最有流量潜力的标签组合
注意
- 用数据说话不要空谈
- 建议要具体到可以直接执行的程度
- 不要说废话和套话
返回 JSON 格式
{{"high_perform_features": "...", "low_perform_issues": "...", "user_preference": "...", "content_suggestions": [{{"topic": "...", "reason": "...", "priority": 1-5}}], "title_templates": ["模板1", "模板2", "模板3"], "recommended_tags": ["标签1", "标签2", ...]}}
"""
PROMPT_WEIGHTED_COPYWRITING = """
你是一个真实的小红书博主正在用手机编辑一篇笔记
智能学习洞察基于你过去笔记的数据分析
{weight_insights}
创作要求
基于以上数据洞察请创作一篇更容易获得高互动的笔记要把数据分析的结论融入创作中但写出来的内容要自然不能看出是"为了数据而写"
标题规则(严格执行)
1. 长度限制必须控制在 18 字以内含Emoji绝对不能超过 20
2. 参考高互动标题的模式{title_advice}
3. 口语化有情绪感像发朋友圈
4. 禁止广告法违禁词
正文规则像说话一样写
1. 想象你在跟闺蜜/朋友面对面聊天
2. 正文控制在 400-600
3. 自然展开不要分点罗列
4. 可以有小情绪吐槽感叹自嘲开心炸裂
5. emoji穿插在情绪高点不要每句都有
6. 绝对禁止 AI 痕迹书面用语
推荐标签优先使用这些高权重标签 {hot_tags}
绘图 Prompt
{sd_prompt_guide}
返回 JSON 格式
{{"title": "...", "content": "...", "sd_prompt": "...", "tags": ["标签1", "标签2", ...]}}
{"title": "...", "content": "...", "sd_prompt": "...", "tags": ["标签1", "标签2", ...]}
"""
PROMPT_HOTSPOT_ANALYSIS = """
你是一个有实战经验的小红书运营人下面是搜索到的热门笔记信息
你是一个小红书运营数据分析专家下面是搜索到的热门笔记信息
{feed_data}
你要像一个真正做过内容运营的人来分析这些数据说话方式接地气一点不要写得像论文
请分析以下方面
1. **热门选题方向**提炼 3-5 个最火的细分选题不要只写大方向如"美妆""穿搭"要具体到用户真正感兴趣的切入角度
2. **标题拆解**这些高赞标题的套路是什么比如用了什么句式什么情绪钩子什么悬念直接举例说明不要写得很理论
3. **内容结构**爆款笔记是怎么组织内容的是先说痛点再给方案还是直接晒结果用大白话说清楚
4. **推荐模仿方案**给出 3 个具体的选题建议要具体到"如果是我来写,我会这样切入"的程度不要泛泛而谈
注意
- 你的分析是给一个普通创作者看的不是给甲方看报告
- 说人话不要堆砌专业术语
- "内容矩阵""赛道""心智占领""用户画像"这种运营黑话能不用就不用
- 建议要可执行不要写"结合自身特色打造差异化内容"这种废话
请分析这些热门笔记总结以下内容
1. **热门选题方向**提炼 3-5 个最火的细分选题
2. **标题套路**总结高赞标题的共同特征和写作模板
3. **内容结构**分析爆款笔记的内容组织方式
4. **推荐模仿方案**基于分析结果给出 3 个具体的模仿选题建议
返回 JSON 格式
{{"hot_topics": ["...", "..."], "title_patterns": ["...", "..."], "content_structure": "...", "suggestions": [{{"topic": "...", "reason": "..."}}]}}
"""
PROMPT_COMMENT_REPLY = """
现在是一个真实的小红书博主你的人设是{persona}
粉丝在你的笔记下留言了你需要像真人一样自然回复
你是一个小红书博主人设为{persona}
有人在你的笔记下评论了请你用符合人设的口吻回复
核心模拟原则极其重要
你必须模拟一个真实的活生生的小红书用户在手机上随手打字的状态
- 打字随意不追求语法完美偶尔可以有口语化的省略
- 真人不会每句话都加 emoji有时一个都不加也很正常
- 绝对不要用"哈哈哈"开头这是 AI 最常见的暴露特征
- 不要用书面化的转折词"不过""然而""但是""确实"用口语"""""""是吧""对对对""哇塞"
- 回复可以很短甚至只有几个字"谢谢宝!""对的呀""试试看~"
- 绝对不要用逗号和句号写出完整的完美句子真人打字经常没有标点
- 不要用感叹号过多真人回复感叹号一般不超过1个
- 不要用""""""等语气词堆砌在一起
回复策略
- 如果是夸奖/支持 简短感谢可以适当撒娇或害羞"嘿嘿被你发现了""爱你""抱住"
- 如果是提问 直接回答不要先说"这个问题很好"之类的废话
- 如果是质疑/负面 轻松带过或幽默化解不要长篇大论解释
- 如果是分享经历 表示共鸣简短回应
- 如果评论很简短比如"好看""不错" 你的回复也应该很简短
禁止出现的 AI 特征
"谢谢你的xxx" "感谢你的分享/支持/喜欢"
"作为一个xxx" "我觉得xxx"
完整的主谓宾结构句子
连续使用3个以上emoji
"哈哈哈"开头
每句话都有标点符号
排比句对仗句
"宝子""姐妹""家人们"等称呼除非人设就是这种风格
规则
1. 回复简洁控制在 50 字以内
2. 语气亲切自然像和朋友聊天
3. 适当加入 1-2 Emoji
4. 如果是质疑礼貌回应如果是夸奖真诚感谢
笔记标题{post_title}
粉丝评论{comment}
用户评论{comment}
直接输出回复内容不要有任何解释回复长度请匹配对方评论的长度对方说的短你也短对方说的长你可以稍长一点
直接返回回复内容不需要 JSON 格式
"""
PROMPT_PROACTIVE_COMMENT = """
你必须完美伪装成一个真实的小红书用户你的人设是{persona}
你正在刷小红书看到了一篇笔记你想留一条看起来像真人写的评论
核心!!你是一个在手机上随手打字的普通人
把自己想象成一个躺在沙发上刷手机的人看到一篇笔记后随手评论
你打字很随意不会字字斟酌想到什么说什么
真人评论特征必须遵循
1. 长度自然变化有时候一句话"救命也太好看了吧"有时候两三句极少超过50字
2. 真人打字习惯
- 经常省略主语"看完立马下单了" 而不是 "我看完后立马下单了"
- 会用缩写和网络用语"绝绝子""yyds""蹲一个""dd""awsl"
- 感叹用语如 "天哪""救命""啊啊啊""绝了" 而不是文绉绉的"真的很棒"
- 偶尔打错字也ok但不要刻意
3. emoji 使用规则
- 50%的概率不加任何emoji
- 加的话最多1-2而且偏好 😭🫠🥺😍 这类情绪化的
- 不要用 💫🌟 这种博主式的装饰emoji
4. 绝对不要分点列举真人评论从不分1234条说
5. 不要用完整标点真人评论经常没逗号句号
评论类型随机选择一种自然风格
- 分享真实感受"这个颜色实物真的绝了 上次路过柜台试了一下就走不动了"
- 提一个具体问题"这个是什么色号呀""博主身高多少 我怕买了不合适"
- 表达种草"看完直接去搜了""钱包在哭泣"
- 补充相关经验"我之前买过xxx 感觉跟这个搭也蛮好看的"
- 简短共鸣"真的!""笑死""太真实了""懂了"
绝对禁止这些是AI评论的特征
"写得真好" "内容很有价值" "干货满满" "收藏了"
"博主太厉害了" "学到了" "受益匪浅" "非常实用"
"我也觉得xxx" "我认为xxx" 这种过于理性客观的表达
"首先...其次...最后..." 任何分点罗列
"哈哈"开头
超过3个emoji
完整规范的标点使用
每句话都很完整很正式
同时出现""和emoji选一个就够了
把笔记标题的关键词重复一遍比如笔记标题说"穿搭"你就评论"穿搭真好看"
你是一个小红书活跃用户人设为{persona}
你正在浏览一篇笔记想要留下一条真诚有价值的评论以提升互动和曝光
笔记信息
标题{post_title}
正文摘要{post_content}
已有评论参考避免重复
已有评论参考可能为空
{existing_comments}
请直接输出一条评论不要有任何解释或前缀记住你是一个真人不是AI
评论规则
1. 评论简洁自然控制在 30-80 不要像机器人
2. 体现你对笔记内容的真实感受或个人经验
3. 可以提问分享类似经历或表达共鸣
4. 适当加入 1-2 Emoji不要过多
5. 不要重复已有评论的观点找新角度
6. 不要生硬带货或自我推广
7. 语气因内容而异教程类请教/补充种草类分享体验生活类表达共鸣
直接返回评论内容不需要 JSON 格式
"""
PROMPT_COPY_WITH_REFERENCE = """
你是一个真实的小红书博主正在参考一些热门笔记来写一篇自己的原创内容
你不是在写营销文案你只是觉得这些笔记写得不错想借鉴思路写一篇自己的体验分享
你是一个小红书爆款内容专家参考以下热门笔记的风格和结构创作全新原创内容
参考笔记
{reference_notes}
@ -249,28 +103,19 @@ PROMPT_COPY_WITH_REFERENCE = """
标题规则
1. 长度限制必须控制在 18 字以内含Emoji绝对不能超过 20
2. 学习参考笔记标题的情绪感和口语感但内容完全原创
3. 写得像你发给朋友看的那种不要像广告
2. 借鉴参考笔记的标题套路但内容必须原创
正文规则写得像真人
1. 想象你是刚体验完然后打开小红书写笔记把你的真实感受和过程写出来
2. 正文控制在 400-600
3. 真人写法
- 开头可以直接说事不需要"嗨大家好"之类的开场白
- 中间夹杂一些个人感受和小吐槽"一开始还在犹豫 结果用了之后真香"
- 不要面面俱到什么优点都说一遍挑2-3个最有感触的重点说
- 可以适当说一两个小缺点让内容更真实"唯一的缺点就是xxx 但瑕不掩瑜"
- 段落自然分割有的段一两句有的段稍长
4. emoji 穿插在情绪高点不要每句都有整篇 6-10 个足够
5. 绝对禁止
排比句对仗句"不仅...而且..." "既...又..."
"值得一提" "需要注意" "总结一下" 等总结性书面用语
每个段落都很工整的1234结构
面面俱到地罗列所有优点
6. 结尾加 5-8 个话题标签(#)
正文规则
1. 口语化多用Emoji分段清晰
2. 正文控制在 600 字以内
3. 结尾有 5 个以上话题标签(#)。
绘图 Prompt
{sd_prompt_guide}
生成 Stable Diffusion 英文提示词适配 JuggernautXL 模型
- 必含质量词masterpiece, best quality, ultra detailed, 8k uhd
- 风格photorealistic, cinematic, editorial photography
- 光影和细节natural lighting, sharp focus, vivid colors, detailed skin texture
- 用英文逗号分隔不用括号权重语法
返回 JSON 格式
{{"title": "...", "content": "...", "sd_prompt": "...", "tags": ["标签1", "标签2", ...]}}
@ -280,93 +125,14 @@ PROMPT_COPY_WITH_REFERENCE = """
class LLMService:
"""LLM API 服务封装"""
# 当主模型返回空内容时,依次尝试的备选模型列表
FALLBACK_MODELS = ["deepseek-v3", "gemini-2.5-flash", "deepseek-v3.1"]
def __init__(self, api_key: str, base_url: str, model: str = "gpt-3.5-turbo"):
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.model = model
@staticmethod
def get_sd_prompt_guide(sd_model_name: str = None, persona: str = None) -> str:
"""根据当前 SD 模型 + 人设 生成 LLM 使用的绘图 Prompt 指南(含反 AI 检测指导 + 人设视觉风格)"""
from sd_service import SD_MODEL_PROFILES, detect_model_profile, get_persona_sd_profile
key = detect_model_profile(sd_model_name) if sd_model_name else "juggernautXL"
profile = SD_MODEL_PROFILES.get(key, SD_MODEL_PROFILES["juggernautXL"])
arch = profile.get("arch", "sdxl")
display = profile.get("display_name", key)
desc = profile.get("description", "")
# 通用反 AI 检测 prompt 技巧
anti_detect_tips = (
"\n\n【重要 - 反AI检测】生成的图片需要尽量像真实手机拍摄\n"
"- 在 prompt 中加入真实感关键词shot on iPhone, casual snapshot, real life, candid photo\n"
"- 加入微小不完美slight motion blur, natural background, everyday environment\n"
"- 避免过度完美的构图词:不要用 perfect composition, symmetrical, flawless 等\n"
"- 光线自然化:用 natural daylight, indoor ambient light, window light 而非 studio lighting\n"
"- 模拟手机拍照特征phone camera, slightly overexposed, casual angle, not centered\n"
)
# 人设视觉风格指导 (追加于末尾)
persona_guide = ""
persona_sd = get_persona_sd_profile(persona) if persona else None
if persona_sd and persona_sd.get("llm_guide"):
persona_guide = persona_sd["llm_guide"]
# 赛博AI虚拟博主不需要反AI检测大方承认是AI
if "赛博AI虚拟博主" in (persona or ""):
anti_detect_tips = (
"\n\n【注意 - 本人设无需反AI检测】该博主大方承认是AI生成不需要伪装真实感。\n"
"- 尽情使用 perfect, flawless, ultra detailed 等高质量词\n"
"- 追求视觉震撼和极致精致,不需要模拟手机拍照\n"
"- 可以使用 studio lighting, dramatic lighting, cinematic 等专业光效词\n"
)
if key == "majicmixRealistic":
base = (
f"生成 Stable Diffusion 英文提示词,当前使用模型: {display} ({desc})\n"
"该模型擅长东亚网红/朋友圈自拍风格,请按以下规则生成 sd_prompt\n"
"- 人物要求(最重要!):必须是东亚面孔中国人\n"
"- 推荐使用 (权重:数值) 语法加强关键词,例如 (asian girl:1.3), (best quality:1.4)\n"
"- 风格关键词RAW photo, realistic, photorealistic, natural makeup, instagram aesthetic\n"
"- 氛围词soft lighting, warm tone, natural skin texture, phone camera feel\n"
"- 非常适合:自拍、穿搭展示、美妆效果、生活日常、闺蜜合照风格\n"
"- 画面要有「朋友圈精选照片」的感觉,自然不做作\n"
"- 用英文逗号分隔"
)
elif key == "realisticVision":
base = (
f"生成 Stable Diffusion 英文提示词,当前使用模型: {display} ({desc})\n"
"该模型擅长写实纪实摄影风格,请按以下规则生成 sd_prompt\n"
"- 人物要求(最重要!):必须是东亚面孔中国人\n"
"- 推荐使用 (权重:数值) 语法,例如 (realistic:1.4), (photorealistic:1.4)\n"
"- 风格关键词RAW photo, DSLR, documentary style, street photography, film color grading\n"
"- 质感词skin pores, detailed skin texture, natural imperfections, real lighting\n"
"- 镜头感shot on Canon/Sony, 85mm lens, f/1.8, depth of field\n"
"- 非常适合:街拍、纪实风、旅行照、真实场景、有故事感的画面\n"
"- 画面要有「专业摄影师抓拍」的质感,保留真实皮肤纹理\n"
"- 用英文逗号分隔"
)
else: # juggernautXL (SDXL)
base = (
f"生成 Stable Diffusion 英文提示词,当前使用模型: {display} ({desc})\n"
"该模型为 SDXL 架构,擅长电影级大片质感,请按以下规则生成 sd_prompt\n"
"- 人物要求(最重要!):必须是东亚面孔中国人,绝对禁止西方人特征\n"
"- 不要使用 (权重:数值) 括号语法SDXL 模型直接用逗号分隔即可\n"
"- 质量词masterpiece, best quality, ultra detailed, 8k uhd, high resolution\n"
"- 风格photorealistic, cinematic lighting, cinematic composition, commercial photography\n"
"- 光影volumetric lighting, ray tracing, golden hour, studio lighting\n"
"- 非常适合:商业摄影、时尚大片、复杂光影场景、杂志封面风格\n"
"- 画面要有「电影画面/杂志大片」的高级感\n"
"- 用英文逗号分隔"
)
return base + anti_detect_tips + persona_guide
def _chat(self, system_prompt: str, user_message: str,
json_mode: bool = True, temperature: float = 0.8) -> str:
"""底层聊天接口含空返回检测、json_mode 回退、模型降级)"""
"""底层聊天接口"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
@ -374,13 +140,8 @@ class LLMService:
if json_mode:
user_message = user_message + "\n请以json格式返回。"
# 构建要尝试的模型列表:主模型 + 备选模型(去重)
models_to_try = [self.model] + [m for m in self.FALLBACK_MODELS if m != self.model]
last_error = None
for model_idx, current_model in enumerate(models_to_try):
payload = {
"model": current_model,
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
@ -397,126 +158,18 @@ class LLMService:
)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
# 检测空返回 — 如果启用了 json_mode 且返回为空,回退去掉 response_format 重试
if not content or not content.strip():
if json_mode:
logger.warning("[%s] LLM 返回空内容 (json_mode=True),关闭 json_mode 回退重试...", current_model)
payload.pop("response_format", None)
resp2 = requests.post(
f"{self.base_url}/chat/completions",
headers=headers, json=payload, timeout=90
)
resp2.raise_for_status()
content = resp2.json()["choices"][0]["message"]["content"]
if not content or not content.strip():
# 当前模型完全无法返回内容,尝试下一个模型
if model_idx < len(models_to_try) - 1:
next_model = models_to_try[model_idx + 1]
logger.warning("[%s] 返回空内容,自动降级到模型: %s", current_model, next_model)
continue
raise RuntimeError(f"所有模型均返回空内容(已尝试: {', '.join(models_to_try[:model_idx+1])}")
if model_idx > 0:
logger.info("模型降级成功: %s%s", self.model, current_model)
return content
except requests.exceptions.HTTPError as e:
status = getattr(resp, 'status_code', 0)
body = getattr(resp, 'text', '')[:300]
# 某些模型/提供商不支持 response_format自动回退重试
if json_mode and status in (400, 422, 500):
logger.warning("[%s] json_mode 请求失败 (HTTP %s),关闭 response_format 回退重试...", current_model, status)
payload.pop("response_format", None)
try:
resp2 = requests.post(
f"{self.base_url}/chat/completions",
headers=headers, json=payload, timeout=90
)
resp2.raise_for_status()
content = resp2.json()["choices"][0]["message"]["content"]
if content and content.strip():
if model_idx > 0:
logger.info("模型降级成功: %s%s", self.model, current_model)
return content
except Exception:
pass
# 当前模型失败,尝试下一个
last_error = ConnectionError(f"LLM API 错误 ({status}): {body}")
if model_idx < len(models_to_try) - 1:
logger.warning("[%s] HTTP %s 失败,降级到: %s", current_model, status, models_to_try[model_idx + 1])
continue
raise last_error
except requests.exceptions.Timeout:
last_error = TimeoutError(f"[{current_model}] LLM 请求超时")
if model_idx < len(models_to_try) - 1:
logger.warning("[%s] 请求超时,降级到: %s", current_model, models_to_try[model_idx + 1])
continue
raise TimeoutError("LLM 请求超时,所有模型均超时,请检查网络")
except (ConnectionError, RuntimeError):
raise
raise TimeoutError("LLM 请求超时,请检查网络或换一个模型")
except requests.exceptions.HTTPError as e:
raise ConnectionError(f"LLM API 错误 ({resp.status_code}): {resp.text[:200]}")
except Exception as e:
last_error = RuntimeError(f"LLM 调用异常: {e}")
if model_idx < len(models_to_try) - 1:
logger.warning("[%s] 调用异常 (%s),降级到: %s", current_model, e, models_to_try[model_idx + 1])
continue
raise last_error
raise last_error or RuntimeError("LLM 调用失败: 未知错误")
raise RuntimeError(f"LLM 调用异常: {e}")
def _parse_json(self, text: str) -> dict:
"""从 LLM 返回文本中解析 JSON多重容错"""
if not text or not text.strip():
raise ValueError("LLM 返回内容为空,无法解析 JSON")
raw = text.strip()
# 策略1: 去除 markdown 代码块
cleaned = re.sub(r"```(?:json)?\s*", "", raw)
cleaned = re.sub(r"```", "", cleaned).strip()
# 策略2: 直接解析
try:
"""从 LLM 返回文本中解析 JSON"""
cleaned = re.sub(r"```json\s*|```", "", text).strip()
return json.loads(cleaned)
except json.JSONDecodeError:
pass
# 策略3: 提取最外层的 { ... } 块
match = re.search(r'(\{[\s\S]*\})', cleaned)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# 策略4: 逐行查找 JSON 开始位置
for i, ch in enumerate(cleaned):
if ch == '{':
try:
return json.loads(cleaned[i:])
except json.JSONDecodeError:
pass
break
# 策略5: 尝试修复常见问题(尾部多余逗号、缺少闭合括号)
try:
# 去除尾部多余逗号
fixed = re.sub(r',\s*([}\]])', r'\1', cleaned)
return json.loads(fixed)
except json.JSONDecodeError:
pass
# 全部失败,打日志并抛出有用的错误信息
preview = raw[:500] if len(raw) > 500 else raw
logger.error("JSON 解析全部失败LLM 原始返回: %s", preview)
raise ValueError(
f"LLM 返回内容无法解析为 JSON。\n"
f"返回内容前200字: {raw[:200]}\n\n"
f"💡 可能原因: 模型不支持 JSON 输出格式,建议更换模型重试"
)
# ---------- 业务方法 ----------
@ -537,23 +190,11 @@ class LLMService:
logger.warning("获取模型列表失败 (%s): %s", url, e)
return []
def generate_copy(self, topic: str, style: str, sd_model_name: str = None, persona: str = None) -> dict:
"""生成小红书文案含重试逻辑自动适配SD模型支持人设"""
sd_guide = self.get_sd_prompt_guide(sd_model_name, persona=persona)
system_prompt = PROMPT_COPYWRITING.format(sd_prompt_guide=sd_guide)
user_msg = f"主题:{topic}\n风格:{style}"
if persona:
user_msg = f"【博主人设】:{persona}\n请以此人设的视角和风格创作。\n\n{user_msg}"
last_error = None
for attempt in range(2):
try:
# 第二次尝试不使用 json_mode兼容不支持的模型
use_json_mode = (attempt == 0)
def generate_copy(self, topic: str, style: str) -> dict:
"""生成小红书文案"""
content = self._chat(
system_prompt,
user_msg,
json_mode=use_json_mode,
temperature=0.92,
PROMPT_COPYWRITING,
f"主题:{topic}\n风格:{style}"
)
data = self._parse_json(content)
@ -563,281 +204,34 @@ class LLMService:
title = title[:20]
data["title"] = title
# 去 AI 化后处理
if "content" in data:
data["content"] = self._humanize_content(data["content"])
return data
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("文案生成 JSON 解析失败 (尝试 %d/2): %s,将关闭 json_mode 重试", attempt + 1, e)
continue
else:
logger.error("文案生成 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
raise RuntimeError(f"文案生成失败: LLM 返回无法解析为 JSON已重试 2 次。\n最后错误: {last_error}")
def generate_copy_with_reference(self, topic: str, style: str,
reference_notes: str, sd_model_name: str = None, persona: str = None) -> dict:
"""参考热门笔记生成文案含重试逻辑自动适配SD模型支持人设"""
sd_guide = self.get_sd_prompt_guide(sd_model_name, persona=persona)
reference_notes: str) -> dict:
"""参考热门笔记生成文案"""
prompt = PROMPT_COPY_WITH_REFERENCE.format(
reference_notes=reference_notes, topic=topic, style=style,
sd_prompt_guide=sd_guide,
)
user_msg = f"请创作关于「{topic}」的小红书笔记"
if persona:
user_msg = f"【博主人设】:{persona}\n请以此人设的视角和风格创作。\n\n{user_msg}"
last_error = None
for attempt in range(2):
try:
use_json_mode = (attempt == 0)
content = self._chat(
prompt, user_msg,
json_mode=use_json_mode, temperature=0.92,
reference_notes=reference_notes, topic=topic, style=style
)
content = self._chat(prompt, f"请创作关于「{topic}」的小红书笔记")
data = self._parse_json(content)
title = data.get("title", "")
if len(title) > 20:
data["title"] = title[:20]
if "content" in data:
data["content"] = self._humanize_content(data["content"])
return data
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("参考文案生成 JSON 解析失败 (尝试 %d/2): %s,将关闭 json_mode 重试", attempt + 1, e)
continue
else:
logger.error("参考文案生成 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
raise RuntimeError(f"参考文案生成失败: LLM 返回无法解析为 JSON已重试 2 次。\n最后错误: {last_error}")
def analyze_hotspots(self, feed_data: str) -> dict:
"""分析热门内容趋势(含重试逻辑)"""
"""分析热门内容趋势"""
prompt = PROMPT_HOTSPOT_ANALYSIS.format(feed_data=feed_data)
last_error = None
for attempt in range(2):
try:
use_json_mode = (attempt == 0)
content = self._chat(prompt, "请分析以上热门笔记数据",
json_mode=use_json_mode)
content = self._chat(prompt, "请分析以上热门笔记数据")
return self._parse_json(content)
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("热点分析 JSON 解析失败 (尝试 %d/2): %s,将关闭 json_mode 重试", attempt + 1, e)
continue
else:
logger.error("热点分析 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
raise RuntimeError(f"热点分析失败: LLM 返回无法解析为 JSON已重试 2 次。\n最后错误: {last_error}")
@staticmethod
def _humanize_content(text: str) -> str:
"""后处理: 深度去除 AI 书面痕迹,模拟真人手机打字风格"""
t = text
# ========== 第一层: 替换过于书面化/AI化的表达 ==========
ai_phrases = {
"值得一提的是": "对了",
"需要注意的是": "不过要注意",
"总的来说": "反正",
"综上所述": "总之",
"总而言之": "总之",
"不仅如此": "而且",
"与此同时": "然后",
"除此之外": "还有",
"众所周知": "",
"毋庸置疑": "",
"不言而喻": "",
"在这里给大家分享": "来分享",
"在此分享给大家": "分享一下",
"接下来让我们": "",
"话不多说": "",
"废话不多说": "",
"下面我来": "",
"让我来": "",
"首先我要说": "先说",
"我认为": "我觉得",
"我相信": "我觉得",
"事实上": "其实",
"实际上": "其实",
"毫无疑问": "",
"不可否认": "",
"客观来说": "",
"坦白说": "",
"具体而言": "就是",
"简而言之": "就是说",
"换句话说": "就是",
"归根结底": "说白了",
"由此可见": "",
"正如我所说": "",
"正如前文所述": "",
"在我看来": "我觉得",
"从某种程度上说": "",
"在一定程度上": "",
"非常值得推荐": "真的可以试试",
"强烈推荐": "真心推荐",
"性价比极高": "性价比很高",
"给大家安利": "安利",
"为大家推荐": "推荐",
"希望对大家有所帮助": "",
"希望能帮到大家": "",
"以上就是": "",
"感谢阅读": "",
"感谢大家的阅读": "",
}
for old, new in ai_phrases.items():
t = t.replace(old, new)
# ========== 第二层: 去掉分点罗列感 ==========
t = re.sub(r'(?m)^首先[,:\s]*', '', t)
t = re.sub(r'(?m)^其次[,:\s]*', '', t)
t = re.sub(r'(?m)^最后[,:\s]*', '', t)
t = re.sub(r'(?m)^再者[,:\s]*', '', t)
t = re.sub(r'(?m)^另外[,:\s]*', '', t)
# 去序号: "1. " "2、" "①" 等
t = re.sub(r'(?m)^[①②③④⑤⑥⑦⑧⑨⑩]\s*', '', t)
t = re.sub(r'(?m)^[1-9][.、))]\s*', '', t)
# ========== 第三层: 去掉AI常见的空洞开头 ==========
for prefix in ["嗨大家好!", "嗨,大家好!", "大家好,", "大家好!",
"哈喽大家好!", "Hello大家好", "嗨~", "hey~",
"各位姐妹大家好!", "各位宝子们好!"]:
if t.startswith(prefix):
t = t[len(prefix):].strip()
# ========== 第四层: 标点符号真人化 ==========
# AI 特征: 每句话都有完整标点 → 真人经常不加标点或只用逗号
sentences = t.split('\n')
humanized_lines = []
for line in sentences:
if not line.strip():
humanized_lines.append(line)
continue
# 随机去掉句末句号 (真人经常不打句号)
if line.rstrip().endswith('') and random.random() < 0.35:
line = line.rstrip()[:-1]
# 随机把部分逗号替换成空格或什么都不加 (模拟打字不加标点)
if random.random() < 0.15:
# 只替换一个逗号
comma_positions = [m.start() for m in re.finditer(r'[,]', line)]
if comma_positions:
pos = random.choice(comma_positions)
line = line[:pos] + ' ' + line[pos+1:]
humanized_lines.append(line)
t = '\n'.join(humanized_lines)
# ========== 第五层: 随机添加真人口语化元素 ==========
# 在段落开头随机插入口语衔接词
oral_connectors = [
"对了 ", "哦对 ", "话说 ", "然后 ", "就是说 ", "emmm ", "",
"说真的 ", "不是 ", "离谱的是 ", "我发现 ",
]
paragraphs = t.split('\n\n')
if len(paragraphs) > 2:
# 在中间段落随机加1-2个口语衔接词
inject_count = random.randint(1, min(2, len(paragraphs) - 2))
inject_indices = random.sample(range(1, len(paragraphs)), inject_count)
for idx in inject_indices:
if paragraphs[idx].strip() and not any(paragraphs[idx].strip().startswith(c.strip()) for c in oral_connectors):
connector = random.choice(oral_connectors)
paragraphs[idx] = connector + paragraphs[idx].lstrip()
t = '\n\n'.join(paragraphs)
# ========== 第六层: 句子长度打散 ==========
# AI 特征: 句子长度高度均匀 → 真人笔记长短参差不齐
# 随机把一些长句用换行打散
lines = t.split('\n')
final_lines = []
for line in lines:
# 超过60字的行, 随机在一个位置断句
if len(line) > 60 and random.random() < 0.3:
# 找到中间附近的标点位置断句
mid = len(line) // 2
best_pos = -1
for offset in range(0, mid):
for check_pos in [mid + offset, mid - offset]:
if 0 < check_pos < len(line) and line[check_pos] in ',。!?、,':
best_pos = check_pos
break
if best_pos > 0:
break
if best_pos > 0:
final_lines.append(line[:best_pos + 1])
final_lines.append(line[best_pos + 1:].lstrip())
continue
final_lines.append(line)
t = '\n'.join(final_lines)
# ========== 第七层: 随机注入微小不完美 ==========
# 真人打字偶尔有重复字、多余空格等
if random.random() < 0.2:
# 随机在某处加一个波浪号或省略号
insert_chars = ['~', '...', '', '..']
lines = t.split('\n')
if lines:
target = random.randint(0, len(lines) - 1)
if lines[target].rstrip() and not lines[target].rstrip()[-1] in '~.。!?!?':
lines[target] = lines[target].rstrip() + random.choice(insert_chars)
t = '\n'.join(lines)
# ========== 第八层: 清理 ==========
# 去掉连续3个以上的 emoji
t = re.sub(r'([\U0001F600-\U0001F9FF\u2600-\u27BF])\1{2,}', r'\1\1', t)
# 清理多余空行
t = re.sub(r'\n{3,}', '\n\n', t)
# 清理行首多余空格 (手机打字不会缩进)
t = re.sub(r'(?m)^[ \t]+', '', t)
return t.strip()
@staticmethod
def _humanize(text: str) -> str:
"""后处理: 深度去除 AI 评论/回复中的非人类痕迹"""
t = text.strip()
# 去掉前后引号包裹
if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")):
t = t[1:-1].strip()
# 去掉 AI 常见的前缀
for prefix in ["回复:", "回复:", "评论:", "评论:", "以下是", "好的,",
"当然,", "当然!", "谢谢你的", "感谢你的", "好的!",
"嗯,"]:
if t.startswith(prefix):
t = t[len(prefix):].strip()
# 去掉末尾多余的句号(真人评论很少用句号结尾)
if t.endswith(""):
t = t[:-1]
# 去掉末尾的"哦" "呢" 堆叠 (AI 常见)
t = re.sub(r'[哦呢呀哈]{2,}$', lambda m: m.group()[0], t)
# 替换过于完整规范的标点为口语化
if random.random() < 0.25 and '' in t:
# 随机去掉一个逗号
comma_pos = [m.start() for m in re.finditer('', t)]
if comma_pos:
pos = random.choice(comma_pos)
t = t[:pos] + ' ' + t[pos+1:]
# 随机去掉末尾感叹号(真人不是每句都加!)
if t.endswith('') and random.random() < 0.3:
t = t[:-1]
# 限制连续 emoji最多2个
t = re.sub(r'([\U0001F600-\U0001F9FF\u2600-\u27BF])\1{2,}', r'\1\1', t)
return t
def generate_reply(self, persona: str, post_title: str, comment: str) -> str:
"""AI 生成评论回复"""
prompt = PROMPT_COMMENT_REPLY.format(
persona=persona, post_title=post_title, comment=comment
)
raw = self._chat(prompt, "请生成回复", json_mode=False, temperature=0.95)
return self._humanize(raw)
return self._chat(prompt, "请生成回复", json_mode=False, temperature=0.9).strip()
def generate_proactive_comment(self, persona: str, post_title: str,
post_content: str, existing_comments: str = "") -> str:
@ -847,62 +241,4 @@ class LLMService:
post_content=post_content,
existing_comments=existing_comments or "暂无评论",
)
raw = self._chat(prompt, "请生成评论", json_mode=False, temperature=0.95)
return self._humanize(raw)
def analyze_note_performance(self, note_data: str) -> dict:
"""AI 深度分析笔记表现,生成内容策略建议"""
prompt = PROMPT_PERFORMANCE_ANALYSIS.format(note_data=note_data)
last_error = None
for attempt in range(2):
try:
use_json_mode = (attempt == 0)
content = self._chat(prompt, "请深度分析以上笔记数据,找出规律并给出优化建议",
json_mode=use_json_mode, temperature=0.7)
return self._parse_json(content)
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("表现分析 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
continue
raise RuntimeError(f"笔记表现分析失败: {last_error}")
def generate_weighted_copy(self, topic: str, style: str,
weight_insights: str, title_advice: str,
hot_tags: str, sd_model_name: str = None, persona: str = None) -> dict:
"""基于权重学习生成高互动潜力的文案自动适配SD模型支持人设"""
sd_guide = self.get_sd_prompt_guide(sd_model_name, persona=persona)
prompt = PROMPT_WEIGHTED_COPYWRITING.format(
weight_insights=weight_insights,
title_advice=title_advice,
hot_tags=hot_tags,
sd_prompt_guide=sd_guide,
)
user_msg = f"主题:{topic}\n风格:{style}\n请创作一篇基于数据洞察的高质量小红书笔记"
if persona:
user_msg = f"【博主人设】:{persona}\n请以此人设的视角和风格创作。\n\n{user_msg}"
last_error = None
for attempt in range(2):
try:
use_json_mode = (attempt == 0)
content = self._chat(
prompt,
user_msg,
json_mode=use_json_mode,
temperature=0.92,
)
data = self._parse_json(content)
title = data.get("title", "")
if len(title) > 20:
data["title"] = title[:20]
if "content" in data:
data["content"] = self._humanize_content(data["content"])
return data
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("加权文案生成失败 (尝试 %d/2): %s", attempt + 1, e)
continue
raise RuntimeError(f"加权文案生成失败: {last_error}")
return self._chat(prompt, "请生成评论", json_mode=False, temperature=0.9).strip()

2232
main.py

File diff suppressed because it is too large Load Diff

View File

@ -273,82 +273,55 @@ class MCPClient:
return self._parse_feed_entries(result.get("text", ""))
@staticmethod
def _extract_comment_obj(c: dict) -> dict:
"""从单个评论 JSON 对象提取结构化数据"""
user_info = c.get("userInfo") or c.get("user") or {}
return {
"comment_id": str(c.get("id", c.get("commentId", ""))),
"user_id": user_info.get("userId", user_info.get("user_id", "")),
"nickname": user_info.get("nickname", user_info.get("nickName", "未知")),
"content": c.get("content", ""),
"sub_comment_count": c.get("subCommentCount", 0),
}
@staticmethod
def _find_comment_list(data: dict) -> list:
"""在多种嵌套结构中定位评论列表"""
if not isinstance(data, dict):
return []
# 格式1: {"data": {"comments": {"list": [...]}}} —— 实际 MCP 返回
d = data.get("data", {})
if isinstance(d, dict):
cm = d.get("comments", {})
if isinstance(cm, dict) and "list" in cm:
return cm["list"]
if isinstance(cm, list):
return cm
# 格式2: {"comments": {"list": [...]}}
cm = data.get("comments", {})
if isinstance(cm, dict) and "list" in cm:
return cm["list"]
if isinstance(cm, list):
return cm
# 格式3: {"data": [{...}, ...]} (直接列表)
if isinstance(d, list):
return d
return []
@classmethod
def _parse_comments(cls, text: str) -> list[dict]:
def _parse_comments(text: str) -> list[dict]:
"""从笔记详情文本中解析评论列表为结构化数据
返回: [{comment_id, user_id, nickname, content, sub_comment_count}, ...]
"""
comments = []
# 方式1: 尝试 JSON 解析(支持多种嵌套格式)
# 方式1: 尝试 JSON 解析
try:
data = json.loads(text)
raw_comments = []
if isinstance(data, list):
if isinstance(data, dict):
raw_comments = data.get("comments", [])
elif isinstance(data, list):
raw_comments = data
elif isinstance(data, dict):
raw_comments = cls._find_comment_list(data)
for c in raw_comments:
if isinstance(c, dict) and c.get("content"):
comments.append(cls._extract_comment_obj(c))
user_info = c.get("userInfo") or c.get("user") or {}
comments.append({
"comment_id": c.get("id", c.get("commentId", "")),
"user_id": user_info.get("userId", user_info.get("user_id", "")),
"nickname": user_info.get("nickname", user_info.get("nickName", "未知")),
"content": c.get("content", ""),
"sub_comment_count": c.get("subCommentCount", 0),
})
if comments:
return comments
except (json.JSONDecodeError, TypeError, AttributeError):
pass
# 方式2: 正则提取 —— 仅当 JSON 完全失败时使用
# 逐个评论块提取,避免跨评论字段错位
# 匹配 JSON 对象中相邻的 id + content + userInfo 组合
comment_blocks = re.finditer(
r'"id"\s*:\s*"([0-9a-fA-F]{20,26})"[^}]*?'
r'"content"\s*:\s*"([^"]{1,500})"[^}]*?'
r'"userInfo"\s*:\s*\{[^}]*?"userId"\s*:\s*"([0-9a-fA-F]{20,26})"'
r'[^}]*?"nickname"\s*:\s*"([^"]{1,30})"',
text, re.DOTALL
)
for m in comment_blocks:
# 方式2: 正则提取 —— 适配多种 MCP 文本格式
# 格式举例: "评论ID: xxx | 用户: xxx (userId) | 内容: xxx"
# 或者: 用户名(@nickname): 评论内容
comment_ids = re.findall(
r'(?:comment_?[Ii]d|评论ID|评论id|"id")["\s:]+([0-9a-f]{24})', text, re.I)
user_ids = re.findall(
r'(?:user_?[Ii]d|userId|用户ID)["\s:]+([0-9a-f]{24})', text, re.I)
nicknames = re.findall(
r'(?:nickname|昵称|用户名|用户)["\s:]+([^\n|,]{1,30})', text, re.I)
contents = re.findall(
r'(?:content|内容|评论内容)["\s:]+([^\n]{1,500})', text, re.I)
count = max(len(comment_ids), len(contents))
for i in range(count):
comments.append({
"comment_id": m.group(1),
"user_id": m.group(3),
"nickname": m.group(4),
"content": m.group(2),
"comment_id": comment_ids[i] if i < len(comment_ids) else "",
"user_id": user_ids[i] if i < len(user_ids) else "",
"nickname": (nicknames[i].strip() if i < len(nicknames) else ""),
"content": (contents[i].strip() if i < len(contents) else ""),
"sub_comment_count": 0,
})
@ -366,35 +339,6 @@ class MCPClient:
}
return self._call_tool("get_feed_detail", args)
def get_feed_comments(self, feed_id: str, xsec_token: str,
load_all: bool = True) -> list[dict]:
"""获取笔记评论列表(结构化)
直接返回解析好的评论列表优先从 raw JSON 解析
"""
result = self.get_feed_detail(feed_id, xsec_token, load_all_comments=load_all)
if "error" in result:
return []
# 优先从 raw 结构中直接提取
raw = result.get("raw", {})
if raw and isinstance(raw, dict):
for item in raw.get("content", []):
if item.get("type") == "text":
try:
data = json.loads(item["text"])
comment_list = self._find_comment_list(data)
if comment_list:
return [self._extract_comment_obj(c)
for c in comment_list
if isinstance(c, dict) and c.get("content")]
except (json.JSONDecodeError, KeyError, TypeError):
pass
# 回退到 text 解析
text = result.get("text", "")
return self._parse_comments(text) if text else []
# ---------- 发布 ----------
def publish_content(self, title: str, content: str, images: list[str],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@ -1,583 +0,0 @@
"""
发布队列模块
SQLite 持久化的内容排期 + 发布队列支持草稿预审定时发布失败重试
"""
import sqlite3
import json
import os
import time
import logging
import threading
from datetime import datetime, timedelta
from typing import Optional
logger = logging.getLogger(__name__)
# 队列项状态
STATUS_DRAFT = "draft" # 草稿 — 待审核
STATUS_APPROVED = "approved" # 已审核 — 待排期或立即可发布
STATUS_SCHEDULED = "scheduled" # 已排期 — 定时发布
STATUS_PUBLISHING = "publishing" # 发布中
STATUS_PUBLISHED = "published" # 已发布
STATUS_FAILED = "failed" # 发布失败
STATUS_REJECTED = "rejected" # 已拒绝/丢弃
ALL_STATUSES = [STATUS_DRAFT, STATUS_APPROVED, STATUS_SCHEDULED,
STATUS_PUBLISHING, STATUS_PUBLISHED, STATUS_FAILED, STATUS_REJECTED]
STATUS_LABELS = {
STATUS_DRAFT: "📝 草稿",
STATUS_APPROVED: "✅ 待发布",
STATUS_SCHEDULED: "🕐 已排期",
STATUS_PUBLISHING: "🚀 发布中",
STATUS_PUBLISHED: "✅ 已发布",
STATUS_FAILED: "❌ 失败",
STATUS_REJECTED: "🚫 已拒绝",
}
MAX_RETRIES = 2
class PublishQueue:
"""发布队列管理器 (SQLite 持久化)"""
def __init__(self, workspace_dir: str):
self.db_path = os.path.join(workspace_dir, "publish_queue.db")
os.makedirs(workspace_dir, exist_ok=True)
self._init_db()
# 发布中状态恢复 (启动时把 publishing → failed)
self._recover_stale()
def _get_conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_db(self):
"""初始化数据库表"""
conn = self._get_conn()
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
sd_prompt TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
image_paths TEXT DEFAULT '[]',
backup_dir TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
scheduled_time TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
published_at TEXT,
topic TEXT DEFAULT '',
style TEXT DEFAULT '',
persona TEXT DEFAULT '',
error_message TEXT DEFAULT '',
retry_count INTEGER DEFAULT 0,
publish_result TEXT DEFAULT ''
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_queue_status ON queue(status)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_queue_scheduled ON queue(scheduled_time)
""")
conn.commit()
finally:
conn.close()
def _recover_stale(self):
"""启动时将残留的 publishing 状态恢复为 failed"""
conn = self._get_conn()
try:
conn.execute(
"UPDATE queue SET status = ?, error_message = '程序重启,发布中断' "
"WHERE status = ?",
(STATUS_FAILED, STATUS_PUBLISHING),
)
conn.commit()
finally:
conn.close()
# ---------- CRUD ----------
def add(self, title: str, content: str, sd_prompt: str = "",
tags: list = None, image_paths: list = None,
backup_dir: str = "", topic: str = "", style: str = "",
persona: str = "", status: str = STATUS_DRAFT,
scheduled_time: str = None) -> int:
"""添加一个队列项,返回 ID"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn = self._get_conn()
try:
cur = conn.execute(
"""INSERT INTO queue (title, content, sd_prompt, tags, image_paths,
backup_dir, status, scheduled_time, created_at, updated_at,
topic, style, persona)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(title, content, sd_prompt,
json.dumps(tags or [], ensure_ascii=False),
json.dumps(image_paths or [], ensure_ascii=False),
backup_dir, status, scheduled_time, now, now,
topic, style, persona),
)
conn.commit()
item_id = cur.lastrowid
logger.info("📋 队列添加 #%d: %s [%s]", item_id, title[:20], status)
return item_id
finally:
conn.close()
def get(self, item_id: int) -> Optional[dict]:
"""获取单个队列项"""
conn = self._get_conn()
try:
row = conn.execute("SELECT * FROM queue WHERE id = ?", (item_id,)).fetchone()
return self._row_to_dict(row) if row else None
finally:
conn.close()
def update_status(self, item_id: int, status: str,
error_message: str = "", publish_result: str = ""):
"""更新状态"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn = self._get_conn()
try:
fields = "status = ?, updated_at = ?"
params = [status, now]
if error_message:
fields += ", error_message = ?"
params.append(error_message)
if publish_result:
fields += ", publish_result = ?"
params.append(publish_result)
if status == STATUS_PUBLISHED:
fields += ", published_at = ?"
params.append(now)
params.append(item_id)
conn.execute(f"UPDATE queue SET {fields} WHERE id = ?", params)
conn.commit()
finally:
conn.close()
def update_content(self, item_id: int, title: str = None, content: str = None,
sd_prompt: str = None, tags: list = None,
scheduled_time: str = None):
"""更新内容 (仅 draft/approved/scheduled/failed 状态可编辑)"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn = self._get_conn()
try:
row = conn.execute("SELECT status FROM queue WHERE id = ?", (item_id,)).fetchone()
if not row or row["status"] in (STATUS_PUBLISHING, STATUS_PUBLISHED):
return False
sets, params = ["updated_at = ?"], [now]
if title is not None:
sets.append("title = ?"); params.append(title)
if content is not None:
sets.append("content = ?"); params.append(content)
if sd_prompt is not None:
sets.append("sd_prompt = ?"); params.append(sd_prompt)
if tags is not None:
sets.append("tags = ?"); params.append(json.dumps(tags, ensure_ascii=False))
if scheduled_time is not None:
sets.append("scheduled_time = ?"); params.append(scheduled_time)
params.append(item_id)
conn.execute(f"UPDATE queue SET {', '.join(sets)} WHERE id = ?", params)
conn.commit()
return True
finally:
conn.close()
def delete(self, item_id: int) -> bool:
"""删除队列项 (仅非 publishing 状态可删)"""
conn = self._get_conn()
try:
row = conn.execute("SELECT status FROM queue WHERE id = ?", (item_id,)).fetchone()
if not row or row["status"] == STATUS_PUBLISHING:
return False
conn.execute("DELETE FROM queue WHERE id = ?", (item_id,))
conn.commit()
return True
finally:
conn.close()
def approve(self, item_id: int, scheduled_time: str = None) -> bool:
"""审核通过 → 进入待发布或排期"""
conn = self._get_conn()
try:
row = conn.execute("SELECT status FROM queue WHERE id = ?", (item_id,)).fetchone()
if not row or row["status"] not in (STATUS_DRAFT, STATUS_FAILED):
return False
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
new_status = STATUS_SCHEDULED if scheduled_time else STATUS_APPROVED
conn.execute(
"UPDATE queue SET status = ?, scheduled_time = ?, updated_at = ?, "
"error_message = '', retry_count = 0 WHERE id = ?",
(new_status, scheduled_time, now, item_id),
)
conn.commit()
return True
finally:
conn.close()
def reject(self, item_id: int) -> bool:
"""拒绝/丢弃"""
return self._set_status_if(item_id, STATUS_REJECTED,
allowed_from=[STATUS_DRAFT, STATUS_APPROVED, STATUS_SCHEDULED, STATUS_FAILED])
def retry(self, item_id: int) -> bool:
"""失败项重试"""
conn = self._get_conn()
try:
row = conn.execute("SELECT status, retry_count FROM queue WHERE id = ?", (item_id,)).fetchone()
if not row or row["status"] != STATUS_FAILED:
return False
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"UPDATE queue SET status = ?, updated_at = ?, error_message = '' WHERE id = ?",
(STATUS_APPROVED, now, item_id),
)
conn.commit()
return True
finally:
conn.close()
def _set_status_if(self, item_id: int, new_status: str, allowed_from: list) -> bool:
conn = self._get_conn()
try:
row = conn.execute("SELECT status FROM queue WHERE id = ?", (item_id,)).fetchone()
if not row or row["status"] not in allowed_from:
return False
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute("UPDATE queue SET status = ?, updated_at = ? WHERE id = ?",
(new_status, now, item_id))
conn.commit()
return True
finally:
conn.close()
# ---------- 查询 ----------
def list_by_status(self, statuses: list = None, limit: int = 50) -> list[dict]:
"""按状态查询队列项"""
conn = self._get_conn()
try:
if statuses:
placeholders = ",".join("?" * len(statuses))
rows = conn.execute(
f"SELECT * FROM queue WHERE status IN ({placeholders}) "
"ORDER BY created_at DESC LIMIT ?",
statuses + [limit],
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM queue ORDER BY created_at DESC LIMIT ?", (limit,)
).fetchall()
return [self._row_to_dict(r) for r in rows]
finally:
conn.close()
def get_pending_publish(self) -> list[dict]:
"""获取待发布项: approved 或 scheduled 且已到时间"""
conn = self._get_conn()
try:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
rows = conn.execute(
"""SELECT * FROM queue
WHERE (status = ? OR (status = ? AND scheduled_time <= ?))
ORDER BY
CASE WHEN scheduled_time IS NOT NULL THEN scheduled_time
ELSE created_at END ASC
LIMIT 10""",
(STATUS_APPROVED, STATUS_SCHEDULED, now),
).fetchall()
return [self._row_to_dict(r) for r in rows]
finally:
conn.close()
def count_by_status(self) -> dict:
"""统计各状态数量"""
conn = self._get_conn()
try:
rows = conn.execute(
"SELECT status, COUNT(*) as cnt FROM queue GROUP BY status"
).fetchall()
return {r["status"]: r["cnt"] for r in rows}
finally:
conn.close()
def get_calendar_data(self, days: int = 30) -> list[dict]:
"""获取日历数据 (最近 N 天的发布/排期概览)"""
conn = self._get_conn()
try:
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
rows = conn.execute(
"""SELECT id, title, status, scheduled_time, published_at, created_at
FROM queue
WHERE created_at >= ? OR scheduled_time >= ? OR published_at >= ?
ORDER BY COALESCE(scheduled_time, published_at, created_at) ASC""",
(cutoff, cutoff, cutoff),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
# ---------- 辅助 ----------
@staticmethod
def _row_to_dict(row: sqlite3.Row) -> dict:
"""Row → dict, 并解析 JSON 字段"""
d = dict(row)
for key in ("tags", "image_paths"):
if key in d and isinstance(d[key], str):
try:
d[key] = json.loads(d[key])
except json.JSONDecodeError:
d[key] = []
return d
def format_queue_table(self, statuses: list = None, limit: int = 30) -> str:
"""生成 Markdown 格式的队列表格"""
items = self.list_by_status(statuses, limit)
if not items:
return "📭 队列为空"
lines = ["| # | 状态 | 标题 | 主题 | 排期时间 | 创建时间 |",
"|---|------|------|------|----------|----------|"]
for item in items:
status_label = STATUS_LABELS.get(item["status"], item["status"])
sched = item.get("scheduled_time") or ""
if sched != "":
sched = sched[:16] # 去掉秒
created = item["created_at"][:16] if item.get("created_at") else ""
title_short = (item.get("title") or "")[:18]
topic_short = (item.get("topic") or "")[:10]
lines.append(f"| {item['id']} | {status_label} | {title_short} | {topic_short} | {sched} | {created} |")
# 统计摘要
counts = self.count_by_status()
summary_parts = []
for s, label in STATUS_LABELS.items():
cnt = counts.get(s, 0)
if cnt > 0:
summary_parts.append(f"{label}: {cnt}")
summary = " · ".join(summary_parts) if summary_parts else "全部为空"
return f"**队列统计**: {summary}\n\n" + "\n".join(lines)
def format_calendar(self, days: int = 14) -> str:
"""生成简易日历视图 (Markdown)"""
data = self.get_calendar_data(days)
if not data:
return "📅 暂无排期数据"
# 按日期分组
by_date = {}
for item in data:
# 优先用排期时间,其次发布时间,最后创建时间
dt_str = item.get("scheduled_time") or item.get("published_at") or item["created_at"]
date_key = dt_str[:10] if dt_str else "未知"
by_date.setdefault(date_key, []).append(item)
lines = ["### 📅 内容日历 (近 %d 天)\n" % days]
today = datetime.now().strftime("%Y-%m-%d")
for date_key in sorted(by_date.keys()):
marker = " 📌 **今天**" if date_key == today else ""
lines.append(f"**{date_key}**{marker}")
for item in by_date[date_key]:
status_icon = STATUS_LABELS.get(item["status"], "")
time_part = ""
if item.get("scheduled_time"):
time_part = f"{item['scheduled_time'][11:16]}"
elif item.get("published_at"):
time_part = f"{item['published_at'][11:16]}"
title_short = (item.get("title") or "无标题")[:20]
lines.append(f" - {status_icon} #{item['id']} {title_short}{time_part}")
lines.append("")
return "\n".join(lines)
def format_preview(self, item_id: int) -> str:
"""生成单个项目的详细预览 (Markdown)"""
item = self.get(item_id)
if not item:
return "❌ 未找到该队列项"
status_label = STATUS_LABELS.get(item["status"], item["status"])
tags = item.get("tags", [])
tags_str = " ".join(f"#{t}" for t in tags) if tags else "无标签"
images = item.get("image_paths", [])
img_count = len(images) if images else 0
lines = [
f"## {status_label} #{item['id']}",
f"### 📌 {item.get('title', '无标题')}",
"",
item.get("content", "无正文"),
"",
f"---",
f"**主题**: {item.get('topic', '')} · **风格**: {item.get('style', '')}",
f"**标签**: {tags_str}",
f"**图片**: {img_count}",
f"**人设**: {(item.get('persona') or '')[:30]}",
]
if item.get("scheduled_time"):
lines.append(f"**排期**: {item['scheduled_time']}")
if item.get("error_message"):
lines.append(f"**错误**: ❌ {item['error_message']}")
if item.get("backup_dir"):
lines.append(f"**备份**: `{item['backup_dir']}`")
lines.extend([
f"**创建**: {item.get('created_at', '')}",
f"**更新**: {item.get('updated_at', '')}",
])
if item.get("published_at"):
lines.append(f"**发布**: {item['published_at']}")
return "\n".join(lines)
class QueuePublisher:
"""后台队列发布处理器"""
def __init__(self, queue: PublishQueue):
self.queue = queue
self._running = threading.Event()
self._thread = None
self._publish_fn = None # 由外部注册的发布回调
self._log_fn = None # 日志回调
def set_publish_callback(self, fn):
"""注册发布回调: fn(item: dict) -> (success: bool, message: str)"""
self._publish_fn = fn
def set_log_callback(self, fn):
"""注册日志回调: fn(msg: str)"""
self._log_fn = fn
def _log(self, msg: str):
logger.info(msg)
if self._log_fn:
try:
self._log_fn(msg)
except Exception:
pass
def start(self, check_interval: int = 60):
"""启动后台队列处理"""
if self._running.is_set():
return
self._running.set()
self._thread = threading.Thread(
target=self._loop, args=(check_interval,), daemon=True
)
self._thread.start()
self._log("📋 发布队列处理器已启动")
def stop(self):
"""停止队列处理"""
self._running.clear()
self._log("📋 发布队列处理器已停止")
@property
def is_running(self) -> bool:
return self._running.is_set()
def _loop(self, interval: int):
while self._running.is_set():
try:
self._process_pending()
except Exception as e:
self._log(f"❌ 队列处理异常: {e}")
logger.error("队列处理异常: %s", e, exc_info=True)
# 等待,但可中断
for _ in range(interval):
if not self._running.is_set():
break
time.sleep(1)
def _process_pending(self):
"""处理所有待发布项"""
if not self._publish_fn:
return
pending = self.queue.get_pending_publish()
if not pending:
return
for item in pending:
if not self._running.is_set():
break
item_id = item["id"]
title = item.get("title", "")[:20]
self._log(f"📋 队列发布 #{item_id}: {title}")
# 标记为发布中
self.queue.update_status(item_id, STATUS_PUBLISHING)
try:
success, message = self._publish_fn(item)
if success:
self.queue.update_status(item_id, STATUS_PUBLISHED, publish_result=message)
self._log(f"✅ 队列发布成功 #{item_id}: {title}")
else:
retry_count = item.get("retry_count", 0) + 1
if retry_count <= MAX_RETRIES:
# 还有重试机会 → approved 状态等下一轮
self.queue.update_status(item_id, STATUS_APPROVED, error_message=f"{retry_count}次失败: {message}")
conn = self.queue._get_conn()
try:
conn.execute("UPDATE queue SET retry_count = ? WHERE id = ?",
(retry_count, item_id))
conn.commit()
finally:
conn.close()
self._log(f"⚠️ #{item_id} 发布失败 (重试 {retry_count}/{MAX_RETRIES}): {message}")
else:
self.queue.update_status(item_id, STATUS_FAILED, error_message=message)
self._log(f"❌ #{item_id} 发布失败已达重试上限: {message}")
except Exception as e:
self.queue.update_status(item_id, STATUS_FAILED, error_message=str(e))
self._log(f"❌ #{item_id} 发布异常: {e}")
# 发布间隔 (模拟真人)
import random
wait = random.randint(5, 15)
self._log(f"⏳ 等待 {wait}s 后处理下一项...")
for _ in range(wait):
if not self._running.is_set():
break
time.sleep(1)
def publish_now(self, item_id: int) -> str:
"""立即发布指定项 (不经过后台循环)"""
if not self._publish_fn:
return "❌ 发布回调未注册"
item = self.queue.get(item_id)
if not item:
return "❌ 未找到队列项"
if item["status"] not in (STATUS_APPROVED, STATUS_SCHEDULED, STATUS_FAILED):
return f"❌ 当前状态 [{STATUS_LABELS.get(item['status'], item['status'])}] 不可发布"
self.queue.update_status(item_id, STATUS_PUBLISHING)
try:
success, message = self._publish_fn(item)
if success:
self.queue.update_status(item_id, STATUS_PUBLISHED, publish_result=message)
return f"✅ 发布成功: {message}"
else:
self.queue.update_status(item_id, STATUS_FAILED, error_message=message)
return f"❌ 发布失败: {message}"
except Exception as e:
self.queue.update_status(item_id, STATUS_FAILED, error_message=str(e))
return f"❌ 发布异常: {e}"

View File

@ -1,603 +1,26 @@
"""
Stable Diffusion 服务模块
封装对 SD WebUI API 的调用支持 txt2img img2img支持 ReActor 换脸
含图片反 AI 检测后处理管线
封装对 SD WebUI API 的调用支持 txt2img img2img
"""
import requests
import base64
import io
import logging
import os
import random
import math
import struct
import zlib
from PIL import Image, ImageFilter, ImageEnhance
from PIL import Image
logger = logging.getLogger(__name__)
SD_TIMEOUT = 1800 # 图片生成可能需要较长时间
SD_TIMEOUT = 900 # 图片生成可能需要较长时间
# 头像文件默认保存路径
FACE_IMAGE_PATH = os.path.join(os.path.dirname(__file__), "my_face.png")
# ==================== 多模型配置系统 ====================
# 每个模型的最优参数、prompt 增强词、负面提示词、三档预设
SD_MODEL_PROFILES = {
# ---- majicmixRealistic: 东亚网红感,朋友圈自拍/美妆/穿搭 (SD 1.5) ----
"majicmixRealistic": {
"display_name": "majicmixRealistic ⭐⭐⭐⭐⭐",
"description": "东亚网红感 | 朋友圈自拍、美妆、穿搭",
"arch": "sd15", # SD 1.5 架构
# 自动追加到 prompt 前面的增强词
"prompt_prefix": (
"(best quality:1.4), (masterpiece:1.4), (ultra detailed:1.3), "
"(photorealistic:1.4), (realistic:1.3), raw photo, "
"(asian girl:1.3), (chinese:1.2), (east asian features:1.2), "
"(delicate facial features:1.2), (fair skin:1.1), (natural skin texture:1.2), "
"(soft lighting:1.1), (natural makeup:1.1), "
),
# 自动追加到 prompt 后面的补充词
"prompt_suffix": (
", film grain, shallow depth of field, "
"instagram aesthetic, xiaohongshu style, phone camera feel"
),
"negative_prompt": (
"(nsfw:1.5), (nudity:1.5), (worst quality:2), (low quality:2), (normal quality:2), "
"lowres, bad anatomy, bad hands, text, error, missing fingers, "
"extra digit, fewer digits, cropped, jpeg artifacts, signature, watermark, "
"blurry, deformed, mutated, disfigured, ugly, duplicate, "
"poorly drawn face, poorly drawn hands, extra limbs, fused fingers, "
"too many fingers, long neck, out of frame, "
"western face, european face, caucasian, deep-set eyes, high nose bridge, "
"blonde hair, red hair, blue eyes, green eyes, freckles, thick body hair, "
"painting, cartoon, anime, sketch, illustration, 3d render"
),
"presets": {
"快速 (约30秒)": {
"steps": 20,
"cfg_scale": 7.0,
"width": 512,
"height": 768,
"sampler_name": "Euler a",
"scheduler": "Normal",
"batch_size": 2,
},
"标准 (约1分钟)": {
"steps": 30,
"cfg_scale": 7.0,
"width": 512,
"height": 768,
"sampler_name": "DPM++ 2M",
"scheduler": "Karras",
"batch_size": 2,
},
"精细 (约2-3分钟)": {
"steps": 40,
"cfg_scale": 7.5,
"width": 576,
"height": 864,
"sampler_name": "DPM++ SDE",
"scheduler": "Karras",
"batch_size": 2,
},
},
},
# ---- Realistic Vision: 写实摄影感,纪实摄影/街拍/真实质感 (SD 1.5) ----
"realisticVision": {
"display_name": "Realistic Vision ⭐⭐⭐⭐",
"description": "写实摄影感 | 纪实摄影、街拍、真实质感",
"arch": "sd15",
"prompt_prefix": (
"RAW photo, (best quality:1.4), (masterpiece:1.3), (realistic:1.4), "
"(photorealistic:1.4), 8k uhd, DSLR, high quality, "
"(asian:1.2), (chinese girl:1.2), (east asian features:1.1), "
"(natural skin:1.2), (skin pores:1.1), (detailed skin texture:1.2), "
),
"prompt_suffix": (
", shot on Canon EOS R5, 85mm lens, f/1.8, "
"natural lighting, documentary style, street photography, "
"film color grading, depth of field"
),
"negative_prompt": (
"(nsfw:1.5), (nudity:1.5), (worst quality:2), (low quality:2), (normal quality:2), "
"lowres, bad anatomy, bad hands, text, error, missing fingers, "
"extra digit, fewer digits, cropped, jpeg artifacts, signature, watermark, "
"blurry, deformed, mutated, disfigured, ugly, duplicate, "
"poorly drawn face, extra limbs, fused fingers, long neck, "
"western face, european face, caucasian, deep-set eyes, "
"blonde hair, blue eyes, green eyes, freckles, "
"painting, cartoon, anime, sketch, illustration, 3d render, "
"over-sharpened, over-saturated, plastic skin, airbrushed, "
"smooth skin, doll-like, HDR, overprocessed"
),
"presets": {
"快速 (约30秒)": {
"steps": 20,
"cfg_scale": 7.0,
"width": 512,
"height": 768,
"sampler_name": "Euler a",
"scheduler": "Normal",
"batch_size": 2,
},
"标准 (约1分钟)": {
"steps": 28,
"cfg_scale": 7.0,
"width": 512,
"height": 768,
"sampler_name": "DPM++ 2M",
"scheduler": "Karras",
"batch_size": 2,
},
"精细 (约2-3分钟)": {
"steps": 40,
"cfg_scale": 7.5,
"width": 576,
"height": 864,
"sampler_name": "DPM++ SDE",
"scheduler": "Karras",
"batch_size": 2,
},
},
},
# ---- Juggernaut XL: 电影大片感,高画质/商业摄影/复杂背景 (SDXL) ----
"juggernautXL": {
"display_name": "Juggernaut XL ⭐⭐⭐⭐",
"description": "电影大片感 | 高画质、商业摄影、复杂背景",
"arch": "sdxl", # SDXL 架构
"prompt_prefix": (
"masterpiece, best quality, ultra detailed, 8k uhd, high resolution, "
"photorealistic, cinematic lighting, cinematic composition, "
"asian girl, chinese, east asian features, black hair, dark brown eyes, "
"delicate facial features, fair skin, slim figure, "
),
"prompt_suffix": (
", cinematic color grading, anamorphic lens, bokeh, "
"volumetric lighting, ray tracing, global illumination, "
"commercial photography, editorial style, vogue aesthetic"
),
"negative_prompt": (
# 默认反向提示词(针对 JuggernautXL / SDXL 优化)
DEFAULT_NEGATIVE = (
"nsfw, nudity, lowres, bad anatomy, bad hands, text, error, missing fingers, "
"extra digit, fewer digits, cropped, worst quality, low quality, normal quality, "
"jpeg artifacts, signature, watermark, blurry, deformed, mutated, disfigured, "
"ugly, duplicate, morbid, mutilated, poorly drawn face, poorly drawn hands, "
"extra limbs, fused fingers, too many fingers, long neck, username, "
"out of frame, distorted, oversaturated, underexposed, overexposed, "
"western face, european face, caucasian, deep-set eyes, high nose bridge, "
"blonde hair, red hair, blue eyes, green eyes, freckles, thick body hair"
),
"presets": {
"快速 (约30秒)": {
"steps": 12,
"cfg_scale": 5.0,
"width": 768,
"height": 1024,
"sampler_name": "Euler a",
"scheduler": "Normal",
"batch_size": 2,
},
"标准 (约1分钟)": {
"steps": 20,
"cfg_scale": 5.5,
"width": 832,
"height": 1216,
"sampler_name": "DPM++ 2M",
"scheduler": "Karras",
"batch_size": 2,
},
"精细 (约2-3分钟)": {
"steps": 35,
"cfg_scale": 6.0,
"width": 832,
"height": 1216,
"sampler_name": "DPM++ 2M SDE",
"scheduler": "Karras",
"batch_size": 2,
},
},
},
}
# ==================== 人设 SD 视觉配置 ====================
# 每个人设对应一组 SD prompt 增强词 + 视觉风格 + LLM 绘图指导
# key 匹配规则: persona 文本中包含 key 即命中
PERSONA_SD_PROFILES = {
# ---- 赛博AI虚拟博主 ----
"赛博AI虚拟博主": {
# 追加到 SD prompt 前面的人设特有增强词
"prompt_boost": (
"(perfect face:1.3), (extremely detailed face:1.3), (beautiful detailed eyes:1.3), "
"(flawless skin:1.2), (ultra high resolution face:1.2), (glossy lips:1.1), "
),
# 追加到 SD prompt 后面的风格词
"prompt_style": (
", cinematic portrait, fashion editorial, vibrant colors, "
"dramatic lighting, creative background, fantasy environment, "
"instagram influencer, trending on artstation"
),
# 额外追加到负面提示词
"negative_extra": "",
# LLM 绘图指导 (追加到 sd_prompt_guide)
"llm_guide": (
"\n\n【人设视觉风格 - 赛博AI虚拟博主】\n"
"你在为一个大方承认自己是AI的虚拟博主生成图片主打高颜值+场景奇观:\n"
"- 五官极致精致:重点描述 detailed eyes, long eyelashes, glossy lips, perfect skin\n"
"- 场景大胆奇幻:巴黎埃菲尔铁塔前、东京霓虹街头、赛博朋克城市、外太空空间站、水下宫殿、樱花隧道等\n"
"- 光效华丽neon lights, cyberpunk glow, holographic, lens flare, volumetric fog\n"
"- 穿搭多变:可大胆使用 futuristic outfit, holographic dress, cyberpunk jacket, maid outfit, school uniform, wedding dress 等\n"
"- 构图要有视觉冲击力close-up portrait, dynamic angle, full body shot, looking at viewer\n"
"- 整体风格:超现实+高颜值,不需要追求真实感,要追求视觉震撼\n"
),
},
# ---- 性感福利主播 ----
"性感福利主播": {
"prompt_boost": (
"(beautiful detailed face:1.3), (seductive eyes:1.2), (glossy lips:1.2), "
"(perfect body proportions:1.2), (slim waist:1.2), (long legs:1.2), "
"(glamour photography:1.3), "
),
"prompt_style": (
", soft glamour lighting, beauty retouching, "
"intimate atmosphere, warm golden tones, shallow depth of field, "
"boudoir style, sensual but tasteful, fashion model pose"
),
"negative_extra": "",
"llm_guide": (
"\n\n【人设视觉风格 - 性感福利主播】\n"
"为一个身材曼妙的时尚博主生成图片,主打身材美感+氛围感:\n"
"- 身材描述slim waist, long legs, perfect figure, hourglass body, graceful pose\n"
"- 穿搭关键lingerie, bikini, bodycon dress, off-shoulder, backless dress, lace, sheer fabric\n"
"- 光线氛围soft warm lighting, golden hour, window light, candle light, intimate mood\n"
"- 场景选择bedroom, luxury hotel, swimming pool, beach sunset, mirror selfie\n"
"- 构图要点full body or three-quarter shot, emphasize curves, elegant pose, looking at viewer\n"
"- 整体风格glamour photography魅力但有品位不要低俗\n"
),
},
# ---- 身材管理健身美女 ----
"身材管理健身美女": {
"prompt_boost": (
"(fit body:1.3), (athletic physique:1.2), (toned muscles:1.2), "
"(healthy glow:1.2), (confident pose:1.2), "
),
"prompt_style": (
", fitness photography, gym environment, athletic wear, "
"dynamic pose, energetic mood, healthy lifestyle, "
"motivational, natural sweat, workout aesthetic"
),
"negative_extra": "",
"llm_guide": (
"\n\n【人设视觉风格 - 身材管理健身美女】\n"
"为一个健身达人生成图片,主打健康美+运动感:\n"
"- 身材描述fit body, toned abs, lean muscles, athletic build, healthy skin\n"
"- 穿搭关键sports bra, yoga pants, running outfit, gym wear, crop top\n"
"- 场景选择gym, yoga studio, outdoor running, home workout, mirror selfie at gym\n"
"- 动作姿势workout pose, stretching, running, yoga pose, flexing, plank\n"
"- 光线gym lighting, natural daylight, morning sun, energetic bright tones\n"
"- 整体风格:充满活力的运动健身风,展现自律和力量美\n"
),
},
# ---- 温柔知性的时尚博主 ----
"温柔知性时尚博主": {
"prompt_boost": (
"(elegant:1.2), (gentle expression:1.2), (sophisticated:1.2), "
"(fashion forward:1.2), (graceful:1.1), "
),
"prompt_style": (
", fashion editorial, street style photography, "
"chic outfit, elegant pose, soft natural tones, "
"magazine cover quality, lifestyle photography"
),
"negative_extra": "",
"llm_guide": (
"\n\n【人设视觉风格 - 温柔知性时尚博主】\n"
"为一个知性优雅的时尚博主生成图片,主打高级感穿搭:\n"
"- 气质描述elegant, gentle smile, sophisticated, poised, graceful\n"
"- 穿搭关键french style, minimalist outfit, trench coat, silk blouse, midi skirt, neutral colors\n"
"- 场景选择cafe, art gallery, tree-lined street, bookstore, european architecture\n"
"- 构图three-quarter shot, walking pose, looking away candidly, holding coffee\n"
"- 色调warm neutral tones, soft creamy, muted colors, film aesthetic\n"
"- 整体风格:法式优雅+知性温柔,像时尚杂志的生活方式大片\n"
),
},
# ---- 文艺青年摄影师 ----
"文艺青年摄影师": {
"prompt_boost": (
"(artistic:1.2), (moody atmosphere:1.2), (film grain:1.2), "
"(vintage tones:1.1), "
),
"prompt_style": (
", film photography, 35mm film, kodak portra 400, "
"vintage color grading, indie aesthetic, dreamy atmosphere, "
"golden hour, nostalgic mood"
),
"negative_extra": "",
"llm_guide": (
"\n\n【人设视觉风格 - 文艺青年摄影师】\n"
"为文艺摄影师生成图片,主打胶片感+故事性:\n"
"- 风格关键film grain, vintage tones, kodak portra, dreamy, nostalgic\n"
"- 场景选择old streets, abandoned places, cafe corner, train station, seaside, flower field\n"
"- 光线golden hour, window light, overcast soft light, dappled sunlight\n"
"- 构图off-center composition, back view, silhouette, reflection\n"
"- 色调warm vintage, faded colors, low contrast, film color grading\n"
"- 整体风格:独立电影画面感,有故事性的文艺氛围\n"
),
},
# ---- 二次元coser ----
"二次元coser": {
"prompt_boost": (
"(cosplay:1.3), (detailed costume:1.2), (colorful:1.2), "
"(anime inspired:1.1), (vibrant:1.2), "
),
"prompt_style": (
", cosplay photography, anime convention, colorful costume, "
"dynamic pose, vibrant colors, fantasy setting, "
"dramatic lighting, character portrayal"
),
"negative_extra": "",
"llm_guide": (
"\n\n【人设视觉风格 - 二次元coser】\n"
"为cos博主生成图片主打二次元还原+视觉冲击:\n"
"- 风格关键cosplay, detailed costume, colorful wig, contact lenses, anime style\n"
"- 场景选择anime convention, fantasy landscape, school rooftop, cherry blossoms, studio backdrop\n"
"- 动作姿势character pose, dynamic action, cute pose, peace sign, holding prop\n"
"- 光效colored lighting, rim light, sparkle effects, dramatic shadows\n"
"- 色调vibrant saturated, anime color palette, high contrast\n"
"- 整体风格真人cos感兼具二次元的鲜艳感和真实摄影的质感\n"
),
},
# ---- 汉服爱好者 ----
"汉服爱好者": {
"prompt_boost": (
"(traditional chinese dress:1.3), (hanfu:1.3), (chinese aesthetic:1.2), "
"(elegant traditional:1.2), (delicate accessories:1.1), "
),
"prompt_style": (
", traditional chinese photography, han dynasty style, "
"ink painting aesthetic, bamboo forest, ancient architecture, "
"flowing silk fabric, classical beauty, ethereal atmosphere"
),
"negative_extra": "",
"llm_guide": (
"\n\n【人设视觉风格 - 汉服爱好者】\n"
"为国风汉服博主生成图片,主打古典美+中国风:\n"
"- 服饰描述hanfu, flowing silk robes, wide sleeves, hair accessories, jade earrings, fan\n"
"- 场景选择bamboo forest, ancient temple, moon gate, lotus pond, plum blossom, mountain mist\n"
"- 光线soft diffused light, misty atmosphere, morning fog, moonlight, lantern glow\n"
"- 构图full body flowing fabric, profile view, looking down gently, holding umbrella\n"
"- 色调muted earth tones, ink wash style, red and white contrast, jade green\n"
"- 整体风格:仙气飘飘的古风摄影,有水墨画的意境\n"
),
},
# ---- 独居女孩 ----
"独居女孩": {
"prompt_boost": (
"(cozy atmosphere:1.3), (warm lighting:1.2), (homey:1.2), "
"(casual style:1.1), (relaxed:1.1), "
),
"prompt_style": (
", cozy home photography, warm ambient light, "
"casual indoor style, hygge aesthetic, "
"soft blanket, candle light, peaceful morning"
),
"negative_extra": "",
"llm_guide": (
"\n\n【人设视觉风格 - 独居女孩】\n"
"为独居生活博主生成图片,主打温馨氛围感+仪式感:\n"
"- 氛围关键cozy, warm, hygge, peaceful, intimate, ambient candlelight\n"
"- 场景选择small apartment, kitchen cooking, bathtub, reading by window, balcony garden\n"
"- 穿搭关键oversized sweater, pajamas, casual homewear, messy bun\n"
"- 光线warm lamp light, candle glow, morning window light, fairy lights\n"
"- 道具coffee mug, book, cat, houseplant, scented candle, blanket\n"
"- 整体风格:温暖治愈的独居日常,有仪式感的精致生活\n"
),
},
# ---- 资深美妆博主 ----
"资深美妆博主": {
"prompt_boost": (
"(flawless makeup:1.3), (detailed eye makeup:1.3), (beauty close-up:1.2), "
"(perfect skin:1.2), (beauty lighting:1.2), "
),
"prompt_style": (
", beauty photography, ring light, macro lens, "
"studio beauty lighting, makeup tutorial style, "
"dewy skin, perfect complexion, vibrant lip color"
),
"negative_extra": "",
"llm_guide": (
"\n\n【人设视觉风格 - 资深美妆博主】\n"
"为美妆博主生成图片,主打妆容特写+产品展示:\n"
"- 妆容描述detailed eye shadow, winged eyeliner, glossy lips, dewy foundation, blush\n"
"- 构图要点face close-up, half face, eye close-up, lip close-up, before and after\n"
"- 场景选择vanity desk, bathroom mirror, ring light studio, flat lay of products\n"
"- 光线ring light, beauty dish, soft diffused studio light, bright even lighting\n"
"- 色调clean bright, pink tones, neutral with pops of color\n"
"- 整体风格:专业美妆教程感,妆容细节清晰可见\n"
),
},
}
def get_persona_sd_profile(persona_text: str) -> dict | None:
"""根据人设文本匹配 SD 视觉配置,返回 profile dict 或 None"""
if not persona_text:
return None
for key, profile in PERSONA_SD_PROFILES.items():
if key in persona_text:
return profile
return None
# 默认配置 profile key
DEFAULT_MODEL_PROFILE = "juggernautXL"
def detect_model_profile(model_name: str) -> str:
"""根据 SD 模型名称自动识别对应的 profile key"""
name_lower = model_name.lower() if model_name else ""
if "majicmix" in name_lower or "majic" in name_lower:
return "majicmixRealistic"
elif "realistic" in name_lower and "vision" in name_lower:
return "realisticVision"
elif "rv" in name_lower and ("v5" in name_lower or "v6" in name_lower or "v4" in name_lower):
return "realisticVision" # RV v5.1 等简写
elif "juggernaut" in name_lower or "jugger" in name_lower:
return "juggernautXL"
# 根据架构猜测
elif "xl" in name_lower or "sdxl" in name_lower:
return "juggernautXL" # SDXL 架构默认用 Juggernaut 参数
else:
return DEFAULT_MODEL_PROFILE # 无法识别时默认
def get_model_profile(model_name: str = None) -> dict:
"""获取模型配置 profile"""
key = detect_model_profile(model_name) if model_name else DEFAULT_MODEL_PROFILE
return SD_MODEL_PROFILES.get(key, SD_MODEL_PROFILES[DEFAULT_MODEL_PROFILE])
def get_model_profile_info(model_name: str = None) -> str:
"""获取当前模型的显示信息 (Markdown 格式)"""
profile = get_model_profile(model_name)
key = detect_model_profile(model_name) if model_name else DEFAULT_MODEL_PROFILE
is_default = key == DEFAULT_MODEL_PROFILE and model_name and detect_model_profile(model_name) == DEFAULT_MODEL_PROFILE
# 如果检测结果是默认回退的, 说明是未知模型
actual_key = detect_model_profile(model_name) if model_name else None
presets = profile["presets"]
first_preset = list(presets.values())[0]
res = f"{first_preset.get('width', '?')}×{first_preset.get('height', '?')}"
lines = [
f"**🎨 {profile['display_name']}** | `{profile['arch'].upper()}` | {res}",
f"> {profile['description']}",
]
if model_name and not any(k in (model_name or "").lower() for k in ["majicmix", "realistic", "juggernaut"]):
lines.append(f"> ⚠️ 未识别的模型,使用默认档案 ({profile['display_name']})")
return "\n".join(lines)
# ==================== 兼容旧接口 ====================
# 默认预设和反向提示词 (使用 Juggernaut XL 作为默认)
SD_PRESETS = SD_MODEL_PROFILES[DEFAULT_MODEL_PROFILE]["presets"]
SD_PRESET_NAMES = list(SD_PRESETS.keys())
def get_sd_preset(name: str, model_name: str = None) -> dict:
"""获取生成预设参数,自动适配模型"""
profile = get_model_profile(model_name)
presets = profile.get("presets", SD_PRESETS)
return presets.get(name, presets.get("标准 (约1分钟)", list(presets.values())[0]))
# 默认反向提示词Juggernaut XL
DEFAULT_NEGATIVE = SD_MODEL_PROFILES[DEFAULT_MODEL_PROFILE]["negative_prompt"]
# ==================== 图片反 AI 检测管线 ====================
def anti_detect_postprocess(img: Image.Image) -> Image.Image:
"""对 AI 生成的图片进行后处理,模拟手机拍摄/加工特征,降低 AI 检测率
处理流程:
1. 剥离所有元数据 (EXIF/SD参数/PNG chunks)
2. 微小随机裁剪 (模拟手机截图不完美)
3. 微小旋转+校正 (破坏像素完美对齐)
4. 色彩微扰 (模拟手机屏幕色差)
5. 不均匀高斯噪声 (模拟传感器噪声)
6. 微小锐化 (模拟手机锐化算法)
7. JPEG 压缩回环 (最关键: 引入真实压缩伪影)
"""
if img.mode != "RGB":
img = img.convert("RGB")
w, h = img.size
# Step 1: 微小随机裁剪 (1-3 像素, 破坏边界对齐)
crop_l = random.randint(0, 3)
crop_t = random.randint(0, 3)
crop_r = random.randint(0, 3)
crop_b = random.randint(0, 3)
if crop_l + crop_r < w and crop_t + crop_b < h:
img = img.crop((crop_l, crop_t, w - crop_r, h - crop_b))
# Step 2: 极微旋转 (0.1°-0.5°, 破坏完美像素排列)
if random.random() < 0.6:
angle = random.uniform(-0.5, 0.5)
img = img.rotate(angle, resample=Image.BICUBIC, expand=False,
fillcolor=(
random.randint(240, 255),
random.randint(240, 255),
random.randint(240, 255),
))
# Step 3: 色彩微扰 (模拟手机屏幕/相机色差)
# 亮度微调
brightness_factor = random.uniform(0.97, 1.03)
img = ImageEnhance.Brightness(img).enhance(brightness_factor)
# 对比度微调
contrast_factor = random.uniform(0.97, 1.03)
img = ImageEnhance.Contrast(img).enhance(contrast_factor)
# 饱和度微调
saturation_factor = random.uniform(0.96, 1.04)
img = ImageEnhance.Color(img).enhance(saturation_factor)
# Step 4: 不均匀传感器噪声 (比均匀噪声更像真实相机)
try:
import numpy as np
arr = np.array(img, dtype=np.float32)
# 生成不均匀噪声: 中心弱边缘强 (模拟暗角)
h_arr, w_arr = arr.shape[:2]
y_grid, x_grid = np.mgrid[0:h_arr, 0:w_arr]
center_y, center_x = h_arr / 2, w_arr / 2
dist = np.sqrt((y_grid - center_y) ** 2 + (x_grid - center_x) ** 2)
max_dist = np.sqrt(center_y ** 2 + center_x ** 2)
# 噪声强度: 中心 1.0, 边缘 2.5
noise_strength = 1.0 + 1.5 * (dist / max_dist)
noise_strength = noise_strength[:, :, np.newaxis]
# 高斯噪声
noise = np.random.normal(0, random.uniform(1.5, 3.0), arr.shape) * noise_strength
arr = np.clip(arr + noise, 0, 255).astype(np.uint8)
img = Image.fromarray(arr)
except ImportError:
# numpy 不可用时用 PIL 的简单模糊代替
pass
# Step 5: 轻微锐化 (模拟手机后处理)
if random.random() < 0.5:
img = img.filter(ImageFilter.SHARPEN)
# 再做一次轻微模糊中和, 避免过度锐化
img = img.filter(ImageFilter.GaussianBlur(radius=0.3))
# Step 6: JPEG 压缩回环 (最关键! 引入真实压缩伪影)
# 模拟: 手机保存 → 社交平台压缩 → 重新上传
quality = random.randint(85, 93) # 质量略低于完美, 像手机存储
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality, subsampling=0)
buf.seek(0)
img = Image.open(buf).copy() # 重新加载, 已包含 JPEG 伪影
# Step 7: resize 回原始尺寸附近 (模拟平台缩放)
# 微小缩放 ±2%
scale = random.uniform(0.98, 1.02)
new_w = int(img.width * scale)
new_h = int(img.height * scale)
if new_w > 100 and new_h > 100:
img = img.resize((new_w, new_h), Image.LANCZOS)
logger.info("🛡️ 图片反检测后处理完成: crop=%dx%d%dx%d, jpeg_q=%d, scale=%.2f",
w, h, img.width, img.height, quality, scale)
return img
def strip_metadata(img: Image.Image) -> Image.Image:
"""彻底剥离图片所有元数据 (EXIF, SD参数, PNG text chunks)"""
clean = Image.new(img.mode, img.size)
clean.putdata(list(img.getdata()))
return clean
"out of frame, distorted, oversaturated, underexposed, overexposed"
)
class SDService:
@ -606,99 +29,6 @@ class SDService:
def __init__(self, sd_url: str = "http://127.0.0.1:7860"):
self.sd_url = sd_url.rstrip("/")
# ---------- 工具方法 ----------
@staticmethod
def _image_to_base64(img: Image.Image) -> str:
"""PIL Image → base64 字符串"""
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
@staticmethod
def load_face_image(path: str = None) -> Image.Image | None:
"""加载头像图片,不存在则返回 None"""
path = path or FACE_IMAGE_PATH
if path and os.path.isfile(path):
try:
return Image.open(path).convert("RGB")
except Exception as e:
logger.warning("头像加载失败: %s", e)
return None
@staticmethod
def save_face_image(img: Image.Image, path: str = None) -> str:
"""保存头像图片,返回保存路径"""
path = path or FACE_IMAGE_PATH
img = img.convert("RGB")
img.save(path, format="PNG")
logger.info("头像已保存: %s", path)
return path
def _build_reactor_args(self, face_image: Image.Image) -> dict:
"""构建 ReActor 换脸参数alwayson_scripts 格式)
参数索引对照 (reactor script-info):
0: source_image (base64) 1: enable 2: source_faces
3: target_faces 4: model 5: restore_face
6: restore_visibility 7: restore_first 8: upscaler
9: scale 10: upscaler_vis 11: swap_in_source
12: swap_in_generated 13: log_level 14: gender_source
15: gender_target 16: save_original 17: codeformer_weight
18: source_hash_check 19: target_hash_check 20: exec_provider
21: face_mask_correction 22: select_source 23: face_model
24: source_folder 25: multiple_sources 26: random_image
27: force_upscale 28: threshold 29: max_faces
30: tab_single
"""
face_b64 = self._image_to_base64(face_image)
return {
"reactor": {
"args": [
face_b64, # 0: source image (base64)
True, # 1: enable ReActor
"0", # 2: source face index
"0", # 3: target face index
"inswapper_128.onnx", # 4: swap model
"CodeFormer", # 5: restore face method
1, # 6: restore face visibility
True, # 7: restore face first, then upscale
"None", # 8: upscaler
1, # 9: scale
1, # 10: upscaler visibility
False, # 11: swap in source
True, # 12: swap in generated
1, # 13: console log level (0=min, 1=med, 2=max)
0, # 14: gender detection source (0=No)
0, # 15: gender detection target (0=No)
False, # 16: save original
0.8, # 17: CodeFormer weight (0=max effect, 1=min)
False, # 18: source hash check
False, # 19: target hash check
"CUDA", # 20: execution provider
True, # 21: face mask correction
0, # 22: select source (0=Image, 1=FaceModel, 2=Folder)
"", # 23: face model filename (when #22=1)
"", # 24: source folder path (when #22=2)
None, # 25: skip for API
False, # 26: random image
False, # 27: force upscale
0.6, # 28: face detection threshold
2, # 29: max faces to detect (0=unlimited)
],
}
}
def has_reactor(self) -> bool:
"""检查 SD WebUI 是否安装了 ReActor 扩展"""
try:
resp = requests.get(f"{self.sd_url}/sdapi/v1/scripts", timeout=5)
scripts = resp.json()
all_scripts = scripts.get("txt2img", []) + scripts.get("img2img", [])
return any("reactor" in s.lower() for s in all_scripts)
except Exception:
return False
def check_connection(self) -> tuple[bool, str]:
"""检查 SD 服务是否可用"""
try:
@ -732,75 +62,33 @@ class SDService:
def txt2img(
self,
prompt: str,
negative_prompt: str = None,
negative_prompt: str = DEFAULT_NEGATIVE,
model: str = None,
steps: int = None,
cfg_scale: float = None,
width: int = None,
height: int = None,
batch_size: int = None,
steps: int = 30,
cfg_scale: float = 5.0,
width: int = 832,
height: int = 1216,
batch_size: int = 2,
seed: int = -1,
sampler_name: str = None,
scheduler: str = None,
face_image: Image.Image = None,
quality_mode: str = None,
persona: str = None,
sampler_name: str = "DPM++ 2M",
scheduler: str = "Karras",
) -> list[Image.Image]:
"""文生图(自动适配当前 SD 模型 + 人设的最优参数)
Args:
model: SD 模型名自动识别并应用对应配置
face_image: 头像 PIL Image传入后自动启用 ReActor 换脸
quality_mode: 预设模式名
persona: 博主人设文本自动注入人设视觉增强词
"""
"""文生图(参数针对 JuggernautXL 优化)"""
if model:
self.switch_model(model)
# 自动识别模型配置
profile = get_model_profile(model)
profile_key = detect_model_profile(model)
logger.info("🎯 SD 模型识别: %s%s (%s)",
model or "默认", profile_key, profile["description"])
# 加载模型专属预设参数
preset = get_sd_preset(quality_mode, model) if quality_mode else get_sd_preset("标准 (约1分钟)", model)
# 自动增强 prompt: 人设增强 + 模型前缀 + 原始 prompt + 模型后缀 + 人设风格
persona_sd = get_persona_sd_profile(persona)
persona_boost = persona_sd.get("prompt_boost", "") if persona_sd else ""
persona_style = persona_sd.get("prompt_style", "") if persona_sd else ""
enhanced_prompt = persona_boost + profile.get("prompt_prefix", "") + prompt + profile.get("prompt_suffix", "") + persona_style
if persona_sd:
logger.info("🎭 人设视觉增强已注入: +%d boost词 +%d style词",
len(persona_boost), len(persona_style))
# 使用模型专属反向提示词 + 人设额外负面词
final_negative = negative_prompt if negative_prompt is not None else profile.get("negative_prompt", DEFAULT_NEGATIVE)
if persona_sd and persona_sd.get("negative_extra"):
final_negative = final_negative + ", " + persona_sd["negative_extra"]
payload = {
"prompt": enhanced_prompt,
"negative_prompt": final_negative,
"steps": steps if steps is not None else preset["steps"],
"cfg_scale": cfg_scale if cfg_scale is not None else preset["cfg_scale"],
"width": width if width is not None else preset["width"],
"height": height if height is not None else preset["height"],
"batch_size": batch_size if batch_size is not None else preset["batch_size"],
"prompt": prompt,
"negative_prompt": negative_prompt,
"steps": steps,
"cfg_scale": cfg_scale,
"width": width,
"height": height,
"batch_size": batch_size,
"seed": seed,
"sampler_name": sampler_name if sampler_name is not None else preset["sampler_name"],
"scheduler": scheduler if scheduler is not None else preset["scheduler"],
"sampler_name": sampler_name,
"scheduler": scheduler,
}
logger.info("SD 生成参数 [%s]: steps=%s, cfg=%.1f, %dx%d, sampler=%s",
profile_key, payload['steps'], payload['cfg_scale'],
payload['width'], payload['height'], payload['sampler_name'])
# 如果提供了头像,通过 ReActor 换脸
if face_image is not None:
payload["alwayson_scripts"] = self._build_reactor_args(face_image)
logger.info("🎭 ReActor 换脸已启用")
resp = requests.post(
f"{self.sd_url}/sdapi/v1/txt2img",
@ -812,8 +100,6 @@ class SDService:
images = []
for img_b64 in resp.json().get("images", []):
img = Image.open(io.BytesIO(base64.b64decode(img_b64)))
# 反 AI 检测后处理: 剥离元数据 + 模拟手机拍摄特征
img = anti_detect_postprocess(img)
images.append(img)
return images
@ -821,37 +107,30 @@ class SDService:
self,
init_image: Image.Image,
prompt: str,
negative_prompt: str = None,
negative_prompt: str = DEFAULT_NEGATIVE,
denoising_strength: float = 0.5,
steps: int = 30,
cfg_scale: float = None,
sampler_name: str = None,
scheduler: str = None,
model: str = None,
cfg_scale: float = 5.0,
sampler_name: str = "DPM++ 2M",
scheduler: str = "Karras",
) -> list[Image.Image]:
"""图生图(自动适配模型参数)"""
profile = get_model_profile(model)
preset = get_sd_preset("标准 (约1分钟)", model)
"""图生图(参数针对 JuggernautXL 优化)"""
# 将 PIL Image 转为 base64
buf = io.BytesIO()
init_image.save(buf, format="PNG")
init_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
enhanced_prompt = profile.get("prompt_prefix", "") + prompt + profile.get("prompt_suffix", "")
final_negative = negative_prompt if negative_prompt is not None else profile.get("negative_prompt", DEFAULT_NEGATIVE)
payload = {
"init_images": [init_b64],
"prompt": enhanced_prompt,
"negative_prompt": final_negative,
"prompt": prompt,
"negative_prompt": negative_prompt,
"denoising_strength": denoising_strength,
"steps": steps,
"cfg_scale": cfg_scale if cfg_scale is not None else preset["cfg_scale"],
"cfg_scale": cfg_scale,
"width": init_image.width,
"height": init_image.height,
"sampler_name": sampler_name if sampler_name is not None else preset["sampler_name"],
"scheduler": scheduler if scheduler is not None else preset["scheduler"],
"sampler_name": sampler_name,
"scheduler": scheduler,
}
resp = requests.post(
@ -864,8 +143,6 @@ class SDService:
images = []
for img_b64 in resp.json().get("images", []):
img = Image.open(io.BytesIO(base64.b64decode(img_b64)))
# 反 AI 检测后处理
img = anti_detect_postprocess(img)
images.append(img)
return images

BIN
zjz.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 MiB