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>
508 lines
12 KiB
TypeScript
508 lines
12 KiB
TypeScript
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,
|
||
};
|
||
}
|