Skill: redisread/article-images-gen (version 1.0.3)

--- name: article-images-gen description: 文案插图专家，为文章生成手绘风格插图。风格：手绘、简约、整洁、留白、构图平衡、色调统一。Use when user asks to generate illustrations for articles, "为文章配图", "生成插图", or needs hand-drawn style images for content. version: 2.0.0 metadata: homepage: https://github.com/victor-skills/tree/main/skills/article-images-gen --- # Article Images Generator (文案插图专家) 专为文章生成手绘风格插图，使用 `opencli gemini image`（主）和 `opencli grok image`（降级）生成图片。 ## 调用脚本 ```bash # 为文章生成配图（全自动：分析 → 生成大纲 → 生成提示词 → 生成图片 → 插入文章） bun scripts/illustrator.ts path/to/article.md [--density <level>] [--output-dir <path>] # 单张图片（直接 prompt） bun scripts/main.ts --prompt "your prompt" [--image output.png] # 批量 prompt 文件 bun scripts/main.ts --promptfiles prompt1.md,prompt2.md ``` 密度选项：`minimal` (1-2张) | `balanced` (3-4张) | `per-section`（推荐）| `rich` (5+张) ## 工作流程 1. **分析文案**：识别章节结构、核心论点、适合插图的位置 2. **分析判断**：AI 根据文章章节数量和内容自动选择密度（默认 per-section） 3. **生成大纲**：保存 `{outputDir}/outline.md` 4. **生成提示词**：保存 `{outputDir}/prompts/NN-hand-drawn-{slug}.md` 5. **生成图片**：调用 `opencli gemini image`，失败后降级 `opencli grok image`，保存到 `{outputDir}/` 6. **更新文章**：在对应章节后插入图片引用 `![描述](imgs/01-hand-drawn-xxx.png)` ## 图片生成策略 - **首选**：`opencli gemini image`（16:9 横版） - **降级**：Gemini 失败时自动切换 `opencli grok image` - **超时**：单次生成最长 300 秒 ## 环境要求 - **依赖**：`opencli` 已安装且 `gemini` / `grok` 可用 - **浏览器**：opencli 需要 Chrome 浏览器支持（自动化操作 gemini.google.com / grok.com） ## 输出目录 固定输出到 `/tmp/imageGen/{YYYYMMDD}/{articleName}/`，例如： `/tmp/imageGen/20260410/my-article/01-hand-drawn-xxx.png` `articleName` 取文章文件名（不含扩展名），日期取当天日期。

path: scripts/main.ts
content: #!/usr/bin/env bun
/**
 * Simple single-image generation entry point
 */

import process from "node:process";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { generateImage } from "./image-generator";

type CliArgs = {
  prompt: string | null;
  promptFiles: string | null;
  image: string | null;
  outputDir: string | null;
  help: boolean;
};

function printUsage(): void {
  console.log(`Usage:
  bun scripts/main.ts --prompt "your prompt here"
  bun scripts/main.ts --promptfiles prompt.md
  bun scripts/main.ts --help

Options:
  --prompt <text>        Direct prompt for image generation
  --promptfiles <files>  Comma-separated list of prompt files to read
  --image <path>         Output image path (used with --promptfiles)
  --output-dir <path>    Output directory for generated images
  -h, --help             Show help
`);
}

function parseArgs(argv: string[]): CliArgs {
  const args: CliArgs = {
    prompt: null,
    promptFiles: null,
    image: null,
    outputDir: null,
    help: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i]!;
    if (current === "--prompt") {
      args.prompt = argv[++i] ?? null;
    } else if (current === "--promptfiles") {
      args.promptFiles = argv[++i] ?? null;
    } else if (current === "--image") {
      args.image = argv[++i] ?? null;
    } else if (current === "--output-dir") {
      args.outputDir = argv[++i] ?? null;
    } else if (current === "--help" || current === "-h") {
      args.help = true;
    }
  }

  return args;
}

