Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
const { createCanvas, loadImage, registerFont } = require('canvas');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// --- Configuration ---
|
||||
const WIDTH = 1080;
|
||||
const HEIGHT = 1440;
|
||||
const PADDING = 80;
|
||||
|
||||
// Colors (Systemic Flux Palette)
|
||||
const COLORS = {
|
||||
bg: '#0D0E12', // Deep Charcoal/Black
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#A0A0A0',
|
||||
accent1: '#00FF94', // Neon Green (Success/Active)
|
||||
accent2: '#5E5CE6', // Indigo (Processing)
|
||||
grid: '#2A2A2A',
|
||||
surface: '#1A1B20',
|
||||
};
|
||||
|
||||
// Font Paths - Try multiple locations with fallback
|
||||
// Priority: 1) NOMIFUN_FONTS_DIR env var, 2) skills/canvas-design relative path, 3) system fonts
|
||||
function getFontDir() {
|
||||
const candidates = [
|
||||
process.env.NOMIFUN_FONTS_DIR,
|
||||
path.join(__dirname, '../../canvas-design/canvas-fonts'),
|
||||
path.join(process.env.HOME || '', 'Library/Application Support/Nomi/config/skills/canvas-design/canvas-fonts'),
|
||||
path.join(process.env.APPDATA || '', 'Nomi/config/skills/canvas-design/canvas-fonts'),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const FONT_DIR = getFontDir();
|
||||
const FONTS = FONT_DIR
|
||||
? {
|
||||
monoBold: path.join(FONT_DIR, 'JetBrainsMono-Bold.ttf'),
|
||||
monoReg: path.join(FONT_DIR, 'JetBrainsMono-Regular.ttf'),
|
||||
sansReg: path.join(FONT_DIR, 'InstrumentSans-Regular.ttf'),
|
||||
sansBold: path.join(FONT_DIR, 'InstrumentSans-Bold.ttf'),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Register Fonts (skip if fonts not found - will use system defaults)
|
||||
if (FONTS) {
|
||||
try {
|
||||
registerFont(FONTS.monoBold, { family: 'Mono', weight: 'bold' });
|
||||
registerFont(FONTS.monoReg, { family: 'Mono', weight: 'normal' });
|
||||
registerFont(FONTS.sansReg, { family: 'Sans', weight: 'normal' });
|
||||
registerFont(FONTS.sansBold, { family: 'Sans', weight: 'bold' });
|
||||
} catch (e) {
|
||||
console.warn('Custom fonts not available, using system defaults:', e.message);
|
||||
}
|
||||
} else {
|
||||
console.warn('Font directory not found, using system default fonts');
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function drawGrid(ctx, w, h, step = 60) {
|
||||
ctx.strokeStyle = COLORS.grid;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x <= w; x += step) {
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
}
|
||||
for (let y = 0; y <= h; y += step) {
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Add some "data points" - crosses at intersections
|
||||
ctx.fillStyle = COLORS.textSecondary;
|
||||
for (let x = step; x < w; x += step * 4) {
|
||||
for (let y = step; y < h; y += step * 4) {
|
||||
ctx.fillRect(x - 2, y - 1, 4, 2);
|
||||
ctx.fillRect(x - 1, y - 2, 2, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawParagraph(ctx, text, x, y, maxWidth, lineHeight) {
|
||||
const chars = text.split('');
|
||||
let line = '';
|
||||
let currentY = y;
|
||||
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
let char = chars[i];
|
||||
if (/[a-zA-Z0-9@]/.test(char)) {
|
||||
let word = char;
|
||||
while (i + 1 < chars.length && /[a-zA-Z0-9@.]/.test(chars[i + 1])) {
|
||||
word += chars[++i];
|
||||
}
|
||||
char = word;
|
||||
}
|
||||
|
||||
const testLine = line + char;
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && line !== '') {
|
||||
ctx.fillText(line, x, currentY);
|
||||
line = char;
|
||||
currentY += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
ctx.fillText(line, x, currentY);
|
||||
return currentY + lineHeight;
|
||||
}
|
||||
|
||||
function drawTechDecoration(ctx) {
|
||||
ctx.fillStyle = COLORS.surface;
|
||||
ctx.strokeStyle = COLORS.textSecondary;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(WIDTH - 250, 40, 210, 60);
|
||||
|
||||
ctx.font = 'bold 16px Mono';
|
||||
ctx.fillStyle = COLORS.accent1;
|
||||
ctx.fillText('STATUS: ONLINE', WIDTH - 230, 75);
|
||||
ctx.fillStyle = COLORS.accent1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(WIDTH - 60, 70, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = COLORS.accent2;
|
||||
ctx.fillRect(40, HEIGHT - 50, 40, 40);
|
||||
ctx.fillStyle = COLORS.textSecondary;
|
||||
ctx.font = '14px Mono';
|
||||
ctx.fillText('SYS.VER.2026.01', 90, HEIGHT - 25);
|
||||
}
|
||||
|
||||
// --- Image 1: Cover ---
|
||||
function generateCover(title1, title2, slogan1, slogan2) {
|
||||
const canvas = createCanvas(WIDTH, HEIGHT);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.fillStyle = COLORS.bg;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
drawGrid(ctx, WIDTH, HEIGHT);
|
||||
drawTechDecoration(ctx);
|
||||
|
||||
ctx.fillStyle = COLORS.accent1;
|
||||
ctx.font = 'bold 24px Mono';
|
||||
ctx.fillText('// WE ARE HIRING', PADDING, 300);
|
||||
|
||||
ctx.fillStyle = COLORS.textPrimary;
|
||||
ctx.font = 'bold 100px Mono';
|
||||
ctx.fillText(title1 || 'AGENT', PADDING, 420);
|
||||
ctx.fillText(title2 || 'DESIGNER', PADDING, 520);
|
||||
|
||||
const cx = WIDTH / 2;
|
||||
const cy = HEIGHT / 2 + 150;
|
||||
|
||||
ctx.strokeStyle = COLORS.accent2;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 150, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = COLORS.accent1;
|
||||
ctx.setLineDash([10, 10]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 170, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
ctx.strokeStyle = COLORS.textSecondary;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = (i / 8) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(angle) * 150, cy + Math.sin(angle) * 150);
|
||||
ctx.lineTo(cx + Math.cos(angle) * 300, cy + Math.sin(angle) * 300);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = COLORS.surface;
|
||||
ctx.fillRect(cx + Math.cos(angle) * 300 - 10, cy + Math.sin(angle) * 300 - 10, 20, 20);
|
||||
ctx.strokeRect(cx + Math.cos(angle) * 300 - 10, cy + Math.sin(angle) * 300 - 10, 20, 20);
|
||||
}
|
||||
|
||||
ctx.fillStyle = COLORS.textPrimary;
|
||||
ctx.font = 'normal 48px Sans';
|
||||
ctx.fillText(slogan1 || '寻找未来的定义者', PADDING, HEIGHT - 200);
|
||||
|
||||
ctx.fillStyle = COLORS.textSecondary;
|
||||
ctx.font = 'normal 32px Sans';
|
||||
ctx.fillText(slogan2 || 'Redefine the Human-AI Interaction', PADDING, HEIGHT - 150);
|
||||
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
fs.writeFileSync('cover.png', buffer);
|
||||
console.log('Created cover.png');
|
||||
}
|
||||
|
||||
// --- Image 2: JD ---
|
||||
function generateJD(roleTitle, roleDesc, responsibilities, requirements, email) {
|
||||
const canvas = createCanvas(WIDTH, HEIGHT);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.fillStyle = COLORS.bg;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
ctx.globalAlpha = 0.3;
|
||||
drawGrid(ctx, WIDTH, HEIGHT);
|
||||
ctx.globalAlpha = 1.0;
|
||||
|
||||
ctx.fillStyle = COLORS.surface;
|
||||
ctx.fillRect(0, 0, WIDTH, 200);
|
||||
|
||||
ctx.fillStyle = COLORS.accent1;
|
||||
ctx.font = 'bold 20px Mono';
|
||||
ctx.fillText('// OPEN POSITION', PADDING, 60);
|
||||
|
||||
ctx.fillStyle = COLORS.textPrimary;
|
||||
ctx.font = 'bold 60px Mono';
|
||||
ctx.fillText(roleTitle || 'AGENT DESIGNER', PADDING, 140);
|
||||
|
||||
let cursorY = 260;
|
||||
const contentWidth = WIDTH - PADDING * 2;
|
||||
|
||||
// 1. Role Description
|
||||
ctx.fillStyle = COLORS.accent2;
|
||||
ctx.font = 'bold 32px Mono';
|
||||
ctx.fillText('< ROLE >', PADDING, cursorY);
|
||||
cursorY += 50;
|
||||
|
||||
ctx.fillStyle = COLORS.textPrimary;
|
||||
ctx.font = 'normal 30px Sans';
|
||||
cursorY = drawParagraph(ctx, roleDesc, PADDING, cursorY, contentWidth, 45);
|
||||
cursorY += 40;
|
||||
|
||||
// 2. Responsibilities
|
||||
ctx.fillStyle = COLORS.accent2;
|
||||
ctx.font = 'bold 32px Mono';
|
||||
ctx.fillText('< RESPONSIBILITIES >', PADDING, cursorY);
|
||||
cursorY += 50;
|
||||
|
||||
ctx.font = 'normal 28px Sans';
|
||||
(responsibilities || []).forEach((duty) => {
|
||||
ctx.fillStyle = COLORS.textPrimary;
|
||||
cursorY = drawParagraph(ctx, duty, PADDING, cursorY, contentWidth, 40);
|
||||
cursorY += 10;
|
||||
});
|
||||
cursorY += 30;
|
||||
|
||||
// 3. Requirements
|
||||
ctx.fillStyle = COLORS.accent2;
|
||||
ctx.font = 'bold 32px Mono';
|
||||
ctx.fillText('< REQUIREMENTS >', PADDING, cursorY);
|
||||
cursorY += 50;
|
||||
|
||||
(requirements || []).forEach((req) => {
|
||||
ctx.fillStyle = COLORS.textPrimary;
|
||||
cursorY = drawParagraph(ctx, req, PADDING, cursorY, contentWidth, 40);
|
||||
cursorY += 10;
|
||||
});
|
||||
cursorY += 30;
|
||||
|
||||
// 4. Contact Box
|
||||
const boxY = HEIGHT - 250;
|
||||
ctx.strokeStyle = COLORS.accent1;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([10, 10]);
|
||||
ctx.strokeRect(PADDING, boxY, contentWidth, 150);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
ctx.fillStyle = COLORS.surface;
|
||||
ctx.fillRect(PADDING, boxY, contentWidth, 150);
|
||||
|
||||
ctx.fillStyle = COLORS.accent1;
|
||||
ctx.font = 'bold 24px Mono';
|
||||
ctx.fillText('APPLY NOW >>', PADDING + 30, boxY + 50);
|
||||
|
||||
ctx.fillStyle = COLORS.textPrimary;
|
||||
ctx.font = 'bold 40px Sans';
|
||||
ctx.fillText(email || 'contact@example.com', PADDING + 30, boxY + 110);
|
||||
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
fs.writeFileSync('jd_details.png', buffer);
|
||||
console.log('Created jd_details.png');
|
||||
}
|
||||
|
||||
// --- Main execution ---
|
||||
// Defaults
|
||||
const defaults = {};
|
||||
|
||||
// You could parse process.argv here to override defaults if needed
|
||||
// For now, we'll use the hardcoded structure or let the user edit this file.
|
||||
|
||||
generateCover(defaults.title1, defaults.title2, defaults.slogan1, defaults.slogan2);
|
||||
generateJD(defaults.roleTitle, defaults.roleDesc, defaults.responsibilities, defaults.requirements, defaults.email);
|
||||
+358
@@ -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)
|
||||
Reference in New Issue
Block a user