Skip to content

Modrinth 与 CurseForge ​

启动器的「社区」分区同时挂了两个源。这一页讲它们的差别,以及我们为什么给 CurseForge 做了三层接口回退。

两个源的形状不一样 ​

Modrinth 的 API 在 https://api.modrinth.com/v2,免鉴权,筛选用 facets(外层 AND、内层 OR):

GET /v2/search?limit=20&offset=0&index=relevance&query=sodium
    &facets=[["project_type:mod"],["categories:fabric"]]

结果挂在 hits,总数在 total_hits。反查走 POST /v2/version_files,请求体 {"hashes":["<小写 sha1>"],"algorithm":"sha1"},一次最多 100 个,所以按 100 分片:

csharp
foreach (var chunk in sha1s.Chunk(100))
{
    // 本地 Convert.ToHexString 给的是大写,直接发会被当成未知文件(返回空表)
    var body = JsonSerializer.Serialize(new
    {
        hashes = chunk.Select(h => h.ToLowerInvariant()).ToArray(),
        algorithm = "sha1",
    });
}

大小写不是洁癖,是硬要求

Modrinth 对大写哈希不匹配,version_files 会安静地返回一张空表——不报错,只是全查不到。

CurseForge 完全不同:查询参数是 gameId=432(Minecraft)+ classId(6=mod、4471=modpack、6946=resourcepack、6552=shader),加载器用数字枚举(1=Forge、4=Fabric、5=Quilt、6=NeoForge),反查走 POST /mods/fingerprints,请求体 {"fingerprints":[123456,...]}。

因为形状差太远,我们做了个偷懒但值当的决定:CurseForge 的搜索结果与文件列表直接复用 Modrinth 的 DTO(ModrinthHit / ModrinthVersion),只在 DTO 上多带一个 Source 标记。下载页、资源库、依赖检查、整合包安装这些已有链路一行不用改,CurseForge 只是"另一个数据来源"。

三层 API 梯子 ​

旧 Tauri 版走 www.curseforge.com/api/v1/...(PCL2 那套无 key 接口),实测已被 Cloudflare 托管挑战拦下;而官方 API 不带 key 明确返回 403 Forbidden: API Key missing or invalid。所以做成三层:

层地址需要 key说明
官方https://api.curseforge.com/v1是数据结构最全,下载归属能算到本启动器
MCIM 镜像https://mod.mcimirror.top/curseforge/v1否国内 CDN 加速;只缓存 Mod 类内容,非 Mod 与 >20MB 文件可能拿不到
社区代理https://api.curse.tools/v1否社区维护的只读代理,兜最后一道

回退在 CurseForgeService.SendAsync:单层 6 秒无响应即判不可用,记 10 分钟冷却,换下一层。成功的层被记住(_preferred),本会话内优先。

csharp
private const int SlowSeconds = 6;
private static readonly TimeSpan Cooldown = TimeSpan.FromMinutes(10);

if (Cooling(apiBase, now) && ladder.Any(b => !Cooling(b, now))) continue;

冷却不能把功能锁死

最后那个 Any(...) 是刻意的:所有层都在冷却里时,仍然按顺序试一次。冷却是"降级"机制,不是"熔断后禁用功能"——用户宁可等 6 秒,也不想看到"CurseForge 不可用"这种永久结论。

CurseForge 的请求不参与原版下载的源健康度统计(trackSourceHealth: false),免得一次 CF 超时把 Mojang 镜像的排序也带偏。

密钥只发给官方层 ​

csharp
private IReadOnlyDictionary<string, string>? HeadersFor(string apiBase)
{
    if (!string.Equals(apiBase, OfficialApiBase, StringComparison.Ordinal)) return null;
    return CurseForgeKey.Effective(_settings()) is { } key
        ? new Dictionary<string, string> { ["x-api-key"] = key } : null;
}

镜像与社区代理拿不到也不该拿到我们的密钥。密钥来自两条路:用户自备优先,留空用构建时注入的内置密钥(从程序集元数据读,本体永不进 GitHub)。

更麻烦的是:官方文件 CDN 自 2026-07-16 起也强制 x-api-key,不带一律 401,表现是"搜索能用、一下载就 401"。解法是一个 DelegatingHandler,只对 CDN 主机补头:

csharp
if (request.RequestUri is { } uri && CurseForgeCdn.IsCdnHost(uri.Host) &&
    CurseForgeKey.Effective(_settings()) is { } key)
    request.Headers.TryAddWithoutValidation("x-api-key", key);

它挂在 HttpClient 上,edge.forgecdn.net / mediafilez.forgecdn.net 两台主机之外的请求一个头都不加。

指纹算法不能改 ​

CurseForge 指纹用于导出整合包时反查 projectID / fileID。规则是:剔除所有 ASCII 空白字节(0x09 / 0x0A / 0x0D / 0x20)后做 murmur2(seed = 1),最后抹掉最高位成 31 位正整数。

csharp
uint h = seed ^ (uint)length;   // length 是剔除空白后的字节数
h ^= h >> 13; h *= m; h ^= h >> 15;
return (int)(h & 0x7FFFFFFF);

两个细节容易写错:murmur2 的初值是 seed ^ length,所以要先数一遍剔除空白后的长度;两遍都是流式的(64KB 缓冲),不把几百 MB 的 jar 整块读进内存。

这段不能"优化"

改一字节就对不上服务端的指纹,反查全部落空,导出的整合包会把本该走下载的文件全塞进 overrides,包体积暴涨。算错了是静默失败,不报错,只是包变大。

反查接口单次上限 1000 个,按 1000 分块提交;任一批失败就返回已拿到的部分,剩下的文件回退 overrides 打包——反查失败不该让导出失败。

相关链接 ​

基于 MIT 许可证(有修改)开源