pyQt5에서 Google Custom Search API 사용해보기

webnautes
By -
0

 Google Custom Search API를 사용하는 pyQt5 프로그램을 클로드 코드를 사용하여 간단히 만들어 봤습니다.



2025. 12. 11 최초작성



API 키 생성 가이드 버튼를 클릭하여 API 키와 검색 엔진 ID를 생성한 후, 위에 보이는 입력란에 입력후 설정 저장 버튼을 클릭해주면 이후 저장되게 됩니다. 


검색어를 입력 후. 검색 버튼을 클릭하면 검색 결과가 아래에 보이게 됩니다. 


주의할 점은 하루 무료 사용량인 100건 이상 검색을 하지 않도록 해야 하는 점입니다.




전체 코드입니다.



import sys
import requests
import json
from datetime import datetime
from pathlib import Path
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout,
    QHBoxLayout, QLineEdit, QPushButton, QLabel,
    QListWidget, QListWidgetItem, QMessageBox, QGroupBox,
    QFormLayout, QDialog, QTextBrowser, QScrollArea, QComboBox,
    QFrame
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QUrl, QSettings
from PyQt5.QtGui import QFont, QDesktopServices


class ApiGuideDialog(QDialog):
    """API 키 생성 가이드 다이얼로그"""
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle('Google API 키 생성 가이드')
        self.setMinimumSize(650, 550)
        self.init_ui()

    def init_ui(self):
        layout = QVBoxLayout()
        layout.setContentsMargins(20, 20, 20, 20)

        # 가이드 내용
        guide_text = QTextBrowser()
        guide_text.setOpenExternalLinks(True)
        guide_text.setFont(QFont('맑은 고딕', 10))
        guide_text.setHtml('''
        <h2 style="color: #4285f4;">Google Custom Search API 설정 가이드</h2>

        <h3>1단계: Google Cloud 프로젝트 생성</h3>
        <ol>
            <li><a href="https://console.cloud.google.com/">Google Cloud Console</a> 접속</li>
            <li>Google 계정으로 로그인</li>
            <li>상단의 프로젝트 선택 → <b>"새 프로젝트"</b> 클릭</li>
            <li>프로젝트 이름 입력 후 <b>"만들기"</b> 클릭</li>
        </ol>

        <h3>2단계: Custom Search API 활성화</h3>
        <ol>
            <li>좌측 메뉴에서 <b>"API 및 서비스"</b> → <b>"라이브러리"</b> 클릭</li>
            <li>검색창에 <b>"Custom Search API"</b> 검색</li>
            <li><b>"Custom Search API"</b> 선택 후 <b>"사용"</b> 버튼 클릭</li>
        </ol>

        <h3>3단계: API 키 생성</h3>
        <ol>
            <li>좌측 메뉴에서 <b>"API 및 서비스"</b> → <b>"사용자 인증 정보"</b> 클릭</li>
            <li>상단의 <b>"+ 사용자 인증 정보 만들기"</b> → <b>"API 키"</b> 선택</li>
            <li>생성된 API 키를 복사하여 앱에 입력</li>
        </ol>

        <h3>4단계: 검색 엔진 ID (CX) 생성</h3>
        <ol>
            <li><a href="https://programmablesearchengine.google.com/">Programmable Search Engine</a> 접속</li>
            <li><b>"추가"</b> 클릭</li>
            <li>검색 엔진 이름 입력</li>
            <li><b>"전체 웹 검색"</b> 옵션 선택</li>
            <li><b>"만들기"</b> 클릭</li>
            <li>생성 후 아래와 같은 코드가 표시됩니다:</li>
        </ol>
        <div style="background-color: #f5f5f5; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 11px;">
            &lt;script async src="https://cse.google.com/cse.js?<b style="color: #ea4335;">cx=a1b2c3d4e5f6g7h8i</b>"&gt;<br>
            &lt;/script&gt;
        </div>
        <p style="margin-top: 10px;">
            <b style="color: #4285f4;">cx= 뒤의 값</b>이 검색 엔진 ID입니다.<br>
            위 예시에서는 <code style="background-color: #e8f0fe; padding: 2px 6px; border-radius: 3px;">a1b2c3d4e5f6g7h8i</code> 부분을 복사하여 앱에 입력하세요.
        </p>

        <hr>
        <h3 style="color: #34a853;">무료 사용량</h3>
        <ul>
            <li><b>하루 100회</b> 무료 검색 가능</li>
            <li>초과 시 1,000회당 $5 과금</li>
        </ul>

        <h3 style="color: #ea4335;">주의사항</h3>
        <ul>
            <li>API 키는 외부에 노출되지 않도록 주의하세요</li>
            <li>필요시 API 키에 제한을 설정하세요 (IP, 리퍼러 등)</li>
        </ul>
        ''')
        layout.addWidget(guide_text)

        # 버튼 레이아웃
        btn_layout = QHBoxLayout()

        # Cloud Console 바로가기
        cloud_btn = QPushButton('Google Cloud Console 열기')
        cloud_btn.setStyleSheet('''
            QPushButton {
                background-color: #4285f4;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 10px 20px;
                font-size: 12px;
            }
            QPushButton:hover {
                background-color: #3367d6;
            }
        ''')
        cloud_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl('https://console.cloud.google.com/')))
        btn_layout.addWidget(cloud_btn)

        # Search Engine 바로가기
        search_btn = QPushButton('Programmable Search Engine 열기')
        search_btn.setStyleSheet('''
            QPushButton {
                background-color: #34a853;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 10px 20px;
                font-size: 12px;
            }
            QPushButton:hover {
                background-color: #2d8e47;
            }
        ''')
        search_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl('https://programmablesearchengine.google.com/')))
        btn_layout.addWidget(search_btn)

        # 닫기 버튼
        close_btn = QPushButton('닫기')
        close_btn.setStyleSheet('''
            QPushButton {
                background-color: #757575;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 10px 20px;
                font-size: 12px;
            }
            QPushButton:hover {
                background-color: #616161;
            }
        ''')
        close_btn.clicked.connect(self.close)
        btn_layout.addWidget(close_btn)

        layout.addLayout(btn_layout)
        self.setLayout(layout)


