Add CurseForge and Modrinth project retrieval APIs

This commit is contained in:
CPTProgrammer 2026-07-13 18:58:49 +08:00
parent 296b69bf1c
commit 9a15a99ba1
No known key found for this signature in database
11 changed files with 465 additions and 11 deletions

View File

@ -1,3 +1,4 @@
export { CurseForgeClient } from "./client";
export { getVersionTypes, getGameVersions, getMinecraftVersions } from "./game";
export { getFiles, uploadFile, updateFile } from "./file";
export { getMod } from "./mod";

View File

@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import { CurseForgeClient } from "./client";
import { getMod } from "./mod";
import secrets from "../../../secrets.json";
const client = new CurseForgeClient(secrets.curseforge, secrets.curseforge_legacy);
describe("getMod", () => {
test("GET /v1/mods/{modId}", async () => {
const response = await getMod(client, 238222);
const mod = response.data;
expect(mod.id).toBe(238222);
expect(mod.name).toBeTruthy();
expect(mod.slug).toBeTruthy();
expect(typeof mod.summary).toBe("string");
expect(typeof mod.downloadCount).toBe("number");
expect(Array.isArray(mod.authors)).toBe(true);
expect(mod.authors.length).toBeGreaterThan(0);
expect(Array.isArray(mod.categories)).toBe(true);
expect(Array.isArray(mod.latestFiles)).toBe(true);
expect(Array.isArray(mod.latestFilesIndexes)).toBe(true);
expect(mod.logo).toBeDefined();
});
});

15
src/lib/curseforge/mod.ts Normal file
View File

@ -0,0 +1,15 @@
import { CurseForgeClient } from "./client";
import { GetModResponseSchema, type GetModResponse } from "@/types";
/**
*
* GET /v1/mods/{modId}
*/
export async function getMod(
client: CurseForgeClient,
modId: number,
): Promise<GetModResponse> {
const path = `/v1/mods/${encodeURIComponent(modId)}`;
const data = await client.get<unknown>(path);
return GetModResponseSchema.parse(data);
}

View File

@ -7,3 +7,4 @@ export {
createVersion,
} from "./version";
export { getLoaders, getGameVersions } from "./tag";
export { getProject, getProjects } from "./project";

View File

@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { ModrinthClient } from "./client";
import { getProject, getProjects } from "./project";
import secrets from "../../../secrets.json";
const client = new ModrinthClient(secrets.modrinth, "ModReleaser (local)");
describe("getProject", () => {
test("GET /project/{id|slug} returns a valid Project", async () => {
const project = await getProject(client, "fabric-api");
expect(project).toBeDefined();
expect(typeof project.id).toBe("string");
expect(project.slug).toBe("fabric-api");
expect(Array.isArray(project.versions)).toBe(true);
expect(Array.isArray(project.game_versions)).toBe(true);
expect(Array.isArray(project.loaders)).toBe(true);
expect(Array.isArray(project.gallery)).toBe(true);
});
});
describe("getProjects", () => {
test("GET /projects?ids=[...] returns an array of Projects", async () => {
const projects = await getProjects(client, ["fabric-api", "sodium"]);
expect(Array.isArray(projects)).toBe(true);
expect(projects.length).toBe(2);
for (const project of projects) {
expect(typeof project.id).toBe("string");
expect(Array.isArray(project.versions)).toBe(true);
}
});
});

View File

@ -0,0 +1,30 @@
import { ModrinthClient } from "./client";
import { ProjectSchema, type Project } from "@/types";
/**
* project
* GET /project/{id|slug}
*/
export async function getProject(
client: ModrinthClient,
idOrSlug: string,
): Promise<Project> {
const path = `/project/${encodeURIComponent(idOrSlug)}`;
const data = await client.get<unknown>(path);
return ProjectSchema.parse(data);
}
/**
* project
* GET /projects?ids=["...","..."]
*/
export async function getProjects(
client: ModrinthClient,
ids: string[],
): Promise<Project[]> {
const params = new URLSearchParams();
params.set("ids", JSON.stringify(ids));
const path = `/projects?${params.toString()}`;
const data = await client.get<unknown[]>(path);
return ProjectSchema.array().parse(data);
}

View File

