您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
AI 활용 면접 질문 생성기
// ==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); } })();