mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-12 07:50:25 +00:00
Introduces a new SSE-based plugin installation API for real-time progress updates and adds frontend support for selecting download mirrors, especially for GitHub-based plugins. Refactors backend plugin directory handling, improves logging, and updates the frontend to use the new API with user-selectable mirrors and progress feedback.
1096 lines
32 KiB
TypeScript
1096 lines
32 KiB
TypeScript
/**
|
||
* GitHub 镜像配置模块
|
||
* 提供统一的镜像源管理,支持复杂网络环境
|
||
*
|
||
* 镜像源测试时间: 2026-01-03
|
||
* 测试通过: 55/61 完全可用
|
||
*/
|
||
|
||
import https from 'https';
|
||
import http from 'http';
|
||
import { RequestUtil } from './request';
|
||
import { PromiseTimer } from './helper';
|
||
|
||
// ============== 镜像源列表 ==============
|
||
|
||
/**
|
||
* GitHub 文件加速镜像
|
||
* 用于加速 release assets 下载
|
||
* 按延迟排序,优先使用快速镜像
|
||
*
|
||
* 测试时间: 2026-01-03
|
||
* 镜像支持 301/302 重定向
|
||
* 懒加载测速:首次使用时自动测速,缓存 30 分钟
|
||
*/
|
||
export const GITHUB_FILE_MIRRORS = [
|
||
'https://github.chenc.dev/',
|
||
'https://ghproxy.cfd/',
|
||
'https://github.tbedu.top/',
|
||
'https://ghproxy.cc/',
|
||
'https://gh.monlor.com/',
|
||
'https://cdn.akaere.online/',
|
||
'https://gh.idayer.com/',
|
||
'https://gh.llkk.cc/',
|
||
'https://ghpxy.hwinzniej.top/',
|
||
'https://github-proxy.memory-echoes.cn/',
|
||
'https://git.yylx.win/',
|
||
'https://gitproxy.mrhjx.cn/',
|
||
'https://gh.fhjhy.top/',
|
||
'https://gp.zkitefly.eu.org/',
|
||
'https://gh-proxy.com/',
|
||
'https://ghfile.geekertao.top/',
|
||
'https://j.1lin.dpdns.org/',
|
||
'https://ghproxy.imciel.com/',
|
||
'https://github-proxy.teach-english.tech/',
|
||
'https://gh.927223.xyz/',
|
||
'https://github.ednovas.xyz/',
|
||
'https://ghf.xn--eqrr82bzpe.top/',
|
||
'https://gh.dpik.top/',
|
||
'https://gh.jasonzeng.dev/',
|
||
'https://gh.xxooo.cf/',
|
||
'https://gh.bugdey.us.kg/',
|
||
'https://ghm.078465.xyz/',
|
||
'https://j.1win.ggff.net/',
|
||
'https://tvv.tw/',
|
||
'https://gitproxy.127731.xyz/',
|
||
'https://gh.inkchills.cn/',
|
||
'https://ghproxy.cxkpro.top/',
|
||
'https://gh.sixyin.com/',
|
||
'https://github.geekery.cn/',
|
||
'https://git.669966.xyz/',
|
||
'https://gh.5050net.cn/',
|
||
'https://gh.felicity.ac.cn/',
|
||
'https://github.dpik.top/',
|
||
'https://ghp.keleyaa.com/',
|
||
'https://gh.wsmdn.dpdns.org/',
|
||
'https://ghproxy.monkeyray.net/',
|
||
'https://fastgit.cc/',
|
||
'https://gh.catmak.name/',
|
||
'https://gh.noki.icu/',
|
||
'', // 原始 URL(无镜像)
|
||
];
|
||
|
||
/**
|
||
* GitHub API 镜像
|
||
* 用于访问 GitHub API(作为备选方案)
|
||
* 注:优先使用非 API 方法,减少对 API 的依赖
|
||
*
|
||
* 经测试,大部分代理镜像不支持 API 转发
|
||
* 建议使用 getLatestReleaseTag 等方法避免 API 调用
|
||
*/
|
||
export const GITHUB_API_MIRRORS = [
|
||
'https://api.github.com',
|
||
// 目前没有可用的公共 API 代理镜像
|
||
];
|
||
|
||
/**
|
||
* GitHub Raw 镜像
|
||
* 用于访问 raw.githubusercontent.com
|
||
* 注:大多数通用代理也支持 raw 文件加速
|
||
*/
|
||
export const GITHUB_RAW_MIRRORS = [
|
||
'https://raw.githubusercontent.com',
|
||
// 测试确认支持 raw 文件的镜像
|
||
'https://github.chenc.dev/https://raw.githubusercontent.com',
|
||
'https://ghproxy.cfd/https://raw.githubusercontent.com',
|
||
'https://ghproxy.cc/https://raw.githubusercontent.com',
|
||
'https://gh-proxy.net/https://raw.githubusercontent.com',
|
||
];
|
||
|
||
/**
|
||
* Nightly.link 镜像
|
||
* 用于访问 GitHub Actions artifacts
|
||
* 优先使用官方服务,出现问题时可切换镜像
|
||
*/
|
||
export const NIGHTLY_LINK_MIRRORS = [
|
||
'https://nightly.link',
|
||
// 可以添加其他 nightly.link 镜像(如果有的话)
|
||
];
|
||
|
||
// ============== 镜像配置接口 ==============
|
||
|
||
export interface MirrorConfig {
|
||
/** 文件下载镜像(用于 release assets) */
|
||
fileMirrors: string[];
|
||
/** API 镜像 */
|
||
apiMirrors: string[];
|
||
/** Raw 文件镜像 */
|
||
rawMirrors: string[];
|
||
/** Nightly.link 镜像(用于 Actions artifacts) */
|
||
nightlyLinkMirrors: string[];
|
||
/** 超时时间(毫秒) */
|
||
timeout: number;
|
||
/** 是否启用镜像 */
|
||
enabled: boolean;
|
||
/** 自定义镜像(优先使用) */
|
||
customMirror?: string;
|
||
}
|
||
|
||
// ============== 默认配置 ==============
|
||
|
||
const defaultConfig: MirrorConfig = {
|
||
fileMirrors: GITHUB_FILE_MIRRORS,
|
||
apiMirrors: GITHUB_API_MIRRORS,
|
||
rawMirrors: GITHUB_RAW_MIRRORS,
|
||
nightlyLinkMirrors: NIGHTLY_LINK_MIRRORS,
|
||
timeout: 5000, // 5秒超时,平衡速度和可靠性
|
||
enabled: true,
|
||
customMirror: undefined,
|
||
};
|
||
|
||
let currentConfig: MirrorConfig = { ...defaultConfig };
|
||
|
||
// ============== 懒加载镜像测速缓存 ==============
|
||
|
||
interface MirrorTestResult {
|
||
mirror: string;
|
||
latency: number;
|
||
success: boolean;
|
||
}
|
||
|
||
// 缓存的快速镜像列表(按延迟排序)
|
||
let cachedFastMirrors: string[] | null = null;
|
||
// 测速是否正在进行
|
||
let mirrorTestingPromise: Promise<string[]> | null = null;
|
||
// 缓存过期时间(30分钟)
|
||
const MIRROR_CACHE_TTL = 30 * 60 * 1000;
|
||
let cacheTimestamp: number = 0;
|
||
|
||
/**
|
||
* 测试单个镜像的延迟(使用 HEAD 请求测试实际文件)
|
||
* 测试一个小型的实际 release 文件,确保镜像支持文件下载
|
||
*/
|
||
async function testMirrorLatency (mirror: string, timeout: number = 5000): Promise<MirrorTestResult> {
|
||
// 使用一个实际存在的小文件来测试(README 或小型 release asset)
|
||
// 用 HEAD 请求,不下载实际内容
|
||
const testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
|
||
const url = buildMirrorUrl(testUrl, mirror);
|
||
const start = Date.now();
|
||
|
||
return new Promise<MirrorTestResult>((resolve) => {
|
||
try {
|
||
const urlObj = new URL(url);
|
||
const isHttps = urlObj.protocol === 'https:';
|
||
const client = isHttps ? https : http;
|
||
|
||
const req = client.request({
|
||
hostname: urlObj.hostname,
|
||
port: urlObj.port || (isHttps ? 443 : 80),
|
||
path: urlObj.pathname + urlObj.search,
|
||
method: 'HEAD',
|
||
timeout,
|
||
headers: {
|
||
'User-Agent': 'NapCat-Mirror-Test',
|
||
},
|
||
}, (res) => {
|
||
const statusCode = res.statusCode || 0;
|
||
// 2xx 或 3xx 都算成功(3xx 说明镜像工作正常,会重定向)
|
||
const isValid = statusCode >= 200 && statusCode < 400;
|
||
resolve({
|
||
mirror,
|
||
latency: Date.now() - start,
|
||
success: isValid,
|
||
});
|
||
});
|
||
|
||
req.on('error', () => {
|
||
resolve({
|
||
mirror,
|
||
latency: Infinity,
|
||
success: false,
|
||
});
|
||
});
|
||
|
||
req.on('timeout', () => {
|
||
req.destroy();
|
||
resolve({
|
||
mirror,
|
||
latency: Infinity,
|
||
success: false,
|
||
});
|
||
});
|
||
|
||
req.end();
|
||
} catch {
|
||
resolve({
|
||
mirror,
|
||
latency: Infinity,
|
||
success: false,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 懒加载获取快速镜像列表
|
||
* 第一次调用时会进行测速,后续使用缓存
|
||
*/
|
||
export async function getFastMirrors (forceRefresh: boolean = false): Promise<string[]> {
|
||
// 检查缓存是否有效
|
||
const now = Date.now();
|
||
if (!forceRefresh && cachedFastMirrors && (now - cacheTimestamp) < MIRROR_CACHE_TTL) {
|
||
return cachedFastMirrors;
|
||
}
|
||
|
||
// 如果已经在测速中,等待结果
|
||
if (mirrorTestingPromise) {
|
||
return mirrorTestingPromise;
|
||
}
|
||
|
||
// 开始测速
|
||
mirrorTestingPromise = performMirrorTest();
|
||
|
||
try {
|
||
const result = await mirrorTestingPromise;
|
||
cachedFastMirrors = result;
|
||
cacheTimestamp = now;
|
||
return result;
|
||
} finally {
|
||
mirrorTestingPromise = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行镜像测速
|
||
* 并行测试所有镜像,返回按延迟排序的可用镜像列表
|
||
*/
|
||
async function performMirrorTest (): Promise<string[]> {
|
||
// 开始镜像测速
|
||
|
||
const timeout = 3000; // 测速超时 3 秒
|
||
|
||
// 并行测试所有镜像
|
||
const mirrors = currentConfig.fileMirrors.filter(m => m);
|
||
const results = await Promise.all(
|
||
mirrors.map(m => testMirrorLatency(m, timeout))
|
||
);
|
||
|
||
// 过滤成功的镜像并按延迟排序
|
||
const successfulMirrors = results
|
||
.filter(r => r.success)
|
||
.sort((a, b) => a.latency - b.latency)
|
||
.map(r => r.mirror);
|
||
|
||
|
||
|
||
// 至少返回原始 URL
|
||
if (successfulMirrors.length === 0) {
|
||
return [''];
|
||
}
|
||
|
||
return successfulMirrors;
|
||
}
|
||
|
||
/**
|
||
* 清除镜像缓存,强制下次重新测速
|
||
*/
|
||
export function clearMirrorCache (): void {
|
||
cachedFastMirrors = null;
|
||
cacheTimestamp = 0;
|
||
|
||
}
|
||
|
||
/**
|
||
* 获取缓存状态
|
||
*/
|
||
export function getMirrorCacheStatus (): { cached: boolean; count: number; age: number; } {
|
||
return {
|
||
cached: cachedFastMirrors !== null,
|
||
count: cachedFastMirrors?.length ?? 0,
|
||
age: cachedFastMirrors ? Date.now() - cacheTimestamp : 0,
|
||
};
|
||
}
|
||
|
||
// ============== 配置管理 ==============
|
||
|
||
/**
|
||
* 获取当前镜像配置
|
||
*/
|
||
export function getMirrorConfig (): MirrorConfig {
|
||
return { ...currentConfig };
|
||
}
|
||
|
||
/**
|
||
* 更新镜像配置
|
||
*/
|
||
export function setMirrorConfig (config: Partial<MirrorConfig>): void {
|
||
currentConfig = { ...currentConfig, ...config };
|
||
}
|
||
|
||
/**
|
||
* 重置为默认配置
|
||
*/
|
||
export function resetMirrorConfig (): void {
|
||
currentConfig = { ...defaultConfig };
|
||
}
|
||
|
||
/**
|
||
* 设置自定义镜像(优先级最高)
|
||
*/
|
||
export function setCustomMirror (mirror: string): void {
|
||
currentConfig.customMirror = mirror;
|
||
}
|
||
|
||
// ============== URL 工具函数 ==============
|
||
|
||
/**
|
||
* 构建镜像 URL
|
||
* @param originalUrl 原始 URL
|
||
* @param mirror 镜像前缀
|
||
*/
|
||
export function buildMirrorUrl (originalUrl: string, mirror: string): string {
|
||
if (!mirror) return originalUrl;
|
||
// 如果镜像已经包含完整域名,直接拼接
|
||
if (mirror.endsWith('/')) {
|
||
return mirror + originalUrl;
|
||
}
|
||
return mirror + '/' + originalUrl;
|
||
}
|
||
|
||
/**
|
||
* 测试 URL 是否可用(HTTP GET)
|
||
* @param url 要测试的 URL
|
||
* @param timeout 超时时间
|
||
*/
|
||
export async function testUrl (url: string, timeout: number = 5000): Promise<boolean> {
|
||
try {
|
||
await PromiseTimer(RequestUtil.HttpGetText(url), timeout);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 测试 URL 是否可用(HTTP HEAD,更快)
|
||
* 验证:状态码、Content-Type、Content-Length
|
||
*/
|
||
export async function testUrlHead (url: string, timeout: number = 5000): Promise<boolean> {
|
||
return new Promise<boolean>((resolve) => {
|
||
const urlObj = new URL(url);
|
||
const isHttps = urlObj.protocol === 'https:';
|
||
const client = isHttps ? https : http;
|
||
|
||
const req = client.request({
|
||
hostname: urlObj.hostname,
|
||
port: urlObj.port || (isHttps ? 443 : 80),
|
||
path: urlObj.pathname + urlObj.search,
|
||
method: 'HEAD',
|
||
timeout,
|
||
headers: {
|
||
'User-Agent': 'NapCat-Mirror-Test',
|
||
},
|
||
}, (res) => {
|
||
const statusCode = res.statusCode || 0;
|
||
const contentType = (res.headers['content-type'] as string) || '';
|
||
|
||
// 简化验证条件:
|
||
// 1. 状态码 2xx 或 3xx
|
||
// 2. Content-Type 不应该是 text/html(表示错误页面)
|
||
const isValidStatus = statusCode >= 200 && statusCode < 400;
|
||
const isNotHtmlError = !contentType.includes('text/html');
|
||
|
||
resolve(isValidStatus && isNotHtmlError);
|
||
});
|
||
|
||
req.on('error', () => resolve(false));
|
||
req.on('timeout', () => {
|
||
req.destroy();
|
||
resolve(false);
|
||
});
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 详细验证 URL 响应
|
||
* 返回验证结果和详细信息
|
||
*/
|
||
export interface UrlValidationResult {
|
||
valid: boolean;
|
||
statusCode?: number;
|
||
contentType?: string;
|
||
contentLength?: number;
|
||
error?: string;
|
||
}
|
||
|
||
export async function validateUrl (url: string, timeout: number = 5000): Promise<UrlValidationResult> {
|
||
return new Promise<UrlValidationResult>((resolve) => {
|
||
const urlObj = new URL(url);
|
||
const isHttps = urlObj.protocol === 'https:';
|
||
const client = isHttps ? https : http;
|
||
|
||
const req = client.request({
|
||
hostname: urlObj.hostname,
|
||
port: urlObj.port || (isHttps ? 443 : 80),
|
||
path: urlObj.pathname + urlObj.search,
|
||
method: 'HEAD',
|
||
timeout,
|
||
headers: {
|
||
'User-Agent': 'NapCat-Mirror-Test',
|
||
},
|
||
}, (res) => {
|
||
const statusCode = res.statusCode || 0;
|
||
const contentType = (res.headers['content-type'] as string) || '';
|
||
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
|
||
|
||
// 简化验证条件
|
||
const isValidStatus = statusCode >= 200 && statusCode < 400;
|
||
const isNotHtmlError = !contentType.includes('text/html');
|
||
|
||
if (!isValidStatus) {
|
||
resolve({
|
||
valid: false,
|
||
statusCode,
|
||
contentType,
|
||
contentLength,
|
||
error: `HTTP ${statusCode}`,
|
||
});
|
||
} else if (!isNotHtmlError) {
|
||
resolve({
|
||
valid: false,
|
||
statusCode,
|
||
contentType,
|
||
contentLength,
|
||
error: '返回了 HTML 页面而非文件',
|
||
});
|
||
} else {
|
||
resolve({
|
||
valid: true,
|
||
statusCode,
|
||
contentType,
|
||
contentLength,
|
||
});
|
||
}
|
||
});
|
||
|
||
req.on('error', (e: Error) => resolve({
|
||
valid: false,
|
||
error: e.message,
|
||
}));
|
||
req.on('timeout', () => {
|
||
req.destroy();
|
||
resolve({
|
||
valid: false,
|
||
error: 'Timeout',
|
||
});
|
||
});
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// ============== 查找可用 URL ==============
|
||
|
||
/**
|
||
* 查找可用的下载 URL
|
||
* 使用懒加载的快速镜像列表
|
||
* @param originalUrl 原始 GitHub URL
|
||
* @param options 选项
|
||
*/
|
||
export async function findAvailableDownloadUrl (
|
||
originalUrl: string,
|
||
options: {
|
||
mirrors?: string[];
|
||
timeout?: number;
|
||
customMirror?: string;
|
||
testMethod?: 'head' | 'get';
|
||
/** 是否使用详细验证(验证 Content-Type 和 Content-Length) */
|
||
validateContent?: boolean;
|
||
/** 期望的最小文件大小(字节),用于验证 */
|
||
minFileSize?: number;
|
||
/** 是否使用懒加载的快速镜像列表 */
|
||
useFastMirrors?: boolean;
|
||
} = {}
|
||
): Promise<string> {
|
||
const {
|
||
timeout = currentConfig.timeout,
|
||
customMirror = currentConfig.customMirror,
|
||
testMethod = 'head',
|
||
validateContent = true, // 默认启用内容验证
|
||
minFileSize,
|
||
useFastMirrors = true, // 默认使用快速镜像列表
|
||
} = options;
|
||
|
||
// 获取镜像列表
|
||
let mirrors = options.mirrors;
|
||
if (!mirrors) {
|
||
// 检查是否是 nightly.link URL
|
||
if (originalUrl.includes('nightly.link')) {
|
||
// 使用 nightly.link 镜像列表(保持完整的 URL 格式)
|
||
mirrors = currentConfig.nightlyLinkMirrors;
|
||
} else if (useFastMirrors) {
|
||
// 使用懒加载的快速镜像列表
|
||
mirrors = await getFastMirrors();
|
||
} else {
|
||
mirrors = currentConfig.fileMirrors;
|
||
}
|
||
}
|
||
|
||
// 使用增强验证或简单测试
|
||
const testWithValidation = async (url: string): Promise<boolean> => {
|
||
if (validateContent) {
|
||
const result = await validateUrl(url, timeout);
|
||
// 额外检查文件大小(仅当指定了 minFileSize 时)
|
||
if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) {
|
||
return false;
|
||
}
|
||
return result.valid;
|
||
}
|
||
// 不验证内容,只检查状态码
|
||
const isValid = testMethod === 'head' ? await testUrlHead(url, timeout) : await testUrl(url, timeout);
|
||
return isValid;
|
||
};
|
||
|
||
// 1. 如果设置了自定义镜像,直接使用(不测试,信任用户选择)
|
||
if (customMirror) {
|
||
const customUrl = buildMirrorUrl(originalUrl, customMirror);
|
||
return customUrl;
|
||
}
|
||
|
||
// 2. 先测试原始 URL
|
||
if (await testWithValidation(originalUrl)) {
|
||
return originalUrl;
|
||
}
|
||
|
||
// 3. 测试镜像源
|
||
let testedCount = 0;
|
||
for (const mirror of mirrors) {
|
||
if (!mirror) continue; // 跳过空字符串
|
||
|
||
// 特殊处理 nightly.link URL
|
||
let mirrorUrl: string;
|
||
if (originalUrl.includes('nightly.link')) {
|
||
// 替换 nightly.link 域名
|
||
mirrorUrl = originalUrl.replace('https://nightly.link', mirror.startsWith('http') ? mirror : `https://${mirror}`);
|
||
} else {
|
||
mirrorUrl = buildMirrorUrl(originalUrl, mirror);
|
||
}
|
||
|
||
testedCount++;
|
||
if (await testWithValidation(mirrorUrl)) {
|
||
return mirrorUrl;
|
||
}
|
||
}
|
||
|
||
throw new Error(`所有下载源都不可用(已测试 ${testedCount} 个镜像),请检查网络连接或配置自定义镜像`);
|
||
}
|
||
|
||
// ============== 版本和 Release 相关(减少 API 依赖) ==============
|
||
|
||
/**
|
||
* 语义化版本正则(简化版,用于排序)
|
||
*/
|
||
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-.]+))?(?:\+([0-9A-Za-z-.]+))?$/;
|
||
|
||
/**
|
||
* 解析语义化版本号
|
||
*/
|
||
function parseSemVerSimple (version: string): { major: number; minor: number; patch: number; prerelease: string; } | null {
|
||
const match = version.match(SEMVER_REGEX);
|
||
if (!match) return null;
|
||
return {
|
||
major: parseInt(match[1] ?? '0', 10),
|
||
minor: parseInt(match[2] ?? '0', 10),
|
||
patch: parseInt(match[3] ?? '0', 10),
|
||
prerelease: match[4] || '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 比较两个版本号
|
||
*/
|
||
function compareSemVerSimple (a: string, b: string): number {
|
||
const pa = parseSemVerSimple(a);
|
||
const pb = parseSemVerSimple(b);
|
||
if (!pa && !pb) return 0;
|
||
if (!pa) return -1;
|
||
if (!pb) return 1;
|
||
|
||
if (pa.major !== pb.major) return pa.major - pb.major;
|
||
if (pa.minor !== pb.minor) return pa.minor - pb.minor;
|
||
if (pa.patch !== pb.patch) return pa.patch - pb.patch;
|
||
|
||
// 预发布版本排在正式版本前面
|
||
if (pa.prerelease && !pb.prerelease) return -1;
|
||
if (!pa.prerelease && pb.prerelease) return 1;
|
||
|
||
return pa.prerelease.localeCompare(pb.prerelease);
|
||
}
|
||
|
||
/**
|
||
* 从 tags 列表中获取最新的 release tag
|
||
* 不依赖 GitHub API
|
||
*/
|
||
// Update definitions validation locally first if possible.
|
||
// I'll assume valid typescript.
|
||
// I will split this into two tool calls to avoid complexity.
|
||
// 1. Update mirror.ts
|
||
// 2. Update UpdateNapCat.ts
|
||
|
||
// This tool call: Update mirror.ts
|
||
export async function getLatestReleaseTag (owner: string, repo: string, mirror?: string): Promise<string> {
|
||
const result = await getAllGitHubTags(owner, repo, mirror);
|
||
|
||
// 过滤出符合 semver 的 tags
|
||
const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
|
||
|
||
if (releaseTags.length === 0) {
|
||
throw new Error('未找到有效的 release tag');
|
||
}
|
||
|
||
// 按版本号排序,取最新的
|
||
releaseTags.sort(compareSemVerSimple);
|
||
const latest = releaseTags[releaseTags.length - 1];
|
||
|
||
if (!latest) {
|
||
throw new Error('未找到有效的 release tag');
|
||
}
|
||
|
||
return latest;
|
||
}
|
||
|
||
/**
|
||
* 直接构建 GitHub release 下载 URL
|
||
* 不需要调用 API,直接基于 tag 和 asset 名称构建
|
||
*/
|
||
export function buildReleaseDownloadUrl (
|
||
owner: string,
|
||
repo: string,
|
||
tag: string,
|
||
assetName: string
|
||
): string {
|
||
return `https://github.com/${owner}/${repo}/releases/download/${tag}/${assetName}`;
|
||
}
|
||
|
||
/**
|
||
* 获取 GitHub release 信息(优先使用非 API 方法)
|
||
*
|
||
* 策略:
|
||
* 1. 先通过 git refs 获取 tags
|
||
* 2. 直接构建下载 URL,不依赖 API
|
||
* 3. 仅当需要 changelog 时才使用 API
|
||
*/
|
||
export async function getGitHubRelease (
|
||
owner: string,
|
||
repo: string,
|
||
tag: string = 'latest',
|
||
options: {
|
||
/** 需要获取的 asset 名称列表 */
|
||
assetNames?: string[];
|
||
/** 是否需要获取 changelog(需要调用 API) */
|
||
fetchChangelog?: boolean;
|
||
/** 指定镜像 */
|
||
mirror?: string;
|
||
} = {}
|
||
): Promise<{
|
||
tag_name: string;
|
||
assets: Array<{
|
||
name: string;
|
||
browser_download_url: string;
|
||
}>;
|
||
body?: string;
|
||
}> {
|
||
const { assetNames = [], fetchChangelog = false, mirror } = options;
|
||
|
||
// 1. 获取实际的 tag 名称
|
||
let actualTag: string;
|
||
if (tag === 'latest') {
|
||
actualTag = await getLatestReleaseTag(owner, repo, mirror);
|
||
} else {
|
||
actualTag = tag;
|
||
}
|
||
// ...
|
||
|
||
// 2. 构建 assets 列表(不需要 API)
|
||
const assets = assetNames.map(name => ({
|
||
name,
|
||
browser_download_url: buildReleaseDownloadUrl(owner, repo, actualTag, name),
|
||
}));
|
||
|
||
// 3. 如果不需要 changelog 且有 assetNames,直接返回
|
||
if (!fetchChangelog && assetNames.length > 0) {
|
||
return {
|
||
tag_name: actualTag,
|
||
assets,
|
||
body: undefined,
|
||
};
|
||
}
|
||
|
||
// 4. 需要更多信息时,尝试调用 API(作为备选)
|
||
const endpoint = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${actualTag}`;
|
||
|
||
for (const apiBase of currentConfig.apiMirrors) {
|
||
try {
|
||
const url = endpoint.replace('https://api.github.com', apiBase);
|
||
const response = await PromiseTimer(
|
||
RequestUtil.HttpGetJson<any>(url, 'GET', undefined, {
|
||
'User-Agent': 'NapCat',
|
||
'Accept': 'application/vnd.github.v3+json',
|
||
}),
|
||
currentConfig.timeout
|
||
);
|
||
return response;
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 5. API 全部失败,但如果有 assetNames,仍然返回构建的 URL
|
||
if (assetNames.length > 0) {
|
||
return {
|
||
tag_name: actualTag,
|
||
assets,
|
||
body: undefined,
|
||
};
|
||
}
|
||
|
||
throw new Error('无法获取 release 信息,所有 API 源都不可用');
|
||
}
|
||
|
||
// ============== Tags 缓存 ==============
|
||
|
||
interface TagsCache {
|
||
tags: string[];
|
||
mirror: string;
|
||
timestamp: number;
|
||
}
|
||
|
||
// 缓存 tags 结果(10 分钟有效,release 版本不会频繁变动)
|
||
const TAGS_CACHE_TTL = 10 * 60 * 1000;
|
||
const tagsCache: Map<string, TagsCache> = new Map();
|
||
|
||
/**
|
||
* 获取所有 GitHub tags(带缓存)
|
||
* 优化:并行请求多个镜像,使用第一个成功返回的结果
|
||
*/
|
||
export async function getAllGitHubTags (owner: string, repo: string, mirror?: string): Promise<{ tags: string[], mirror: string; }> {
|
||
const cacheKey = `${owner}/${repo}/${mirror || 'auto'}`;
|
||
|
||
// 检查缓存
|
||
const cached = tagsCache.get(cacheKey);
|
||
if (cached && (Date.now() - cached.timestamp) < TAGS_CACHE_TTL) {
|
||
return { tags: cached.tags, mirror: cached.mirror };
|
||
}
|
||
|
||
const baseUrl = `https://github.com/${owner}/${repo}.git/info/refs?service=git-upload-pack`;
|
||
|
||
// 解析 tags 的辅助函数
|
||
const parseTags = (raw: string): string[] => {
|
||
return raw
|
||
.split('\n')
|
||
.map((line: string) => {
|
||
const match = line.match(/refs\/tags\/(.+)$/);
|
||
return match ? match[1] : undefined;
|
||
})
|
||
.filter((tag): tag is string => tag !== undefined && !tag.endsWith('^{}'));
|
||
};
|
||
|
||
// 尝试从 URL 获取 tags
|
||
const fetchFromUrl = async (url: string, usedMirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
|
||
try {
|
||
const raw = await PromiseTimer(
|
||
RequestUtil.HttpGetText(url),
|
||
currentConfig.timeout
|
||
);
|
||
|
||
// 检查返回内容是否有效(不是 HTML 错误页面)
|
||
if (raw.includes('refs/tags')) {
|
||
return { tags: parseTags(raw), mirror: usedMirror };
|
||
}
|
||
} catch {
|
||
// 忽略错误
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 准备镜像列表
|
||
let mirrors: string[] = [];
|
||
if (mirror) {
|
||
// 如果指定了镜像,只使用该镜像
|
||
mirrors = [mirror];
|
||
} else {
|
||
// 否则使用 auto 逻辑,利用缓存的快速镜像列表
|
||
mirrors = await getFastMirrors();
|
||
}
|
||
|
||
// 并行请求
|
||
const promises = mirrors.map(m => {
|
||
const url = m ? buildMirrorUrl(baseUrl, m) : baseUrl;
|
||
return fetchFromUrl(url, m || 'https://github.com');
|
||
});
|
||
|
||
try {
|
||
const result = await Promise.any(promises.filter(p => p !== null) as Promise<{ tags: string[], mirror: string; } | null>[]);
|
||
if (result) {
|
||
tagsCache.set(cacheKey, {
|
||
tags: result.tags,
|
||
mirror: result.mirror,
|
||
timestamp: Date.now(),
|
||
});
|
||
return result;
|
||
}
|
||
} catch {
|
||
// all failed
|
||
}
|
||
|
||
if (mirror) {
|
||
throw new Error(`指定镜像 ${mirror} 获取 tags 失败`);
|
||
}
|
||
|
||
throw new Error('无法获取 tags,所有镜像源都不可用');
|
||
}
|
||
|
||
// ============== Action Artifacts 支持 ==============
|
||
|
||
// ActionArtifact 接口定义
|
||
export interface ActionArtifact {
|
||
id: number;
|
||
name: string;
|
||
size_in_bytes: number;
|
||
created_at: string;
|
||
expires_at: string;
|
||
archive_download_url: string;
|
||
workflow_run_id?: number;
|
||
head_sha?: string;
|
||
workflow_title?: string;
|
||
}
|
||
|
||
// ============== Action Artifacts 缓存 ==============
|
||
|
||
interface ArtifactsCache {
|
||
artifacts: ActionArtifact[];
|
||
mirror: string;
|
||
timestamp: number;
|
||
}
|
||
|
||
// 缓存 artifacts 结果(10 分钟有效)
|
||
const ARTIFACTS_CACHE_TTL = 10 * 60 * 1000;
|
||
const artifactsCache: Map<string, ArtifactsCache> = new Map();
|
||
|
||
/**
|
||
* 清除 artifacts 缓存
|
||
*/
|
||
export function clearArtifactsCache (): void {
|
||
artifactsCache.clear();
|
||
}
|
||
|
||
/**
|
||
* 通过解析 GitHub Actions HTML 页面获取 workflow runs(备用方案)
|
||
* 当 api.github.com 不可用时使用
|
||
* 页面格式: https://github.com/{owner}/{repo}/actions/workflows/{workflow}
|
||
*/
|
||
|
||
async function getWorkflowRunsFromHtml (
|
||
owner: string,
|
||
repo: string,
|
||
workflow: string = 'build.yml',
|
||
maxRuns: number = 10,
|
||
mirror?: string
|
||
): Promise<{ runs: Array<{ id: number; created_at: string; title: string; }>; mirror: string; }> {
|
||
const baseUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflow}`;
|
||
|
||
// 尝试使用镜像获取 HTML
|
||
// 如果指定了 mirror,则只使用该 mirror
|
||
let mirrors: string[] = [];
|
||
if (mirror) {
|
||
mirrors = [mirror];
|
||
} else {
|
||
// 使用缓存的快速镜像列表
|
||
mirrors = await getFastMirrors();
|
||
}
|
||
|
||
for (const mirrorItem of mirrors) {
|
||
try {
|
||
const allRuns: Array<{ id: number; created_at: string; title: string; }> = [];
|
||
const foundIds = new Set<number>();
|
||
let page = 1;
|
||
const maxPages = 10; // 防止无限请求,最多翻10页(约250个条目)
|
||
|
||
while (allRuns.length < maxRuns && page <= maxPages) {
|
||
const pageUrl = page > 1 ? `${baseUrl}?page=${page}` : baseUrl;
|
||
const url = mirrorItem ? buildMirrorUrl(pageUrl, mirrorItem) : pageUrl;
|
||
|
||
const html = await PromiseTimer(
|
||
RequestUtil.HttpGetText(url),
|
||
10000
|
||
);
|
||
|
||
// 使用 Block 分割策略,更稳健地关联 ID 和时间
|
||
const rows = html.split('<div class="Box-row');
|
||
let foundOnThisPage = 0;
|
||
|
||
for (const row of rows) {
|
||
// 提取 Run ID 和 Status
|
||
// <a href="/NapNeko/NapCatQQ/actions/runs/20799940346" ... aria-label="completed successfully: ...">
|
||
const runMatch = new RegExp(`href="/${owner}/${repo}/actions/runs/(\\d+)"[^>]*aria-label="([^"]*)"`, 'i').exec(row);
|
||
|
||
if (!runMatch || !runMatch[1] || !runMatch[2]) continue;
|
||
|
||
const id = parseInt(runMatch[1]);
|
||
const ariaLabel = runMatch[2];
|
||
const ariaLabelLower = ariaLabel.toLowerCase();
|
||
|
||
// 只需要判断 completed
|
||
if (ariaLabelLower.includes('completed')) {
|
||
if (!foundIds.has(id)) {
|
||
// 提取时间 (取 Block 内的第一个 relative-time)
|
||
const timeMatch = /<relative-time\s+datetime="([^"]+)"/.exec(row);
|
||
if (timeMatch && timeMatch[1]) {
|
||
foundIds.add(id);
|
||
foundOnThisPage++;
|
||
|
||
// 优先从 markdown-title class 提取标题
|
||
let title = '';
|
||
const titleMatch = /class="[^"]*markdown-title[^"]*"[^>]*>([\s\S]*?)<\/span>/i.exec(row);
|
||
if (titleMatch && titleMatch[1]) {
|
||
title = titleMatch[1].trim();
|
||
}
|
||
|
||
// 如果没找到,回退到 aria-label 逻辑
|
||
if (!title) {
|
||
title = ariaLabel;
|
||
const prefixMatch = /^(completed successfully:\s*)/i.exec(title);
|
||
if (prefixMatch) {
|
||
title = title.substring(prefixMatch[0].length);
|
||
}
|
||
}
|
||
|
||
allRuns.push({
|
||
id,
|
||
created_at: timeMatch[1],
|
||
title: title.trim()
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果本页没有找到任何 completed 的 run(但页面可能不为空),或者页面内容太少(可能是最后一页或错误)
|
||
// 这里简化判断: 如果本页没提取到任何有效数据,就认为没有更多数据了
|
||
if (foundOnThisPage === 0) {
|
||
// 也要考虑到可能是页面解析失败或者全是 failed 状态
|
||
// 检查是否有翻页按钮可能更复杂,暂时假设如果一整页都没有 successful run,可能后面也没有了,或者我们已经获取够多了
|
||
// 为了稳健,如果本页没找到,且 allRuns 还没满,尝试下一页 (除非页面很小说明是空页)
|
||
if (rows.length < 2) { // 只有 split 的第一个空元素
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 分页逻辑:总是尝试下一页,直到满足 maxRuns
|
||
page++;
|
||
}
|
||
|
||
if (allRuns.length > 0) {
|
||
return { runs: allRuns, mirror: mirrorItem || 'https://github.com' };
|
||
}
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return { runs: [], mirror: '' };
|
||
}
|
||
|
||
/**
|
||
* 通过 API 获取最新的 workflow runs,然后直接拼接 nightly.link 下载链接
|
||
* 无需解析 HTML,直接使用固定的 URL 格式
|
||
*
|
||
* 策略:
|
||
* 1. 优先使用 GitHub API
|
||
* 2. API 失败时,从 GitHub Actions HTML 页面解析
|
||
*/
|
||
async function getArtifactsFromNightlyLink (
|
||
owner: string,
|
||
repo: string,
|
||
workflow: string = 'build.yml',
|
||
_branch: string = 'main',
|
||
maxRuns: number = 10,
|
||
mirror?: string
|
||
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
|
||
// 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
|
||
try {
|
||
// 以前尝试使用 GitHub API,现在弃用,完全使用 HTML 解析逻辑
|
||
// 并获取 workflow // 直接从 HTML 页面解析
|
||
const { runs: workflowRuns, mirror: runsMirror } = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns, mirror);
|
||
|
||
if (workflowRuns.length === 0) {
|
||
return { artifacts: [], mirror: runsMirror };
|
||
}
|
||
|
||
// 直接拼接 nightly.link URL
|
||
// 格式: https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
||
const artifacts: ActionArtifact[] = [];
|
||
const artifactNames = ['NapCat.Framework', 'NapCat.Shell']; // 已知的 artifact 名称
|
||
|
||
// 如果 HTML 解析使用的 mirror 是 github.com(空),则 nightly.link 使用默认配置
|
||
// 如果使用了镜像,可能需要特殊的 nightly.link 镜像,或者这里仅记录 HTML 来源镜像
|
||
// 实际上 nightly.link 本身就是一个服务,我们使用配置中的 nightlyLinkMirrors
|
||
const baseNightlyMirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
|
||
|
||
for (const run of workflowRuns) {
|
||
for (const artifactName of artifactNames) {
|
||
artifacts.push({
|
||
id: run.id,
|
||
name: artifactName,
|
||
size_in_bytes: 0,
|
||
created_at: run.created_at,
|
||
expires_at: new Date(new Date(run.created_at).getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||
archive_download_url: `${baseNightlyMirror}/${owner}/${repo}/actions/runs/${run.id}/${artifactName}.zip`,
|
||
workflow_run_id: run.id,
|
||
workflow_title: run.title,
|
||
});
|
||
}
|
||
}
|
||
return { artifacts, mirror: runsMirror };
|
||
|
||
} catch {
|
||
return { artifacts: [], mirror: '' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取 GitHub Action 最新运行的 artifacts
|
||
* 用于下载 nightly/dev 版本
|
||
*
|
||
* 策略:
|
||
* 1. 检查缓存(10分钟有效)
|
||
* 2. 优先尝试从 nightly.link 获取(无需认证,更稳定)
|
||
* 3. 这里的实现已经完全移除了对 GitHub API 的依赖,直接解析 HTML
|
||
*/
|
||
export async function getLatestActionArtifacts (
|
||
owner: string,
|
||
repo: string,
|
||
workflow: string = 'build.yml',
|
||
branch: string = 'main',
|
||
maxRuns: number = 10,
|
||
mirror?: string
|
||
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
|
||
const cacheKey = `${owner}/${repo}/${workflow}/${branch}/${mirror || 'auto'}`;
|
||
|
||
// 检查缓存
|
||
const cached = artifactsCache.get(cacheKey);
|
||
if (cached && (Date.now() - cached.timestamp) < ARTIFACTS_CACHE_TTL) {
|
||
return { artifacts: cached.artifacts, mirror: cached.mirror };
|
||
}
|
||
|
||
let result: { artifacts: ActionArtifact[], mirror: string; } = { artifacts: [], mirror: '' };
|
||
|
||
// 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
|
||
try {
|
||
result = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns, mirror);
|
||
} catch {
|
||
// 获取失败
|
||
}
|
||
|
||
// 缓存结果(即使为空也缓存,避免频繁请求)
|
||
if (result.artifacts.length > 0) {
|
||
artifactsCache.set(cacheKey, {
|
||
artifacts: result.artifacts,
|
||
mirror: result.mirror,
|
||
timestamp: Date.now(),
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}
|