관리 메뉴

SIMPLE & UNIQUE

[gpt와 함께하는 업무 자동화] 1. GPT가 코드 짜고, Gemini가 요약한 주식 알림 자동화 시스템 본문

착한코딩 YouTube

[gpt와 함께하는 업무 자동화] 1. GPT가 코드 짜고, Gemini가 요약한 주식 알림 자동화 시스템

착한코딩 2025. 12. 14. 11:09

pythonInvest.zip
0.01MB

 

1. autoGemini.py

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (
    StaleElementReferenceException,
    TimeoutException,
    ElementNotInteractableException
)
from selenium.webdriver.common.action_chains import ActionChains
from webdriver_manager.chrome import ChromeDriverManager
import time

# ✅ (추가) 결과를 파일로 저장하기 위한 라이브러리
import json
from datetime import datetime

# 디버그 로그 출력 여부 (True면 답변 추출 과정에서 상태를 계속 출력)
DEBUG = True  # 필요 없으면 False

# ==============================
# Chrome 옵션
# ==============================
# Selenium이 크롬을 실행할 때 적용할 "실행 옵션"을 지정합니다.
# - binary_location: 크롬 실행 파일 위치
# - user-data-dir / profile-directory: 로그인 세션(쿠키 등)을 유지하기 위해 크롬 프로필을 사용
# - start-maximized: 처음부터 최대화
# - AutomationControlled 등: 자동화 탐지 완화 목적(100% 차단은 아님)
options = webdriver.ChromeOptions()
options.binary_location = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
options.add_argument(r"--user-data-dir=C:\pythonInvest\chrome_profile_gemini")
options.add_argument("--profile-directory=Default")
options.add_argument("--start-maximized")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument("--disable-extensions")
options.add_argument("--remote-debugging-port=0")

# 실제 크롬 드라이버(ChromeDriver)를 생성합니다.
# webdriver_manager가 크롬 버전에 맞는 ChromeDriver를 자동 설치/사용합니다.
driver = webdriver.Chrome(
    service=Service(ChromeDriverManager().install()),
    options=options
)

# Gemini 사이트로 이동
driver.get("https://gemini.google.com/")

# WebDriverWait: 특정 요소가 나타날 때까지 최대 N초 기다리는 도구
# 여기서는 전체적으로 최대 180초까지 기다릴 수 있게 준비해 둠(필요시 사용)
wait = WebDriverWait(driver, 180)

# ==============================
# 컨텍스트(iframe/메인) 정렬
# ==============================
def switch_to_iframe_with_content(driver, timeout=20):
    """
    [왜 필요한가?]
    - 웹페이지는 "메인 문서" 안에 "iframe"이라는 하위 문서를 포함할 수 있습니다.
    - Selenium은 현재 보고 있는 문서(컨텍스트)에서만 요소를 찾을 수 있습니다.
    - Gemini UI는 어떤 경우에는 메인 문서에 입력창이 있고,
      어떤 경우에는 iframe 내부에 입력창이 있을 수 있어요.
    - 그래서 "입력창이 존재하는 문서(메인/iframe)"를 찾아서 그쪽으로 전환해주는 함수입니다.

    [무엇을 하나?]
    1) 우선 메인(document)으로 이동
    2) 메인에 입력창이 있으면 종료
    3) 없으면 모든 iframe을 하나씩 들어가면서 입력창이 있는지 확인
    4) 제한 시간(timeout) 안에 찾지 못하면 TimeoutException 발생
    """
    # 먼저 메인(최상위) 문서로 이동
    driver.switch_to.default_content()
    end = time.time() + timeout

    # 현재 컨텍스트에 입력창 후보가 있는지 확인하는 내부 함수
    def has_content():
        # textarea 또는 contenteditable(채팅 입력 UI에 자주 쓰임) 요소가 있으면 True로 간주
        return (
            driver.find_elements(By.XPATH, "//textarea") or
            driver.find_elements(By.XPATH, "//*[@contenteditable='true']")
        )

    while time.time() < end:
        # 1) 메인 문서 확인
        driver.switch_to.default_content()
        if has_content():
            # 메인 문서에 입력 UI가 있으면 여기서 끝(현재 컨텍스트 유지)
            return

        # 2) iframe들을 순회하면서 입력 UI가 있는지 확인
        for iframe in driver.find_elements(By.TAG_NAME, "iframe"):
            try:
                # iframe 전환 전에 항상 메인으로 돌아갔다가(frame 참조 안정성 확보)
                driver.switch_to.default_content()
                driver.switch_to.frame(iframe)

                # iframe 내부에서 입력창이 발견되면 그 컨텍스트에 머문 채로 종료
                if has_content():
                    return
            except Exception:
                # 어떤 iframe은 접근이 막혀있거나 전환 시 오류가 날 수 있음 -> 무시하고 다음 iframe로
                continue

        # 잠깐 쉬었다가 다시 시도(페이지 로딩/렌더링을 기다리기 위함)
        time.sleep(0.5)

    # 제한 시간 내에 메인/iframe 어디에서도 입력 UI를 찾지 못한 경우
    raise TimeoutException("Gemini UI 컨텍스트를 찾지 못했습니다.")

