본문 바로가기
PHP/CSS, JavaScript

클로드 AI를 이용해 만들어 본 이미지 번역 브라우저 확장앱

by ethanjoh 2025. 3. 12.

최근 AI 툴들을 써보면서 아예 처음부터 만들어 볼 수 있을까? 싶어서 클로드를 이용해 만들어보기로 했다.

 

 

위의 이미지처럼 처음에는 그냥 브라우저 확장앱을 만들어보고 싶었다.

그래서 프롬프트도 자세하게 지정하지 않고 그냥 생각나는대로 적어보았다.

그랬더니 google vision과 traslation API를 이용하는 확장앱을 간단하게 만들어주었다.

 

그리고 몇 번의 에러 수정과 개선을 통해 그럭저럭 만족할 만한 앱이 만들어졌다.

여기서 내가 한 것이라고는 클로드에 프롬프트를 주고 구글에서 API를 키를 받아온 것 밖에는 없다.

 

출처: 잉글리시시티

 

위는 UI를 조금 개선하고 원본 텍스트도 나오게끔 수정한 버전이다.

이미지에 마우스를 갖다대기만 하면 팝업창이 떠서 실시간으로 번역해준다.

영어, 일본어 상관없다.

심지어 한글도 원본 그대로 읽어들인다. ㅋ

 

진짜 좋은 세상이다.

확장앱을 어떻게 만드는지 하나도 모르는 상태에서 그냥 만들어달라고 했는데 이런 결과가 나올 줄이야...

아마도 프로그래밍을 거의 해본 적이 없는 사람이더라도 그냥 지시만 똑똑하게 한다면 어렵지 않게 프로그램 하나 뚝딱 만들 수 있을거다.

 

여기에 AI가 만들어준 소스를 첨부해 본다.

 

1. manifest.json

{
  "manifest_version": 3,
  "name": "Image Text Translator",
  "version": "1.0",
  "description": "Hover over images to translate text to Korean",
  "permissions": [
    "activeTab",
    "storage"
  ],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ],
    "icons": {
    "48": "icon48.png",
    "128": "icon128.png"
  },
  "background": {
    "service_worker": "background.js"
  },
  "options_page": "options.html"
}

 

 

2. content.js

let tooltip = null;
let isTooltipVisible = false;
let activeImage = null;
const translationCache = new Map();
const imageLoader = new Image();

