Compare commits
No commits in common. "a75d6ea4225ba43432c7e4fba3cc6e1f7fbdc340" and "087d23f3fbb15ea88d7490504966c2721e6639b4" have entirely different histories.
a75d6ea422
...
087d23f3fb
105
CHANGELOG.md
105
CHANGELOG.md
@ -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
|
||||
|
||||
### 🚀 新功能
|
||||
|
||||
@ -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、img2img(JuggernautXL 优化)
|
||||
└─ 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
142
README.md
@ -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` | 你的小红书 userId(24 位十六进制) | 数据看板/自动回复需要 |
|
||||
| `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 款模型:majicmixRealistic(SD 1.5)、Realistic Vision(SD 1.5)、Juggernaut XL(SDXL)。系统会自动检测当前模型并匹配最佳参数。未知模型自动回退到 SDXL 默认档案。
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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)}"
|
||||
BIN
beauty.png
BIN
beauty.png
Binary file not shown.
|
Before Width: | Height: | Size: 6.5 MiB |
@ -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="
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
814
llm_service.py
814
llm_service.py
@ -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()
|
||||
|
||||
118
mcp_client.py
118
mcp_client.py
@ -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],
|
||||
|
||||
BIN
my_face.png
BIN
my_face.png
Binary file not shown.
|
Before Width: | Height: | Size: 5.4 MiB |
BIN
myself.jpg
BIN
myself.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB |
583
publish_queue.py
583
publish_queue.py
@ -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}"
|
||||
793
sd_service.py
793
sd_service.py
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user