# ==============================
# 입력창 찾기
# ==============================
def get_input_box(driver, timeout=60):
    """
    [목적]
    - Gemini의 질문 입력칸 요소(웹 요소)를 찾아서 반환합니다.

    [방법]
    - textarea 또는 contenteditable 요소를 모두 찾은 다음,
      화면에 실제로 보이는(is_displayed == True) 요소를 반환합니다.

    [예외]
    - DOM이 자주 바뀌면 StaleElementReferenceException이 날 수 있음
      (요소를 찾았는데 그 순간 UI가 갱신되어 요소가 "낡은 참조"가 되는 현상)
    - 제한 시간 내에 입력창을 못 찾으면 TimeoutException 발생
    """
    end = time.time() + timeout
    while time.time() < end:
        # 입력창 후보들을 모두 찾음
        for el in driver.find_elements(By.XPATH, "//textarea | //*[@contenteditable='true']"):
            try:
                # 화면에 보이는 요소만 사용(숨겨진 textarea 등 제외)
                if el.is_displayed():
                    return el
            except StaleElementReferenceException:
                # 요소를 잡았는데 페이지가 갱신돼서 참조가 끊긴 경우 -> 다시 찾도록 continue
                continue

        time.sleep(0.3)

    raise TimeoutException("입력창을 찾지 못했습니다.")

# ==============================
# 질문 전송
# ==============================
def send_question(driver, question):
    """
    [목적]
    - 입력창에 질문을 넣고 Enter를 눌러 전송합니다.

    [흐름]
    1) 입력창이 있는 컨텍스트(메인/iframe)로 이동
    2) 입력창 요소 찾기
    3) 마우스로 클릭해서 커서를 입력창에 둠
    4) question 텍스트 입력
    5) Enter 전송

    [ActionChains를 쓰는 이유]
    - 단순히 el.click()만으로 클릭이 안 되는 경우가 있어서,
      실제 사용자처럼 "이동(move_to_element) -> 클릭(click)" 동작을 만들기 위해 사용합니다.
    """
    switch_to_iframe_with_content(driver, 30)
    box = get_input_box(driver, 60)

    # 입력창으로 마우스 이동 후 클릭(커서 활성화)
    ActionChains(driver).move_to_element(box).click().perform()
    time.sleep(0.2)

    # 질문 입력
    box.send_keys(question)
    time.sleep(0.3)

    # Enter(전송)
    ActionChains(driver).send_keys(Keys.ENTER).perform()

