|
|
@@ -62,7 +62,10 @@ class XiaohongshuPublisher(BasePublisher):
|
|
|
|
|
|
try:
|
|
|
async with async_playwright() as playwright:
|
|
|
- browser = await playwright.chromium.launch(headless=True)
|
|
|
+ try:
|
|
|
+ browser = await playwright.chromium.launch(headless=True, channel="chrome")
|
|
|
+ except Exception:
|
|
|
+ browser = await playwright.chromium.launch(headless=True)
|
|
|
browser_context = await browser.new_context()
|
|
|
|
|
|
if STEALTH_JS_PATH.exists():
|
|
|
@@ -488,47 +491,128 @@ class XiaohongshuPublisher(BasePublisher):
|
|
|
|
|
|
self.report_progress(20, "正在上传视频...")
|
|
|
|
|
|
+ # 验证视频文件存在
|
|
|
+ if not os.path.isfile(params.video_path):
|
|
|
+ return PublishResult(
|
|
|
+ success=False,
|
|
|
+ platform=self.platform_name,
|
|
|
+ error=f"视频文件不存在: {params.video_path}",
|
|
|
+ status="failed",
|
|
|
+ )
|
|
|
+ file_size_mb = os.path.getsize(params.video_path) / (1024 * 1024)
|
|
|
+ print(f"[{self.platform_name}] 视频文件: {params.video_path} ({file_size_mb:.1f} MB)")
|
|
|
+
|
|
|
# 等待页面加载
|
|
|
await asyncio.sleep(2)
|
|
|
|
|
|
- # 上传视频
|
|
|
+ # 打印页面结构辅助调试
|
|
|
+ try:
|
|
|
+ file_inputs = self.page.locator('input[type="file"]')
|
|
|
+ input_count = await file_inputs.count()
|
|
|
+ print(f"[{self.platform_name}] 页面 file input 数量: {input_count}")
|
|
|
+ for i in range(input_count):
|
|
|
+ accept = await file_inputs.nth(i).get_attribute("accept") or ""
|
|
|
+ print(f"[{self.platform_name}] input[{i}] accept='{accept}'")
|
|
|
+ all_btns = self.page.locator("button")
|
|
|
+ btn_count = await all_btns.count()
|
|
|
+ print(f"[{self.platform_name}] 页面 button 数量: {btn_count}")
|
|
|
+ for i in range(min(btn_count, 15)):
|
|
|
+ text = (await all_btns.nth(i).text_content() or "").strip()
|
|
|
+ vis = await all_btns.nth(i).is_visible()
|
|
|
+ if text:
|
|
|
+ print(f"[{self.platform_name}] button[{i}] text='{text[:30]}' visible={vis}")
|
|
|
+ except Exception as e:
|
|
|
+ print(f"[{self.platform_name}] 页面结构诊断失败: {e}")
|
|
|
+
|
|
|
upload_triggered = False
|
|
|
|
|
|
- # 方法1: 直接设置隐藏的 file input
|
|
|
- print(f"[{self.platform_name}] 尝试方法1: 设置 file input")
|
|
|
- file_inputs = self.page.locator('input[type="file"]')
|
|
|
- input_count = await file_inputs.count()
|
|
|
- print(f"[{self.platform_name}] 找到 {input_count} 个 file input")
|
|
|
+ # 辅助函数:点击元素并通过 file_chooser 上传
|
|
|
+ async def _click_and_upload(locator, label: str) -> bool:
|
|
|
+ try:
|
|
|
+ if await locator.count() == 0 or not await locator.is_visible():
|
|
|
+ return False
|
|
|
+ print(f"[{self.platform_name}] 尝试点击: {label}")
|
|
|
+ async with self.page.expect_file_chooser(timeout=10000) as fc_info:
|
|
|
+ await locator.click()
|
|
|
+ file_chooser = await fc_info.value
|
|
|
+ await file_chooser.set_files(params.video_path)
|
|
|
+ print(f"[{self.platform_name}] 通过 {label} 上传成功")
|
|
|
+ return True
|
|
|
+ except Exception as e:
|
|
|
+ print(f"[{self.platform_name}] {label} 失败: {e}")
|
|
|
+ return False
|
|
|
|
|
|
- if input_count > 0:
|
|
|
- # 找到接受视频的 input
|
|
|
- for i in range(input_count):
|
|
|
- input_el = file_inputs.nth(i)
|
|
|
- accept = await input_el.get_attribute("accept") or ""
|
|
|
- print(f"[{self.platform_name}] Input {i} accept: {accept}")
|
|
|
- if "video" in accept or "*" in accept or not accept:
|
|
|
- await input_el.set_input_files(params.video_path)
|
|
|
- upload_triggered = True
|
|
|
- print(f"[{self.platform_name}] 视频文件已设置到 input {i}")
|
|
|
- break
|
|
|
+ # 方法1: 点击页面上的"上传视频"红色按钮或上传区域(最可靠)
|
|
|
+ print(f"[{self.platform_name}] 方法1: 点击上传按钮/区域")
|
|
|
+ click_selectors = [
|
|
|
+ # 红色"上传视频"按钮(排除顶部 tab)
|
|
|
+ ('button:has-text("上传视频")', "上传视频 button"),
|
|
|
+ ('[class*="upload-btn"]:has-text("上传")', "upload-btn"),
|
|
|
+ ('[class*="btn"]:has-text("上传视频")', "btn 上传视频"),
|
|
|
+ # 上传拖拽区域
|
|
|
+ ('[class*="drag"]', "拖拽区域"),
|
|
|
+ ('[class*="upload-area"]', "upload-area"),
|
|
|
+ ('[class*="upload-wrapper"]', "upload-wrapper"),
|
|
|
+ ('[class*="upload-container"]', "upload-container"),
|
|
|
+ ]
|
|
|
+ for sel, label in click_selectors:
|
|
|
+ el = self.page.locator(sel).first
|
|
|
+ if await _click_and_upload(el, f"方法1-{label}"):
|
|
|
+ upload_triggered = True
|
|
|
+ break
|
|
|
|
|
|
- # 方法2: 点击上传区域触发文件选择器
|
|
|
+ # 方法2: 遍历所有按钮,精确匹配文本"上传视频"
|
|
|
if not upload_triggered:
|
|
|
- print(f"[{self.platform_name}] 尝试方法2: 点击上传区域")
|
|
|
+ print(f"[{self.platform_name}] 方法2: 遍历按钮匹配文本")
|
|
|
try:
|
|
|
- upload_area = self.page.locator(
|
|
|
- '[class*="upload-wrapper"], [class*="upload-area"], .upload-input'
|
|
|
- ).first
|
|
|
- if await upload_area.count() > 0:
|
|
|
- async with self.page.expect_file_chooser(timeout=5000) as fc_info:
|
|
|
- await upload_area.click()
|
|
|
- file_chooser = await fc_info.value
|
|
|
- await file_chooser.set_files(params.video_path)
|
|
|
- upload_triggered = True
|
|
|
- print(f"[{self.platform_name}] 通过点击上传区域上传成功")
|
|
|
+ all_btns = self.page.locator("button")
|
|
|
+ count = await all_btns.count()
|
|
|
+ for i in range(count):
|
|
|
+ btn = all_btns.nth(i)
|
|
|
+ try:
|
|
|
+ text = (await btn.text_content() or "").strip()
|
|
|
+ if "上传视频" in text and await btn.is_visible():
|
|
|
+ if await _click_and_upload(btn, f"方法2-button[{i}]({text})"):
|
|
|
+ upload_triggered = True
|
|
|
+ break
|
|
|
+ except:
|
|
|
+ continue
|
|
|
except Exception as e:
|
|
|
print(f"[{self.platform_name}] 方法2失败: {e}")
|
|
|
|
|
|
+ # 方法3: 直接 set_input_files(某些页面有效)
|
|
|
+ if not upload_triggered:
|
|
|
+ print(f"[{self.platform_name}] 方法3: 直接 set_input_files")
|
|
|
+ try:
|
|
|
+ file_inputs = self.page.locator('input[type="file"]')
|
|
|
+ input_count = await file_inputs.count()
|
|
|
+ for i in range(input_count):
|
|
|
+ input_el = file_inputs.nth(i)
|
|
|
+ accept = await input_el.get_attribute("accept") or ""
|
|
|
+ if "video" in accept or "*" in accept or not accept:
|
|
|
+ await input_el.set_input_files(params.video_path)
|
|
|
+ upload_triggered = True
|
|
|
+ print(f"[{self.platform_name}] set_input_files 到 input[{i}] accept='{accept}'")
|
|
|
+ break
|
|
|
+ except Exception as e:
|
|
|
+ print(f"[{self.platform_name}] 方法3失败: {e}")
|
|
|
+
|
|
|
+ # 方法4: AI 识别上传按钮
|
|
|
+ if not upload_triggered:
|
|
|
+ print(f"[{self.platform_name}] 方法4: AI 识别上传按钮")
|
|
|
+ try:
|
|
|
+ suggest = await self.ai_suggest_playwright_selector(
|
|
|
+ "点击小红书视频上传页面中间区域的红色'上传视频'按钮(不是顶部的'上传视频'标签页),该按钮用于打开文件选择器选择视频文件"
|
|
|
+ )
|
|
|
+ if suggest.get("has_selector") and suggest.get("selector"):
|
|
|
+ sel = suggest["selector"]
|
|
|
+ print(f"[{self.platform_name}] AI 建议选择器: {sel} (置信度: {suggest.get('confidence')})")
|
|
|
+ el = self.page.locator(sel).first
|
|
|
+ if await _click_and_upload(el, f"方法4-AI({sel})"):
|
|
|
+ upload_triggered = True
|
|
|
+ except Exception as e:
|
|
|
+ print(f"[{self.platform_name}] 方法4失败: {e}")
|
|
|
+
|
|
|
if not upload_triggered:
|
|
|
screenshot_base64 = await self.capture_screenshot()
|
|
|
page_url = await self.get_page_url()
|
|
|
@@ -544,40 +628,184 @@ class XiaohongshuPublisher(BasePublisher):
|
|
|
self.report_progress(40, "等待视频上传完成...")
|
|
|
print(f"[{self.platform_name}] 等待视频上传和处理...")
|
|
|
|
|
|
- # 等待上传完成(检测页面变化)
|
|
|
+ # 辅助:检查页面是否处于上传选择页(即跳回了)
|
|
|
+ async def _is_on_upload_page() -> bool:
|
|
|
+ """检测页面是否回到了视频选择/上传页面"""
|
|
|
+ try:
|
|
|
+ drag_area = await self.page.locator('[class*="drag"], [class*="upload-area"], [class*="upload-wrapper"]').count()
|
|
|
+ upload_btn_visible = False
|
|
|
+ for sel in ['span.d-text:has-text("上传视频")', 'button:has-text("上传视频")']:
|
|
|
+ loc = self.page.locator(sel).first
|
|
|
+ if await loc.count() > 0:
|
|
|
+ try:
|
|
|
+ if await loc.is_visible():
|
|
|
+ upload_btn_visible = True
|
|
|
+ break
|
|
|
+ except:
|
|
|
+ pass
|
|
|
+ title_count = await self.page.locator('input[placeholder*="标题"], input[placeholder*="填写标题"]').count()
|
|
|
+ if (drag_area > 0 or upload_btn_visible) and title_count == 0:
|
|
|
+ return True
|
|
|
+ except:
|
|
|
+ pass
|
|
|
+ return False
|
|
|
+
|
|
|
+ # 辅助:抓取页面上的 toast / 错误提示
|
|
|
+ async def _get_page_error() -> str:
|
|
|
+ """尝试获取页面上的错误提示文本"""
|
|
|
+ error_selectors = [
|
|
|
+ '.toast', '.el-message', '[class*="toast"]', '[class*="Toast"]',
|
|
|
+ '[class*="message"]', '[class*="Message"]', '[class*="notice"]',
|
|
|
+ '[class*="error"]', '[class*="Error"]', '[class*="alert"]',
|
|
|
+ '[class*="tip"]', '[class*="warn"]', '.ant-message',
|
|
|
+ '[role="alert"]', '[role="status"]',
|
|
|
+ ]
|
|
|
+ errors = []
|
|
|
+ for sel in error_selectors:
|
|
|
+ try:
|
|
|
+ els = self.page.locator(sel)
|
|
|
+ count = await els.count()
|
|
|
+ for j in range(min(count, 5)):
|
|
|
+ el = els.nth(j)
|
|
|
+ if await el.is_visible():
|
|
|
+ text = (await el.text_content() or "").strip()
|
|
|
+ if text and len(text) < 200:
|
|
|
+ errors.append(text)
|
|
|
+ except:
|
|
|
+ continue
|
|
|
+ return "; ".join(errors[:3]) if errors else ""
|
|
|
+
|
|
|
+ # 辅助:执行一次上传操作(用于重试)
|
|
|
+ async def _do_upload() -> bool:
|
|
|
+ """触发文件上传,返回是否成功触发"""
|
|
|
+ for sel, label in [
|
|
|
+ ('[class*="drag"]', "drag-area"),
|
|
|
+ ('span.d-text:has-text("上传视频")', "span上传视频"),
|
|
|
+ ('button:has-text("上传视频")', "btn上传视频"),
|
|
|
+ ]:
|
|
|
+ try:
|
|
|
+ el = self.page.locator(sel).first
|
|
|
+ if await el.count() > 0 and await el.is_visible():
|
|
|
+ async with self.page.expect_file_chooser(timeout=8000) as fc_info:
|
|
|
+ await el.click()
|
|
|
+ fc = await fc_info.value
|
|
|
+ await fc.set_files(params.video_path)
|
|
|
+ print(f"[{self.platform_name}] 重试上传成功: {label}")
|
|
|
+ return True
|
|
|
+ except Exception as e:
|
|
|
+ print(f"[{self.platform_name}] 重试上传-{label}失败: {e}")
|
|
|
+ # set_input_files 兜底
|
|
|
+ try:
|
|
|
+ file_inputs = self.page.locator('input[type="file"]')
|
|
|
+ for k in range(await file_inputs.count()):
|
|
|
+ inp = file_inputs.nth(k)
|
|
|
+ accept = await inp.get_attribute("accept") or ""
|
|
|
+ if "video" in accept or "*" in accept or not accept:
|
|
|
+ await inp.set_input_files(params.video_path)
|
|
|
+ print(f"[{self.platform_name}] 重试 set_input_files 成功")
|
|
|
+ return True
|
|
|
+ except:
|
|
|
+ pass
|
|
|
+ return False
|
|
|
+
|
|
|
upload_complete = False
|
|
|
- for i in range(60): # 最多等待3分钟
|
|
|
+ redirect_retry_count = 0
|
|
|
+ max_redirect_retries = 2
|
|
|
+
|
|
|
+ for i in range(90): # 最多等待 ~4.5 分钟
|
|
|
await asyncio.sleep(3)
|
|
|
|
|
|
+ current_url = self.page.url
|
|
|
+
|
|
|
+ # 检测是否跳转到登录页
|
|
|
+ if "login" in current_url or "passport" in current_url:
|
|
|
+ print(f"[{self.platform_name}] ⚠ 检测到跳转到登录页: {current_url}")
|
|
|
+ screenshot_base64 = await self.capture_screenshot()
|
|
|
+ return PublishResult(
|
|
|
+ success=False,
|
|
|
+ platform=self.platform_name,
|
|
|
+ error="上传过程中登录态失效,请重新登录后再试",
|
|
|
+ screenshot_base64=screenshot_base64,
|
|
|
+ page_url=current_url,
|
|
|
+ status="need_captcha",
|
|
|
+ need_captcha=True,
|
|
|
+ captcha_type="login",
|
|
|
+ )
|
|
|
+
|
|
|
+ # 检测是否跳回了上传选择页面
|
|
|
+ if await _is_on_upload_page():
|
|
|
+ error_msg = await _get_page_error()
|
|
|
+ redirect_retry_count += 1
|
|
|
+ print(
|
|
|
+ f"[{self.platform_name}] ⚠ 页面跳回上传选择页 (第 {redirect_retry_count} 次), "
|
|
|
+ f"URL={current_url}, 页面错误: {error_msg or '无'}"
|
|
|
+ )
|
|
|
+
|
|
|
+ if redirect_retry_count > max_redirect_retries:
|
|
|
+ screenshot_base64 = await self.capture_screenshot()
|
|
|
+ page_url = await self.get_page_url()
|
|
|
+ return PublishResult(
|
|
|
+ success=False,
|
|
|
+ platform=self.platform_name,
|
|
|
+ error=f"视频上传失败(页面反复跳回上传页){': ' + error_msg if error_msg else ',可能是登录过期或视频格式不支持'}",
|
|
|
+ screenshot_base64=screenshot_base64,
|
|
|
+ page_url=page_url,
|
|
|
+ status="need_action",
|
|
|
+ )
|
|
|
+
|
|
|
+ # 等一下再重试上传
|
|
|
+ print(f"[{self.platform_name}] 等待 3 秒后重试上传...")
|
|
|
+ await asyncio.sleep(3)
|
|
|
+ retry_ok = await _do_upload()
|
|
|
+ if not retry_ok:
|
|
|
+ screenshot_base64 = await self.capture_screenshot()
|
|
|
+ page_url = await self.get_page_url()
|
|
|
+ return PublishResult(
|
|
|
+ success=False,
|
|
|
+ platform=self.platform_name,
|
|
|
+ error=f"重试上传失败{': ' + error_msg if error_msg else ''}",
|
|
|
+ screenshot_base64=screenshot_base64,
|
|
|
+ page_url=page_url,
|
|
|
+ status="need_action",
|
|
|
+ )
|
|
|
+ continue
|
|
|
+
|
|
|
# 检查是否有标题输入框(上传完成后出现)
|
|
|
title_input_count = await self.page.locator(
|
|
|
'input[placeholder*="标题"], input[placeholder*="填写标题"]'
|
|
|
).count()
|
|
|
- # 或者检查编辑器区域
|
|
|
editor_count = await self.page.locator(
|
|
|
'[class*="ql-editor"], [contenteditable="true"]'
|
|
|
).count()
|
|
|
- # 检查发布按钮是否可见
|
|
|
publish_btn_count = await self.page.locator(
|
|
|
'.publishBtn, button:has-text("发布")'
|
|
|
).count()
|
|
|
|
|
|
- print(
|
|
|
- f"[{self.platform_name}] 检测 {i + 1}: 标题框={title_input_count}, 编辑器={editor_count}, 发布按钮={publish_btn_count}"
|
|
|
- )
|
|
|
+ if i % 5 == 0 or title_input_count > 0 or editor_count > 0:
|
|
|
+ print(
|
|
|
+ f"[{self.platform_name}] 检测 {i + 1}: 标题框={title_input_count}, "
|
|
|
+ f"编辑器={editor_count}, 发布按钮={publish_btn_count}, URL={current_url}"
|
|
|
+ )
|
|
|
|
|
|
if title_input_count > 0 or (editor_count > 0 and publish_btn_count > 0):
|
|
|
upload_complete = True
|
|
|
print(f"[{self.platform_name}] 视频上传完成!")
|
|
|
break
|
|
|
|
|
|
+ # 每 30 秒抓一次页面错误提示
|
|
|
+ if i > 0 and i % 10 == 0:
|
|
|
+ err = await _get_page_error()
|
|
|
+ if err:
|
|
|
+ print(f"[{self.platform_name}] 页面提示: {err}")
|
|
|
+
|
|
|
if not upload_complete:
|
|
|
screenshot_base64 = await self.capture_screenshot()
|
|
|
page_url = await self.get_page_url()
|
|
|
+ error_msg = await _get_page_error()
|
|
|
return PublishResult(
|
|
|
success=False,
|
|
|
platform=self.platform_name,
|
|
|
- error="视频上传超时",
|
|
|
+ error=f"视频上传超时{': ' + error_msg if error_msg else ''}",
|
|
|
screenshot_base64=screenshot_base64,
|
|
|
page_url=page_url,
|
|
|
status="need_action",
|
|
|
@@ -646,78 +874,273 @@ class XiaohongshuPublisher(BasePublisher):
|
|
|
await asyncio.sleep(2)
|
|
|
self.report_progress(80, "正在发布...")
|
|
|
|
|
|
+ # ========== 等待视频处理完成 ==========
|
|
|
+ # 小红书上传视频后需要转码处理,发布按钮在处理完前可能是禁用状态
|
|
|
+ print(f"[{self.platform_name}] 等待视频处理完成...")
|
|
|
+ for wait_i in range(40): # 最多等待 ~2 分钟
|
|
|
+ await asyncio.sleep(3)
|
|
|
+ # 检查是否有 "处理中"、"上传中"、进度条等
|
|
|
+ processing_indicators = [
|
|
|
+ '[class*="progress"]',
|
|
|
+ '[class*="loading"]',
|
|
|
+ '[class*="uploading"]',
|
|
|
+ ':text("上传中")',
|
|
|
+ ':text("处理中")',
|
|
|
+ ':text("转码")',
|
|
|
+ ]
|
|
|
+ still_processing = False
|
|
|
+ for ind_sel in processing_indicators:
|
|
|
+ try:
|
|
|
+ loc = self.page.locator(ind_sel).first
|
|
|
+ if await loc.count() > 0 and await loc.is_visible():
|
|
|
+ still_processing = True
|
|
|
+ if wait_i % 5 == 0:
|
|
|
+ text = (await loc.text_content() or "").strip()[:40]
|
|
|
+ print(f"[{self.platform_name}] 视频仍在处理: {ind_sel} -> '{text}'")
|
|
|
+ break
|
|
|
+ except:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 检查发布按钮是否已可用
|
|
|
+ try:
|
|
|
+ pub_btn = self.page.locator(
|
|
|
+ 'button:has-text("发布"):not(:has-text("定时发布")):not(:has-text("定时"))'
|
|
|
+ ).first
|
|
|
+ if await pub_btn.count() > 0 and await pub_btn.is_visible():
|
|
|
+ if await pub_btn.is_enabled():
|
|
|
+ print(f"[{self.platform_name}] 发布按钮已可用(等待了 {wait_i * 3} 秒)")
|
|
|
+ break
|
|
|
+ else:
|
|
|
+ if wait_i % 5 == 0:
|
|
|
+ print(f"[{self.platform_name}] 发布按钮存在但尚未启用,继续等待...")
|
|
|
+ except:
|
|
|
+ pass
|
|
|
+
|
|
|
+ if not still_processing and wait_i > 3:
|
|
|
+ print(f"[{self.platform_name}] 未检测到处理指示器,继续尝试发布")
|
|
|
+ break
|
|
|
+
|
|
|
await asyncio.sleep(2)
|
|
|
|
|
|
# 滚动到页面底部确保发布按钮可见
|
|
|
await self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
|
await asyncio.sleep(1)
|
|
|
|
|
|
+ # ========== 诊断页面上所有按钮 ==========
|
|
|
+ print(f"[{self.platform_name}] === 页面按钮诊断 ===")
|
|
|
+ try:
|
|
|
+ all_page_btns = self.page.locator("button")
|
|
|
+ btn_total = await all_page_btns.count()
|
|
|
+ print(f"[{self.platform_name}] 页面共有 {btn_total} 个 <button> 元素")
|
|
|
+ for di in range(min(btn_total, 15)):
|
|
|
+ try:
|
|
|
+ b = all_page_btns.nth(di)
|
|
|
+ t = (await b.text_content() or "").strip().replace("\n", " ")[:40]
|
|
|
+ c = await b.get_attribute("class") or ""
|
|
|
+ v = await b.is_visible()
|
|
|
+ e = await b.is_enabled()
|
|
|
+ print(f"[{self.platform_name}] button[{di}]: text='{t}' class='{c[:60]}' visible={v} enabled={e}")
|
|
|
+ except:
|
|
|
+ continue
|
|
|
+ except Exception as ex:
|
|
|
+ print(f"[{self.platform_name}] 按钮诊断失败: {ex}")
|
|
|
+
|
|
|
+ # 同时诊断可能不是 button 的发布元素
|
|
|
+ try:
|
|
|
+ non_btn_publish = self.page.locator(
|
|
|
+ 'div:has-text("发布"), span:has-text("发布"), a:has-text("发布")'
|
|
|
+ )
|
|
|
+ nb_count = await non_btn_publish.count()
|
|
|
+ print(f"[{self.platform_name}] 含'发布'文本的非button元素数量: {nb_count}")
|
|
|
+ for di in range(min(nb_count, 10)):
|
|
|
+ try:
|
|
|
+ el = non_btn_publish.nth(di)
|
|
|
+ t = (await el.text_content() or "").strip().replace("\n", " ")[:40]
|
|
|
+ tag = await el.evaluate("e => e.tagName")
|
|
|
+ c = await el.evaluate("e => e.className") or ""
|
|
|
+ v = await el.is_visible()
|
|
|
+ if v and ("发布" in t and len(t) < 15):
|
|
|
+ print(f"[{self.platform_name}] ** {tag}[{di}]: text='{t}' class='{str(c)[:60]}' visible={v}")
|
|
|
+ except:
|
|
|
+ continue
|
|
|
+ except:
|
|
|
+ pass
|
|
|
+ print(f"[{self.platform_name}] === 诊断结束 ===")
|
|
|
+
|
|
|
print(f"[{self.platform_name}] 查找发布按钮...")
|
|
|
|
|
|
- # 点击发布
|
|
|
+ # ========== 点击发布 ==========
|
|
|
publish_selectors = [
|
|
|
"button.publishBtn",
|
|
|
".publishBtn",
|
|
|
+ "button.css-k0qbsp",
|
|
|
"button.d-button.red",
|
|
|
- 'button:has-text("发布"):not(:has-text("定时发布"))',
|
|
|
+ 'button.el-button--primary:has-text("发布")',
|
|
|
+ 'button[class*="red"]:has-text("发布")',
|
|
|
'[class*="publish"][class*="btn"]',
|
|
|
]
|
|
|
|
|
|
publish_clicked = False
|
|
|
- for selector in publish_selectors:
|
|
|
+
|
|
|
+ # 辅助:安全地点击元素
|
|
|
+ async def _safe_click_publish(locator, label: str) -> bool:
|
|
|
try:
|
|
|
- btn = self.page.locator(selector).first
|
|
|
- if await btn.count() > 0:
|
|
|
- is_visible = await btn.is_visible()
|
|
|
- is_enabled = await btn.is_enabled()
|
|
|
- print(
|
|
|
- f"[{self.platform_name}] 按钮 {selector}: visible={is_visible}, enabled={is_enabled}"
|
|
|
+ if await locator.count() == 0:
|
|
|
+ return False
|
|
|
+ if not await locator.is_visible():
|
|
|
+ return False
|
|
|
+ box = await locator.bounding_box()
|
|
|
+ if box:
|
|
|
+ print(f"[{self.platform_name}] 点击发布按钮: {label}, 位置: ({box['x']:.0f}, {box['y']:.0f})")
|
|
|
+ await self.page.mouse.click(
|
|
|
+ box["x"] + box["width"] / 2,
|
|
|
+ box["y"] + box["height"] / 2,
|
|
|
)
|
|
|
+ return True
|
|
|
+ else:
|
|
|
+ await locator.click(timeout=5000)
|
|
|
+ print(f"[{self.platform_name}] 直接click发布按钮: {label}")
|
|
|
+ return True
|
|
|
+ except Exception as e:
|
|
|
+ print(f"[{self.platform_name}] 点击 {label} 失败: {e}")
|
|
|
+ return False
|
|
|
|
|
|
- if is_visible and is_enabled:
|
|
|
- box = await btn.bounding_box()
|
|
|
- if box:
|
|
|
- print(
|
|
|
- f"[{self.platform_name}] 点击发布按钮: {selector}, 位置: ({box['x']}, {box['y']})"
|
|
|
- )
|
|
|
- # 使用真实鼠标点击
|
|
|
- await self.page.mouse.click(
|
|
|
- box["x"] + box["width"] / 2,
|
|
|
- box["y"] + box["height"] / 2,
|
|
|
- )
|
|
|
- publish_clicked = True
|
|
|
- break
|
|
|
+ # 策略1: 预定义选择器
|
|
|
+ for selector in publish_selectors:
|
|
|
+ btn = self.page.locator(selector).first
|
|
|
+ if await _safe_click_publish(btn, f"策略1-{selector}"):
|
|
|
+ publish_clicked = True
|
|
|
+ break
|
|
|
+
|
|
|
+ # 策略2: 精确匹配 button 文本为"发布"(排除"定时发布"等)
|
|
|
+ if not publish_clicked:
|
|
|
+ print(f"[{self.platform_name}] 策略2: 遍历button匹配文本'发布'...")
|
|
|
+ try:
|
|
|
+ all_btns = self.page.locator("button")
|
|
|
+ count = await all_btns.count()
|
|
|
+ for i in range(count):
|
|
|
+ btn = all_btns.nth(i)
|
|
|
+ try:
|
|
|
+ text = (await btn.text_content() or "").strip()
|
|
|
+ if not text:
|
|
|
+ continue
|
|
|
+ # 宽松匹配:text 包含"发布"且不含"定时"
|
|
|
+ if "发布" in text and "定时" not in text and len(text) <= 10:
|
|
|
+ if await btn.is_visible():
|
|
|
+ if await _safe_click_publish(btn, f"策略2-button[{i}]('{text}')"):
|
|
|
+ publish_clicked = True
|
|
|
+ break
|
|
|
+ except:
|
|
|
+ continue
|
|
|
except Exception as e:
|
|
|
- print(f"[{self.platform_name}] 选择器 {selector} 错误: {e}")
|
|
|
+ print(f"[{self.platform_name}] 策略2失败: {e}")
|
|
|
|
|
|
+ # 策略3: 非 button 元素(div/span/a)包含"发布"文本
|
|
|
if not publish_clicked:
|
|
|
+ print(f"[{self.platform_name}] 策略3: 查找非button的发布元素...")
|
|
|
+ try:
|
|
|
+ for tag in ["div", "span", "a"]:
|
|
|
+ candidates = self.page.locator(f'{tag}:has-text("发布")')
|
|
|
+ cnt = await candidates.count()
|
|
|
+ for i in range(cnt):
|
|
|
+ el = candidates.nth(i)
|
|
|
+ try:
|
|
|
+ text = (await el.text_content() or "").strip()
|
|
|
+ if "发布" in text and "定时" not in text and len(text) <= 10:
|
|
|
+ if await el.is_visible():
|
|
|
+ # 排除包含子元素的大容器
|
|
|
+ children = await el.evaluate("e => e.children.length")
|
|
|
+ if children <= 2:
|
|
|
+ if await _safe_click_publish(el, f"策略3-{tag}[{i}]('{text}')"):
|
|
|
+ publish_clicked = True
|
|
|
+ break
|
|
|
+ except:
|
|
|
+ continue
|
|
|
+ if publish_clicked:
|
|
|
+ break
|
|
|
+ except Exception as e:
|
|
|
+ print(f"[{self.platform_name}] 策略3失败: {e}")
|
|
|
+
|
|
|
+ # 策略4: 通过 CSS 类名模糊匹配(小红书常用 d-button red 类名)
|
|
|
+ if not publish_clicked:
|
|
|
+ print(f"[{self.platform_name}] 策略4: CSS类名模糊匹配...")
|
|
|
+ css_selectors = [
|
|
|
+ '[class*="submit"]',
|
|
|
+ '[class*="publish"]',
|
|
|
+ '.red.d-button',
|
|
|
+ 'button.red',
|
|
|
+ '[class*="btn-publish"]',
|
|
|
+ '[class*="btn_publish"]',
|
|
|
+ ]
|
|
|
+ for sel in css_selectors:
|
|
|
+ try:
|
|
|
+ el = self.page.locator(sel).first
|
|
|
+ if await el.count() > 0 and await el.is_visible():
|
|
|
+ text = (await el.text_content() or "").strip()
|
|
|
+ if "发布" in text and "定时" not in text:
|
|
|
+ if await _safe_click_publish(el, f"策略4-{sel}('{text}')"):
|
|
|
+ publish_clicked = True
|
|
|
+ break
|
|
|
+ except:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 策略5: AI 兜底
|
|
|
+ if not publish_clicked:
|
|
|
+ print(f"[{self.platform_name}] 策略5: AI 识别发布按钮...")
|
|
|
try:
|
|
|
suggest = await self.ai_suggest_playwright_selector(
|
|
|
- "点击小红书发布按钮"
|
|
|
+ "点击小红书笔记发布页面的红色'发布'按钮(不是'定时发布'按钮),"
|
|
|
+ "该按钮通常在页面右下区域,是红色背景白色文字的按钮"
|
|
|
)
|
|
|
if suggest.get("has_selector") and suggest.get("selector"):
|
|
|
sel = suggest.get("selector")
|
|
|
- btn = self.page.locator(sel).first
|
|
|
- if (
|
|
|
- await btn.count() > 0
|
|
|
- and await btn.is_visible()
|
|
|
- and await btn.is_enabled()
|
|
|
- ):
|
|
|
- try:
|
|
|
- await btn.click()
|
|
|
- except:
|
|
|
- box = await btn.bounding_box()
|
|
|
- if box:
|
|
|
- await self.page.mouse.click(
|
|
|
- box["x"] + box["width"] / 2,
|
|
|
- box["y"] + box["height"] / 2,
|
|
|
- )
|
|
|
+ print(f"[{self.platform_name}] AI 建议: {sel} (置信度: {suggest.get('confidence')})")
|
|
|
+ el = self.page.locator(sel).first
|
|
|
+ if await _safe_click_publish(el, f"策略5-AI({sel})"):
|
|
|
publish_clicked = True
|
|
|
except Exception as e:
|
|
|
- print(f"[{self.platform_name}] AI 点击发布按钮失败: {e}", flush=True)
|
|
|
+ print(f"[{self.platform_name}] 策略5失败: {e}", flush=True)
|
|
|
+
|
|
|
+ # 策略6: JavaScript 直接查找并点击
|
|
|
+ if not publish_clicked:
|
|
|
+ print(f"[{self.platform_name}] 策略6: JavaScript 查找并点击...")
|
|
|
+ try:
|
|
|
+ clicked_by_js = await self.page.evaluate("""() => {
|
|
|
+ // 查找所有可见的包含"发布"的元素
|
|
|
+ const candidates = [];
|
|
|
+ const all = document.querySelectorAll('button, [role="button"], a, span, div');
|
|
|
+ for (const el of all) {
|
|
|
+ const text = (el.textContent || '').trim();
|
|
|
+ if (text === '发布' || (text.includes('发布') && !text.includes('定时') && text.length <= 8)) {
|
|
|
+ const rect = el.getBoundingClientRect();
|
|
|
+ if (rect.width > 0 && rect.height > 0 && rect.top >= 0) {
|
|
|
+ const style = window.getComputedStyle(el);
|
|
|
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
|
+ candidates.push({
|
|
|
+ el: el,
|
|
|
+ text: text,
|
|
|
+ tag: el.tagName,
|
|
|
+ area: rect.width * rect.height,
|
|
|
+ y: rect.top
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (candidates.length === 0) return null;
|
|
|
+ // 优先选择面积适中(像按钮)且在页面下方的元素
|
|
|
+ candidates.sort((a, b) => b.y - a.y);
|
|
|
+ const target = candidates.find(c => c.area > 500 && c.area < 50000) || candidates[0];
|
|
|
+ target.el.click();
|
|
|
+ return { tag: target.tag, text: target.text, y: target.y };
|
|
|
+ }""")
|
|
|
+ if clicked_by_js:
|
|
|
+ print(f"[{self.platform_name}] JS点击成功: {clicked_by_js}")
|
|
|
+ publish_clicked = True
|
|
|
+ except Exception as e:
|
|
|
+ print(f"[{self.platform_name}] 策略6失败: {e}")
|
|
|
|
|
|
if not publish_clicked:
|
|
|
- # 保存截图用于调试
|
|
|
screenshot_dir = os.path.join(
|
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
|
"screenshots",
|
|
|
@@ -730,15 +1153,6 @@ class XiaohongshuPublisher(BasePublisher):
|
|
|
print(
|
|
|
f"[{self.platform_name}] 未找到发布按钮,截图保存到: {screenshot_path}"
|
|
|
)
|
|
|
-
|
|
|
- # 打印页面 HTML 结构用于调试
|
|
|
- buttons = await self.page.query_selector_all("button")
|
|
|
- print(f"[{self.platform_name}] 页面上共有 {len(buttons)} 个按钮")
|
|
|
- for i, btn in enumerate(buttons[:10]):
|
|
|
- text = await btn.text_content() or ""
|
|
|
- cls = await btn.get_attribute("class") or ""
|
|
|
- print(f" 按钮 {i}: text='{text.strip()[:30]}', class='{cls[:50]}'")
|
|
|
-
|
|
|
raise Exception("未找到发布按钮")
|
|
|
|
|
|
print(f"[{self.platform_name}] 已点击发布按钮,等待发布完成...")
|
|
|
@@ -2095,7 +2509,10 @@ class XiaohongshuPublisher(BasePublisher):
|
|
|
# --- Step 1: 初始化浏览器和 Cookie ---
|
|
|
cookie_list = self.parse_cookies(cookies)
|
|
|
playwright = await async_playwright().start()
|
|
|
- browser = await playwright.chromium.launch(headless=False)
|
|
|
+ try:
|
|
|
+ browser = await playwright.chromium.launch(headless=False, channel="chrome")
|
|
|
+ except Exception:
|
|
|
+ browser = await playwright.chromium.launch(headless=False)
|
|
|
context = await browser.new_context(
|
|
|
viewport={"width": 1400, "height": 900},
|
|
|
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|