awesome-gpt-image-2/scripts/utils/cms-client.ts
Jared Liu a00a14c74f feat: scaffold awesome-gpt-image-2 from nano-banana-pro template
Mirror of awesome-nano-banana-pro-prompts tuned for OpenAI's upcoming
GPT Image 2 (codename "duct-tape"):

- Swap CMS model filter to gpt-image-2 + campaign gpt-image-2-prompts
- i18n.ts: all 16 languages rebranded; rewrite seedancePromo to
  cross-promote nano-banana-pro, and whatIs content to reflect
  GPT Image 2's actual strengths:
    * Pixel-perfect multi-language text rendering (zh/en/ja)
    * Cross-image pixel-level consistency
    * Commercial-ready illustration quality
    * True art style induction
  Primary languages (en, zh, zh-TW, ja, ko) hand-translated;
  others fall back to English copy (will be refined later)
- markdown-generator: cover image path switched to
  gpt-image-2-prompts-cover-{en,zh}.png, arenaUrl points at the
  gallery page (the side-by-side arena page doesn't exist yet)
- GitHub Actions:
    * update-readme.yml runs on cron 0 0,12 * * * (twice daily
      per product request, instead of every 4 hours)
    * sync-approved-to-cms.yml wired to gpt-image-2 model
- Cover images, issue templates, docs, LICENSE (year 2026) updated
- Initial 16 README_*.md files generated from live CMS (78 prompts,
  44 categories) so the repo renders immediately without waiting
  for the first Action run

Secrets the repo needs before the Action runs on the remote:
  - CMS_HOST
  - CMS_API_KEY

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:42:24 +08:00

508 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fetch from "node-fetch";
import { stringify } from "qs-esm";
const CMS_HOST = process.env.CMS_HOST;
const CMS_API_KEY = process.env.CMS_API_KEY;
export interface Media {
id: number;
alt?: string | null;
caption?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ("ltr" | "rtl") | null;
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
tiny?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
thumbnail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
square?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
small?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
medium?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
large?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
xlarge?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
og?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
export interface Prompt {
id: number;
model?: string;
title: string;
description: string;
content: string;
translatedContent?: string; // Translated content for current locale
sourceLink?: string; // Optional source link
sourcePublishedAt: string;
sourceMedia: string[];
video?: {
url: string;
thumbnail?: string;
};
media?: Media[];
author: {
name: string;
link?: string;
};
language: string;
featured?: boolean;
sort?: number;
needReferenceImages?: boolean; // Whether this prompt requires user to input images
sourceMeta?: Record<string, any>;
imageCategories?: {
useCases?: Array<PromptCategory>;
styles?: Array<PromptCategory>;
subjects?: Array<PromptCategory>;
};
}
interface CMSResponse {
docs: Prompt[];
totalDocs: number;
}
/**
* 处理 prompt 数据,提取图片
*/
function processPromptImages(item: Prompt): Prompt {
let images: string[] = [];
if (item.media) {
images = item.media.map((m) => m.url || "").filter(Boolean) as string[];
} else {
if (item.sourceMedia) {
images = item.sourceMedia;
}
if (item.video?.thumbnail) {
images.push(item.video.thumbnail);
}
}
return { ...item, sourceMedia: images };
}
/**
* 获取 featured prompts
*/
async function fetchFeaturedPrompts(
locale: string
): Promise<{ docs: Prompt[]; totalDocs: number }> {
const query = {
limit: 30,
sort: ["-featured", "sort", "-sourcePublishedAt"].join(","),
depth: 2,
locale,
where: {
model: {
equals: "gpt-image-2",
},
},
};
const stringifiedQuery = stringify(query, { addQueryPrefix: true });
const url = `${CMS_HOST}/api/prompts${stringifiedQuery}`;
const response = await fetch(url, {
headers: {
Authorization: `users API-Key ${CMS_API_KEY}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`CMS API error: ${response.statusText}`);
}
const data = (await response.json()) as CMSResponse;
const docs = data.docs.filter((p) => p.featured)
.map(processPromptImages)
.filter((p) => p.sourceMedia?.length > 0);
return { docs, totalDocs: data.totalDocs };
}
/**
* 获取指定类目的 prompts
*/
async function fetchPromptsByCategory(
categoryId: number,
categoryTitle: string,
locale: string
): Promise<Prompt[]> {
const query = {
limit: 20,
sort: ["sort", "-sourcePublishedAt"].join(","),
depth: 2,
locale,
where: {
model: {
equals: "gpt-image-2",
},
"imageCategories.useCases": {
contains: categoryId,
},
},
};
const stringifiedQuery = stringify(query, { addQueryPrefix: true });
const url = `${CMS_HOST}/api/prompts${stringifiedQuery}`;
const response = await fetch(url, {
headers: {
Authorization: `users API-Key ${CMS_API_KEY}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`CMS API error: ${response.statusText}`);
}
const data = (await response.json()) as CMSResponse;
return data.docs
.map(processPromptImages)
.filter((p) => p.sourceMedia?.length > 0)
.map((p) => ({
...p,
title: `${categoryTitle} - ${p.title}`,
}));
}
/**
* 获取 prompts
* @param locale 语言版本,默认 en-US
* @param allCategories 完整类目数组,函数内部会筛选出 use-cases 的二级类目
* @returns { docs: Prompt[], total: number }
*/
export async function fetchAllPrompts(
locale: string = "en-US",
allCategories: FilterCategory[] = []
): Promise<{ docs: Prompt[]; total: number }> {
// 1. 获取 featured prompts
const { docs: featuredPrompts, totalDocs } =
await fetchFeaturedPrompts(locale);
// 2. 筛选出 use-cases 的二级类目parentSlug 为 use-cases 的类目)
const useCaseCategories = allCategories.filter(
(cat) => cat.parentSlug === "use-cases"
);
// 3. 按类目顺序获取每个类目的 prompts
const categoryPrompts: Prompt[] = [];
const seenIds = new Set(featuredPrompts.map((p) => p.id));
for (const category of useCaseCategories) {
const prompts = await fetchPromptsByCategory(
category.id,
category.title,
locale
);
// 去重:排除已在 featured 或其他类目中出现的 prompts
for (const prompt of prompts) {
if (!seenIds.has(prompt.id)) {
seenIds.add(prompt.id);
categoryPrompts.push(prompt);
}
}
}
const docs = [...featuredPrompts, ...categoryPrompts];
return { docs, total: totalDocs };
}
/**
* 排序 prompts
* @param prompts prompts 数组
* @param total 可选的总数(用于显示真实总数,而非当前获取的数量)
*/
export function sortPrompts(prompts: Prompt[], total?: number) {
const featured = prompts.filter((p) => p.featured);
const regular = prompts.filter((p) => !p.featured);
return {
all: prompts,
featured,
regular,
stats: {
total: total ?? prompts.length,
featured: featured.length,
},
};
}
/**
* 根据 GitHub issue 编号查找已存在的 prompt
*/
export async function findPromptByGitHubIssue(
issueNumber: string
): Promise<Prompt | null> {
const query = {
limit: 1,
depth: 2,
where: {
"sourceMeta.github_issue": {
equals: issueNumber,
},
model: {
equals: "gpt-image-2",
},
},
};
const stringifiedQuery = stringify(query, { addQueryPrefix: true });
const url = `${CMS_HOST}/api/prompts${stringifiedQuery}`;
const response = await fetch(url, {
headers: {
Authorization: `users API-Key ${CMS_API_KEY}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`CMS API error: ${response.statusText}`);
}
const data = (await response.json()) as CMSResponse;
return data.docs.length > 0 ? data.docs[0] : null;
}
/**
* 创建新 prompt直接发布无草稿
*/
export async function createPrompt(
data: Partial<Prompt>
): Promise<Prompt | null> {
const url = `${CMS_HOST}/api/prompts`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `users API-Key ${CMS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to create prompt: ${response.statusText} - ${errorText}`
);
}
return response.json() as Promise<Prompt | null>;
}
/**
* 更新已存在的 prompt
*/
export async function updatePrompt(
id: number,
data: Partial<Prompt>
): Promise<Prompt | null> {
const url = `${CMS_HOST}/api/prompts/${id}`;
const response = await fetch(url, {
method: "PATCH",
headers: {
Authorization: `users API-Key ${CMS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to update prompt: ${response.statusText} - ${errorText}`
);
}
return response.json() as Promise<Prompt | null>;
}
/**
* Category from CMS
*/
export interface PromptCategory {
id: number;
title: string;
slug: string;
parent?: PromptCategory | null;
featured?: boolean;
sort?: number;
}
/**
* Processed category for filtering
*/
export interface FilterCategory {
id: number;
title: string;
slug: string;
parentId?: number | null;
parentSlug?: string | null;
featured?: boolean;
sort?: number | null;
}
/**
* Category group organized by parent-child structure
*/
export interface CategoryGroup {
parentId: number | null;
parentTitle: string | null;
parentSlug: string | null;
children: FilterCategory[];
}
interface CMSCategoryResponse {
docs: PromptCategory[];
totalDocs: number;
}
/**
* Fetch prompt categories from CMS
*/
export async function fetchPromptCategories(locale: string = "en-US"): Promise<{
allCategories: FilterCategory[];
featuredCategories: FilterCategory[];
}> {
const query = {
limit: 9999,
sort: "sort",
locale,
where: {
campaign: {
contains: "gpt-image-2-prompts",
},
},
};
const stringifiedQuery = stringify(query, { addQueryPrefix: true });
const url = `${CMS_HOST}/api/prompt-categories${stringifiedQuery}`;
const response = await fetch(url, {
headers: {
Authorization: `users API-Key ${CMS_API_KEY}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`CMS API error: ${response.statusText}`);
}
const data = (await response.json()) as CMSCategoryResponse;
// Transform to FilterCategory format
const allCategories: FilterCategory[] = data.docs.map((cat) => {
let parentId: number | null = null;
let parentSlug: string | null = null;
if (cat.parent) {
if (typeof cat.parent === "number") {
parentId = cat.parent;
} else if (typeof cat.parent === "object" && cat.parent !== null) {
parentId = cat.parent.id;
parentSlug = cat.parent.slug;
}
}
return {
id: cat.id,
title: cat.title,
slug: cat.slug,
parentId,
parentSlug,
featured: cat.featured ?? false,
sort: cat.sort,
};
});
// Filter featured categories (leaf nodes with featured=true)
const featuredCategories = allCategories.filter((cat) => {
const isParent = allCategories.some((c) => c.parentId === cat.id);
return cat.featured && !isParent;
});
return {
allCategories,
featuredCategories,
};
}