# ==============================
# 답변 후보 블록 수집 (멀티 셀렉터)
# ==============================
def get_message_blocks(driver):
    """
    [왜 이렇게 복잡한가?]
    - Gemini 웹 UI는 업데이트로 구조가 자주 바뀝니다.
    - 어떤 날은 role='article' 안에 답변이 있고,
      어떤 날은 class 이름에 markdown/response/message가 붙어있을 수 있어요.
    - 그래서 여러 XPath 후보를 준비해 "답변처럼 보이는 텍스트 덩어리"를 최대한 수집합니다.

    [노이즈 제거]
    - 버튼/메뉴 같은 짧은 텍스트는 제외(예: '도구', '새 창에서 열기' 등)
    - 중복 텍스트는 seen(set)으로 제거
    """
    candidates = [
        # 1) role 기반(있으면 가장 좋음)
        "//*[@role='article']",
        "//*[@role='main']//*[self::div or self::p]",

        # 2) class / testid 기반 (UI 변경 대응)
        "//*[contains(@class,'markdown') or contains(@class,'Markdown') or contains(@class,'message') or contains(@class,'response')]",
        "//*[@data-testid and (contains(@data-testid,'response') or contains(@data-testid,'message') or contains(@data-testid,'assistant'))]",

        # 3) aria-label 기반 (다국어/접근성 라벨 대응)
        "//*[contains(@aria-label,'Response') or contains(@aria-label,'response') or contains(@aria-label,'답변')]",

        # 4) 최후의 보루: main 내 텍스트 덩어리(너무 넓게 잡힘 -> 길이 필터로 거름)
        "//main//*[self::div or self::p or self::span]"
    ]

    elems = []
    seen = set()

    for xp in candidates:
        try:
            found = driver.find_elements(By.XPATH, xp)
        except Exception:
            continue

        for el in found:
            try:
                # 화면에 보이는 요소만 사용
                if not el.is_displayed():
                    continue

                txt = (el.text or "").strip()

                # 너무 짧은 텍스트는 버튼/메뉴/잡음 가능성이 큼 -> 제외
                # (필요 시 40 값을 20~60 범위로 조절)
                if len(txt) < 40:
                    continue

                # 중복 제거용 key(앞부분+길이 조합)
                key = (txt[:120], len(txt))
                if key in seen:
                    continue

                seen.add(key)
                elems.append(el)

            except (StaleElementReferenceException, Exception):
                # DOM 변경 등으로 요소 참조가 깨지면 무시하고 계속
                continue

    return elems

# ==============================
# Gemini 답변 대기 + 추출
# ==============================
def wait_and_get_answer(driver, timeout=120):
    """
    [핵심 아이디어]
    - 질문을 보내기 "직후" 화면에 있던 텍스트들은 base_set(기준 스냅샷)으로 저장합니다.
    - 이후 화면에서 발견되는 텍스트 중 base_set에 없던 것(=새로 생긴 텍스트)을 new_texts로 봅니다.
    - new_texts 중 "가장 긴 텍스트"가 보통 실제 답변이므로 candidate로 선택합니다.
    - 답변 생성 중에는 텍스트 길이가 계속 바뀌거나 내용이 조금씩 바뀔 수 있으니,
      같은 candidate가 연속으로 관측되면 생성이 끝났다고 판단합니다(stable_count).

    [반환]
    - 안정화(stable_count >= 1)되면 답변(best)을 반환합니다.
    - 시간 초과면 마지막 best가 있으면 반환, 없으면 TimeoutException 발생
    """
    switch_to_iframe_with_content(driver, 30)

    # 기준 스냅샷 확보(질문 직후 UI에 있던 텍스트들)
    try:
        base_blocks = [e.text.strip() for e in get_message_blocks(driver)]
    except Exception:
        base_blocks = []
    base_set = set(base_blocks)

    end = time.time() + timeout
    best = ""          # 현재까지 가장 그럴듯한 답변 텍스트
    stable_count = 0   # 같은 답변이 연속 관측된 횟수(생성 완료 판단에 사용)

    while time.time() < end:
        try:
            # UI가 바뀌어도 입력/출력 컨텍스트가 맞도록 주기적으로 정렬
            switch_to_iframe_with_content(driver, 10)

            # 화면에서 답변 후보 블록을 다시 수집
            blocks = get_message_blocks(driver)
            texts = [(b.text or "").strip() for b in blocks]
            texts = [t for t in texts if t]

            # base_set에 없던 텍스트만 추출 -> 새로 생긴 답변 후보
            new_texts = [t for t in texts if t not in base_set and len(t) >= 40]

            if DEBUG:
                print(f"[DEBUG] blocks={len(blocks)} new_texts={len(new_texts)} best_len={len(best)}")

            if new_texts:
                # 가장 긴 텍스트를 답변 candidate로 선택
                candidate = max(new_texts, key=len)

                # 동일 candidate가 계속 나오면 생성이 멈췄다고 보고 stable_count 증가
                if candidate == best:
                    stable_count += 1
                else:
                    best = candidate
                    stable_count = 0

                # 2회 연속으로 동일하면(=stable_count 1 이상) 답변 완료로 간주하고 반환
                if stable_count >= 1:
                    return best

        except StaleElementReferenceException:
            # DOM이 바뀌면 종종 발생 -> 다시 루프 돌며 재시도
            pass
        except Exception:
            pass

        # 1초마다 다시 확인 (너무 빠르면 UI 갱신/렌더링을 못 따라갈 수 있음)
        time.sleep(1)

    # 시간 초과: 그래도 best가 있으면 반환(부분 답변이라도)
    if best:
        return best

    raise TimeoutException("Gemini 답변을 시간 내에 가져오지 못했습니다.")

