- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手) - 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度 - 登录页重构,整合微信登录与 OMS JWT 认证 - 订单、渠道、用户中心等页面与 API 增强 Co-authored-by: Cursor <cursoragent@cursor.com>
1126 lines
37 KiB
Python
1126 lines
37 KiB
Python
"""图文发布,对应 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)
|