From 9a15a99ba18cd560e3f385beaa200b11a848d2b4 Mon Sep 17 00:00:00 2001 From: CPTProgrammer <46586216+CPTProgrammer@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:58:49 +0800 Subject: [PATCH] Add CurseForge and Modrinth project retrieval APIs --- src/lib/curseforge/index.ts | 1 + src/lib/curseforge/mod.test.ts | 24 +++++ src/lib/curseforge/mod.ts | 15 +++ src/lib/modrinth/index.ts | 1 + src/lib/modrinth/project.test.ts | 31 ++++++ src/lib/modrinth/project.ts | 30 ++++++ src/types/curseforge/files.ts | 22 ++-- src/types/curseforge/index.ts | 19 ++++ src/types/curseforge/mod.ts | 178 +++++++++++++++++++++++++++++++ src/types/modrinth/index.ts | 13 +++ src/types/modrinth/project.ts | 142 ++++++++++++++++++++++++ 11 files changed, 465 insertions(+), 11 deletions(-) create mode 100644 src/lib/curseforge/mod.test.ts create mode 100644 src/lib/curseforge/mod.ts create mode 100644 src/lib/modrinth/project.test.ts create mode 100644 src/lib/modrinth/project.ts create mode 100644 src/types/curseforge/mod.ts create mode 100644 src/types/modrinth/project.ts diff --git a/src/lib/curseforge/index.ts b/src/lib/curseforge/index.ts index 051cbba..ab39e42 100644 --- a/src/lib/curseforge/index.ts +++ b/src/lib/curseforge/index.ts @@ -1,3 +1,4 @@ export { CurseForgeClient } from "./client"; export { getVersionTypes, getGameVersions, getMinecraftVersions } from "./game"; export { getFiles, uploadFile, updateFile } from "./file"; +export { getMod } from "./mod"; diff --git a/src/lib/curseforge/mod.test.ts b/src/lib/curseforge/mod.test.ts new file mode 100644 index 0000000..8317e3d --- /dev/null +++ b/src/lib/curseforge/mod.test.ts @@ -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(); + }); +}); diff --git a/src/lib/curseforge/mod.ts b/src/lib/curseforge/mod.ts new file mode 100644 index 0000000..c062ea2 --- /dev/null +++ b/src/lib/curseforge/mod.ts @@ -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 { + const path = `/v1/mods/${encodeURIComponent(modId)}`; + const data = await client.get(path); + return GetModResponseSchema.parse(data); +} diff --git a/src/lib/modrinth/index.ts b/src/lib/modrinth/index.ts index 9c9e962..ab9452d 100644 --- a/src/lib/modrinth/index.ts +++ b/src/lib/modrinth/index.ts @@ -7,3 +7,4 @@ export { createVersion, } from "./version"; export { getLoaders, getGameVersions } from "./tag"; +export { getProject, getProjects } from "./project"; diff --git a/src/lib/modrinth/project.test.ts b/src/lib/modrinth/project.test.ts new file mode 100644 index 0000000..1542e1d --- /dev/null +++ b/src/lib/modrinth/project.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/modrinth/project.ts b/src/lib/modrinth/project.ts new file mode 100644 index 0000000..c7578e2 --- /dev/null +++ b/src/lib/modrinth/project.ts @@ -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 { + const path = `/project/${encodeURIComponent(idOrSlug)}`; + const data = await client.get(path); + return ProjectSchema.parse(data); +} + +/** + * 批量获取多个 project 的完整信息。 + * GET /projects?ids=["...","..."] + */ +export async function getProjects( + client: ModrinthClient, + ids: string[], +): Promise { + const params = new URLSearchParams(); + params.set("ids", JSON.stringify(ids)); + const path = `/projects?${params.toString()}`; + const data = await client.get(path); + return ProjectSchema.array().parse(data); +} diff --git a/src/types/curseforge/files.ts b/src/types/curseforge/files.ts index 298dc82..b802119 100644 --- a/src/types/curseforge/files.ts +++ b/src/types/curseforge/files.ts @@ -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(), /** 文件模块列表 */ diff --git a/src/types/curseforge/index.ts b/src/types/curseforge/index.ts index 45521ff..e149844 100644 --- a/src/types/curseforge/index.ts +++ b/src/types/curseforge/index.ts @@ -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"; diff --git a/src/types/curseforge/mod.ts b/src/types/curseforge/mod.ts new file mode 100644 index 0000000..d422f22 --- /dev/null +++ b/src/types/curseforge/mod.ts @@ -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; +// 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; +// 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; +// 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; +// 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; +// 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; +// 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; +// endregion + +// region GetModResponse — GET /v1/mods/{modId} 成功响应体 +export const GetModResponseSchema = z.object({ + data: ModSchema, +}); +export type GetModResponse = z.infer; +// endregion diff --git a/src/types/modrinth/index.ts b/src/types/modrinth/index.ts index c6a3473..7ed83c9 100644 --- a/src/types/modrinth/index.ts +++ b/src/types/modrinth/index.ts @@ -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"; diff --git a/src/types/modrinth/project.ts b/src/types/modrinth/project.ts new file mode 100644 index 0000000..b4a4769 --- /dev/null +++ b/src/types/modrinth/project.ts @@ -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; +// 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; +// 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; +// endregion + +// region ModeratorMessage — 已弃用,始终为 null +export const ModeratorMessageSchema = z.object({ + /** 审核员留言 */ + message: z.string(), + /** 留言详细内容 */ + body: z.string().nullable(), +}); +export type ModeratorMessage = z.infer; +// endregion + +// region Project — 完整 Project 对象 +export const ProjectSchema = z.object({ + /** Project ID(base62 编码) */ + 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; +// endregion