# ==============================
# 불필요 문구 제거 + JSON 저장
# ==============================
def clean_gemini_answer(raw_text: str, question: str) -> str:
    """
    [목적]
    - Gemini 페이지에 섞여 들어오는 UI 텍스트(메뉴/경고 문구 등) 제거
    - 질문이 답변 블록에 같이 섞여 나온 경우 제거
    - 빈 줄 제거

    [주의]
    - remove_exact는 "완전히 동일한 줄"만 제거합니다.
    - ln.startswith("====") 등은 구분선 형태를 제거합니다.
    """
    remove_exact = {
        "Gemini와의 대화",
        "도구",
        "빠른 모드",
        "새 창에서 열기",
        "Gemini는 인물 등에 관한 정보 제공 시 실수를 할 수 있으니 다시 한번 확인하세요.",
        "================ Gemini Answer ================",
        "==============================================",
    }

    # 원문을 줄 단위로 분리하고 양끝 공백 제거
    lines = [ln.strip() for ln in (raw_text or "").splitlines()]

    cleaned = []
    for ln in lines:
        # 빈 줄 제거
        if not ln:
            continue
        # 정확히 일치하는 UI 텍스트 제거
        if ln in remove_exact:
            continue
        # 구분선 형태 제거
        if ln.startswith("====") or ln.startswith("----"):
            continue
        cleaned.append(ln)

    q = (question or "").strip()
    if q:
        # 질문이 답변에 그대로 포함되어 있는 경우(줄 전체가 질문인 경우) 제거
        cleaned = [ln for ln in cleaned if ln != q]
        # 질문이 문장 중간에 섞여 들어온 경우도 제거(보수적 처리)
        cleaned = [ln for ln in cleaned if q not in ln]

    return "\n".join(cleaned).strip()

def save_result_to_json(filepath: str, question: str, answer_clean: str):
    """
    [목적]
    - 질문/정리된 답변/타임스탬프를 JSON 파일로 저장

    [파일 구조 예시]
    {
      "timestamp": "2025-12-12T17:50:43",
      "question": "...",
      "answer": "..."
    }
    """
    payload = {
        "timestamp": datetime.now().isoformat(timespec="seconds"),
        "question": question,
        "answer": answer_clean
    }
    with open(filepath, "w", encoding="utf-8") as f:
        # ensure_ascii=False: 한글이 \uXXXX로 깨지지 않게 저장
        # indent=2: 사람이 읽기 좋게 보기(들여쓰기)
        json.dump(payload, f, ensure_ascii=False, indent=2)

# ==============================
# 실행
# ==============================
print("⌛ 로그인/로딩 대기 중...")
# Gemini가 로딩되면서 DOM 구조가 잡힐 시간을 줌 + 입력 컨텍스트 확보
switch_to_iframe_with_content(driver, 60)

# 실제 보낼 질문(프롬프트)
question = (
    "초보자들이 가독성 좋게 읽을 수 있도록 표 없이 텍스트로만 번호 붙여서 "
    "국내, 미국 증시 흐름 깔끔하게 요약해줘. "
    "추가로 내용에 대한 설명은 삭제해줘. "
    "너가 대답하고 부연 설명하는 부분은 제거해줘. "
    "마지막에 궁금한 거 있냐고 질문하는 것도 제거해줘."
    "아래 양식을 지켜서 결과 만들어줘"
    "국내 증시 흐름 (12월 12일)..다음 내용"
    "미국 증시 흐름 (12월 11일)..다음 내용"
)

print(f"\n🧑 질문:\n{question}")