// 팝업창 스타일과 생성 함수
function createTooltip() {
  // 기존 툴팁이 있다면 제거
  if (tooltip) {
    document.body.removeChild(tooltip);
  }

  // 팝업 컨테이너 생성
  tooltip = document.createElement('div');
  tooltip.style.cssText = `
    position: fixed;
    padding: 16px;
    background: white;
    border: 1px solid #ccc;
    border-radius: 8px;
    box-shadow: 0 4px 20px rgba(0,0,0,0.3);
    z-index: 10000;
    width: 400px;
    max-height: 500px;
    overflow-y: auto;
    display: none;
    line-height: 1.6;
    font-size: 14px;
    word-break: break-word;
    pointer-events: auto;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  `;
  
  // 팝업 헤더 생성
  const header = document.createElement('div');
  header.style.cssText = `
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 12px;
    padding-bottom: 8px;
    border-bottom: 1px solid #eee;
  `;
  
  // 팝업 제목
  const title = document.createElement('div');
  title.textContent = '이미지 텍스트 번역';
  title.style.cssText = `
    font-weight: bold;
    font-size: 16px;
  `;
  
  // 닫기 버튼
  const closeButton = document.createElement('button');
  closeButton.innerHTML = '&times;';
  closeButton.style.cssText = `
    background: none;
    border: none;
    font-size: 20px;
    cursor: pointer;
    padding: 0 5px;
  `;
  
  closeButton.addEventListener('click', () => {
    hideTooltip();
  });
  
  // 헤더에 제목과 닫기 버튼 추가
  header.appendChild(title);
  header.appendChild(closeButton);
  
  // 원본 텍스트 영역 생성
  const originalContent = document.createElement('div');
  originalContent.id = 'original-content';
  originalContent.style.cssText = `
    margin-bottom: 12px;
    padding-bottom: 12px;
    border-bottom: 1px dashed #ccc;
  `;
  
  // 원본 텍스트 헤더
  const originalHeader = document.createElement('div');
  originalHeader.textContent = '원본 텍스트';
  originalHeader.style.cssText = `
    font-weight: bold;
    margin-bottom: 8px;
    color: #555;
    font-size: 14px;
  `;
  
  // 원본 텍스트 콘텐츠
  const originalTextContent = document.createElement('div');
  originalTextContent.id = 'original-text-content';
  originalTextContent.style.cssText = `
    background-color: #f8f8f8;
    padding: 10px;
    border-radius: 4px;
    border-left: 3px solid #ddd;
    max-height: 150px;
    overflow-y: auto;
  `;
  
  originalContent.appendChild(originalHeader);
  originalContent.appendChild(originalTextContent);
  
  // 번역 텍스트 영역 생성
  const translatedContent = document.createElement('div');
  translatedContent.id = 'translated-content';
  
  // 번역 텍스트 헤더
  const translatedHeader = document.createElement('div');
  translatedHeader.textContent = '번역 텍스트';
  translatedHeader.style.cssText = `
    font-weight: bold;
    margin-bottom: 8px;
    color: #555;
    font-size: 14px;
  `;
  
  // 번역 텍스트 콘텐츠
  const translatedTextContent = document.createElement('div');
  translatedTextContent.id = 'translated-text-content';
  translatedTextContent.style.cssText = `
    background-color: #f0f7ff;
    padding: 10px;
    border-radius: 4px;
    border-left: 3px solid #4a86e8;
    max-height: 150px;
    overflow-y: auto;
  `;
  
  translatedContent.appendChild(translatedHeader);
  translatedContent.appendChild(translatedTextContent);
  
  // 팝업에 헤더와 콘텐츠 추가
  tooltip.appendChild(header);
  tooltip.appendChild(originalContent);
  tooltip.appendChild(translatedContent);
  
  // 마우스 이벤트 리스너
  tooltip.addEventListener('mouseenter', () => {
    isTooltipVisible = true;
  });
  
  tooltip.addEventListener('mouseleave', () => {
    isTooltipVisible = true;
  });
  
  // 팝업창 외부 클릭 시 닫기
  const overlay = document.createElement('div');
  overlay.style.cssText = `
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.3);
    z-index: 9999;
    display: none;
  `;
  
  overlay.addEventListener('click', (e) => {
    if (e.target === overlay) {
      hideTooltip();
    }
  });
  
  tooltip.overlay = overlay;
  document.body.appendChild(overlay);
  document.body.appendChild(tooltip);
}

// 팝업 숨기기 함수
function hideTooltip() {
  tooltip.style.display = 'none';
  tooltip.overlay.style.display = 'none';
  isTooltipVisible = false;
  activeImage = null;
}

// 이미지 포맷 확인
function getImageFormat(src) {
  const extension = src.split('.').pop().toLowerCase();
  const formats = {
    'jpg': 'image/jpeg',
    'jpeg': 'image/jpeg',
    'png': 'image/png',
    'gif': 'image/gif',
    'webp': 'image/webp',
    'bmp': 'image/bmp',
    'svg': 'image/svg+xml'
  };
  return formats[extension] || 'image/jpeg';
}

// 이미지 로드 및 캔버스 변환
function loadImage(imgElement) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    
    img.crossOrigin = 'anonymous';
    
    img.onload = () => {
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      
      // 이미지 크기가 0인 경우 처리
      if (img.naturalWidth === 0 || img.naturalHeight === 0) {
        reject(new Error('Invalid image dimensions'));
        return;
      }
      
      canvas.width = img.naturalWidth;
      canvas.height = img.naturalHeight;
      
      try {
        // 이미지를 캔버스에 그리기
        ctx.drawImage(img, 0, 0);
        
        // WebP, PNG 등 투명도가 있는 이미지를 위해 흰색 배경 추가
        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        if (hasTransparency(imageData)) {
          ctx.fillStyle = '#FFFFFF';
          ctx.fillRect(0, 0, canvas.width, canvas.height);
          ctx.drawImage(img, 0, 0);
        }
        
        // 큰 이미지는 리사이즈
        resizeImage(canvas.toDataURL('image/jpeg', 0.9))
          .then(resizedData => resolve(resizedData))
          .catch(error => reject(error));
      } catch (error) {
        reject(new Error('Failed to process image: ' + error.message));
      }
    };
    
    img.onerror = () => {
      // CORS 오류 시 프록시 서버 재시도
      if (!img.src.startsWith('https://cors-anywhere.herokuapp.com/')) {
        img.src = `https://cors-anywhere.herokuapp.com/${imgElement.src}`;
      } else {
        reject(new Error('Failed to load image'));
      }
    };
    
    try {
      img.src = imgElement.src;
    } catch (error) {
      reject(new Error('Invalid image source'));
    }
  });
}