async function main(): Promise<void> {
  const args = parseArgs(process.argv.slice(2));

  if (args.help) {
    printUsage();
    return;
  }

  const outDir = args.outputDir || "/tmp/imageGen/opencli";
  await mkdir(outDir, { recursive: true });

  if (args.prompt) {
    // Direct prompt mode
    console.log("Generating image (Gemini → Grok fallback)...");

    const result = await generateImage(args.prompt, { outputDir: outDir });

    if (args.image && result.imagePath !== args.image) {
      await writeFile(args.image, await readFile(result.imagePath));
      console.log(`Image copied to: ${args.image}`);
    } else {
      console.log(`Image saved to: ${result.imagePath}`);
    }
    return;
  }

  if (args.promptFiles) {
    // Batch prompt files mode
    const files = args.promptFiles.split(",");
    for (const file of files) {
      const promptContent = await readFile(file.trim(), "utf8");
      const promptText = promptContent.replace(/^---\n[\s\S]*?\n---\n/, "").trim();

      console.log(`Generating image (Gemini → Grok fallback)...`);

      const result = await generateImage(promptText, { outputDir: outDir });

      const outputFile = args.image || file.trim().replace(".md", ".png");
      if (result.imagePath !== outputFile) {
        await writeFile(outputFile, await readFile(result.imagePath));
      }
      console.log(`Image saved to: ${result.imagePath}`);
    }
    return;
  }

  printUsage();
}

main().catch((error) => {
  console.error("Fatal error:", error instanceof Error ? error.message : String(error));
  process.exit(1);
});


path: scripts/article-analyzer.ts
content: import { writeFile, mkdir } from "node:fs/promises";
import path from "node:path";

type IllustrationPosition = {
  index: number;
  section: string;
  purpose: string;
  visualContent: string;
  filename: string;
};

export type OutlineData = {
  style: string;
  density: string;
  image_count: number;
  positions: IllustrationPosition[];
};

/** Convert text to a safe ASCII slug for filenames. Chinese chars use Unicode codepoint hex. */
function toSlug(text: string): string {
  return text
    .split("")
    .map((char) => {
      const code = char.charCodeAt(0);
      if (code >= 0x4e00 && code <= 0x9fff) {
        return code.toString(16);
      }
      return char.toLowerCase();
    })
    .join("")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 40) || "concept";
}

