// ==UserScript==
// @name GitHub Interview Question Generator
// @namespace http://tampermonkey.net/
// @version 0.21
// @description AI 활용 면접 질문 생성기
// @author chocolatestain
// @match https://github.com/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @connect generativelanguage.googleapis.com
// @connect api.github.com
// @license MIT
// @icon https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png
// ==/UserScript==
(function () {
'use strict';
const GEMINI_API_KEY_ID = 'GEMINI_API_KEY';
const GITHUB_TOKEN_ID = 'GITHUB_TOKEN';
const GITHUB_STYLES = `
@keyframes generateButtonPulse {
0% { transform: scale(1); }
50% { transform: scale(1.02); }
100% { transform: scale(1); }
}
@keyframes generateSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes generateModalFadeIn {
0% { opacity: 0; transform: translate(-50%, -60%); }
100% { opacity: 1; transform: translate(-50%, -50%); }
}
@keyframes generateOverlayFadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
.generate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 16px;
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
user-select: none;
border: 1px solid;
border-radius: 6px;
appearance: none;
text-decoration: none;
transition: all 0.2s cubic-bezier(0.3, 0, 0.5, 1);
position: relative;
overflow: hidden;
}
.generate-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s;
}
.generate-btn:hover::before {
left: 100%;
}
/* Light theme styles */
.generate-btn {
color: #24292f;
background-color: #f6f8fa;
border-color: rgba(27,31,36,0.15);
box-shadow: 0 1px 0 rgba(27,31,36,0.04), inset 0 1px 0 rgba(255,255,255,0.25);
}
.generate-btn:hover:not(:disabled) {
background-color: #f3f4f6;
border-color: rgba(27,31,36,0.15);
box-shadow: 0 1px 0 rgba(27,31,36,0.1), inset 0 1px 0 rgba(255,255,255,0.03);
transform: translateY(-1px);
}
.generate-btn:active:not(:disabled) {
background-color: #ebecf0;
border-color: rgba(27,31,36,0.15);
transform: translateY(0);
box-shadow: inset 0 1px 0 rgba(27,31,36,0.1);
}
/* Dark theme styles */
[data-color-mode="dark"] .generate-btn,
[data-theme="dark"] .generate-btn,
html[data-color-mode="dark"] .generate-btn {
color: #f0f6fc;
background-color: #21262d;
border-color: rgba(240,246,252,0.1);
box-shadow: 0 0 transparent, 0 0 transparent;
}
[data-color-mode="dark"] .generate-btn:hover:not(:disabled),
[data-theme="dark"] .generate-btn:hover:not(:disabled),
html[data-color-mode="dark"] .generate-btn:hover:not(:disabled) {
background-color: #30363d;
border-color: rgba(240,246,252,0.15);
transform: translateY(-1px);
}
[data-color-mode="dark"] .generate-btn:active:not(:disabled),
[data-theme="dark"] .generate-btn:active:not(:disabled),
html[data-color-mode="dark"] .generate-btn:active:not(:disabled) {
background-color: #282e33;
transform: translateY(0);
}
.generate-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
transform: none !important;
}
.generate-btn.loading {
animation: generateButtonPulse 2s infinite;
}
.generate-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: generateSpin 1s linear infinite;
}
.generate-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
/* 모달 */
.generate-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 999;
animation: generateOverlayFadeIn 0.3s ease-out;
}
.generate-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #222831 !important;
border: 1px solid #444950 !important;
border-radius: 12px;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.18);
max-width: min(95vw, 1600px);
width: 1200px;
max-height: 90vh;
overflow: hidden;
z-index: 1000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
animation: generateModalFadeIn 0.3s ease-out;
color: white !important;
}
.generate-modal-header {
padding: 24px 24px 0 24px;
border-bottom: 1px solid #444950 !important;
background: #222831 !important;
color: white !important;
}
.generate-modal-title {
margin: 0 0 24px 0;
font-size: 24px;
font-weight: 600;
color: white !important;
display: flex;
align-items: center;
gap: 12px;
}
.generate-modal-content {
padding: 24px;
overflow-y: auto;
max-height: calc(90vh - 120px);
background: #222831 !important;
color: white !important;
}
.generate-category {
margin-bottom: 32px;
padding: 20px;
background: #222831 !important;
border: 1px solid #444950 !important;
border-radius: 8px;
transition: all 0.2s ease;
color: white !important;
}
.generate-category:hover {
border-color: var(--color-border-default, #d0d7de);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.generate-category-title {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 600;
color: white !important;
display: flex;
align-items: center;
gap: 8px;
}
.generate-category-badge {
background: var(--color-accent-emphasis, #0969da);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.generate-questions-list {
margin: 0;
padding: 0;
list-style: none;
}
.generate-question-item {
margin-bottom: 12px;
padding: 12px 16px;
background: #393e46 !important;
border: 1px solid #444950 !important;
border-radius: 6px;
font-size: 14px;
line-height: 1.6;
color: white !important;
transition: all 0.2s ease;
position: relative;
}
.generate-close-btn {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #cccccc !important;
padding: 8px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
}
.generate-close-btn:hover {
background: #444950 !important;
color: white !important;
}
.generate-toast {
position: fixed;
bottom: 20px;
right: 20px;
background: #393e46 !important;
color: white !important;
padding: 12px 16px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
z-index: 1001;
animation: generateModalFadeIn 0.3s ease-out;
font-size: 14px;
font-weight: 500;
}
`;
const styleSheet = document.createElement("style");
styleSheet.textContent = GITHUB_STYLES;
document.head.appendChild(styleSheet);
async function getApiKey() {
let key = await GM_getValue(GEMINI_API_KEY_ID);
if (!key) {
// 최초 입력 시 안내 포함된 커스텀 프롬프트
showApiKeyModal('', async (enteredKey) => {
if (enteredKey && enteredKey.length >= 10) {
await GM_setValue(GEMINI_API_KEY_ID, enteredKey);
}
});
// 입력이 끝날 때까지 대기
return new Promise(resolve => {
const interval = setInterval(async () => {
const stored = await GM_getValue(GEMINI_API_KEY_ID);
if (stored) {
clearInterval(interval);
resolve(stored);
}
}, 300);
});
}
return key;
}
GM_registerMenuCommand('Gemini API 키 설정', async () => {
let oldKey = await GM_getValue(GEMINI_API_KEY_ID) || '';
showApiKeyModal(oldKey, async (enteredKey) => {
if (enteredKey && enteredKey.length >= 10) {
await GM_setValue(GEMINI_API_KEY_ID, enteredKey);
showToast('API 키가 저장되었습니다.');
}
});
});
function showApiKeyModal(currentValue, onSave) {
document.querySelectorAll('.generate-modal-overlay, .generate-modal').forEach(e => e.remove());
const overlay = document.createElement('div');
overlay.className = 'generate-modal-overlay';
const modal = document.createElement('div');
modal.className = 'generate-modal';
modal.style.maxWidth = '400px';
modal.style.paddingBottom = '24px';
const header = document.createElement('div');
header.className = 'generate-modal-header';
const title = document.createElement('h2');
title.className = 'generate-modal-title';
title.textContent = 'Gemini API 키 설정';
const closeButton = document.createElement('button');
closeButton.className = 'generate-close-btn';
closeButton.innerHTML = '×';
closeButton.onclick = () => { modal.remove(); overlay.remove(); };
header.appendChild(title);
header.appendChild(closeButton);
modal.appendChild(header);
const content = document.createElement('div');
content.className = 'generate-modal-content';
content.style.display = 'flex';
content.style.flexDirection = 'column';
content.style.gap = '16px';
const label = document.createElement('label');
label.textContent = 'Gemini API 키 입력';
label.style.marginBottom = '8px';
const input = document.createElement('input');
input.type = 'text';
input.value = currentValue;
input.style.width = '100%';
input.style.padding = '8px';
input.style.borderRadius = '6px';
input.style.border = '1px solid #d0d7de';
input.autofocus = true;
const link = document.createElement('a');
link.href = 'https://aistudio.google.com/app/apikey?hl=ko';
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.color = '#0969da';
link.style.fontSize = '14px';
link.style.marginTop = '8px';
link.textContent = '→ Gemini API 키 발급 방법 안내';
const saveBtn = document.createElement('button');
saveBtn.textContent = '저장';
saveBtn.className = 'generate-btn';
saveBtn.style.marginTop = '8px';
saveBtn.onclick = () => {
modal.remove(); overlay.remove();
if (typeof onSave === 'function') onSave(input.value.trim());
};
content.appendChild(label);
content.appendChild(input);
content.appendChild(link);
content.appendChild(saveBtn);
modal.appendChild(content);
document.body.appendChild(overlay);
document.body.appendChild(modal);
input.focus();
input.select();
}
// Toast notification
function showToast(message) {
const toast = document.createElement('div');
toast.className = 'generate-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// 파일 리스트 추출
async function fetchFilesInCurrentDirectory() {
const pathParts = window.location.pathname.split('/').filter(Boolean);
const owner = pathParts[0];
const repo = pathParts[1];
let path = '';
let branch = 'main';
// /tree 에서는 브랜치 및 디렉토리 추출
if (pathParts[2] === 'tree') {
branch = pathParts[3];
if (pathParts.length > 4) {
path = pathParts.slice(4).join('/');
}
}
const headers = token ? { 'Authorization': `token ${token}` } : {};
const url = path
? `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`
: `https://api.github.com/repos/${owner}/${repo}/contents`;
try {
const contentsResponse = await fetch(url, { headers });
if (!contentsResponse.ok) throw new Error('파일 목록을 가져올 수 없습니다.');
const contents = await contentsResponse.json();
// .file 만 추출
const files = (contents || []).filter(f => f.type === 'file' && !f.name.startsWith('.')).slice(0, 5);
// 파일 내용 일부 가져오기(최대 1000자)
const fileContents = await Promise.all(
files.map(async file => {
try {
const fileResponse = await fetch(file.url, { headers });
if (!fileResponse.ok) return null;
const fileData = await fileResponse.json();
return { name: file.name, content: atob(fileData.content).slice(0, 1000) };
} catch { return null; }
})
);
return {
owner, repo, branch, path,
files: fileContents.filter(Boolean)
};
} catch (e) {
alert('파일 조회 중 오류: ' + e.message);
throw e;
}
}
// 프롬프트 구성 (카테고리 자동 추출)
function buildPrompt(repoInfo) {
return `다음은 GitHub 레포지토리의 현재 디렉토리 정보입니다.
이 디렉토리에서 실제로 사용된 언어, 프레임워크, 라이브러리, 도구, 아키텍처, 개발 패턴 등을 파악하여,
각 카테고리별로 3~5개의 기술 면접 질문을 생성하세요.
- 실제로 코드, 설정 등에서 확인된 항목만 카테고리로 만드세요.
- 존재하지 않는 기술(예: 미사용 라이브러리, 언어 등)은 카테고리로 만들지 마세요.
- 각 질문은 실제 코드, 구현, 구조, 설정 등 구체적 근거를 기반으로 작성해야 하며,
- 각 카테고리별 질문 수는 3~5개로 제한하세요.
- 출력은 순수 JSON 배열만, 예시는 아래와 같습니다:
[
{"title": "카테고리1", "questions": ["질문1", ...]},
{"title": "카테고리2", "questions": ["질문1", ...]},
...
]
디렉토리 정보:
${JSON.stringify(repoInfo, null, 2)}
`;
}
// Gemini 호출 및 응답 파싱
async function makeApiRequest(apiKey, prompt) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
timeout: 30000,
onload: function(response) {
try {
console.log('[Gemini] HTTP status:', response.status);
console.log('[Gemini] Raw response:', response.responseText);
const data = JSON.parse(response.responseText);
if (data.error) throw new Error(data.error.message || 'API 오류');
if (!data.candidates || !Array.isArray(data.candidates) || data.candidates.length === 0)
throw new Error('API candidates 배열이 없습니다.');
const candidate = data.candidates[0];
const text = candidate.content.parts[0]?.text || '';
// 코드블록 마크다운 제거, 시작~끝 괄호 추출
let cleanText = text.replace(/```json/g, '').replace(/```/g, '').trim();
if (!cleanText.startsWith('[') && cleanText.includes('[')) {
cleanText = cleanText.slice(cleanText.indexOf('['));
}
if (!cleanText.endsWith(']') && cleanText.lastIndexOf(']') > 0) {
cleanText = cleanText.slice(0, cleanText.lastIndexOf(']') + 1);
}
console.log('[Gemini] Cleaned text for JSON.parse:', cleanText);
let questions;
try {
questions = JSON.parse(cleanText);
} catch (parseError) {
console.error('[Gemini] JSON.parse 실패:', parseError);
console.error('[Gemini] 파싱 실패 원본 cleanText:', cleanText);
alert('[Gemini] API 응답이 JSON 형식이 아닙니다.\n\n----원본----\n' +
cleanText + '\n\n----에러----\n' + parseError.message);
throw new Error('API 응답이 JSON 형식이 아님');
}
if (!Array.isArray(questions)) throw new Error('응답이 배열이 아닙니다.');
resolve(questions);
} catch (error) {
console.error('[Gemini] 최종 오류:', error);
reject(error);
}
},
onerror: function(e) {
console.error('[Gemini] API 연결 실패:', e);
reject(new Error('Gemini API 연결 실패'));
},
ontimeout: function() {
console.error('[Gemini] API 요청 시간 초과');
reject(new Error('Gemini API 요청 시간 초과'));
}
});
});
}
// DOM 랜더링
function showQuestions(questions) {
const existingModal = document.querySelector('#generate-questions-modal');
if (existingModal) existingModal.remove();
const overlay = document.createElement('div');
overlay.className = 'generate-modal-overlay';
overlay.onclick = () => { modal.remove(); overlay.remove(); };
const modal = document.createElement('div');
modal.id = 'generate-questions-modal';
modal.className = 'generate-modal';
const header = document.createElement('div');
header.className = 'generate-modal-header';
const title = document.createElement('h2');
title.className = 'generate-modal-title';
title.innerHTML = `
<svg class="generate-icon" viewBox="0 0 16 16">
<path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"/>
</svg>
생성된 질문
`;
const closeButton = document.createElement('button');
closeButton.className = 'generate-close-btn';
closeButton.innerHTML = '×';
closeButton.onclick = () => { modal.remove(); overlay.remove(); };
header.appendChild(title);
header.appendChild(closeButton);
modal.appendChild(header);
const content = document.createElement('div');
content.className = 'generate-modal-content';
questions.forEach((category, index) => {
const categoryDiv = document.createElement('div');
categoryDiv.className = 'generate-category';
const categoryTitle = document.createElement('h3');
categoryTitle.className = 'generate-category-title';
categoryTitle.textContent = category.title;
categoryDiv.appendChild(categoryTitle);
const questionsList = document.createElement('ul');
questionsList.className = 'generate-questions-list';
category.questions.forEach((question) => {
const li = document.createElement('li');
li.className = 'generate-question-item';
li.textContent = question;
questionsList.appendChild(li);
});
categoryDiv.appendChild(questionsList);
content.appendChild(categoryDiv);
});
modal.appendChild(content);
document.body.appendChild(overlay);
document.body.appendChild(modal);
}
function addButton() {
// Repo 메인 화면
const borderGrid = document.querySelector('div.OverviewContent-module__Box_1--RhaEy');
// /tree 디렉토리
const filenameBox = document.querySelector('[data-testid="breadcrumbs-filename"]');
// 이미 버튼이 있으면 중복 생성 방지
if (document.querySelector('#generate-interview-btn')) return;
let container = borderGrid;
if (window.location.pathname.includes('/tree/')) {
if (!filenameBox) {
setTimeout(addButton, 500);
return;
}
container = filenameBox;
}
if (!container) return;
// 버튼 생성
const btn = document.createElement('button');
btn.id = 'generate-interview-btn';
btn.className = 'generate-btn';
btn.innerHTML = `
<svg class="generate-icon" viewBox="0 0 16 16">
<path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"/>
</svg>
질문 생성하기
`;
btn.style.marginLeft = '8px';
btn.style.verticalAlign = 'middle';
btn.onclick = async function() {
btn.disabled = true;
btn.classList.add('loading');
btn.innerHTML = `
<div class="generate-spinner"></div>
질문 생성 중...
`;
try {
const apiKey = await getApiKey();
if (!apiKey) throw new Error('API Key 없음');
const repoInfo = await fetchFilesInCurrentDirectory();
const prompt = buildPrompt(repoInfo);
const questions = await makeApiRequest(apiKey, prompt);
showQuestions(questions);
} catch (e) {
if (e.message.includes('API')) {
alert('API 오류: ' + e.message + '\n\nAPI 키가 올바른지 확인해주세요.');
}else {
alert('질문 생성 실패: ' + e.message);
}
} finally {
btn.disabled = false;
btn.classList.remove('loading');
btn.innerHTML = `
<svg class="generate-icon" viewBox="0 0 16 16">
<path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"/>
</svg>
질문 생성하기
`;
}
};
// /tree : Copy path 버튼 바로 뒤에 삽입
if (window.location.pathname.includes('/tree/')) {
// Copy path 버튼 바로 뒤에 삽입
const copyBtn = container.querySelector('button, [data-component="IconButton"]');
if (copyBtn && copyBtn.nextSibling) {
container.insertBefore(btn, copyBtn.nextSibling);
} else {
container.appendChild(btn);
}
} else {
container.appendChild(btn);
}
}
// MutationObserver로 동적 버튼 유지
let isObserving = false;
function startObserving() {
if (isObserving) return;
const observer = new MutationObserver(() => {
if (!document.querySelector('#generate-interview-btn')) addButton();
});
observer.observe(document.body, { childList: true, subtree: true });
isObserving = true;
}
// 초기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
startObserving();
setTimeout(() => { if (!document.querySelector('#generate-interview-btn')) addButton(); }, 500);
});
} else {
startObserving();
setTimeout(() => { if (!document.querySelector('#generate-interview-btn')) addButton(); }, 500);
}
})();