// 투명도 확인
function hasTransparency(imageData) {
  const data = imageData.data;
  for (let i = 3; i < data.length; i += 4) {
    if (data[i] < 255) {
      return true;
    }
  }
  return false;
}

// 문단 구분 처리
function formatText(text) {
  if (!text || text.trim() === '') return '텍스트가 없습니다.';
  
  // 줄바꿈 처리
  text = text.replace(/\n{3,}/g, '\n\n'); // 3개 이상의 연속 줄바꿈을 2개로 통일
  
  // 문단 구분
  const paragraphs = text.split(/\n\s*\n/);
  
  // HTML로 변환하여 문단 구분
  return paragraphs.map(p => {
    // 빈 문단 제거
    if (!p.trim()) return '';
    
    // 줄바꿈 유지
    const lines = p.split('\n')
      .map(line => line.trim())
      .filter(line => line)
      .join('<br>');
    
    return `<p>${lines}</p>`;
  }).join('');
}

// 툴팁(팝업) 표시
function showTooltip(originalText, translatedText, event) {
  const originalElement = tooltip.querySelector('#original-text-content');
  const translatedElement = tooltip.querySelector('#translated-text-content');
  
  // 로딩 상태 표시
  if (translatedText === '이미지 분석 중...') {
    originalElement.innerHTML = '';
    translatedElement.innerHTML = `
      <div style="text-align: center; padding: 20px;">
        <div style="margin-bottom: 10px;">이미지 분석 중...</div>
        <div style="width: 40px; height: 40px; border: 3px solid #f3f3f3; 
             border-top: 3px solid #3498db; border-radius: 50%; 
             margin: 0 auto; animation: spin 1s linear infinite;"></div>
      </div>
      <style>
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
      </style>
    `;
  } else if (translatedText.startsWith('오류:')) {
    // 오류 메시지 표시
    originalElement.innerHTML = '';
    translatedElement.innerHTML = `<p style="color: #e74c3c;">${translatedText}</p>`;
  } else {
    // 원본 텍스트와 번역 텍스트 표시
    originalElement.innerHTML = formatText(originalText);
    translatedElement.innerHTML = formatText(translatedText);
  }
  
  // 팝업창과 오버레이 표시
  tooltip.style.display = 'block';
  tooltip.overlay.style.display = 'block';
  isTooltipVisible = true;
}

// 이미지 리사이즈
function resizeImage(base64Str, maxWidth = 1024, maxHeight = 1024) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      let width = img.width;
      let height = img.height;
      
      if (width > maxWidth || height > maxHeight) {
        const ratio = Math.min(maxWidth / width, maxHeight / height);
        width *= ratio;
        height *= ratio;
      }
      
      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;
      
      try {
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, width, height);
        resolve(canvas.toDataURL('image/jpeg', 0.9));
      } catch (error) {
        reject(new Error('Failed to resize image: ' + error.message));
      }
    };
    
    img.onerror = () => reject(new Error('Failed to load image for resizing'));
    img.src = base64Str;
  });
}