# ============================================================
# Google Custom Search API 설정
#
# 1. Google Cloud Console (https://console.cloud.google.com/)
#    - 새 프로젝트 생성
#    - "Custom Search API" 검색 후 활성화
#    - "사용자 인증 정보" > "API 키 만들기"
#
# 2. Programmable Search Engine (https://programmablesearchengine.google.com/)
#    - "새 검색 엔진" 만들기
#    - "전체 웹 검색" 선택
#    - 생성 후 "검색 엔진 ID" (cx) 복사
# ============================================================

API_KEY = ''  # 여기에 API 키 입력 또는 앱에서 설정
SEARCH_ENGINE_ID = ''  # 여기에 검색 엔진 ID 입력 또는 앱에서 설정
DAILY_LIMIT = 100  # 하루 무료 검색 제한


class UsageTracker:
    """하루 API 사용량을 로컬 파일에 저장하고 관리하는 클래스"""

    def __init__(self):
        # 사용량 파일 경로 (앱과 같은 폴더에 저장)
        self.usage_file = Path(__file__).parent / 'usage_data.json'
        self.load_usage()

    def load_usage(self):
        """저장된 사용량 데이터 로드"""
        today = datetime.now().strftime('%Y-%m-%d')

        if self.usage_file.exists():
            try:
                with open(self.usage_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    # 오늘 날짜가 아니면 초기화
                    if data.get('date') != today:
                        self.data = {'date': today, 'count': 0}
                        self.save_usage()
                    else:
                        self.data = data
            except (json.JSONDecodeError, IOError):
                self.data = {'date': today, 'count': 0}
                self.save_usage()
        else:
            self.data = {'date': today, 'count': 0}
            self.save_usage()

    def save_usage(self):
        """사용량 데이터 저장"""
        try:
            with open(self.usage_file, 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=2)
        except IOError as e:
            print(f"사용량 저장 실패: {e}")

    def increment(self):
        """사용량 1 증가"""
        today = datetime.now().strftime('%Y-%m-%d')

        # 날짜가 바뀌었으면 초기화
        if self.data.get('date') != today:
            self.data = {'date': today, 'count': 0}

        self.data['count'] += 1
        self.save_usage()
        return self.data['count']

    def get_count(self):
        """현재 사용량 반환"""
        today = datetime.now().strftime('%Y-%m-%d')

        # 날짜가 바뀌었으면 초기화
        if self.data.get('date') != today:
            self.data = {'date': today, 'count': 0}
            self.save_usage()

        return self.data['count']

    def is_limit_exceeded(self):
        """제한 초과 여부 확인"""
        return self.get_count() >= DAILY_LIMIT


class SearchThread(QThread):
    """검색을 백그라운드에서 수행하는 스레드"""
    finished = pyqtSignal(list)
    error = pyqtSignal(str)

    def __init__(self, keyword, api_key, cx):
        super().__init__()
        self.keyword = keyword
        self.api_key = api_key
        self.cx = cx

    def run(self):
        try:
            results = self.google_search(self.keyword)
            self.finished.emit(results)
        except Exception as e:
            self.error.emit(str(e))

    def google_search(self, keyword, num_results=10):
        """Google Custom Search API를 사용하여 검색"""
        url = 'https://www.googleapis.com/customsearch/v1'

        params = {
            'key': self.api_key,
            'cx': self.cx,
            'q': keyword,
            'num': min(num_results, 10),  # API는 최대 10개
            'lr': 'lang_ko'# 한국어 결과 우선
        }

        response = requests.get(url, params=params, timeout=10)

        if response.status_code == 403:
            raise Exception('API 키가 유효하지 않거나 할당량을 초과했습니다.')
        elif response.status_code == 400:
            raise Exception('검색 엔진 ID(CX)가 유효하지 않습니다.')

        response.raise_for_status()
        data = response.json()

        results = []
        for item in data.get('items', []):
            results.append({
                'title': item.get('title', ''),
                'link': item.get('link', ''),
                'description': item.get('snippet', '')
            })

        return results


class GoogleSearchApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.search_results = []
        self.settings = QSettings('GoogleSearchApp', 'Settings')
        self.usage_tracker = UsageTracker()
        self.init_ui()
        self.load_settings()
        self.update_usage_display()

    def init_ui(self):
        self.setWindowTitle('Google 검색')
        self.resize(1024, 768)

        # 중앙 위젯
        central_widget = QWidget()
        self.setCentralWidget(central_widget)

        # 메인 레이아웃
        layout = QVBoxLayout()
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)

        # 제목 라벨
        title_label = QLabel('Google 검색')
        title_label.setFont(QFont('맑은 고딕', 16, QFont.Bold))
        title_label.setAlignment(Qt.AlignCenter)
        layout.addWidget(title_label)

        # API 설정 그룹
        api_group = QGroupBox('API 설정')
        api_group.setFont(QFont('맑은 고딕', 10))
        api_layout = QFormLayout()

        self.api_key_input = QLineEdit()
        self.api_key_input.setPlaceholderText('Google API 키 입력')
        self.api_key_input.setEchoMode(QLineEdit.Password)
        api_layout.addRow('API 키:', self.api_key_input)

        self.cx_input = QLineEdit()
        self.cx_input.setPlaceholderText('검색 엔진 ID (cx) 입력')
        self.cx_input.setEchoMode(QLineEdit.Password)
        api_layout.addRow('검색 엔진 ID:', self.cx_input)

        # 버튼 레이아웃
        btn_layout = QHBoxLayout()

        # API 키 생성 가이드 버튼
        guide_btn = QPushButton('API 키 생성 가이드')
        guide_btn.setStyleSheet('''
            QPushButton {
                background-color: #fbbc04;
                color: #333;
                border: none;
                border-radius: 4px;
                padding: 8px 15px;
            }
            QPushButton:hover {
                background-color: #f9a825;
            }
        ''')
        guide_btn.clicked.connect(self.show_api_guide)
        btn_layout.addWidget(guide_btn)

        # 설정 저장 버튼
        save_btn = QPushButton('설정 저장')
        save_btn.setStyleSheet('''
            QPushButton {
                background-color: #34a853;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 8px 15px;
            }
            QPushButton:hover {
                background-color: #2d8e47;
            }
        ''')
        save_btn.clicked.connect(self.save_settings)
        btn_layout.addWidget(save_btn)

        btn_layout.addStretch()
        api_layout.addRow('', btn_layout)

        api_group.setLayout(api_layout)
        layout.addWidget(api_group)

        # 사용량 표시 프레임
        usage_frame = QFrame()
        usage_frame.setStyleSheet('''
            QFrame {
                background-color: #f8f9fa;
                border: 1px solid #dee2e6;
                border-radius: 6px;
                padding: 8px;
            }
        ''')
        usage_layout = QHBoxLayout(usage_frame)
        usage_layout.setContentsMargins(15, 8, 15, 8)

        # 오늘 사용량 라벨
        usage_icon = QLabel('📊')
        usage_icon.setFont(QFont('Segoe UI Emoji', 14))
        usage_layout.addWidget(usage_icon)

        self.usage_label = QLabel('오늘 사용량: 0 / 100')
        self.usage_label.setFont(QFont('맑은 고딕', 11, QFont.Bold))
        usage_layout.addWidget(self.usage_label)

        usage_layout.addStretch()

        # 남은 횟수 라벨
        self.remaining_label = QLabel('남은 횟수: 100회')
        self.remaining_label.setFont(QFont('맑은 고딕', 10))
        self.remaining_label.setStyleSheet('color: #666;')
        usage_layout.addWidget(self.remaining_label)

        layout.addWidget(usage_frame)

        # 검색 입력 레이아웃
        search_layout = QHBoxLayout()

        # 검색 입력창 (콤보박스로 변경 - 검색 기록 저장)
        self.search_input = QComboBox()
        self.search_input.setEditable(True)
        self.search_input.setFont(QFont('맑은 고딕', 11))
        self.search_input.setMinimumHeight(40)
        self.search_input.lineEdit().setPlaceholderText('검색어를 입력하세요...')
        self.search_input.lineEdit().returnPressed.connect(self.search)
        self.search_input.setInsertPolicy(QComboBox.NoInsert)
        search_layout.addWidget(self.search_input)

        # 검색 버튼
        self.search_button = QPushButton('검색')
        self.search_button.setFont(QFont('맑은 고딕', 11))
        self.search_button.setMinimumHeight(40)
        self.search_button.setMinimumWidth(80)
        self.search_button.clicked.connect(self.search)
        self.search_button.setStyleSheet("""
            QPushButton {
                background-color: #4285f4;
                color: white;
                border: none;
                border-radius: 4px;
            }
            QPushButton:hover {
                background-color: #3367d6;
            }
            QPushButton:pressed {
                background-color: #2a56c6;
            }
            QPushButton:disabled {
                background-color: #cccccc;
            }
        """)
        search_layout.addWidget(self.search_button)

        # 검색어 삭제 버튼
        clear_history_btn = QPushButton('기록 삭제')
        clear_history_btn.setFont(QFont('맑은 고딕', 10))
        clear_history_btn.setMinimumHeight(40)
        clear_history_btn.clicked.connect(self.clear_search_history)
        clear_history_btn.setStyleSheet("""
            QPushButton {
                background-color: #ea4335;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 0 15px;
            }
            QPushButton:hover {
                background-color: #d33828;
            }
        """)
        search_layout.addWidget(clear_history_btn)

        layout.addLayout(search_layout)

        # 상태 라벨
        self.status_label = QLabel('')
        self.status_label.setFont(QFont('맑은 고딕', 10))
        self.status_label.setStyleSheet('color: #666666;')
        layout.addWidget(self.status_label)

        # 검색 결과 리스트
        self.result_list = QListWidget()
        self.result_list.setFont(QFont('맑은 고딕', 10))
        self.result_list.setSpacing(5)
        self.result_list.itemDoubleClicked.connect(self.open_link)
        self.result_list.setStyleSheet("""
            QListWidget {
                border: 1px solid #ddd;
                border-radius: 4px;
            }
            QListWidget::item {
                padding: 10px;
                border-bottom: 1px solid #eee;
            }
            QListWidget::item:selected {
                background-color: #e8f0fe;
                color: black;
            }
            QListWidget::item:hover {
                background-color: #f5f5f5;
            }
        """)
        layout.addWidget(self.result_list)

        # 안내 라벨
        hint_label = QLabel('* 더블클릭: 브라우저에서 열기 | 무료: 하루 100회 검색')
        hint_label.setFont(QFont('맑은 고딕', 9))
        hint_label.setStyleSheet('color: #888888;')
        layout.addWidget(hint_label)

        central_widget.setLayout(layout)

        # 스타일 적용
        self.setStyleSheet("""
            QMainWindow {
                background-color: #ffffff;
            }
            QLineEdit {
                border: 2px solid #ddd;
                border-radius: 4px;
                padding: 5px 10px;
            }
            QLineEdit:focus {
                border-color: #4285f4;
            }
            QGroupBox {
                border: 1px solid #ddd;
                border-radius: 4px;
                margin-top: 10px;
                padding-top: 10px;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 5px;
            }
        """)

    def load_settings(self):
        """저장된 설정 불러오기"""
        api_key = self.settings.value('api_key', API_KEY)
        cx = self.settings.value('cx', SEARCH_ENGINE_ID)
        self.api_key_input.setText(api_key)
        self.cx_input.setText(cx)

        # 검색 기록 불러오기
        search_history = self.settings.value('search_history', [])
        if search_history:
            self.search_input.addItems(search_history)

    def save_settings(self):
        """설정 저장"""
        self.settings.setValue('api_key', self.api_key_input.text().strip())
        self.settings.setValue('cx', self.cx_input.text().strip())
        QMessageBox.information(self, '알림', '설정이 저장되었습니다.')

    def show_api_guide(self):
        """API 키 생성 가이드 다이얼로그 표시"""
        dialog = ApiGuideDialog(self)
        dialog.exec_()

    def add_to_search_history(self, keyword):
        """검색어를 기록에 추가"""
        # 이미 있는 검색어면 제거 후 맨 앞에 추가
        index = self.search_input.findText(keyword)
        if index >= 0:
            self.search_input.removeItem(index)
        self.search_input.insertItem(0, keyword)
        self.search_input.setCurrentIndex(0)

        # 최대 20개까지만 저장
        while self.search_input.count() > 20:
            self.search_input.removeItem(self.search_input.count() - 1)

        # 설정에 저장
        history = [self.search_input.itemText(i) for i in range(self.search_input.count())]
        self.settings.setValue('search_history', history)

    def clear_search_history(self):
        """검색 기록 삭제"""
        reply = QMessageBox.question(
            self, '확인', '검색 기록을 모두 삭제하시겠습니까?',
            QMessageBox.Yes | QMessageBox.No, QMessageBox.No
        )
        if reply == QMessageBox.Yes:
            self.search_input.clear()
            self.settings.setValue('search_history', [])
            QMessageBox.information(self, '알림', '검색 기록이 삭제되었습니다.')

    def update_usage_display(self):
        """사용량 표시 업데이트"""
        count = self.usage_tracker.get_count()
        remaining = DAILY_LIMIT - count

        self.usage_label.setText(f'오늘 사용량: {count} / {DAILY_LIMIT}')
        self.remaining_label.setText(f'남은 횟수: {remaining}회')

        # 사용량에 따라 색상 변경
        if count >= DAILY_LIMIT:
            # 제한 초과 - 빨간색
            self.usage_label.setStyleSheet('color: #dc3545; font-weight: bold;')
            self.remaining_label.setStyleSheet('color: #dc3545;')
        elif count >= 80:
            # 80% 이상 - 주황색 경고
            self.usage_label.setStyleSheet('color: #fd7e14; font-weight: bold;')
            self.remaining_label.setStyleSheet('color: #fd7e14;')
        elif count >= 50:
            # 50% 이상 - 노란색
            self.usage_label.setStyleSheet('color: #ffc107; font-weight: bold;')
            self.remaining_label.setStyleSheet('color: #856404;')
        else:
            # 정상 - 초록색
            self.usage_label.setStyleSheet('color: #28a745; font-weight: bold;')
            self.remaining_label.setStyleSheet('color: #666;')

    def search(self):
        keyword = self.search_input.currentText().strip()
        api_key = self.api_key_input.text().strip()
        cx = self.cx_input.text().strip()

        if not api_key:
            QMessageBox.warning(self, '알림', 'API 키를 입력해주세요.')
            return
        if not cx:
            QMessageBox.warning(self, '알림', '검색 엔진 ID를 입력해주세요.')
            return
        if not keyword:
            QMessageBox.warning(self, '알림', '검색어를 입력해주세요.')
            return

        # 사용량 제한 체크
        current_count = self.usage_tracker.get_count()
        if current_count >= DAILY_LIMIT:
            QMessageBox.critical(
                self, '⚠️ 사용량 초과',
                f'오늘의 무료 검색 한도({DAILY_LIMIT}회)를 초과했습니다.\n\n'
                '내일 자정(00:00)이 지나면 사용량이 초기화됩니다.\n'
                '추가 검색이 필요하시면 Google Cloud에서 유료 플랜을 확인해주세요.'
            )
            return

        # 검색어를 기록에 추가
        self.add_to_search_history(keyword)

        # UI 비활성화
        self.search_button.setEnabled(False)
        self.search_input.setEnabled(False)
        self.status_label.setText('검색 중...')
        self.result_list.clear()

        # 백그라운드 스레드에서 검색 수행
        self.search_thread = SearchThread(keyword, api_key, cx)
        self.search_thread.finished.connect(self.on_search_finished)
        self.search_thread.error.connect(self.on_search_error)
        self.search_thread.start()

    def on_search_finished(self, results):
        """검색 완료 시 호출"""
        self.search_results = results
        self.result_list.clear()

        # 사용량 증가 및 표시 업데이트
        new_count = self.usage_tracker.increment()
        self.update_usage_display()

        # 90회 이상 사용 시 경고 메시지
        if new_count == 90:
            QMessageBox.warning(
                self, '⚠️ 사용량 경고',
                f'오늘 검색 횟수가 {new_count}회에 도달했습니다.\n'
                f'무료 한도({DAILY_LIMIT}회)까지 {DAILY_LIMIT - new_count}회 남았습니다.'
            )

        if not results:
            self.status_label.setText('검색 결과가 없습니다.')
        else:
            self.status_label.setText(f'{len(results)}개의 검색 결과')

            for i, result in enumerate(results, 1):
                # 리스트 아이템 생성
                item_text = f"{i}. {result['title']}\n   {result['link']}"
                if result['description']:
                    desc = result['description'][:150] + '...' if len(result['description']) > 150 else result['description']
                    item_text += f"\n   {desc}"

                item = QListWidgetItem(item_text)
                item.setData(Qt.UserRole, result['link'])
                self.result_list.addItem(item)

        # UI 활성화
        self.search_button.setEnabled(True)
        self.search_input.setEnabled(True)

    def on_search_error(self, error_msg):
        """검색 에러 시 호출"""
        self.status_label.setText('')
        QMessageBox.critical(self, '오류', f'검색 중 오류가 발생했습니다:\n{error_msg}')

        # UI 활성화
        self.search_button.setEnabled(True)
        self.search_input.setEnabled(True)

    def open_link(self, item):
        """링크를 브라우저에서 열기"""
        link = item.data(Qt.UserRole)
        if link:
            QDesktopServices.openUrl(QUrl(link))

    def get_results(self):
        """검색 결과를 반환 (외부에서 활용 가능)"""
        return self.search_results


def main():
    app = QApplication(sys.argv)
    window = GoogleSearchApp()
    window.show()
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()




댓글 쓰기

0 댓글

댓글 쓰기 (0)