# 질문 전송
send_question(driver, question)

print("\n🤖 Gemini 답변 대기 중...")

# 답변 추출(대기 + 새 텍스트 탐지)
answer = wait_and_get_answer(driver, timeout=120)

# 원본 답변 출력(디버깅용)
print("\n================ Gemini Answer ================\n")
print(answer)
print("\n==============================================\n")

# 답변 정리(UI 문구 제거 등)
answer_clean = clean_gemini_answer(answer, question)

print("\n=========== Cleaned Gemini Answer ===========\n")
print(answer_clean)
print("\n===========================================\n")

# JSON 파일로 저장
save_result_to_json("gemini_result.json", question, answer_clean)
print("✅ gemini_result.json 저장 완료")​

 

 

2. send_kakao.py

import subprocess
import time
import pyperclip
import json
import re
from pywinauto.keyboard import send_keys  # 전역 키 입력(현재 포커스된 창에 키를 보냄)

# ==============================
# 설정값
# ==============================
# Gemini에서 생성된 결과(JSON 파일)를 읽어오기 위한 경로
JSON_PATH = "gemini_result.json"

# ==============================
# 1. JSON 파일에서 Gemini 답변 읽기
# ==============================
def load_answer(path: str) -> str:
    """
    [목적]
    - gemini_result.json 파일에서
      Gemini가 생성한 'answer' 값만 읽어오기

    [JSON 구조 예시]
    {
      "timestamp": "...",
      "question": "...",
      "answer": "여기에 실제 답변 텍스트"
    }

    [동작]
    1) JSON 파일 열기
    2) answer 키에 해당하는 문자열 추출
    3) 앞뒤 공백 제거
    4) 비어 있으면 에러 발생
    """
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)

    ans = (data.get("answer") or "").strip()

    # answer가 없거나 비어 있으면 이후 카톡 전송 의미가 없으므로 즉시 에러
    if not ans:
        raise ValueError("JSON에 answer가 비어있습니다.")

    return ans

# ==============================
# 2. Gemini 답변을 '번호 붙은 텍스트'로 정리
# ==============================
def format_numbered_text(answer: str) -> str:
    """
    [목적]
    - Gemini가 준 답변을
      카카오톡에 보내기 좋게 '번호 붙은 텍스트' 형태로 정리

    [정리 규칙]
    - '국내 증시 흐름 (...)', '미국 증시 흐름 (...)' 같은
      섹션 제목은 그대로 유지
    - 섹션 제목 아래 문장들은
      1), 2), 3) ... 형태로 번호 부여
    - 빈 줄은 제거

    [왜 필요한가?]
    - Gemini 원문은 줄바꿈/서술 방식이 들쭉날쭉함
    - 카카오톡에서는 간결한 번호형 요약이 가독성이 좋음
    """
    # 줄 단위로 쪼갠 뒤, 앞뒤 공백 제거 + 빈 줄 제거
    lines = [ln.strip() for ln in answer.splitlines() if ln.strip()]

    out = []   # 최종 결과를 담을 리스트
    idx = 1    # 번호 카운터 (1), 2), 3)...

    for ln in lines:
        # 섹션 헤더인지 확인
        # 예: "국내 증시 흐름 (12월 12일)"
        if re.match(r"^(국내|미국)\s*증시\s*흐름", ln):
            # 이전 섹션과 시각적으로 구분하기 위해 한 줄 띄움
            if out and out[-1] != "":
                out.append("")

            # 섹션 제목은 그대로 추가
            out.append(ln)

            # 번호는 새 섹션이므로 다시 1부터
            idx = 1
            continue

        # 일반 문장은 번호를 붙여서 추가
        out.append(f"{idx}) {ln}")
        idx += 1

    # 리스트를 다시 문자열로 합쳐 반환
    return "\n".join(out).strip()

# ==============================
# 3. JSON → 카카오톡으로 보낼 메시지 준비
# ==============================
# 1) JSON에서 Gemini 답변 읽기
answer = load_answer(JSON_PATH)

# 2) 번호 붙은 텍스트로 정리
msg = format_numbered_text(answer)

