maomomo-article-writer/scripts/maomomo_image_gen.py

505 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/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": (
"明亮白底,暖橙强调色,干净卡片布局,友好的白橘猫作为向导,"
"适合港卡、银行活动、返现攻略和教程总览。"
),
"clean-editorial": (
"大标题、留白充足、少量暖橙和黑灰文字,像一张信息密度适中的中文攻略头图。"
),
"data-card-dashboard": (
"指标卡、时间线、计算公式和对比表清晰分区,适合返现、费用、门槛和路径对比。"
),
"handdrawn-note": (
"白底纸感、手绘箭头、便利贴、重点圈注和轻量猫咪贴纸,适合避坑经验和保姆式步骤。"
),
"xiaohongshu-vertical": (
"3:4 或 9:16 原生竖版,手机端可读,大字少字,强标题钩子,多图系列一致视觉,"
"默认 2D 扁平信息卡片,用户确认后可用 3D 轻拟物风,带 MAOMOMO 标识。"
),
"clean-professional": (
"浅色背景、蓝绿或暖橙强调、结构化信息卡片和清晰层级,适合正式银行规则、开户教程和综合攻略。"
),
"creative-magazine": (
"大标题、强留白、编辑部排版和轻视觉冲击,适合传播型封面、观点总结和活动盘点。"
),
"retro-flat-illustration": (
"低饱和暖色、扁平金融小物件、轻复古海报感,适合轻松羊毛攻略和经验分享。"
),
"e-ink-editorial": (
"纸感背景、黑白灰为主、少量强调色、强标题和元信息条,适合深度解释和观点型长文。"
),
"scientific-defense": (
"严谨浅色版式、证据图、流程框和来源标注,适合规则拆解、政策解读和多来源核对。"
),
"mckinsey-brief": (
"结论先行、矩阵、2x2、瀑布图和高对比商业配色适合方案对比、路径选择和决策建议。"
),
}
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 strip_toml_comment(line: str) -> str:
quote = ""
escaped = False
for index, char in enumerate(line):
if escaped:
escaped = False
continue
if quote and char == "\\":
escaped = True
continue
if char in ("'", '"'):
if not quote:
quote = char
elif quote == char:
quote = ""
continue
if char == "#" and not quote:
return line[:index]
return line
def parse_toml_scalar(value: str) -> Any:
raw = strip_toml_comment(value).strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'):
inner = raw[1:-1]
if raw[0] == '"':
return inner.replace(r"\"", '"').replace(r"\\", "\\")
return inner
if raw.lower() == "true":
return True
if raw.lower() == "false":
return False
return raw
def parse_basic_toml(text: str) -> Dict[str, Any]:
"""解析 Codex 配置需要的 TOML 子集,避免 Python 3.10 及以下缺少 tomllib。"""
data: Dict[str, Any] = {}
current: Dict[str, Any] = data
for raw_line in text.splitlines():
line = strip_toml_comment(raw_line).strip()
if not line:
continue
if line.startswith("[") and line.endswith("]"):
section = line.strip("[]").strip()
current = data
for part in section.split("."):
key = part.strip().strip("'\"")
if not key:
continue
child = current.get(key)
if not isinstance(child, dict):
child = {}
current[key] = child
current = child
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
current[key.strip().strip("'\"")] = parse_toml_scalar(value)
return data
def load_prompt_file(path: Path) -> str:
text = read_text(path).strip()
stripped = text.lstrip()
if path.suffix.lower() == ".json" or stripped.startswith("{"):
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
die(f"--prompt-file 看起来是 JSON但解析失败{path}: {exc}")
if not isinstance(data, dict) or not isinstance(data.get("prompt"), str) or not data["prompt"].strip():
die("--prompt-file 不能传入缺少 prompt 字段的 JSON。请先抽取 prompt 字段生成 .prompt.txt。")
prompt = data["prompt"].strip()
prompt_out = path.with_suffix(".prompt.txt")
write_text(prompt_out, prompt + "\n")
warn(f"--prompt-file 收到 JSON已抽取 prompt 字段到:{prompt_out};后续请直接传入该 .prompt.txt。")
return prompt
return text
def load_codex_config_base_url() -> Optional[str]:
config_path = Path.home() / ".codex" / "config.toml"
if not config_path.exists():
return None
text = read_text(config_path)
try:
import tomllib
data = tomllib.loads(text)
except ModuleNotFoundError:
data = parse_basic_toml(text)
except Exception:
data = parse_basic_toml(text)
try:
if not isinstance(data, dict):
data = parse_basic_toml(text)
except Exception:
data = parse_basic_toml(text)
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,
visual_direction: str = "",
portrait_rules: str = "",
extra: str = "",
) -> str:
allowed_visible = [value for value in (title, core_text, "MAOMOMO") if value.strip()]
allowed_visible_text = "".join(f"{value}" for value in allowed_visible)
parts = [
"生成一张 MAOMOMO 中文金融实操文章配图。",
f"图片类型:{image_type}",
f"标题 / 主题:{title}",
f"核心文案:{core_text}",
f"画幅:{aspect_ratio}",
f"画面呈现:{style_prompt(style)}",
f"可见文字限制:画面只允许出现这些指定业务文案:{allowed_visible_text}如补充要求明确列出已核对的品牌或支付标识可作为小型辅助标识出现不要添加副标题、风格标签、prompt 描述、说明文字、额外 slogan 或无关文字。",
"硬性要求:包含清晰可见的 MAOMOMO 主标识;中文文字清楚可读;信息层级明确;港币金额统一使用 HKD 写法;不要伪造真实 App 截图Mastercard、Visa、UnionPay、银行、支付宝等 Logo 或支付网络标识只有在官方材料、用户素材或补充要求明确核对正确时才可出现,并且只能作为辅助识别,不能抢占或替代 MAOMOMO 主标识位置;未确认时使用通用卡片或通用支付符号,不要凭空加入或替换卡组织;不要出现真实个人信息;猫咪不穿戴第三方 Logo。",
]
if visual_direction.strip():
parts.append(f"确认视觉方向:{visual_direction.strip()}。整组图片必须保持同一视觉方向,不得混用 2D 和 3D。")
if style == "xiaohongshu-vertical" or aspect_ratio in {"3:4", "9:16"}:
rules = portrait_rules.strip() or (
"native portrait compositionno squeezed elements"
"no stretched card / logo / textvertical layout redesigned for Xiaohongshu"
"禁止把横版设计硬塞进竖版画幅。"
)
parts.append(f"小红书竖图要求:{rules}")
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=300) 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=300) 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 = load_prompt_file(Path(args.prompt_file))
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,
visual_direction=args.visual_direction,
portrait_rules=args.portrait_rules,
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)
visual_direction = str(item.get("visual_direction") or args.visual_direction or "")
raw_portrait_rules = item.get("portrait_composition_rules") or args.portrait_rules
if isinstance(raw_portrait_rules, list):
portrait_rules = "".join(str(value) for value in raw_portrait_rules)
else:
portrait_rules = str(raw_portrait_rules or "")
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,
visual_direction=visual_direction,
portrait_rules=portrait_rules,
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("--visual-direction", default="")
common.add_argument("--portrait-rules", default="")
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())