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,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);
@@ -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)