GPT-snaplist 프로젝트

Category
OpenSource
Status
Published
Tags
Chrome Extension
Description
Published
Slug
 

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); } }); }

복사되는 파일

  1. manifest.json → Chrome이 확장 프로그램을 인식하는 설정 파일
  1. icons/ → 확장 프로그램 아이콘들 (16px, 32px, 48px, 128px)
  1. _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.tsConversationExtractor는 ChatGPT 페이지에서 HTML 덩어리를 긁어온 뒤
  1. 기본적인 <strong>, <li> 등은 Turndown번역기가 자동으로 마크다운으로 변환하고,
  1. 코드 블록처럼 특별한 놈은 addRule로 가르쳐 준 규칙으로 변환하여,
  1. 마크다운 텍스트를 copyContentfetchQuestions에 제공하는 것
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);
  1. 활성 탭 확인 → 현재 Chrome에서 보고 있는 탭이 ChatGPT인지 확인
  1. URL 검증chatgpt.com/c/ 또는 chatgpt.com/g/ 패턴 확인
  1. Content Script 연결 테스트ping 메시지로 통신 가능 확인
  1. 데이터 요청 → Content Script에 질문 목록 요청
  1. 상태 업데이트 → 받은 데이터로 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" } }

 

전체 아키텍처 플로우

notion image

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/

 

실제 실행 시나리오

사용자가 확장 프로그램을 클릭했을 때

  1. Chrome이 manifest.json 읽기
      • "default_popup": "popup.html" 확인
      • 380px × 600px 팝업 창 생성
  1. popup.html 로드
      • <div id="root"></div> 생성
      • /src/popup.tsx 모듈 로드 (Vite가 popup.js로 변환)
  1. React 앱 초기화
      • ThemeProvider로 토스 스타일 테마 적용
      • PopupApp 컴포넌트 렌더링 시작
  1. useQuestions 훅 실행
      • fetchQuestions() 함수 호출
      • 로딩 상태로 LoadingSpinner 표시
  1. Chrome API를 통한 탭 확인
      • 현재 활성 탭이 ChatGPT 페이지인지 확인
      • URL이 https://chatgpt.com/c/* 패턴과 일치하는지 검증
  1. Content Script와 통신
      • chrome.tabs.sendMessage로 질문 목록 요청
      • Content Script가 DOM을 분석하여 질문/답변 추출
  1. 데이터 수신 및 UI 업데이트
      • 질문 목록을 React 상태로 저장
      • QuestionList 컴포넌트로 시각화
  1. 사용자 인터랙션 대기
      • 질문 클릭 시 스크롤 이동
      • 복사 버튼 클릭 시 클립보드 복사