Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: xiaohongshu-recruiter
|
||||
description: 用于准备在小红书上发布高质量的 AI 相关岗位招聘帖子。包含自动生成极客风格的招聘封面图和详情图,并提供确认门控的发布脚本。当用户需要发布招聘信息、寻找 Agent 设计师或其他 AI 领域人才时使用。
|
||||
---
|
||||
|
||||
# Xiaohongshu Recruiter (小红书招聘助手)
|
||||
|
||||
本技能旨在帮助用户快速、专业地准备小红书 AI 岗位招聘信息。通过 "Systemic Flux" 设计理念生成符合极客审美的视觉素材,并提供 Playwright 脚本实现半自动化发布;提交到第三方平台前必须由用户最终确认。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
### 简化模式(默认)
|
||||
|
||||
当用户仅给出一句话指令(如“发布一个前端开发工程师的招聘信息到小红书”)时:
|
||||
|
||||
1. 不再向用户追问细节,由模型自行补全招聘信息与文案。
|
||||
2. 不要求用户提供邮箱或投递方式,模型自动补一个“私信联系/评论联系”的默认投递方式。
|
||||
3. 自动生成封面图与详情图,并准备发布流程。
|
||||
4. 自动打开浏览器、等待用户扫码登录后,可以自动填写图文信息;点击发布前必须展示最终内容并等待用户确认。
|
||||
|
||||
### 1. 信息收集
|
||||
|
||||
向用户确认(仅在用户明确要求或关键信息冲突时才询问):
|
||||
|
||||
- **岗位名称** (如:Agent Designer)
|
||||
- **核心职责 & 要求**
|
||||
- **投递邮箱**
|
||||
|
||||
### 2. 生成视觉素材 (Visual Generation)
|
||||
|
||||
默认使用本地脚本 `scripts/generate_images.js` 生成图片(暂时隐藏/禁用大模型生图流程)。
|
||||
|
||||
- **操作**:
|
||||
```bash
|
||||
node scripts/generate_images.js
|
||||
```
|
||||
_(注:可视情况修改脚本中的文本配置)_
|
||||
- **产出**:`cover.png`, `jd_details.png`
|
||||
|
||||
### 3. 生成文案 (Content Generation)
|
||||
|
||||
生成符合小红书调性的文案,保存为 `post_content.txt`。
|
||||
|
||||
- **规则**:参考 `assets/rules.md`。
|
||||
- **标题**:<20 字。
|
||||
- **正文**:包含话题标签。
|
||||
|
||||
### 4. 自动化发布 (Auto Publishing)
|
||||
|
||||
使用 `scripts/publish_xiaohongshu.py` 启动浏览器进行发布。
|
||||
|
||||
**前置要求**:
|
||||
|
||||
- 安装 Playwright: `pip install playwright`
|
||||
- 安装浏览器驱动: `playwright install chromium`
|
||||
|
||||
**执行命令**:
|
||||
|
||||
```bash
|
||||
python3 scripts/publish_xiaohongshu.py "你的标题" "post_content.txt" "cover.png" "jd_details.png"
|
||||
```
|
||||
|
||||
**交互流程(确认门控发布)**:
|
||||
|
||||
1. 观察浏览器窗口:脚本已打开小红书创作者中心。
|
||||
2. 若出现登录页,请扫码登录。
|
||||
3. 登录完成后,脚本自动上传图片并填写标题与正文。
|
||||
4. 脚本在最终发布前停止并等待用户明确确认;确认后才可点击“发布”。
|
||||
|
||||
## 资源文件
|
||||
|
||||
- **assets/design_philosophy.md**: 视觉设计哲学。
|
||||
- **assets/rules.md**: 详细的操作规范和平台限制。
|
||||
- **scripts/generate_images.js**: 图片生成脚本。
|
||||
- **scripts/publish_xiaohongshu.py**: 发布自动化脚本。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Visual Philosophy: Systemic Flux
|
||||
|
||||
**Movement Name:** Systemic Flux
|
||||
|
||||
**The Philosophy:**
|
||||
|
||||
Systemic Flux is the visual manifestation of intelligence in motion. It bridges the gap between the rigid precision of algorithmic systems and the organic unpredictability of human interaction. It is not merely "tech" design; it is "living system" design. It treats the canvas not as a static page, but as a frozen moment in a continuous data stream.
|
||||
|
||||
**Space and Form:**
|
||||
The composition is governed by a strict, visible grid—a nod to the underlying logic of code. However, elements within this grid break free, creating tension. Forms are geometric but modified—rectangles with clipped corners, circles that are actually dense clusters of data points. Negative space is vast and active, representing the potential for computation. It is the silence before the output.
|
||||
|
||||
**Color and Material:**
|
||||
The palette is rooted in the "Dark Mode" of a developer's IDE—deep voids of charcoal and midnight blue—punctuated by "Syntax Highlighting" accents: electric neon green, alert orange, and processing blue. These colors are not decorative; they serve as functional indicators of status and hierarchy. The "material" feels like matte screen glass or anodized aluminum: cool, precise, and premium.
|
||||
|
||||
**Typography:**
|
||||
Typography is the primary interface. It relies heavily on monospaced fonts (referencing the terminal) for headers and data, paired with highly legible sans-serifs for human-readable content. Text is treated as code: structured, indented, and tagged. Scale varies dramatically—from massive, architectural headers that anchor the composition to microscopic "metadata" labels that add texture and credibility.
|
||||
|
||||
**Visual Hierarchy:**
|
||||
Information is layered. The primary message is bold and undeniable. Secondary information is organized into modular "blocks" or "cards," mimicking UI components. Decorative elements are never random; they look like debug overlays, cursor trails, or loading states—functional artifacts of the machine thinking. The overall effect is one of "high-bandwidth transmission": clear, dense, and beautifully engineered.
|
||||
|
||||
**Craftsmanship:**
|
||||
Every alignment is deliberate. Every pixel is accounted for. The final output must look like a high-fidelity rendering of a futuristic operating system, captured at 8K resolution. It radiates the confidence of a system that cannot crash—a masterpiece of digital engineering.
|
||||
@@ -0,0 +1,81 @@
|
||||
# AI Agent 招聘与小红书发布规则 (Rules)
|
||||
|
||||
本规则涵盖了从视觉设计、文案生成到发布流程的全链路标准,旨在确保 AI 相关岗位招聘的高效与专业。
|
||||
|
||||
## 1. 视觉设计规则 (Systemic Flux)
|
||||
|
||||
所有招聘图片必须遵循 "Systemic Flux" (系统流变) 设计理念:
|
||||
|
||||
- **核心风格**:暗色模式 (Charcoal/Black),模拟 IDE 界面。
|
||||
- **色彩方案**:以 #0D0E12 为背景,配合荧光绿 (#00FF94) 作为激活态,靛蓝 (#5E5CE6) 作为处理态。
|
||||
- **构图元素**:
|
||||
- 必须包含严谨的 60px 网格系统。
|
||||
- 装饰元素应包含技术感细节(如状态栏、系统版本号、坐标点)。
|
||||
- 核心图形采用神经网络节点或动态连接线。
|
||||
- **字体规范**:
|
||||
- 标题/代码:JetBrains Mono (Bold)。
|
||||
- 正文:Instrument Sans。
|
||||
|
||||
## 2. 内容生成规则
|
||||
|
||||
### 招聘文案标准
|
||||
|
||||
- **标题**:必须控制在 **20 个字符**以内(含 Emoji),直接点出岗位核心。
|
||||
- **正文结构**:
|
||||
1. **Slogan**:一句吸引人的开场白(如“寻找未来的定义者”)。
|
||||
2. **🔥 职位名称**:清晰标注岗位全称。
|
||||
3. **✨ 岗位职责**:使用列表,涵盖产品全链路设计、交互定义、用户洞察等。
|
||||
4. **🎯 任职要求**:明确年限(如 3 年以上)和核心背景(如 C 端产品经验)。
|
||||
5. **📩 投递方式**:显眼标注邮箱,并告知邮件主题格式。
|
||||
- **Emoji 使用**:适量使用(每段 1-2 个),增强小红书社区的阅读亲和力。
|
||||
|
||||
## 3. 标准执行流程 (SOP)
|
||||
|
||||
本流程记录了发布招聘信息的完整操作路径,供 Agent 调用参考。
|
||||
|
||||
### 第一阶段:素材生成
|
||||
|
||||
1. **环境准备**:
|
||||
- 确保 Node.js 环境可用。
|
||||
- 安装依赖:`npm install canvas`。
|
||||
2. **脚本执行**:
|
||||
- 创建或调用 `generate_images.js`。
|
||||
- 脚本需包含 `Systemic Flux` 配色与布局逻辑。
|
||||
- 运行命令:`node generate_images.js`。
|
||||
3. **产物校验**:
|
||||
- 确认生成 `cover.png` (封面) 和 `jd_details.png` (详情)。
|
||||
- **保留文件**:生成的文件不应自动删除,以便后续上传。
|
||||
|
||||
### 第二阶段:Web 自动化发布
|
||||
|
||||
1. **登录检查**:
|
||||
- 访问 `https://creator.xiaohongshu.com/`。
|
||||
- 检查登录状态(通过截图或 URL 判断)。
|
||||
- 若未登录,暂停流程并提示用户扫码/短信登录。
|
||||
2. **进入发布页**:
|
||||
- 导航至 `https://creator.xiaohongshu.com/publish/publish`。
|
||||
- **关键动作**:点击“上传图文” Tab (Class: `tab`),确保进入图文模式而非视频模式。
|
||||
3. **文件上传**:
|
||||
- 定位上传按钮(`input[type="file"]` 或按钮文本“上传图片”)。
|
||||
- 依次上传 `cover.png` 和 `jd_details.png`。
|
||||
- 等待上传完成(可通过 DOM 变化或 Console 状态确认)。
|
||||
4. **内容填充**:
|
||||
- **标题**:定位标题输入框 (`placeholder="填写标题..."`),填入 <20 字的标题。
|
||||
- _异常处理_:若提示字数超限,需自动截断或重写标题。
|
||||
- **正文**:定位正文输入框 (多行文本框),填入完整招聘文案。
|
||||
5. **发布执行**:
|
||||
- 点击“发布”按钮。
|
||||
- **状态监控**:点击后需监控按钮状态变化(是否变灰/禁用)或页面跳转/Toast 提示(“发布成功”)。
|
||||
- 若无反馈,需进行二次检查(是否有点选“原创声明”等必选项漏选)。
|
||||
|
||||
## 4. 平台技术限制汇总
|
||||
|
||||
- **标题上限**:20 字符(**严格限制**,超出无法发布)。
|
||||
- **正文上限**:1000 字符。
|
||||
- **图片数量**:最多 18 张。
|
||||
- **图片大小**:单张最大 32MB。
|
||||
- **话题限制**:正文底部建议包含 5-10 个核心话题。
|
||||
|
||||
---
|
||||
|
||||
_Created by Recruitment Agent - 2026-01-20_
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Reference Documentation for Xiaohongshu Recruiter
|
||||
|
||||
This is a placeholder for detailed reference documentation.
|
||||
Replace with actual reference content or delete if not needed.
|
||||
|
||||
Example real reference docs from other skills:
|
||||
|
||||
- product-management/references/communication.md - Comprehensive guide for status updates
|
||||
- product-management/references/context_building.md - Deep-dive on gathering context
|
||||
- bigquery/references/ - API references and query examples
|
||||
|
||||
## When Reference Docs Are Useful
|
||||
|
||||
Reference docs are ideal for:
|
||||
|
||||
- Comprehensive API documentation
|
||||
- Detailed workflow guides
|
||||
- Complex multi-step processes
|
||||
- Information too lengthy for main SKILL.md
|
||||
- Content that's only needed for specific use cases
|
||||
|
||||
## Structure Suggestions
|
||||
|
||||
### API Reference Example
|
||||
|
||||
- Overview
|
||||
- Authentication
|
||||
- Endpoints with examples
|
||||
- Error codes
|
||||
- Rate limits
|
||||
|
||||
### Workflow Guide Example
|
||||
|
||||
- Prerequisites
|
||||
- Step-by-step instructions
|
||||
- Common patterns
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
+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