@ -124,8 +124,8 @@ export const CurseForgeFileSchema = z.object({
fileLength: z.number().int(),
/** 下载次数 */
downloadCount: z.number().int(),
/** 磁盘占用(字节) */
fileSizeOnDisk: z.number().int(),
/** 磁盘占用(字节),可能为 null 或被省略 */
fileSizeOnDisk: z.number().int().nullable().optional(),
/** 下载地址 */
downloadUrl: z.string(),
/** 关联的游戏版本(字符串列表) */
@ -135,19 +135,19 @@ export const CurseForgeFileSchema = z.object({
/** 依赖关系列表 */
dependencies: z.array(FileDependencySchema),
/** 是否作为替代文件暴露 */
exposeAsAlternative: z.boolean(),
/** 父项目文件 ID无则为 null */
parentProjectFileId: z.number().int().nullable(),
exposeAsAlternative: z.boolean().nullable().optional(),
/** 父项目文件 ID无则为 null 或被省略 */
parentProjectFileId: z.number().int().nullable().optional(),
/** 替代文件 ID无则为 null */
alternateFileId: z.number().int().nullable(),
/** 是否为服务端整合包 */
isServerPack: z.boolean(),
/** 服务端整合包文件 ID无则为 null */
serverPackFileId: z.number().int().nullable(),
isServerPack: z.boolean().nullable().optional(),
/** 服务端整合包文件 ID无则为 null 或被省略 */
serverPackFileId: z.number().int().nullable().optional(),
/** 是否为抢先体验内容 */
isEarlyAccessContent: z.boolean(),
/** 抢先体验截止日期ISO-8601无则为 null */
earlyAccessEndDate: z.string().nullable(),
isEarlyAccessContent: z.boolean().nullable().optional(),
/** 抢先体验截止日期ISO-8601无则为 null 或被省略 */
earlyAccessEndDate: z.string().nullable().optional(),
/** 文件指纹 */
fileFingerprint: z.number().int(),
/** 文件模块列表 */

View File

@ -54,3 +54,22 @@ export {
type UpdateMetadata,
type UploadResponse,
} from "./upload";
export {
ModStatusSchema,
ModLinksSchema,
ModAuthorSchema,
ModAssetSchema,
CategorySchema,
FileIndexSchema,
ModSchema,
GetModResponseSchema,
type ModStatus,
type ModLinks,
type ModAuthor,
type ModAsset,
type Category,
type FileIndex,
type Mod,
type GetModResponse,
} from "./mod";

178
src/types/curseforge/mod.ts Normal file
View File

@ -0,0 +1,178 @@
import { z } from "zod";
import {
FileReleaseTypeSchema,
CurseForgeFileSchema,
} from "./files";
// region ModStatus — 项目状态
export const ModStatusSchema = z.union([
z.literal(1), // New
z.literal(2), // ChangesRequired
z.literal(3), // UnderSoftReview
z.literal(4), // Approved
z.literal(5), // Rejected
z.literal(6), // ChangesMade
z.literal(7), // Inactive
z.literal(8), // Abandoned
z.literal(9), // Deleted
z.literal(10), // UnderReview
]);
export type ModStatus = z.infer<typeof ModStatusSchema>;
// endregion
// region ModLinks — 项目相关链接
export const ModLinksSchema = z.object({
/** 官网链接 */
websiteUrl: z.string(),
/** Wiki 链接 */
wikiUrl: z.string(),
/** Issue 追踪链接 */
issuesUrl: z.string(),
/** 源码仓库链接 */
sourceUrl: z.string(),
});
export type ModLinks = z.infer<typeof ModLinksSchema>;
// endregion
// region ModAuthor — 作者信息
export const ModAuthorSchema = z.object({
/** 作者 ID */
id: z.number().int(),
/** 作者名 */
name: z.string(),
/** 作者页面 URL */
url: z.string(),
});
export type ModAuthor = z.infer<typeof ModAuthorSchema>;
// endregion
// region ModAsset — 项目资源Logo / Screenshot
export const ModAssetSchema = z.object({
/** 资源 ID */
id: z.number().int(),
/** 所属项目 ID */
modId: z.number().int(),
/** 资源标题 */
title: z.string(),
/** 资源描述 */
description: z.string(),
/** 缩略图 URL */
thumbnailUrl: z.string(),
/** 原图 URL */
url: z.string(),
});
export type ModAsset = z.infer<typeof ModAssetSchema>;
// endregion
// region Category — 分类
export const CategorySchema = z.object({
/** 分类 ID */
id: z.number().int(),
/** 关联的游戏 ID */
gameId: z.number().int(),
/** 分类名称 */
name: z.string(),
/** URL 中的分类标识 */
slug: z.string(),
/** 分类页面 URL */
url: z.string(),
/** 分类图标 URL */
iconUrl: z.string(),
/** 最后修改时间ISO-8601 */
dateModified: z.string(),
/** 是否为顶层分类 */
isClass: z.boolean().nullable(),
/** 所属 class ID */
classId: z.number().int().nullable(),
/** 父分类 ID */
parentCategoryId: z.number().int().nullable(),
/** 显示排序索引 */
displayIndex: z.number().int().nullable().optional(),
});
export type Category = z.infer<typeof CategorySchema>;
// endregion
// region FileIndex — 文件索引
export const FileIndexSchema = z.object({
/** 游戏版本 */
gameVersion: z.string(),
/** 文件 ID */
fileId: z.number().int(),
/** 文件名 */
filename: z.string(),
/** 发布类型 */
releaseType: FileReleaseTypeSchema,
/** 版本类型 ID */
gameVersionTypeId: z.number().int().nullable(),
/** Mod Loader 类型数值API 可能返回未在文档中列出的值,也可能省略) */
modLoader: z.number().int().optional(),
});
export type FileIndex = z.infer<typeof FileIndexSchema>;
// endregion
// region Mod — GET /v1/mods/{modId} 响应中的 data 字段
export const ModSchema = z.object({
/** 项目 ID */
id: z.number().int(),
/** 游戏 ID */
gameId: z.number().int(),
/** 项目名称 */
name: z.string(),
/** URL 中的项目标识 */
slug: z.string(),
/** 相关链接 */
links: ModLinksSchema,
/** 项目简介 */
summary: z.string(),
/** 项目状态 */
status: ModStatusSchema,
/** 下载总次数 */
downloadCount: z.number().int(),
/** 是否为精选项目 */
isFeatured: z.boolean(),
/** 主分类 ID */
primaryCategoryId: z.number().int(),
/** 分类列表 */
categories: z.array(CategorySchema),
/** 所属 class ID无则为 null */
classId: z.number().int().nullable(),
/** 作者列表 */
authors: z.array(ModAuthorSchema),
/** Logo 资源 */
logo: ModAssetSchema,
/** 截图列表 */
screenshots: z.array(ModAssetSchema),
/** 主文件 ID */
mainFileId: z.number().int(),
/** 最新文件列表 */
latestFiles: z.array(CurseForgeFileSchema),
/** 最新文件索引 */
latestFilesIndexes: z.array(FileIndexSchema),
/** 最新抢先体验文件索引 */
latestEarlyAccessFilesIndexes: z.array(FileIndexSchema),
/** 创建时间ISO-8601 */
dateCreated: z.string(),
/** 最后修改时间ISO-8601 */
dateModified: z.string(),
/** 发布时间ISO-8601 */
dateReleased: z.string(),
/** 是否允许分发 */
allowModDistribution: z.boolean().nullable(),
/** 游戏内热门排名 */
gamePopularityRank: z.number().int(),
/** 是否可被搜索到 */
isAvailable: z.boolean(),
/** 点赞数 */
thumbsUpCount: z.number().int(),
/** 评分,无则为 null 或被省略 */
rating: z.number().nullable().optional(),
});
export type Mod = z.infer<typeof ModSchema>;
// endregion
// region GetModResponse — GET /v1/mods/{modId} 成功响应体
export const GetModResponseSchema = z.object({
data: ModSchema,
});
export type GetModResponse = z.infer<typeof GetModResponseSchema>;
// endregion

View File

@ -33,3 +33,16 @@ export {
type CreatableVersion,
type CreateVersionBody,
} from "./create-version";
export {
ProjectLicenseSchema,
ProjectDonationURLSchema,
GalleryImageSchema,
ModeratorMessageSchema,
ProjectSchema,
type ProjectLicense,
type ProjectDonationURL,
type GalleryImage,
type ModeratorMessage,
type Project,
} from "./project";

View File

@ -0,0 +1,142 @@
import { z } from "zod";
// region ProjectLicense
export const ProjectLicenseSchema = z.object({
/** SPDX 许可证 ID如 `LGPL-3.0-or-later` */
id: z.string(),
/** 许可证全名(如 `GNU Lesser General Public License v3 or later` */
name: z.string(),
/** 许可证文本 URL */
url: z.string().nullable(),
});
export type ProjectLicense = z.infer<typeof ProjectLicenseSchema>;
// endregion
// region ProjectDonationURL
export const ProjectDonationURLSchema = z.object({
/** 捐赠平台 ID如 `patreon` */
id: z.string(),
/** 捐赠平台名称(如 `Patreon` */
platform: z.string(),
/** 捐赠链接 */
url: z.string(),
});
export type ProjectDonationURL = z.infer<typeof ProjectDonationURLSchema>;
// endregion
// region GalleryImage
export const GalleryImageSchema = z.object({
/** 图片 URL */
url: z.string(),
/** 是否为精选图片 */
featured: z.boolean(),
/** 图片标题 */
title: z.string().nullable(),
/** 图片描述 */
description: z.string().nullable(),
/** 创建日期ISO-8601 */
created: z.string(),
/** 排序序号 */
ordering: z.number().int(),
});
export type GalleryImage = z.infer<typeof GalleryImageSchema>;
// endregion
// region ModeratorMessage — 已弃用,始终为 null
export const ModeratorMessageSchema = z.object({
/** 审核员留言 */
message: z.string(),
/** 留言详细内容 */
body: z.string().nullable(),
});
export type ModeratorMessage = z.infer<typeof ModeratorMessageSchema>;
// endregion
// region Project — 完整 Project 对象
export const ProjectSchema = z.object({
/** Project IDbase62 编码) */
id: z.string(),
/** 用于自定义 URL 的简短标识符 */
slug: z.string(),
/** Project 名称或标题 */
title: z.string(),
/** 简短描述 */
description: z.string(),
/** 长描述(支持 Markdown */
body: z.string(),
/** 主分类列表 */
categories: z.array(z.string()),
/** 附加分类 */
additional_categories: z.array(z.string()),
/** 客户端支持类型 */
client_side: z.enum(["required", "optional", "unsupported", "unknown"]),
/** 服务端支持类型 */
server_side: z.enum(["required", "optional", "unsupported", "unknown"]),
/** 项目类型 */
project_type: z.enum(["mod", "modpack", "resourcepack", "shader"]),
/** 总下载次数 */
downloads: z.number().int(),
/** 关注者数量 */
followers: z.number().int(),
/** 项目状态 */
status: z.enum([
"approved",
"archived",
"rejected",
"draft",
"unlisted",
"processing",
"withheld",
"scheduled",
"private",
"unknown",
]),
/** 申请中的状态 */
requested_status: z
.enum(["approved", "archived", "unlisted", "private", "draft"])
.nullable(),
/** 许可证信息 */
license: ProjectLicenseSchema,
/** 拥有此 project 的团队 ID */
team: z.string(),
/** 发布日期ISO-8601 */
published: z.string(),
/** 最后更新日期ISO-8601 */
updated: z.string(),
/** 审核通过日期ISO-8601 */
approved: z.string().nullable(),
/** 提交审核日期ISO-8601 */
queued: z.string().nullable(),
/** 图标 URL */
icon_url: z.string().nullable(),
/** 从图标自动生成的 RGB 颜色值 */
color: z.number().int().nullable(),
/** 关联的审核线程 ID */
thread_id: z.string(),
/** 变现状态 */
monetization_status: z.enum(["monetized", "demonetized", "force-demonetized"]),
/** 问题跟踪链接 */
issues_url: z.string().nullable(),
/** 源代码链接 */
source_url: z.string().nullable(),
/** Wiki 链接 */
wiki_url: z.string().nullable(),
/** Discord 邀请链接 */
discord_url: z.string().nullable(),
/** 捐赠链接列表 */
donation_urls: z.array(ProjectDonationURLSchema),
/** 已弃用,始终为 null */
body_url: z.null(),
/** 已弃用,始终为 null */
moderator_message: z.null(),
/** 所有版本 ID 列表 */
versions: z.array(z.string()),
/** 支持的 Minecraft 版本列表 */
game_versions: z.array(z.string()),
/** 支持的模组加载器列表 */
loaders: z.array(z.string()),
/** 图库图片列表 */
gallery: z.array(GalleryImageSchema),
});
export type Project = z.infer<typeof ProjectSchema>;
// endregion