diff --git a/config.json b/config.json index 41d0310..0b11c37 100644 --- a/config.json +++ b/config.json @@ -21,5 +21,5 @@ "base_url": "https://wolfai.top/v1" } ], - "xsec_token": "ABS1TagQqhCpZmeNlq0VoCfNEyI6Q83GJzjTGJvzEAq5I=" + "xsec_token": "ABdAEbqP9ScgelmyolJxsnpCr_e645SCpnub2dLZJc4Ck=" } \ No newline at end of file diff --git a/llm_service.py b/llm_service.py index 739f62f..5944869 100644 --- a/llm_service.py +++ b/llm_service.py @@ -26,7 +26,13 @@ PROMPT_COPYWRITING = """ 3. 结尾必须有 5 个以上相关话题标签(#)。 【绘图 Prompt】: -生成对应的 Stable Diffusion 英文提示词,强调:masterpiece, best quality, 8k, soft lighting, ins style。 +生成对应的 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", ...]} @@ -105,7 +111,11 @@ PROMPT_COPY_WITH_REFERENCE = """ 3. 结尾有 5 个以上话题标签(#)。 【绘图 Prompt】: -生成 Stable Diffusion 英文提示词。 +生成 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", ...]}} diff --git a/main.py b/main.py index 44ddb09..f6fc172 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,9 @@ import time import logging import platform import subprocess +import threading +import random +from datetime import datetime from PIL import Image import matplotlib import matplotlib.pyplot as plt @@ -881,6 +884,306 @@ def fetch_my_profile(user_id, xsec_token, mcp_url): return f"❌ {e}", "", None, None, None +# ================================================== +# 自动化运营模块 +# ================================================== + +# 自动化状态 +_auto_running = threading.Event() +_auto_thread: threading.Thread | None = None +_auto_log: list[str] = [] + +DEFAULT_TOPICS = [ + "春季穿搭", "通勤穿搭", "约会穿搭", "显瘦穿搭", "平价好物", + "护肤心得", "妆容教程", "好物分享", "生活好物", "减脂餐分享", + "居家好物", "收纳技巧", "咖啡探店", "书单推荐", "旅行攻略", +] + +DEFAULT_STYLES = ["好物种草", "干货教程", "情绪共鸣", "生活Vlog", "测评避雷"] + +DEFAULT_COMMENT_KEYWORDS = [ + "穿搭", "美食", "护肤", "好物推荐", "旅行", "生活日常", "减脂", +] + + +def _auto_log_append(msg: str): + """记录自动化日志""" + ts = datetime.now().strftime("%H:%M:%S") + entry = f"[{ts}] {msg}" + _auto_log.append(entry) + if len(_auto_log) > 500: + _auto_log[:] = _auto_log[-300:] + logger.info("[自动化] %s", msg) + + +def _auto_comment_with_log(keywords_str, mcp_url, model, persona_text): + """一键评论 + 同步刷新日志""" + msg = auto_comment_once(keywords_str, mcp_url, model, persona_text) + return msg, get_auto_log() + + +def auto_comment_once(keywords_str, mcp_url, model, persona_text): + """一键评论:自动搜索高赞笔记 → AI生成评论 → 发送""" + try: + keywords = [k.strip() for k in keywords_str.split(",") if k.strip()] if keywords_str else DEFAULT_COMMENT_KEYWORDS + keyword = random.choice(keywords) + _auto_log_append(f"🔍 搜索关键词: {keyword}") + + client = get_mcp_client(mcp_url) + + # 搜索高赞笔记 + entries = client.search_feeds_parsed(keyword, sort_by="最多点赞") + if not entries: + _auto_log_append("⚠️ 搜索无结果,尝试推荐列表") + entries = client.list_feeds_parsed() + if not entries: + return "❌ 未找到任何笔记" + + # 过滤掉自己的笔记 + my_uid = cfg.get("my_user_id", "") + if my_uid: + filtered = [e for e in entries if e.get("user_id") != my_uid] + if filtered: + entries = filtered + + # 从前10个中随机选择 + target = random.choice(entries[:min(10, len(entries))]) + feed_id = target["feed_id"] + xsec_token = target["xsec_token"] + title = target.get("title", "未知") + _auto_log_append(f"🎯 选中: {title[:30]} (@{target.get('author', '未知')})") + + if not feed_id or not xsec_token: + return "❌ 笔记缺少必要参数 (feed_id/xsec_token)" + + # 模拟浏览延迟 + time.sleep(random.uniform(2, 5)) + + # 加载笔记详情 + result = client.get_feed_detail(feed_id, xsec_token, load_all_comments=True) + if "error" in result: + return f"❌ 加载笔记失败: {result['error']}" + + full_text = result.get("text", "") + if "评论" in full_text: + parts = full_text.split("评论", 1) + content_part = parts[0].strip()[:600] + comments_part = ("评论" + parts[1])[:800] if len(parts) > 1 else "" + else: + content_part = full_text[:500] + comments_part = "" + + # AI 生成评论 + api_key, base_url, _ = _get_llm_config() + if not api_key: + return "❌ LLM 未配置,请先在全局设置中配置提供商" + + svc = LLMService(api_key, base_url, model) + comment = svc.generate_proactive_comment( + persona_text, title, content_part, comments_part + ) + _auto_log_append(f"💬 生成评论: {comment[:60]}...") + + # 随机等待后发送 + time.sleep(random.uniform(3, 8)) + result = client.post_comment(feed_id, xsec_token, comment) + resp_text = result.get("text", "") + _auto_log_append(f"📡 MCP 响应: {resp_text[:200]}") + + if "error" in result: + _auto_log_append(f"❌ 评论发送失败: {result['error']}") + return f"❌ 评论发送失败: {result['error']}" + + # 检查是否真正成功 + if "成功" not in resp_text and "success" not in resp_text.lower() and not resp_text: + _auto_log_append(f"⚠️ 评论可能未成功,MCP 原始响应: {result}") + return f"⚠️ 评论状态不确定,请手动检查\nMCP 响应: {resp_text[:300]}\n📝 评论: {comment}" + + _auto_log_append(f"✅ 评论已发送到「{title[:20]}」") + return f"✅ 已评论「{title[:25]}」\n📝 评论: {comment}\n\n💡 小红书可能有内容审核延迟,请稍等 1-2 分钟后查看" + + except Exception as e: + _auto_log_append(f"❌ 一键评论异常: {e}") + return f"❌ 评论失败: {e}" + + +def _auto_publish_with_log(topics_str, mcp_url, sd_url_val, sd_model_name, model): + """一键发布 + 同步刷新日志""" + msg = auto_publish_once(topics_str, mcp_url, sd_url_val, sd_model_name, model) + return msg, get_auto_log() + + +def auto_publish_once(topics_str, mcp_url, sd_url_val, sd_model_name, model): + """一键发布:自动生成文案 → 生成图片 → 发布到小红书""" + try: + topics = [t.strip() for t in topics_str.split(",") if t.strip()] if topics_str else DEFAULT_TOPICS + topic = random.choice(topics) + style = random.choice(DEFAULT_STYLES) + _auto_log_append(f"📝 主题: {topic} | 风格: {style}") + + # 生成文案 + api_key, base_url, _ = _get_llm_config() + if not api_key: + return "❌ LLM 未配置,请先在全局设置中配置提供商" + + svc = LLMService(api_key, base_url, model) + data = svc.generate_copy(topic, style) + title = (data.get("title", "") or "")[:20] + content = data.get("content", "") + sd_prompt = data.get("sd_prompt", "") + tags = data.get("tags", []) + + if not title: + return "❌ 文案生成失败:无标题" + _auto_log_append(f"📄 文案: {title}") + + # 生成图片 + if not sd_url_val or not sd_model_name: + return "❌ SD WebUI 未连接或未选择模型,请先在全局设置中连接" + + sd_svc = SDService(sd_url_val) + images = sd_svc.txt2img(prompt=sd_prompt, model=sd_model_name) + if not images: + return "❌ 图片生成失败:没有返回图片" + _auto_log_append(f"🎨 已生成 {len(images)} 张图片") + + # 保存图片到临时目录 + temp_dir = os.path.join(OUTPUT_DIR, "_temp_publish") + os.makedirs(temp_dir, exist_ok=True) + image_paths = [] + ts = int(time.time()) + for idx, img in enumerate(images): + if isinstance(img, Image.Image): + path = os.path.abspath(os.path.join(temp_dir, f"auto_{ts}_{idx}.png")) + img.save(path) + image_paths.append(path) + + if not image_paths: + return "❌ 图片保存失败" + + # 发布到小红书 + client = get_mcp_client(mcp_url) + result = client.publish_content( + title=title, content=content, images=image_paths, tags=tags + ) + if "error" in result: + _auto_log_append(f"❌ 发布失败: {result['error']}") + return f"❌ 发布失败: {result['error']}" + + _auto_log_append(f"🚀 发布成功: {title}") + return f"✅ 发布成功!\n📌 标题: {title}\n{result.get('text', '')}" + + except Exception as e: + _auto_log_append(f"❌ 一键发布异常: {e}") + return f"❌ 发布失败: {e}" + + +def _scheduler_loop(comment_enabled, publish_enabled, + comment_min, comment_max, publish_min, publish_max, + keywords, topics, mcp_url, sd_url_val, sd_model_name, + model, persona_text): + """后台定时调度循环""" + _auto_log_append("🤖 自动化调度器已启动") + + # 首次执行的随机延迟 + next_comment = time.time() + random.randint(10, 60) + next_publish = time.time() + random.randint(30, 120) + + while _auto_running.is_set(): + now = time.time() + + # 自动评论 + if comment_enabled and now >= next_comment: + try: + _auto_log_append("--- 🔄 执行自动评论 ---") + msg = auto_comment_once(keywords, mcp_url, model, persona_text) + _auto_log_append(msg) + except Exception as e: + _auto_log_append(f"❌ 自动评论异常: {e}") + interval = random.randint(int(comment_min) * 60, int(comment_max) * 60) + next_comment = time.time() + interval + _auto_log_append(f"⏰ 下次评论: {interval // 60} 分钟后") + + # 自动发布 + if publish_enabled and now >= next_publish: + try: + _auto_log_append("--- 🔄 执行自动发布 ---") + msg = auto_publish_once(topics, mcp_url, sd_url_val, sd_model_name, model) + _auto_log_append(msg) + except Exception as e: + _auto_log_append(f"❌ 自动发布异常: {e}") + interval = random.randint(int(publish_min) * 60, int(publish_max) * 60) + next_publish = time.time() + interval + _auto_log_append(f"⏰ 下次发布: {interval // 60} 分钟后") + + # 每5秒检查一次停止信号 + for _ in range(5): + if not _auto_running.is_set(): + break + time.sleep(1) + + _auto_log_append("🛑 自动化调度器已停止") + + +def start_scheduler(comment_on, publish_on, c_min, c_max, p_min, p_max, + keywords, topics, mcp_url, sd_url_val, sd_model_name, + model, persona_text): + """启动定时自动化""" + global _auto_thread + if _auto_running.is_set(): + return "⚠️ 调度器已在运行中,请先停止" + + if not comment_on and not publish_on: + return "❌ 请至少启用一项自动化功能(评论或发布)" + + api_key, _, _ = _get_llm_config() + if not api_key: + return "❌ LLM 未配置,请先在全局设置中配置提供商" + + _auto_running.set() + _auto_thread = threading.Thread( + target=_scheduler_loop, + args=(comment_on, publish_on, + c_min, c_max, p_min, p_max, + keywords, topics, mcp_url, sd_url_val, sd_model_name, + model, persona_text), + daemon=True, + ) + _auto_thread.start() + + parts = [] + if comment_on: + parts.append(f"评论 (每 {int(c_min)}-{int(c_max)} 分钟)") + if publish_on: + parts.append(f"发布 (每 {int(p_min)}-{int(p_max)} 分钟)") + + _auto_log_append(f"调度器已启动: {' + '.join(parts)}") + return f"✅ 自动化已启动 🟢\n任务: {' | '.join(parts)}\n\n💡 点击「刷新日志」查看实时进度" + + +def stop_scheduler(): + """停止定时自动化""" + if not _auto_running.is_set(): + return "⚠️ 调度器未在运行" + _auto_running.clear() + _auto_log_append("⏹️ 收到停止信号,等待当前任务完成...") + return "🛑 调度器停止中...当前任务完成后将完全停止" + + +def get_auto_log(): + """获取自动化运行日志""" + if not _auto_log: + return "📋 暂无日志\n\n💡 点击「一键评论」「一键发布」或启动定时后日志将在此显示" + return "\n".join(_auto_log[-80:]) + + +def get_scheduler_status(): + """获取调度器运行状态""" + if _auto_running.is_set(): + return "🟢 **调度器运行中**" + return "⚪ **调度器未运行**" + + # ================================================== # UI 构建 # ================================================== @@ -1294,6 +1597,101 @@ with gr.Blocks( label="笔记数据明细", ) + # -------- Tab 6: 自动运营 -------- + with gr.Tab("🤖 自动运营"): + gr.Markdown( + "### 🤖 无人值守自动化运营\n" + "> 一键评论引流 + 一键内容发布 + 随机定时全自动\n\n" + "⚠️ **注意**: 请确保已连接 LLM、SD WebUI 和 MCP 服务" + ) + + with gr.Row(): + # 左栏: 一键操作 + with gr.Column(scale=1): + gr.Markdown("#### 💬 一键智能评论") + gr.Markdown( + "> 自动搜索高赞笔记 → AI 分析内容 → 生成评论 → 发送\n" + "每次随机选关键词搜索,从结果中随机选笔记" + ) + auto_comment_keywords = gr.Textbox( + label="评论关键词池 (逗号分隔)", + value="穿搭, 美食, 护肤, 好物推荐, 旅行, 生活日常", + placeholder="关键词1, 关键词2, ...", + ) + btn_auto_comment = gr.Button( + "💬 一键评论 (单次)", variant="primary", size="lg", + ) + auto_comment_result = gr.Markdown("") + + gr.Markdown("---") + gr.Markdown("#### 🚀 一键智能发布") + gr.Markdown( + "> 随机选主题+风格 → AI 生成文案 → SD 生成图片 → 自动发布" + ) + auto_publish_topics = gr.Textbox( + label="主题池 (逗号分隔)", + value="春季穿搭, 通勤穿搭, 显瘦穿搭, 平价好物, 护肤心得, 好物分享", + placeholder="主题1, 主题2, ...", + ) + btn_auto_publish = gr.Button( + "🚀 一键发布 (单次)", variant="primary", size="lg", + ) + auto_publish_result = gr.Markdown("") + + # 右栏: 定时自动化 + with gr.Column(scale=1): + gr.Markdown("#### ⏰ 随机定时自动化") + gr.Markdown( + "> 设置时间间隔后启动,系统将在随机时间自动执行\n" + "> 模拟真人操作节奏,降低被检测风险" + ) + sched_status = gr.Markdown("⚪ **调度器未运行**") + + with gr.Group(): + sched_comment_on = gr.Checkbox( + label="✅ 启用自动评论", value=True, + ) + with gr.Row(): + sched_c_min = gr.Number( + label="评论最小间隔(分钟)", value=15, minimum=5, + ) + sched_c_max = gr.Number( + label="评论最大间隔(分钟)", value=45, minimum=10, + ) + + with gr.Group(): + sched_publish_on = gr.Checkbox( + label="✅ 启用自动发布", value=True, + ) + with gr.Row(): + sched_p_min = gr.Number( + label="发布最小间隔(分钟)", value=60, minimum=30, + ) + sched_p_max = gr.Number( + label="发布最大间隔(分钟)", value=180, minimum=60, + ) + + with gr.Row(): + btn_start_sched = gr.Button( + "▶️ 启动定时", variant="primary", size="lg", + ) + btn_stop_sched = gr.Button( + "⏹️ 停止定时", variant="stop", size="lg", + ) + sched_result = gr.Markdown("") + + gr.Markdown("---") + gr.Markdown("#### 📋 运行日志") + with gr.Row(): + btn_refresh_log = gr.Button("🔄 刷新日志", size="sm") + btn_clear_log = gr.Button("🗑️ 清空日志", size="sm") + auto_log_display = gr.TextArea( + label="自动化运行日志", + value="📋 暂无日志\n\n💡 执行操作后日志将在此显示", + lines=15, + interactive=False, + ) + # ================================================== # 事件绑定 # ================================================== @@ -1482,6 +1880,41 @@ with gr.Blocks( outputs=[data_status, profile_card, chart_interact, chart_notes, notes_detail], ) + # ---- Tab 6: 自动运营 ---- + btn_auto_comment.click( + fn=_auto_comment_with_log, + inputs=[auto_comment_keywords, mcp_url, llm_model, persona], + outputs=[auto_comment_result, auto_log_display], + ) + btn_auto_publish.click( + fn=_auto_publish_with_log, + inputs=[auto_publish_topics, mcp_url, sd_url, sd_model, llm_model], + outputs=[auto_publish_result, auto_log_display], + ) + btn_start_sched.click( + fn=start_scheduler, + inputs=[sched_comment_on, sched_publish_on, + sched_c_min, sched_c_max, sched_p_min, sched_p_max, + auto_comment_keywords, auto_publish_topics, + mcp_url, sd_url, sd_model, llm_model, persona], + outputs=[sched_result], + ) + btn_stop_sched.click( + fn=stop_scheduler, + inputs=[], + outputs=[sched_result], + ) + btn_refresh_log.click( + fn=lambda: (get_auto_log(), get_scheduler_status()), + inputs=[], + outputs=[auto_log_display, sched_status], + ) + btn_clear_log.click( + fn=lambda: (_auto_log.clear() or "📋 日志已清空"), + inputs=[], + outputs=[auto_log_display], + ) + # ---- 启动时自动刷新 SD ---- app.load(fn=connect_sd, inputs=[sd_url], outputs=[sd_model, status_bar]) diff --git a/sd_service.py b/sd_service.py index 750dc6a..6c7f8a3 100644 --- a/sd_service.py +++ b/sd_service.py @@ -10,13 +10,16 @@ from PIL import Image logger = logging.getLogger(__name__) -SD_TIMEOUT = 180 # 图片生成可能需要较长时间 +SD_TIMEOUT = 900 # 图片生成可能需要较长时间 -# 默认反向提示词 +# 默认反向提示词(针对 JuggernautXL / SDXL 优化) DEFAULT_NEGATIVE = ( - "nsfw, 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" + "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" ) @@ -61,14 +64,16 @@ class SDService: prompt: str, negative_prompt: str = DEFAULT_NEGATIVE, model: str = None, - steps: int = 25, - cfg_scale: float = 7.0, - width: int = 768, - height: int = 1024, + steps: int = 30, + cfg_scale: float = 5.0, + width: int = 832, + height: int = 1216, batch_size: int = 2, seed: int = -1, + sampler_name: str = "DPM++ 2M", + scheduler: str = "Karras", ) -> list[Image.Image]: - """文生图""" + """文生图(参数针对 JuggernautXL 优化)""" if model: self.switch_model(model) @@ -81,6 +86,8 @@ class SDService: "height": height, "batch_size": batch_size, "seed": seed, + "sampler_name": sampler_name, + "scheduler": scheduler, } resp = requests.post( @@ -101,11 +108,13 @@ class SDService: init_image: Image.Image, prompt: str, negative_prompt: str = DEFAULT_NEGATIVE, - denoising_strength: float = 0.6, - steps: int = 25, - cfg_scale: float = 7.0, + denoising_strength: float = 0.5, + steps: int = 30, + cfg_scale: float = 5.0, + sampler_name: str = "DPM++ 2M", + scheduler: str = "Karras", ) -> list[Image.Image]: - """图生图(参考图修改)""" + """图生图(参数针对 JuggernautXL 优化)""" # 将 PIL Image 转为 base64 buf = io.BytesIO() init_image.save(buf, format="PNG") @@ -120,6 +129,8 @@ class SDService: "cfg_scale": cfg_scale, "width": init_image.width, "height": init_image.height, + "sampler_name": sampler_name, + "scheduler": scheduler, } resp = requests.post(