GitHub 仓库增强:仓库主页、星标页、探索/搜索/议题等列表页显示仓库大小、星标页作者头像与仓库备注、不活跃仓库警告
Ajankohdalta
// ==UserScript==
// @name GitHub Super Enhancer
// @namespace http://tampermonkey.net/
// @version 1.1.1
// @description GitHub 仓库增强:仓库主页、星标页、探索/搜索/议题等列表页显示仓库大小、星标页作者头像与仓库备注、不活跃仓库警告
// @author miscellaneouszx
// @match https://github.com/*
// @icon https://github.githubassets.com/favicons/favicon.png
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ================= 配置区 =================
// 未填 Token 时,GitHub API 每小时只有约 60 次请求额度,浏览仓库一多就会触发 403 限流,
// 标签就会突然不显示。强烈建议生成 Token 填到下面:
// 1. 打开 https://github.com/settings/tokens
// 2. 「Generate new token」→「Generate new token (classic)」
// 3. 读取公开仓库大小不需要勾选任何权限(scope),直接生成即可
// 4. 把生成的 ghp_... 粘贴到下面两个引号之间
// 填了 Token 后额度提升到每小时 5000 次,基本不会再触发限流。
const GITHUB_TOKEN = '';
const CACHE_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 仓库数据缓存 7 天
const INACTIVE_MONTHS = 6; // 超过多少个月没有 push 才提示
const STAR_SCAN_RETRY = [0, 500, 1500, 3500]; // 星标页异步加载时,补扫几次
const STAR_SIZE_CONCURRENCY = 4; // 星标页同时查询 GitHub API 的数量
// ==========================================
GM_addStyle(`
.custom-header-size {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 20px;
box-sizing: border-box;
padding: 0 7px;
margin-left: 6px;
border: 1px solid var(--borderColor-accent-emphasis, #0969da);
border-radius: 999px;
color: var(--fgColor-accent, #0969da);
background: transparent;
font-size: 12px;
font-weight: 500;
line-height: 18px;
vertical-align: middle;
white-space: nowrap;
}
.custom-header-size svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
/* 星标页仓库名后的大小标签:与主页标签保持同样的胶囊风格,但更紧凑 */
.custom-star-size {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
height: 20px;
box-sizing: border-box;
padding: 0 7px;
margin-left: 6px;
border: 1px solid var(--borderColor-accent-emphasis, #0969da);
border-radius: 999px;
color: var(--fgColor-accent, #0969da);
background: transparent;
font-size: 12px;
font-weight: 500;
line-height: 18px;
vertical-align: middle;
white-space: nowrap;
text-decoration: none;
}
.custom-star-size svg {
width: 13px;
height: 13px;
flex: 0 0 auto;
}
.star-size-loading {
opacity: 0.65;
}
.custom-inactive-warning {
box-sizing: border-box;
background: var(--bgColor-danger-muted, #ffebe9);
border: 1px solid var(--borderColor-danger-emphasis, #cf222e);
color: var(--fgColor-danger, #d1242f);
padding: 10px 12px;
border-radius: 6px;
margin: 10px 0;
font-weight: 600;
text-align: center;
transition: opacity 0.5s ease;
}
.star-enhancer-avatar {
display: inline-block;
width: 20px;
height: 20px;
min-width: 20px;
margin-right: 8px;
border-radius: 50%;
vertical-align: -5px;
object-fit: cover;
background: var(--bgColor-muted, #f6f8fa);
}
.star-enhancer-avatar-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
min-width: 20px;
margin-right: 8px;
border-radius: 50%;
vertical-align: -5px;
background: var(--bgColor-muted, #f6f8fa);
color: var(--fgColor-muted, #656d76);
font-size: 10px;
font-weight: 600;
}
.star-note-container {
margin-top: 8px;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
min-height: 22px;
}
.star-note-text {
color: var(--fgColor-accent, #0969da);
cursor: pointer;
border-bottom: 1px dashed currentColor;
display: inline-block;
padding: 2px 0;
max-width: min(600px, 75vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.star-note-input {
background: var(--bgColor-default, #fff);
border: 1px solid var(--borderColor-default, #d0d7de);
color: var(--fgColor-default, #1f2328);
padding: 3px 7px;
border-radius: 4px;
width: min(300px, 50vw);
box-sizing: border-box;
display: none;
}
.star-note-input:focus {
outline: 2px solid var(--focus-outlineColor, #0969da);
outline-offset: -1px;
}
`);
// ================= 状态与工具 =================
const state = {
observerTimer: null,
starScanTimer: null,
listScanTimer: null,
repoKey: '',
repoDataPromise: null,
repoHeaderKey: '',
starSizeQueueRunning: false
};
const repoDataInflight = new Map();
function getRepoKeyFromPath() {
const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)$/);
if (!match) return '';
const owner = decodeURIComponent(match[1]);
const repo = decodeURIComponent(match[2]);
if (repo === 'settings' || repo === 'issues' || repo === 'pulls' ||
repo === 'actions' || repo === 'security' || repo === 'pulse' ||
repo === 'graphs' || repo === 'network' || repo === 'commits') {
// 这些路径可能并不代表仓库主页,交给下方更严格的判断。
}
return `${owner}/${repo}`;
}
function isRepoPage() {
const path = window.location.pathname;
return /^\/[^/]+\/[^/]+$/.test(path) && !/^\/[^/]+\/[^/]+\/(?!$)/.test(path);
}
function isStarsPage() {
const path = window.location.pathname;
const search = window.location.search;
return path.includes('/stars') || /(?:^|[?&])tab=stars(?:&|$)/.test(search);
}
function createApiHeaders() {
const headers = {
'Accept': 'application/vnd.github+json'
};
if (GITHUB_TOKEN) {
headers.Authorization = `Bearer ${GITHUB_TOKEN}`;
}
return headers;
}
// ================= GitHub API:带缓存 + 请求去重 =================
async function fetchRepoData(owner, repo) {
const cacheKey = `repo_data_${owner}/${repo}`;
const cached = GM_getValue(cacheKey);
if (cached && cached.timestamp && (Date.now() - cached.timestamp < CACHE_EXPIRE_TIME)) {
return cached.data;
}
const inflightKey = `${owner}/${repo}`;
if (repoDataInflight.has(inflightKey)) {
return repoDataInflight.get(inflightKey);
}
const promise = (async () => {
try {
const res = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
{
method: 'GET',
headers: createApiHeaders(),
cache: 'default'
}
);
if (!res.ok) {
const remaining = res.headers.get('X-RateLimit-Remaining');
const limit = res.headers.get('X-RateLimit-Limit');
console.warn(
`[GitHub Super Enhancer] API ${res.status} ` +
`(剩余额度 ${remaining != null ? remaining : '?'}/${limit != null ? limit : '?'}): ${owner}/${repo}`
);
// 403 / 429 基本是限流;给出一条明确的解决办法。
if (res.status === 403 || res.status === 429) {
console.warn(
'[GitHub Super Enhancer] 已触发 GitHub API 限流(未配置 Token 时约 60 次/小时)。' +
'请在脚本顶部 GITHUB_TOKEN 填入 Personal Access Token,可提升到 5000 次/小时。'
);
}
// 退回过期缓存:即使限流,标签也仍显示上次成功获取的大小。
if (cached && cached.data) {
return cached.data;
}
return null;
}
const data = await res.json();
const compact = {
size: Number.isFinite(data.size) ? data.size : 0,
pushed_at: data.pushed_at || null
};
GM_setValue(cacheKey, {
timestamp: Date.now(),
data: compact
});
return compact;
} catch (e) {
console.error('[GitHub Super Enhancer] Fetch API Failed:', e);
// 网络异常时同样退回过期缓存,保证标签不消失。
if (cached && cached.data) {
return cached.data;
}
return null;
} finally {
repoDataInflight.delete(inflightKey);
}
})();
repoDataInflight.set(inflightKey, promise);
return promise;
}
function formatSize(kb) {
if (!Number.isFinite(kb) || kb < 0) return '--';
if (kb < 1024) return `${Math.round(kb)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(mb >= 100 ? 0 : 1)} MB`;
const gb = mb / 1024;
if (gb < 1024) return `${gb.toFixed(gb >= 100 ? 0 : 1)} GB`;
const tb = gb / 1024;
return `${tb.toFixed(tb >= 100 ? 0 : 1)} TB`;
}
// ================= 星标页:头像 + 备注 =================
function getRepoCard(link) {
return (
link.closest('[data-testid="list-view-item"]') ||
link.closest('article') ||
link.closest('.Box-row') ||
link.closest('[class*="Box-row"]') ||
link.closest('li') ||
link.parentElement?.parentElement ||
link.parentElement
);
}
function makeAvatar(owner) {
const avatar = document.createElement('img');
avatar.className = 'star-enhancer-avatar';
avatar.alt = `${owner} avatar`;
avatar.width = 20;
avatar.height = 20;
avatar.loading = 'lazy';
avatar.decoding = 'async';
avatar.referrerPolicy = 'no-referrer';
avatar.src = `https://github.com/${encodeURIComponent(owner)}.png?size=40`;
let failedOnce = false;
avatar.addEventListener('error', () => {
if (!failedOnce) {
failedOnce = true;
avatar.src = `https://avatars.githubusercontent.com/${encodeURIComponent(owner)}?size=40`;
return;
}
const fallback = document.createElement('span');
fallback.className = 'star-enhancer-avatar-fallback';
fallback.textContent = owner.slice(0, 1).toUpperCase();
fallback.title = owner;
avatar.replaceWith(fallback);
}, { once: false });
return avatar;
}
function ensureStarAvatar(link, owner) {
if (link.dataset.starAvatarReady === '1') return;
const oldAvatar = link.querySelector(':scope > .star-enhancer-avatar, :scope > .star-enhancer-avatar-fallback');
if (!oldAvatar) {
link.insertBefore(makeAvatar(owner), link.firstChild);
}
link.dataset.starAvatarReady = '1';
}
function ensureStarNote(link, repoFullName) {
const card = getRepoCard(link);
if (!card || card.querySelector('.star-note-container')) return;
const container = document.createElement('div');
container.className = 'star-note-container';
container.dataset.repo = repoFullName;
const savedNote = GM_getValue(`note_${repoFullName}`, '');
const noteDisplay = document.createElement('span');
noteDisplay.className = 'star-note-text';
noteDisplay.textContent = savedNote ? `📝 备注: ${savedNote}` : '备注';
noteDisplay.title = savedNote || '点击添加备注';
const input = document.createElement('input');
input.className = 'star-note-input';
input.type = 'text';
input.value = savedNote;
input.placeholder = '输入此仓库的用途...';
input.autocomplete = 'off';
const showInput = () => {
noteDisplay.style.display = 'none';
input.style.display = 'inline-block';
input.focus();
input.select();
};
const saveNote = () => {
const value = input.value.trim();
GM_setValue(`note_${repoFullName}`, value);
if (value) {
noteDisplay.textContent = `📝 备注: ${value}`;
noteDisplay.title = value;
} else {
noteDisplay.textContent = '✏️ 添加备注';
noteDisplay.title = '点击添加备注';
}
noteDisplay.style.display = 'inline-block';
input.style.display = 'none';
};
noteDisplay.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showInput();
});
input.addEventListener('blur', saveNote);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
input.value = savedNote;
input.blur();
}
});
container.append(noteDisplay, input);
// 尽量插到仓库标题所在的卡片下方,不再依赖旧版 .Box-row 结构。
const heading = link.closest('h3');
if (heading) {
heading.insertAdjacentElement('afterend', container);
} else {
card.appendChild(container);
}
link.dataset.starNoteReady = '1';
}
// 创建“仓库大小”标签。主页和星标页共用同一套图标。
function createSizeBadge(sizeKb, extraClass = '') {
const badge = document.createElement('span');
badge.className = `${extraClass || 'custom-header-size'}`.trim();
badge.title = 'GitHub API 返回的仓库大小';
badge.innerHTML = `
<svg aria-hidden="true" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1.75C4.4 1.75 1.75 3.02 1.75 4.75v6.5C1.75 12.98 4.4 14.25 8 14.25s6.25-1.27 6.25-3V4.75C14.25 3.02 11.6 1.75 8 1.75Zm4.75 9.5c0 .75-1.84 1.75-4.75 1.75s-4.75-1-4.75-1.75V9.55C4.36 10.2 6.04 10.5 8 10.5s3.64-.3 4.75-.95v1.7Zm0-3c0 .75-1.84 1.75-4.75 1.75s-4.75-1-4.75-1.75V6.55C4.36 7.2 6.04 7.5 8 7.5s3.64-.3 4.75-.95v1.7Zm0-3.25C12.75 5.75 10.91 6.75 8 6.75S3.25 5.75 3.25 5V4.75C3.25 4 5.09 3 8 3s4.75 1 4.75 1.75V5Z"></path>
</svg>
<span>${formatSize(sizeKb)}</span>
`;
return badge;
}
function ensureStarSizeBadge(link, repoFullName) {
if (!link || !repoFullName) return;
if (link.querySelector(':scope > .custom-star-size')) return;
if (link.parentElement?.querySelector(':scope > .custom-star-size')) return;
const badge = createSizeBadge(0, 'custom-star-size star-size-loading');
badge.textContent = '加载中…';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('fill', 'currentColor');
svg.innerHTML = '<path d="M8 1.75C4.4 1.75 1.75 3.02 1.75 4.75v6.5C1.75 12.98 4.4 14.25 8 14.25s6.25-1.27 6.25-3V4.75C14.25 3.02 11.6 1.75 8 1.75Zm4.75 9.5c0 .75-1.84 1.75-4.75 1.75s-4.75-1-4.75-1.75V9.55C4.36 10.2 6.04 10.5 8 10.5s3.64-.3 4.75-.95v1.7Zm0-3c0 .75-1.84 1.75-4.75 1.75s-4.75-1-4.75-1.75V6.55C4.36 7.2 6.04 7.5 8 7.5s3.64-.3 4.75-.95v1.7Zm0-3.25C12.75 5.75 10.91 6.75 8 6.75S3.25 5.75 3.25 5V4.75C3.25 4 5.09 3 8 3s4.75.75 4.75 1.75V5Z"></path>';
badge.textContent = '';
badge.append(svg, document.createTextNode('加载中…'));
badge.dataset.starSizeRepo = repoFullName;
badge.dataset.starSizeState = 'loading';
// 最关键:直接插入仓库链接后面,确保一定是“仓库名后”。
link.insertAdjacentElement('afterend', badge);
return badge;
}
async function loadStarSizeBadge(link, repoFullName) {
if (!link || !repoFullName) return;
let badge = link.parentElement?.querySelector(':scope > .custom-star-size[data-star-size-repo="' + CSS.escape(repoFullName) + '"]');
if (!badge) {
badge = ensureStarSizeBadge(link, repoFullName);
}
if (!badge) return;
if (badge.dataset.starSizeState === 'ready') return;
if (badge.dataset.starSizeState === 'loading' && badge.dataset.starSizeStarted === '1') return;
const [owner, repo] = repoFullName.split('/');
if (!owner || !repo) return;
badge.dataset.starSizeStarted = '1';
const data = await fetchRepoData(owner, repo);
// 页面可能已经发生 SPA 跳转,旧节点不再属于当前 DOM。
if (!badge.isConnected) return;
if (data && Number.isFinite(Number(data.size)) && Number(data.size) >= 0) {
badge.className = 'custom-star-size';
badge.title = `${repoFullName} · GitHub 仓库大小`;
badge.replaceChildren();
const fresh = createSizeBadge(Number(data.size), 'custom-star-size');
badge.replaceWith(fresh);
return;
}
// 请求失败时不长期占位;下次动态扫描可再次尝试。
badge.remove();
}
async function processStarSizeQueue(links) {
if (!Array.isArray(links) || !links.length) return;
if (state.starSizeQueueRunning) return;
state.starSizeQueueRunning = true;
try {
let index = 0;
async function worker() {
while (index < links.length) {
const link = links[index++];
if (!link?.isConnected) continue;
const href = link.getAttribute('href') || '';
const match = href.match(/^\/([^/]+)\/([^/?#]+)$/);
if (!match) continue;
const owner = decodeURIComponent(match[1]);
const repo = decodeURIComponent(match[2]);
await loadStarSizeBadge(link, `${owner}/${repo}`);
}
}
const workers = Array.from(
{ length: Math.min(STAR_SIZE_CONCURRENCY, links.length) },
() => worker()
);
await Promise.all(workers);
} finally {
state.starSizeQueueRunning = false;
}
}
function processStarPage() {
if (!isStarsPage()) return;
// 不再只筛选“未处理头像/备注”的链接。
// 因为星标页是动态列表,大小标签也必须独立判断。
const allLinks = Array.from(document.querySelectorAll('h3 a[href^="/"]'));
const repoLinks = [];
allLinks.forEach((link) => {
const href = link.getAttribute('href') || '';
const match = href.match(/^\/([^/]+)\/([^/?#]+)$/);
if (!match) return;
const owner = decodeURIComponent(match[1]);
const repo = decodeURIComponent(match[2]);
const repoFullName = `${owner}/${repo}`;
ensureStarAvatar(link, owner);
ensureStarNote(link, repoFullName);
// 只把还没有成功显示大小的仓库加入队列。
const alreadyReady = link.parentElement?.querySelector(':scope > .custom-star-size:not(.star-size-loading)');
if (!alreadyReady) {
repoLinks.push(link);
}
});
// 头像、备注立即处理;大小标签异步查询,不阻塞页面其它功能。
if (repoLinks.length) {
processStarSizeQueue(repoLinks);
}
}
function scheduleStarScan() {
if (!isStarsPage()) return;
if (state.starScanTimer) {
clearTimeout(state.starScanTimer);
}
state.starScanTimer = setTimeout(() => {
state.starScanTimer = null;
processStarPage();
// GitHub 星标列表可能是异步渲染,轻量补扫,解决“首次打开头像/备注有时不出现”。
STAR_SCAN_RETRY.slice(1).forEach((delay) => {
window.setTimeout(() => {
if (isStarsPage()) processStarPage();
}, delay);
});
}, 50);
}
// ================= 通用仓库列表页(探索 / 搜索 / 议题):大小标签 =================
function isRepoListPage() {
const path = window.location.pathname;
// 探索页
if (path === '/explore' || path.startsWith('/explore/')) return true;
// 搜索页(代码 / 仓库 / 议题 / 提交 / 用户等所有 type)
if (path === '/search' || path.startsWith('/search/')) return true;
// 全局议题 / 拉取请求列表页
if (path === '/issues' || path === '/pulls') return true;
return false;
}
// GitHub 保留的“非仓库”第一段路径:这些永远不是 owner/repo。
const RESERVED_OWNERS = new Set([
'topics', 'collections', 'marketplace', 'sponsors', 'orgs', 'events', 'features',
'apps', 'codespaces', 'pricing', 'about', 'contact', 'site', 'readme', 'login',
'signup', 'account', 'settings', 'search', 'explore', 'issues', 'pulls',
'notifications', 'new', 'watching', 'stars', 'followers', 'following',
'repositories', 'packages', 'projects', 'discussions'
]);
// 判断某个 /owner/repo 链接是否真的是“仓库名”链接。
// 探索页只显示短名(无斜杠),搜索/议题页显示 owner/repo(有斜杠),
// 因此结合多种信号判断,避免漏判或误判。
function isRepoNameLink(a) {
// 1) 文字显示为 “owner / repo”(含斜杠):搜索 / 议题 / 代码搜索页。
const text = a.textContent.trim();
if (text.includes('/')) return true;
// 2) 链接位于标题元素里:探索页仓库卡片把仓库名放在 h2 里。
if (a.closest('h1, h2, h3, h4, h5, h6')) return true;
// 3) GitHub 埋点标记 click_target 为 REPOSITORY。
const hydro = a.getAttribute('data-hydro-click') || '';
if (/REPOSITORY/i.test(hydro)) return true;
return false;
}
// 找出列表中所有“仓库名”链接(href 形如 /owner/repo)。
function findRepoNameLinks() {
const result = [];
const anchors = document.querySelectorAll('a[href^="/"]');
for (const a of anchors) {
const href = a.getAttribute('href') || '';
const match = href.match(/^\/([^/]+)\/([^/]+)$/);
if (!match) continue;
const owner = decodeURIComponent(match[1]);
const repo = decodeURIComponent(match[2]);
if (!owner || !repo) continue;
// 排除 /topics/xxx、/collections/xxx 等保留路径。
if (RESERVED_OWNERS.has(owner.toLowerCase())) continue;
if (!isRepoNameLink(a)) continue;
result.push(a);
}
return result;
}
function processRepoListPage() {
if (!isRepoListPage()) return;
const links = findRepoNameLinks();
const toProcess = [];
for (const link of links) {
// 只把还没有成功显示大小的链接加入队列。
const alreadyReady = link.parentElement?.querySelector(':scope > .custom-star-size:not(.star-size-loading)');
if (!alreadyReady) {
toProcess.push(link);
}
}
if (toProcess.length) {
processStarSizeQueue(toProcess);
}
}
function scheduleListPageScan() {
if (!isRepoListPage()) return;
if (state.listScanTimer) {
clearTimeout(state.listScanTimer);
}
state.listScanTimer = setTimeout(() => {
state.listScanTimer = null;
processRepoListPage();
// 搜索 / 议题结果经常是异步渲染,补扫几次。
[800, 2000, 4000].forEach((delay) => {
window.setTimeout(() => {
if (isRepoListPage()) processRepoListPage();
}, delay);
});
}, 120);
}
// ================= 仓库主页:不活跃警告 + 大小标签 =================
function getRepositoryHeader() {
// GitHub 2024 年改版后使用 data-testid="repo-header",
// 旧的 #repository-container-header 已逐步下线,这里保留两者兼容。
return (
document.querySelector('[data-testid="repo-header"]') ||
document.getElementById('repository-container-header') ||
document.querySelector('[data-testid="repository-header"]') ||
document.querySelector('header[class*="Header"]') ||
document.querySelector('[data-testid="breadcrumbs"]') ||
document.querySelector('main h1')?.closest('header') ||
document.querySelector('main h1')?.parentElement
);
}
// GitHub 页面结构经常调整,因此不再只依赖一个固定选择器。
function getRepositoryTitle(header) {
const repoKey = getRepoKeyFromPath();
const [owner, repo] = repoKey ? repoKey.split('/') : ['', ''];
// 1. 最优先:当前仓库链接所在的 h1。
if (owner && repo) {
const escapedOwner = CSS.escape(owner);
const escapedRepo = CSS.escape(repo);
const exactRepoLink =
document.querySelector(`h1 a[href="/${escapedOwner}/${escapedRepo}"]`) ||
document.querySelector(`h1 a[href^="/${escapedOwner}/${escapedRepo}"]`);
if (exactRepoLink) {
const h1 = exactRepoLink.closest('h1');
if (h1) return h1;
}
}
// 2. GitHub 常见结构(新布局 data-testid 与旧布局 id 都覆盖)。
const selectors = [
'#repository-container-header h1',
'[data-testid="repository-header"] h1',
'[data-testid="repo-header"] h1',
'[data-testid="repo-title"]',
'main h1'
];
for (const selector of selectors) {
const h1 = document.querySelector(selector);
if (h1 && h1.textContent.trim()) return h1;
}
// 3. 最后兜底:从所有 h1 中寻找最像仓库名的标题。
if (repo) {
const h1s = document.querySelectorAll('h1');
for (const h1 of h1s) {
if (h1.textContent.includes(repo)) return h1;
}
}
return null;
}
function findVisibilityLabel(header, title) {
// 依次扩大搜索范围:标题 → header → 新布局头部容器 → 旧容器 → 面包屑。
const scopes = [
title,
header,
document.querySelector('[data-testid="repo-header"]'),
document.getElementById('repository-container-header'),
document.querySelector('[data-testid="breadcrumbs"]')
];
// 1) 按 data-testid 精确定位。
// 新布局的可见性胶囊带 data-testid="repo-visibility-label",
// 不依赖界面语言(中文界面文字是“公共”,英文是“Public”)。
for (const scope of scopes) {
if (!scope) continue;
const byTestid = scope.querySelector(
'[data-testid="repo-visibility-label"], [data-testid="visibility-label"], [data-testid*="visibility"]'
);
if (byTestid) return byTestid;
}
// 1.5) 全文档按 testid 定位:新布局可能没有可用的 header 容器,
// data-testid 是权威标记,直接全文档查找最可靠。
const docByTestid = document.querySelector(
'[data-testid="repo-visibility-label"], [data-testid="visibility-label"]'
);
if (docByTestid) return docByTestid;
// 2) 兜底:按标签文字匹配(兼容英文与中文界面:
// Public / Private / Internal / 公共 / 公开 / 私有 / 私人 / 内部)。
const visibilityTest = /^(Public|Private|Internal|公共|公开|私有|私人|内部)( repository| template)?$/i;
for (const scope of scopes) {
if (!scope) continue;
const candidates = scope.querySelectorAll(
'.Label, [class*="Label--"], [class*="prc-Label"], span'
);
for (const el of candidates) {
if (el.classList.contains('custom-header-size')) continue;
const text = el.textContent.trim().replace(/\s+/g, ' ');
if (visibilityTest.test(text)) {
return el;
}
}
}
// 3) 最后:全文档范围内只匹配“标签样式”的元素(避免误匹配正文)。
const docCandidates = document.querySelectorAll('.Label, [class*="Label--"], [class*="prc-Label"]');
for (const el of docCandidates) {
if (el.classList.contains('custom-header-size')) continue;
const text = el.textContent.trim().replace(/\s+/g, ' ');
if (visibilityTest.test(text)) return el;
}
return null;
}
// 在仓库主页找到“仓库名”链接(头部面包屑里的 <a href="/owner/repo">),
// 与星标页逻辑完全一致:锚点稳定,找到链接后直接在其后面插入大小标签。
function findRepoTitleLink(owner, repo) {
if (!owner || !repo) return null;
const escapedOwner = CSS.escape(owner);
const escapedRepo = CSS.escape(repo);
const selectors = [
`h1 a[href="/${escapedOwner}/${escapedRepo}"]`,
// 新布局仓库名链接的权威标记(例如 data-testid="repo-name-link")。
`[data-testid="repo-name-link"][href="/${escapedOwner}/${escapedRepo}"], [data-testid="repo-name-link"]`,
`[data-testid="repo-title"] a[href="/${escapedOwner}/${escapedRepo}"]`,
'[data-testid="repo-name-breadcrumb"]',
`[data-testid="breadcrumbs"] a[href="/${escapedOwner}/${escapedRepo}"]`,
`[data-testid="repo-header"] a[href="/${escapedOwner}/${escapedRepo}"]`,
`#repository-container-header a[href="/${escapedOwner}/${escapedRepo}"]`
];
for (const selector of selectors) {
const el = document.querySelector(selector);
if (el) return el;
}
// 兜底:页面中第一个指向该仓库的链接,通常就是头部面包屑里的仓库名。
return document.querySelector(`a[href="/${escapedOwner}/${escapedRepo}"]`);
}
function applySizeBadge(repoData, repoKey) {
const size = Number(repoData?.size);
if (!Number.isFinite(size) || size < 0) return false;
const [owner, repo] = repoKey ? repoKey.split('/') : ['', ''];
if (!owner || !repo) return false;
const titleLink = findRepoTitleLink(owner, repo);
const header = getRepositoryHeader();
const title = getRepositoryTitle(header);
const badge = createSizeBadge(size, 'custom-header-size');
badge.title = `${repoKey} · GitHub 仓库大小`;
// 1) 首选:放到 Public / Private 可见性胶囊标签后面(与之前截图位置一致)。
const visibilityLabel = findVisibilityLabel(header, title);
// 调试日志:如果位置仍不对,把这条输出发我即可定位。
console.info(
'[GitHub Super Enhancer] 大小标签定位:',
visibilityLabel ? '找到可见性标签 → 插到其后面' : '未找到可见性标签 → 退回仓库名链接后',
{ repoKey, foundLabel: !!visibilityLabel, foundTitleLink: !!titleLink, foundHeader: !!header }
);
if (visibilityLabel) {
// 已经正确就位则跳过。
if (visibilityLabel.nextElementSibling?.classList.contains('custom-header-size')) {
return true;
}
// 旧版本可能把标签插到了仓库名链接后面(Public 标签前面),
// 这里把头部区域内残留的标签全部移除,再重新插到正确位置。
const region = header || document.querySelector('[data-testid="repo-header"]') || title || document.body;
region.querySelectorAll('.custom-header-size').forEach((el) => el.remove());
visibilityLabel.insertAdjacentElement('afterend', badge);
return badge.isConnected;
}
// 2) 其次:新布局没有可见性标签时,直接放在仓库名链接后面。
if (titleLink) {
const linkParent = titleLink.parentElement;
if (linkParent?.querySelector(':scope > .custom-header-size')) return true;
titleLink.insertAdjacentElement('afterend', badge);
if (badge.isConnected) return true;
}
// 3) 兜底:追加到 h1 最末尾。
if (title) {
if (title.querySelector(':scope > .custom-header-size')) return true;
title.appendChild(badge);
return badge.isConnected;
}
return false;
}
function applyInactiveWarning(repoData, repoKey) {
const header = getRepositoryHeader();
if (!header || !repoData?.pushed_at) return false;
// 关键修复:
// 不再用“DOM 中有没有 warning”判断。
// 警告移除以后,MutationObserver 仍会收到 mutation,
// 如果没有“已处理”标记,就会再次生成,形成无限循环。
const handledKey = header.dataset.customInactiveHandled || '';
if (handledKey === repoKey) return true;
header.dataset.customInactiveHandled = repoKey;
const oldWarning = header.parentElement?.querySelector('.custom-inactive-warning');
if (oldWarning) oldWarning.remove();
const lastPush = new Date(repoData.pushed_at);
if (Number.isNaN(lastPush.getTime())) return true;
const monthsDiff = (Date.now() - lastPush.getTime()) / (1000 * 60 * 60 * 24 * 30);
if (monthsDiff <= INACTIVE_MONTHS) {
return true;
}
const warning = document.createElement('div');
warning.className = 'custom-inactive-warning';
warning.dataset.repoKey = repoKey;
warning.innerHTML =
`⚠️ <b>不活跃警告:</b> 该仓库最后一次代码提交是在 ` +
`<b>${lastPush.toLocaleDateString()}</b>,距今已有 ` +
`<b>${Math.floor(monthsDiff)} 个月</b>未更新,可能已停止维护。`;
const anchor = header.parentElement || header;
if (header.nextSibling) {
anchor.insertBefore(warning, header.nextSibling);
} else {
anchor.appendChild(warning);
}
window.setTimeout(() => {
warning.style.opacity = '0';
window.setTimeout(() => warning.remove(), 500);
}, 5000);
return true;
}
async function processRepoPage() {
if (!isRepoPage()) {
state.repoKey = '';
state.repoDataPromise = null;
state.repoHeaderKey = '';
return;
}
const repoKey = getRepoKeyFromPath();
if (!repoKey) return;
const [owner, repo] = repoKey.split('/');
if (!owner || !repo) return;
// 同一仓库只发起一次 API 请求,不因 MutationObserver 反复触发。
if (state.repoKey !== repoKey) {
state.repoKey = repoKey;
state.repoDataPromise = fetchRepoData(owner, repo);
state.repoHeaderKey = '';
}
const repoData = await state.repoDataPromise;
if (!repoData) return;
// 大小标签只依赖“仓库名”链接,不依赖 header;
// 即使 header 尚未渲染出来,也要先尝试插入。
applySizeBadge(repoData, repoKey);
// 不活跃警告需要 header;如果标题尚未由 GitHub 渲染出来,
// 不锁死状态,让后面的 observer 再尝试。
const header = getRepositoryHeader();
if (header) {
applyInactiveWarning(repoData, repoKey);
}
// 记录当前已经成功命中的 header,后续同一页面只做轻量检查。
state.repoHeaderKey = repoKey;
}
function scheduleRepoEnhancement() {
if (!isRepoPage()) return;
if (state.observerTimer) {
clearTimeout(state.observerTimer);
}
state.observerTimer = setTimeout(() => {
state.observerTimer = null;
processRepoPage();
}, 80);
}
function runEnhancements() {
if (isStarsPage()) {
scheduleStarScan();
return;
}
if (isRepoPage()) {
scheduleRepoEnhancement();
return;
}
if (isRepoListPage()) {
scheduleListPageScan();
}
}
// ================= 事件 / SPA 导航 =================
function shouldRescanFromMutations(mutations) {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (isStarsPage()) {
if (
node.matches?.('h3, article, li, [data-testid="list-view-item"]') ||
node.querySelector?.('h3 a[href^="/"]')
) {
return 'stars';
}
}
if (isRepoPage()) {
if (
node.id === 'repository-container-header' ||
node.matches?.('header, h1, [data-testid="repo-header"], [data-testid="repository-header"], [data-testid="repo-title"], [data-testid="breadcrumbs"]') ||
node.querySelector?.('#repository-container-header, h1, [data-testid="repo-header"], [data-testid="repository-header"], [data-testid="repo-title"], [data-testid="breadcrumbs"]')
) {
return 'repo';
}
}
if (isRepoListPage()) {
if (
node.matches?.('a[href^="/"], article, li, [data-testid*="result"], [data-testid*="search"], [data-testid*="issue"], [data-testid*="repository"], [data-testid*="item"]') ||
node.querySelector?.('a[href^="/"]')
) {
return 'list';
}
}
}
}
return '';
}
const observer = new MutationObserver((mutations) => {
const type = shouldRescanFromMutations(mutations);
if (type === 'stars') {
scheduleStarScan();
} else if (type === 'repo') {
scheduleRepoEnhancement();
} else if (type === 'list') {
scheduleListPageScan();
}
});
function startObserver() {
if (!document.body) return;
observer.observe(document.body, {
childList: true,
subtree: true
});
}
document.addEventListener('turbo:render', () => {
state.repoKey = '';
state.repoDataPromise = null;
state.repoHeaderKey = '';
runEnhancements();
});
document.addEventListener('pjax:end', () => {
state.repoKey = '';
state.repoDataPromise = null;
state.repoHeaderKey = '';
runEnhancements();
});
window.addEventListener('pageshow', runEnhancements);
// 首次加载
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
startObserver();
runEnhancements();
}, { once: true });
} else {
startObserver();
runEnhancements();
}
})();