// 이미지 호버 처리
async function handleImageHover(event) {
  const img = event.target;
  activeImage = img;
  
  try {
    showTooltip('', '이미지 분석 중...', event);
    
    const cacheKey = img.src;
    
    if (translationCache.has(cacheKey)) {
      const cachedResult = translationCache.get(cacheKey);
      showTooltip(cachedResult.originalText, cachedResult.translatedText, event);
      return;
    }
    
    const imageData = await loadImage(img);
    
    if (!imageData) {
      throw new Error('이미지 데이터를 처리할 수 없습니다.');
    }
    
    const response = await chrome.runtime.sendMessage({
      type: 'EXTRACT_TEXT',
      imageData: imageData
    });
    
    if (!response) {
      throw new Error('서버 응답이 없습니다.');
    }
    
    if (response.error) {
      throw new Error(response.error);
    }
    
    // 백엔드 응답 구조에 따라 텍스트 확인
    // 원래 API가 어떤 형태로 응답하는지 확인
    let originalText = '';
    let translatedText = '';
    
    // 기존 API가 'extractedText'와 'translatedText'로 응답하는 경우
    if (response.extractedText !== undefined) {
      originalText = response.extractedText;
    }
    // 기존 API가 'originalText'와 'translatedText'로 응답하는 경우
    else if (response.originalText !== undefined) {
      originalText = response.originalText;
    }
    // 기존 API가 'text' 또는 다른 필드명으로 원본 텍스트를 전달하는 경우
    else if (response.text !== undefined) {
      originalText = response.text;
    }
    
    // 번역 텍스트 확인
    if (response.translatedText !== undefined) {
      translatedText = response.translatedText;
    }
    
    // 두 텍스트 모두 없는 경우
    if (!originalText && !translatedText) {
      // 기존의 단일 텍스트 필드만 있는 경우 (하위 호환성)
      if (typeof response === 'string' || response.text) {
        originalText = typeof response === 'string' ? response : (response.text || '');
        translatedText = '번역 기능이 활성화되지 않았습니다.';
      } else {
        throw new Error('텍스트를 추출할 수 없습니다.');
      }
    }
    
    // 캐시에 원본 텍스트와 번역된 텍스트 모두 저장
    translationCache.set(cacheKey, {
      originalText: originalText || '추출된 텍스트가 없습니다.',
      translatedText: translatedText || '번역된 텍스트가 없습니다.'
    });
    
    showTooltip(originalText || '추출된 텍스트가 없습니다.', 
                translatedText || '번역된 텍스트가 없습니다.', event);
  } catch (error) {
    console.error('Error processing image:', error);
    showTooltip('추출 실패', `오류: ${error.message}`, event);
  }
}

// ESC 키 이벤트 처리
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && isTooltipVisible) {
    hideTooltip();
  }
});

// 초기화
function initialize() {
  createTooltip();
  
  // 이미지 호버 이벤트
  document.addEventListener('mouseover', (e) => {
    if (e.target.tagName === 'IMG' && !isTooltipVisible) {
      handleImageHover(e);
    }
  });
  
  // 이미지 우클릭 컨텍스트 메뉴
  document.addEventListener('contextmenu', (e) => {
    if (e.target.tagName === 'IMG') {
      // 여기에 추가 기능 구현 가능
    }
  });
  
  // 창 크기 변경 시 팝업창 중앙 유지
  window.addEventListener('resize', () => {
    if (isTooltipVisible) {
      // 팝업은 항상 중앙에 위치하므로 추가 조정 필요 없음
    }
  });
}

initialize();

 

 

3. background.js

async function getApiKeys() {
  return new Promise((resolve) => {
    chrome.storage.sync.get(['visionApiKey', 'translateApiKey'], (items) => {
      resolve({
        visionApiKey: items.visionApiKey,
        translateApiKey: items.translateApiKey
      });
    });
  });
}

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.type === 'EXTRACT_TEXT') {
    const extractText = async (imageData) => {
      try {
        const apiKeys = await getApiKeys();
        
        if (!apiKeys.visionApiKey || !apiKeys.translateApiKey) {
          throw new Error('API keys not set. Please configure in extension options.');
        }
        
        // Google Cloud Vision API 호출
        const visionEndpoint = `https://vision.googleapis.com/v1/images:annotate?key=${apiKeys.visionApiKey}`;
        const visionBody = {
          requests: [{
            image: {
              content: imageData.split(',')[1]
            },
            features: [{
              type: 'TEXT_DETECTION'
            }]
          }]
        };
        
        const visionResponse = await fetch(visionEndpoint, {
          method: 'POST',
          body: JSON.stringify(visionBody)
        });
        
        const visionResult = await visionResponse.json();
        const text = visionResult.responses[0]?.textAnnotations[0]?.description || '';
        
        // Google Translate API 호출
        const translatedText = await translateText(text, apiKeys.translateApiKey);
        
        sendResponse({ success: true, text, translatedText });
      } catch (error) {
        console.error('Error:', error);
        sendResponse({ success: false, error: error.message });
      }
    };
    
    extractText(request.imageData);
    return true;
  }
});

