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 分片:
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),本会话内优先。
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 镜像的排序也带偏。
密钥只发给官方层
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 主机补头:
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 位正整数。
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 打包——反查失败不该让导出失败。
相关链接
- 统一内容库 —— 两个源的内容如何进同一个库
- 整合包格式与导入导出 —— 指纹反查在导出时怎么用
- 中文搜索 —— 英文索引带来的搜索问题