Files
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

359 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys
import os
import time
import subprocess
import socket
from playwright.sync_api import sync_playwright
# Ensure logs flush immediately
try:
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
except Exception:
pass
def log(msg: str) -> None:
print(msg, flush=True)
def find_free_port():
"""Find a free port for Chrome debugging."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
return s.getsockname()[1]
def is_port_in_use(port):
"""Check if a port is already in use."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(('localhost', port)) == 0
def launch_standalone_chrome(profile_dir, debug_port):
"""Launch Chrome as a standalone process that won't close when script exits."""
chrome_paths = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
os.path.expanduser("~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
]
chrome_path = None
for path in chrome_paths:
if os.path.exists(path):
chrome_path = path
break
if not chrome_path:
return None
# Launch Chrome with remote debugging enabled
# Using start_new_session=True makes Chrome independent of this script
# --disable-features=ChromeWhatsNewUI prevents some popups
# --no-service-autorun prevents service workers from keeping Chrome alive
cmd = [
chrome_path,
f"--remote-debugging-port={debug_port}",
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check",
"--disable-features=ChromeWhatsNewUI",
"--disable-background-networking",
"about:blank"
]
try:
# start_new_session=True on Unix creates a new process group
# This prevents Chrome from being killed when the parent script exits
process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
log(f"️ Chrome 进程已启动,PID: {process.pid}")
# Wait for Chrome to start and listen on the debug port
for i in range(30):
if is_port_in_use(debug_port):
log(f"️ Chrome 已就绪,调试端口 {debug_port} 已开放")
return debug_port
time.sleep(0.5)
log("⚠️ Chrome 启动超时,调试端口未开放")
except Exception as e:
log(f"⚠️ 启动独立 Chrome 失败: {e}")
return None
def publish(title, content, images):
"""
Automates the Xiaohongshu publishing process.
"""
log("🚀 小红书发布脚本已启动")
log("操作指南:")
log("1) 观察浏览器窗口:已打开小红书创作者中心。")
log("2) 如果出现登录页,请扫码登录。")
log("3) 登录完成后脚本会自动上传图片并填写标题/正文。")
log('4) 请在浏览器中检查内容,确认无误后点击"发布"')
log("5) 浏览器将保持打开,脚本退出后也不会关闭。")
log(f"标题: {title}")
log(f"图片: {images}")
# Determine profile directory - use a unique directory to avoid conflicts with user's Chrome
env_profile = os.environ.get("XHS_PROFILE_DIR")
default_xhs_profile = os.path.join(os.path.expanduser("~"), ".nomifun", "xiaohongshu-chrome-profile")
profile_dir = env_profile or default_xhs_profile
os.makedirs(profile_dir, exist_ok=True)
log(f"️ 使用浏览器 profile: {profile_dir}")
# Find a port for Chrome debugging
debug_port = 9222
existing_chrome = is_port_in_use(debug_port)
if existing_chrome:
log(f"️ 端口 {debug_port} 已被占用,尝试连接已有 Chrome 实例...")
else:
log("ℹ️ 启动独立 Chrome 进程(脚本退出后浏览器将保持打开)...")
launched_port = launch_standalone_chrome(profile_dir, debug_port)
if not launched_port:
# Fallback: find another port
debug_port = find_free_port()
log(f"️ 尝试使用备用端口 {debug_port}...")
launched_port = launch_standalone_chrome(profile_dir, debug_port)
if launched_port:
debug_port = launched_port
else:
log("⚠️ 无法启动独立 Chrome,将使用 Playwright 托管模式(脚本退出时浏览器可能关闭)")
debug_port = None
with sync_playwright() as p:
if debug_port and is_port_in_use(debug_port):
# Connect to standalone Chrome via CDP
log(f"️ 通过 CDP 连接到 Chrome (端口 {debug_port})...")
browser = p.chromium.connect_over_cdp(f"http://localhost:{debug_port}")
context = browser.contexts[0] if browser.contexts else browser.new_context()
page = context.new_page()
else:
# Fallback to Playwright-managed browser
log("️ 使用 Playwright 托管模式启动浏览器...")
context = p.chromium.launch_persistent_context(profile_dir, headless=False)
page = context.new_page()
try:
# 1. Navigate to Publish Page
log("🌐 正在打开小红书创作者中心...")
page.goto("https://creator.xiaohongshu.com/publish/publish", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=5000)
except Exception:
log("⚠️ networkidle 等待超时,继续执行...")
try:
log(f"️ 当前页面标题: {page.title()}")
except Exception:
log("⚠️ 读取页面标题失败,继续执行...")
# 2. Check login status - wait if on login page
start = time.time()
while "/login" in page.url:
elapsed = int(time.time() - start)
if elapsed == 0 or elapsed % 5 == 0:
log("⚠️ 当前为未登录态,请在打开的窗口完成登录,脚本会自动继续。")
if elapsed > 120:
log("❌ 登录等待超时(2分钟),请手动操作。")
break
time.sleep(2)
# Also check for login prompts on publish page
try:
if page.locator("text=扫码登录").count() > 0:
log("⚠️ 检测到登录弹窗,请扫码登录...")
# Wait for login to complete (URL change or popup disappear)
for _ in range(60):
if page.locator("text=扫码登录").count() == 0:
log("✅ 登录成功!")
break
time.sleep(2)
except Exception:
pass
page.wait_for_timeout(1000)
# 3. Switch to Image Tab - use direct URL navigation for reliability
log("🔄 [步骤 2] 正在切换到图文发布模式...")
current_url = page.url
if "target=video" in current_url or "上传视频" in page.content():
# Navigate directly to image upload mode via URL
page.goto("https://creator.xiaohongshu.com/publish/publish?from=tab_switch", wait_until="domcontentloaded")
page.wait_for_timeout(2000)
# Also try clicking the tab as backup
try:
# Use get_by_text with exact=False to find "上传图文" in the tab area
tabs = page.locator("text=上传图文")
if tabs.count() >= 2:
# The second occurrence is usually the clickable tab
tabs.nth(1).click()
page.wait_for_timeout(1000)
elif tabs.count() == 1:
tabs.first.click()
page.wait_for_timeout(1000)
except Exception as e:
log(f"⚠️ 点击图文标签失败: {e}")
# Verify we're on image upload page
if page.locator("text=上传图片,或写文字生成图片").count() > 0:
log("✅ 已切换到图文发布模式")
else:
log("⚠️ 可能未成功切换,继续尝试...")
# 4. Upload Images BEFORE waiting for form (form appears after upload)
log("📤 [步骤 3] 正在上传图片...")
upload_success = False
try:
# Wait for file input to be present
page.wait_for_selector("input[type='file']", timeout=5000)
# Set input files directly - this works even for hidden inputs
upload_input = page.locator("input[type='file']").first
upload_input.set_input_files(images)
log(f"✅ 已选择 {len(images)} 张图片")
upload_success = True
# Wait for upload to process - look for the image count indicator
log("⏳ 等待图片上传完成...")
for i in range(20):
# Check for "(N/18)" pattern which indicates upload progress
if page.locator("text=/\\(\\d+\\/18\\)/").count() > 0:
log("✅ 图片上传成功")
break
# Also check for title input which appears after upload
if page.locator("input[placeholder*='标题']").count() > 0:
log("✅ 检测到发布表单已加载")
break
time.sleep(0.5)
else:
log("⚠️ 等待上传确认超时,继续执行...")
except Exception as e:
log(f"❌ 图片上传失败:{e}")
log("👉 请手动上传图片后继续")
# 5. NOW wait for form to appear (after image upload)
log("⏳ [步骤 4] 正在等待发布表单加载...")
# Wait for title input to appear (max 30 seconds)
title_input = None
for i in range(15):
# Try multiple selectors
for sel in [
"input[placeholder*='填写标题']",
"input[placeholder*='标题']",
]:
loc = page.locator(sel)
if loc.count() > 0 and loc.first.is_visible():
title_input = loc.first
break
if title_input:
log("✅ 发布表单已加载")
break
if i % 5 == 0:
log(f"⏳ 等待表单加载... ({i*2}s)")
time.sleep(2)
if not title_input:
log("⚠️ 未找到标题输入框,尝试查找可编辑区域...")
# Try contenteditable as fallback
editables = page.locator("div[contenteditable='true']")
if editables.count() > 0:
title_input = editables.first
else:
raise RuntimeError("无法找到任何可输入区域")
# 6. Fill Content
log("✍️ [步骤 5] 正在填写标题与正文...")
# Title (Limit 20 chars)
if len(title) > 20:
log(f"⚠️ 标题过长({len(title)} 字),已截断到 20 字。")
title = title[:20]
try:
title_input.click()
title_input.fill(title)
log(f"✅ 已填写标题: {title}")
# Wait a moment for content area to be ready
page.wait_for_timeout(500)
# Content input - find the multiline textbox (content area)
# Based on observation: it's a textbox that appears after the title
content_selectors = [
"div[contenteditable='true'] p", # Rich text editor paragraph
".ql-editor", # Quill editor
"div[contenteditable='true']",
]
content_input = None
for sel in content_selectors:
loc = page.locator(sel)
if loc.count() > 0:
# Get the last one (content is usually after title)
content_input = loc.last
if content_input.is_visible():
break
if content_input:
content_input.click()
content_input.fill(content)
log("✅ 已填写正文内容")
else:
log("⚠️ 未找到正文输入框")
except Exception as e:
log(f"❌ 填写文本失败:{e}")
log("✨ [步骤 4] 草稿已生成,正在自动发布...")
try:
publish_btn = page.get_by_role("button", name="发布")
publish_btn.wait_for(timeout=10000)
publish_btn.click()
log("✅ 已自动点击发布按钮,请在页面确认发布成功。")
except Exception as e:
log(f"⚠️ 自动点击发布失败:{e}")
log("👉 请手动点击“发布”完成发布。")
except Exception as e:
print(f"❌ 脚本执行中断:{e}")
print("👉 浏览器将保持打开,方便你手动完成发布。")
finally:
# In CDP mode, browser runs independently - script can exit safely
if debug_port and is_port_in_use(debug_port):
log("✅ 脚本已结束。浏览器作为独立进程运行,不会随脚本关闭。")
log("ℹ️ 请在浏览器中完成操作后手动关闭浏览器窗口。")
else:
# Playwright-managed mode - keep script alive to prevent browser close
log("✅ 脚本已结束,浏览器将保持打开,请手动关闭浏览器窗口。")
log("ℹ️ 脚本将持续运行输出心跳,不会主动关闭浏览器。")
try:
while True:
time.sleep(30)
log("⏳ 仍在等待中...(按 Ctrl+C 结束脚本)")
except KeyboardInterrupt:
log("收到退出指令,脚本结束。")
if __name__ == "__main__":
# Usage: python publish_xiaohongshu.py <title> <content_file_path> <img1> <img2> ...
if len(sys.argv) < 4:
print("用法: python publish_xiaohongshu.py <title> <content_file> <img1> [img2 ...]")
sys.exit(1)
title_arg = sys.argv[1]
content_file = sys.argv[2]
image_args = sys.argv[3:]
# Read content from file
if os.path.exists(content_file):
with open(content_file, 'r', encoding='utf-8') as f:
content_arg = f.read()
else:
# Fallback if user passed raw text (not recommended for long text)
content_arg = content_file
publish(title_arg, content_arg, image_args)