// 텍스트 번역 함수
async function translateText(text, apiKey) {
  try {
    const endpoint = `https://translation.googleapis.com/language/translate/v2?key=${apiKey}`;
    
    const body = {
      q: text,
      target: 'ko'
    };
    
    const response = await fetch(endpoint, {
      method: 'POST',
      body: JSON.stringify(body)
    });
    
    const result = await response.json();
    return result.data.translations[0].translatedText;
  } catch (error) {
    console.error('Translation error:', error);
    return text;
  }
}

 

 

4. options.js

document.getElementById('save').addEventListener('click', () => {
  const visionApiKey = document.getElementById('visionApiKey').value;
  const translateApiKey = document.getElementById('translateApiKey').value;
  
  chrome.storage.sync.set({
    visionApiKey,
    translateApiKey
  }, () => {
    alert('Settings saved!');
  });
});

// Load saved settings
chrome.storage.sync.get(['visionApiKey', 'translateApiKey'], (items) => {
  if (items.visionApiKey) {
    document.getElementById('visionApiKey').value = items.visionApiKey;
  }
  if (items.translateApiKey) {
    document.getElementById('translateApiKey').value = items.translateApiKey;
  }
});

 

 

5. options.html

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>이미지 번역 옵션</title>
  <style>
    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
      font-family: 'Noto Sans KR', Arial, sans-serif;
    }
    
    body {
      padding: 30px;
      background-color: #f5f7fa;
      color: #333;
      max-width: 800px;
      margin: 0 auto;
      line-height: 1.6;
    }
    
    h2 {
      color: #2c3e50;
      margin-bottom: 25px;
      padding-bottom: 10px;
      border-bottom: 2px solid #3498db;
      font-size: 28px;
    }
    
    .form-container {
      background-color: white;
      border-radius: 8px;
      padding: 25px;
      box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
    }
    
    .form-group {
      margin-bottom: 20px;
    }
    
    label {
      display: block;
      margin-bottom: 8px;
      font-weight: 600;
      color: #2c3e50;
    }
    
    input[type="text"] {
      width: 100%;
      padding: 10px 12px;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 16px;
      transition: border-color 0.3s;
    }
    
    input[type="text"]:focus {
      border-color: #3498db;
      outline: none;
      box-shadow: 0 0 5px rgba(52, 152, 219, 0.3);
    }
    
    button {
      background-color: #3498db;
      color: white;
      border: none;
      padding: 12px 20px;
      font-size: 16px;
      border-radius: 4px;
      cursor: pointer;
      transition: background-color 0.3s;
    }
    
    button:hover {
      background-color: #2980b9;
    }
    
    .footer {
      margin-top: 20px;
      text-align: center;
      font-size: 14px;
      color: #7f8c8d;
    }
  </style>
</head>
<body>
  <h2>이미지 번역 옵션</h2>
  <div class="form-container">
    <div class="form-group">
      <label for="visionApiKey">Google Cloud Vision API 키:</label>
      <input type="text" id="visionApiKey" placeholder="API 키를 입력하세요">
    </div>
    <div class="form-group">
      <label for="translateApiKey">Google Translate API 키:</label>
      <input type="text" id="translateApiKey" placeholder="API 키를 입력하세요">
    </div>
    <button id="save">저장하기</button>
  </div>
  <div class="footer">
    © 2025 이미지 번역 확장 프로그램
  </div>
  
  <script src="options.js"></script>
</body>
</html>

 

아래 두 개 아이콘들도 같은 폴더에 넣어준다.

 

 

 

사용법:

 

1. 위의 소스들을 파일로 만들어 하나의 폴더에 넣어둔다.

2. 브라우저의 확장 메뉴를 눌러 확장관리로 간다.

3. 개발자 모드를 ON

4. "압축 풀린 파일 로드" 를 눌러 해당 폴더를 선택한다.

5. 설치된 확장앱의 확장옵션을 선택해서 구글 API들을 입력하고 저장한다.

    ★ API 신청하는 과정이 조금 복잡해서 나도 잘 모르겠...여기서는 패스

 

https://cloud.google.com/apis?hl=ko

 

https://cloud.google.com/apis?hl=ko

 

cloud.google.com