# ==============================
# 4. 카카오톡 PC 실행
# ==============================
# subprocess.Popen:
# - 외부 프로그램(KakaoTalk.exe)을 실행
# - stdout/stderr를 버려서 콘솔 출력 방지
subprocess.Popen(
    [r"C:\Program Files (x86)\Kakao\KakaoTalk\KakaoTalk.exe"],
    stdout=subprocess.DEVNULL,
    stderr=subprocess.DEVNULL
)

# 카카오톡 UI가 완전히 뜰 때까지 대기
time.sleep(5)

# ==============================
# 5. 카카오톡 검색창 열기
# ==============================
# Ctrl+F:
# - 카카오톡 PC 버전에서 채팅방/친구 검색창 열기
send_keys("^f")
time.sleep(0.6)

# 검색창이 열렸어도 포커스가 애매할 수 있어
# 한 번 더 Ctrl+F로 확실히 커서 위치 보정
send_keys("^f")
time.sleep(0.4)

# ==============================
# 5-1. 검색창 기존 내용 지우기 (추가)
# ==============================
send_keys("{HOME}")
time.sleep(0.05)

send_keys("+{END}")   # Shift + End
time.sleep(0.05)

send_keys("{DELETE}")
time.sleep(0.2)

# ==============================
# 6. 채팅방 이름 입력
# ==============================
# 타이핑(send_keys("테스트")) 대신
# 클립보드 붙여넣기를 쓰는 이유:
# - 한글 IME 문제
# - 키 입력 누락 방지
pyperclip.copy("테스트")
time.sleep(0.05)

# Ctrl+V로 검색창에 붙여넣기
send_keys("^v")
time.sleep(0.3)

# ==============================
# 7. 엔터 → 채팅방 입장
# ==============================
# 검색 결과에서 첫 번째 방으로 입장
send_keys("{ENTER}")

# 채팅방 UI 로딩 대기
time.sleep(2)

# ==============================
# 8. 메시지 붙여넣기 + 전송
# ==============================
# 정리된 Gemini 답변(msg)을 클립보드에 복사
pyperclip.copy(msg)
time.sleep(2)

# 채팅 입력창에 붙여넣기
send_keys("^v")
time.sleep(0.2)

# Enter → 메시지 전송
send_keys("{ENTER}")

print("끝")

 

3. autoGemini.py 프롬프트

파이썬으로 제미나이(https://gemini.google.com/)에 접속해서 질문을 입력하고,
화면에 표시되는 답변을 기다렸다가 자동으로 가져오는 코드를 만들어줘.
크롬 로그인 상태를 유지한 채로 동작하게 해주고,
화면 구조가 바뀌어도 최대한 잘 동작하도록 안정적으로 만들어줘.
가져온 답변은 불필요한 문구를 제거해서 깔끔하게 정리해줘.
최종 결과는 시간 정보와 함께 파일로 저장되는 하나의 파이썬 파일(gemini_result.json)로 만들어줘.

* 제미나이에게 할 질문
초보자들이 가독성 좋게 읽을 수 있도록 표 없이 텍스트로만 번호 붙여서
국내, 미국 증시 흐름 깔끔하게 요약해줘
추가로 내용에 대한 설명은 삭제해줘
너가 대답하고 부연 설명하는 부분은 제거해줘
마지막에 궁금한 거 있냐고 질문하는 것도 제거해줘
아래 양식을 지켜서 결과 만들어줘
국내 증시 흐름 (12월 12일)..다음 내용
미국 증시 흐름 (12월 11일)..다음 내용

 

4. send_kakao.py 프롬프트

파이썬으로 gemini_result.json 파일을 읽어서, 그 안의 answer 내용을 가져오는 코드를 만들어줘.
가져온 내용을 카카오톡에 보내기 좋게 줄바꿈 정리하고, “국내/미국 증시 흐름” 같은 제목은 유지한 채 문장들에는 1), 2)처럼 번호를 붙여줘.
그 다음 카카오톡 PC를 실행해서 검색창을 열고, 기존 검색어를 지운 뒤 “테스트” 채팅방에 들어가게 해줘.
마지막으로 정리된 메시지를 붙여넣고 엔터로 전송까지 자동으로 하게 해줘.
초보자도 따라할 수 있게 주석을 자세히 달고, 실행 가능한 하나의 파이썬 파일로 전체 코드를 출력해줘.

 

Comments