GitHub 仓库增强:仓库主页、星标页(含“已星标 / 创建的”过滤清单)、探索/搜索/议题等列表页显示仓库大小、星标页作者头像与仓库说明备注、不活跃仓库警告;优化动态加载与 API 请求
// ==UserScript==
// @name GitHub Super Enhancer
// @namespace http://tampermonkey.net/
// @version 1.3.5
// @description GitHub 仓库增强:仓库主页、星标页(含“已星标 / 创建的”过滤清单)、探索/搜索/议题等列表页显示仓库大小、星标页作者头像与仓库说明备注、不活跃仓库警告;优化动态加载与 API 请求
// @author miscellaneouszx
// @match https://github.com/*
// @icon https://github.githubassets.com/favicons/favicon.png
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect api.github.com
// @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 = 14 * 24 * 60 * 60 * 1000; // 仓库数据缓存 14 天(大小变化慢,加长可明显减少 API 调用)
const INACTIVE_MONTHS = 6; // 超过多少个月没有 push 才提示
const STAR_SCAN_RETRY = [0, 500, 1500, 3500]; // 星标页异步加载时,补扫几次
const STAR_SIZE_CONCURRENCY = 4; // 同时查询 GitHub API 的数量
const LAZY_LOAD_MARGIN = 1000; // 懒加载提前量(px):滚动到距视口 1000px 内才发起查询
const FAIL_COOLDOWN_MS = 5 * 60 * 1000; // 单个仓库查询失败后的冷却时间(避免反复重试)
const RATE_LIMIT_LOG_DELAY = 4000; // 打印 API 额度前的延迟(ms):等本页其它查询先消耗额度,打印实时剩余额度
// ==========================================
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: 30px;
height: 30px;
min-width: 30px;
margin-right: 8px;
border-radius: 50%;
vertical-align: -7px;
object-fit: cover;
background: var(--bgColor-muted, #f6f8fa);
}
.star-enhancer-avatar-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
min-width: 30px;
margin-right: 8px;
border-radius: 50%;
vertical-align: -7px;
background: var(--bgColor-muted, #f6f8fa);
color: var(--fgColor-muted, #656d76);
font-size: 15px;
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();
// 限流 / 失败冷却 / 懒加载队列
let rateLimitedUntil = 0; // 全局限流冷却截止时间(毫秒时间戳),期间不再发请求
let rateLimitRescanScheduled = false;
let lastRateLimit = null; // 最近一次 API 响应头记录的额度(/rate_limit 失败时兜底显示)
let quotaPrintTimer = null; // 浏览消耗额度后的刷新打印防抖定时器
let lastPrintedQuotaKey = ''; // 已打印额度的去重键(limit:remaining:reset),数值无变化不重复打印
const repoFailCooldown = new Map(); // repo → 查询失败后的冷却截止时间(毫秒时间戳)
const starSizePending = []; // 待查询的仓库链接队列
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;
}
// 从任意 api.github.com 响应头记录限流信息(X-RateLimit-* 每个 API 响应都会带)。
// 用于 /rate_limit 请求失败时兜底显示剩余额度。
function recordRateLimitFromHeaders(headers) {
if (!headers) return;
const limit = Number(headers.get('X-RateLimit-Limit'));
const remaining = Number(headers.get('X-RateLimit-Remaining'));
const reset = Number(headers.get('X-RateLimit-Reset'));
if (Number.isFinite(limit) && limit >= 0) {
lastRateLimit = {
limit,
remaining: Number.isFinite(remaining) && remaining >= 0 ? remaining : limit,
reset: Number.isFinite(reset) && reset > 0 ? reset : 0
};
// 每次真实 API 响应都触发防抖刷新:浏览未缓存仓库消耗额度后,控制台立即更新。
scheduleQuotaRefresh();
}
}
// GM_xmlhttpRequest 的 responseHeaders 是原始字符串("Name: value\r\n..."),
// 转成 recordRateLimitFromHeaders 需要的 { get(name) } 形式。
function recordRateLimitFromRawHeaders(headersText) {
if (!headersText) return;
recordRateLimitFromHeaders({
get: (name) => {
const m = headersText.match(new RegExp('(?:^|\\r?\\n)' + name + ': ([^\\r\\n]*)', 'i'));
return m ? m[1].trim() : null;
}
});
}
// 请求 /rate_limit 获取实时额度(该接口本身不消耗额度)。
// 优先用 GM_xmlhttpRequest(油猴特权请求):
// - 完全绕过浏览器 CORS 预检,带 Token(Authorization 头)时也稳定;
// - 不经过页面 HTTP 缓存层,配 cache: false 后每次都是实时值,
// 不会被 GitHub 响应头 Cache-Control: private, max-age=60 的缓存拖住。
// 普通 fetch 作为无 GM_xmlhttpRequest 的脚本管理器下的兜底。
function fetchRateLimit() {
const url = `https://api.github.com/rate_limit?_=${Date.now()}`;
const headers = createApiHeaders();
const xmlhttp = typeof GM_xmlhttpRequest === 'function'
? GM_xmlhttpRequest
: (typeof GM !== 'undefined' && GM.xmlhttpRequest ? GM.xmlhttpRequest.bind(GM) : null);
if (xmlhttp) {
return new Promise((resolve) => {
xmlhttp({
method: 'GET',
url,
headers,
cache: false,
timeout: 10000,
onload: (res) => {
recordRateLimitFromRawHeaders(res.responseHeaders);
if (res.status >= 200 && res.status < 300) {
try {
const data = JSON.parse(res.responseText);
const core = data && data.resources && data.resources.core;
if (core && core.limit != null) {
resolve({
used: core.used,
limit: core.limit,
remaining: core.remaining,
reset: core.reset
});
return;
}
} catch (e) { /* 解析失败走下方 resolve(null) */ }
}
resolve(null);
},
onerror: () => resolve(null),
ontimeout: () => resolve(null)
});
});
}
// 无 GM_xmlhttpRequest 的环境:退回普通 fetch,只带 CORS 安全请求头,避免预检。
return fetch(url, { method: 'GET', headers, cache: 'no-store' })
.then((res) => {
recordRateLimitFromHeaders(res.headers);
return res.json();
})
.then((data) => {
const core = data && data.resources && data.resources.core;
if (core && core.limit != null) {
return { used: core.used, limit: core.limit, remaining: core.remaining, reset: core.reset };
}
return null;
})
.catch(() => null);
}
// ================= 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'
}
);
// 记录本次响应头里的限流信息,供 /rate_limit 失败时兜底。
recordRateLimitFromHeaders(res.headers);
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:优先按 Retry-After / X-RateLimit-Reset 计算冷却时间,
// 期间全局暂停查询,冷却结束自动重扫恢复。
if (res.status === 403 || res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After'));
const reset = Number(res.headers.get('X-RateLimit-Reset'));
let waitMs = 60 * 1000;
if (retryAfter > 0) waitMs = retryAfter * 1000;
else if (reset > 0) waitMs = reset * 1000 - Date.now();
waitMs = Math.max(waitMs, 5 * 1000);
rateLimitedUntil = Date.now() + waitMs;
console.warn(
'[GitHub Super Enhancer] 已触发 GitHub API 限流(可能是次级限流,额度未用完也会触发),' +
`将在约 ${Math.ceil(waitMs / 1000)} 秒后自动恢复,期间暂停查询。`
);
scheduleRateLimitRescan(waitMs);
}
// 单仓库失败冷却:失败后 5 分钟内不再重复查询它。
repoFailCooldown.set(inflightKey, Date.now() + FAIL_COOLDOWN_MS);
// 退回过期缓存:即使限流,标签也仍显示上次成功获取的大小。
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
};
// 查询成功:清除该仓库的失败冷却。
repoFailCooldown.delete(inflightKey);
GM_setValue(cacheKey, {
timestamp: Date.now(),
data: compact
});
return compact;
} catch (e) {
console.error('[GitHub Super Enhancer] Fetch API Failed:', e);
// 网络异常时同样退回过期缓存,保证标签不消失。
repoFailCooldown.set(inflightKey, Date.now() + FAIL_COOLDOWN_MS);
if (cached && cached.data) {
return cached.data;
}
return null;
} finally {
repoDataInflight.delete(inflightKey);
}
})();
repoDataInflight.set(inflightKey, promise);
return promise;
}
// 限流冷却结束后,自动重新扫描当前页面,让“加载中”的标签继续完成。
function scheduleRateLimitRescan(waitMs) {
if (rateLimitRescanScheduled) return;
rateLimitRescanScheduled = true;
window.setTimeout(() => {
rateLimitRescanScheduled = false;
if (isStarsPage()) processStarPage();
if (isRepoListPage()) processRepoListPage();
if (isRepoPage()) scheduleRepoEnhancement();
}, waitMs + 1000);
}
// 统一打印额度信息;同时记录去重键,避免防抖刷新重复打印同一数值。
function printQuotaInfo(used, limit, remaining, reset, sourceNote) {
lastPrintedQuotaKey = `${limit}:${remaining}:${reset}`;
const resetAt = reset ? new Date(reset * 1000).toLocaleTimeString() : '?';
console.info(
`[GitHub Super Enhancer] API 额度:核心 ${used}/${limit}` +
`(剩余 ${remaining},约 ${resetAt} 重置)` +
(GITHUB_TOKEN ? '' : ';未配置 Token,额度较低(60 次/小时),建议配置') +
(sourceNote ? `(${sourceNote})` : '')
);
}
// 浏览过程中每消耗一次额度(任意 API 响应返回)都会触发:
// 防抖 1.2 秒后打印一次最新剩余额度;数值无变化则不重复打印(避免刷屏)。
function scheduleQuotaRefresh() {
if (quotaPrintTimer) clearTimeout(quotaPrintTimer);
quotaPrintTimer = window.setTimeout(() => {
quotaPrintTimer = null;
if (lastRateLimit && lastRateLimit.limit != null) {
const key = `${lastRateLimit.limit}:${lastRateLimit.remaining}:${lastRateLimit.reset}`;
if (key === lastPrintedQuotaKey) return;
printQuotaInfo(
lastRateLimit.limit - lastRateLimit.remaining,
lastRateLimit.limit,
lastRateLimit.remaining,
lastRateLimit.reset
);
}
}, 1200);
}
// 打印当前 API 额度(rate_limit 接口本身不消耗额度)。
// 说明:
// 1) 原实现每次刷新都显示满额度(5000):GitHub 的 /rate_limit 响应头带
// Cache-Control: private, max-age=60,浏览器/代理层会按 URL 缓存响应,
// 60 秒内重复请求可能命中缓存拿到旧值(表现为“剩余额度一直停在同一个数”)。
// 这里给 URL 追加时间戳参数,并改用 GM_xmlhttpRequest(cache: false),
// 彻底绕开所有缓存层,每次都是实时值。
// 2) 原实现页面加载后立即请求,此时本页其它仓库查询还没消耗额度,打印的剩余额度
// 看起来"没有即时更新"。改为延迟 RATE_LIMIT_LOG_DELAY 毫秒,等本页主要查询完成后,
// 打印的是包含本次页面浏览消耗的真实剩余额度。
// 3) 曾给请求额外加 Cache-Control / Pragma 请求头试图绕缓存——这两个头不在
// CORS 安全请求头白名单里(实测 api.github.com 的 Access-Control-Allow-Headers
// 只放行 Authorization、Content-Type 等,不含 cache-control/pragma),跨域请求
// 预检失败导致 TypeError: Failed to fetch。因此额度请求改用 GM_xmlhttpRequest
// 特权请求,完全不走 CORS 预检;普通 fetch 兜底时也只带安全请求头。
// 4) 即使 /rate_limit 仍偶发失败,也优先用本页其它 API 响应头里的 X-RateLimit-*
// 打印剩余额度,保证额度信息即时可见,而不是只报错。
// 5) 除页面加载 / SPA 跳转外,浏览“未缓存”的仓库会真实消耗额度:每次 API 响应
// 都会触发 scheduleQuotaRefresh 防抖刷新(1.2 秒),数值有变化即打印更新后的
// 剩余额度;浏览“已缓存”仓库不发起请求,既不消耗额度也不打印。
function logRateLimitStatus() {
window.setTimeout(async () => {
const quota = await fetchRateLimit();
if (quota) {
// 与最近一次“随浏览刷新”打印的数值相同则跳过,避免同一数值重复刷屏。
if (`${quota.limit}:${quota.remaining}:${quota.reset}` !== lastPrintedQuotaKey) {
printQuotaInfo(quota.used, quota.limit, quota.remaining, quota.reset);
}
return;
}
// /rate_limit 请求失败(网络波动等)时,退回最近一次 API 响应头里的额度,
// 保证剩余额度仍然即时可见,不再只报错。
if (lastRateLimit && lastRateLimit.limit != null) {
const key = `${lastRateLimit.limit}:${lastRateLimit.remaining}:${lastRateLimit.reset}`;
if (key === lastPrintedQuotaKey) return;
console.warn('[GitHub Super Enhancer] /rate_limit 请求失败,改用最近一次 API 响应头显示额度');
printQuotaInfo(
lastRateLimit.limit - lastRateLimit.remaining,
lastRateLimit.limit,
lastRateLimit.remaining,
lastRateLimit.reset,
'最近一次 API 响应头'
);
} else {
console.warn('[GitHub Super Enhancer] 获取 API 额度失败(不影响其它功能)');
}
}, RATE_LIMIT_LOG_DELAY);
}
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 = 30;
avatar.height = 30;
avatar.loading = 'lazy';
avatar.decoding = 'async';
avatar.referrerPolicy = 'no-referrer';
avatar.src = `https://github.com/${encodeURIComponent(owner)}.png?size=60`;
let failedOnce = false;
avatar.addEventListener('error', () => {
if (!failedOnce) {
failedOnce = true;
avatar.src = `https://avatars.githubusercontent.com/${encodeURIComponent(owner)}?size=60`;
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;
// 全局限流冷却中:先不查询,等冷却结束后由自动重扫继续。
if (Date.now() < rateLimitedUntil) return;
const [owner, repo] = repoFullName.split('/');
if (!owner || !repo) return;
// 该仓库刚查询失败过,冷却中:跳过(标签保留“加载中”,冷却后重扫继续)。
if (Date.now() < (repoFailCooldown.get(repoFullName) || 0)) 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();
}
// 把仓库链接加入查询队列,并尝试开始处理(队列可随时追加,不丢链接)。
function enqueueStarSizeLinks(links) {
if (!Array.isArray(links)) return;
for (const link of links) {
if (link?.isConnected) starSizePending.push(link);
}
drainStarSizeQueue();
}
async function drainStarSizeQueue() {
if (state.starSizeQueueRunning) return;
if (!starSizePending.length) return;
state.starSizeQueueRunning = true;
try {
const links = starSizePending.splice(0);
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;
// 处理期间可能又有新的入队,继续排空。
if (starSizePending.length) drainStarSizeQueue();
}
}
// 判断元素是否接近当前视口(含提前量,提前量内就开始加载)。
function isNearViewport(el) {
const rect = el.getBoundingClientRect();
const margin = LAZY_LOAD_MARGIN;
return rect.bottom >= -margin && rect.top <= window.innerHeight + margin;
}
// 懒加载:滚动到可视区附近才真正发起查询,节省 API 额度。
const sizeLazyObserver = new IntersectionObserver((entries) => {
const toEnqueue = [];
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const link = entry.target;
sizeLazyObserver.unobserve(link);
toEnqueue.push(link);
}
if (toEnqueue.length) enqueueStarSizeLinks(toEnqueue);
}, { rootMargin: `${LAZY_LOAD_MARGIN}px 0px ${LAZY_LOAD_MARGIN}px 0px` });
function processStarPage() {
if (!isStarsPage()) return;
// 不再只筛选“未处理头像/备注”的链接。
// 因为星标页是动态列表,大小标签也必须独立判断。
//
// 星标页包含“已星标(Starred)”与“创建的(Created)”两种清单:
// 经典布局里两者的仓库名都在 h3 中;新版布局(list-view)可能把仓库名放在
// div[data-testid="list-view-item-title-container"] 的 h4 里。
// 因此统一收集“仓库名链接”(href 形如 /owner/repo,下面再用正则过滤),并去重。
const candidates = document.querySelectorAll(
'h3 a[href^="/"], ' +
'#user-starred-repos a[href^="/"], ' +
'[data-testid="list-view-item"] a[href^="/"], ' +
'[data-testid="list-view-item-title-container"] a[href^="/"]'
);
const seen = new Set(candidates);
const allLinks = Array.from(candidates);
// 兜底:再补一轮通用“仓库名链接”探测(标题层级 / 文字含斜杠 / 埋点标记),
// 防止 GitHub 改版后“创建的”清单换成其它容器结构而漏掉。
for (const link of findRepoNameLinks()) {
if (seen.has(link)) continue;
seen.add(link);
allLinks.push(link);
}
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) return;
// 懒加载:只查询接近视口的仓库,其余等滚动到附近再查询(省 API 额度)。
if (isNearViewport(link)) {
repoLinks.push(link);
} else {
sizeLazyObserver.observe(link);
}
});
// 头像、备注立即处理;大小标签异步查询,不阻塞页面其它功能。
if (repoLinks.length) {
enqueueStarSizeLinks(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) continue;
// 懒加载:只查询接近视口的仓库,其余等滚动到附近再查询。
if (isNearViewport(link)) {
toProcess.push(link);
} else {
sizeLazyObserver.observe(link);
}
}
if (toProcess.length) {
enqueueStarSizeLinks(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, h4, article, li, [data-testid="list-view-item"], [data-testid="list-view-item-title-container"]') ||
node.querySelector?.('h3 a[href^="/"], h4 a[href^="/"], [data-testid="list-view-item-title-container"] a[href^="/"]') ||
(node.matches?.('a[href^="/"]') && /^\/([^/]+)\/([^/?#]+)$/.test(node.getAttribute('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();
// SPA 跳转后重新打印实时额度(rate_limit 不消耗额度,成本可忽略)。
logRateLimitStatus();
});
document.addEventListener('pjax:end', () => {
state.repoKey = '';
state.repoDataPromise = null;
state.repoHeaderKey = '';
runEnhancements();
logRateLimitStatus();
});
window.addEventListener('pageshow', runEnhancements);
// 首次加载
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
startObserver();
runEnhancements();
logRateLimitStatus();
}, { once: true });
} else {
startObserver();
runEnhancements();
logRateLimitStatus();
}
})();