From 3590c526b0cb50018fd0bf5c0c8a87f2daf6ac17 Mon Sep 17 00:00:00 2001 From: suihf Date: Thu, 13 Aug 2026 09:45:44 +0800 Subject: [PATCH] fix(xiaohongshu): declare repost source and harden cover upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check_original_declaration: replace the original checkbox with the '来源转载' (repost) declaration flow: open the declaration picker, select '来源转载', fill the media name, confirm. Falls back to JS click when the element text is wrapped in non-interactive spans - set_thumbnail: switch to the '上传封面' tab and target the upload-wrapper file input instead of the cover-plugin-preview selector; skip gracefully on failure and let the video first frame be used --- uploader/xiaohongshu_uploader/main.py | 147 ++++++++++++++++++++------ 1 file changed, 115 insertions(+), 32 deletions(-) diff --git a/uploader/xiaohongshu_uploader/main.py b/uploader/xiaohongshu_uploader/main.py index 9d46ddc..7ca7163 100644 --- a/uploader/xiaohongshu_uploader/main.py +++ b/uploader/xiaohongshu_uploader/main.py @@ -44,6 +44,26 @@ def _msg(emoji: str, text: str) -> str: return f"{emoji} {text}" +async def _js_click_by_text(page: Page, text: str) -> bool: + """用 JS 找到文字完全匹配的最内层元素并点击它及其祖先(绕过 span pointer-events:none / 遮罩拦截)。 + + 小红书很多可点项文字在 里,pointer-events 常被禁用, + Playwright 常规 click 会超时。用原生 click 冒泡触发 Vue 事件更可靠。 + """ + return await page.evaluate( + """(t) => { + const nodes = [...document.querySelectorAll('*')].filter( + e => e.children.length === 0 && (e.textContent || '').trim() === t + ); + if (!nodes.length) return false; + let el = nodes[nodes.length - 1]; + for (let i = 0; i < 4 && el; i++) { try { el.click(); } catch (e) {} el = el.parentElement; } + return true; + }""", + text, + ) + + async def _emit_qrcode_callback(qrcode_callback, payload: dict): if not qrcode_callback: return @@ -447,25 +467,58 @@ class XiaoHongShuBaseUploader(BaseVideoUploader): await self.fill_tags(page) async def check_original_declaration(self, page: Page) -> None: - """勾选原创声明(如果页面上有的话)""" + """设置「来源转载」声明,填写转载来源。 + + 流程(对应 codegen 录制): + 点「添加内容类型声明」→ 点包含「来源转载」的 div + → 填 placeholder「请输入媒体名称」→ 点 button「确认」。 + 容错:任一步失败记 warning 跳过、继续发布,不中断。 + """ + source = getattr(self, "repost_source", "") or "" try: - # 小红书的原创声明通常是 checkbox 或 switch 组件 - original_checkbox = page.locator('div.original-declaration checkbox, div.original-declaration input[type="checkbox"], label:has-text("原创") input[type="checkbox"]').first - if await original_checkbox.count() and not await original_checkbox.is_checked(): - await original_checkbox.check() - xiaohongshu_logger.success(_msg("✅", "原创声明已勾选")) - return + # 1. 点「添加内容类型声明」 + trigger = page.get_by_text("添加内容类型声明", exact=False).first + try: + await trigger.scroll_into_view_if_needed(timeout=5000) + except Exception: + pass + await trigger.click(force=True) + await page.wait_for_timeout(1500) - # 尝试通过文本匹配找到原创声明区域并点击 - original_text = page.locator('div:has-text("原创声明"), span:has-text("原创声明"), div:has-text("原创"), label:has-text("原创")').first - if await original_text.count(): - await original_text.click() - xiaohongshu_logger.success(_msg("✅", "原创声明已勾选")) - return + # 2. 选「来源转载」选项 + import re as _re + repost_option = page.locator("#publish-container div").filter( + has_text=_re.compile(r"^来源转载$") + ).last + if await repost_option.count(): + await repost_option.click(force=True) + else: + await _js_click_by_text(page, "来源转载") + await page.wait_for_timeout(1500) - xiaohongshu_logger.info(_msg("🧾", "未发现原创声明选项,跳过")) + # 3. 填写媒体名称 + source_input = page.get_by_placeholder("请输入媒体名称").first + await source_input.wait_for(state="visible", timeout=8000) + await source_input.click() + await source_input.fill(source) + await page.wait_for_timeout(500) + + # 4. 点「确认」按钮 + confirm = page.get_by_role("button", name="确认").first + try: + await confirm.wait_for(state="visible", timeout=5000) + await confirm.click() + except Exception: + await _js_click_by_text(page, "确认") + + await page.wait_for_timeout(1000) + xiaohongshu_logger.success(_msg("🧾", f"来源转载已声明(来源:{source})")) except Exception as exc: - xiaohongshu_logger.warning(_msg("⚠️", f"勾选原创声明时出错,跳过: {exc}")) + xiaohongshu_logger.warning(_msg("⚠️", f"设置来源转载失败,跳过继续发布: {exc}")) + try: + await page.keyboard.press("Escape") + except Exception: + pass class XiaoHongShuVideo(XiaoHongShuBaseUploader): @@ -514,28 +567,58 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader): xiaohongshu_logger.info(_msg("🖼️", "小人准备设置封面")) - cover_plugin_title = page.locator("div.cover-plugin-title").filter(has_text="设置封面") - cover_upload_dialog = cover_plugin_title.locator( - "xpath=ancestor::div[contains(@class, 'cover-plugin-preview')]" - ).locator("div.cover > div.default:visible") - await cover_upload_dialog.wait_for(state="visible", timeout=30000) + # 封面设置为增强步骤:失败时记 warning 跳过、继续发布(用视频首帧兜底)。 + try: + # 发布页封面区域内嵌,点击 div.upload-cover 打开封面弹窗(d-modal)。 + cover_section = page.locator("text=设置封面").first + try: + await cover_section.scroll_into_view_if_needed(timeout=5000) + except Exception: + pass + await page.wait_for_timeout(2000) - await cover_upload_dialog.click(force=True) + # 1. 点击 div.upload-cover 打开封面弹窗 + upload_cover = page.locator("div.upload-cover").first + if not await upload_cover.count(): + upload_cover = page.locator("div.cover-plugin-preview div.default.pointer").first + await upload_cover.click(force=True) + await page.wait_for_timeout(3000) - modal = page.locator("div.d-modal.cover-modal") - await modal.wait_for(state="visible", timeout=30000) + # 2. 切换到「上传封面」tab(默认在「截取封面」) + upload_tab = page.get_by_text("上传封面", exact=True).first + await upload_tab.wait_for(state="visible", timeout=10000) + await upload_tab.click() + await page.wait_for_timeout(2000) - file_input = modal.locator('input[type="file"][accept*="image"]').first - await file_input.wait_for(state="attached", timeout=10000) - await file_input.set_input_files(thumbnail_path) - await page.wait_for_timeout(2000) + # 3. 找到图片 file input(parent class: upload-wrapper)并上传 + file_input = page.locator('div.upload-wrapper input[type="file"][accept*="image"]').first + if not await file_input.count(): + file_input = page.locator('input[type="file"][accept*="image"]').last + await file_input.set_input_files(thumbnail_path) + await page.wait_for_timeout(4000) # 等图片加载+裁剪渲染 - confirm_button = modal.locator("button.mojito-button").filter(has_text="确定").first - await confirm_button.wait_for(state="visible", timeout=10000) - await confirm_button.click() + # 4. 点「确定」按钮 + modal_footer = page.locator("div.d-modal-footer") + confirm = modal_footer.get_by_text("确定", exact=True).first + if not await confirm.count(): + confirm = page.get_by_role("button", name="确定").first + await confirm.wait_for(state="visible", timeout=10000) + await confirm.click() - await modal.wait_for(state="hidden", timeout=30000) - xiaohongshu_logger.success(_msg("🥳", "封面已经设置完成")) + # 5. 等弹窗关闭 + modal = page.locator("div.d-modal") + try: + await modal.first.wait_for(state="hidden", timeout=15000) + except Exception: + pass + xiaohongshu_logger.success(_msg("🥳", "封面已经设置完成")) + except Exception as exc: + xiaohongshu_logger.warning(_msg("🖼️", f"封面设置失败,跳过该步骤继续发布(用视频首帧):{exc}")) + try: + await page.keyboard.press("Escape") + await page.wait_for_timeout(500) + except Exception: + pass async def upload_video_content(self, page: Page) -> None: xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4"))