copy-assets.cjs 파일
React + Vite로 빌드하면 JavaScript와 CSS만 번들링되고, Chrome 확장 프로그램에 필요한 다른 파일들은 자동으로 복사되지 않음. 이 파일이 그 역할 담당
const fs = require('fs'); const path = require('path'); // 1. 디렉토리 전체를 복사하는 함수 function copyDir(src, dest) { // 목적지 폴더가 없으면 생성 if (!fs.existsSync(dest)) { fs.mkdirSync(dest, { recursive: true }); } // 소스 폴더의 모든 파일/폴더 목록 가져오기 const items = fs.readdirSync(src); items.forEach(item => { const srcPath = path.join(src, item); const destPath = path.join(dest, item); // 폴더면 재귀적으로 복사, 파일이면 직접 복사 if (fs.statSync(srcPath).isDirectory()) { copyDir(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } }); }
복사되는 파일
- manifest.json → Chrome이 확장 프로그램을 인식하는 설정 파일
- icons/ → 확장 프로그램 아이콘들 (16px, 32px, 48px, 128px)
- _locales/ → 다국어 지원 파일 (한국어, 영어)
content.ts 파일 (Content Script)
Content Script란?
Chrome 확장 프로그램이 웹 페이지 내부에 직접 삽입되어 실행되는 JavaScript 코드 웹 페이지의 DOM에 직접 접근할 수 있는 방법
실행 시점과 방식
// manifest.json에서 설정 "content_scripts": [ { "matches": ["https://chatgpt.com/c/*", "https://chatgpt.com/g/*"], "js": ["libs/turndown.js", "content.js"], "run_at": "document_end" // DOM 로딩 완료 후 실행 } ]
주요 구성 요소
1. ConversationExtractor 클래스
class ConversationExtractor { private turndownService: any; // HTML → Markdown 변환기 constructor() { this.initTurndown(); // 초기화 } }
2. DOM 분석
public parseQuestionList(): { questions: Question[]; messageIdMap: MessageIdMap } { // ChatGPT 페이지에서 사용자 메시지 찾기 const userMessages = document.querySelectorAll('div[data-message-author-role="user"]'); // 어시스턴트 응답 메시지 찾기 const assistantMessages = document.querySelectorAll('div[data-message-author-role="assistant"]'); // 질문과 답변을 매핑하여 반환 return { questions, messageIdMap }; }
3. HTML → Markdown 변환
서식은 유지하되, 노션이나 블로그에 붙여넣기 좋은 깔끔한 마크다운
content.ts의 ConversationExtractor는 ChatGPT 페이지에서 HTML 덩어리를 긁어온 뒤- 기본적인
<strong>,<li>등은 Turndown번역기가 자동으로 마크다운으로 변환하고,
- 코드 블록처럼 특별한 놈은
addRule로 가르쳐 준 규칙으로 변환하여,
- 마크다운 텍스트를
copyContent나fetchQuestions에 제공하는 것
private enhanceTurndown(): void { // 코드 블록 처리 this.turndownService.addRule("codeBlocks", { filter: (node) => node.nodeName === "PRE" && node.firstChild?.nodeName === "CODE", replacement: (_, node) => { const codeElement = node.firstChild; const lang = codeElement.className.match(/language-(\w+)/)?.[1] || 'plaintext'; return `\n\`\`\`${lang}\n${codeElement.textContent}\n\`\`\`\n`; } }); // 표(table) 처리, 수식 처리, 이미지 처리 등... }
4. Chrome API 통신
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === 'getQuestionList') { extractor.extractConversationData() .then(data => sendResponse(data)); return true; // 비동기 응답 활성화 } });
Hooks
useQuestions.ts
역할: 질문 데이터 상태 관리
export const useQuestions = (): UseQuestionsReturn => { // 상태 관리 const [questions, setQuestions] = useState<Question[]>([]); const [messageIdMap, setMessageIdMap] = useState<MessageIdMap>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const [activeTabId, setActiveTabId] = useState<number | null>(null);
- 활성 탭 확인 → 현재 Chrome에서 보고 있는 탭이 ChatGPT인지 확인
- URL 검증 →
chatgpt.com/c/또는chatgpt.com/g/패턴 확인
- Content Script 연결 테스트 →
ping메시지로 통신 가능 확인
- 데이터 요청 → Content Script에 질문 목록 요청
- 상태 업데이트 → 받은 데이터로 React 상태 업데이트
useCallback 사용이유
const fetchQuestions = useCallback(async () => { // 함수 내용... }, []); // 의존성 배열이 비어있어서 컴포넌트 재렌더링 시에도 함수가 재생성되지 않음
useChromeMessage.ts
→ Chrome API와 통신 관리
export const useChromeMessage = (): UseChromeMessageReturn => { const scrollToMessage = useCallback(async (tabId: number, messageId: string) => { // 1. Content Script 연결 확인 await new Promise<void>((resolve, reject) => { chrome.tabs.sendMessage(tabId, { action: 'ping' }, (_response) => { if (chrome.runtime.lastError) { reject(new Error('Content script가 로드되지 않았습니다.')); return; } resolve(); }); }); // 2. 스크롤 명령 전송 await requestScrollToMessage(tabId, messageId); }, []);
copyContent 함수
const copyContent = useCallback(async (tabId, questionId, messageIdMap) => { try { // 1. 질문과 답변 내용을 Content Script에서 가져옴 const content = await copyQuestionAndAnswer(tabId, questionId, messageIdMap); // 2. 클립보드에 복사 await navigator.clipboard.writeText(content); return content; } catch (error) { throw error; } }, []);
Utils
chromeApi.ts
getActiveTab 함수
export const getActiveTab = (): Promise<chrome.tabs.Tab> => { return new Promise((resolve, reject) => { // Chrome API로 현재 활성화된 탭 정보 가져오기 chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (chrome.runtime.lastError) { reject(chrome.runtime.lastError); return; } if (tabs.length === 0) { reject(new Error('활성 탭을 찾을 수 없습니다.')); return; } resolve(tabs[0]); // 첫 번째 탭이 현재 활성 탭 }); }); };
sendMessageToTab 함수
export const sendMessageToTab = <T>(tabId: number, message: ChromeMessage): Promise<ChromeResponse<T>> => { return new Promise((resolve) => { // 특정 탭의 Content Script에 메시지 전송 chrome.tabs.sendMessage(tabId, message, (response) => { if (chrome.runtime.lastError) { resolve({ success: false, error: chrome.runtime.lastError.message, }); return; } resolve(response); }); }); };
isValidChatGPTUrl 함수
export const isValidChatGPTUrl = (url: string): boolean => { return ( url.startsWith('https://chatgpt.com/c/') || // 일반 대화 url.startsWith('https://chatgpt.com/g/') // 커스텀 GPT ); };
messageHandler.ts
requestConversationData 함수
export const requestConversationData = async (tabId: number): Promise<ConversationData> => { // 1. Content Script에 질문 목록 요청 메시지 생성 const message: ChromeMessage = { action: 'getQuestionList' }; // 2. 메시지 전송 및 응답 대기 const response = await sendMessageToTab<ConversationData>(tabId, message); // 3. 응답 데이터 검증 및 변환 if (response.success && response.data) { return response.data; } // 4. Content Script에서 직접 데이터를 반환한 경우 처리 if (response.success && response.questions) { return { questions: response.questions || [], messageIdMap: response.messageIdMap || {}, success: true }; } // 5. 실패 시 기본값 반환 return { questions: [], messageIdMap: {}, success: false, error: response.error || '데이터 형식이 올바르지 않습니다.', }; };
copyQuestionAndAnswer 함수
export const copyQuestionAndAnswer = async (tabId, questionId, messageIdMap) => { // 1. 질문 ID에 해당하는 답변 ID 찾기 const assistantMessageId = messageIdMap[questionId]; if (!assistantMessageId) { throw new Error('이 질문에 대한 답변이 없습니다.'); } // 2. 질문과 답변 내용 병렬로 가져오기 const [questionResponse, answerResponse] = await Promise.all([ requestMessageContent(tabId, questionId), // 질문 내용 requestMessageContent(tabId, assistantMessageId), // 답변 내용 ]); // 3. 마크다운 형태로 조합하여 반환 if (questionResponse.success && answerResponse.success) { return `Q: ${questionResponse.content}\nA: ${answerResponse.content}`; } throw new Error('질문 또는 답변을 가져오는 데 실패했습니다.'); };
manifest.json
manifest.json 역할
Chrome 확장 프로그램의 설정 파일이자 진입점. Chrome이 이 파일을 읽고 확장 프로그램이 무엇인지, 어떤 권한이 필요한지, 어떤 파일들을 사용하는지 파악함
{ "manifest_version": 3, "name": "__MSG_appName__", "version": "2.0.0", // 확장 프로그램 버전 "description": "__MSG_appDesc__", "permissions": ["tabs"], // 탭 정보 접근 권한 "action": { "default_popup": "popup.html", // 아이콘 클릭 시 열리는 팝업 "default_icon": { "16": "icons/gptlist16.png", // 아이콘 "32": "icons/gptlist32.png", "48": "icons/gptlist48.png", "128": "icons/gptlist128.png" } }, "icons": { // 확장 프로그램 관리 페이지에서 보여질 아이콘 "16": "icons/gptlist16.png", "32": "icons/gptlist32.png", "48": "icons/gptlist48.png", "128": "icons/gptlist128.png" }, "content_scripts": [ // 웹 페이지에 삽입될 스크립트 { "matches": ["https://chatgpt.com/c/*", "https://chatgpt.com/g/*"], "js": ["libs/turndown.js", "content.js"], "run_at": "document_end" } ], "default_locale": "en" // 기본 언어 (영어)
manifest_version: 3
- Chrome Extension API 버전
- V2는 2023년부터 단계적으로 지원 중단
- V3에서는 보안이 강화되고 Service Worker 사용
permissions: ["tabs"]
// 이 권한으로 할 수 있는 것들: chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { console.log(tabs[0].url); // 현재 탭의 URL console.log(tabs[0].title); // 현재 탭의 제목 }); chrome.tabs.sendMessage(tabId, message, callback); // Content Script와 통신
content_scripts 설정
"content_scripts": [ { "matches": ["https://chatgpt.com/c/*", "https://chatgpt.com/g/*"], // 이 URL 패턴과 일치하는 페이지에서만 실행 "js": ["libs/turndown.js", "content.js"], // 실행할 JavaScript 파일들 (순서대로 실행) "run_at": "document_end" // DOM 로딩 완료 후 실행 (다른 옵션: document_start, document_idle) } ]
다국어 지원 (MSG_appName)
// _locales/ko/messages.json { "appName": { "message": "ChatGPT 대화 분석기" }, "appDesc": { "message": "ChatGPT 대화를 시각화하고 관리하세요" } } // _locales/en/messages.json { "appName": { "message": "ChatGPT Conversation Analyzer" }, "appDesc": { "message": "Visualize and manage your ChatGPT conversations" } }
전체 아키텍처 플로우

1. 진입점 (popup.html)
<!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8" /> <title>ChatGPT 대화 분석기</title> </head> <body> <div id="root"></div> <script type="module" src="/src/popup.tsx"></script> <!-- Vite가 이 스크립트 번들링해서 popup.js로 변환 --> </body> </html>
2. React 앱 초기화 (popup.tsx)
import React from 'react'; import ReactDOM from 'react-dom/client'; import { ThemeProvider } from '@emotion/react'; import { Global } from '@emotion/react'; import { PopupApp } from '@/components/popup/PopupApp/PopupApp'; import { lightTheme } from '@/styles/themes/light'; import { GlobalStyles } from '@/styles/GlobalStyles'; const root = ReactDOM.createRoot(document.getElementById('root')!); root.render( <React.StrictMode> <ThemeProvider theme={lightTheme}> <Global styles={GlobalStyles(lightTheme)} /> <PopupApp /> {/* 메인 앱 컴포넌트 */} </ThemeProvider> </React.StrictMode> );
3. 컴포넌트 구조
PopupApp ├── StyledHeader │ └── StyledTitle ("ChatGPT 대화 분석") ├── StyledContent │ ├── LoadingSpinner (loading === true) │ ├── ErrorMessage (error !== null) │ └── QuestionList (성공시) │ └── QuestionItem[] (각 질문별) │ ├── StyledQuestionIndex (순번) │ ├── StyledQuestionContent (질문 내용) │ └── StyledCopyButton (복사 버튼) └── StyledFooter └── StyledFooterText (사용 안내)
4. 데이터 플로우
// 1. 컴포넌트 마운트 시 useEffect(() => { fetchQuestions(); // 데이터 요청 시작 }, [fetchQuestions]); // 2. 활성 탭 정보 가져오기 const activeTab = await getActiveTab(); // 3. Content Script와 통신 const conversationData = await requestConversationData(activeTab.id); // 4. 상태 업데이트로 UI 리렌더링 트리거 setQuestions(conversationData.questions); setMessageIdMap(conversationData.messageIdMap);
5. Chrome API 통신 흐름
// Popup → Content Script chrome.tabs.sendMessage(tabId, { action: 'getQuestionList' }, (response) => { // Content Script → Popup console.log(response.questions); }); // Content Script에서 수신 chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === 'getQuestionList') { const data = extractor.extractConversationData(); sendResponse(data); // Popup으로 응답 } });
6. 사용자 상호작용 처리
// 질문 클릭 → 스크롤 이동 const handleQuestionClick = async () => { await scrollToMessage(activeTabId, question.messageId); }; // 복사 버튼 클릭 → 클립보드 복사 const handleCopyClick = async () => { const content = await copyContent(activeTabId, question.messageId, messageIdMap); navigator.clipboard.writeText(content); };
빌드 시 파일 변환
개발 환경 src/popup.tsx → (Vite) → dist/popup.js src/content/content.ts → (Vite) → dist/content.js popup.html → (복사) → dist/popup.html manifest.json → (복사) → dist/manifest.json icons/ → (copy-assets.cjs) → dist/icons/
실제 실행 시나리오
사용자가 확장 프로그램을 클릭했을 때
- Chrome이 manifest.json 읽기
"default_popup": "popup.html"확인- 380px × 600px 팝업 창 생성
- popup.html 로드
<div id="root"></div>생성/src/popup.tsx모듈 로드 (Vite가 popup.js로 변환)
- React 앱 초기화
- ThemeProvider로 토스 스타일 테마 적용
- PopupApp 컴포넌트 렌더링 시작
- useQuestions 훅 실행
fetchQuestions()함수 호출- 로딩 상태로 LoadingSpinner 표시
- Chrome API를 통한 탭 확인
- 현재 활성 탭이 ChatGPT 페이지인지 확인
- URL이
https://chatgpt.com/c/*패턴과 일치하는지 검증
- Content Script와 통신
chrome.tabs.sendMessage로 질문 목록 요청- Content Script가 DOM을 분석하여 질문/답변 추출
- 데이터 수신 및 UI 업데이트
- 질문 목록을 React 상태로 저장
- QuestionList 컴포넌트로 시각화
- 사용자 인터랙션 대기
- 질문 클릭 시 스크롤 이동
- 복사 버튼 클릭 시 클립보드 복사