在 GitHub 页面添加悬浮按钮和快捷键,一键在新标签页中打开 ZRead.ai 或 DeepWiki 进行 AI 项目解析。
// ==UserScript==
// @name GitHub AI Reader Redirector (Tampermonkey Version)
// @namespace https://zread.ai/
// @version 1.0.0
// @description 在 GitHub 页面添加悬浮按钮和快捷键,一键在新标签页中打开 ZRead.ai 或 DeepWiki 进行 AI 项目解析。
// @author Andrew05060414
// @match *://*.github.com/*
// @grant none
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// Config targets
const TARGETS = {
zread: {
name: 'ZRead.ai',
urlTemplate: 'https://zread.ai/{owner}/{repo}'
},
deepwiki: {
name: 'DeepWiki',
urlTemplate: 'https://deepwiki.com/{owner}/{repo}'
}
};
// State variables
let repoInfo = null;
let isDarkMode = false;
// DOM Elements
let widgetWrapper = null;
let floatBtn = null;
let hoverMenu = null;
// Translation Dictionary
const userLang = navigator.language || navigator.userLanguage;
const isZh = userLang.startsWith('zh');
const t = {
title: isZh ? 'AI 项目解析' : 'AI Repo Reader',
newTab: isZh ? '新标签页' : 'New Tab',
zread: 'ZRead.ai',
deepwiki: 'DeepWiki',
dragTip: isZh ? '双击拖动按钮' : 'Double-click to drag'
};
// Initialize
function init() {
handleUrlChange();
// Monitor URL changes for GitHub client-side transitions
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
handleUrlChange();
}
}, 1000);
// Sync theme
detectTheme();
const themeObserver = new MutationObserver(detectTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-color-mode', 'class'] });
// Keyboard hotkeys
document.addEventListener('keydown', (e) => {
if (!repoInfo) return;
// Ignore keypress inside input/textarea fields
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
return;
}
// Alt + Z (ZRead.ai)
if (e.altKey && e.key.toLowerCase() === 'z') {
openTarget('zread');
e.preventDefault();
}
// Alt + D (DeepWiki)
if (e.altKey && e.key.toLowerCase() === 'd') {
openTarget('deepwiki');
e.preventDefault();
}
});
}
// Handle URL change
function handleUrlChange() {
repoInfo = parseGitHubUrl(location.href);
if (repoInfo) {
createWidget();
if (widgetWrapper) widgetWrapper.style.display = 'flex';
} else {
if (widgetWrapper) widgetWrapper.style.display = 'none';
}
}
// Check GitHub Theme
function detectTheme() {
const colorMode = document.documentElement.getAttribute('data-color-mode');
const hasDarkClass = document.documentElement.classList.contains('dark');
isDarkMode = (colorMode === 'dark' || hasDarkClass || (colorMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches));
if (widgetWrapper) {
if (isDarkMode) {
widgetWrapper.classList.remove('theme-light');
widgetWrapper.classList.add('theme-dark');
} else {
widgetWrapper.classList.remove('theme-dark');
widgetWrapper.classList.add('theme-light');
}
}
}
// Parse GitHub repo owner and name
function parseGitHubUrl(urlStr) {
try {
const url = new URL(urlStr);
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length >= 2) {
const owner = parts[0];
const repo = parts[1];
const ignoredPaths = [
'settings', 'marketplace', 'explore', 'notifications',
'trending', 'topics', 'sponsors', 'pricing', 'features',
'customer-stories', 'about', 'readme', 'search', 'pulls', 'issues'
];
if (ignoredPaths.includes(owner.toLowerCase())) {
return null;
}
return { owner, repo };
}
} catch (e) {
console.error(e);
}
return null;
}
// Create UI Widget
function createWidget() {
if (document.getElementById('gh-ai-reader-userscript-root')) return;
// Create wrapper container
widgetWrapper = document.createElement('div');
widgetWrapper.id = 'gh-ai-reader-userscript-root';
widgetWrapper.className = 'ai-widget-wrapper';
// Inject Custom CSS
const styleEl = document.createElement('style');
styleEl.textContent = `
.ai-widget-wrapper {
position: fixed;
right: 20px;
bottom: 45%; /* Centered vertically by default */
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
box-sizing: border-box;
}
/* Hover Menu */
.ai-hover-menu {
width: 170px;
border-radius: 12px;
padding: 5px;
display: flex;
flex-direction: column;
gap: 3px;
opacity: 0;
transform: translateY(10px) scale(0.95);
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
box-sizing: border-box;
border-width: 1px;
border-style: solid;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Hover Bridge to prevent menu closing when moving cursor */
.ai-hover-bridge {
position: absolute;
bottom: 35px;
height: 25px;
left: 0;
right: 0;
background: transparent;
pointer-events: auto;
display: none;
}
.ai-widget-wrapper:hover .ai-hover-menu {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.ai-widget-wrapper:hover .ai-hover-bridge {
display: block;
}
.menu-header {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 5px 8px 3px;
}
.menu-item {
display: flex;
align-items: center;
padding: 7px 8px;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
text-decoration: none;
box-sizing: border-box;
gap: 6px;
}
.menu-item .badge {
margin-left: auto;
font-size: 9px;
padding: 1px 4px;
border-radius: 4px;
font-weight: 500;
}
/* Floating trigger button */
.ai-float-btn {
width: 40px;
height: 40px;
border-radius: 50%;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border-style: solid;
border-width: 1px;
padding: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.ai-float-btn:hover {
transform: scale(1.08);
}
.ai-float-btn:active {
cursor: grabbing;
transform: scale(0.95);
}
.ai-float-btn svg {
width: 18px;
height: 18px;
}
/* Theme styling */
.theme-dark .ai-hover-menu {
background: rgba(13, 17, 23, 0.85);
border-color: rgba(240, 246, 252, 0.15);
}
.theme-dark .menu-header {
color: #8b949e;
}
.theme-dark .menu-item {
color: #f0f6fc;
}
.theme-dark .menu-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.theme-dark .menu-item .badge {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.theme-dark .ai-float-btn {
background: linear-gradient(135deg, #a855f7 0%, #3b82f6 100%);
border-color: rgba(240, 246, 252, 0.15);
color: #ffffff;
box-shadow: 0 4px 16px rgba(147, 51, 234, 0.3);
}
.theme-light .ai-hover-menu {
background: rgba(255, 255, 255, 0.88);
border-color: rgba(208, 215, 222, 0.5);
}
.theme-light .menu-header {
color: #57606a;
}
.theme-light .menu-item {
color: #24292f;
}
.theme-light .menu-item:hover {
background: rgba(0, 0, 0, 0.05);
}
.theme-light .menu-item .badge {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
}
.theme-light .ai-float-btn {
background: linear-gradient(135deg, #9333ea 0%, #2563eb 100%);
border-color: rgba(208, 215, 222, 0.5);
color: #ffffff;
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.25);
}
`;
// Load persisted coordinates
const savedBottom = localStorage.getItem('gh-ai-reader-btn-bottom');
if (savedBottom) {
widgetWrapper.style.bottom = savedBottom;
}
// Build DOM structure
widgetWrapper.innerHTML = `
<div class="ai-hover-menu">
<div class="ai-hover-bridge"></div>
<div class="menu-header">${t.title}</div>
<div class="menu-item" id="menu-zread">
<span>📖</span>
<span>${t.zread}</span>
<span class="badge">${t.newTab}</span>
</div>
<div class="menu-item" id="menu-deepwiki">
<span>🧠</span>
<span>${t.deepwiki}</span>
<span class="badge">${t.newTab}</span>
</div>
</div>
<button class="ai-float-btn" title="${t.dragTip}">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L14.85 9.15L22 12L14.85 14.85L12 22L9.15 14.85L2 12L9.15 9.15L12 2Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M19 4L20 6.5L22.5 7.5L20 8.5L19 11L18 8.5L15.5 7.5L18 6.5L19 4Z" fill="currentColor" opacity="0.65"/>
</svg>
</button>
`;
document.body.appendChild(styleEl);
document.body.appendChild(widgetWrapper);
// Dynamic theme setup
detectTheme();
// Map elements
floatBtn = widgetWrapper.querySelector('.ai-float-btn');
hoverMenu = widgetWrapper.querySelector('.ai-hover-menu');
// Click behavior
floatBtn.addEventListener('click', (e) => {
if (hasDragged) {
e.preventDefault();
return;
}
openTarget('zread'); // Default target is ZRead
});
// Hover Menu actions
widgetWrapper.querySelector('#menu-zread').addEventListener('click', () => openTarget('zread'));
widgetWrapper.querySelector('#menu-deepwiki').addEventListener('click', () => openTarget('deepwiki'));
// Draggable functionality
let isDragging = false;
let startY = 0;
let startBottom = 0;
let hasDragged = false;
const dragThreshold = 5;
floatBtn.addEventListener('mousedown', (e) => {
isDragging = true;
startY = e.clientY;
startBottom = parseInt(window.getComputedStyle(widgetWrapper).bottom);
widgetWrapper.style.transition = 'none';
hasDragged = false;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const diff = startY - e.clientY;
if (Math.abs(diff) > dragThreshold) {
hasDragged = true;
}
const newBottom = startBottom + diff;
const maxBottom = window.innerHeight - 100;
const minBottom = 20;
const computedBottom = `${Math.min(maxBottom, Math.max(minBottom, newBottom))}px`;
widgetWrapper.style.bottom = computedBottom;
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
widgetWrapper.style.transition = 'bottom 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)';
// Save position
localStorage.setItem('gh-ai-reader-btn-bottom', widgetWrapper.style.bottom);
}
});
}
// Action: Open reader URL in a new tab
function openTarget(key) {
if (!repoInfo) return;
const template = TARGETS[key]?.urlTemplate;
if (template) {
const url = template.replace('{owner}', repoInfo.owner).replace('{repo}', repoInfo.repo);
window.open(url, '_blank');
}
}
// Start
init();
})();