diff --git a/README.md b/README.md index 5f03aef..1fcb40b 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,14 @@ maomomo-article-writer/ ├── README.md # 面向使用者的说明文档 ├── agents/ │ └── openai.yaml # 技能展示名称、简介和默认提示语 +├── scripts/ +│ └── maomomo_image_gen.py # OpenAI 兼容 API/CLI 配图 fallback └── references/ ├── source-and-fact-check.md # 来源读取、事实核查和风险口径 ├── style-guide.md # MAOMOMO 写作风格、模板和文章骨架 ├── visual-assets.md # 配图清单、视觉规则和图片 QA + ├── recommended-styles.md # 推荐配图风格和提示词片段 + ├── image-generation.md # 图片生成脚本、manifest 和后端选择 └── delivery-rules.md # 完整交付、打包和最终检查 ``` @@ -122,7 +126,7 @@ maomomo-article-writer 2. 读取来源并列出已读 / 未读材料,整理事实表和待确认项。 3. 确认文章范围、大纲、标题方向、核心口径和图片规划。 4. 写作或优化发布用 Markdown。 -5. 生成或准备独立 PNG 配图,插入 Markdown。 +5. 确认配图风格,生成或准备独立 PNG 配图,插入 Markdown。 6. 检查来源、图片路径、风险提示、编辑痕迹和 ZIP 内容,最后交付文件路径。 用户明确说“只要文字”“只要大纲”“不要图片”“不要打包”时,会按用户限制执行,不强行创建完整发布包。 @@ -147,6 +151,7 @@ maomomo-article-writer - 语气像懂行朋友,不像官方宣传稿 - 涉及金融、券商、银行、数字货币和合规内容时,保留谨慎措辞 - 配图统一使用白橘猫、暖橙色、干净金融科技教程风 +- 如果用户没有指定配图风格,先在 `warm-fintech-guide`、`data-card-dashboard`、`clean-editorial`、`handdrawn-note`、`xiaohongshu-vertical` 中推荐 2-3 个方向。 文章不应当: @@ -161,6 +166,8 @@ maomomo-article-writer - 详细文章模板、标题套路和常用词写在 `references/style-guide.md`。 - 来源核查、时效性、冲突口径和风险表达写在 `references/source-and-fact-check.md`。 - 配图类型、猫咪视觉、文件命名和图片 QA 写在 `references/visual-assets.md`。 +- 推荐配图风格、适用场景和提示词片段写在 `references/recommended-styles.md`。 +- 图片 API/CLI fallback、批量 manifest 和脚本用法写在 `references/image-generation.md`,脚本为 `scripts/maomomo_image_gen.py`。 - 完整交付、ZIP、附件和最终检查写在 `references/delivery-rules.md`。 - 修改技能展示名称或默认提示语时,同步更新 `agents/openai.yaml`。 - 如果新增具体行业资料、平台规则或长期复用模板,优先放入 `references/`,避免让 `SKILL.md` 过长。 diff --git a/SKILL.md b/SKILL.md index 90f7d70..5dc0f86 100644 --- a/SKILL.md +++ b/SKILL.md @@ -70,8 +70,10 @@ description: 生成或优化符合 maomomo / 猫MOMO 风格的中文出海金融 5. 配图规划和生成。 - 需要配图时读 `references/visual-assets.md`。 - - 先生成图片清单:类型、标题、放置位置、核心文案、文件名、alt text、是否依赖用户截图。 + - 若用户没有指定视觉方向,读 `references/recommended-styles.md`,先提供 2-3 个 MAOMOMO 配图风格选项并推荐一个;确认后整篇文章保持同一视觉身份。 + - 先生成图片清单:类型、标题、放置位置、核心文案、文件名、alt text、风格、是否依赖用户截图。 - 每张图独立生成或准备为高清 PNG,放入 `assets/`,用相对路径插入 Markdown。 + - 优先使用当前 agent 的内置图片生成工具;当用户要求 API/CLI、内置工具不可用或需要批量 manifest 时,读 `references/image-generation.md` 并使用 `scripts/maomomo_image_gen.py`。 - 没有真实截图时只能生成“示意图 / 信息图”,不能伪造官方 App 截图。 6. 打包与最终 QA。 @@ -117,4 +119,6 @@ description: 生成或优化符合 maomomo / 猫MOMO 风格的中文出海金融 - `references/source-and-fact-check.md`:来源读取、事实表、时效性、冲突口径和金融风险核查。 - `references/style-guide.md`:MAOMOMO 文章类型、标题套路、语气、结构模板、小红书模式和写作细节。 - `references/visual-assets.md`:配图类型、猫咪视觉、文件命名、Markdown 插图、禁止伪造截图和图片 QA。 +- `references/recommended-styles.md`:推荐配图风格、适用场景、推荐话术和提示词片段。 +- `references/image-generation.md`:图片生成后端选择、`scripts/maomomo_image_gen.py` 用法、manifest 和故障处理。 - `references/delivery-rules.md`:完整交付、ZIP、更新打包、附件策略和最终检查清单。 diff --git a/agents/openai.yaml b/agents/openai.yaml index 945d989..b404104 100644 --- a/agents/openai.yaml +++ b/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "MAOMOMO 文章生成器" - short_description: "阶段化生成 MAOMOMO 文章、配图和 ZIP 发布包" - default_prompt: "使用 $maomomo-article-writer 生成或优化一篇 MAOMOMO 风格的中文出海金融实操文章。请先核查来源和口径,再交付 Markdown、assets 独立配图目录和 ZIP 包。" + short_description: "阶段化生成 MAOMOMO 文章、多风格配图和 ZIP 发布包" + default_prompt: "使用 $maomomo-article-writer 生成或优化一篇 MAOMOMO 风格的中文出海金融实操文章。请先核查来源和口径,再确认配图风格,最后交付 Markdown、assets 独立配图目录和 ZIP 包。" policy: allow_implicit_invocation: true diff --git a/references/image-generation.md b/references/image-generation.md new file mode 100644 index 0000000..ebda391 --- /dev/null +++ b/references/image-generation.md @@ -0,0 +1,107 @@ +# 图片生成脚本与后端选择 + +需要实际生成配图文件、使用 API/CLI fallback、批量生成 assets,或排查图片接口配置时,先读本文件。 + +## 后端选择 + +优先顺序: + +1. Codex / 当前 agent 的内置图片生成工具。适合大多数配图任务。 +2. `scripts/maomomo_image_gen.py` API/CLI fallback。适合用户明确要求 API/CLI、内置工具不可用、需要批量 manifest、或希望把生成过程记录到文件时。 + +不要因为脚本存在就强制使用脚本。若内置图片工具可用,仍优先使用内置工具生成独立 PNG,再保存到 `assets/`。 + +## 配置来源 + +脚本按以下顺序读取配置: + +- `MAOMOMO_IMAGE_API_KEY` / `OPENAI_API_KEY` +- `MAOMOMO_IMAGE_BASE_URL` / `OPENAI_BASE_URL` +- `MAOMOMO_IMAGE_MODEL` / `CODEX_PPT_IMAGE_MODEL` +- `~/.codex/config.toml` 中的 `base_url` +- `~/.codex/auth.json` 中可识别的 API key + +不要在日志、回答、Markdown 或 manifest 中写入完整 API key。 + +## 单张生成 + +```bash +python3 scripts/maomomo_image_gen.py generate \ + --out article/assets/hsbc-mastercard-alipay-campaign-01-cover.png \ + --style warm-fintech-guide \ + --title "汇丰 Mastercard 支付宝活动" \ + --image-type "封面图" \ + --core-text "先看报名、门槛和封顶,避免白刷" +``` + +先只看 prompt: + +```bash +python3 scripts/maomomo_image_gen.py generate \ + --out article/assets/hsbc-mastercard-alipay-campaign-01-cover.png \ + --style data-card-dashboard \ + --title "返现计算图" \ + --image-type "计算图" \ + --core-text "HK$190 ÷ 9% ≈ HK$2112,超过部分没有额外收益" \ + --dry-run +``` + +## 批量 manifest + +manifest 示例: + +```json +{ + "images": [ + { + "file_name": "assets/hsbc-mastercard-alipay-campaign-01-cover.png", + "type": "封面图", + "title": "汇丰 Mastercard 支付宝活动", + "core_text": "先报名,再看门槛和封顶", + "alt_text": "MAOMOMO 汇丰 Mastercard 支付宝返现活动封面图", + "style": "warm-fintech-guide", + "aspect_ratio": "16:9" + }, + { + "file_name": "assets/hsbc-mastercard-alipay-campaign-02-calculation.png", + "type": "计算图", + "title": "怎么刷到刚刚好", + "core_text": "HK$190 ÷ 9% ≈ HK$2112,超过部分没有额外收益", + "style": "data-card-dashboard" + } + ] +} +``` + +运行: + +```bash +python3 scripts/maomomo_image_gen.py batch \ + --manifest article/image_manifest.json \ + --base-dir article +``` + +`file_name` 必须与 Markdown 中的相对路径一致,且最终文件必须真实存在。 + +## 内置风格名 + +可用: + +- `warm-fintech-guide` +- `clean-editorial` +- `data-card-dashboard` +- `handdrawn-note` +- `xiaohongshu-vertical` + +查看完整描述: + +```bash +python3 scripts/maomomo_image_gen.py styles +``` + +## 失败处理 + +- API key 缺失:提示用户配置环境变量或检查 Codex 配置,不要编造图片文件。 +- 接口失败:保留已生成图片,说明失败项,必要时用 `--dry-run` 交付 prompt 清单作为临时排查材料。 +- 图片文字乱码或金额错误:重新生成该单张图片;不要手工覆盖文字伪装成模型输出。 +- 图片不符合风格:先更新 manifest 的 `style` / `core_text` / `extra`,再重跑对应项。 diff --git a/references/recommended-styles.md b/references/recommended-styles.md new file mode 100644 index 0000000..0563197 --- /dev/null +++ b/references/recommended-styles.md @@ -0,0 +1,136 @@ +# MAOMOMO 推荐配图风格 + +需要选择、推荐、复用或调整配图风格时,读本文件。风格名要与 `scripts/maomomo_image_gen.py` 的 `--style` 保持一致。 + +## 推荐顺序 + +默认优先推荐 `warm-fintech-guide`。如果文章明显更偏数字、对比、避坑或小红书,再切换到更合适的风格。 + +| 风格名 | 优先使用场景 | 不适合 | +|---|---|---| +| `warm-fintech-guide` | 常规 MAOMOMO 文章、港卡、银行活动、返现攻略、开户教程 | 数字极密集的计算页、小红书竖版 | +| `data-card-dashboard` | 返现计算、费用测算、活动门槛、产品对比、时间线 | 情绪化避坑故事、截图驱动步骤 | +| `clean-editorial` | SEO 长文、总结盘点、品牌感封面、解释型文章 | 需要强步骤感或大量数字卡片的图 | +| `handdrawn-note` | 避坑经验、保号教程、操作步骤、实测提醒 | 严肃金融报告感、密集表格 | +| `xiaohongshu-vertical` | 小红书、4 图看完、手机端竖版内容 | 常规网站横版封面和正文配图 | + +## warm-fintech-guide + +推荐语: + +```text +我建议用 warm-fintech-guide,因为它最贴近 MAOMOMO 默认视觉:白底、暖橙、猫咪向导和金融教程卡片,适合大多数港卡、银行活动和返现攻略。 +``` + +视觉要点: + +- 明亮白底,暖橙色强调。 +- 白橘猫作为向导、提示牌或角落品牌元素。 +- 信息卡片清晰,数字、日期和步骤不拥挤。 +- 适合 16:9 横版封面、总览图、流程图。 + +提示词片段: + +```text +MAOMOMO 暖橙金融教程风,明亮白底,暖橙强调色,干净卡片布局,友好的白橘猫作为向导,现代 App 教程质感,中文标题清楚,包含 MAOMOMO 标识。 +``` + +## data-card-dashboard + +推荐语: + +```text +我建议用 data-card-dashboard,因为这篇重点是规则、门槛和金额,读者需要一眼看懂数字和限制。 +``` + +视觉要点: + +- 指标卡、对比表、时间线、计算公式。 +- 数字层级强,适合“封顶”“门槛”“成本”“到账时间”。 +- 猫咪比例较小,只做提示和品牌识别。 +- 适合返现计算图、路线对比图、活动总览图。 + +提示词片段: + +```text +MAOMOMO 数据卡片仪表盘风,白底,暖橙和蓝灰辅助色,使用指标卡、表格、时间线和计算公式展示关键信息,中文数字清晰可读,猫咪作为小型提示元素。 +``` + +## clean-editorial + +推荐语: + +```text +我建议用 clean-editorial,因为这篇更像站内解释型长文,需要封面干净、标题有传播感,但不要太像促销海报。 +``` + +视觉要点: + +- 大标题、留白、轻杂志感。 +- 猫咪放在角落或标题区附近,不抢正文信息。 +- 更适合封面、文章总结图、观点图。 +- 画面克制,避免过度卡通。 + +提示词片段: + +```text +MAOMOMO 清爽编辑部风,白底大标题,轻杂志排版,暖橙点缀,留白充足,白橘猫作为角落品牌元素,适合中文金融经验文章封面。 +``` + +## handdrawn-note + +推荐语: + +```text +我建议用 handdrawn-note,因为这篇是避坑 / 步骤教程,手绘箭头、便签和圈注更容易把翻车点讲明白。 +``` + +视觉要点: + +- 手绘箭头、便签、圈注、风险提示贴纸。 +- 适合“不要这样做”“先做这一步”“最大坑点”。 +- 信息密度中等,避免塞太多长句。 +- 猫咪可以举提示牌或指向关键步骤。 + +提示词片段: + +```text +MAOMOMO 手绘便签风,白底纸感,暖橙便利贴,手绘箭头和圈注,白橘猫举提示牌,中文步骤清楚,适合避坑经验和保姆式教程。 +``` + +## xiaohongshu-vertical + +推荐语: + +```text +我建议用 xiaohongshu-vertical,因为用户要发小红书,应该重新做 9:16 竖图,而不是复用站内横版图。 +``` + +视觉要点: + +- 9:16 竖版,手机端可读。 +- 大字少字,标题钩子强。 +- 4 图系列统一:封面、核心拆解、操作流程、避坑总结。 +- 每张图都有 MAOMOMO 标识或猫咪水印。 + +提示词片段: + +```text +MAOMOMO 小红书竖版攻略风,9:16,手机端可读,大标题,少量关键数字,暖橙白底,白橘猫水印,适合 4 图看完的中文金融攻略。 +``` + +## 风格选择输出格式 + +向用户推荐时使用: + +```text +我建议用 A,因为【一句话说明与文章类型的匹配】。 + +A. warm-fintech-guide(推荐):【适合原因】 +B. data-card-dashboard:【适合原因】 +C. handdrawn-note:【适合原因】 + +你选哪个?也可以指定更偏简洁、数据感、手绘感或小红书感。 +``` + +用户选择后,把风格名写入图片清单的 `风格` 字段或 manifest 的 `style` 字段。 diff --git a/references/visual-assets.md b/references/visual-assets.md index 52283b7..cfd29a8 100644 --- a/references/visual-assets.md +++ b/references/visual-assets.md @@ -11,6 +11,40 @@ - 用户提供真实截图时,优先用真实截图,并隐去姓名、账号、余额、地址、手机号、二维码、交易号等个人信息。 - 每张图必须包含清晰可见的 `MAOMOMO` 标识。 +## 风格选择 + +如果用户没有指定视觉方向,生成图片前先读 `references/recommended-styles.md`,给 2-3 个可选风格,并推荐一个。确认后,整篇文章的配图保持同一套视觉身份:稳定的配色、猫咪造型、标题区、图标语言和信息密度;不同图片可以根据用途变化构图。 + +默认推荐逻辑: + +- 活动 / 返现 / 港卡攻略:优先 `warm-fintech-guide`。 +- 规则、门槛、费用、返现计算和产品对比:优先 `data-card-dashboard`。 +- SEO 长文、经验整理、品牌感更强的文章:优先 `clean-editorial`。 +- 保姆式教程、避坑经验和轻量操作步骤:优先 `handdrawn-note`。 +- 小红书 / 4 图看完:必须用 `xiaohongshu-vertical`,并生成 9:16 竖图。 + +可选风格: + +| 风格名 | 适合场景 | 视觉方向 | +|---|---|---| +| `warm-fintech-guide` | 默认 MAOMOMO 金融攻略、港卡、银行活动 | 白底、暖橙强调色、干净卡片、白橘猫向导 | +| `clean-editorial` | SEO 长文、解释型文章、站内封面 | 大标题、留白、轻杂志感、猫咪作为角落品牌元素 | +| `data-card-dashboard` | 返现计算、活动对比、费用路径、时间线 | 指标卡、表格感、仪表盘、清晰数字层级 | +| `handdrawn-note` | 避坑、步骤教程、保号经验 | 手绘箭头、便签、圈注、猫咪提示牌 | +| `xiaohongshu-vertical` | 小红书发布版、4 图看完 | 9:16 竖版、大字少字、强钩子、系列化版式 | + +示例确认话术: + +```text +我建议用 A,因为这篇是活动攻略,读者最需要快速看懂门槛、封顶和翻车点。 + +A. warm-fintech-guide(推荐):白底暖橙、猫咪向导、金融教程感,适合 MAOMOMO 常规文章。 +B. data-card-dashboard:更强调数字卡片和计算表,适合门槛 / 返现 / 费用信息密集的文章。 +C. handdrawn-note:便签和手绘箭头更明显,适合避坑经验和保姆式步骤。 + +你选哪个?也可以指定颜色、猫咪比例或是否更像小红书图。 +``` + ## 常见配图类型 | 类型 | 用途 | @@ -29,10 +63,10 @@ 生成图片前先规划清单: ```markdown -| 文件名 | 类型 | 放置位置 | 核心文案 | alt text | 依赖素材 | -|---|---|---|---|---|---| -| hong-kong-bank-account-01-cover.png | 封面 | 标题下方 | 港卡保号关键点 | MAOMOMO 港卡保号教程封面图 | 无 | -| hong-kong-bank-account-02-flow.png | 流程图 | 操作步骤前 | 三步完成账户活跃 | 港卡保号操作流程图 | 用户截图 1 | +| 文件名 | 类型 | 放置位置 | 核心文案 | alt text | 风格 | 依赖素材 | +|---|---|---|---|---|---|---| +| hong-kong-bank-account-01-cover.png | 封面 | 标题下方 | 港卡保号关键点 | MAOMOMO 港卡保号教程封面图 | warm-fintech-guide | 无 | +| hong-kong-bank-account-02-flow.png | 流程图 | 操作步骤前 | 三步完成账户活跃 | 港卡保号操作流程图 | handdrawn-note | 用户截图 1 | ``` 确认用户不需要先看清单时,可以直接执行,但最终必须让文件名、alt text 和插图位置与正文一致。 @@ -117,6 +151,7 @@ MAOMOMO 风格中文金融教程信息图,干净明亮白色背景,暖橙色 - 每张图有 MAOMOMO 标识或猫咪水印。 - 手机端可读,大字少字。 - 默认结构:封面、核心拆解、操作流程、避坑总结。 +- 风格固定为 `xiaohongshu-vertical`,不直接复用横版文章图。 ## 图片 QA diff --git a/scripts/maomomo_image_gen.py b/scripts/maomomo_image_gen.py new file mode 100644 index 0000000..01ee90a --- /dev/null +++ b/scripts/maomomo_image_gen.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""MAOMOMO article image generation CLI. + +This is a lightweight OpenAI-compatible fallback for article assets. Prefer the +agent's built-in image tool when it is available; use this script when the user +explicitly wants API/CLI generation or the built-in backend is unavailable. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +from pathlib import Path +import sys +import time +from typing import Any, Dict, Iterable, List, Optional +import urllib.error +import urllib.request + + +DEFAULT_BASE_URL = "https://api.openai.com/v1" +DEFAULT_MODEL = "gpt-image-2" +DEFAULT_SIZE = "1536x1024" +DEFAULT_QUALITY = "medium" +DEFAULT_STYLE = "warm-fintech-guide" + + +STYLE_PRESETS: Dict[str, str] = { + "warm-fintech-guide": ( + "MAOMOMO 暖橙金融教程风:明亮白底,暖橙强调色,干净卡片布局,友好的白橘猫作为向导," + "适合港卡、银行活动、返现攻略和教程总览。" + ), + "clean-editorial": ( + "清爽编辑部风:大标题、留白充足、少量暖橙和黑灰文字,像一张信息密度适中的中文攻略头图。" + ), + "data-card-dashboard": ( + "数据卡片仪表盘风:指标卡、时间线、计算公式和对比表清晰分区,适合返现、费用、门槛和路径对比。" + ), + "handdrawn-note": ( + "手绘便签风:白底纸感、手绘箭头、便利贴、重点圈注和轻量猫咪贴纸,适合避坑经验和保姆式步骤。" + ), + "xiaohongshu-vertical": ( + "小红书竖版攻略风:9:16 手机端可读,大字少字,强标题钩子,4 图系列一致视觉,带 MAOMOMO 标识。" + ), +} + + +def die(message: str, code: int = 1) -> None: + print(f"错误:{message}", file=sys.stderr) + raise SystemExit(code) + + +def warn(message: str) -> None: + print(f"警告:{message}", file=sys.stderr) + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + + +def read_json(path: Path) -> Any: + return json.loads(read_text(path)) + + +def load_codex_config_base_url() -> Optional[str]: + config_path = Path.home() / ".codex" / "config.toml" + if not config_path.exists(): + return None + try: + import tomllib + except ModuleNotFoundError: + return None + try: + data = tomllib.loads(read_text(config_path)) + except Exception: + return None + + provider_name = data.get("model_provider") + providers = data.get("model_providers") + if isinstance(provider_name, str) and isinstance(providers, dict): + provider = providers.get(provider_name) + if isinstance(provider, dict) and isinstance(provider.get("base_url"), str): + return provider["base_url"].strip() + + if isinstance(data.get("base_url"), str): + return data["base_url"].strip() + if isinstance(providers, dict): + for provider in providers.values(): + if isinstance(provider, dict) and isinstance(provider.get("base_url"), str): + return provider["base_url"].strip() + return None + + +def find_secret(value: Any) -> Optional[str]: + if isinstance(value, dict): + for key in ("MAOMOMO_IMAGE_API_KEY", "OPENAI_API_KEY", "api_key", "openai_api_key", "token"): + raw = value.get(key) + if isinstance(raw, str) and raw.strip(): + return raw.strip() + for child in value.values(): + found = find_secret(child) + if found: + return found + if isinstance(value, list): + for child in value: + found = find_secret(child) + if found: + return found + return None + + +def load_codex_auth_api_key() -> Optional[str]: + auth_path = Path.home() / ".codex" / "auth.json" + if not auth_path.exists(): + return None + try: + return find_secret(read_json(auth_path)) + except Exception: + return None + + +def api_key() -> str: + value = os.getenv("MAOMOMO_IMAGE_API_KEY") or os.getenv("OPENAI_API_KEY") or load_codex_auth_api_key() + if not value: + die( + "未找到 API Key。请设置 MAOMOMO_IMAGE_API_KEY 或 OPENAI_API_KEY," + "或确保 ~/.codex/auth.json 中存在可用 key。" + ) + return value + + +def base_url() -> str: + return ( + os.getenv("MAOMOMO_IMAGE_BASE_URL") + or os.getenv("OPENAI_BASE_URL") + or load_codex_config_base_url() + or DEFAULT_BASE_URL + ).rstrip("/") + + +def image_model() -> str: + return os.getenv("MAOMOMO_IMAGE_MODEL") or os.getenv("CODEX_PPT_IMAGE_MODEL") or DEFAULT_MODEL + + +def style_prompt(style: str) -> str: + if style in STYLE_PRESETS: + return STYLE_PRESETS[style] + return style + + +def build_prompt( + *, + title: str, + image_type: str, + core_text: str, + style: str, + aspect_ratio: str, + extra: str = "", +) -> str: + parts = [ + "生成一张 MAOMOMO 中文金融实操文章配图。", + f"图片类型:{image_type}", + f"标题 / 主题:{title}", + f"核心文案:{core_text}", + f"画幅:{aspect_ratio}", + f"视觉风格:{style_prompt(style)}", + "硬性要求:包含清晰可见的 MAOMOMO 标识;中文文字清楚可读;信息层级明确;不要伪造真实 App 截图;不要使用未经提供的银行、券商、支付机构或卡组织 Logo;不要出现真实个人信息;猫咪不能承载第三方品牌 Logo。", + ] + if extra.strip(): + parts.append(f"补充要求:{extra.strip()}") + return "\n".join(parts) + + +def request_image(prompt: str, *, size: str, quality: str, output_format: str) -> bytes: + payload = { + "model": image_model(), + "prompt": prompt, + "size": size, + "quality": quality, + "n": 1, + "response_format": "b64_json", + } + if output_format: + payload["output_format"] = output_format + + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + base_url() + "/images/generations", + data=body, + headers={ + "Authorization": f"Bearer {api_key()}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=180) as resp: + data = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace") + die(f"图片接口返回 HTTP {exc.code}: {detail}") + except Exception as exc: + die(f"图片接口请求失败:{exc}") + + items = data.get("data") + if not isinstance(items, list) or not items: + die("图片接口响应缺少 data。") + first = items[0] + if not isinstance(first, dict): + die("图片接口响应格式不正确。") + if isinstance(first.get("b64_json"), str): + return base64.b64decode(first["b64_json"]) + if isinstance(first.get("url"), str): + with urllib.request.urlopen(first["url"], timeout=180) as resp: + return resp.read() + die("图片接口响应中没有 b64_json 或 url。") + return b"" + + +def normalize_output(path: str, *, base_dir: Optional[Path]) -> Path: + out = Path(path) + if not out.is_absolute() and base_dir is not None: + out = base_dir / out + return out.resolve() + + +def generate_one(args: argparse.Namespace) -> Dict[str, str]: + if args.prompt_file: + prompt = read_text(Path(args.prompt_file)).strip() + elif args.prompt: + prompt = args.prompt.strip() + else: + prompt = build_prompt( + title=args.title, + image_type=args.image_type, + core_text=args.core_text, + style=args.style, + aspect_ratio=args.aspect_ratio, + extra=args.extra, + ) + + out = normalize_output(args.out, base_dir=None) + if args.dry_run: + write_text(out.with_suffix(".prompt.txt"), prompt + "\n") + return {"out": str(out), "prompt": str(out.with_suffix(".prompt.txt")), "status": "dry-run"} + + image_bytes = request_image(prompt, size=args.size, quality=args.quality, output_format=args.output_format) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(image_bytes) + return {"out": str(out), "status": "generated"} + + +def manifest_items(path: Path) -> List[Dict[str, Any]]: + data = read_json(path) + if isinstance(data, dict): + items = data.get("images") + else: + items = data + if not isinstance(items, list): + die("manifest 必须是图片数组,或包含 images 数组的对象。") + normalized: List[Dict[str, Any]] = [] + for index, item in enumerate(items, start=1): + if not isinstance(item, dict): + die(f"manifest 第 {index} 项不是对象。") + normalized.append(item) + return normalized + + +def batch(args: argparse.Namespace) -> List[Dict[str, str]]: + manifest_path = Path(args.manifest).resolve() + base_dir = Path(args.base_dir).resolve() if args.base_dir else manifest_path.parent + results: List[Dict[str, str]] = [] + + for index, item in enumerate(manifest_items(manifest_path), start=1): + filename = item.get("file_name") or item.get("filename") or item.get("out") + if not isinstance(filename, str) or not filename.strip(): + die(f"manifest 第 {index} 项缺少 file_name。") + style = str(item.get("style") or args.style or DEFAULT_STYLE) + aspect_ratio = str(item.get("aspect_ratio") or args.aspect_ratio) + prompt = item.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + prompt = build_prompt( + title=str(item.get("title") or item.get("alt_text") or filename), + image_type=str(item.get("type") or "文章配图"), + core_text=str(item.get("core_text") or item.get("caption") or item.get("alt_text") or ""), + style=style, + aspect_ratio=aspect_ratio, + extra=str(item.get("extra") or item.get("requirements") or ""), + ) + + out = normalize_output(filename, base_dir=base_dir) + if args.dry_run: + write_text(out.with_suffix(".prompt.txt"), prompt + "\n") + results.append({"out": str(out), "prompt": str(out.with_suffix(".prompt.txt")), "status": "dry-run"}) + continue + + image_bytes = request_image( + prompt, + size=str(item.get("size") or args.size), + quality=str(item.get("quality") or args.quality), + output_format=str(item.get("output_format") or args.output_format), + ) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(image_bytes) + results.append({"out": str(out), "status": "generated"}) + if args.sleep > 0: + time.sleep(args.sleep) + return results + + +def print_styles(_: argparse.Namespace) -> int: + print(json.dumps(STYLE_PRESETS, ensure_ascii=False, indent=2)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate MAOMOMO article images with an OpenAI-compatible API.") + subparsers = parser.add_subparsers(dest="command", required=True) + + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--style", default=DEFAULT_STYLE, help="Style preset name or custom style text.") + common.add_argument("--size", default=DEFAULT_SIZE) + common.add_argument("--quality", default=DEFAULT_QUALITY) + common.add_argument("--output-format", default="png") + common.add_argument("--aspect-ratio", default="16:9") + common.add_argument("--dry-run", action="store_true", help="Only write prompt files; do not call the image API.") + + generate = subparsers.add_parser("generate", parents=[common], help="Generate one image.") + generate.add_argument("--out", required=True) + generate.add_argument("--prompt") + generate.add_argument("--prompt-file") + generate.add_argument("--title", default="MAOMOMO 文章配图") + generate.add_argument("--image-type", default="文章配图") + generate.add_argument("--core-text", default="") + generate.add_argument("--extra", default="") + + batch_parser = subparsers.add_parser("batch", parents=[common], help="Generate images from a JSON manifest.") + batch_parser.add_argument("--manifest", required=True) + batch_parser.add_argument("--base-dir", help="Base directory for relative file_name paths. Defaults to manifest dir.") + batch_parser.add_argument("--sleep", type=float, default=0.0, help="Seconds to sleep between API calls.") + + subparsers.add_parser("styles", help="Print bundled style presets.") + return parser + + +def main(argv: Optional[Iterable[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + if args.command == "styles": + return print_styles(args) + if args.command == "generate": + result = generate_one(args) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "batch": + results = batch(args) + print(json.dumps(results, ensure_ascii=False, indent=2)) + return 0 + parser.print_help() + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())