Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,358 @@
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)