// ==UserScript==
// @name github-release-assets-recommend
// @namespace https://github.com/tjx666/user-scripts
// @version 0.5.3
// @description Highlights compatible assets in GitHub release pages based on your platform (auto language detection)
// @author yutengjing
// @match https://github.com/*/releases/tag/*
// @match https://github.com/*/releases/latest
// @grant none
// @homepageURL https://github.com/tjx666/user-scripts
// @supportURL https://github.com/tjx666/user-scripts/issues
// ==/UserScript==
(function () {
'use strict';
// ==================== 配置常量 ====================
/**
* 调试开关,设为 true 启用日志输出
*/
const DEBUG = true;
/**
* 语言检测 - 支持中文和英文
*/
const isZhCN =
navigator.language.startsWith('zh') ||
document.documentElement.lang.startsWith('zh') ||
document.querySelector('html[lang*="zh"]') !== null;
/**
* 多语言文本配置
*/
const LABELS = isZhCN
? {
recommended: '推荐',
compatible: '兼容',
tooltips: {
recommended: '完美匹配您的设备',
compatible: '与您的设备兼容,但可能不是最优选择',
},
}
: {
recommended: 'Recommended',
compatible: 'Compatible',
tooltips: {
recommended: 'Perfect match for your device',
compatible: 'Compatible with your device',
},
};
/**
* 优先级分数配置
*/
const PRIORITY = {
PREFERRED_FORMAT: 250, // OS + 架构 + 首选格式完全匹配
PERFECT_MATCH: 200, // OS + 架构完全匹配
OS_MATCH: 100, // 仅OS匹配
ARCH_MATCH: 50, // 仅架构匹配
NO_MATCH: 0, // 不匹配
AUXILIARY_FILE: -1000, // 辅助文件(不显示)
};
/**
* 文件扩展名匹配配置
*/
const EXTENSIONS = {
macos: ['.dmg', '.pkg', '.zip'],
windows: ['.exe', '.msi', '.zip'],
linux: ['.AppImage', '.deb', '.rpm', '.tar.gz', '.snap', '.flatpak', '.zip'],
};
/**
* 各平台首选格式配置
*/
const PREFERRED_EXTENSIONS = {
macos: ['.dmg', '.pkg'],
windows: ['.exe', '.msi'],
linux: ['.AppImage', '.deb', '.rpm'],
};
/**
* 架构匹配关键词配置
*/
const ARCH_KEYWORDS = {
arm64: ['arm64', 'aarch64', 'apple', 'm1', 'm2', 'm3'],
arm32: ['arm32', 'armv7', 'armhf'],
x64: ['x64', 'x86_64', 'amd64', 'intel'],
x86: ['x86', 'i386', '386'],
};
/**
* 平台标识符配置 - 用于识别文件的目标平台
*/
const PLATFORM_IDENTIFIERS = {
macos: ['apple', 'darwin', 'macos', 'osx', 'mac'],
windows: ['windows', 'win32', 'win64', 'msvc', 'mingw'],
linux: ['linux', 'gnu', 'musl', 'ubuntu', 'debian', 'fedora', 'centos'],
};
/**
* 辅助文件扩展名(不会显示标签)
*/
const AUXILIARY_EXTENSIONS = ['.blockmap', '.sig', '.sha256', '.asc', '.yml', '.yaml'];
/**
* 超时配置
*/
const TIMEOUTS = {
ELEMENT_WAIT: 15000, // 等待元素出现
ASSETS_LOAD: 10000, // 等待资源加载
RETRY_DELAY: 1000, // 重试延迟
};
/**
* 样式配置
*/
const STYLES = {
RECOMMENDED: {
backgroundColor: '#238636',
color: 'white',
text: LABELS.recommended,
},
COMPATIBLE: {
backgroundColor: '#0969da',
color: 'white',
text: LABELS.compatible,
},
INFO_BOX: {
backgroundColor: '#f6f8fa',
borderColor: '#d0d7de',
platform: '#0969da',
},
};
// ==================== 全局变量 ====================
/**
* 平台检测结果缓存
*/
let platformCache = null;
// ==================== 工具函数 ====================
/**
* 日志输出封装函数
*/
function log(...args) {
if (DEBUG) {
console.log('[GitHub Smart Release]', ...args);
}
}
/**
* 获取 WebGL 渲染器信息
* @returns {string} 渲染器名称或错误信息
*/
function getWebGLRenderer() {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
if (!gl) return 'unavailable';
// 尝试获取调试信息扩展
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (debugInfo) {
return gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || 'unavailable';
}
// 回退到基础渲染器信息
return gl.getParameter(gl.RENDERER) || 'unavailable';
} catch (e) {
return 'error';
}
}
/**
* 检测操作系统类型
* @param {string} userAgent - 浏览器用户代理字符串
* @returns {string} 操作系统类型
*/
function detectOS(userAgent) {
const ua = userAgent.toLowerCase();
if (ua.includes('mac')) return 'macos';
if (ua.includes('windows') || ua.includes('win')) return 'windows';
if (ua.includes('linux')) return 'linux';
return 'unknown';
}
/**
* 从用户代理检测架构
* @param {string} userAgent - 浏览器用户代理字符串
* @returns {Object} 检测结果
*/
function detectArchFromUserAgent(userAgent) {
const ua = userAgent.toLowerCase();
if (ua.includes('arm64') || ua.includes('aarch64') || ua.includes('arm')) {
return { arch: 'arm64', method: 'userAgent', confidence: 'high' };
}
return { arch: 'x64', method: 'unknown', confidence: 'low' };
}
/**
* 使用 navigator.userAgentData 检测架构
* @returns {Promise<Object>} 检测结果
*/
async function detectArchFromUserAgentData() {
if (!('userAgentData' in navigator) || !navigator.userAgentData.getHighEntropyValues) {
return { arch: null, method: 'userAgentData', confidence: 'unavailable' };
}
try {
const uaData = await navigator.userAgentData.getHighEntropyValues(['architecture']);
if (uaData.architecture === 'arm') {
return { arch: 'arm64', method: 'userAgentData', confidence: 'high' };
} else if (uaData.architecture === 'x86') {
return { arch: 'x64', method: 'userAgentData', confidence: 'high' };
}
} catch (e) {
log('userAgentData detection failed:', e);
}
return { arch: null, method: 'userAgentData', confidence: 'failed' };
}
/**
* 使用 WebGL 渲染器检测 Apple Silicon
* @returns {Object} 检测结果
*/
function detectAppleSiliconFromWebGL() {
const renderer = getWebGLRenderer();
// Chrome: "Apple M1", "Apple M2", "Apple M3"
// Safari: "Apple GPU" (不够准确)
if (
renderer &&
(renderer.includes('Apple M') ||
renderer.includes('M1') ||
renderer.includes('M2') ||
renderer.includes('M3'))
) {
return { arch: 'arm64', method: 'webgl_renderer', confidence: 'high', renderer };
}
return { arch: null, method: 'webgl_renderer', confidence: 'low', renderer };
}
/**
* 检测 macOS 设备的架构
* @param {string} userAgent - 浏览器用户代理字符串
* @returns {Promise<Object>} 架构检测结果
*/
async function detectMacOSArch(userAgent) {
let arch = 'x64';
let detectionResults = { method: 'unknown', confidence: 'low' };
// 方法1: 用户代理检测
const uaResult = detectArchFromUserAgent(userAgent);
if (uaResult.confidence === 'high') {
return { arch: uaResult.arch, detectionResults: uaResult };
}
// 方法2: userAgentData API (Chrome专有,最准确)
const uadResult = await detectArchFromUserAgentData();
if (uadResult.arch && uadResult.confidence === 'high') {
return { arch: uadResult.arch, detectionResults: uadResult };
}
// 方法3: WebGL渲染器检测
const webglResult = detectAppleSiliconFromWebGL();
if (webglResult.arch && webglResult.confidence === 'high') {
return { arch: webglResult.arch, detectionResults: webglResult };
}
// 方法4: WebGL扩展检测 (ARM Mac可能缺少某些扩展)
let missingS3TC = false;
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
if (gl) {
const extensions = gl.getSupportedExtensions() || [];
missingS3TC = extensions.indexOf('WEBGL_compressed_texture_s3tc_srgb') === -1;
}
} catch (e) {
// Ignore
}
// 方法5: CPU核心数检测 (苹果芯片通常有更多核心)
const highCoreCount = navigator.hardwareConcurrency && navigator.hardwareConcurrency >= 8;
// 统计推断:2024年后大部分新Mac都是Apple Silicon
arch = 'arm64';
detectionResults = {
method: 'mac_default_arm64',
confidence: 'medium',
reason: 'Default to ARM64 for modern Macs',
webglRenderer: webglResult.renderer,
missingS3TC,
highCoreCount,
hardwareConcurrency: navigator.hardwareConcurrency,
userAgentDataAvailable: 'userAgentData' in navigator,
};
log('macOS architecture detection:', { finalArch: arch, ...detectionResults });
return { arch, detectionResults };
}
/**
* 检测用户平台和架构(带缓存)
* @returns {Promise<Object>} 平台信息
*/
async function detectPlatform() {
// 如果缓存存在,直接返回
if (platformCache) {
log('Using cached platform detection result');
return platformCache;
}
const userAgent = navigator.userAgent;
const os = detectOS(userAgent);
let arch = 'x64';
let detectionResults = null;
if (os === 'macos') {
const macResult = await detectMacOSArch(userAgent);
arch = macResult.arch;
detectionResults = macResult.detectionResults;
} else {
// 对于非 macOS,使用基础检测
const result = detectArchFromUserAgent(userAgent);
arch = result.arch;
}
log('Platform detection details:', {
userAgent,
detected: { os, arch },
webGLRenderer: getWebGLRenderer(),
});
// 缓存结果
platformCache = {
os,
arch,
...(detectionResults && { detectionResults }),
};
return platformCache;
}
/**
* 获取文件匹配优先级
* @param {string} filename - 文件名
* @param {Object} userPlatform - 用户平台信息
* @param {string} userPlatform.os - 操作系统
* @param {string} userPlatform.arch - 架构
* @returns {number} 优先级分数
*/
function getFilePriority(filename, userPlatform) {
const name = filename.toLowerCase();
const { os, arch } = userPlatform;
// 过滤辅助文件,返回负分不显示标签
if (AUXILIARY_EXTENSIONS.some((ext) => name.endsWith(ext))) {
return PRIORITY.AUXILIARY_FILE;
}
// 检测文件的目标平台
let filePlatform = null;
for (const [platform, keywords] of Object.entries(PLATFORM_IDENTIFIERS)) {
if (keywords.some((keyword) => name.includes(keyword))) {
filePlatform = platform;
break;
}
}
// 如果文件明确属于其他平台,直接排除
if (filePlatform && filePlatform !== os) {
log(`File ${filename} is for ${filePlatform}, excluding from ${os}`);
return -Infinity;
}
let osMatch = false;
let archMatch = false;
// 如果文件有明确的平台标识且匹配当前平台,认为是OS匹配
if (filePlatform && filePlatform === os) {
osMatch = true;
} else if (!filePlatform && EXTENSIONS[os] && EXTENSIONS[os].some((ext) => name.endsWith(ext))) {
// 只有在没有平台标识时,才检查扩展名
osMatch = true;
}
// 检查架构匹配(支持所有架构)
let detectedArch = null;
for (const [archType, keywords] of Object.entries(ARCH_KEYWORDS)) {
if (keywords.some((keyword) => name.includes(keyword))) {
detectedArch = archType;
archMatch = arch === archType;
break;
}
}
// 检查是否为首选格式
const isPreferredFormat = PREFERRED_EXTENSIONS[os] &&
PREFERRED_EXTENSIONS[os].some((ext) => name.endsWith(ext));
// 优先级计算
if (osMatch && archMatch) {
if (isPreferredFormat) {
// 完全匹配 + 首选格式:最高优先级
return PRIORITY.PREFERRED_FORMAT;
} else {
// 完全匹配但非首选格式:推荐
return PRIORITY.PERFECT_MATCH;
}
} else if (osMatch && !archMatch) {
// 操作系统匹配但架构不匹配:对于苹果芯片不显示标签
if (os === 'macos' && arch === 'arm64') {
// 苹果芯片遇到 x64 文件,不显示标签
return PRIORITY.NO_MATCH;
} else {
// 其他情况显示兼容
return PRIORITY.OS_MATCH;
}
} else if (archMatch && !osMatch) {
// 同架构的其他格式可以标记为兼容
return PRIORITY.ARCH_MATCH;
} else {
// 不匹配
return PRIORITY.NO_MATCH;
}
}
/**
* 添加样式
*/
function addStyles() {
const style = document.createElement('style');
style.textContent = `
.smart-release-recommended {
background-color: ${STYLES.RECOMMENDED.backgroundColor} !important;
color: ${STYLES.RECOMMENDED.color} !important;
border-radius: 6px !important;
padding: 2px 6px !important;
font-weight: 600 !important;
margin-left: 8px !important;
transition: all 0.2s ease !important;
}
.smart-release-recommended:hover {
opacity: 0.8 !important;
}
.smart-release-compatible {
background-color: ${STYLES.COMPATIBLE.backgroundColor} !important;
color: ${STYLES.COMPATIBLE.color} !important;
border-radius: 6px !important;
padding: 2px 6px !important;
margin-left: 8px !important;
transition: all 0.2s ease !important;
}
.smart-release-compatible:hover {
opacity: 0.8 !important;
}
.smart-release-info {
background-color: ${STYLES.INFO_BOX.backgroundColor} !important;
border: 1px solid ${STYLES.INFO_BOX.borderColor} !important;
border-radius: 6px !important;
padding: 8px 12px !important;
margin: 16px 0 !important;
font-size: 14px !important;
}
.smart-release-platform {
font-weight: 600 !important;
color: ${STYLES.INFO_BOX.platform} !important;
}
/* 暗色主题支持 */
@media (prefers-color-scheme: dark) {
.smart-release-info {
background-color: #21262d !important;
border-color: #30363d !important;
color: #e6edf3 !important;
}
.smart-release-platform {
color: #58a6ff !important;
}
}
`;
document.head.appendChild(style);
}
/**
* 等待元素出现
* @param {string} selector - CSS选择器
* @param {number} timeout - 超时时间(毫秒)
* @returns {Promise<Element|null>} 找到的元素或null
*/
function waitForElement(selector, timeout = TIMEOUTS.ELEMENT_WAIT) {
return new Promise((resolve) => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}
/**
* 等待 assets 列表完全加载
* @param {number} timeout - 超时时间(毫秒)
* @returns {Promise<boolean>} 是否加载成功
*/
function waitForAssetsLoaded(timeout = TIMEOUTS.ASSETS_LOAD) {
return new Promise((resolve) => {
let attempts = 0;
const maxAttempts = Math.floor(timeout / 1000); // 每秒检查一次
function checkAssets() {
attempts++;
// 尝试多种选择器策略
const selectors = [
'a[href*="/releases/download/"]',
'ul[data-view-component="true"] a[href*="/releases/download/"]',
'details[open] a[href*="/releases/download/"]',
'.Box ul a[href*="/releases/download/"]',
];
let assetLinks = [];
let usedSelector = '';
for (const selector of selectors) {
assetLinks = Array.from(document.querySelectorAll(selector));
if (assetLinks.length > 0) {
usedSelector = selector;
break;
}
}
log(`Observer check attempt ${attempts}`);
log(`Found ${assetLinks.length} asset links using selector: ${usedSelector}`);
// 调试信息:列出找到的文件名
if (assetLinks.length > 0) {
const filenames = assetLinks.map((link) => {
const textContent = link.textContent.trim();
const href = link.getAttribute('href');
return textContent || href.split('/').pop();
});
log('Asset filenames:', filenames);
}
// 调试信息:检查页面状态
const detailsElement = document.querySelector('details');
const assetsContainer = document.querySelector('ul[data-view-component="true"]');
const boxContainer = document.querySelector('.Box--condensed');
log('Page elements status:', {
detailsOpen: detailsElement ? detailsElement.hasAttribute('open') : 'not found',
assetsContainer: assetsContainer ? 'found' : 'not found',
boxContainer: boxContainer ? 'found' : 'not found',
});
if (assetLinks.length > 0) {
log('Assets loaded successfully');
return true;
}
if (attempts >= maxAttempts) {
log('Max attempts reached');
return false;
}
return null; // 继续等待
}
// 立即检查一次
const result = checkAssets();
if (result === true) {
resolve(true);
return;
} else if (result === false) {
resolve(false);
return;
}
log('Assets not found, setting up periodic check...');
const intervalId = setInterval(() => {
const result = checkAssets();
if (result === true) {
clearInterval(intervalId);
resolve(true);
} else if (result === false) {
clearInterval(intervalId);
resolve(false);
}
}, 1000);
});
}
/**
* 处理 release 页面
* @throws {Error} 如果处理过程中发生错误
*/
async function processReleasePage() {
try {
const userPlatform = await detectPlatform();
log('Detected platform:', userPlatform);
// 等待 assets 区域加载 - 更新选择器以匹配实际页面结构
log('Looking for assets container...');
const assetsContainer = await waitForElement(
'ul[data-view-component="true"], details-toggle details, [data-testid*="asset"]',
);
if (!assetsContainer) {
log('Assets container not found');
return;
}
// 查找并展开 details 元素(如果存在)
const detailsElement = document.querySelector('details');
if (detailsElement && !detailsElement.hasAttribute('open')) {
const summary = detailsElement.querySelector('summary');
if (summary) {
log('Expanding assets list...');
summary.click();
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
// 等待 assets 完全加载(处理异步加载)
log('Waiting for assets to load...');
const assetsLoaded = await waitForAssetsLoaded();
if (!assetsLoaded) {
log('Assets failed to load within timeout');
return;
}
// 获取所有资源链接
const assetLinks = Array.from(
document.querySelectorAll('a[href*="/releases/download/"]'),
);
if (assetLinks.length === 0) {
log('No asset links found after loading');
return;
}
log(`Found ${assetLinks.length} assets`);
// 计算每个文件的匹配度
const scoredAssets = assetLinks.map((link) => {
const filename = link.textContent.trim();
const priority = getFilePriority(filename, userPlatform);
return { link, filename, priority };
});
// 按优先级排序
scoredAssets.sort((a, b) => b.priority - a.priority);
// 添加标签
const bestMatch = scoredAssets.find(
(asset) => asset.priority >= PRIORITY.PERFECT_MATCH,
);
const compatibleAssets = scoredAssets.filter(
(asset) => asset.priority > 0 && asset.priority < PRIORITY.PERFECT_MATCH,
);
log(
'Asset scores:',
scoredAssets.map((a) => ({ filename: a.filename, priority: a.priority })),
);
// 标记推荐文件 (完全匹配:OS + 架构)
if (bestMatch) {
const recommendedTag = document.createElement('span');
recommendedTag.textContent = STYLES.RECOMMENDED.text;
recommendedTag.className = 'smart-release-recommended';
recommendedTag.title = `${LABELS.tooltips.recommended} (${userPlatform.os} ${userPlatform.arch})`;
bestMatch.link.parentNode.appendChild(recommendedTag);
}
// 标记兼容文件 (部分匹配或同架构其他格式)
compatibleAssets.slice(0, 3).forEach((asset) => {
const compatibleTag = document.createElement('span');
compatibleTag.textContent = STYLES.COMPATIBLE.text;
compatibleTag.className = 'smart-release-compatible';
compatibleTag.title = LABELS.tooltips.compatible;
asset.link.parentNode.appendChild(compatibleTag);
});
} catch (error) {
log('Error processing release page:', error);
// 在页面上显示错误信息(仅调试模式)
if (DEBUG) {
const errorInfo = document.createElement('div');
errorInfo.className = 'smart-release-info';
errorInfo.style.backgroundColor = '#fee';
errorInfo.style.borderColor = '#fcc';
errorInfo.innerHTML = `
<div><strong>⚠️ Smart Release 处理失败:</strong></div>
<div><small>${error.message}</small></div>
`;
const target =
document.querySelector('h1[data-view-component="true"]') ||
document.querySelector('main > div:first-child');
if (target) {
target.parentNode.insertBefore(errorInfo, target.nextSibling);
}
}
throw error; // 重新抛出错误供上层处理
}
}
// 防止重复处理的标记
let isProcessing = false;
let hasProcessed = false;
/**
* 带重试机制的页面处理函数
* @param {number} maxRetries - 最大重试次数,默认为2次
* @throws {Error} 如果所有重试都失败
*/
async function processWithRetry(maxRetries = 2) {
if (isProcessing) {
log('Already processing, skipping...');
return;
}
if (hasProcessed && window.location.href === window.lastProcessedUrl) {
log('Already processed this page, skipping...');
return;
}
isProcessing = true;
try {
for (let i = 0; i < maxRetries; i++) {
try {
await processReleasePage();
hasProcessed = true;
window.lastProcessedUrl = window.location.href;
log('Processing completed successfully');
return; // 成功则退出
} catch (error) {
log(`Attempt ${i + 1} failed:`, error);
if (i < maxRetries - 1) {
// 等待一段时间后重试
await new Promise((resolve) => setTimeout(resolve, TIMEOUTS.RETRY_DELAY));
}
}
}
log('All attempts failed to process release page');
} finally {
isProcessing = false;
}
}
/**
* 检查当前页面是否为GitHub release页面
* @returns {boolean} 如果是release页面返回true,否则返回false
*/
function isReleasePage() {
const isReleaseUrl = /github\.com\/.*\/releases(\/tag\/|\/latest)/.test(
window.location.href,
);
const hasReleaseElements =
document.querySelector('h1[data-view-component="true"]') &&
document.querySelector('a[href*="/releases/download/"]');
log('URL check:', isReleaseUrl, 'Elements check:', hasReleaseElements);
return isReleaseUrl || hasReleaseElements;
}
/**
* 初始化脚本,添加样式并开始监听页面变化
* 在页面加载和URL变化时自动处理release页面
*/
function init() {
log('GitHub Smart Release Downloads - Initializing...');
log('Current URL:', window.location.href);
addStyles();
// 检查是否为release页面
if (!isReleasePage()) {
log('Not a release page, skipping...');
return;
}
if (document.readyState === 'loading') {
log('Document still loading, waiting for DOMContentLoaded...');
document.addEventListener('DOMContentLoaded', () => {
log('DOMContentLoaded fired, starting processing...');
processWithRetry();
});
} else {
log('Document ready, starting processing...');
processWithRetry();
}
// 处理 turbo 导航
document.addEventListener('turbo:load', () => {
log('Turbo load event, reprocessing...');
processWithRetry();
});
// 处理 GitHub 的软导航
document.addEventListener('pjax:end', () => {
log('PJAX end event, reprocessing...');
processWithRetry();
});
}
init();
})();