function buildIllustrationPositions(content: string): IllustrationPosition[] {
  const sections = content.split(/^##+\s+/m).filter((s) => s.trim());
  const positions: IllustrationPosition[] = [];

  for (let i = 0; i < sections.length && positions.length < 8; i++) {
    const section = sections[i]!;
    const sectionHeading = section.split("\n")[0]?.replace(/^#+\s*/, "").trim() || "";

    if (!sectionHeading) continue;

    const index = positions.length + 1;
    const filenameSlug = toSlug(sectionHeading);

    positions.push({
      index,
      section: sectionHeading,
      purpose: `Visualize key concept of ${sectionHeading}`,
      visualContent: `Illustrate the main idea of ${sectionHeading} in hand-drawn minimalist style`,
      filename: `${String(index).padStart(2, "0")}-hand-drawn-${filenameSlug}.png`,
    });
  }

  return positions;
}

function recommendDensity(content: string): string {
  const sections = content.split(/^##+\s+/m).filter((s) => s.trim());
  if (sections.length <= 3) return "minimal";
  if (sections.length <= 6) return "balanced";
  if (sections.length <= 10) return "per-section";
  return "rich";
}

function formatOutlineMarkdown(outline: OutlineData, articlePath: string): string {
  const header = `---
style: ${outline.style}
density: ${outline.density}
image_count: ${outline.image_count}
---

# Article Illustration Outline

Article: ${articlePath}
Style: Hand-drawn Minimalist
Density: ${outline.density}
Total Images: ${outline.image_count}

---

`;

  const body = outline.positions
    .map(
      (pos) => `## Illustration ${pos.index}

**Position**: ${pos.section}
**Purpose**: ${pos.purpose}
**Visual Content**: ${pos.visualContent}
**Filename**: ${pos.filename}

---
`
    )
    .join("\n");

  return header + body;
}

export async function generateOutline(
  articlePath: string,
  content: string,
  density: string,
  outputDir: string
): Promise<OutlineData> {
  let positions = buildIllustrationPositions(content);

  if (density === "minimal") {
    positions = positions.slice(0, 2);
  } else if (density === "balanced") {
    positions = positions.slice(0, 4);
  }
  // per-section and rich keep all positions

  const outline: OutlineData = {
    style: "hand-drawn",
    density,
    image_count: positions.length,
    positions,
  };

  await mkdir(outputDir, { recursive: true });
  await writeFile(path.join(outputDir, "outline.md"), formatOutlineMarkdown(outline, articlePath));

  return outline;
}

export async function generatePromptFiles(
  outline: OutlineData,
  articleContent: string,
  outputDir: string
): Promise<string[]> {
  const promptsDir = path.join(outputDir, "prompts");
  await mkdir(promptsDir, { recursive: true });

  const promptFiles: string[] = [];

  for (const position of outline.positions) {
    const promptContent = buildHandDrawnPrompt(articleContent, position.visualContent, position.section);
    const filePath = path.join(promptsDir, position.filename.replace(".png", ".md"));

    await writeFile(filePath, `---
illustration_id: ${position.index}
style: hand-drawn
---

${promptContent}`);
    promptFiles.push(filePath);
  }

  return promptFiles;
}

function buildHandDrawnPrompt(articleContent: string, illustrationDesc: string, sectionTitle: string): string {
  const { primaryColor, accentColor, elements } = extractKeyTerms(articleContent);

  return `请生成一张手绘风格插图，用于文章章节「${sectionTitle}」的配图：

## 主题描述
${illustrationDesc}

## 核心元素
${elements.map((e) => `- ${e}`).join("\n") || "- 抽象概念可视化"}

## 色彩要求
- 主色调：${primaryColor}
- 辅助色：${accentColor}
- 色调统一，风格一致

## 风格要求
- 手绘风格，有手工绘制的质感
- 简约，不复杂
- 整洁，画面干净
- 适当留白，不要填满
- 构图平衡，视觉焦点清晰
- 色调统一，不要过于跳跃
- 不要包含任何文字

## 技术规格
- 横版构图 (16:9)
- 适合文章配图使用
- 便于排版的简洁设计`;
}

function extractKeyTerms(content: string): {
  primaryColor: string;
  accentColor: string;
  elements: string[];
} {
  const elements: string[] = [];
  const lowerContent = content.toLowerCase();

  const quoteMatches = content.match(/[""](.*?)[""]/g) || [];
  for (const quote of quoteMatches.slice(0, 5)) {
    const text = quote.replace(/[""']/g, "").trim();
    if (text.length > 1 && text.length < 30) {
      elements.push(text);
    }
  }

  let primaryColor = "蓝色/青色系";
  let accentColor = "橙色/黄色系";

  if (lowerContent.includes("科技") || lowerContent.includes("tech") || lowerContent.includes("ai") || lowerContent.includes("代码")) {
    primaryColor = "蓝色/紫色系";
    accentColor = "青色/绿色系";
  } else if (lowerContent.includes("温暖") || lowerContent.includes("warm") || lowerContent.includes("story")) {
    primaryColor = "橙色/黄色系";
    accentColor = "红色/粉色调";
  } else if (lowerContent.includes("自然") || lowerContent.includes("nature") || lowerContent.includes("green")) {
    primaryColor = "绿色/青色系";
    accentColor = "黄色/橙色调";
  } else if (lowerContent.includes("商业") || lowerContent.includes("business") || lowerContent.includes("金融")) {
    primaryColor = "深蓝/灰色系";
    accentColor = "金色/橙色调";
  }

  return { primaryColor, accentColor, elements };
}

export { recommendDensity };


path: scripts/illustrator.ts
content: #!/usr/bin/env bun
/**
 * article-images-gen - 文案插图专家
 *
 * 专业的文章配图生成工具，专注于生成高质量的手绘风格插图
 * 使用阿里百炼 Qwen-Image 生成
 */

import path from "node:path";
import process from "node:process";
import { readFile, writeFile, mkdir, access, copyFile } from "node:fs/promises";
import { generateImage } from "./image-generator";
import { generateOutline, generatePromptFiles } from "./article-analyzer";

type CliArgs = {
  articlePath: string | null;
  density: "minimal" | "balanced" | "per-section" | "rich" | null;
  outputDir: string | null;
  help: boolean;
};

const DENSITY_OPTIONS = ["minimal", "balanced", "per-section", "rich"] as const;

function printUsage(): void {
  console.log(`Usage:
  bun scripts/illustrator.ts path/to/article.md
  bun scripts/illustrator.ts path/to/article.md --density balanced
  bun scripts/illustrator.ts --help

Options:
  --density <level>    Image density: minimal (1-2), balanced (3-4), per-section, rich (5+)
  --output-dir <path>  Override output directory
  -h, --help           Show help

Density Levels:
  minimal      Core concepts only (1-2 images)
  balanced     Major sections (3-4 images)
  per-section  At least 1 per section (recommended)
  rich         Comprehensive coverage (5+ images)
`);
}

function parseArgs(argv: string[]): CliArgs {
  const args: CliArgs = {
    articlePath: null,
    density: null,
    outputDir: null,
    help: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i]!;
    if (current === "--density") {
      args.density = argv[++i] as CliArgs["density"];
    } else if (current === "--output-dir") {
      args.outputDir = argv[++i] ?? null;
    } else if (current === "--help" || current === "-h") {
      args.help = true;
    } else if (!current.startsWith("-") && !args.articlePath) {
      args.articlePath = current;
    }
  }

  return args;
}

async function fileExists(filePath: string): Promise<boolean> {
  try {
    await access(filePath);
    return true;
  } catch {
    return false;
  }
}

function determineOutputDir(articlePath: string, overrideDir: string | null): string {
  if (overrideDir) {
    return overrideDir;
  }
  const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
  const articleName = path.basename(articlePath, path.extname(articlePath));
  return `/tmp/imageGen/${today}/${articleName}`;
}

async function generateIllustrationsForArticle(
  articlePath: string,
  articleContent: string,
  density: string,
  outputDir: string
): Promise<void> {
  console.log(`\n📄 Analyzing article: ${articlePath}`);
  console.log(`📊 Density: ${density}`);
  console.log(`📁 Output: ${outputDir}\n`);

  // Step 1: Generate outline
  console.log("Step 1: Generating outline...");
  const outline = await generateOutline(articlePath, articleContent, density, outputDir);
  console.log(`  ✓ Outline saved to ${outputDir}/outline.md`);
  console.log(`  ✓ ${outline.image_count} illustrations planned\n`);

  // Step 2: Generate prompt files
  console.log("Step 2: Generating prompt files...");
  const promptFiles = await generatePromptFiles(outline, articleContent, outputDir);
  console.log(`  ✓ ${promptFiles.length} prompt files saved to ${outputDir}/prompts/\n`);

  // Step 3: Generate images
  console.log("Step 3: Generating images...");
  let successCount = 0;
  let failureCount = 0;

  for (const position of outline.positions) {
    const promptFile = promptFiles.find((f) =>
      f.includes(position.filename.replace(".png", ".md"))
    );

    if (!promptFile) {
      console.log(`  ✗ Illustration ${position.index}: prompt file not found`);
      failureCount++;
      continue;
    }

    const promptContent = await readFile(promptFile, "utf8");
    const promptText = promptContent.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
    const outputPath = path.join(outputDir, position.filename);

    try {
      const result = await generateImage(promptText, { outputDir });
      // Copy the generated image to the expected output path
      await copyFile(result.imagePath, outputPath);
      console.log(`  ✓ Illustration ${position.index}: ${position.filename}`);
      successCount++;
    } catch (error) {
      console.log(
        `  ✗ Illustration ${position.index}: ${error instanceof Error ? error.message : String(error)}`
      );
      failureCount++;
    }

    // Avoid rate limiting between requests
    if (position.index < outline.positions.length) {
      await new Promise((resolve) => setTimeout(resolve, 15000));
    }
  }

  // Step 4: Update article
  console.log("\nStep 4: Updating article...");
  await updateArticleWithImages(articlePath, outline.positions, outputDir);
  console.log(`  ✓ Article updated with ${successCount} image references\n`);

  // Summary
  console.log("=".repeat(50));
  console.log("Article Illustration Complete!");
  console.log(`Article: ${articlePath}`);
  console.log(`Style: Hand-drawn Minimalist`);
  console.log(`Density: ${density}`);
  console.log(`Location: ${outputDir}`);
  console.log(`Images: ${successCount}/${outline.image_count} generated`);
  if (failureCount > 0) {
    console.log(`Failed: ${failureCount}`);
  }
}

async function updateArticleWithImages(
  articlePath: string,
  positions: Array<{ index: number; section: string; filename: string }>,
  outputDir: string
): Promise<void> {
  const content = await readFile(articlePath, "utf8");
  const lines = content.split("\n");
  const newLines: string[] = [];

  const articleDir = path.dirname(articlePath);
  const getRelativeImagePath = (filename: string) => {
    const imagePath = path.join(outputDir, filename);
    return path.relative(articleDir, imagePath);
  };

  for (const line of lines) {
    newLines.push(line);

    const headingMatch = line.match(/^##+\s+(.+)$/);
    if (headingMatch) {
      const heading = headingMatch[1]!.trim();
      const position = positions.find((p) => p.section === heading);
      if (position) {
        const relativePath = getRelativeImagePath(position.filename);
        newLines.push(`![${position.section}](${relativePath})`);
        newLines.push("");
      }
    }
  }

  const backupPath = `${articlePath}.bak-${Date.now()}`;
  await copyFile(articlePath, backupPath);
  console.log(`  Backup created: ${backupPath}`);

  await writeFile(articlePath, newLines.join("\n"));
}

async function main(): Promise<void> {
  const args = parseArgs(process.argv.slice(2));

  if (args.help || !args.articlePath) {
    printUsage();
    return;
  }

  if (!(await fileExists(args.articlePath))) {
    console.error(`Error: Article not found: ${args.articlePath}`);
    process.exit(1);
  }

  const density = args.density || "per-section";
  if (!DENSITY_OPTIONS.includes(density)) {
    console.error(`Error: Invalid density "${density}". Options: ${DENSITY_OPTIONS.join(", ")}`);
    process.exit(1);
  }

  const outputDir = determineOutputDir(args.articlePath, args.outputDir);
  const articleContent = await readFile(args.articlePath, "utf8");

  await generateIllustrationsForArticle(args.articlePath, articleContent, density, outputDir);
}

main().catch((error) => {
  console.error("Fatal error:", error instanceof Error ? error.message : String(error));
  process.exit(1);
});


path: scripts/image-generator.ts
content: import { exec } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import process from "node:process";

const execAsync = promisify(exec);

const DEFAULT_AR = "16:9";
const DEFAULT_OUTPUT_DIR = "/tmp/imageGen/opencli";

/** Map aspect ratio string to opencli --rt format */
function toOpencliRatio(ar: string): string {
  const map: Record<string, string> = {
    "1:1": "1:1",
    "16:9": "16:9",
    "9:16": "9:16",
    "4:3": "4:3",
    "3:4": "3:4",
    "2:3": "2:3",
    "3:2": "3:2",
  };
  return map[ar] || "16:9";
}

/**
 * Generate image via opencli gemini image, falling back to opencli grok image.
 * Returns the path to the saved image file.
 */
export async function generateImage(
  prompt: string,
  options?: {
    aspectRatio?: string | null;
    outputDir?: string;
  }
): Promise<{ imagePath: string }> {
  const ar = toOpencliRatio(options?.aspectRatio || DEFAULT_AR);
  const outDir = options?.outputDir || DEFAULT_OUTPUT_DIR;

  // Style prefix for the prompt
  const stylePrompt = `Hand-drawn minimalist illustration: ${prompt}. Simple, clean, lots of white space, balanced composition, unified color tone, no text, no words, no letters.`;

  // Try Gemini first
  try {
    console.log(`  → Generating with opencli gemini image (ratio: ${ar})...`);
    const result = await runGeminiImage(stylePrompt, ar, outDir);
    if (result) return result;
  } catch (error) {
    console.log(`  ✗ Gemini failed: ${error instanceof Error ? error.message : String(error)}`);
    console.log(`  → Falling back to opencli grok image...`);
  }

  // Fallback to Grok
  const grokResult = await runGrokImage(stylePrompt, outDir);
  if (grokResult) return grokResult;

  throw new Error("Both Gemini and Grok image generation failed");
}

async function runGeminiImage(
  prompt: string,
  ar: string,
  outDir: string
): Promise<{ imagePath: string } | null> {
  const cmd = `opencli gemini image "${escapeShell(prompt)}" --rt ${ar} --op "${escapeShell(outDir)}" -f json --timeout 300`;

  const { stdout } = await execAsync(cmd, { timeout: 310_000 });

  let parsed: any;
  try {
    parsed = JSON.parse(stdout);
  } catch {
    // Try to find JSON in output (opencli may emit logs before JSON)
    const jsonMatch = stdout.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
    if (!jsonMatch) throw new Error(`Gemini: could not parse JSON output: ${stdout.slice(0, 500)}`);
    parsed = JSON.parse(jsonMatch[0]);
  }

  // opencli gemini image output columns: status, file, link
  const items = Array.isArray(parsed) ? parsed : parsed.results || parsed.images || [parsed];
  const success = items.find((item: any) => item.status === "success" || item.file);

  if (!success?.file) {
    throw new Error(`Gemini: no successful image in output. Status: ${JSON.stringify(items.slice(0, 2))}`);
  }

  const imagePath = success.file as string;
  console.log(`  ✓ Gemini image saved: ${imagePath}`);
  return { imagePath };
}

async function runGrokImage(
  prompt: string,
  outDir: string
): Promise<{ imagePath: string }> {
  const cmd = `opencli grok image "${escapeShell(prompt)}" --out "${escapeShell(outDir)}" -f json --timeout 300 --count 1`;

  const { stdout } = await execAsync(cmd, { timeout: 310_000 });

  let parsed: any;
  try {
    parsed = JSON.parse(stdout);
  } catch {
    const jsonMatch = stdout.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
    if (!jsonMatch) throw new Error(`Grok: could not parse JSON output: ${stdout.slice(0, 500)}`);
    parsed = JSON.parse(jsonMatch[0]);
  }

  // opencli grok image output columns: url, width, height, path
  const items = Array.isArray(parsed) ? parsed : parsed.results || parsed.images || [parsed];
  const success = items.find((item: any) => item.path || item.url);

  if (!success) {
    throw new Error(`Grok: no image in output. Got: ${JSON.stringify(items.slice(0, 2))}`);
  }

  const imagePath = (success.path || success.url) as string;
  console.log(`  ✓ Grok image saved: ${imagePath}`);
  return { imagePath };
}

function escapeShell(str: string): string {
  return str.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
}

/** Download image from URL and return local file path */
export async function downloadImage(url: string, outputPath: string): Promise<string> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Failed to download image from ${url}: ${res.status}`);
  const buf = Buffer.from(await res.arrayBuffer());
  const { writeFile, mkdir } = await import("node:fs/promises");
  await mkdir(path.dirname(outputPath), { recursive: true });
  await writeFile(outputPath, buf);
  return outputPath;
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)