|
|
@@ -335,20 +335,67 @@ class DouyinPublisher(BasePublisher):
|
|
|
# 检查当前 URL 和页面状态
|
|
|
current_url = self.page.url
|
|
|
print(f"[{self.platform_name}] 当前 URL: {current_url}")
|
|
|
+
|
|
|
+ async def wait_for_manual_login(timeout_seconds: int = 300) -> bool:
|
|
|
+ if not self.page:
|
|
|
+ return False
|
|
|
+ self.report_progress(8, "检测到需要登录,请在浏览器窗口完成登录...")
|
|
|
+ try:
|
|
|
+ await self.page.bring_to_front()
|
|
|
+ except:
|
|
|
+ pass
|
|
|
+ waited = 0
|
|
|
+ while waited < timeout_seconds:
|
|
|
+ try:
|
|
|
+ url = self.page.url
|
|
|
+ if "login" not in url and "passport" not in url:
|
|
|
+ if "creator.douyin.com" in url:
|
|
|
+ return True
|
|
|
+ await asyncio.sleep(2)
|
|
|
+ waited += 2
|
|
|
+ except:
|
|
|
+ await asyncio.sleep(2)
|
|
|
+ waited += 2
|
|
|
+ return False
|
|
|
|
|
|
# 检查是否在登录页面或需要登录
|
|
|
if "login" in current_url or "passport" in current_url:
|
|
|
- screenshot_base64 = await self.capture_screenshot()
|
|
|
- return PublishResult(
|
|
|
- success=False,
|
|
|
- platform=self.platform_name,
|
|
|
- error="Cookie 已过期,需要重新登录",
|
|
|
- need_captcha=True,
|
|
|
- captcha_type='login',
|
|
|
- screenshot_base64=screenshot_base64,
|
|
|
- page_url=current_url,
|
|
|
- status='need_captcha'
|
|
|
- )
|
|
|
+ if not self.headless:
|
|
|
+ logged_in = await wait_for_manual_login()
|
|
|
+ if logged_in:
|
|
|
+ try:
|
|
|
+ if self.context:
|
|
|
+ cookies_after = await self.context.cookies()
|
|
|
+ await self.sync_cookies_to_node(cookies_after)
|
|
|
+ except:
|
|
|
+ pass
|
|
|
+ await self.page.goto("https://creator.douyin.com/creator-micro/content/upload")
|
|
|
+ await asyncio.sleep(3)
|
|
|
+ current_url = self.page.url
|
|
|
+ else:
|
|
|
+ screenshot_base64 = await self.capture_screenshot()
|
|
|
+ return PublishResult(
|
|
|
+ success=False,
|
|
|
+ platform=self.platform_name,
|
|
|
+ error="需要登录:请在浏览器窗口完成登录后重试",
|
|
|
+ need_captcha=True,
|
|
|
+ captcha_type='login',
|
|
|
+ screenshot_base64=screenshot_base64,
|
|
|
+ page_url=current_url,
|
|
|
+ status='need_captcha'
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ screenshot_base64 = await self.capture_screenshot()
|
|
|
+ return PublishResult(
|
|
|
+ success=False,
|
|
|
+ platform=self.platform_name,
|
|
|
+ error="Cookie 已过期,需要重新登录",
|
|
|
+ need_captcha=True,
|
|
|
+ captcha_type='login',
|
|
|
+ screenshot_base64=screenshot_base64,
|
|
|
+ page_url=current_url,
|
|
|
+ status='need_captcha'
|
|
|
+ )
|
|
|
|
|
|
# 使用 AI 检测验证码
|
|
|
ai_captcha_result = await self.ai_check_captcha()
|
|
|
@@ -1070,3 +1117,330 @@ class DouyinPublisher(BasePublisher):
|
|
|
'work_comments': all_work_comments,
|
|
|
'total': len(all_work_comments)
|
|
|
}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ async def auto_reply_private_messages(self, cookies: str) -> dict:
|
|
|
+ """自动回复抖音私信 - 适配新页面结构"""
|
|
|
+ print(f"\n{'='*60}")
|
|
|
+ print(f"[{self.platform_name}] 开始自动回复抖音私信")
|
|
|
+ print(f"{'='*60}")
|
|
|
+ try:
|
|
|
+ await self.init_browser()
|
|
|
+ cookie_list = self.parse_cookies(cookies)
|
|
|
+ await self.set_cookies(cookie_list)
|
|
|
+ if not self.page:
|
|
|
+ raise Exception("Page not initialized")
|
|
|
+
|
|
|
+ # 访问抖音私信页面
|
|
|
+ await self.page.goto("https://creator.douyin.com/creator-micro/data/following/chat", timeout=30000)
|
|
|
+ await asyncio.sleep(3)
|
|
|
+
|
|
|
+ # 检查登录状态
|
|
|
+ current_url = self.page.url
|
|
|
+ print(f"[{self.platform_name}] 当前 URL: {current_url}")
|
|
|
+ if "login" in current_url or "passport" in current_url:
|
|
|
+ raise Exception("Cookie 已过期,请重新登录")
|
|
|
+
|
|
|
+ replied_count = 0
|
|
|
+
|
|
|
+ # 处理两个tab: 陌生人私信 和 朋友私信
|
|
|
+ for tab_name in ["陌生人私信", "朋友私信"]:
|
|
|
+ print(f"\n{'='*50}")
|
|
|
+ print(f"[{self.platform_name}] 处理 {tab_name} ...")
|
|
|
+ print(f"{'='*50}")
|
|
|
+
|
|
|
+ # 点击对应tab
|
|
|
+ tab_locator = self.page.locator(f'div.semi-tabs-tab:text-is("{tab_name}")')
|
|
|
+ if await tab_locator.count() > 0:
|
|
|
+ await tab_locator.click()
|
|
|
+ await asyncio.sleep(2)
|
|
|
+ else:
|
|
|
+ print(f"⚠️ 未找到 {tab_name} 标签,跳过")
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 获取私信列表
|
|
|
+ session_items = self.page.locator('.semi-list-item')
|
|
|
+ session_count = await session_items.count()
|
|
|
+ print(f"[{self.platform_name}] {tab_name} 共找到 {session_count} 条会话")
|
|
|
+
|
|
|
+ if session_count == 0:
|
|
|
+ print(f"[{self.platform_name}] {tab_name} 无新私信")
|
|
|
+ continue
|
|
|
+
|
|
|
+ for idx in range(session_count):
|
|
|
+ try:
|
|
|
+ # 重新获取列表(防止 DOM 变化)
|
|
|
+ current_sessions = self.page.locator('.semi-list-item')
|
|
|
+ if idx >= await current_sessions.count():
|
|
|
+ break
|
|
|
+
|
|
|
+ session = current_sessions.nth(idx)
|
|
|
+ user_name = await session.locator('.item-header-name-vL_79m').inner_text()
|
|
|
+ last_msg = await session.locator('.text-whxV9A').inner_text()
|
|
|
+ print(f"\n ➤ [{idx+1}/{session_count}] 处理用户: {user_name} | 最后消息: {last_msg[:30]}...")
|
|
|
+
|
|
|
+ # 检查会话预览消息是否包含非文字内容
|
|
|
+ if "分享" in last_msg and ("视频" in last_msg or "图片" in last_msg or "链接" in last_msg):
|
|
|
+ print(" ➤ 会话预览为非文字消息,跳过")
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 点击进入聊天
|
|
|
+ await session.click()
|
|
|
+ await asyncio.sleep(2)
|
|
|
+
|
|
|
+ # 提取聊天历史(判断最后一条是否是自己发的)
|
|
|
+ chat_messages = self.page.locator('.box-item-dSA1TJ:not(.time-Za5gKL)')
|
|
|
+ msg_count = await chat_messages.count()
|
|
|
+ should_reply = True
|
|
|
+
|
|
|
+ if msg_count > 0:
|
|
|
+ # 最后一条消息
|
|
|
+ last_msg_el = chat_messages.nth(msg_count - 1)
|
|
|
+ # 获取元素的 class 属性判断是否是自己发的
|
|
|
+ classes = await last_msg_el.get_attribute('class') or ''
|
|
|
+ is_my_message = 'is-me-' in classes # 包含 is-me- 表示是自己发的
|
|
|
+ should_reply = not is_my_message # 如果是自己发的就不回复
|
|
|
+
|
|
|
+ if should_reply:
|
|
|
+ # 提取完整聊天历史
|
|
|
+ chat_history = await self._extract_chat_history()
|
|
|
+
|
|
|
+ if chat_history:
|
|
|
+ # 生成回复
|
|
|
+ reply_text = await self._generate_reply_with_ai(chat_history)
|
|
|
+ if not reply_text:
|
|
|
+ reply_text = self._generate_reply(chat_history)
|
|
|
+
|
|
|
+ if reply_text:
|
|
|
+ print(f" 📝 回复内容: {reply_text}")
|
|
|
+
|
|
|
+ # 填充输入框
|
|
|
+ input_box = self.page.locator('div.chat-input-dccKiL[contenteditable="true"]')
|
|
|
+ send_btn = self.page.locator('button:has-text("发送")')
|
|
|
+
|
|
|
+ if await input_box.is_visible() and await send_btn.is_visible():
|
|
|
+ await input_box.fill(reply_text)
|
|
|
+ await asyncio.sleep(0.5)
|
|
|
+ await send_btn.click()
|
|
|
+ print(" ✅ 已发送")
|
|
|
+ replied_count += 1
|
|
|
+ await asyncio.sleep(2)
|
|
|
+ else:
|
|
|
+ print(" ❌ 输入框或发送按钮不可见")
|
|
|
+ else:
|
|
|
+ print(" ➤ 无需回复")
|
|
|
+ else:
|
|
|
+ print(" ➤ 聊天历史为空,跳过")
|
|
|
+ else:
|
|
|
+ print(" ➤ 最后一条是我发的,跳过")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ print(f" ❌ 处理会话 {idx+1} 时出错: {e}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ print(f"[{self.platform_name}] 自动回复完成,共回复 {replied_count} 条消息")
|
|
|
+ return {
|
|
|
+ 'success': True,
|
|
|
+ 'platform': self.platform_name,
|
|
|
+ 'replied_count': replied_count,
|
|
|
+ 'message': f'成功回复 {replied_count} 条私信'
|
|
|
+ }
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ import traceback
|
|
|
+ traceback.print_exc()
|
|
|
+ return {
|
|
|
+ 'success': False,
|
|
|
+ 'platform': self.platform_name,
|
|
|
+ 'error': str(e)
|
|
|
+ }
|
|
|
+ finally:
|
|
|
+ await self.close_browser()
|
|
|
+
|
|
|
+
|
|
|
+ # 辅助方法保持兼容(可复用)
|
|
|
+ def _generate_reply(self, chat_history: list) -> str:
|
|
|
+ """规则回复"""
|
|
|
+ if not chat_history:
|
|
|
+ return "你好!感谢联系~"
|
|
|
+ last_msg = chat_history[-1]["content"]
|
|
|
+ if "谢谢" in last_msg or "感谢" in last_msg:
|
|
|
+ return "不客气!欢迎常来交流~"
|
|
|
+ elif "你好" in last_msg or "在吗" in last_msg:
|
|
|
+ return "你好!请问有什么可以帮您的?"
|
|
|
+ elif "视频" in last_msg or "怎么拍" in last_msg:
|
|
|
+ return "视频是用手机拍摄的,注意光线和稳定哦!"
|
|
|
+ else:
|
|
|
+ return "收到!我会认真阅读您的留言~"
|
|
|
+
|
|
|
+ async def _extract_chat_history(self) -> list:
|
|
|
+ """精准提取聊天记录,区分作者(自己)和用户"""
|
|
|
+ if not self.page:
|
|
|
+ return []
|
|
|
+
|
|
|
+ history = []
|
|
|
+ # 获取所有聊天消息(排除时间戳元素)
|
|
|
+ message_wrappers = self.page.locator('.box-item-dSA1TJ:not(.time-Za5gKL)')
|
|
|
+ count = await message_wrappers.count()
|
|
|
+
|
|
|
+ for i in range(count):
|
|
|
+ try:
|
|
|
+ wrapper = message_wrappers.nth(i)
|
|
|
+ # 检查是否为自己发送的消息
|
|
|
+ classes = await wrapper.get_attribute('class') or ''
|
|
|
+ is_author = 'is-me-' in classes # 包含 is-me- 表示是自己发的
|
|
|
+
|
|
|
+ # 获取消息文本内容
|
|
|
+ text_element = wrapper.locator('.text-X2d7fS')
|
|
|
+ if await text_element.count() > 0:
|
|
|
+ content = await text_element.inner_text()
|
|
|
+ content = content.strip()
|
|
|
+
|
|
|
+ if content: # 只添加非空消息
|
|
|
+ # 获取用户名(如果是对方消息)
|
|
|
+ author_name = ''
|
|
|
+ if not is_author:
|
|
|
+ # 尝试获取对方用户名
|
|
|
+ name_elements = wrapper.locator('.aweme-author-name-m8uoXU')
|
|
|
+ if await name_elements.count() > 0:
|
|
|
+ author_name = await name_elements.nth(0).inner_text()
|
|
|
+ else:
|
|
|
+ author_name = '用户'
|
|
|
+ else:
|
|
|
+ author_name = '我'
|
|
|
+
|
|
|
+ history.append({
|
|
|
+ "author": author_name,
|
|
|
+ "content": content,
|
|
|
+ "is_author": is_author,
|
|
|
+ })
|
|
|
+ except Exception as e:
|
|
|
+ print(f" ⚠️ 解析第 {i+1} 条消息失败: {e}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ return history
|
|
|
+
|
|
|
+ async def _generate_reply_with_ai(self, chat_history: list) -> str:
|
|
|
+ """使用 AI 生成回复(保留原逻辑)"""
|
|
|
+ import os, requests, json
|
|
|
+ try:
|
|
|
+ ai_api_key = os.environ.get('DASHSCOPE_API_KEY', '')
|
|
|
+ ai_base_url = os.environ.get('DASHSCOPE_BASE_URL', 'https://dashscope.aliyuncs.com/compatible-mode/v1')
|
|
|
+ ai_model = os.environ.get('AI_MODEL', 'qwen-plus')
|
|
|
+ if not ai_api_key:
|
|
|
+ return self._generate_reply(chat_history)
|
|
|
+
|
|
|
+ messages = [{"role": "system", "content": "你是一个友好的抖音创作者助手,负责回复粉丝私信。请保持简洁、友好、专业的语气。回复长度不超过20字。"}]
|
|
|
+ for msg in chat_history:
|
|
|
+ role = "assistant" if msg.get("is_author", False) else "user"
|
|
|
+ messages.append({"role": role, "content": msg["content"]})
|
|
|
+
|
|
|
+ headers = {'Authorization': f'Bearer {ai_api_key}', 'Content-Type': 'application/json'}
|
|
|
+ payload = {"model": ai_model, "messages": messages, "max_tokens": 150, "temperature": 0.8}
|
|
|
+ response = requests.post(f"{ai_base_url}/chat/completions", headers=headers, json=payload, timeout=30)
|
|
|
+ if response.status_code == 200:
|
|
|
+ ai_reply = response.json().get('choices', [{}])[0].get('message', {}).get('content', '').strip()
|
|
|
+ return ai_reply if ai_reply else self._generate_reply(chat_history)
|
|
|
+ else:
|
|
|
+ return self._generate_reply(chat_history)
|
|
|
+ except:
|
|
|
+ return self._generate_reply(chat_history)
|
|
|
+
|
|
|
+
|
|
|
+ async def get_work_comments_mapping(self, cookies: str) -> dict:
|
|
|
+ """获取所有作品及其评论的对应关系
|
|
|
+
|
|
|
+ Args:
|
|
|
+ cookies: 抖音创作者平台的cookies
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict: 包含作品和评论对应关系的JSON数据
|
|
|
+ """
|
|
|
+ print(f"\n{'='*60}")
|
|
|
+ print(f"[{self.platform_name}] 获取作品和评论对应关系")
|
|
|
+ print(f"{'='*60}")
|
|
|
+
|
|
|
+ work_comments_mapping = []
|
|
|
+
|
|
|
+ try:
|
|
|
+ await self.init_browser()
|
|
|
+ cookie_list = self.parse_cookies(cookies)
|
|
|
+ await self.set_cookies(cookie_list)
|
|
|
+
|
|
|
+ if not self.page:
|
|
|
+ raise Exception("Page not initialized")
|
|
|
+
|
|
|
+ # 访问创作者中心首页
|
|
|
+ await self.page.goto("https://creator.douyin.com/creator-micro/home", timeout=30000)
|
|
|
+ await asyncio.sleep(3)
|
|
|
+
|
|
|
+ # 检查登录状态
|
|
|
+ current_url = self.page.url
|
|
|
+ if "login" in current_url or "passport" in current_url:
|
|
|
+ raise Exception("Cookie 已过期,请重新登录")
|
|
|
+
|
|
|
+ # 访问内容管理页面获取作品列表
|
|
|
+ print(f"[{self.platform_name}] 访问内容管理页面...")
|
|
|
+ await self.page.goto("https://creator.douyin.com/creator-micro/content/manage", timeout=30000)
|
|
|
+ await asyncio.sleep(5)
|
|
|
+
|
|
|
+ # 获取作品列表
|
|
|
+ works_result = await self.get_works(cookies, page=0, page_size=20)
|
|
|
+ if not works_result.success:
|
|
|
+ print(f"[{self.platform_name}] 获取作品列表失败: {works_result.error}")
|
|
|
+ return {
|
|
|
+ 'success': False,
|
|
|
+ 'platform': self.platform_name,
|
|
|
+ 'error': works_result.error,
|
|
|
+ 'work_comments': []
|
|
|
+ }
|
|
|
+
|
|
|
+ print(f"[{self.platform_name}] 获取到 {len(works_result.works)} 个作品")
|
|
|
+
|
|
|
+ # 对每个作品获取评论
|
|
|
+ for i, work in enumerate(works_result.works):
|
|
|
+ print(f"[{self.platform_name}] 正在获取作品 {i+1}/{len(works_result.works)} 的评论: {work.title[:20]}...")
|
|
|
+
|
|
|
+ # 获取单个作品的评论
|
|
|
+ comments_result = await self.get_comments(cookies, work.work_id)
|
|
|
+ if comments_result.success:
|
|
|
+ work_comments_mapping.append({
|
|
|
+ 'work_info': work.to_dict(),
|
|
|
+ 'comments': [comment.to_dict() for comment in comments_result.comments]
|
|
|
+ })
|
|
|
+ print(f"[{self.platform_name}] 作品 '{work.title[:20]}...' 获取到 {len(comments_result.comments)} 条评论")
|
|
|
+ else:
|
|
|
+ print(f"[{self.platform_name}] 获取作品 '{work.title[:20]}...' 评论失败: {comments_result.error}")
|
|
|
+ work_comments_mapping.append({
|
|
|
+ 'work_info': work.to_dict(),
|
|
|
+ 'comments': [],
|
|
|
+ 'error': comments_result.error
|
|
|
+ })
|
|
|
+
|
|
|
+ # 添加延时避免请求过于频繁
|
|
|
+ await asyncio.sleep(2)
|
|
|
+
|
|
|
+ print(f"[{self.platform_name}] 所有作品评论获取完成")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ import traceback
|
|
|
+ traceback.print_exc()
|
|
|
+ return {
|
|
|
+ 'success': False,
|
|
|
+ 'platform': self.platform_name,
|
|
|
+ 'error': str(e),
|
|
|
+ 'work_comments': []
|
|
|
+ }
|
|
|
+ finally:
|
|
|
+ await self.close_browser()
|
|
|
+
|
|
|
+ return {
|
|
|
+ 'success': True,
|
|
|
+ 'platform': self.platform_name,
|
|
|
+ 'work_comments': work_comments_mapping,
|
|
|
+ 'summary': {
|
|
|
+ 'total_works': len(work_comments_mapping),
|
|
|
+ 'total_comments': sum(len(item['comments']) for item in work_comments_mapping),
|
|
|
+ }
|
|
|
+ }
|