Add CurseForge and Modrinth project retrieval APIs
This commit is contained in:
parent
296b69bf1c
commit
9a15a99ba1
@ -1,3 +1,4 @@
|
||||
export { CurseForgeClient } from "./client";
|
||||
export { getVersionTypes, getGameVersions, getMinecraftVersions } from "./game";
|
||||
export { getFiles, uploadFile, updateFile } from "./file";
|
||||
export { getMod } from "./mod";
|
||||
|
||||
24
src/lib/curseforge/mod.test.ts
Normal file
24
src/lib/curseforge/mod.test.ts
Normal 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
15
src/lib/curseforge/mod.ts
Normal 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);
|
||||
}
|
||||
@ -7,3 +7,4 @@ export {
|
||||
createVersion,
|
||||
} from "./version";
|
||||
export { getLoaders, getGameVersions } from "./tag";
|
||||
export { getProject, getProjects } from "./project";
|
||||
|
||||
31
src/lib/modrinth/project.test.ts
Normal file
31
src/lib/modrinth/project.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
30
src/lib/modrinth/project.ts
Normal file
30
src/lib/modrinth/project.ts
Normal 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);
|
||||
}
|
||||
@ -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(),
|
||||
/** 文件模块列表 */
|
||||
|
||||
@ -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
178
src/types/curseforge/mod.ts
Normal 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
|
||||
@ -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";
|
||||
|
||||
142
src/types/modrinth/project.ts
Normal file
142
src/types/modrinth/project.ts
Normal 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 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<typeof ProjectSchema>;
|
||||
// endregion
|
||||
Loading…
Reference in New Issue
Block a user