"""图文发布,对应 Go xiaohongshu/publish.go(837 行)。""" from __future__ import annotations import json import logging import random import re import time from .cdp import Page from .errors import ContentTooLongError, PublishError, TitleTooLongError, UploadTimeoutError from .selectors import ( CONTENT_EDITOR, CONTENT_LENGTH_ERROR, CREATOR_TAB, DATETIME_INPUT, FILE_INPUT, ORIGINAL_SWITCH, ORIGINAL_SWITCH_CARD, POPOVER, SCHEDULE_SWITCH, TAG_FIRST_ITEM, TAG_TOPIC_CONTAINER, TITLE_INPUT, TITLE_MAX_SUFFIX, UPLOAD_INPUT, VISIBILITY_DROPDOWN, VISIBILITY_OPTIONS, ) from .types import PublishImageContent from .urls import PUBLISH_URL logger = logging.getLogger(__name__) def publish_image_content(page: Page, content: PublishImageContent) -> None: """发布图文内容(填写表单 + 点击发布)。 Args: page: CDP 页面对象。 content: 发布内容。 Raises: PublishError: 发布失败。 UploadTimeoutError: 上传超时。 TitleTooLongError: 标题超长。 ContentTooLongError: 正文超长。 """ fill_publish_form(page, content) click_publish_button(page) def fill_publish_form(page: Page, content: PublishImageContent) -> None: """填写图文发布表单,不点击发布按钮。 Args: page: CDP 页面对象。 content: 发布内容。 Raises: PublishError: 填写失败。 UploadTimeoutError: 上传超时。 TitleTooLongError: 标题超长。 ContentTooLongError: 正文超长。 """ if not content.image_paths: raise PublishError("图片不能为空") # 导航到发布页 _navigate_to_publish_page(page) # 进入「上传图文」面板(页面可能存在多个隐藏 upload 区域,不能仅凭元素存在跳过) _click_publish_tab(page, "上传图文") _wait_for_image_publish_surface(page) time.sleep(1) # 上传图片 _upload_images(page, content.image_paths) # 标签截取 tags = content.tags[:10] if len(content.tags) > 10 else content.tags if len(content.tags) > 10: logger.warning("标签数量超过10,截取前10个") logger.info( "发布内容: title=%s, images=%d, tags=%d, schedule=%s, original=%s, visibility=%s", content.title, len(content.image_paths), len(tags), content.schedule_time, content.is_original, content.visibility, ) # 填写表单(不点击发布) _fill_publish_form( page, content.title, content.content, tags, content.schedule_time, content.is_original, content.visibility, ) _TRY_CLICK_PUBLISH_BUTTON_JS = """ (() => { function isVisible(el) { if (!el) return false; const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return false; if (style.display === 'none' || style.visibility === 'hidden') return false; const opacity = parseFloat(style.opacity); if (!isNaN(opacity) && opacity <= 0.05) return false; if (el.getAttribute('aria-hidden') === 'true') return false; return true; } function buttonText(el) { return (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim(); } function isDisabled(el) { if (!el) return true; if (el.disabled) return true; if (el.getAttribute('aria-disabled') === 'true') return true; const cls = el.className ? String(el.className) : ''; return /(^|\\s)disabled(\\s|$)/.test(cls); } function isPublishLabel(text) { if (!text) return false; if (text === '发布') return true; return text.includes('发布') && !text.includes('发布笔记'); } function clickEl(el, via) { el.scrollIntoView({ block: 'center', inline: 'center' }); const inner = el.querySelector ? el.querySelector('button') : null; if (inner && isVisible(inner) && !isDisabled(inner) && isPublishLabel(buttonText(inner))) { inner.click(); return { ok: true, via: via + '-inner', text: buttonText(inner) }; } if (typeof el.click === 'function') { el.click(); return { ok: true, via, text: buttonText(el) }; } return { ok: false, found: true, reason: '元素无法 click' }; } // 1) 新版 web component for (const widget of document.querySelectorAll('xhs-publish-btn')) { if (!isVisible(widget)) continue; if (widget.getAttribute('is-publish') === 'false') continue; if (widget.getAttribute('submit-disabled') === 'true') { return { ok: false, found: true, reason: '新版发布按钮 submit-disabled=true' }; } return clickEl(widget, 'xhs-publish-btn'); } // 2) 2026 改版 ce-btn + 旧版 bg-red const buttonSelectors = [ 'button.ce-btn.bg-red', '.publish-page-publish-btn button.bg-red', 'button.bg-red', ]; for (const selector of buttonSelectors) { for (const btn of document.querySelectorAll(selector)) { if (!isVisible(btn)) continue; const text = buttonText(btn); if (!isPublishLabel(text)) continue; if (isDisabled(btn)) { return { ok: false, found: true, reason: '发布按钮 disabled', text }; } return clickEl(btn, selector); } } // 3) 兜底:任意可见「发布」按钮 for (const btn of document.querySelectorAll('button, [role="button"]')) { if (!isVisible(btn)) continue; const text = buttonText(btn); if (text !== '发布') continue; if (isDisabled(btn)) { return { ok: false, found: true, reason: '发布按钮 disabled', text }; } return clickEl(btn, 'generic-button'); } return { ok: false, found: false }; })() """ _DEBUG_PUBLISH_BUTTONS_JS = """ (() => { const samples = []; for (const el of document.querySelectorAll('xhs-publish-btn, button, [role="button"]')) { const text = (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim(); if (!text || !text.includes('发布')) continue; const rect = el.getBoundingClientRect(); samples.push({ tag: el.tagName.toLowerCase(), text, className: String(el.className || '').slice(0, 120), visible: rect.width > 0 && rect.height > 0, disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true', isPublish: el.getAttribute ? el.getAttribute('is-publish') : null, submitDisabled: el.getAttribute ? el.getAttribute('submit-disabled') : null, }); if (samples.length >= 12) break; } return JSON.stringify({ url: location.href, candidates: samples }); })() """ _FIND_PUBLISH_BUTTON_JS = """ (() => { function isVisible(el) { if (!el) return false; const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return false; if (style.display === 'none' || style.visibility === 'hidden') return false; const opacity = parseFloat(style.opacity); if (!isNaN(opacity) && opacity <= 0.05) return false; if (el.getAttribute('aria-hidden') === 'true') return false; return true; } function buttonText(el) { return (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim(); } function isDisabled(el) { if (!el) return true; if (el.disabled) return true; if (el.getAttribute('aria-disabled') === 'true') return true; const cls = el.className ? String(el.className) : ''; return /(^|\\s)disabled(\\s|$)/.test(cls); } function isPublishLabel(text) { if (!text) return false; if (text === '发布') return true; return text.includes('发布') && !text.includes('发布笔记'); } for (const widget of document.querySelectorAll('xhs-publish-btn')) { if (!isVisible(widget)) continue; if (widget.getAttribute('is-publish') === 'false') continue; if (widget.getAttribute('submit-disabled') === 'true') { return { found: true, clickable: false, reason: '新版发布按钮 submit-disabled=true' }; } return { found: true, clickable: true, via: 'xhs-publish-btn' }; } const buttonSelectors = [ 'button.ce-btn.bg-red', '.publish-page-publish-btn button.bg-red', 'button.bg-red', ]; for (const selector of buttonSelectors) { for (const btn of document.querySelectorAll(selector)) { if (!isVisible(btn)) continue; const text = buttonText(btn); if (!isPublishLabel(text)) continue; if (isDisabled(btn)) { return { found: true, clickable: false, reason: '发布按钮 disabled', text }; } return { found: true, clickable: true, via: selector, text }; } } for (const btn of document.querySelectorAll('button, [role="button"]')) { if (!isVisible(btn)) continue; const text = buttonText(btn); if (text !== '发布') continue; if (isDisabled(btn)) { return { found: true, clickable: false, reason: '发布按钮 disabled', text }; } return { found: true, clickable: true, via: 'generic-button', text }; } return { found: false, clickable: false }; })() """ def wait_for_publish_button_clickable(page: Page, max_wait: float = 60.0) -> None: """等待发布按钮出现且可点击。""" deadline = time.monotonic() + max_wait last_reason = "" while time.monotonic() < deadline: result = page.evaluate(_FIND_PUBLISH_BUTTON_JS) if result.get("found") and result.get("clickable"): return if result.get("found"): last_reason = str(result.get("reason") or "发布按钮暂不可点击") time.sleep(1) detail = last_reason or "等待发布按钮超时" raise UploadTimeoutError(f"等待发布按钮可点击超时: {detail}") def click_publish_button(page: Page, max_wait: float = 60.0) -> None: """点击发布按钮(兼容新版 xhs-publish-btn / ce-btn / 旧版 bg-red)。 Raises: PublishError: 点击失败。 """ deadline = time.monotonic() + max_wait last_reason = "" while time.monotonic() < deadline: result = page.evaluate(_TRY_CLICK_PUBLISH_BUTTON_JS) if result.get("ok"): time.sleep(3) logger.info("发布完成 (via=%s)", result.get("via")) return if result.get("found"): last_reason = str(result.get("reason") or "发布按钮暂不可点击") else: last_reason = "页面上没有匹配的发布按钮" time.sleep(1) debug_info = page.evaluate(_DEBUG_PUBLISH_BUTTONS_JS) logger.error("发布按钮调试信息: %s", debug_info) detail = last_reason or "等待发布按钮超时" raise PublishError(f"未找到发布按钮: {detail}") def save_as_draft(page: Page) -> None: """点击「暂存离开」按钮保存草稿。""" clicked = page.evaluate( """ (() => { const buttons = document.querySelectorAll('button.custom-button'); for (const btn of buttons) { if (btn.textContent.trim() === '暂存离开') { btn.click(); return true; } } return false; })() """ ) if clicked: time.sleep(2) logger.info("已点击「暂存离开」,内容已保存到草稿箱") else: logger.warning("未找到「暂存离开」按钮") raise PublishError("未找到「暂存离开」按钮") # ========== 页面导航 ========== def _navigate_to_publish_page(page: Page) -> None: """导航到发布页面。""" page.navigate(PUBLISH_URL) page.wait_for_load(timeout=300) time.sleep(3) page.wait_dom_stable() time.sleep(2) def _click_publish_tab(page: Page, tab_name: str) -> None: """点击发布页 TAB(上传图文/上传视频)。""" deadline = time.monotonic() + 15 while time.monotonic() < deadline: # 查找匹配的 TAB(支持多种结构) found = page.evaluate( f""" (() => {{ const tabName = {json.dumps(tab_name)}; function isTabVisible(el) {{ const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return false; if (style.display === 'none' || style.visibility === 'hidden') return false; const opacity = parseFloat(style.opacity); if (!isNaN(opacity) && opacity <= 0.05) return false; if (el.getAttribute('aria-hidden') === 'true') return false; const tabindex = el.getAttribute('tabindex'); if (tabindex === '-1') {{ const cls = el.getAttribute('class') || ''; if (!/(^|\\s)active(\\s|$)/.test(cls)) return false; }} const inlineStyle = el.getAttribute('style') || ''; if (inlineStyle.includes('opacity: 1e-05')) return false; if (/opacity:\\s*0(\\s|;|$)/.test(inlineStyle)) return false; return true; }} function isBlocked(el) {{ const rect = el.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const target = document.elementFromPoint(x, y); return !(target === el || el.contains(target)); }} let anyBlocked = false; // 策略1: 查找 div.creator-tab(跳过隐藏叠加层,遍历全部同名 TAB) const tabs = document.querySelectorAll({json.dumps(CREATOR_TAB)}); for (const tab of tabs) {{ const titleSpan = tab.querySelector('span.title'); const tabText = titleSpan ? titleSpan.textContent.trim() : tab.textContent.trim(); if (tabText !== tabName) continue; if (!isTabVisible(tab)) continue; if (isBlocked(tab)) {{ anyBlocked = true; continue; }} tab.click(); return 'clicked'; }} // 策略2: 查找任意包含目标文本的叶子元素 for (const el of document.querySelectorAll('*')) {{ if (el.children.length !== 0) continue; if (el.textContent.trim() !== tabName) continue; if (!isTabVisible(el)) continue; if (isBlocked(el)) {{ anyBlocked = true; continue; }} el.click(); return 'clicked'; }} if (anyBlocked) return 'blocked'; return 'not_found'; }})() """ ) if found == "clicked": return if found == "blocked": # 尝试移除弹窗 _remove_pop_cover(page) time.sleep(0.2) # 调试:输出页面信息 debug_info = page.evaluate(""" (() => { const creatorTabs = document.querySelectorAll('div.creator-tab'); const tabTexts = Array.from(creatorTabs).map(t => ({ text: t.textContent.trim(), html: t.outerHTML.substring(0, 200) })); const url = window.location.href; return JSON.stringify({url, tabCount: creatorTabs.length, tabs: tabTexts}); })() """) logger.error("调试信息: %s", debug_info) raise PublishError(f"没有找到发布 TAB - {tab_name}") def _remove_pop_cover(page: Page) -> None: """移除弹窗遮挡。""" if page.has_element(POPOVER): page.remove_element(POPOVER) # 点击空位置 x = 380 + random.randint(0, 100) y = 20 + random.randint(0, 60) page.mouse_click(float(x), float(y)) # ========== 图片上传 ========== _IS_PANEL_VISIBLE_JS = """ function isPanelVisible(el) { if (!el) return false; const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return false; if (style.display === 'none' || style.visibility === 'hidden') return false; const opacity = parseFloat(style.opacity); if (!isNaN(opacity) && opacity <= 0.05) return false; if (el.getAttribute('aria-hidden') === 'true') return false; return true; } """ _MARK_IMAGE_INPUT_JS = """ (() => { """ + _IS_PANEL_VISIBLE_JS + """ document.querySelectorAll('input[data-xhs-upload-target]').forEach((el) => { el.removeAttribute('data-xhs-upload-target'); }); function isImageAccept(accept) { const value = accept || ''; return ( value.includes('image') || value.includes('.jpg') || value.includes('.jpeg') || value.includes('.png') || value.includes('.gif') || value.includes('.webp') ); } function hasVisibleUploadAncestor(input) { let node = input.parentElement; while (node && node !== document.body) { if (node.matches && node.matches('div.upload-content')) { return isPanelVisible(node); } node = node.parentElement; } node = input.parentElement; while (node && node !== document.body) { if (isPanelVisible(node)) return true; node = node.parentElement; } return false; } const inputs = Array.from(document.querySelectorAll('input[type="file"]')); for (const input of inputs) { const accept = input.getAttribute('accept') || ''; if (inputs.length > 1 && !isImageAccept(accept)) continue; if (!hasVisibleUploadAncestor(input)) continue; input.setAttribute('data-xhs-upload-target', 'active'); return 'input[type="file"][data-xhs-upload-target="active"]'; } return null; })() """ _DISPATCH_FILE_INPUT_CHANGE_JS = """ ((selector) => { const input = document.querySelector(selector); if (!input) return false; input.dispatchEvent(new Event('change', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true })); return true; }) """ _COUNT_UPLOADED_IMAGES_JS = """ (() => { const previewSelectors = [ '.img-preview-area .pr', '.img-preview-area img[src]', '.img-preview-area [class*="preview"]', '[class*="img-preview"] img[src]', ]; let previewCount = 0; for (const sel of previewSelectors) { const items = Array.from(document.querySelectorAll(sel)).filter((el) => { const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }); previewCount = Math.max(previewCount, items.length); } const stillUploading = !!document.querySelector( '[class*="upload"][class*="progress"], [class*="uploading"], [class*="loading"][class*="image"]' ); const titleReady = !!Array.from( document.querySelectorAll('input, textarea, [contenteditable="true"]') ).find((el) => { if (!el.offsetParent) return false; const placeholder = (el.getAttribute('placeholder') || '').trim(); const cls = el.className ? String(el.className) : ''; const maxLength = Number(el.getAttribute('maxlength') || 0); return ( placeholder.includes('标题') || placeholder.includes('赞') || /title/i.test(placeholder) || /title/i.test(cls) || maxLength === 20 ); }); return { previewCount, stillUploading, titleReady }; })() """ _INSPECT_PUBLISH_SURFACE_JS = """ (() => { """ + _IS_PANEL_VISIBLE_JS + """ const text = (document.body?.innerText || '').replace(/\\s+/g, ' ').trim(); const hasTitleInput = !!Array.from(document.querySelectorAll('input, textarea')).find((el) => { if (!el.offsetParent) return false; const placeholder = (el.getAttribute('placeholder') || '').trim(); const cls = el.className ? String(el.className) : ''; const maxLength = Number(el.getAttribute('maxlength') || 0); return ( placeholder.includes('标题') || /title/i.test(placeholder) || /title/i.test(cls) || maxLength === 20 ); }); const hasImageInput = !!Array.from(document.querySelectorAll('input[type="file"]')).find((el) => { const accept = el.getAttribute('accept') || ''; return ( accept.includes('image') || accept.includes('.jpg') || accept.includes('.jpeg') || accept.includes('.png') || accept.includes('.gif') || accept.includes('.webp') ); }); const hasVideoSurface = text.includes('拖拽视频到此处点击上传') || text.includes('上传视频'); const state = hasTitleInput ? 'editor_ready' : hasImageInput || !hasVideoSurface ? 'image_surface' : 'video_surface'; return state; })() """ def _wait_for_image_publish_surface(page: Page, timeout: float = 10.0) -> None: """等待进入图文上传面板(避免仍停留在视频发布页)。""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: state = page.evaluate(_INSPECT_PUBLISH_SURFACE_JS) if state != "video_surface": return time.sleep(0.5) raise PublishError("未能进入「上传图文」发布页,当前仍在视频发布页") def _resolve_image_upload_selector(page: Page) -> str: """定位可见的图片 file input,避免命中隐藏 TAB 里的上传框。""" selector = page.evaluate(_MARK_IMAGE_INPUT_JS) if selector: return selector # 兜底:旧版 upload-input if page.has_element(UPLOAD_INPUT): return UPLOAD_INPUT if page.has_element(FILE_INPUT): return FILE_INPUT raise PublishError("找不到可见的图片上传输入框") def _upload_images(page: Page, image_paths: list[str]) -> None: """逐张上传图片。""" import os valid_paths = [p for p in image_paths if os.path.exists(p)] if not valid_paths: raise PublishError("没有有效的图片文件") for i, path in enumerate(valid_paths): selector = _resolve_image_upload_selector(page) logger.info("上传第 %d 张图片: %s (selector=%s)", i + 1, path, selector) page.set_file_input(selector, [path]) page.evaluate(f"{_DISPATCH_FILE_INPUT_CHANGE_JS}({json.dumps(selector)})") _wait_for_upload_complete(page, i + 1) time.sleep(1) def _wait_for_upload_complete(page: Page, expected_count: int) -> None: """等待图片上传完成。""" max_wait = 90.0 start = time.monotonic() last_log_count = expected_count - 1 while time.monotonic() - start < max_wait: status = page.evaluate(_COUNT_UPLOADED_IMAGES_JS) preview_count = int(status.get("previewCount") or 0) still_uploading = bool(status.get("stillUploading")) title_ready = bool(status.get("titleReady")) if preview_count != last_log_count: logger.info( "等待图片上传: current=%d expected=%d uploading=%s title_ready=%s", preview_count, expected_count, still_uploading, title_ready, ) last_log_count = preview_count if preview_count >= expected_count: logger.info("图片上传完成: %d", preview_count) return # 新版创作者中心:首张图上传后会渲染标题编辑区 if expected_count == 1 and title_ready and not still_uploading: logger.info("图片上传完成(检测到编辑表单已出现)") return time.sleep(0.5) debug_info = page.evaluate( f""" (() => {{ const status = {_COUNT_UPLOADED_IMAGES_JS}; const inputs = Array.from(document.querySelectorAll('input[type="file"]')).map((el) => ({{ accept: el.getAttribute('accept') || '', visible: !!el.offsetParent, marked: el.getAttribute('data-xhs-upload-target') || '', }})); return JSON.stringify({{ status, inputs, url: location.href }}); }})() """ ) logger.error("图片上传调试信息: %s", debug_info) raise UploadTimeoutError(f"第{expected_count}张图片上传超时(90s)") # ========== 表单提交 ========== def _extract_hashtags_from_content(content: str, tags: list[str]) -> tuple[str, list[str]]: """从正文末尾提取 hashtag 行,合并到 tags 列表。 Returns: (cleaned_content, merged_tags) """ lines = content.rstrip().split("\n") # 检查最后一行是否全是 #tag 格式 if lines: last_line = lines[-1].strip() hashtag_pattern = re.compile(r"^(#\S+\s*)+$") if hashtag_pattern.match(last_line): # 提取 hashtag extracted = re.findall(r"#(\S+)", last_line) # 合并到 tags(去重) existing = {t.lstrip("#") for t in tags} merged = list(tags) for t in extracted: if t not in existing: merged.append(t) existing.add(t) # 去掉最后一行 cleaned = "\n".join(lines[:-1]).rstrip() logger.info("从正文末尾提取 %d 个标签,合并后共 %d 个", len(extracted), len(merged)) return cleaned, merged return content, list(tags) def _fill_publish_form( page: Page, title: str, content: str, tags: list[str], schedule_time: str | None, is_original: bool, visibility: str, ) -> None: """填写表单(不点击发布)。""" # 从正文末尾提取 hashtag 并合并到 tags content, tags = _extract_hashtags_from_content(content, tags) # 标题——填写前先校验长度,超限直接报错(由 AI 重新生成标题) from title_utils import calc_title_length title_len = calc_title_length(title) if title_len > 20: raise TitleTooLongError(str(title_len), "20") page.input_text(TITLE_INPUT, title) time.sleep(0.5) _check_title_max_length(page) logger.info("标题长度检查通过") time.sleep(1) # 正文 content_selector = _find_content_element(page) page.input_content_editable(content_selector, content) # 回点标题(增强稳定性) time.sleep(1) page.click_element(TITLE_INPUT) logger.info("已回点标题输入框") # 标签 if tags: _input_tags(page, content_selector, tags) time.sleep(1) _check_content_max_length(page) logger.info("正文长度检查通过") # 定时发布 if schedule_time: _set_schedule_publish(page, schedule_time) # 可见范围 _set_visibility(page, visibility) # 原创声明 if is_original: try: _set_original(page) logger.info("已声明原创") except Exception as e: logger.warning("设置原创声明失败: %s", e) logger.info("表单填写完成,等待确认发布") def _find_content_element(page: Page) -> str: """查找内容输入框(兼容两种 UI)。""" if page.has_element(CONTENT_EDITOR): return CONTENT_EDITOR # 查找带 placeholder 的 p 元素的 textbox 父元素 found = page.evaluate( """ (() => { const ps = document.querySelectorAll('p'); for (const p of ps) { const placeholder = p.getAttribute('data-placeholder'); if (placeholder && placeholder.includes('输入正文描述')) { let current = p; for (let i = 0; i < 5; i++) { current = current.parentElement; if (!current) break; if (current.getAttribute('role') === 'textbox') { return 'found'; } } } } return ''; })() """ ) if found == "found": return "[role='textbox']" raise PublishError("没有找到内容输入框") def _check_title_max_length(page: Page) -> None: """检查标题长度是否超限。""" text = page.get_element_text(TITLE_MAX_SUFFIX) if text: parts = text.split("/") if len(parts) == 2: raise TitleTooLongError(parts[0], parts[1]) raise TitleTooLongError(text, "?") def _check_content_max_length(page: Page) -> None: """检查正文长度是否超限。""" text = page.get_element_text(CONTENT_LENGTH_ERROR) if text: parts = text.split("/") if len(parts) == 2: raise ContentTooLongError(parts[0], parts[1]) raise ContentTooLongError(text, "?") # ========== 标签输入 ========== def _input_tags(page: Page, content_selector: str, tags: list[str]) -> None: """输入标签。""" time.sleep(1) # 先记录当前段落数(insertParagraph 之前),之后用于精确定位正文最后一段 # 注意:必须在 insertParagraph 之前记录,否则 para_count_before 会包含新增的 tags 行 para_count_before = int(page.evaluate( f'document.querySelector("{content_selector}").querySelectorAll("p").length' ) or 1) # 用 evaluate 直接 focus 编辑器、光标移到末尾并换行一次 # 避免 click_element 因 isTrusted=false 无法真正 focus Quill 编辑器的问题 page.evaluate( f""" (() => {{ const el = document.querySelector("{content_selector}"); if (!el) return; el.focus(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); document.execCommand("insertParagraph", false, null); }})() """ ) time.sleep(0.5) for tag in tags: tag = tag.lstrip("#") _input_single_tag(page, content_selector, tag) # 输入完所有 tags 后,回到正文最后一段(tags 输入前的最后一段)末尾,按下回车 # 用 para_count_before 精确定位,避免 tags 输入后 Quill 自动新增空段导致偏移 page.evaluate( f""" (() => {{ const el = document.querySelector("{content_selector}"); if (!el) return; const paras = el.querySelectorAll("p"); // tags 输入前最后一段的索引 = para_count_before - 1 const lastContent = paras[{para_count_before} - 1]; if (!lastContent) return; el.focus(); const range = document.createRange(); range.selectNodeContents(lastContent); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); document.execCommand("insertParagraph", false, null); }})() """ ) time.sleep(0.3) def _input_single_tag(page: Page, content_selector: str, tag: str) -> None: """输入单个标签。""" # 输入 # page.type_text("#", delay_ms=0) time.sleep(0.3) # 逐字输入标签(随机间隔模拟真实输入) for char in tag: page.type_text(char, delay_ms=0) time.sleep(random.uniform(0.05, 0.12)) # 等待标签联想出现(最多 3 秒) deadline = time.monotonic() + 3.0 clicked = False while time.monotonic() < deadline: time.sleep(0.5) if page.has_element(TAG_TOPIC_CONTAINER): item_selector = f"{TAG_TOPIC_CONTAINER} {TAG_FIRST_ITEM}" if page.has_element(item_selector): page.click_element(item_selector) logger.info("点击标签联想: %s", tag) clicked = True break if not clicked: # 没有联想,直接空格 logger.warning("未找到标签联想,直接输入空格: %s", tag) page.type_text(" ", delay_ms=0) time.sleep(0.8) # ========== 定时发布 ========== def _set_schedule_publish(page: Page, schedule_time: str) -> None: """设置定时发布。""" from datetime import datetime # 解析 ISO8601 时间 try: dt = datetime.fromisoformat(schedule_time) except ValueError as e: raise PublishError(f"定时发布时间格式错误: {e}") from e # 点击定时发布开关 page.click_element(SCHEDULE_SWITCH) time.sleep(0.8) # 设置日期时间 datetime_str = dt.strftime("%Y-%m-%d %H:%M") page.select_all_text(DATETIME_INPUT) page.input_text(DATETIME_INPUT, datetime_str) time.sleep(0.5) logger.info("已设置定时发布: %s", datetime_str) # ========== 可见范围 ========== def _set_visibility(page: Page, visibility: str) -> None: """设置可见范围。""" if not visibility or visibility == "公开可见": logger.info("可见范围: 公开可见(默认)") return supported = {"仅自己可见", "仅互关好友可见"} if visibility not in supported: raise PublishError( f"不支持的可见范围: {visibility},支持: 公开可见、仅自己可见、仅互关好友可见" ) # 点击下拉框 page.click_element(VISIBILITY_DROPDOWN) time.sleep(0.5) # 查找并点击目标选项 clicked = page.evaluate( f""" (() => {{ const opts = document.querySelectorAll({json.dumps(VISIBILITY_OPTIONS)}); for (const opt of opts) {{ if (opt.textContent.includes({json.dumps(visibility)})) {{ opt.click(); return true; }} }} return false; }})() """ ) if not clicked: raise PublishError(f"未找到可见范围选项: {visibility}") logger.info("已设置可见范围: %s", visibility) time.sleep(0.2) # ========== 原创声明 ========== def _set_original(page: Page) -> None: """设置原创声明。""" # 查找原创声明卡片并点击开关 result = page.evaluate( f""" (() => {{ const cards = document.querySelectorAll({json.dumps(ORIGINAL_SWITCH_CARD)}); for (const card of cards) {{ if (!card.textContent.includes('原创声明')) continue; const sw = card.querySelector({json.dumps(ORIGINAL_SWITCH)}); if (!sw) continue; const input = sw.querySelector('input[type="checkbox"]'); if (input && input.checked) return 'already_on'; sw.click(); return 'clicked'; }} return 'not_found'; }})() """ ) if result == "already_on": logger.info("原创声明已开启") return if result == "not_found": raise PublishError("未找到原创声明选项") time.sleep(0.5) # 处理确认弹窗 _confirm_original_declaration(page) def _confirm_original_declaration(page: Page) -> None: """处理原创声明确认弹窗。""" time.sleep(0.8) # 勾选 checkbox page.evaluate( """ (() => { const footers = document.querySelectorAll('div.footer'); for (const footer of footers) { if (!footer.textContent.includes('原创声明须知')) continue; const cb = footer.querySelector('div.d-checkbox input[type="checkbox"]'); if (cb && !cb.checked) cb.click(); return; } })() """ ) time.sleep(0.5) # 点击声明原创按钮 result = page.evaluate( """ (() => { const footers = document.querySelectorAll('div.footer'); for (const footer of footers) { if (!footer.textContent.includes('声明原创')) continue; const btn = footer.querySelector('button.custom-button'); if (btn) { if (btn.classList.contains('disabled') || btn.disabled) { const cb = footer.querySelector('div.d-checkbox input[type="checkbox"]'); if (cb && !cb.checked) cb.click(); return 'button_disabled'; } btn.click(); return 'clicked'; } } return 'button_not_found'; })() """ ) if result == "button_not_found": raise PublishError("未找到声明原创按钮") if result == "button_disabled": raise PublishError("声明原创按钮仍处于禁用状态") logger.info("已成功点击声明原创按钮") time.sleep(0.3)